feat(520,521): full decision-corpus migration + parallel-orientation startup rewrite (PR2 of 2) #527

Merged
timothy merged 17 commits from feat/520-521-migration-kickoff into main 2026-07-21 12:29:19 +02:00
20 changed files with 1277 additions and 259 deletions
+2
View File
@@ -752,6 +752,8 @@ jobs:
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
- name: Active catalog in sync
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
- name: Kickoff guard
run: bash scripts/check-kickoff-guard.sh
# BLOCKING (unlike docs-reminder): the mechanizable half of the "docs-update in the
# same PR" rule for the API contract (ersatztv#303 H4/H5). If a PR touches the API
+15 -2
View File
@@ -56,7 +56,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read the `docs/README.md` **task-signal map** and only the sections it points to for your task — not the whole corpus. **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code. **Decision/convention lookups start at the active catalog**, `docs/decisions/README.md` — resolve by topic/key, never by chasing a file path named in a historical comment (the breadcrumb rule; see `docs/README.md` → "Knowledge retrieval").
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
| Change | Update in the same PR |
@@ -91,11 +91,24 @@ Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pa
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
4. **Close comment**: Add a structured `## Closing record` comment on the issue (template below).
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
**`## Closing record` template** (step 4 — this is both the human-readable summary and the per-issue unit MemPalace mines for retrieval; see `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval" for the retrieval contract this feeds):
```markdown
## Closing record
**Outcome:** <what shipped / what didn't; PR link>
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
**Verification:** <tests run, live-E2E, CI status>
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
```
## Project Boundaries
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
+77 -28
View File
@@ -1,34 +1,81 @@
# docs/ — reading order
# docs/ — task-signal map
Purpose: index of `docs/` so a fresh contributor/agent knows what to read and in what order.
**Update this doc in the same PR that adds, removes, or retitles a doc below.**
Purpose: route a fresh contributor/agent to the minimal set of docs for the task at hand, instead of
a mandatory front-to-back read. **Update this doc in the same PR that adds, removes, or retitles a
doc below, or that changes which sections a task signal points to.**
Read in this order at session start:
## Start here, always
1. **`CLAUDE.md`** (repo root) — project intro: architecture, layout, dev commands, conventions.
2. **`docs/contributing.md`** — established code patterns (CQRS/MediatR, LanguageExt, the ChicoryTV
SPA, EF Core dual-provider migrations, FFmpeg pipeline, analyzers, testing). Read before
any non-trivial change.
3. **`docs/domain-model.md`** — what the app IS: entity glossary, channel→playout→schedule/block
concept map, where each concept is edited in the SPA.
4. **`docs/api-conventions.md`** — checklist for adding/changing a `/api/*` endpoint (controllers,
DTOs, error mapping, auth, OpenAPI regen, tests).
5. **`docs/spa-conventions.md`** — playbook for adding a screen to the ChicoryTV React SPA.
6. **`docs/e2e-local.md`** (+ `scripts/e2e-local.sh`) — how to run a live local instance for manual
or Playwright-MCP verification.
7. **`docs/testing.md`** — testing map: what each `*.Tests` project / `web` suite covers,
golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification
gate.
8. **`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.
9. **`docs/decisions.md`** — lifecycle "why" log: active records plus retired/superseded ones
relocated to `docs/decisions/archive/`. Check here before challenging an existing convention;
start from the generated active catalog, `docs/decisions/README.md`.
10. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
- **`CLAUDE.md`** (repo root) — project intro: architecture, layout, dev commands, conventions,
Task Completion Protocol.
- **`docs/contributing.md`** — established code patterns (CQRS/MediatR, LanguageExt, the ChicoryTV
SPA, EF Core dual-provider migrations, FFmpeg pipeline, analyzers, testing). Read before any
non-trivial change.
Also present in `docs/`:
## Task signal → minimal sections
| Signal | Read |
| --- | --- |
| Session startup / "what's next" (no issue named) | `docs/handoffs/chicorytv-issue-queue.md` (standing kickoff — two concurrent tracks: orientation ‖ `scripts/select-queue.sh 5`) |
| Named-issue pickup | Skip queue selection; go straight to focused retrieval — see "Knowledge retrieval" below, then the issue body |
| Adding/changing a `/api/*` endpoint | `docs/api-conventions.md` checklist + `docs/endpoint-index.md` |
| Adding a ChicoryTV SPA screen | `docs/spa-conventions.md` |
| Scheduling / playout engine work | `docs/domain-model.md` + decisions catalog rows keyed `sched.*` (`docs/decisions/README.md`) |
| Concurrency / optimistic-locking work | `docs/api-conventions.md` §7a/b/c + `docs/decisions/optimistic-concurrency.md` |
| Auth / security-surface work | `docs/decisions/api-auth-security.md` |
| CI / release pipeline work | `docs/ci-cd.md` + `docs/decisions/release-ci-governance.md` |
| Live local run / Playwright-MCP verification | `docs/e2e-local.md` + `scripts/e2e-local.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) → `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)
These four rules are the seam agreed with server-management#642 (the Gitea→MemPalace exporter).
They apply whether the question comes up via MemPalace, a grep, or a stale comment:
1. **Current conventions/decisions → catalog-first.** Start at `docs/decisions/README.md`; discover
via the `ErsatzTV-Decisions` wing (active) / `ErsatzTV-Decisions-Archive` (superseded/retired).
**Resolve by topic/key, never by chasing a file path.**
2. **Issue history → evidence, not authority.** The `Gitea-ErsatzTV` wing is historical narrative
that may be stale; it never overrides current Markdown.
3. **The breadcrumb rule (the crux behavior change).** A file path named inside a *historical issue
comment* (e.g. "grep `docs/decisions.md` 2026-07-17", "see …") is a **breadcrumb, not a live
pointer.** Find the current rule via the catalog / active wing **by concept**; do not treat the
named path as current. (Why it's safe: still-current → in the active wing, breadcrumb resolves;
superseded → the active wing returns the *successor* and a literal follow lands on a record that
announces its own `status: superseded`; retired → the active wing returns nothing, which is
itself the signal. The validator-enforced move-to-`archive/` is what prevents the catastrophic
"superseded rule read as current" case.)
4. **Fallback when MemPalace is stale/down:** `docs/decisions/README.md` catalog, then
`` rg '^`key: <dotted.key>`' docs/decisions/ ``. MemPalace is never authority nor sole fallback.
MemPalace is candidate discovery only — every passage is verified against its cited Markdown/Gitea
source before use. Never derive live queue state from MemPalace, #237, or historical comments; queue
state is live Gitea state, retrieved via `scripts/select-queue.sh` (see
`docs/handoffs/chicorytv-issue-queue.md`). Full retrieval contract (altitude/precedence, staleness
bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "Knowledge retrieval".
## Also present in `docs/`
- **`docs/domain-model.md`** — what the app IS: entity glossary, channel→playout→schedule/block
concept map, where each concept is edited in the SPA.
- **`docs/api-conventions.md`** — checklist for adding/changing a `/api/*` endpoint (controllers,
DTOs, error mapping, auth, OpenAPI regen, tests).
- **`docs/spa-conventions.md`** — playbook for adding a screen to the ChicoryTV React SPA.
- **`docs/e2e-local.md`** (+ `scripts/e2e-local.sh`) — how to run a live local instance for manual
or Playwright-MCP verification.
- **`docs/testing.md`** — testing map: what each `*.Tests` project / `web` suite covers,
golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification
gate.
- **`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.md`** + **`docs/decisions/*.md`** — active decision records (lifecycle schema:
key/status/since/supersedes/superseded-by). **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.
- **`docs/rest-api.md`** — REST API design doc for ersatztv#2 (goals, conventions, per-slice plan).
Largely superseded day-to-day by `docs/api-conventions.md`; read this for the original rationale.
- **`docs/mcp.md`** — the `ErsatzTV.Mcp` stdio JSON-RPC MCP server (#58): how it wraps `/api/v1` as
@@ -41,6 +88,8 @@ Also present in `docs/`:
OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` /
`scripts/update-openapi.sh`.
- **`docs/handoffs/chicorytv-issue-queue.md`** — static session kickoff prompt + workflow lore.
Queue state (goal, arc, what's in flight/next) lives in the pinned Gitea tracker ersatztv#237
— read that, not this file, for current state (protocol: decisions.md 2026-07-11).
Queue state is **live Gitea state**, retrieved each session via `scripts/select-queue.sh` — see
that file's standing kickoff for the two concurrent tracks (orientation ‖ selection). ersatztv#237
is a closed, archival historical tracker (superseded by `startup.parallel-orientation` in
`docs/decisions.md`) — not a live pointer.
- **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2).
+6 -4
View File
@@ -50,11 +50,13 @@ step is now continuous. The release boundary is instead where you:
`docs/decisions/archive/` (the validator fails this, but eyeball it at the boundary too).
3. Regenerate the active catalog: `PYTHONPATH=. python3 scripts/build_decisions_catalog.py` and
commit any drift.
4. Check the aggregate active-corpus budget (`decisions_validate.py --budget <n>`, default 4200
4. Check the aggregate active-corpus budget (`decisions_validate.py --budget <n>`, default **4800**
lines across `docs/decisions.md` + topic files + the catalog — replaces the old single-file
1800-line floor). Going over budget is a **non-blocking warning** (`::warning::` to stderr, not a
validator error) — a ratchet/reminder to migrate more `legacy-unmigrated` records or extract a new
topic file, not a release gate.
1800-line floor). **Re-baselined 2026-07-21 (#520)**: the corpus is now fully migrated at
~4366 lines; 4800 gives headroom so the warning fires on real future growth, not on the expected
post-migration size. Going over budget is a **non-blocking warning** (`::warning::` to stderr,
not a validator error) — a ratchet/reminder to extract a new topic file or archive more history,
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 `[decisions-edit]` in the commit message (see the
+360 -18
View File
@@ -91,7 +91,6 @@ in-file entries.
- [2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)](#2026-07-10--playout-api-mutations-return-409-while-the-build-lock-is-held-215)
- [2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved](#2026-07-11--schedules-spa-editor-draftexplicit-save-over-instant-persist-copy-includes-multismartrerun-shuffled-get-normalization-preserved)
- [2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212)](#2026-07-11--channel-editor-bare-create-entry-point--external-logo-mutual-exclusion-212)
- [2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file](#2026-07-11--queue-state-lives-in-the-pinned-gitea-tracker-237-not-in-the-handoff-file)
- [2026-07-11 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)](#2026-07-11--entitylocker-atomic-flags--single-owner-release-discipline-no-owner-tokens-231)
- [2026-07-11 — Media-source management REST write API + SPA (#202)](#2026-07-11--media-source-management-rest-write-api--spa-202)
- [2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204)](#2026-07-11--legacyspa-redirect-matcher-exact-map--ordered-segment-template-patterns-204)
@@ -130,10 +129,15 @@ in-file entries.
- [2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)](#2026-07-20--external-url-channel-logos-pass-through-to-the-graphics-engine-never-fileexists-gated-never-ffmpeg-native-502)
- [2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)](#2026-07-20--remote-graphics-engine-images-are-fetched-through-a-bounded-pooled-iremoteimagefetcher-re-fetched-per-element-init-not-cached-511)
- [2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521)](#2026-07-21--decision-records-carry-a-lifecycle-schema-validated-by-a-script-append-only-by-diff-is-retired-521)
- [2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520)](#2026-07-21--parallel-orientation--selection-is-the-startup-protocol-237-retired-520)
---
## 2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer
`key: api.mediatr-passthrough` · `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
@@ -146,6 +150,10 @@ that depth, to avoid N+1 validation queries — precedent set by the schedules e
`docs/api-conventions.md` §3 for the up-to-date statement of this rule.
## 2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin
`key: spa.spa-rebuild-decision` · `status: active` · `since: 2026-06` · `supersedes: none` · `superseded-by: none`
**Rule:** The UI is a full React SPA (ChicoryTV) rebuild over the REST API, not a Blazor Server reskin.
**Signals:** Blazor removal phases (a)/(b), root-flip, legacy-route redirects · paths: `ErsatzTV/LegacyUiRedirects.cs`, `docs/blazor-route-parity.md` · issues: #59, #91, #148
**Mechanics:** `docs/blazor-route-parity.md`
#59 committed to a full SPA rebuild rather than reskinning Blazor Server pages. Blazor removal is
split into two phases under #91: **(a)** root-flip (SPA becomes `/`) + legacy-route redirects —
@@ -154,6 +162,10 @@ Blazor removal — gated on every route having an SPA equivalent; tracked route-
`docs/blazor-route-parity.md`.
## 2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable`
`key: api.response-dtos` · `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
@@ -163,6 +175,10 @@ with an optional member needs its own `#nullable enable` pragma at the top (most
there; that's a Core/Api-layer-only convention. Full detail: `docs/api-conventions.md` §2.
## 2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default
`key: api.put-replace-index-order` · `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
@@ -174,6 +190,10 @@ so it acts as the catch-all default. Established by the alternate-schedules work
`AlternateScheduleSelector.cs`).
## 2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar
`key: spa.templates-editor-table` · `status: active` · `since: 2026-07` · `supersedes: none` · `superseded-by: none`
**Rule:** The SPA templates editor renders day/block assignment as a table, not Blazor's drag-and-drop calendar grid — an accepted, deliberate parity deviation.
**Signals:** `TemplateEditor.razor` vs table UI · paths: `/app/templates/{id}` · issues: #173
**Mechanics:** PR #173
The legacy Blazor `TemplateEditor.razor` used a drag-and-drop day-grid calendar UI. The SPA
equivalent (`/app/templates/{id}`, PR #173) renders the same day/block assignment as a table
@@ -181,6 +201,10 @@ instead. This is an accepted, deliberate parity deviation — don't "fix" it to
interaction model without discussing it first.
## 2026-07-07 — API artwork contract: rooted URLs produced server-side
`key: api.artwork-rooted-urls` · `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
@@ -195,6 +219,10 @@ DTOs — those still return old Blazor-convention relative paths; map from the d
root the path via `ApiArtwork`.
## 2026-07-07 — Decode-style endpoints take a row id and look up server-side
`key: api.decode-by-id` · `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
@@ -203,6 +231,10 @@ JSON (`Key`/`Details`) is decoded server-side into `PlayoutHistoryDetailsRespons
never round-trips the raw payload itself.
## 2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints
`key: api.parentid-drillin` · `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
@@ -211,6 +243,10 @@ media-detail's season/episode/artist/music-video browsing). Avoids a combinatori
per-kind child endpoints.
## 2026-07-07 — Convention docs read at session start, updated in-PR
`key: docs.convention-docs-session-start` · `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`
@@ -226,6 +262,10 @@ follow-up under #184 rather than fixed inline, since it's a pre-existing gap, no
doc-drafting pass caused.
## 2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel
`key: spa.playback-troubleshoot-poll` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
**Rule:** The playback-troubleshooting screen reports FFmpeg completion by polling `GET /api/troubleshoot/playback/status` (~2s) rather than a server push channel.
**Signals:** `PlaybackTroubleshootingScreen.tsx`, `ICourier`/`ISnackbar` parity · paths: `web/src/screens/PlaybackTroubleshootingScreen.tsx` · issues: #145
**Mechanics:** `GET /api/troubleshoot/playback/status`
The SPA playback-troubleshooting screen (`PlaybackTroubleshootingScreen.tsx`, #145) reports FFmpeg
completion by **polling `GET /api/troubleshoot/playback/status` every ~2s** while a session is
@@ -240,6 +280,10 @@ real-time transport. Speed thresholds and the "(Speed: Nx)" badge colors are cop
Blazor `GetSpeedClass` (red <0.9, green >1.1, amber otherwise).
## 2026-07-09 — datetime-local instead of Chronic natural-language start parsing
`key: spa.datetime-local-input` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
**Rule:** The channel-mode date/time input uses a native `<input type="datetime-local">` instead of free-text Chronic natural-language parsing.
**Signals:** `playback.m3u8` `start` param, ISO-8601 round-trip · paths: none beyond the screen itself · issues: none cited
**Mechanics:** playback-troubleshooting screen, `playback.m3u8` `start` param
The channel-mode "Date and Time" input in the SPA playback-troubleshooting screen uses a native
`<input type="datetime-local">`, a **deliberate deviation** from the Blazor page, which parsed a
@@ -249,6 +293,10 @@ Chronic dependency and a picker is unambiguous; the selected local datetime is s
controller binds to `DateTimeOffset?` exactly as the Blazor round-trip (`"o"`) format did.
## 2026-07-09 — SPA gates Download Media Sample while a session is active
`key: spa.download-sample-gate` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
**Rule:** The SPA disables both Download Media Sample and Download Results while a troubleshooting session is starting/running (Blazor only gated Download Results).
**Signals:** I/O contention with the live transcode · paths: none · issues: none cited
**Mechanics:** playback-troubleshooting screen
Minor intentional deviation: the SPA playback-troubleshooting screen disables **Download Media
Sample** (alongside Download Results) while a troubleshooting session is starting/running; Blazor
@@ -257,6 +305,10 @@ archiver reads the same media file, so gating both during a session is strictly
nothing (sessions are short).
## 2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198)
`key: api.openapi-mirrors-runtime` · `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`
@@ -273,6 +325,10 @@ real contract resolver.** This also fixed a latent SPA bug (the channel-list "FF
`fFmpegProfile` and always showed "Unassigned"). Issue #198.
## 2026-07-09 — YAML playout validator: paste-textarea instead of a server file path
`key: spa.yaml-validator-textarea` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
**Rule:** The YAML playout validator takes pasted YAML via a `<textarea>`, not a server-side file path, since the SPA has no filesystem access.
**Signals:** `YamlValidatorScreen.tsx` · paths: `web/src/screens/YamlValidatorScreen.tsx` · issues: none cited
**Mechanics:** `YamlValidatorScreen.tsx`
The legacy Blazor YAML playout validator took a **server-side file path** (read directly off the
container's filesystem). The SPA's `YamlValidatorScreen.tsx` instead uses a paste `<textarea>` — a
@@ -282,6 +338,10 @@ filesystem-browsing endpoint or silently fail; pasting the YAML directly is simp
every other SPA editor already round-trips content through the API instead of the disk.
## 2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder
`key: spa.channel-renumber-prompt` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
**Rule:** Channel renumbering uses a sequential `prompt()`-driven "Renumber" action instead of drag-to-reorder.
**Signals:** `App.tsx` Renumber action · paths: `web/src/App.tsx` · issues: none cited
**Mechanics:** `App.tsx` Renumber action
The legacy Blazor channel list let you drag-and-drop rows to reorder channel numbers. The SPA
(`App.tsx`) instead offers a "Renumber" action that walks the list and asks for each channel's new
@@ -291,6 +351,10 @@ existing per-channel update call and needs no new UI dependency. Revisit only if
large enough that prompt-per-channel becomes tedious.
## 2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor
`key: spa.deco-templates-table` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
**Rule:** The deco-templates editor also renders its day/deco assignment as a table, extending (not replacing) the templates-editor-table convention.
**Signals:** `DecoTemplatesScreen.tsx`, same accepted-deviation status · paths: `web/src/screens/DecoTemplatesScreen.tsx` · issues: none cited
**Mechanics:** `DecoTemplatesScreen.tsx`
Extends the 2026-07 "Templates editor in the SPA is a table, not Blazor's drag-calendar" entry
above (not editing that entry — this generalizes it): the deco-templates editor
@@ -300,6 +364,10 @@ accepted-deviation status — don't "fix" either editor to match Blazor's intera
discussing it first.
## 2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)
`key: api.search-paging-cap` · `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
@@ -308,6 +376,10 @@ appends the results client-side. This reuses the same `GetLibraryBrowseItems` qu
adds no API surface, and only pays for follow-up requests when a kind exceeds the first-page cap.
## 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement
`key: spa.logs-page-size-local` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** The Logs page rows-per-page preference is stored in `window.localStorage` (`ctv-logs-page-size`), not a server `ConfigElement`.
**Signals:** `LogsScreen.tsx`, wrapped-`Storage` pattern, `designSystem.ts`/`auth.ts` precedent · paths: `LogsScreen.tsx`, `designSystem.ts` · issues: none cited
**Mechanics:** `LogsScreen.tsx` localStorage key `ctv-logs-page-size`
The legacy Blazor Logs page persisted the user's chosen rows-per-page via
`ConfigElementKey.LogsPageSize` (`SaveConfigElementByKey`/`GetConfigElementByKey`), a
@@ -322,6 +394,10 @@ localStorage convention (`designSystem.ts` theme, `auth.ts` token) rather than i
persistence mechanism.
## 2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`
`key: api.logs-sort-params` · `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`,
@@ -334,6 +410,10 @@ indicating the active field/direction; clicking the active column toggles direct
other column switches to it ascending.
## 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode
`key: spa.playout-reset-button` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** `POST /api/channels/{channelNumber}/playout/reset`, no `mode` param · paths: none · issues: #210
**Mechanics:** `POST /api/channels/{channelNumber}/playout/reset`
Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control
(setting the daily rebuild time). The SPA keeps Reset — `POST
@@ -344,6 +424,10 @@ Edit-details flow, so a second entry point would duplicate an existing capabilit
deviation, not a lost capability. Issue #210.
## 2026-07-09 — Collection custom order: move up/down buttons, any-kind collections
`key: spa.collection-custom-order-ui` · `status: active` · `since: 2026-07-09` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** `CollectionsScreen.tsx`, `PUT /api/collections/{id}/custom-order`, `CustomOrderCollectionEnumerator` · paths: `CollectionsScreen.tsx` · issues: #211
**Mechanics:** `PUT /api/collections/{id}/custom-order`
Blazor reordered collection items with SortableJS drag-and-drop and only enabled custom ordering
for movies-only collections. The SPA (`CollectionsScreen.tsx`) uses per-row **Move up / Move
@@ -355,6 +439,10 @@ the movies-only gate: the API and the playback-side `CustomOrderCollectionEnumer
custom order enabled. Issue #211.
## 2026-07-10 — Shared "Add to…" layer lives in `web/src/media/addTo/`; select-mode is an explicit toggle
`key: spa.add-to-layer` · `status: active` · `since: 2026-07-10` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** `AddToCollectionDialog`, `AddToPlaylistDialog`, `AddToScheduleDialog`, `AddToMenu`, `ProgramScheduleItemCommandBase.CollectionTypeMustBeValid` · paths: `web/src/media/addTo/` · issues: #208, #209
**Mechanics:** `web/src/media/addTo/`
The media mutation surface (#208/#209) is built on one reusable component group,
`web/src/media/addTo/` (media-domain components, like `MediaPosterCard` — not generic
@@ -379,6 +467,10 @@ via `GET /api/search/all-items`, then reuse the id-list add endpoints — no que
command exists server-side. Issues #208/#209.
## 2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)
`key: api.schedule-item-flat-dto` · `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
@@ -399,6 +491,10 @@ deliberate and lives on the read side (documented + tested). New shared `NamedId
(`ErsatzTV.Core/Api/`) is the generic `{id, name}` embed for API responses. Issues #126/#207/#212.
## 2026-07-10 — Playout API mutations return 409 while the build lock is held (#215)
`key: api.playout-build-lock-409` · `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
@@ -436,6 +532,10 @@ Precedent for the 409 shape: `TraktController` (left as-is with its own private
to keep the diff small). Convention recorded in `api-conventions.md` §3a.
## 2026-07-11 — Schedules SPA editor: draft/explicit-Save over instant-persist; Copy includes multi/smart/rerun; shuffled-GET normalization preserved
`key: spa.schedules-editor-draft-save` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** draft/dirty guard, `navigationGuard.ts`, `copyDraftItem`, `EnforceProperties` · paths: `web/src/screens/SchedulesScreen.tsx`, `web/src/schedules/`, `docs/spa-conventions.md` §8 · issues: #207
**Mechanics:** `docs/spa-conventions.md` §8
The ChicoryTV schedules editor (`web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/`,
issue #207) rebuilds the schedule-item lineup to full mutation parity with the legacy Blazor editor.
@@ -467,6 +567,10 @@ mode from the option lists (and disables the reorder arrows) for shuffled schedu
so a GET→edit→PUT round-trip stays consistent with the server's read-side normalization. Issue #207.
## 2026-07-11 — Channel editor: bare-create entry point + external-logo mutual exclusion (#212)
`key: spa.channel-editor-create-logo` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** `ChannelEditor.razor` add-mode defaults, `ArtworkContentTypeModel.isExternalUrl`, `EMPTY_LOGO` · paths: `web/src/App.tsx`, `ChannelEditScreen.tsx` · issues: #212
**Mechanics:** `ChannelEditScreen.tsx`
Two Blazor-parity decisions closing the channel-editor gaps (`ChannelEditor.razor` +
`ChannelEditViewModel`):
@@ -501,24 +605,11 @@ it's absent from the reference list (`optionsKeepingCurrent` in `ChannelEditScre
an existing channel never silently changes the value out from under an unmanaged language code or a
template/selector file removed from disk since save.
## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file
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 — EntityLocker: atomic flags + single-owner release discipline, no owner tokens (#231)
`key: locking.entitylocker-atomic-flags` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** `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.
**Signals:** six `bool`→`int` flags, `TraktController.EnqueueWithTraktLock`, `unlock: last` tail pattern · paths: `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`, `IEntityLocker` · issues: #231, #232, #234, adversarial-reviewer#20/F5
**Mechanics:** `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`
`EntityLocker` (process-wide singleton, `ErsatzTV.Infrastructure/Locking/EntityLocker.cs`) is the
advisory "operation on entity X is in progress" mutex layer. Adversarial review (#20/F5, →#231)
@@ -553,6 +644,10 @@ successful lock, compensating unlock on enqueue failure, one release per acquisi
#234 (BuildPlayout/subtitles gate their `finally` unlock on the captured acquire result).
## 2026-07-11 — Media-source management REST write API + SPA (#202)
`key: media.source-mgmt-write-api` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** 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).
**Signals:** `RemoteConnectionResponseModel {hasApiKey}`, per-family identity contracts, Plex pin-flow polling, EntityLocker non-owner-token discipline · paths: `LocalLibrariesController`, `Plex|Jellyfin|EmbyMediaSourcesController` · issues: #202, #197, #231
**Mechanics:** `docs/handoffs/` session record, issue #202
Replaced the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages (14 routes) with SPA
screens under `/app/libraries/*` over new write controllers (`LocalLibrariesController`,
@@ -639,6 +734,10 @@ App re-pushes the pre-pop URL and the wrapper never sees the rejected path. This
list and the resolved caveat text.
## 2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204)
`key: spa.legacy-redirect-matcher` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** `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`.
**Signals:** `{id}` strict-positive-integer token, `{any}` token, `AppendQueryString` merge · paths: `ErsatzTV/LegacyUiRedirects.cs` · issues: #204
**Mechanics:** `LegacyUiRedirects.TryGetRedirect`, `AppendQueryString`
`LegacyUiRedirects.TryGetRedirect` grew from a single exact-path dictionary to a **two-tier matcher**
behind the unchanged `(PathString, out string)` signature. **Tier 1** is the existing
@@ -680,6 +779,10 @@ a single unified rule list (loses the O(1) dictionary hit for the ~52 exact rout
traffic). No `/api` change, no OpenAPI regen, no SPA change.
## 2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205)
`key: blazor.rollback-tag` · `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
@@ -701,6 +804,10 @@ action of the Step 2 deletion PR merge** (not before — `main` moves until then
Not cut this session — `main` still carries Blazor and will advance before the removal PR.
## 2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235)
`key: api.async-op-contract` · `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
@@ -756,6 +863,10 @@ a lock outlives its batch while the process keeps running). Adding a bounded-cha
drain is out of scope and would not fix a correctness bug.
## 2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)
`key: api.postcommit-cancellation-none` · `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
@@ -797,6 +908,10 @@ region). Low git-conflict risk, but merge `main` in and expect to see the `Cance
convention already present on the post-commit enqueues.
## 2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired
`key: scan.collections-scan-status` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** `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.
**Signals:** `CollectionsScanStatusResponseModel`, `useCollectionsScan`, `pruneGraceExpiredPending`, `COLLECTIONS_PENDING_TIMEOUT_MS` removed · paths: `MediaSourcesController.GetCollectionsScanStatus` · issues: #271
**Mechanics:** `GET /api/v1/media-sources/collections-scan-status`
**External Collections rows stay client-derived.** The SPA derives them from `GET
/api/v1/media-sources`: remote sources expose only sync-enabled libraries, so a non-empty `libraries` list is
@@ -827,6 +942,10 @@ the fast-scan-between-polls race — the same bounded-pending discipline #232/#2
`COLLECTIONS_PENDING_TIMEOUT_MS` is gone.
## 2026-07-11 — Blazor Server UI removed (#91 phase b)
`key: blazor.ui-removed` · `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.
@@ -872,6 +991,10 @@ Also removed the now-dead ersatztv#25 razor-Sonar `<NoWarn>S6966;S3267;…</NoWa
restore path). Not a `v*` tag → no prod release build.
## 2026-07-12 — Live-E2E is a required step for API write-path handler changes (#303)
`key: release.live-e2e-required` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** lazy LanguageExt `Map` blind spot, #229 finding · paths: `docs/e2e-local.md`, `docs/api-conventions.md` §7 · issues: #303, #229
**Mechanics:** `docs/e2e-local.md`
**A PR that changes an API write-path handler (a `POST`/`PUT`/`DELETE` `/api/*` command that mutates
state and reloads it through the read path) MUST include a live-E2E pass** — driving the real endpoint
@@ -889,6 +1012,10 @@ wasn't required (the same stated-exemption discipline as the review skip rubric)
remains the only net for this class") into a standing convention.
## 2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)
`key: spa.topbar-primary-action` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** `usePrimaryAction` hook, `ctv:primary-action` event, dead-button class · paths: `web/src/primaryAction.ts`, `docs/spa-conventions.md` §10 · issues: #238, #229, #247, #295
**Mechanics:** `web/src/primaryAction.ts`, `docs/spa-conventions.md` §10
The shell TopBar rendered a prominent top-right primary-action button (Plus icon) for **every** screen, but
only `SchedulesScreen` had ever subscribed to its `ctv:primary-action` event — so on every other screen the
@@ -927,6 +1054,10 @@ formalize; #238 keeps it a documented convention guarded by a data-driven `App.t
create screens (a typo'd route id → the banner navigates nowhere → red). Refs #238 #247.
## 2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)
`key: api.versioning-v1` · `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
@@ -969,6 +1100,10 @@ asserts it matches `^/api/v\d+/` — so a new controller that drifts (relative o
`/api` (e.g. `GET /auth/oidc/login`) are out of scope for the test. Docs: `api-conventions.md` §1/§9. Refs #286 #197.
## 2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)
`key: api.scheduling-hardening` · `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
@@ -1003,6 +1138,10 @@ non-existence-check, and the missing `Name=` on `PlayoutController` Create/Delet
`Name=`-less ops, and adding `Name=` would risk renaming generated SPA client methods). Refs #172 #197.
## 2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)
`key: ci.functional-e2e-harness` · `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 curl-only, deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check.
**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:
@@ -1032,6 +1171,10 @@ flaky. Assertions were written against a real running instance, not the source
`/artwork/*` returns `400` (not the `404` a static read suggested); extend the harness the same way.
## 2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)
`key: iptv.base-url` · `status: active` · `since: 2026-07-16` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** `AdvertisedBaseUrl.TryParse`/`Resolve`, `ConfigElementKey.IptvBaseUrl`, request-derived-host symptom (Gitea #1) · paths: `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `docs/m3u-xmltv.md` · issues: #340
**Mechanics:** `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`, `GET`/`PUT /api/v1/settings/iptv`
ErsatzTV's absolute IPTV URLs (M3U stream/logo/guide URLs, XMLTV `<icon>`/artwork) were always
derived from the incoming request's `Scheme`/`Host`/`PathBase`, so any client that fetched with a
@@ -1078,6 +1221,10 @@ separate, DB-stored (`ConfigElementKey.IptvBaseUrl`, **no EF migration**) advert
output. See `docs/m3u-xmltv.md` → "IPTV base URL (#340)".
## 2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)
`key: sched.auto-tune-foundation` · `status: active` · `since: 2026-07-16` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** auto-tuning, SmartCollection, additive coexistence, channel numbering · paths: `CreateChannelFromLineup` · issues: #69, #63
**Mechanics:** `PreviewAutoTuneChannelsHandler`, `AutoTuneAxisMap`, `ISender`-based `CreateChannelFromLineup` primitive
Auto-tuning (#69) generates channels from library metadata (TV Show / TV Genre / Movie Genre).
Enumeration for the preview uses EF distinct+count queries (exact counts drive the min-items
@@ -1091,6 +1238,10 @@ Created/Skipped/Failed outcome. Known MVP limitation: the generated SmartCollect
channel; a name collision with an existing SmartCollection surfaces as a per-channel Failed outcome.
## 2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)
`key: sched.reshuffle-scoped-reset` · `status: active` · `since: 2026-07-16` · `supersedes: none` · `superseded-by: none`
**Rule:** `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.
**Signals:** reshuffle, Playout.Seed, ErasePlayoutHistory, CollectionEnumeratorState · paths: `PlayoutBuilder` · issues: #71
**Mechanics:** `POST /api/v1/playouts/{id}/reshuffle`; `ErasePlayoutHistory`; `PlayoutBuildMode.Reset`
- The shuffle seed (`Playout.Seed`) + per-collection `CollectionEnumeratorState` already persist a stable
shuffled order across rebuilds. #71's real gap was a **user-triggered per-playout reshuffle** (a Classic
@@ -1109,6 +1260,10 @@ channel; a name collision with an existing SmartCollection surfaces as a per-cha
**visible confirmation** (the seed changes after a reshuffle).
## 2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred
`key: sched.clock-padding-existing` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** clock padding, FillerMode.Pad, pad_to_next, EPG guide-stop coalescing · paths: `PlayoutModeSchedulerBase.AddFiller`, `ChannelGuideProjector.ProjectFlood`, `/app/filler-presets` · issues: #77, #388
**Mechanics:** `PlayoutBuildGoldenTests.Classic_clock_padded`, `ChannelGuideProjectorClockPadTests`
#77 asked to "pad/snap channel schedules to clock boundaries (:00/:15/:30) using filler" — a Tunarr
"Pad Times" analogue for broadcast-looking guides. Investigation found the **core is already
@@ -1142,6 +1297,10 @@ the #388 design-system sync epic** (all UI work is currently gated on #388), whi
60-minute increment to the filler-preset editor's options (the backend already accepts any integer).
## 2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380)
`key: sched.shuffle-source-builder` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** ShuffleSourceBuilder, PlaybackOrder, cross-engine reach-in, god-factory rejection · paths: `ErsatzTV.Core/Scheduling/ShuffleSourceBuilder.cs`, `PlayoutBuilder`, `PlaylistEnumerator` · issues: #380, #163, #176, #70, #381
**Mechanics:** `ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle`/`GetCollectionItemsForShuffleInOrder`; `classic-shuffle` golden in `PlayoutBuildGoldenTests`
`PlaybackOrder` is turned into an enumerator at five independent sites (one per schedule kind, dispatched
by `BuildPlayoutHandler` on `ScheduleKind`): Classic (`PlayoutBuilder`), Block (`BlockPlayoutBuilder`,
@@ -1183,6 +1342,10 @@ one engine reaching into another engine's class.
(multi-collection vs fake-multi-collection lookup; multi-part grouping on/off).
## 2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented
`key: sched.seasonal-scheduling-existing` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** alternate schedules, playout templates, date-conditional, catch-all-last evaluation · paths: `AlternateScheduleSelector.cs`, `PlayoutBuilder`, `EffectiveBlock`, `DecoSelector`, `PlayoutScheduleEditors.tsx` · issues: #73, #70
**Mechanics:** `AlternateScheduleSelectorTests`, `playoutTemplateCalendar.test.ts`; `channels.md` "Recipe: seasonal / holiday programming"
#73 asked for "seasonal / date-conditional channels & schedule rules" — a PseudoTV holiday-channel analogue
— on the stated premise that *"ErsatzTV has no native date-conditional scheduling today; you'd hand-build it
@@ -1245,6 +1408,10 @@ plus the gotchas above) and a `domain-model.md` glossary row. No production code
(same reasoning as #77).
## 2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)
`key: sched.autotune-detailpanel-members` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** Auto-Tune DetailPanel, search-index roll-up, SmartCollection fidelity · paths: `GET /api/v1/channels/auto-tune/members`, `MediaCollectionRepository.GetSmartCollectionItems` · issues: #384, #383, #69, #385, #386
**Mechanics:** `AutoTuneAxisMap.GenerateQuery`; `LibraryBrowseItemMapper.GetShows/GetMovies`; `PagedLibraryBrowseItemsResponseModel`
The Auto-Tune DetailPanel (#383) shows, per proposed channel, the distinct **content sources** its
generated SmartCollection resolves to (a genre channel's shows/movies), each weightable in the #385
@@ -1393,6 +1560,10 @@ not size the `small` lane expecting otherwise.
triple-build (#398).
## 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: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** 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:** `api-conventions.md` §3a (precedent: `IsLocked` on `PlayoutListItemResponseModel`); `ChannelResponseModel.PlayoutCount`
#72 asks for per-channel status in the Channels list, "especially channels that will fail to play".
@@ -1437,6 +1608,10 @@ The related *silent* server-side coercion of Mirror→Generated (a 200 that disc
against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here.
## 2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)
`key: sched.weighted-shuffle` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** WeightedShuffle, fair-share, ShuffleInOrder anti-clumping, MultiCollectionItem.Weight, write-path validation · paths: `WeightedShuffleCollectionEnumerator`, `MultiCollectionGrouper`, `ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder`, `AddMultiCollectionItemWeight` migration · issues: #70, #380, #403, #385, #386
**Mechanics:** `CollectionWithItems.Weight`; `ReplacePlaylistItems`/`CreateChannelFromLineup` write-path gates; `EffectiveWeight` clamp
`PlaybackOrder.WeightedShuffle = 9` ships the two behaviors #70 asked for — "air Show A 70% / Show B 30%"
and "no single show dominates" — as **one** order, because fair-share is the equal-weights case of weighted.
@@ -1538,6 +1713,10 @@ and "no single show dominates" — as **one** order, because fair-share is the e
`CollectionWithItems` source-agnostically.
## 2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416)
`key: ci.docs-only-skip-steps` · `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
@@ -1573,6 +1752,10 @@ again on the merge-to-`main` over identical code (#420), and the within-run trip
(#398). Full mechanism in `ci-cd.md` → "Docs-only skip".
## 2026-07-17 — Docs-only detect must be shallow-checkout safe: FETCH_HEAD + two-dot, not origin/main + three-dot (#416 follow-up)
`key: ci.docs-only-detect-shallow-safe` · `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
@@ -1589,6 +1772,10 @@ real-PR verification that **measures the effect** (job durations, not just a gre
which is exactly what #416's Done-when demanded. Fixed in the #416 follow-up PR.
## 2026-07-17 — Pre-push guard: don't push a file whose working-tree copy is uncommitted (H13, #416 session)
`key: release.prepush-clean-worktree-guard` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** 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`.
**Signals:** working-tree vs committed-tree mismatch, stale index, pre-push hook, H13 · paths: `.claude/hooks/prepush-clean-worktree-check.sh`, `.husky/pre-push` · issues: #416
**Mechanics:** wired after the H11 rebase check in `.husky/pre-push`; same hook family as H11/H12 (#303)
A review fix (`--no-renames`) was edited into the working file and empirically verified there, but a
`git reset --soft` + `git add` then committed the *stale index*, leaving the fix as an uncommitted
@@ -1610,6 +1797,10 @@ rule a hook, not prose to remember" throughline. Verified: dirty PR-file → blo
dirty non-PR file → allow; escape hatch → allow.
## 2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)
`key: sched.autotune-per-channel-overrides` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** Auto-Tune, per-channel overrides, advanced-options DTO reuse · paths: `CreateAutoTunedChannelsHandler`, `POST /api/v1/channels/auto-tune` · issues: #385, #383, #425, #283
**Mechanics:** `CreateAutoTunedChannelsHandler.CreateOne`; `CreateChannelFromLineupAdvancedOptionsRequest`
The Auto-Tune DetailPanel (#383) makes each proposed channel individually editable before bulk-create.
The backend for that (#385) split cleanly along a "structural cost" line, and only the additive half
@@ -1654,6 +1845,10 @@ shipped here; the rest is deliberately deferred rather than forced.
`logo` above already covers the on-screen bug for channels that supply an image.
## 2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)
`key: api.healthcheck-remediation-dto` · `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.
@@ -1697,6 +1892,10 @@ code). Orthogonal to the UX; filed separately so a SPA-polled health panel gets
polls.
## 2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)
`key: spa.autotune-detailpanel-slideover` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** Auto-Tune DetailPanel, SlideOver, shared advanced-options hook · paths: `web/src/components/overlay.tsx`, `web/src/builder/advancedOptions.tsx` · issues: #386, #384, #385, #425, #135
**Mechanics:** spa-conventions.md §11; `useAdvancedOverrides` hook
The SPA half of the Auto-Tune per-channel editor. It builds only what the shipped `/api/v1` surface
(#384 members read, #385 per-channel `templateId`/`logo`/`advanced`) can actually carry, so the panel
@@ -1727,6 +1926,10 @@ never presents a control with nowhere to send its value.
makes that visible) and only screen navigation / full-page unload with uncommitted edits confirms.
## 2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)
`key: spa.smartcollection-rule-builder` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** 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:** `GET /api/v1/search/fields`; api-conventions.md; 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
@@ -1763,6 +1966,10 @@ for value inputs, relative-date operators, nesting deeper than one level, and in
`spa-conventions.md` §12).
## 2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)
`key: sched.autotune-per-source-weights` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** Auto-Tune, per-source weights, MultiCollection, WeightedShuffle · paths: `AutoTunedChannelRequest`, `OwnedByChannelId` migration · issues: #425, #70, #383, #386, #385
**Mechanics:** `AddCollectionOwnedByChannelId` migration; `WeightedShuffleCollectionEnumerator`
Per-source rotation weights (`3× Show A, 1× Show B`) and query corrections (exclude / add-untagged) for
an auto-tune channel are supplied **at bulk-create time** — an optional `sources: [{sourceId, weight,
@@ -1813,6 +2020,10 @@ non-atomic across the two handlers (mirrors #69) with best-effort rollback of th
failure.
## 2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)
`key: api.search-allitems-paging` · `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
@@ -1855,6 +2066,10 @@ page to completeness** — rather than option (b) (a generous cap + truncation s
— bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
## 2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)
`key: spa.sidebar-collapsible-accordions` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** sidebar, collapsible nav groups, localStorage keys · paths: `web/src/app/AppShell.tsx`, `web/src/app/sidebarState.ts` · issues: #396
**Mechanics:** spa-conventions.md §5d; `ctv-sidebar-collapsed` / `ctv-sidebar-groups` keys
The shell sidebar (`web/src/app/AppShell.tsx`) gained (a) a header toggle that collapses it to a 60px
icon rail and (b) collapsible accordions per **labeled** nav group (Media, System); the unlabeled
@@ -1885,6 +2100,10 @@ icon rail and (b) collapsible accordions per **labeled** nav group (Media, Syste
- No route/screen was added or redirected (shell-chrome only), so no `blazor-route-parity.md` change.
## 2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)
`key: sched.playbackorder-support-matrix` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** PlaybackOrder, support matrix, build-time dispatch · paths: `ErsatzTV.Core/Scheduling/PlaybackOrderSupport`, `PlaylistEnumerator.Create`, `BlockPlayoutBuilder` · issues: #403, #70
**Mechanics:** `PlaybackOrderSupportTests` (partition assertion)
`#70` closed the *persistence* hole for `WeightedShuffle` (the write path rejects it on the engines that
can't handle it) and made **YAML + Scripted** log a warning; `MultiCollectionGroup` already threw. It left
@@ -1931,6 +2150,10 @@ the three still-**silent** build-time dispatch sites — the ones this issue nam
*order* degrades silently" and is not part of #403's scope.
## 2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip
`key: ci.build-once-rejected` · `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
@@ -1952,6 +2175,10 @@ Build-once (a `compile` job producing a single artifact, consumed by `test`/`mig
Refs: #398 (closed), #420, PR #455.
## 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)
`key: media.lastscan-null-boundary` · `status: active` · `since: 2026-07-18` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** LastScan, MinValue sentinel, API-boundary honesty · paths: `GetAllMediaSourcesForApiHandler.NormalizeLastScan`, `MediaSourceRepository` · issues: #409
**Mechanics:** `NullOutNeverScannedLastScan` migration; `LibrariesScreen.tsx` `hasScanned` removal
`Library.LastScan` / `LibraryPath.LastScan` are `DateTime?`; a never-scanned library is `null` at
runtime for a freshly-created row. But the `0001-01-01 00:00:00` MinValue sentinel still appears in the
@@ -1988,6 +2215,10 @@ is honest. (Follow-up option, not done here: have `MediaSourceRepository` write
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
## 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)
`key: sched.weightedshuffle-editor` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** WeightedShuffle SPA, multi-collection weights, fair-share reset · paths: `web/src/.../itemRules.ts` `MULTI_COLLECTION_ORDERS`, `itemsFromMultiCollection`/`toItemRequest` · issues: #404, #70, #402
**Mechanics:** spa-conventions.md §4 (replace-all-DTO trap); `fillWithGroupModeEligible` exclusion
The UI half of #70 (backend + API shipped in PR #402). No new endpoint or DTO — `weight` was already on
`MultiCollectionItemRequest`/`…ResponseModel` and `WeightedShuffle` already in the `PlaybackOrder` enum; this
@@ -2027,6 +2258,10 @@ is purely SPA (+ docs).
gained `min`/`max`/`inputMode`/`onBlur` passthroughs for this (reusable by #425's weight UI).
## 2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)
`key: api.healthcheck-ttl-cache` · `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
@@ -2058,6 +2293,10 @@ performance half.
reviewer doesn't read the absence of a `SemaphoreSlim` as an oversight.
## 2026-07-19 — The `format` gate runs `dotnet format whitespace . --folder`, not the full solution format (#469)
`key: ci.format-gate-folder-mode` · `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
@@ -2128,6 +2367,10 @@ off the live test-job peak-anon this instrument now reports, not off the build-o
(swap/read-method move the figure >1 GiB).
## 2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473)
`key: media.remote-stream-probe` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
**Rule:** `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.
**Signals:** remote-stream probing, fail-open/fail-closed, exit-8 ffmpeg loop · paths: `GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, `IRemoteStreamProber` · issues: #473, #350, #480
**Mechanics:** `HttpRemoteStreamProberTests` (`Should_Fail_Open_*` / `Should_Fail_Open_On_404_That_Was_Not_Redirected`)
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath` now probes the Plex/Jellyfin/Emby
remote-stream URL via the new `IRemoteStreamProber` seam before returning it, and on a 404 **from the media
@@ -2188,6 +2431,10 @@ server** returns the new `PlayoutItemNotAvailableFromMediaServer` error instead
untested one.
## 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)
`key: scan.zero-item-fetch-guard` · `status: active` · `since: 2026-07-19` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** library sweep, zero-item guard, anti-nuke · paths: `MediaServerReconciliationGuard`, `MediaServerTelevisionLibraryScanner`/`MovieLibraryScanner`/`OtherVideoLibraryScanner` · issues: #477, #476
**Mechanics:** `MediaServerReconciliationGuardTests` policy table
Each media-server scanner reconciles "gone upstream" as `existing.Except(incoming)` and flags the result
`FileNotFound`. If a *successful* fetch returns **zero** items — the server is up but mid-restore /
@@ -2234,6 +2481,10 @@ protection by accident of control flow, not by design.
watching all four anti-nuke assertions fail while the `(0,0)` no-op case stays green.
## 2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480)
`key: media.remote-stream-probe-externaljson` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** external-JSON channels, remote-stream probe, scope-gap follow-up · paths: `ExternalJsonPlayoutItemProvider.StreamRemotely` · issues: #480, #473
**Mechanics:** `ExternalJsonPlayoutItemProviderTests`
The #473 fix (PR #479, the entry above dated 2026-07-19) probed Plex/Jellyfin/Emby remote-stream URLs in
`GetPlayoutItemProcessByChannelNumberHandler.ValidatePlayoutItemPath`, but explicitly scoped itself to the
@@ -2269,6 +2520,10 @@ for its whole slot. This closes that gap.
neutralizing the probe guard and watching the unavailable assertion flip to `Right`.
## 2026-07-20 — `ILibraryRepository.GetOrAddFolder` resolves the folder from the DB, not the caller's `LibraryPath.LibraryFolders` navigation (#488)
`key: scan.getoraddfolder-db-lookup` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `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.
**Signals:** GetOrAddFolder, LibraryFolders navigation, ArgumentNullException, folder lookup contract, remote scanner crash, eager-load assumption · paths: `ILibraryRepository.GetOrAddFolder`, `LibraryRepositoryTests`, `JellyfinMusicVideoLibraryScanner` · issues: #488
**Mechanics:** `LibraryRepositoryTests` (`LibraryPath.LibraryFolders == null` case); test coverage in `LibraryRepositoryTests`
`GetOrAddFolder` looked the existing folder up by reading `libraryPath.LibraryFolders` in memory. That
navigation collection is only eager-loaded on the **local** scan path — `LibraryRepository.GetLibrary`
@@ -2301,6 +2556,10 @@ remote scanner tripped it, and the feature had never run in prod, CI, or locally
it is not affected; left as-is (out of #488 scope).
## 2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)
`key: scan.musicvideo-reconciliation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `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 · paths: `JellyfinMusicVideoLibraryScanner.TrashMissingMusicVideos`, `FindMusicVideoPaths`/`DeleteByPath`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496
**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
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`
@@ -2338,6 +2597,10 @@ media-server identity those base scanners rely on.
earns its keep once the sweep exists.
## 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds
`key: scan.jellyfin-mixed-content-library` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** mixed-content library, `LibraryMediaKind.Mixed`, per-kind sequential scan, per-kind scan dispatch, silent-success bug · paths: `JellyfinApiClient.Project()`, `ScanMixedLibrary`, `SynchronizeJellyfinLibraryByIdHandler`, `ScanLocalLibraryHandler` · issues: #489, #474, #488
**Mechanics:** local mixed libraries deliberately unsupported (`LocalFolderScanner.VideoFileExtensions` hazard); `LibraryMediaKind.Mixed` dispatch in the Jellyfin sync handlers; per-kind scanners queried by `parentId` + `includeItemTypes`
A Jellyfin library whose collection type is `mixed` — or absent — now maps to `LibraryMediaKind.Mixed`
instead of being dropped by `JellyfinApiClient.Project()`'s `_ => None`. Scanning it runs the movie,
@@ -2399,6 +2662,10 @@ fixed here:
that scanner's other gaps (no `ItemId`/`Etag`, path-keyed identity — see #488).
## 2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)
`key: iptv.logo-drives-bug-preset` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** channel logo, watermark bug preset, ChannelWatermark seeding, ChannelLogo imageSource, seed/adopt semantics, quick-add · paths: `ChannelWatermarkImageSource.ChannelLogo`, `DbInitializer.Initialize`, `watermark.channel_bug_seeded`, `WatermarkResponseModel` · issues: #67, #286, #502
**Mechanics:** `DbInitializerChannelBugWatermarkTests`; `WatermarkResponseModel.imageSource`; `DbInitializer` seeded `Channel Bug` watermark + `watermark.channel_bug_seeded` ConfigElement marker
#67 asked that one uploaded image drive both the listing logo and the on-screen bug, separably
overridable, with preview. Most of it already existed: `ChannelWatermarkImageSource.ChannelLogo`
@@ -2438,6 +2705,10 @@ shared row already delivers the user-visible behavior with no schema change.
**Accepted trade-off:** every channel on the shared preset shares one geometry; per-channel tweaks mean
creating a second preset on the Watermarks screen.
## 2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool
`key: ffmpeg.qsv-decode-encode-split` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** QSV, native VA-API decode, Dolby Vision passthrough, hybrid decode/encode, HDR tonemap fallback · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder.SetTonemap`, `QsvPipelineBuilder` · issues: #498, #505
**Mechanics:** `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md`; migration `HasDefaultValue(true)` on the nullable `bool?` column; `QsvPipelineBuilder` decoder-mode branch
`FFmpegProfile.HardwareAcceleration` picked one pipeline builder for **both** decode and encode, so an
Intel QSV profile decoded with the QSV decoder — which is materially less tolerant of imperfect H.264 than
@@ -2477,6 +2748,10 @@ ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-desi
FFmpeg has no `vaapi` hwaccel on Windows (and Windows QSV capabilities are over-reported), so on Windows
a QSV profile keeps QSV decode regardless of the flag.
## 2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639)
`key: ci.small-lane-git-only` · `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:
@@ -2504,6 +2779,10 @@ ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-desi
have already finished.
## 2026-07-20 — External-URL channel logos pass through to the graphics engine; never `File.Exists`-gated, never ffmpeg-native (#502)
`key: ffmpeg.external-logo-graphics-engine` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** external-URL logo, WatermarkSelector, graphics engine · paths: `WatermarkSelector`, `FFmpegLibraryProcessService`, `ImageElementBase.LoadImage` · issues: #502, #67, #1, #510, #511
**Mechanics:** `WatermarkSelectorChannelLogoTests`; `ChannelLogoWatermarkOptions` helper
A channel whose logo is an **external URL** never rendered an on-screen bug, even with a
`ImageSource = ChannelLogo` watermark attached. `WatermarkSelector` resolved the URL correctly and then
@@ -2559,6 +2838,11 @@ again — the duplication is what let the defect exist in triplicate. Covered by
## 2026-07-20 — HLS cold start is fixed with `-readrate_initial_burst`, not by raising the work-ahead limit (#350)
`key: ffmpeg.hls-cold-start-burst` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** HLS cold start, readrate, work_ahead_limit, HlsSessionWorker, FFmpegKnownOption capability gate · paths: `HlsSessionWorker`, `SetRealtimeInput`, `FFmpegPlaybackSettingsCalculator`, `FFmpegKnownOption`/`HasOption` · issues: #350
**Mechanics:** `SetRealtimeInput` readrate-burst option; `FFmpegKnownOption.HasOption` version-capability gate
- **`-readrate` throttles from the first read, so it sets a floor on time-to-first-segment.** The
realtime playback path pins input reading to 1.05× wall clock so a channel behaves like live TV.
Since `OutputFormatHls.SegmentSeconds` is 4 and the segmenter serves the playlist only once the
@@ -2603,6 +2887,11 @@ again — the duplication is what let the defect exist in triplicate. Covered by
## 2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58)
`key: mcp.server-foundation` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** `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`.
**Signals:** MCP server, tool catalog, read-only default, ERSATZTV_ALLOW_WRITES, X-Api-Key, If-Match round-trip · paths: `ErsatzTV.Mcp`, `docs/mcp.md` · issues: #58, #286, #197, #76, #289, #63, #64, #65, #66, #67, #68
**Mechanics:** `docs/mcp.md`; `ErsatzTV.Mcp` tool executor and catalog
The MCP server (issue #58, gated on #286 route-freeze + #197 security review, both closed) is built
**fresh** as `ErsatzTV.Mcp` — a stdio JSON-RPC server wrapping `/api/v1` — superseding the closed
read-only PR #76 rather than rebasing it. Full doc: `docs/mcp.md`. Decisions frozen:
@@ -2630,6 +2919,11 @@ read-only PR #76 rather than rebasing it. Full doc: `docs/mcp.md`. Decisions fro
their backend endpoints exist. No `/api` endpoint was added, so no OpenAPI regen.
## 2026-07-20 — Remote graphics-engine images are fetched through a bounded, pooled `IRemoteImageFetcher`; re-fetched per element init, not cached (#511)
`key: ffmpeg.remote-image-fetcher-bounded` · `status: active` · `since: 2026-07-20` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** remote image fetch, decompression bomb, MaxFrames, decode budget vs retention budget, SSRF accepted risk · paths: `IRemoteImageFetcher`, `HttpRemoteImageFetcher`, `ImageElementBase.LoadImage`, `DecoderOptions.MaxFrames`, `WatermarkElementRemoteImageTests` · issues: #511, #502, #289
**Mechanics:** `HttpRemoteImageFetcher` (Infrastructure) over `IHttpClientFactory`; `EnsureDecodeAffordable`/`EnsureScaledFramesAffordable` pure-function budget checks
`ImageElementBase.LoadImage` fetched `http(s)` images with a throwaway `new HttpClient()` and
`GetStreamAsync`. That is unbounded in three directions at once — no timeout override (the 100s
`HttpClient` default), no response size cap, and a new connection pool per element — and it runs
@@ -2810,3 +3104,51 @@ record as its `superseded-by` and this record's `supersedes` pointing back — t
pattern the new rule itself mandates for every future reversal. `docs/decisions/migration-map.md`
tracks the legacy-heading → key → status → location mapping as the rest of the log migrates
(ersatztv#520/#521, ongoing — most existing headings remain `legacy-unmigrated` until touched).
## 2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520)
`key: startup.parallel-orientation` · `status: active` · `since: 2026-07-21` · `supersedes: docs.queue-state-gitea-tracker@2026-07-11` · `superseded-by: none`
**Rule:** 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.
**Signals:** startup protocol, queue selection, MemPalace retrieval, breadcrumb rule · paths: `docs/handoffs/chicorytv-issue-queue.md`, `scripts/select-queue.sh`, `docs/README.md`, `scripts/check-kickoff-guard.sh` · issues: #237, #520, #521, server-management#642
**Mechanics:** `docs/handoffs/chicorytv-issue-queue.md` → "Two concurrent tracks at session start" + "Knowledge retrieval"; `scripts/select-queue.sh`; regression guard `scripts/check-kickoff-guard.sh` (wired into the `decisions lifecycle` CI job)
**Why this reverses `docs.queue-state-gitea-tracker` (2026-07-11), not just extends it.** That
record's rule was "queue state lives in pinned tracker #237, not in the handoff file" — sound in
2026-07-11 when the arc was live and #237's body/comments were the only concurrency-safe place to
put session state. Two things changed: (1) `scripts/select-queue.sh` (2026-07-19) already replaced
the *mechanical* selection logic with a deterministic query over live Gitea state — no script reads
#237's prose; and (2) the arc itself completed and #237 **closed** on 2026-07-13. A closed issue
cannot be "where live queue state lives" — continuing to point agents at it was no longer just
stale, it was actively wrong, and the old record's own text ("Pinned tracker issue #237 holds the
goal + ordered arc") was live regression bait: a session reading it verbatim would try to treat a
closed tracker as authoritative. So this is a genuine reversal (the *store* changes from "a specific
Gitea issue" to "live Gitea queries, no pinned issue required"), not a mechanical follow-on.
**What the new protocol is.** Session start runs two independent tracks, neither blocking the other:
**Orientation** — read `AGENTS.md`/`CLAUDE.md`, then `docs/README.md`'s task-signal map (replacing
the old mandatory 1–10 reading order), then the active decisions catalog `docs/decisions/README.md`
for "why do we do X" questions. **Selection** — only when the user hasn't named an issue, run
`scripts/select-queue.sh [N]`, which excludes `in-progress`/`parked`/PRs, resolves
`GET /issues/{n}/dependencies`, tiers by LOCAL `.milestone.state`/`review`/`priority:` filters, and
orders deterministically; it flags `CLAIM?`/`UMBRELLA?` for the orchestrator to resolve by reading
the flagged issue, and does not rank an arc tier (none is active in maintenance/backlog mode — if
one is ever re-established, extend the script, never revert to prose-derived ranking). A
user-named issue skips Selection outright and goes straight to focused retrieval on that issue.
**MemPalace retrieval contract (server-management#642, the sole Gitea→MemPalace exporter).**
MemPalace is candidate discovery only, verified against its cited source before use, at a default
`ErsatzTV-Decisions` wing; the four load-bearing orientation bullets — catalog-first for current
rules, issue history as evidence not authority, the breadcrumb rule (a file path in a historical
comment resolves by concept via the active wing, never by literal path-chase), and the
catalog→`rg` exact-search fallback when MemPalace is stale/down — are carried verbatim in
`docs/README.md` and `docs/handoffs/chicorytv-issue-queue.md`. Never derive live queue state from
MemPalace, #237, or historical comments; live Gitea state via `scripts/select-queue.sh` is the only
source of truth for pickup ordering. The `## Closing record` template (Outcome / Root cause /
Decisions changed / Reusable knowledge / Verification / Deferred / Docs updated) is the structured
per-issue artifact this retrieval contract is built to consume — see `CLAUDE.md` → Task Completion
Protocol and the kickoff's "Closing record" section.
**Regression guard.** `scripts/check-kickoff-guard.sh` greps the standing startup docs
(`docs/handoffs/chicorytv-issue-queue.md`, `docs/README.md`, `CLAUDE.md`, `scripts/select-queue.sh`)
for #237-as-live-state phrasings ("read #237", "tracker #237", "queue state lives in", …), exempting
lines/sections that are explicitly archival. It is wired into the `decisions lifecycle` CI job so a
future PR cannot silently reintroduce the reversed rule.
+104
View File
@@ -8,7 +8,111 @@ 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](../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-signal` | Channel health rides `ChannelResponseModel`/`ChannelListItem` DTOs as a raw `int PlayoutCount` fact (free — `GetAll` already `Include`s `Playouts`), not a new endpoint, not `/channels/state` (runtime-liveness cadence), and not a derived `ChannelHealth` enum (would freeze policy before the #383/#384 auto-tune status taxonomy lands). | 2026-07-17 | [link](../decisions.md#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72) |
| `api.decode-by-id` | Endpoints that decode/expand opaque stored state accept a database row id and resolve it server-side rather than round-tripping client-supplied serialized state. | 2026-07-07 | [link](../decisions.md#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side) |
| `api.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-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) |
| `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.docs-only-detect-shallow-safe` | The docs-only detect script must diff against `FETCH_HEAD` (always resolves after `git fetch`, even shallow) using a two-dot tree diff — not `origin/<base>` with three-dot — because a `fetch-depth: 1` shallow clone has no remote-tracking ref and no merge-base, which silently fails the original detect into `docs_only=false` (full matrix, no functional error). A CI-behavior change must be verified by measuring the effect (job durations), not just a green check. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-detect-must-be-shallow-checkout-safe-fetch_head--two-dot-not-originmain--three-dot-416-follow-up) |
| `ci.docs-only-skip-steps` | A docs-only change must still run every required job (`test`, `migrations`) so their commit-status contexts always report; each heavy job runs `scripts/ci-detect-docs-only.sh` first and gates its real STEPS on `if: steps.detect.outputs.docs_only != 'true'`, never `if:`-skips the whole job (an `if:`-skipped job reports `skipped`, not `success`, which branch protection may never unblock on). Detection biases toward running more on any doubt. | 2026-07-17 | [link](../decisions.md#2026-07-17--docs-only-ci-skip-gates-steps-in-always-running-required-jobs-never-if-skips-them-416) |
| `ci.format-gate-folder-mode` | The blocking `format` CI job (and matching pre-commit hook) runs `dotnet format whitespace . --folder --include <files>` instead of loading the full MSBuild/Roslyn solution, cutting the gate from ~480s to ~0.5s with unchanged whitespace/charset coverage. | 2026-07-19 | [link](../decisions.md#2026-07-19--the-format-gate-runs-dotnet-format-whitespace----folder-not-the-full-solution-format-469) |
| `ci.functional-e2e-harness` | The `functional-e2e` CI job boots the PR's own code from source via `dotnet run` (`scripts/e2e-local.sh`) and runs curl-only, deterministic assertions (`scripts/e2e-functional.sh`) as an advisory (non-blocking) job, not a `build` dependency or required check. | 2026-07-16 | [link](../decisions.md#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) |
| `ci.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) |
| `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-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) |
| `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.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) |
| `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) |
| `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) |
| `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-reconciliation` | `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. | 2026-07-20 | [link](../decisions.md#2026-07-20--jellyfinmusicvideolibraryscanner-reconciles-by-library-scoped-path-diff--hard-delete-not-server-itemid-soft-trash-494) |
| `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.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) |
| `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.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) |
| `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.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.smartcollection-rule-builder` | 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. | 2026-07-18 | [link](../decisions.md#2026-07-18--smartcollection-rule-builder-compile-only-closed-subset-no-stored-ast-one-level-nesting-176) |
| `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) |
+36
View File
@@ -26,6 +26,10 @@ contract-freeze), #206 (Blazor-removal auth posture), #283 (artwork content-type
---
## 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
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
@@ -68,6 +72,10 @@ pieces above; `MapControllers()` + `/docs` (Scalar), currently co-hosted in the
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.
@@ -95,6 +103,10 @@ the OpenAPI security scheme) is decomposed into #280–#289 with the phased road
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` →
@@ -131,6 +143,10 @@ duplicated in `UploadArtworkHandler`). Both serve sinks are `[ApiExplorerSetting
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
@@ -176,6 +192,10 @@ versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 rema
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*
@@ -207,6 +227,10 @@ number-based lookup endpoint may be added additively later; `UniqueId` (Guid) st
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
@@ -306,6 +330,10 @@ is an additional accepted credential the doc needn't express. `AuthController` i
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
@@ -346,6 +374,10 @@ logout is a future nicety. Docs: `spa-conventions.md §5e` (SPA seams), `api-con
(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);
@@ -375,6 +407,10 @@ camera/microphone/geolocation/payment/usb) and an **enforcing** `Content-Securit
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
+40
View File
@@ -0,0 +1,40 @@
# Archive — startup / kickoff protocol
Superseded/retired records for the session-startup / queue-selection protocol. See
`docs/decisions/archive/README.md` for the archive's general rules (rationale kept verbatim,
never in the active read-path). Active successor: `startup.parallel-orientation` in
`docs/decisions.md`.
---
## 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`
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.
+17 -13
View File
@@ -1,10 +1,14 @@
# Decision migration map (legacy heading → active record / archive)
Auditable mapping produced during the #521 lifecycle migration. Every legacy H2 heading maps to
either an active record (key) or an archive location. Most headings in `docs/decisions.md` and the
`docs/decisions/*.md` topic files are still `status: legacy-unmigrated` (no metadata block yet) — this
table only covers records touched so far. `PYTHONPATH=. python3 scripts/decisions_validate.py` prints
the running legacy-unmigrated count; it must trend to 0 over the arc, not drop to 0 in one PR.
The full corpus (`docs/decisions.md` and the `docs/decisions/*.md` topic files) was **fully migrated
to the lifecycle schema on 2026-07-21 (#520/#521)**: `PYTHONPATH=. python3 scripts/decisions_validate.py`
reports 0 `legacy-unmigrated` headings, and the catalog (`docs/decisions/README.md`, built by
`scripts/build_decisions_catalog.py`) lists 108 active records. Every active decision heading now
carries a metadata block (`key:`/`status:`/`since:`/`supersedes:`/`superseded-by:`). Most headings were
migrated **in place** — the heading text is unchanged, only the metadata block was added — so their key
is discoverable via the catalog or `rg '^\`key:\`' docs/decisions/`, not via this file. This table
records only the **non-trivial** mappings produced during the migration: splits, supersessions, and
prose-only reversal notes. It is not, and was never meant to be, a 1:1 index of all 108 records.
| Legacy heading | Key | Status | Location |
| --- | --- | --- | --- |
@@ -13,6 +17,8 @@ the running legacy-unmigrated count; it must trend to 0 over the arc, not drop t
| 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3) — heading kept identical to the pre-split original (only the body/metadata were split) so the lifecycle validator's relocation check matches it | `docs.append-only-guard` | superseded (`superseded-by: docs.decision-lifecycle@2026-07-21`) | `docs/decisions/archive/release-ci-governance.md` |
| 2026-07-12 — Root-screenshot guard: pre-commit refuses root-level `*.png` (#303 H3) — split from the original H9/H3 bundle | `ci.root-screenshot-guard` | active | `docs/decisions/release-ci-governance.md` |
| 2026-07-21 — Decision records carry a lifecycle schema, validated by a script; append-only-by-diff is retired (#521) | `docs.decision-lifecycle` | active (`supersedes: docs.append-only-guard@2026-07-12`) | `docs/decisions.md` |
| 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file | `docs.queue-state-gitea-tracker` | superseded (`superseded-by: startup.parallel-orientation@2026-07-21`) | `docs/decisions/archive/startup.md` |
| 2026-07-21 — Parallel orientation + selection is the startup protocol; #237 retired (#520) | `startup.parallel-orientation` | active (`supersedes: docs.queue-state-gitea-tracker@2026-07-11`) | `docs/decisions.md` |
## Migration conventions
@@ -47,12 +53,10 @@ sibling.
and is very much active, so this is a straightforward **supersession of an unnamed predecessor**, not
a retirement — there's no case to make for `retired` here since the surface it measures is live.
## Still open (tracked for later migration passes, not this PR)
## Still open
- `## 2026-07-11 — Queue state lives in the pinned Gitea tracker (#237), not in the handoff file` is
reversed by #520, not #521 — per the task-5 brief its supersession lands in PR2/Task 8, not here.
- The remaining ~90 headings in `docs/decisions.md` and the other three `docs/decisions/*.md` topic
files (`optimistic-concurrency.md`, `api-auth-security.md`, `spa-modularization.md`, and the rest of
`release-ci-governance.md`) are unmigrated (`status: legacy-unmigrated`, no metadata block). They are
valid under the schema as-is (the validator treats an unmigrated H2 as a migration target, not an
error) and migrate incrementally as each is next touched, per the arc's design.
- **DONE**: `docs.queue-state-gitea-tracker` (#237) — superseded by `startup.parallel-orientation`
(2026-07-21, #520), archived at `docs/decisions/archive/startup.md`. See the table above.
- Nothing else is open from this migration — the corpus is fully migrated (0 legacy-unmigrated). Future
new decisions are simply added directly in the lifecycle schema; there is no further migration pass
to track here.
+28
View File
@@ -22,6 +22,10 @@ cross-editor ETag rotation). Refs #197.
---
## 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`
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
@@ -55,6 +59,10 @@ the mechanics live in `api-conventions.md` §7a. Decisions frozen here:
"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:
@@ -97,6 +105,10 @@ force-write on conflict: adopt the stored token, retry, never revert the concurr
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
@@ -121,6 +133,10 @@ whatever previously held its new slot. Contract + rules in **api-conventions §7
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*
@@ -169,6 +185,10 @@ bump *through the handler* via a pre-tracked context (`RootWriterForceVersionTes
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
@@ -223,6 +243,10 @@ refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate**
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
@@ -257,6 +281,10 @@ the change only makes a hand-written/tooling `If-Match` get the RFC-correct stat
§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
+28
View File
@@ -27,6 +27,10 @@ rule a derivation/hook, not prose to remember (#303 methodology review).
---
## 2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)
`key: release.api-contract-ci-gate` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** OpenAPI drift gate, blocking CI, api-docs job · paths: `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` · issues: #303 (H4/H5)
**Mechanics:** `docs/api-conventions.md` §5; `scripts/update-openapi.sh`; `.gitea/workflows` `api-docs` job
**A PR whose diff touches `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship the
regenerated OpenAPI artifacts in that same diff, enforced by a blocking `api-docs` CI job.** It rebuilds
@@ -42,6 +46,10 @@ local-only: `update-openapi.sh` runs `dotnet-getdocument` against the already-bu
immune (no `bin/` on a fresh checkout).
## 2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)
`key: release.done-when-merge-consent` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** Done-when checklist, merge consent, state-derived gate · paths: `.claude/settings.json`, `.husky/pre-push` · issues: #303 (H6)
**Mechanics:** `pretooluse-merge-consent.sh`; `prepush-donewhen.sh`; CLAUDE.md → Task Completion Protocol
**An issue's `## Done-when` checklist (in the issue body) is the machine-readable source of truth for whether
its PR may merge; consent is *derived*, not asserted.** Rationale: DONE/OPEN status used to live in
@@ -75,6 +83,10 @@ a forced `git add -f` still can't land one. Nested `*.png` (real assets) are una
both: the methodology review (#303) — make the process rules derivations/hooks, not prose to remember.
## 2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)
`key: release.review-verdict-gate` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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).
**Signals:** review-verdict, head-sha match, stale-review prevention · paths: `.claude/settings.json` · issues: #303 (H10), #242
**Mechanics:** `pretooluse-merge-consent.sh`; CLAUDE.md → Task Completion Protocol (H10 convention)
**A PR may not merge until a `Review-verdict:` comment on it references the PR's CURRENT head sha** —
so the *latest* commit is proven-reviewed, not a stale earlier diff. This mechanizes the ersatztv#242
@@ -114,6 +126,10 @@ real merge path; docs-only PRs remain exempt via H6's file-set exemption). Ratio
Wave-1/2/3 hook set: make the process rule a derivation/hook, not prose to remember (#303).
## 2026-07-12 — Formatting-as-you-touch, enforced; rebase-not-merge for PR branches (#311 H11 + format CI)
`key: release.format-as-you-touch-rebase` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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`.
**Signals:** format-as-you-touch, rebase not merge, BOM backlog · paths: `.husky/pre-push`, `.claude/hooks/prepush-rebase-check.sh` · issues: #311 (H11), #309, #310, #269, #312
**Mechanics:** `docs/contributing.md` §7; `.claude/hooks/prepush-rebase-check.sh`; `npm run check:api`
Two coupled process decisions, prompted when a stale docs branch *merged main in*, dragged ~17
legacy-BOM `.cs` files it never touched into the merge commit, and the pre-commit `dotnet format`
@@ -145,6 +161,10 @@ Rationale, as with the whole hook program: make the process rule a derivation/ho
remember (#303 methodology review). Tracked: #311; sibling #312 (H12 issue-qualification audit).
## 2026-07-12 — Merge-consent gate auto-grants when satisfied (no redundant prompt); state IS the consent (#314)
`key: release.merge-consent-autogrant` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** auto-grant, permissionDecision allow, merge consent · paths: `.claude/settings.json` · issues: #314, #303, #317
**Mechanics:** `pretooluse-merge-consent.sh`; CLAUDE.md → Task Completion Protocol
Completes the #303 H6/H10 intent — *derive merge-consent from state* — which the original hook only
half-delivered. The rule the user set: **merge permission is auto-granted for the session when the
@@ -178,6 +198,10 @@ warranted only when the gate **asks** (state not derivable). This supersedes the
consent in-conversation per session" phrasing in the kickoff HARD CONSTRAINTS (updated in the same PR).
## 2026-07-12 — Release path rehearses migrations on a prod-DB copy before promoting (#315)
`key: release.migration-rehearsal-prodcopy` · `status: active` · `since: 2026-07-12` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** migration rehearsal, prod-copy smoke test, DatabaseMigratorService · paths: `scripts/migration-smoke.sh` · issues: #315
**Mechanics:** `docs/ci-cd.md` → Migration-on-prod-copy smoke; `scripts/migration-smoke.sh`
The CI `migrations` job proves a migration is well-formed against a **fresh, empty** DB (model-drift +
apply-to-fresh, per provider). That is necessary but not sufficient: it never exercises the migration —
@@ -197,6 +221,10 @@ Rationale: data-plane rigor — catch a bad migration on a disposable copy, not
See `docs/ci-cd.md` → Migration-on-prod-copy smoke. Cross-repo wiring tracked in server-management.
## 2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)
`key: release.promotion-floating-prod` · `status: active` · `since: 2026-07-13` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** floating prod tag, exact-image scan, manual promotion · paths: Komodo `DeployStack` · issues: #335, server-management#585, server-management#589
**Mechanics:** `docs/ci-cd.md` → Versioning & releases
Prod keeps the floating `:prod` image reference; PR #191's workflow-driven immutable pin bump is
closed as superseded. server-management#585 proved that automatic and manual promotions share
+192
View File
@@ -0,0 +1,192 @@
# Retrieval-eval question bank (decision-lifecycle corpus, #521)
This bank measures **correct active-record selection + citation**, not mere semantic proximity.
The migrated decision corpus (`docs/decisions.md` + `docs/decisions/*.md`, catalog at
`docs/decisions/README.md`, archive at `docs/decisions/archive/`) intentionally keeps a superseded
or retired record's prose *verbatim* next to a live `superseded-by`/`supersedes` pointer. A retrieval
approach that matches on wording alone can walk straight past that pointer into the archived record
and return a decision this project no longer follows. For each question below, the only correct
answer is the cited active `key` (status: `active`, in a file `active_files()` globs) — landing on
an archived/superseded key, or missing a key that already answers the question (and thus proposing a
reimplementation), is scored a **miss**, regardless of how relevant the returned text reads.
This bank is run as the **Task 10 cold-agent retrieval sim**: a fresh agent is given the catalog
(`docs/decisions/README.md`) plus the active `docs/decisions/*.md` wing and asked each question with
no other context, then graded on whether it names the expected `key` and cites the right file.
Verification: every `key` cited below was confirmed present and `status: active` via
`PYTHONPATH=. python3 -c "import scripts.decisions_lib as dl; [print(r.key, r.status) for f in dl.active_files() for r in dl.parse_file(f) if r.key]"`
against this worktree's corpus on 2026-07-21.
---
## 1. Paraphrased task → decision discovery
**Q1.** "I'm adding a new REST endpoint for a feature that touches three tables — do I need to stand
up a service/business-logic layer, or can the controller call MediatR directly?"
- **Expected key:** `api.mediatr-passthrough`
- **File:** `docs/decisions.md`
- **Why:** The rule is literally "thin controllers over existing MediatR handlers, no new
service/business-logic layer" — answer is NO, don't add one. A naive search might instead surface
`mcp.server-foundation` (also MediatR-adjacent, wrong layer) or nothing at all if it only matches
on "REST endpoint" rather than the layering question.
**Q2.** "Should the playout-build-finished notification go out over a websocket/SignalR push channel
so the SPA doesn't have to poll?"
- **Expected key:** `api.async-op-contract`
- **File:** `docs/decisions.md`
- **Why:** The active rule explicitly rejects a live push channel in favor of an `isLocked`
HTTP-observable flag on list/detail GETs as the substitute. A naive search keying on "poll" alone
might return `spa.playback-troubleshoot-poll` (a different, narrower polling decision for the
troubleshoot screen) instead of the general async-op contract that actually governs new
queue-triggering endpoints.
## 2. Exact code/path lookup
**Q3.** "Where do new API response DTOs live, and what nullable pragma convention do they follow?"
- **Expected key:** `api.response-dtos`
- **File:** `docs/decisions.md`
- **Why:** Direct hit — rule gives the exact path pattern
(`ErsatzTV.Core/Api/<Domain>/*ResponseModel.cs`) and the file-scoped `#nullable enable` convention.
No plausible superseded alternative exists for this key.
**Q4.** "Where does the `EntityLocker` implementation live, and how does it avoid a torn
check-then-set race on its lock flags?"
- **Expected key:** `locking.entitylocker-atomic-flags`
- **File:** `docs/decisions.md`
- **Why:** Rule + Mechanics cite `ErsatzTV.Infrastructure/Locking/EntityLocker.cs` and the
`Interlocked.CompareExchange`-guarded atomic-flag fix directly; no other record touches this file.
## 3. Active-vs-superseded (selecting the archived record here is a FAILURE)
**Q5** *(verbatim, coordinated with server-management#642).* "Is #390's small-lane CI move current?"
- **Expected answer:** **No** — superseded by `ci.runner-placement`.
- **Expected key:** `ci.runner-placement`
- **File:** `docs/decisions.md`
- **Why:** #390 moved `docker build` jobs onto the `small` runner lane to dodge queue time; #406
reversed that (worst-case memory, not runtime, was the real constraint) and the durable rule now
lives under `ci.runner-placement` (and its sibling `ci.small-lane-git-only`, which defines
`small` by job *kind*, not usual runtime). #390 itself was never given its own `##` record (it's
prose-only, referenced inside `ci.runner-placement`'s own Signals line) — answering as if #390's
move still holds, or citing #390 as a standalone current decision, is the failure mode this
question targets.
**Q6.** "Is `docs/decisions.md` still kept append-only by a line-deletion-diff CI/hook guard?"
- **Expected answer:** **No** — that mechanism is superseded.
- **Expected key:** `docs.decision-lifecycle`
- **File:** `docs/decisions.md`
- **Superseded record a naive search might return instead:** `docs.append-only-guard`
(`docs/decisions/archive/release-ci-governance.md`, `status: superseded`,
`superseded-by: docs.decision-lifecycle@2026-07-21`)
- **Why:** `docs.append-only-guard`'s prose (numstat-deleted-count hook + CI job) still reads as a
perfectly good, on-topic answer to "is decisions.md append-only" — that's exactly the trap: the
*mechanism* it describes was replaced by the lifecycle-schema validator
(`scripts/decisions_validate.py`) that this migration introduced. Selecting the archived record
instead of following its `superseded-by` pointer to `docs.decision-lifecycle` is the scored
failure.
**Q6b.** "What is the current source of live queue state at session start?"
- **Expected answer:** Two concurrent tracks — Orientation (docs/README.md task-signal map → the
active decisions catalog) and, only when no issue is named, Selection
(`scripts/select-queue.sh N`, a deterministic query over live Gitea state). Selecting the archived
`docs.queue-state-gitea-tracker` ("queue state lives in pinned tracker issue #237") is a **FAILURE**
— #237 closed 2026-07-13 and is now a single archival breadcrumb that MUST NOT be read for live
state.
- **Expected key:** `startup.parallel-orientation`
- **File:** `docs/decisions.md`
- **Superseded record a naive search might return instead:** `docs.queue-state-gitea-tracker`
(`docs/decisions/archive/startup.md`, `status: superseded`,
`superseded-by: startup.parallel-orientation@2026-07-21`)
- **Why:** `docs.queue-state-gitea-tracker`'s prose ("Pinned tracker issue #237 holds the goal +
ordered arc") still reads as a plausible, on-topic answer — that's the trap: the arc completed and
#237 closed, so `scripts/select-queue.sh` (2026-07-19) replaced the *mechanical* selection logic
with live Gitea queries, and #520 formalized the two-track protocol that retires #237 entirely.
Following `docs.queue-state-gitea-tracker`'s own `superseded-by` pointer to
`startup.parallel-orientation` (2026-07-21) rather than citing the archived record as current is
the scored behavior.
## 4. Retired feature
No `status: retired` record exists yet anywhere in the migrated corpus (confirmed via
`grep -rn "status: retired" docs/decisions.md docs/decisions/*.md docs/decisions/archive/*.md` —
zero hits). Using the one available superseded record as the closest analog instead:
**Q7.** "Has the Husky `commit-msg` decisions-guard hook (the one that blocks any line-deletion diff
to `decisions.md`) been removed now that decisions carry a lifecycle schema?"
- **Expected answer:** Superseded, not simply "removed" — the append-only-by-diff mechanism is
replaced by `scripts/decisions_validate.py`'s lifecycle-field checks; the sibling H3
root-screenshot guard from the same original record was split out and is **still active** today.
- **Expected key:** `docs.decision-lifecycle` (supersedes `docs.append-only-guard`); sibling active
key `ci.root-screenshot-guard` (`docs/decisions/release-ci-governance.md`) must NOT be reported as
superseded — it's a distinct, still-live record split from the same legacy heading.
- **Why:** Tests that an agent doesn't over-generalize "the old #303 H9/H3 heading was archived" into
wrongly retiring the H3 half too — the migration-map explicitly documents the split.
## 5. Rationale / rejected-alternative
**Q8.** "Why doesn't `EntityLocker` use owner tokens or lease objects instead of plain unlock calls?"
- **Expected key:** `locking.entitylocker-atomic-flags`
- **File:** `docs/decisions.md`
- **Why:** The record's body explicitly documents the single-owner-release discipline as the chosen
design and that `Unlock*` on an already-unlocked slot returns `false` + logs a Warning rather than
throwing — the rejected alternative (owner tokens/leases) is named in the Rule line itself.
**Q9.** "Why did the project reject a CI build-once shared-compile-artifact approach?"
- **Expected key:** `ci.build-once-rejected`
- **File:** `docs/decisions.md`
- **Why:** Rule states it plainly: measured and rejected for a 40-85% wall-clock regression, keeping
the #420 cross-run tree-identity skip instead. A search for "CI build speed" alone might surface
`ci.docs-only-skip-steps` or `ci.peak-anon-measurement` (both real, both wrong for "why was
build-once rejected").
## 6. Convention already implemented — do NOT reimplement
**Q10.** "We need seasonal/holiday scheduling (different programming around Christmas, say) — should
I design a new date-conditional scheduling feature?"
- **Expected key:** `sched.seasonal-scheduling-existing`
- **File:** `docs/decisions.md`
- **Why:** Already ships via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block
`PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate`; #73 was closed as
already-implemented with a docs-only recipe added. Building a new feature here duplicates existing,
shipped functionality — the correct answer is "use alternate schedules," not a design doc.
**Q11.** "Should I add a way to pad content out to the next clock boundary (e.g. keep a channel's
7:00 PM start exact) per channel?"
- **Expected key:** `sched.clock-padding-existing`
- **File:** `docs/decisions.md`
- **Why:** Already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and
`pad_to_next`/`pad_until` (Sequential/YAML); #77 was closed as verified+documented, not built new.
Only the one-click per-channel UI toggle is deferred (behind the #388 design-system epic) — the
underlying capability is not missing.
**Q12.** "Do we need to build a fair-share / weighted rotation mode for multi-collections, or does
`ShuffleInOrder` already cover that?"
- **Expected key:** `sched.weighted-shuffle`
- **File:** `docs/decisions.md`
- **Why:** This one cuts the other way on purpose — `ShuffleInOrder` only anti-clumps (its padding
spacers emit nothing), so it does NOT already cover fair-share; the correct answer is that a new
`PlaybackOrder.WeightedShuffle = 9` order was deliberately added rather than retrofitting
`ShuffleInOrder`. Tests that "already exists, don't rebuild" isn't over-applied to a
superficially-similar existing feature that actually doesn't do the job.
---
## Class coverage summary
| # | Class | Key(s) |
| - | ----- | ---- |
| Q1 | paraphrased-discovery | `api.mediatr-passthrough` |
| Q2 | paraphrased-discovery | `api.async-op-contract` |
| Q3 | exact-lookup | `api.response-dtos` |
| Q4 | exact-lookup | `locking.entitylocker-atomic-flags` |
| Q5 | active-vs-superseded (verbatim #390) | `ci.runner-placement` |
| Q6 | active-vs-superseded | `docs.decision-lifecycle` (vs archived `docs.append-only-guard`) |
| Q6b | active-vs-superseded | `startup.parallel-orientation` (vs archived `docs.queue-state-gitea-tracker`) |
| Q7 | retired-feature (no `retired` record exists; superseded used as analog) | `docs.decision-lifecycle` / `ci.root-screenshot-guard` (must stay active) |
| Q8 | rationale/rejected-alternative | `locking.entitylocker-atomic-flags` |
| Q9 | rationale/rejected-alternative | `ci.build-once-rejected` |
| Q10 | convention-already-implemented | `sched.seasonal-scheduling-existing` |
| Q11 | convention-already-implemented | `sched.clock-padding-existing` |
| Q12 | convention-already-implemented (negative control) | `sched.weighted-shuffle` |
13 scored questions (Q1–Q12 plus Q6b) across all six required classes.
+12
View File
@@ -18,6 +18,10 @@ Issue trail: epic #243 — phase 1 #244 (Channels), phase 2 #245 (Playouts), pha
---
## 2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)
`key: spa.channels-screen-extraction` · `status: active` · `since: 2026-07-11` · `supersedes: none` · `superseded-by: none`
**Rule:** 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`.
**Signals:** screen extraction, single-file screen, no helper dir · paths: `web/src/screens/ChannelsScreen.tsx` · issues: #244, #243, #238
**Mechanics:** `docs/spa-conventions.md` §6
First bounded extraction under the App.tsx modularization epic (#243): the Channels domain moved
verbatim out of `web/src/App.tsx` into `web/src/screens/ChannelsScreen.tsx` (zero-prop, self-sufficient,
@@ -44,6 +48,10 @@ dispatch is #238's owned bug and out of scope for a behavior-preserving extracti
is deferred to #247 (epic phase 4).
## 2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)
`key: spa.playouts-screen-extraction` · `status: active` · `since: 2026-07-14` · `supersedes: none` · `superseded-by: none`
**Rule:** 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.
**Signals:** screen extraction, route wrapper ownership, sub-path routing · paths: `web/src/screens/PlayoutsScreen.tsx` · issues: #245, #243
**Mechanics:** `docs/spa-conventions.md` §2
Second bounded extraction under the App.tsx modularization epic (#243): the Playouts domain moved from
`web/src/App.tsx` into `web/src/screens/PlayoutsScreen.tsx`, including its loading/error/empty states,
@@ -64,6 +72,10 @@ zero-playout Add Playout affordance, lock/409 handling, refresh/poll ownership,
dialog flows. Refs #245 #243.
## 2026-07-15 — App shell/routing extraction + explicit primary-action ownership (#247)
`key: spa.app-shell-extraction` · `status: active` · `since: 2026-07-15` · `supersedes: none` · `superseded-by: none`
**Rule:** `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.
**Signals:** app shell extraction, route identity, primary-action ownership · paths: `web/src/app/routes.tsx`, `web/src/app/AppShell.tsx`, `web/src/app/ScreenContent.tsx` · issues: #247, #243, #238, #230
**Mechanics:** `docs/spa-conventions.md` §10; `App.test.tsx`
Final phase of the App.tsx modularization epic (#243). `web/src/App.tsx` is now only the composition
root: it owns `activeRoute`, `currentPathRef`, the `navigate`/`popstate` pair that consults the dirty
+197 -185
View File
@@ -1,22 +1,21 @@
# ChicoryTV issue-queue handoff (client-neutral kickoff + workflow lore)
> **PROTOCOL CHANGED 2026-07-11** (decisions.md entry of same date). Queue state lives in the
> **pinned Gitea tracker [ersatztv#237](http://192.168.1.95:3000/timothy/ersatztv/issues/237)**,
> not in this file. Do **NOT** write session state, queue order, or next-session prompts here —
> sessions ending under the old protocol should append their session comment to #237 instead.
> This file holds only stable operating guidance: the standing kickoff prompt and the workflow lore.
> Historical per-session state: `git log` of this file (last state-bearing revision: 8b77d5e7).
Static kickoff prompt + workflow lore, pruned (not appended) as protocols change — git history has
the rest. **Queue state is live Gitea state**, resolved fresh every session by
`scripts/select-queue.sh`; never written here as a snapshot, never read from stale prose or a
closed tracker.
<!-- archival:237 --> ersatztv#237 was the pinned pickup tracker through 2026-07-21; it is CLOSED
and now purely historical (superseded by `startup.parallel-orientation` in `docs/decisions.md`; full
history in `docs/decisions/archive/startup.md`). Do not read it for current queue state.
---
## Current phase — maintenance / backlog mode (terminal transition, 2026-07-13)
The ChicoryTV rewrite + go-live arc is **COMPLETE**: the pinned arc tracker
[ersatztv#237](http://192.168.1.95:3000/timothy/ersatztv/issues/237) is **CLOSED** and all six
numbered arc items shipped. The project is now in **maintenance / backlog mode**. This is a
*terminal* fact (the arc will not re-open), so it belongs in this standing file — unlike any
"current frontier issue", which must never be written here (read it live; see the queue-drift
lesson at the end of this file).
The ChicoryTV rewrite + go-live arc is **COMPLETE** and closed out. The project is now in
**maintenance / backlog mode**. This is a *terminal* fact (the arc will not re-open), so it belongs
in this standing file — unlike any "current frontier issue", which must never be written here (read
it live; see the queue-drift lesson in the archived section below).
So a fresh session stops re-discovering this every time:
- The **arc / gate / open-`review` tiers are normally EMPTY now.** An empty arc frontier is the
@@ -100,43 +99,44 @@ already-collected bounded evidence for explicitly tool-free synthesis or report
silently expanding orchestrator reconnaissance. Global Codex hook enforcement is tracked in
`timothy/server-management#592`; the wider Claude-hook port is tracked in `timothy/server-management#593`.
**When the user has not named an issue, queue selection is mechanical fast/small work, never orchestrator
work.** FIRST run the deterministic selector — `ETV_GITEA_BASICAUTH=user:pass scripts/select-queue.sh [N]`
— which does the parts a cheap model kept getting wrong IN CODE: it excludes `in-progress`/`parked`/PRs,
resolves `GET /issues/{n}/dependencies` on every candidate (dropping any with an OPEN blocker), tiers by
LOCAL `.milestone.state`/`review`/`priority:` filters (never the server `?milestones=` name filter, which
no-ops on `:`/`+`), and orders by (tier, priority, issue#). **Its DEPS/tiering/ordering are deterministic —
trust them, do not re-derive them by hand.** It also raises two JUDGMENT flags it deliberately does not
decide — `CLAIM?` (a claim can precede the label) and `UMBRELLA?` (an epic whose children are the real
pickups); the orchestrator (or a cheap model) resolves ONLY those by reading the flagged issue's
comments/body. The script does not rank the arc tier (arc order is #237 prose, currently CLOSED) — read
#237 if an arc re-opens. If the script is unavailable (no creds / Gitea down), fall back to the cheap-model
path below. Dispatch exactly one selector on the cheapest suitable model at `low` effort. Give it #237's
body and last ~6 comments, then have it query live candidates as a cascade in this exact tier order:
**arc → OPEN issues assigned to OPEN milestones → open `review` → unmilestoned/unreviewed
`priority: high` → `priority: medium` → `priority: low`**. Every candidate row represents an issue
(or eligible reviewer audit): milestone records provide tier metadata and are NEVER pickup candidates.
An OPEN milestone with zero eligible OPEN issues contributes zero candidates. Deduplicate candidates,
apply the live eligibility exclusions at each tier (including automatic exclusion of `parked` issues and any issue with an open `blocked-by` dependency),
and stop as soon as five eligible issues have been accumulated (or all tiers are exhausted). It returns
only that ranked top-five shortlist, with one-line rationales and live-state evidence; a small result
packet is the desired behavior, but an empty or undersized higher tier must fall through to the next tier.
The orchestrator receives only that compact packet, then performs a focused live recheck of the winner
before claiming. If the cheap worker lacks a repository-scoped issue-list tool, it must immediately return
the literal result `TOOL_LIMITATION`; it must not rank issue IDs mentioned in tracker prose, turn milestone
records into candidates, or infer that any tier is empty. The orchestrator then performs only the mechanical
tier queries (repository + state + milestone/label), passes at most the bounded raw ISSUE rows back to the
cheap worker, and leaves all filtering/ranking to that worker. Never substitute an owner-wide/global search
or treat that tool limitation as an empty tier.
Do not load implementation docs or issue bodies into the selector. If the client cannot route a
cheaper subagent, run the selector in a separate low-cost session before starting or resuming the orchestrator
and pass in its packet. **Do not fall back to inline sorting or an equally expensive selector.** If no cheaper
route or session is available, pause and request the selector packet rather than consuming orchestrator tokens
on queue ranking. If the user names an issue, skip selection and only verify that issue's live claimability;
this explicit user choice is the sole path by which a `parked` issue may be worked.
## Two concurrent tracks at session start
FIRST read `AGENTS.md` and `CLAUDE.md` when present, then `docs/README.md`, the convention docs it
indexes, and the Lessons below. Apply both client instruction files; where they differ, follow the
A fresh session runs **two independent tracks** — neither blocks the other, and both are cheap/mechanical,
never orchestrator-tier work:
- **Track A — Orientation** (always, regardless of whether an issue is named): read `AGENTS.md` and
`CLAUDE.md` when present, then `docs/README.md`'s task-signal map, and the active decisions catalog
`docs/decisions/README.md`. This is what replaces re-deriving conventions from source or from a
pinned tracker's prose — see "Knowledge retrieval" below for the full catalog-first / breadcrumb-rule
contract.
- **Track B — Selection** (only when the user has NOT named an issue): run the deterministic selector,
`ETV_GITEA_BASICAUTH=user:pass scripts/select-queue.sh [N]`. It does the mechanical parts a cheap
model used to get wrong IN CODE: excludes `in-progress`/`parked`/PRs, resolves
`GET /issues/{n}/dependencies` on every candidate (dropping any with an OPEN blocker), tiers by
LOCAL `.milestone.state`/`review`/`priority:` filters (never the server `?milestones=` name filter,
which no-ops on `:`/`+`), and orders by (tier, priority, issue#). **Its DEPS/tiering/ordering are
deterministic — trust them, do not re-derive them by hand** (see the archived section below for why
that used to be necessary and isn't anymore). It also raises two JUDGMENT flags it deliberately does
not decide — `CLAIM?` (a claim can precede the label) and `UMBRELLA?` (an epic whose children are the
real pickups) — resolve ONLY those by reading the flagged issue's comments/body. The script does not
rank an arc tier (there is no active arc right now — maintenance/backlog mode, see above); if a new
arc/milestone is ever established, add it as a script tier rather than reverting to prose-derived
ranking. If the script is unavailable (no creds / Gitea down), it fails open (prints a notice, exits
0) — in that case fall back to a single low-cost model session querying live tiers directly
(OPEN-milestone → `review` → `priority: high/medium/low`), never to inline sorting by the
orchestrator.
**If the user names an issue, skip Track B entirely** and go straight to focused retrieval (below)
plus a live claimability check on that issue; this explicit user choice is the sole path by which a
`parked` issue may be worked.
Dispatch exactly one selector on the cheapest suitable model at `low` effort when the client can route
a cheaper subagent; otherwise run it in a separate low-cost session before starting or resuming the
orchestrator and pass in its packet. **Do not fall back to inline sorting or an equally expensive
selector.** If no cheaper route or session is available, pause and request the selector packet rather
than consuming orchestrator tokens on queue ranking.
FIRST read `AGENTS.md` and `CLAUDE.md` when present, then `docs/README.md` and the sections its
task-signal map points to for your task, plus the Lessons below. Apply both client instruction files; where they differ, follow the
stricter safety/completion requirement unless a higher-priority instruction resolves the conflict.
FRONTIER ESCALATION — these moments go to the strongest available reasoning model. In Claude Code,
@@ -154,75 +154,101 @@ the client cannot route the escalation. Do not block merely because one brand-sp
Everything else (claiming, worktrees, dispatching implementers, CI monitoring, protocol
bookkeeping, routine merges of green reviewed PRs with user consent) stays at your level.
## Knowledge retrieval (MemPalace + catalog + Gitea — the #642 seam)
**MemPalace = candidate discovery only**, retrieved at a bounded `k`; every passage **verified
against its cited Markdown/Gitea source** before use. **Default wing = `ErsatzTV-Decisions`** (active
`docs/decisions.md` + `docs/decisions/*.md` + catalog). History wings `ErsatzTV-Decisions-Archive`
(`docs/decisions/archive/**`) and `Gitea-ErsatzTV` (issues/comments incl. closing records) are touched
**only** when the question is explicitly "what did the rule *used to be*." "What is the current rule
for X" never touches the history wings.
The four load-bearing orientation bullets — carry these **VERBATIM** wherever this contract is
referenced (`docs/README.md` too):
1. **Current conventions/decisions → catalog-first.** Start at `docs/decisions/README.md`; discover
via the `ErsatzTV-Decisions` wing (active) / `ErsatzTV-Decisions-Archive` (superseded/retired).
**Resolve by topic/key, never by chasing a file path.**
2. **Issue history → evidence, not authority.** The `Gitea-ErsatzTV` wing is historical narrative
that may be stale; it never overrides current Markdown.
3. **The breadcrumb rule (the crux behavior change).** A file path named inside a *historical issue
comment* (e.g. "grep `docs/decisions.md` 2026-07-17", "see …") is a **breadcrumb, not a live
pointer.** Find the current rule via the catalog / active wing **by concept**; do not treat the
named path as current. (Why it's safe: still-current → in the active wing, breadcrumb resolves;
superseded → the active wing returns the *successor* and a literal follow lands on a record that
announces its own `status: superseded`; retired → the active wing returns nothing, which is itself
the signal. The validator-enforced move-to-`archive/` is what prevents the catastrophic "superseded
rule read as current" case.)
4. **Fallback when MemPalace is stale/down:** `docs/decisions/README.md` catalog, then
`` rg '^`key: <dotted.key>`' docs/decisions/ ``. MemPalace is never authority nor sole fallback.
**Graceful degradation (calibration, not a correctness cliff):** even an under-oriented agent that
literally greps `docs/decisions.md` post-#521 gets valid-but-*incomplete* results (misses topic files
+ archived records), never *wrong* ones — that file holds only active records. So this is a
completeness/latency risk during rollout, not a correctness cliff.
**Altitude / precedence:** `docs/decisions.md` (+ topic files) is **normative/current** ("the rule is
X") — the `ErsatzTV-Decisions` wing, authority for "what is the current rule." Gitea issues are
**evidentiary/historical** ("we worked X on date D; spec, discussion, outcome") — the `Gitea-ErsatzTV`
wing, authority for "how was this handled / what was the context." A decision record points to its
issue; the issue carries the provenance the record compresses. Same fact, two altitudes — canonical
Markdown wins for "current."
**Issues in MemPalace are for DISCOVERY, not queue state.** Encounter a problem → search MemPalace →
find relevant docs AND relevant open issue(s): this **avoids filing duplicates** and **surfaces
bundles** (related issues to pick up together). But a MemPalace issue hit is a *candidate*:
open/closed/claimed/blocked/priority flip constantly and MemPalace lags (seconds–1h, up to a week if
the webhook is down), so a hit may be a since-closed issue shown open, or miss a just-filed one.
**Always re-confirm live state in Gitea (`scripts/select-queue.sh`) before acting** — never treat a
MemPalace issue hit as current queue state.
**What to mine per issue:** open issue → the BODY (dedup + bundle-clustering; no closing record yet).
Closed issue → body + the `## Closing record` (how it was resolved). Ephemeral comments (claim /
progress / `Review-verdict:`) → skip or de-weight both ways — process exhaust, not the knowledge
store. The `## Closing record` is the one structured per-issue summary meant for retrieval + export.
**Staleness bounds** (so agents know when to distrust a hit): webhook re-mine in seconds; hourly
reconcile; weekly full sweep catches file moves/deletes. Worst case before a supersession takes effect
in retrieval: ~1h, or ~1 week if the webhook is down and only the move happened. When in doubt, fall
back to exact search (bullet 4 above).
**Never** derive live queue state from MemPalace, #237, or historical comments.
Then work the queue:
1. Unless the user named an issue, dispatch or obtain the mandatory low-cost selector packet. The
**selector**, not the orchestrator, reads the pinned tracker **ersatztv#237** — body = goal + ordered
arc + session protocol — and its **last ~6 session comments** (newest-first; the full comment
payload is large, so stop at ~6).
**SOURCE OF TRUTH = live Gitea issue state, NEVER the prose.** The arc body carries ORDER + goal
only; a session comment's "Recommended next" is a forward *guess* written before the next session
acted. Both go stale the instant an item closes (especially under parallel sessions narrating each
other's work as "the gate that unblocks X"). So derive the candidate set from LIVE state, not from
any inline marker or a prior comment's "next": the **current gate = the lowest-numbered OPEN arc
item in #237's arc list**; its open children are the gate cluster (query them by the `review`
label). Cross-check every arc / "recommended" item's real open/closed state (issue **and**
milestone) before trusting it — do NOT hardcode which issue is the frontier; read it. Candidate
discovery is a bounded cascade, not a full inventory: query **arc → OPEN issues assigned to OPEN
milestones → open `review` → unmilestoned/unreviewed `priority: high` → `priority: medium` →
`priority: low`**, carrying eligible unique results forward until the shortlist contains five issues
or all tiers are exhausted. Milestone records are tier metadata, NEVER pickup candidates; an OPEN
milestone with zero eligible OPEN issues contributes zero candidates.
This priority-label fall-through is mandatory: an empty arc/milestone/review pool is never evidence
that the queue is empty. Each priority tier queries all OPEN `timothy/ersatztv` issues carrying that
label, not only IDs mentioned in #237 or recent comments, and excludes pull requests. Treat an
umbrella/epic as a container rather than a pickup when #237 names its eligible children. In the
review tier, ALSO list
open `ersatztv`-labeled issues in **timothy/adversarial-reviewer** — unclaimed audits there are
pickup candidates too (read-only, parallel-safe; see the tracker's "Pending adversarial reviews"
section). Reviewer audits are claimed by comment: a claim remains active until a later comment
explicitly releases or abandons it, and a posted audit/review deliverable is completed work even
when its issue stays open for implementer replies. If the Gitea MCP is down, hit the REST API
directly (use credentials supplied by the environment or your global client instructions; never
print them):
`curl -u <user>:<pass> http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/issues/237`.
**The authoritative pickup + ranking protocol lives in #237's "Session protocol" section — this is a summary; if the two ever disagree, #237 wins.**
2. The selector returns the highest-ranked eligible candidate plus up to four fallbacks. Apply this
strict tier order: **(1)** open arc items in #237 arc order; **(2)** OPEN issues assigned to an OPEN
milestone;
**(3)** OPEN `review`-labeled issues and eligible reviewer-repo audits; **(4)** remaining
unmilestoned/unreviewed `priority: high` issues; **(5)** `priority: medium`; **(6)** `priority: low`.
Within milestone and review tiers, order by `priority: high` > `medium` > `low`; for otherwise equal
candidates preserve #237's explicit eligible order, then use lowest issue number. At every tier exclude
pull requests, CLOSED issues, `in-progress` claims, `parked` issues, issues blocked by a CLOSED milestone,
**any issue with an open `blocked-by` dependency** (`GET /issues/{n}/dependencies`; Gitea auto-clears the
block when the blocker closes — do not pick an issue ahead of its open blocker), containers whose children
are the pickups, and reviewer audits that are already claimed or have a posted deliverable. Note the
2026-07-17 convention: `parked` (no concrete plans) issues are **closed**, not left parked, so `parked`
should rarely appear; if it does, treat it as excluded and surface it to the user.
Query the next tier only while fewer than five eligible unique candidates have been collected; do not
enumerate the remainder after the shortlist is full.
The orchestrator then makes one focused Gitea read to confirm the proposed winner is still OPEN,
unclaimed, and not blocked by a closed milestone; if it changed, check the next supplied fallback.
**An empty arc frontier is not a stopping condition.** If the selector returns any eligible
candidate, claim its top-ranked winner; do not ask the user to choose merely because candidates
belong to different workstreams.
Do not reread the full tracker or comments for selection. If the prose says "recommended next / now
unblocked" but the issue (or its milestone) is already CLOSED, it is done — skip it and fix the
stale line in your session comment. Prose lags live state; live state wins; tier + `priority:` labels
decide order, not the prose. Never invent a fix-size, recency, or perceived-relevance tiebreaker.
(This mirrors #237's Session-protocol ranking — #237 is canonical.)
1. Run the two tracks above (orientation always; selection unless an issue is named).
2. If Track B ran: apply its ranked shortlist. The orchestrator makes one focused live recheck of the
winner (still OPEN, unclaimed, not blocked) before claiming; if it changed, check the next fallback.
**An empty backlog is not a stopping condition** — if the selector returns any eligible candidate,
claim its top-ranked winner; do not ask the user to choose merely because candidates belong to
different workstreams. Never invent a fix-size, recency, or perceived-relevance tiebreaker.
3. **Claim it**: add the `in-progress` label + a "claiming" comment on the issue(s);
reviewer-repo audits are claimed by comment only. Treat that claim as live until a later comment
explicitly releases or abandons it, and exclude audits with a posted deliverable even while the
issue remains open for implementer replies.
4. Read the issue bodies (they carry the task context/evidence) and work the item under the
HARD CONSTRAINTS below.
5. Finish by following the session-end protocol in #237: run the **H12 qualification audit**
5. Finish the session: run the **H12 qualification audit**
(`ETV_GITEA_BASICAUTH=user:pass scripts/issue-qualification-audit.sh`) and add a `priority:`
label to anything it lists (every issue you filed this session included); then ONE session
comment on the tracker (template in the tracker body, incl. triage verdicts for any new issues),
remove your `in-progress` labels, and complete the per-issue Task Completion Protocol from the
applicable `AGENTS.md` / `CLAUDE.md` instructions (including the `done` workflow when required).
label to anything it lists (every issue you filed this session included); post a `## Closing
record` (template below) on each issue you closed or substantially progressed; remove your
`in-progress` labels; and complete the per-issue Task Completion Protocol from the applicable
`AGENTS.md` / `CLAUDE.md` instructions (including the `done` workflow when required).
## Closing record (session-end / issue-close artifact)
Post this as the structured closing comment on any issue you close (or substantially progress) — it
is both the human-readable summary and the per-issue unit MemPalace mines for retrieval:
```markdown
## Closing record
**Outcome:** <what shipped / what didn't; PR link>
**Root cause:** <for bug fixes/incidents — why the problem existed, or "unknown, see follow-up #N">
**Decisions/conventions changed:** <keys added/superseded in docs/decisions.md, or "none">
**Reusable knowledge:** <a fact/gotcha worth surfacing to a future session or MemPalace search>
**Verification:** <tests run, live-E2E, CI status>
**Deferred:** <anything explicitly punted, with a follow-up issue link, or "none">
**Docs updated:** <which docs/*.md files changed in this PR, or "none required and why">
```
HARD CONSTRAINTS:
- Work in worktrees off `origin/main` — ALWAYS, including for a one-line docs commit. `/Users/timothy/ersatztv`
@@ -315,58 +341,19 @@ HARD CONSTRAINTS:
> (+ `api-conventions.md` §7a/b/c for the concurrency/side-effect mechanics, `spa-conventions.md`,
> `e2e-local.md`). Do NOT re-record a code/API/SPA decision here — it will duplicate and drift.
> Pruned 2026-07-12: issue-specific engineering bullets now covered in those docs, and dead
> Blazor-parity process notes, were removed (git history has them).
> Blazor-parity process notes, were removed (git history has them). Pruned 2026-07-21: pre-script
> selector lore and the #237-specific queue-drift narrative moved to the archived section below —
> the mechanism they describe no longer exists.
- READ docs/README.md → the convention docs FIRST; point recon/implementer agents at specific
doc sections. Only recon the task-specific delta.
- **The mechanical selection checks now live in `scripts/select-queue.sh` — run it instead of
re-deriving them by hand (2026-07-19).** The three bullets below are the *evidence* for why the
script exists: a cheap model repeatedly reported blocked issues as `deps:clear`, mis-tiered on the
no-op `?milestones=` filter, and ranked by issue number instead of priority-within-tier — so the
lore grew to "re-derive the whole contested tier every session," which is exactly the tax the
script pays down. The script does the deterministic work in code (dependency exclusion via
- **The mechanical selection checks live in `scripts/select-queue.sh` — run it instead of
re-deriving them by hand.** It does the deterministic work in code (dependency exclusion via
`GET /dependencies`, LOCAL `.milestone.state`/`review`/`priority:` tiering, (tier, priority, issue#)
ordering, `in-progress`/`parked`/PR exclusion) and only *flags* `CLAIM?`/`UMBRELLA?` for judgment.
**Trust its DEPS/tiering/ordering; do the focused recheck only on the flagged rows.** Keep the
evidence bullets below (they explain the failure modes the script encodes), but the *prescription*
is now "run the script," not "hand-re-derive." If you extend the tiers/filters, update the script
first, then these bullets.
- **Gitea milestone-name issue filter silently NO-OPS on names with special chars (2026-07-17,
#77 selection)**: `GET /issues?milestones=<name>` returns the WHOLE open-issue list — not a
filtered set — when the milestone title contains `:` or `+` (e.g. `Scheduling: refactor +
distribution`), because the raw name doesn't round-trip through Gitea's filter. A selector that
trusts that response will mis-tier issues (it made #72 look like a milestone-10 member when it's
unmilestoned, and hid #77's true sibling set). **Robust recipe: fetch all open issues once and
filter LOCALLY on each issue's `.milestone.title`** (`?state=open&type=issues&limit=50`, then
`[i for i in issues if (i['milestone'] or {}).get('title')==NAME]`) — do not rely on the
server-side `milestones=` name filter. If a count looks suspiciously like "all open issues," that
filter silently failed; re-derive membership locally. (Dependencies API is fine: `POST
/issues/{n}/dependencies` with `{"owner","repo","index"}` sets blocked-by; the bare `{index}`
form 201s but may not attach — verify with the GET.)
- **Confirmed again + widened (2026-07-17, #72/#164 selection): treat EVERY mechanical claim in the
selector packet as unverified — not just deps and priority.** That run's dependency data was
correct, yet it still ranked **#383 first while missing that #384 was `in-progress`** (claimed 40
minutes earlier) **and that #383 is that bundle's umbrella** (its body: "Follow-up arc to #69" with
#384–#386 as deferred sub-work). Claiming its #1 pick would have collided head-on with a live
parallel session. The packet does not reliably see: `blocked-by`, priority-within-tier,
**`in-progress` claim state**, or umbrella-vs-child. Before claiming, check the candidate's **last
comments** for a live claim (a claim stays live until explicitly released) and read its **body** for
umbrella framing. An empty-ish tier is normal in maintenance mode — three of the four open
milestone issues that day were legitimately blocked or claimed, which is the expected steady state,
not a reason to relax the checks.
- **The cheap selector's dependency + priority data is NOT trustworthy — re-derive the whole contested tier,
not just the winner (2026-07-17, #73 selection)**: the selector packet reported **#385/#386 as `deps:clear`
when both were blocked** (#385 by open #70; #386 by open #385+#384) — it never called
`GET /issues/{n}/dependencies` — and **missed #383 entirely** (the only `priority: medium` in tier 1),
ranking by issue number and ignoring the priority-within-tier rule. Either error alone produces a bad
pickup: working a blocked issue, or skipping the top-priority one. The kickoff's "orchestrator makes one
focused recheck of the **winner**" is **insufficient** whenever a tier has >1 candidate — a wrong winner is
invisible to a winner-only recheck. **Recipe: fetch all open issues once, filter locally on
`.milestone.title`/labels (the `?milestones=` filter no-ops on `:`/`+` names — see the bullet above; both
current open milestones have them), then run `GET /issues/{n}/dependencies` yourself on every candidate you'd
plausibly claim.** That's ~3 cheap calls and it is the only thing standing between the packet and a wasted
session. Keep delegating selection (it's still not orchestrator work) — just verify its two mechanical
outputs (deps, priority order) rather than its ranking prose.
**Trust its DEPS/tiering/ordering; do the focused recheck only on the flagged rows.** If you extend
the tiers/filters, update the script first, then these bullets. (See the archived section below for
the pre-script failure modes that motivated writing it, if you need the history.)
- **Codex cheap-worker launch (tested 2026-07-14)**: the native `spawn_agent` interface currently
has no model/effort selector, so it provides parallelism but not cost savings. For bounded,
tool-bearing selector/recon work, launch a separate worker with
@@ -437,7 +424,7 @@ HARD CONSTRAINTS:
refs/heads/v4: …` is act refreshing its `/root/.cache/act` action cache and is followed by `Cloned …`
— it is NOISE, not a cause. Grep for the failure marker, not for the word "error".
- **`cancelled` is NOT `failure` — never read a cancel as a CI verdict** (2026-07-17, operator-surfaced).
The operator cancels runs by hand (they're the only one who can — see the batching constraint), and a
The operator cancels runs by hand (they're the only one who can — see the batching constraint above), and a
run-level `conclusion: cancelled` means **no verdict**, not a pass and not a fail. Two traps: (1) a
run whose overall state is `failure` may hold a *genuine* job failure that happened **before** the
cancel — check job-level `conclusion` + timestamps, don't attribute it to the cancel; (2) a
@@ -467,16 +454,15 @@ HARD CONSTRAINTS:
(tiny ffmpeg testsrc MKVs + `LibraryPath` SQL rows + scan). Live-E2E is a **stated requirement**
for write-path handler changes — see that doc's "When live-E2E is required" + the decisions.md
entry; it's the only net for the lazy-enumeration / reload-through-read-path 500 class (#229).
- **Parallel sessions (2026-07-11 protocol)**: claim before working (`in-progress` label — the
tiny read→claim race window is accepted; later claimant backs off). Claiming prevents
duplicate pickup, NOT overlapping code changes — check the tracker's dependency notes
("#234 after #231", "coordinate with #215") before touching shared surfaces. Editing THIS
lore: prune covered/stale bullets too (not append-only — git keeps history), `git pull --rebase`
before commit. Two runners (ci-runner VM 127 + bumblebee-runner, 4 slots); **no cancel route exists
on this Gitea version — verified 1.25.4: REST and MCP `cancel_run` both 404** (see the batching
constraint above). Superseded runs do finish on their own, but they hold a runner slot while doing
it, so with 4 parallel sessions they are a real cost — batch pushes rather than relying on them to
drain.
- **Parallel sessions**: claim before working (`in-progress` label — the tiny read→claim race window
is accepted; later claimant backs off). Claiming prevents duplicate pickup, NOT overlapping code
changes — check the issue's dependency notes ("#234 after #231", "coordinate with #215") before
touching shared surfaces. Editing THIS lore: prune covered/stale bullets too (not append-only — git
keeps history), `git pull --rebase` before commit. Two runners (ci-runner VM 127 + bumblebee-runner,
4 slots); **no cancel route exists on this Gitea version — verified 1.25.4: REST and MCP
`cancel_run` both 404** (see the batching constraint above). Superseded runs do finish on their own,
but they hold a runner slot while doing it, so with 4 parallel sessions they are a real cost — batch
pushes rather than relying on them to drain.
- **Two sessions touching one machine**: a branch may be checked out in ANOTHER session's
worktree — never commit/merge inside a worktree you didn't create. To land a merge on such a
branch without touching their checkout: plumbing merge (`git read-tree -m base ours theirs`
@@ -510,7 +496,7 @@ HARD CONSTRAINTS:
them plausibly-but-wrong and `npm run check:api` is the guard. Escape hatch for a deliberate
non-rebased push: `ETV_SKIP_REBASE_CHECK=1 git push`.
- **H12 issue-qualification audit** (ersatztv#312, `scripts/issue-qualification-audit.sh`): a
session-end check that lists OPEN issues missing a `priority:` label — the #237 ranking keys off
session-end check that lists OPEN issues missing a `priority:` label — the queue tiering keys off
`priority:`/gate labels, so an unlabeled issue is invisible to it. "Fully qualified" = has a
`priority: {high,medium,low}` label (that signals triage ran; gate-vs-backlog is then derivable
from the `review` label / milestone, and a milestone is NOT required — backlog is unmilestoned).
@@ -582,20 +568,46 @@ HARD CONSTRAINTS:
`CancellationTokenSource.CancelAfter(timeout)` into BOTH `SendAsync` and the stream reads +
`HttpClient.Timeout = InfiniteTimeSpan`; catch transport/timeout exceptions and turn them into a response.
(Not yet in decisions.md — #289 landed on PR#76, not main.)
- **Queue-drift root cause + standing rule (2026-07-12, user-surfaced)**: pickups repeatedly re-picked
already-done work — #91b was framed "recommended next / now unblocked" for two sessions *after* it had
merged (2026-07-11), and the #251/#252 priority-pickups sat listed "open" after closing — requiring a
reactive body correction (tracker comment 16:00). ROOT CAUSE: DONE/OPEN status was read from **prose** (the
arc body's inline "DONE" markers + each session comment's "Recommended next"), which is append-only and
hand-edited, so it lags real issue state — worst under parallel sessions that narrate each other's merges as
"the gate that unblocks X" (#271 got cast as the last gate for an already-merged #91b). It is a *structural*
bug, not a stale-writer bug: any status embedded in prose will drift. STANDING FIX (don't just re-patch the
body next time): **live Gitea state is the ONLY source of truth for status.** The arc body carries order+goal;
a comment's "Recommended next" is a *candidate* that MUST be re-verified OPEN (issue **and** milestone) at
pickup. When prose disagrees with live state, live state wins — correct the prose in your session comment,
never propagate it. STRUCTURAL CURE (2026-07-12 review, Fable): #237's arc no longer carries inline `DONE`
markers — closed items move to a "Done (history)" section, so status lives ONLY in live Gitea state and can't
drift; and the gate/frontier is defined **structurally** (lowest-numbered open arc item), never hardcoded to
an issue number. Beware: the *first* pass at this fix re-planted the very bug by hardcoding "#197 cluster" /
"#91b milestone CLOSED" into the kickoff — if you name today's frontier issue in this standing file, you are
writing the next drift. Gate cluster = open `review`-labeled issues serving the current open arc item.
---
## Archived — pre-script selector history (do not follow)
> This section is history, not instruction. It explains why `scripts/select-queue.sh` exists and
> why the closed #237 tracker used to be the queue-state store; none of it is a live protocol. The
> current protocol is "Two concurrent tracks at session start" above.
- **(Pre-2026-07-19) Before the selector script existed**, a cheap model was dispatched each session
to rank the backlog by reading tracker prose directly, and the orchestrator was told to RE-DERIVE
its mechanical claims because the model kept getting them wrong: it reported blocked issues as
`deps:clear` (never called `/dependencies`), mis-tiered issues when the server-side `?milestones=`
filter silently no-ops on `:`/`+` names, ranked by issue number and ignored priority-within-tier,
and missed live `in-progress` claims. `scripts/select-queue.sh` was written to do the mechanical
parts deterministically in code instead — the evidence bullets below are kept for context, not
because you need to re-derive anything by hand anymore.
- **Gitea milestone-name issue filter silently NO-OPS on names with special chars (2026-07-17,
#77 selection)**: `GET /issues?milestones=<name>` returns the WHOLE open-issue list — not a
filtered set — when the milestone title contains `:` or `+` (e.g. `Scheduling: refactor +
distribution`), because the raw name doesn't round-trip through Gitea's filter. A selector that
trusts that response will mis-tier issues (it made #72 look like a milestone-10 member when it's
unmilestoned, and hid #77's true sibling set). Robust recipe (now encoded in the script): fetch
all open issues once and filter LOCALLY on each issue's `.milestone.title`.
- **Confirmed again + widened (2026-07-17, #72/#164 selection): the packet does not reliably see
`blocked-by`, priority-within-tier, `in-progress` claim state, or umbrella-vs-child** — that run's
dependency data was correct, yet it still ranked #383 first while missing that #384 was
`in-progress` (claimed 40 minutes earlier) and that #383 is that bundle's umbrella. The script's
`CLAIM?`/`UMBRELLA?` flags now surface exactly this instead of relying on a full manual re-check.
- **The cheap selector's dependency + priority data was NOT trustworthy — the fix was to make the
script compute them, not to re-derive the whole contested tier by hand every session** (2026-07-17,
#73 selection): the old packet reported #385/#386 as `deps:clear` when both were blocked, and
missed #383 entirely (the only `priority: medium` in tier 1). The script now runs
`GET /issues/{n}/dependencies` on every surviving candidate and tiers by priority-within-tier
deterministically, closing this class.
- **Queue-drift root cause (2026-07-12, user-surfaced, historical)**: pickups repeatedly re-picked
already-done work because DONE/OPEN status was read from **prose** (the old tracker's arc body
inline markers + session comments' "Recommended next"), which lags real issue state — worst under
parallel sessions narrating each other's merges. STANDING FIX (still the rule, just no longer tied
to any specific tracker): **live Gitea state is the ONLY source of truth for status**; a comment's
"Recommended next" is a candidate that MUST be re-verified OPEN before pickup, never propagated
when stale. Do not name today's frontier issue in this standing file — that's how the drift
started the first time.
@@ -1144,6 +1144,29 @@ git -c core.hooksPath=/dev/null commit -m "feat(521): rework decisions guard —
`Gitea-ErsatzTV` wing — "how/why it was worked"); `docs/decisions.md` is **normative** (the
current rule). A decision points to its issue; the issue carries the provenance. Canonical
Markdown wins for "current rule"; the issue wins for "context + is there prior/related work."
- **The four load-bearing orientation bullets (the #642 seam — carry VERBATIM into the kickoff +
`docs/README.md`; #642's 283-doc export is retrievable-but-unreached without them):**
1. **Current conventions/decisions → catalog-first.** Start at `docs/decisions/README.md`;
discover via the `ErsatzTV-Decisions` wing (active) / `ErsatzTV-Decisions-Archive` (superseded/
retired). **Resolve by topic/key, never by chasing a file path.**
2. **Issue history → evidence, not authority.** The `Gitea-ErsatzTV` wing is historical narrative
that may be stale; it never overrides current Markdown.
3. **The breadcrumb rule (the crux behavior change).** A file path named inside a *historical
issue comment* (e.g. "grep `docs/decisions.md` 2026-07-17", "see …") is a **breadcrumb, not a
live pointer.** Find the current rule via the catalog / active wing **by concept**; do not
treat the named path as current. (Why it's safe: still-current → in the active wing, breadcrumb
resolves; superseded → the active wing returns the *successor* and a literal follow lands on a
record that announces its own `status: superseded`; retired → the active wing returns nothing,
which is itself the signal. The validator-enforced move-to-`archive/` is what prevents the
catastrophic "superseded rule read as current" case.)
4. **Fallback when MemPalace is stale/down:** `docs/decisions/README.md` catalog, then
``rg '^`key: <dotted.key>`' docs/decisions/``. MemPalace is never authority nor sole fallback.
- **Graceful degradation (calibration, not a correctness cliff):** even an under-oriented agent that
literally greps `docs/decisions.md` post-#521 gets valid-but-*incomplete* results (misses topic
files + archived records), never *wrong* ones — that file holds only active records. So this is a
completeness/latency risk during rollout, not a correctness cliff. (#642 additionally strengthens
each exported doc's authority banner to name the catalog/wing and "don't treat file paths below as
current" — defense-in-depth for the skipped-orientation case.)
Without this the #520 "MemPalace candidate discovery + canonical verification + exact-search
fallback documented" box is unmet.
- [ ] **Step 3c (dogfood supersession — Fable #5, PR2 half)** #520 reverses the recorded decision
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# ersatztv#520 — block re-treating the CLOSED arc tracker #237 as live queue state / source of truth.
# Scans active startup docs for forbidden phrasings. A hit is exempt when the line itself contains
# "archiv" OR the line is inside an archival section (a heading whose text contains "archiv", up to
# the next heading of the same or higher level). The "archiv" escape is an anti-footgun convenience,
# not an anti-adversary control.
set -euo pipefail
FILES=(
"docs/handoffs/chicorytv-issue-queue.md"
"docs/README.md"
"CLAUDE.md"
"scripts/select-queue.sh"
)
# Case-insensitive patterns that assert #237 is live/authoritative.
PATTERNS='read #237|#237.?s (body|arc|comments)|tracker #237|queue state lives in|read that, not this file'
rc=0
for f in "${FILES[@]}"; do
[ -f "$f" ] || continue
archive_level=0 # 0 = not in an archival section; else the heading level that opened it
lineno=0
while IFS= read -r line; do
lineno=$((lineno + 1))
# Track archival-section state via Markdown ATX headings.
if [[ "$line" =~ ^(#{1,6})[[:space:]] ]]; then
level=${#BASH_REMATCH[1]}
if printf '%s' "$line" | grep -qi 'archiv'; then
archive_level=$level
elif [ "$archive_level" -ne 0 ] && [ "$level" -le "$archive_level" ]; then
archive_level=0 # a same-or-higher heading closes the archival section
fi
continue
fi
[ "$archive_level" -ne 0 ] && continue # inside archival section → exempt
printf '%s' "$line" | grep -qi 'archiv' && continue # per-line escape
if printf '%s' "$line" | grep -qiE "$PATTERNS"; then
echo "kickoff-guard: $f:$lineno reintroduces #237-as-live-state: ${line}" >&2
rc=1
fi
done < "$f"
done
[ "$rc" -eq 0 ] && echo "kickoff-guard: OK"
exit "$rc"
+12 -1
View File
@@ -98,7 +98,18 @@ def parse_file(path: Path) -> list[Record]:
return parse_text(path.read_text(encoding="utf-8"), path)
_NON_DECISION_FILES = {"README.md", "migration-map.md"}
def metadata_line_count(rec: Record) -> int:
"""Count how many lines in `rec.body` look like a metadata line (a line starting with
`` `key: `` after stripping). A well-formed record has exactly 1; more indicates a
duplicate/stacked metadata block left behind by a botched migration."""
count = 0
for line in rec.body.splitlines():
if line.strip().startswith("`key:"):
count += 1
return count
_NON_DECISION_FILES = {"README.md", "migration-map.md", "retrieval-eval.md"}
def active_files() -> list[Path]:
+9 -3
View File
@@ -17,7 +17,7 @@ from pathlib import Path
import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as module)
SKIP_HEADINGS = {"Index", "Active catalog"}
SKIP_HEADINGS = {"Index", "Active catalog", "Contents"}
REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by")
EDIT_TOKEN = "[decisions-edit]" # noqa: S105 (a commit-message marker, not a credential)
@@ -61,6 +61,9 @@ def validate(
errs.append(
f"{r.heading}: status {r.status} but still in the active set — relocate to docs/decisions/archive/"
)
n_meta = dl.metadata_line_count(r)
if n_meta > 1:
errs.append(f"{r.heading}: {n_meta} metadata blocks found (expected 1) — duplicate metadata block")
for r in archive_records:
if r.status == "active":
@@ -77,11 +80,14 @@ def validate(
if r.key:
by_key[r.key] = r
# reciprocal supersession (successor may live in active OR archive)
# reciprocal supersession (successor/predecessor may live in active OR archive). Runs over the
# union of active + archive records so a twice-reversed decision (an archived record whose
# superseded-by points to ANOTHER archived record) is checked from both sides too.
for r in decision_recs:
if r.status == "active" and r.superseded_by not in (None, "", "none"):
errs.append(f"{r.heading}: active record cannot already be superseded (superseded-by set)")
for r in decision_recs + list(archive_records):
sk = _key_of(r.superseded_by)
if sk:
if sk not in known_keys:
@@ -285,7 +291,7 @@ def main(argv=None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base")
ap.add_argument("--head")
ap.add_argument("--budget", type=int, default=4200) # aggregate; ratchet down as archive grows
ap.add_argument("--budget", type=int, default=4800) # aggregate; re-baselined post-#521 migration
args = ap.parse_args(argv)
records = dl.all_active_records()
+6 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# scripts/select-queue.sh — deterministic queue selector for the #237 pickup protocol.
# scripts/select-queue.sh — deterministic queue selector (live Gitea state, never a tracker issue).
#
# WHY THIS EXISTS. The kickoff dispatches a cheap model to rank the backlog, and the lore then makes
# the orchestrator RE-DERIVE the selector's mechanical claims because a small model kept getting them
@@ -25,8 +25,9 @@
# whose most-recent comments look like a live claim so the orchestrator reads them before claiming.
# - UMBRELLA? — an epic/umbrella whose children are the real pickups reads like a normal issue; the
# script flags bodies that open with umbrella framing so the orchestrator treats it as a container.
# - ARC tier — arc order lives in #237's prose (currently CLOSED / arc complete), not in any label,
# so this script deliberately does NOT rank an arc tier. If an arc ever re-opens, read #237.
# - ARC tier — there is no active arc right now (maintenance/backlog mode; arc complete), so this
# script deliberately does not rank an arc tier. If a new arc/milestone is ever established, add
# it as a tier here — never fall back to hand-ranking from a tracker's prose.
#
# Usage:
# ETV_GITEA_BASICAUTH=user:pass scripts/select-queue.sh [N]
@@ -94,7 +95,7 @@ rows=$(
| (($L | index("review")) != null) as $isReview
| ((.milestone != null) and (.milestone.state == "open")) as $inOpenMs
# tier: 2=open-milestone, 3=review, 4/5/6=priority high/medium/low (unmilestoned/unreviewed).
# (Tier 1 = arc; not label-derivable, handled by the orchestrator via #237 prose.)
# (Tier 1 = arc; there is no active arc right now — see the header comment above.)
| (if $inOpenMs then 2
elif $isReview then 3
elif $prank==0 then 4
@@ -162,4 +163,4 @@ done <<< "$rows"
echo
echo "Ranked by (tier, priority, issue#). DEPS/tiering/ordering are deterministic — trust them."
echo "Resolve CLAIM?/UMBRELLA? flags before claiming (read the issue's comments/body). Arc tier: read #237."
echo "Resolve CLAIM?/UMBRELLA? flags before claiming (read the issue's comments/body). No active arc tier right now."
+69
View File
@@ -68,6 +68,23 @@ def test_reciprocal_supersession_correct_pair_passes():
assert errs == []
def test_archive_to_archive_reciprocal_pair_passes():
# A twice-reversed decision: both records live in the archive wing, pointing at each other.
a = _rec(heading="A", key="a.b", status="superseded", superseded_by="a.c@2026-07-01")
b = _rec(heading="B", key="a.c", status="retired", supersedes="a.b@2026-07-01")
errs = _v([], archive_records=[a, b], archive_keys={"a.b", "a.c"})
assert errs == []
def test_archive_to_archive_broken_back_link_fails():
# A.superseded-by=B but B.supersedes=none → the back-link is broken, and neither side is active
# (so this could previously only be caught by iterating archive_records, not decision_recs).
a = _rec(heading="A", key="a.b", status="superseded", superseded_by="a.c@2026-07-01")
b = _rec(heading="B", key="a.c", status="retired", supersedes="none")
errs = _v([], archive_records=[a, b], archive_keys={"a.b", "a.c"})
assert any("does not point back" in e and "a.c" in e for e in errs)
def test_active_record_with_superseded_by_fails():
recs = [_rec(key="a.b", status="active", superseded_by="a.c@2026-07-01")]
assert any("active record cannot already be superseded" in e for e in _v(recs, archive_keys={"a.c"}))
@@ -120,6 +137,58 @@ def test_demoted_heading_fails():
)
def test_duplicate_metadata_block_fails():
# A migration bug left records with two stacked metadata blocks; the parser only reads the
# first, so only an explicit body scan (metadata_line_count) can catch the leftover second block.
body = (
"`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n"
"**Rule:** first block rule.\n"
"\n"
"`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n"
"**Rule:** second block rule.\n"
"\n"
"Rationale prose goes here."
)
rec = _rec(key="a.b", body=body)
assert dl.metadata_line_count(rec) == 2
assert any("duplicate metadata block" in e and "2 metadata blocks" in e for e in _v([rec]))
def test_single_metadata_block_passes():
body = (
"`key: a.b` · `status: active` · `since: 2026-01-01` · `supersedes: none` · `superseded-by: none`\n"
"**Rule:** the only rule.\n"
"\n"
"Rationale prose goes here."
)
rec = _rec(key="a.b", body=body)
assert dl.metadata_line_count(rec) == 1
assert not any("duplicate metadata block" in e for e in _v([rec]))
def test_contents_heading_is_skipped():
# docs/decisions/*.md topic files use "## Contents" as their index heading (the analog of
# decisions.md's "## Index") — it carries no metadata and must not be miscounted as a
# legacy-unmigrated record, same as "Index" already isn't.
assert "Contents" in dv.SKIP_HEADINGS
rec = dl.Record(
heading="Contents",
source=Path("x"),
lineno=1,
status="legacy-unmigrated",
since=None,
supersedes=None,
superseded_by=None,
)
# validate() itself excludes SKIP_HEADINGS from decision_recs, so a bare Contents record
# produces no errors (it isn't checked for required metadata, bad status, etc.)
assert _v([rec]) == []
# mirrors main()'s legacy-unmigrated count filter
unmigrated = [r for r in [rec] if r.status == "legacy-unmigrated" and r.heading not in dv.SKIP_HEADINGS]
assert unmigrated == []
def _git(cwd: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)