- Exclude docs/decisions/retrieval-eval.md from active decision parsing (_NON_DECISION_FILES); its `## N.` eval-question headings were being miscounted as 7 legacy-unmigrated records. - Add decisions_lib.metadata_line_count() + a decisions_validate guard that fails a record with more than one `key:` metadata line, so a stacked-metadata-block migration bug (which the parser silently tolerated by reading only the first block) can't recur unnoticed. TDD: test_duplicate_metadata_block_fails / test_single_metadata_block_passes. - De-duplicate the 6 docs/decisions.md records left with two stacked metadata blocks (scan.getoraddfolder-db-lookup #488, scan.musicvideo-reconciliation #494, scan.jellyfin-mixed-content-library #489, iptv.logo-drives-bug-preset #67, ffmpeg.qsv-decode-encode-split #498, ci.small-lane-git-only server-management#639), merging the union of Signals/paths/issues/Mechanics from both blocks and keeping the richer Rule wording; rationale prose untouched. - Fill in the deferred Q6b row in docs/decisions/retrieval-eval.md now that startup.parallel-orientation is active in docs/decisions.md, scoring it as a real active-vs-superseded question against the archived docs.queue-state-gitea-tracker. - Regenerate docs/decisions/README.md via build_decisions_catalog.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
46 KiB
46 KiB
Active decisions — catalog / task router
The compact current view of settled decisions. Each row is an active record; follow
the link for rationale. Superseded/retired history lives in archive/. Regenerated by
scripts/build_decisions_catalog.py.
| 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 |
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 |
api.channel-health-signal |
Channel health rides ChannelResponseModel/ChannelListItem DTOs as a raw int PlayoutCount fact (free — GetAll already Includes 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 |
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 |
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 |
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 |
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 |
api.mediatr-passthrough |
The REST API is thin controllers over existing MediatR handlers, with no new service/business-logic layer. | 2026-06 | link |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
docs.convention-docs-session-start |
The standing convention-doc set (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) is read at session start and updated in the same PR that changes what it documents, replacing per-session recon. | 2026-07-07 | link |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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.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.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.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 |
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.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.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 |
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.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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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.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 |
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 |
spa.channel-renumber-prompt |
Channel renumbering uses a sequential prompt()-driven "Renumber" action instead of drag-to-reorder. |
2026-07-09 | link |
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.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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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.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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |