feat(520,521): full decision-corpus migration + parallel-orientation startup rewrite (PR2 of 2) #527
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+44
@@ -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"
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user