From 7bdb83fb16acd5e92c425fd9d5fac212ef22481b Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 21 Jul 2026 11:11:01 +0200 Subject: [PATCH 01/11] docs(520): carry #642 orientation bullets into PR2 plan (from a814dd16) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-07-21-decision-lifecycle-and-kickoff.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md b/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md index 00ec3a384..d9319e620 100644 --- a/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md +++ b/docs/superpowers/plans/2026-07-21-decision-lifecycle-and-kickoff.md @@ -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: `' 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 -- 2.47.3 From ab2d80b47a2996ed324d48615ea4d57634f8588d Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 21 Jul 2026 11:14:00 +0200 Subject: [PATCH 02/11] fix(521): reciprocity covers archive<->archive pairs; skip topic-file Contents heading Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/decisions_validate.py | 7 +++-- scripts/tests/test_decisions_validate.py | 40 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/scripts/decisions_validate.py b/scripts/decisions_validate.py index 1c3da25f9..ac6d033e6 100644 --- a/scripts/decisions_validate.py +++ b/scripts/decisions_validate.py @@ -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) @@ -77,11 +77,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: diff --git a/scripts/tests/test_decisions_validate.py b/scripts/tests/test_decisions_validate.py index 352e21c57..8c0d7e5b1 100644 --- a/scripts/tests/test_decisions_validate.py +++ b/scripts/tests/test_decisions_validate.py @@ -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,29 @@ def test_demoted_heading_fails(): ) +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) -- 2.47.3 From 97062cc439b38e6835e6851006f670588eeeb0ea Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 21 Jul 2026 11:25:50 +0200 Subject: [PATCH 03/11] feat(521): migrate decisions.md range C: tail #488+/#350/#58/#511 decision records to lifecycle schema [decisions-edit] Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/decisions.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/decisions.md b/docs/decisions.md index 41f85c7da..bcfe0acbf 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -2270,6 +2270,11 @@ for its whole slot. This closes that gap. ## 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:** `GetOrAddFolder` looks the existing folder up with a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` navigation collection (which is null on the remote/Jellyfin path). +**Signals:** folder lookup contract, remote scanner crash, ArgumentNullException, eager-load assumption · paths: `ILibraryRepository.GetOrAddFolder`, `LibraryRepositoryTests`, `JellyfinMusicVideoLibraryScanner` · issues: #488 +**Mechanics:** `ILibraryRepository.GetOrAddFolder`; 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` `.Include(l => l.Paths).ThenInclude(p => p.LibraryFolders)` — which every `*FolderScanner` goes through. The @@ -2302,6 +2307,11 @@ remote scanner tripped it, and the feature had never run in prod, CI, or locally ## 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 by a library-scoped local-path diff and hard-deletes music videos gone upstream, since it has no server itemId/etag identity to diff against. +**Signals:** music-video trash sweep, path-keyed identity, cross-kind safety, empty-fetch guard reuse · paths: `JellyfinMusicVideoLibraryScanner`, `TrashMissingMusicVideos`, `IMusicVideoRepository`, `MediaServerReconciliationGuard` · issues: #494, #477, #488, #496 +**Mechanics:** `JellyfinMusicVideoLibraryScanner.ScanLibrary`/`TrashMissingMusicVideos`; 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` (`TrashMissingMusicVideos`), mirroring the `MediaServer{Movie,Television,OtherVideo}LibraryScanner` "gone @@ -2339,6 +2349,11 @@ media-server identity those base scanners rely on. ## 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 `LibraryMediaKind.Mixed` and runs the movie, TV and music-video scanners against it in sequence, instead of being dropped. +**Signals:** mixed-content library, LibraryMediaKind, per-kind scan dispatch, silent-success bug · paths: `JellyfinApiClient.Project`, `ScanMixedLibrary`, `ScanLocalLibraryHandler`, `SynchronizeJellyfinLibraryByIdHandler` · issues: #489, #474, #488 +**Mechanics:** `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, television and music-video scanners in sequence against that one library. @@ -2400,6 +2415,11 @@ fixed here: ## 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 logo drives both the listing logo and the on-screen bug via a shared, seeded `ChannelLogo`-sourced watermark preset, not per-channel bug schema. +**Signals:** channel bug, ChannelWatermark, ChannelLogo imageSource, seed/adopt semantics, quick-add · paths: `ChannelWatermarkImageSource`, `DbInitializer.Initialize`, `WatermarkResponseModel`, `DbInitializerChannelBugWatermarkTests` · issues: #67, #286, #502 +**Mechanics:** `DbInitializer` seeded `Channel Bug` watermark + `watermark.channel_bug_seeded` ConfigElement marker; `WatermarkResponseModel.imageSource` + #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` resolves the channel's own logo artwork at render time at all three watermark precedence levels, and @@ -2439,6 +2459,11 @@ shared row already delivers the user-visible behavior with no schema change. 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:** `FFmpegProfile.QsvPreferNativeDecoder` (default on, Linux-only) decodes QSV profiles with the native VA-API decoder while still encoding with QSV, mirroring Jellyfin's hybrid decode/encode toggle instead of a general decode-family enum. +**Signals:** QSV, VA-API, Dolby Vision passthrough, hybrid decode/encode, HDR tonemap fallback · paths: `FFmpegProfile.QsvPreferNativeDecoder`, `QsvPipelineBuilder`, `docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md` · issues: #498, #505 +**Mechanics:** `QsvPipelineBuilder` decoder-mode branch; migration `HasDefaultValue(true)` on the nullable `bool?` column + `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 FFmpeg's native VA-API decoder and cannot carry Dolby Vision metadata. Jellyfin, on identical hardware, @@ -2478,6 +2503,11 @@ ErsatzTV. Full design: `docs/superpowers/specs/2026-07-20-qsv-native-decode-desi 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:** the `small` CI lane is defined by what a job does (git-only), not its typical runtime; both `docker build` jobs move to `ubuntu-latest` so `small`'s per-slot memory cap can shrink and its concurrency widen. +**Signals:** runner lane definition, memory cap vs capacity, act setup-phase hang, docker build placement · paths: `docker-build.yml`, `ci-image.yml`, runner config · issues: server-management#639, #406, #604, #574 +**Mechanics:** `.gitea/workflows/docker-build.yml` and `ci-image.yml` `runs-on`; `docs/ci-cd.md` + - **The `small` lane is defined by what a job *does*, not by how long it usually takes.** Both jobs removed from it here were justified as small on a runtime argument that only held in the common case: `docker-build.yml`'s `build` is a 1-second skip on PR runs (but a real image build on main/tags), and @@ -2559,6 +2589,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 +2638,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 +2670,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 -- 2.47.3 From 63a6fe80fb30b2fe2bf49f83904c117ffedd8340 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 21 Jul 2026 11:26:25 +0200 Subject: [PATCH 04/11] feat(521): migrate topic-file decision records to lifecycle schema [decisions-edit] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds metadata blocks (key/status/since/supersedes/superseded-by + Rule/Signals/Mechanics) to all 26 legacy-unmigrated ## records across optimistic-concurrency.md (7, concurrency.*), api-auth-security.md (9, security.*), release-ci-governance.md (7 remaining legacy records, release.*), and spa-modularization.md (3, spa.*). Heading text and rationale prose are byte-identical; only metadata was inserted, per the key-table assignments. security.session-auth-dual-credential (#295 PR1) gets a one-line cross-reference noting it narrows the OIDC-inert sub-claim of security.blazor-removal-auth-posture (#206), per orchestrator adjudication — both stay status: active with supersedes/superseded-by: none. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/decisions/api-auth-security.md | 36 ++++++++++++++++++++++++ docs/decisions/optimistic-concurrency.md | 28 ++++++++++++++++++ docs/decisions/release-ci-governance.md | 28 ++++++++++++++++++ docs/decisions/spa-modularization.md | 12 ++++++++ 4 files changed, 104 insertions(+) diff --git a/docs/decisions/api-auth-security.md b/docs/decisions/api-auth-security.md index 7dff31034..0c1ca0707 100644 --- a/docs/decisions/api-auth-security.md +++ b/docs/decisions/api-auth-security.md @@ -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 `