From 2dcc88a3256d46f2dfde4dd36a3dc4e8bb232797 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 25 Jul 2026 19:14:48 +0200 Subject: [PATCH] feat(610): split the decision corpus into one YAML-frontmatter file per record 168 records -> docs/decisions/records//.md (163 active, 23 dirs) and docs/decisions/archive//.md (5 archived). The filename IS the key, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a `git mv`. WHY: the monolith was a concurrency problem before an aesthetic one. A 3,900-line append target made parallel sessions collide -- PR #605 and PR #614 both hit append-vs-append conflicts during routine rebases, and hand-resolving those inside the corpus is exactly the operation the rationale-rewrite guard exists to police. HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness does not rest on reading it. The parser was taught BOTH formats first, so the body-diff guard parses the old form at the merge-base and the new form at head -- the migration validates itself, no bypass. The proof is a field-level equivalence harness: 168 records before and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing. Reviewers should scrutinise the harness; it is the actual evidence. What measuring caught that reading would not have: - ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each topic file's preamble, mostly the only copy. Source files are kept and stripped, never deleted. They also cannot be filed per-area: topic files hold several areas and 4 of 23 areas span several files. - Archive discovery was a non-recursive glob; after the split it found ZERO archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure. - ~32 live docs point into the corpus BY DATE, which the split dangles. Each stripped file now ends with a generated "Records formerly in this file" index, which also rescues the identical breadcrumbs in old issue comments. - decisions.md's "In this file:" list was 97 same-file anchor bullets that the split makes WRONG, not merely stale. Dropped; the generated index replaces them with links that resolve. The equivalence harness now runs against a checked-in FIXTURE, not the live corpus. The earlier version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the SCRIPT rather than the repo's current state. Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain two directories for one concept. Renaming a key is not a move -- it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work. refs #610 --- CLAUDE.md | 2 +- docs/README.md | 12 +- docs/decisions.md | 3963 +---------------- docs/decisions/README.md | 333 +- docs/decisions/api-auth-security.md | 523 +-- docs/decisions/archive/api.md | 50 +- .../archive/api/channel-health-signal.md | 53 + .../archive/docs/append-only-guard.md | 33 + .../archive/docs/queue-state-gitea-tracker.md | 37 + .../archive/release-ci-governance.md | 30 +- docs/decisions/archive/scan.md | 43 +- .../archive/scan/musicvideo-reconciliation.md | 46 + docs/decisions/archive/spa.md | 42 +- .../spa/smartcollection-rule-builder.md | 45 + docs/decisions/archive/startup.md | 34 +- docs/decisions/optimistic-concurrency.md | 312 +- .../records/api/artwork-rooted-urls.md | 23 + .../records/api/async-op-contract.md | 64 + .../records/api/channel-health-object.md | 21 + .../records/api/channel-preview-capability.md | 37 + docs/decisions/records/api/decode-by-id.md | 17 + .../records/api/from-lineup-clear-to-none.md | 19 + .../api/healthcheck-remediation-dto.md | 52 + .../records/api/healthcheck-ttl-cache.md | 40 + .../decisions/records/api/logs-sort-params.md | 21 + .../records/api/mediatr-passthrough.md | 21 + .../records/api/openapi-mirrors-runtime.md | 25 + .../decisions/records/api/parentid-drillin.md | 17 + .../records/api/playout-build-lock-409.md | 46 + .../api/postcommit-cancellation-none.md | 50 + .../records/api/put-replace-index-order.md | 20 + docs/decisions/records/api/response-dtos.md | 18 + .../records/api/schedule-item-flat-dto.md | 29 + .../records/api/scheduling-hardening.md | 43 + .../records/api/search-allitems-paging.md | 51 + .../records/api/search-field-values.md | 46 + .../records/api/search-paging-cap.md | 17 + docs/decisions/records/api/versioning-v1.md | 51 + docs/decisions/records/blazor/rollback-tag.md | 30 + docs/decisions/records/blazor/ui-removed.md | 54 + .../records/channel/origin-marker.md | 17 + .../ci/batch-pushes-no-cancel-route.md | 21 + .../records/ci/build-once-rejected.md | 30 + .../records/ci/cancelled-is-not-a-verdict.md | 21 + .../records/ci/decisions-edit-trailer.md | 51 + .../records/ci/decisions-lifecycle-flake.md | 22 + .../ci/docs-only-detect-shallow-safe.md | 25 + .../records/ci/docs-only-skip-steps.md | 44 + .../records/ci/format-gate-folder-mode.md | 40 + .../records/ci/functional-e2e-harness.md | 39 + .../records/ci/gitea-milestone-filter-noop.md | 25 + .../records/ci/infra-shaped-red-under-load.md | 26 + .../decisions/records/ci/killed-job-triage.md | 27 + .../records/ci/monitor-armed-at-pr-open.md | 53 + .../records/ci/no-host-health-gating.md | 19 + .../records/ci/peak-anon-measurement.md | 46 + .../records/ci/root-screenshot-guard.md | 16 + docs/decisions/records/ci/runner-placement.md | 126 + .../records/ci/small-lane-git-only.md | 36 + docs/decisions/records/ci/ui-e2e-harness.md | 54 + .../records/ci/verify-locally-ci-confirms.md | 18 + .../records/ci/web-test-per-test-timeouts.md | 18 + .../records/concurrency/diff-scalar-fanout.md | 51 + .../concurrency/etag-rotation-completion.md | 63 + .../concurrency/force-write-non-ifmatch.md | 57 + .../concurrency/idempotent-concurrent-add.md | 42 + .../records/concurrency/ifmatch-rfc7232.md | 43 + .../concurrency/replace-all-contract.md | 42 + .../schedule-item-child-identity.md | 33 + .../docs/convention-docs-session-start.md | 24 + .../records/docs/decision-lifecycle.md | 55 + .../docs/decision-one-file-per-record.md | 54 + .../docs/decision-optional-provenance.md | 43 + .../records/docs/tracker-comment-retrofit.md | 38 + .../ffmpeg/external-logo-graphics-engine.md | 63 + .../records/ffmpeg/hls-cold-start-burst.md | 71 + .../records/ffmpeg/qsv-decode-encode-split.md | 49 + .../ffmpeg/qsv-extra-hw-frames-floor.md | 70 + .../ffmpeg/remote-image-fetcher-bounded.md | 143 + .../records/ffmpeg/work-ahead-slot-atomic.md | 41 + .../work-ahead-slot-release-never-negative.md | 19 + .../graphics/channel-level-attachment.md | 51 + .../records/graphics/channel-logo-caching.md | 60 + docs/decisions/records/iptv/base-url.md | 55 + .../records/iptv/logo-drives-bug-preset.md | 49 + .../locking/entitylocker-atomic-flags.md | 43 + .../records/mcp/server-foundation.md | 37 + .../records/media/lastscan-null-boundary.md | 58 + .../media/remote-stream-probe-externaljson.md | 44 + .../records/media/remote-stream-probe.md | 69 + .../records/media/source-mgmt-write-api.md | 95 + .../process/bom-format-detection-recipe.md | 30 + .../process/branch-off-feature-branch.md | 16 + .../process/build-concurrency-limits.md | 20 + .../process/codex-cheap-worker-launch.md | 24 + .../consistency-fix-new-code-scrutiny.md | 18 + ...te-workaround-behaviors-before-deleting.md | 19 + .../foreign-worktree-plumbing-merge.md | 15 + .../harden-with-runtime-posture-not-clamp.md | 19 + .../process/independent-review-rubric.md | 20 + .../process/issue-qualification-audit.md | 16 + .../records/process/local-gate-before-push.md | 18 + .../lock-ownership-enumerate-producers.md | 21 + .../one-worktree-one-committing-agent.md | 15 + .../records/process/parallel-session-claim.md | 19 + .../process/per-agent-model-routing.md | 65 + .../records/process/pr-routine-sequence.md | 22 + .../review-disagreement-frontier-judge.md | 19 + .../records/process/shared-tree-readonly.md | 28 + .../records/process/subagent-drop-resume.md | 14 + .../rulebuilder/relative-date-macros.md | 30 + .../records/scan/collections-scan-status.md | 39 + .../records/scan/getoraddfolder-db-lookup.md | 41 + .../scan/jellyfin-mixed-content-library.md | 70 + .../scan/musicvideo-server-identity.md | 70 + .../scan/projection-failure-sweep-guard.md | 124 + .../records/scan/zero-item-fetch-guard.md | 55 + .../records/sched/auto-tune-foundation.md | 22 + .../sched/autotune-detailpanel-members.md | 38 + .../sched/autotune-per-channel-overrides.md | 53 + .../sched/autotune-per-source-weights.md | 59 + .../records/sched/clock-padding-existing.md | 42 + .../sched/clock-padding-schedule-toggle.md | 29 + .../sched/playbackorder-support-matrix.md | 55 + .../records/sched/reshuffle-scoped-reset.md | 27 + .../sched/seasonal-scheduling-existing.md | 71 + .../records/sched/shuffle-source-builder.md | 50 + .../records/sched/weighted-shuffle.md | 110 + .../records/sched/weightedshuffle-editor.md | 48 + .../ondemand-guide-refresh-on-thaw.md | 19 + .../security/artwork-content-type-sniff.md | 45 + .../security/baseline-response-headers.md | 36 + .../security/blazor-removal-auth-posture.md | 51 + .../security/contract-freeze-honesty.md | 40 + .../records/security/corp-same-origin.md | 26 + .../security/csp-permissions-policy.md | 38 + .../records/security/fail-closed-api-auth.md | 54 + .../security/iptv-access-token-transport.md | 51 + .../records/security/iptv-browser-token.md | 56 + .../security/session-auth-dual-credential.md | 108 + .../security/session-cutover-postify.md | 49 + .../session/shared-checkout-refresh.md | 34 + docs/decisions/records/spa/add-to-layer.md | 33 + .../records/spa/app-shell-extraction.md | 43 + .../spa/autotune-detailpanel-slideover.md | 39 + .../records/spa/channel-editor-create-logo.md | 44 + .../records/spa/channel-renumber-prompt.md | 18 + .../records/spa/channels-screen-extraction.md | 35 + .../records/spa/collection-custom-order-ui.md | 20 + .../records/spa/datetime-local-input.md | 18 + .../records/spa/deco-templates-table.md | 18 + .../records/spa/download-sample-gate.md | 17 + .../records/spa/legacy-redirect-matcher.md | 50 + .../records/spa/logs-page-size-local.md | 23 + .../records/spa/playback-troubleshoot-poll.md | 23 + .../records/spa/playout-reset-button.md | 19 + .../records/spa/playouts-screen-extraction.md | 29 + .../records/spa/rulebuilder-nesting.md | 48 + .../spa/schedules-editor-draft-save.md | 40 + .../spa/sidebar-collapsible-accordions.md | 39 + .../records/spa/spa-rebuild-decision.md | 17 + .../records/spa/templates-editor-table.md | 16 + .../records/spa/topbar-primary-action.md | 47 + .../records/spa/yaml-validator-textarea.md | 18 + .../records/startup/parallel-orientation.md | 53 + .../testing/e2e-cleanup-scope-by-pid.md | 67 + .../testing/e2e-local-fresh-config-dir.md | 29 + .../testing/live-e2e-prepush-timing.md | 17 + .../playwright-mcp-download-and-recovery.md | 17 + .../scripted-playout-golden-deferred.md | 46 + .../troubleshoot-path-cannot-test-branding.md | 18 + docs/decisions/release-ci-governance.md | 225 +- docs/decisions/spa-modularization.md | 100 +- docs/decisions/workflow-process.md | 652 +-- scripts/migrate_decisions_split.py | 39 +- .../tests/fixtures/premigration/decisions.md | 28 + .../premigration/decisions/archive/old.md | 8 + .../fixtures/premigration/decisions/topic.md | 10 + scripts/tests/test_migration_equivalence.py | 99 +- 179 files changed, 6843 insertions(+), 5983 deletions(-) create mode 100644 docs/decisions/archive/api/channel-health-signal.md create mode 100644 docs/decisions/archive/docs/append-only-guard.md create mode 100644 docs/decisions/archive/docs/queue-state-gitea-tracker.md create mode 100644 docs/decisions/archive/scan/musicvideo-reconciliation.md create mode 100644 docs/decisions/archive/spa/smartcollection-rule-builder.md create mode 100644 docs/decisions/records/api/artwork-rooted-urls.md create mode 100644 docs/decisions/records/api/async-op-contract.md create mode 100644 docs/decisions/records/api/channel-health-object.md create mode 100644 docs/decisions/records/api/channel-preview-capability.md create mode 100644 docs/decisions/records/api/decode-by-id.md create mode 100644 docs/decisions/records/api/from-lineup-clear-to-none.md create mode 100644 docs/decisions/records/api/healthcheck-remediation-dto.md create mode 100644 docs/decisions/records/api/healthcheck-ttl-cache.md create mode 100644 docs/decisions/records/api/logs-sort-params.md create mode 100644 docs/decisions/records/api/mediatr-passthrough.md create mode 100644 docs/decisions/records/api/openapi-mirrors-runtime.md create mode 100644 docs/decisions/records/api/parentid-drillin.md create mode 100644 docs/decisions/records/api/playout-build-lock-409.md create mode 100644 docs/decisions/records/api/postcommit-cancellation-none.md create mode 100644 docs/decisions/records/api/put-replace-index-order.md create mode 100644 docs/decisions/records/api/response-dtos.md create mode 100644 docs/decisions/records/api/schedule-item-flat-dto.md create mode 100644 docs/decisions/records/api/scheduling-hardening.md create mode 100644 docs/decisions/records/api/search-allitems-paging.md create mode 100644 docs/decisions/records/api/search-field-values.md create mode 100644 docs/decisions/records/api/search-paging-cap.md create mode 100644 docs/decisions/records/api/versioning-v1.md create mode 100644 docs/decisions/records/blazor/rollback-tag.md create mode 100644 docs/decisions/records/blazor/ui-removed.md create mode 100644 docs/decisions/records/channel/origin-marker.md create mode 100644 docs/decisions/records/ci/batch-pushes-no-cancel-route.md create mode 100644 docs/decisions/records/ci/build-once-rejected.md create mode 100644 docs/decisions/records/ci/cancelled-is-not-a-verdict.md create mode 100644 docs/decisions/records/ci/decisions-edit-trailer.md create mode 100644 docs/decisions/records/ci/decisions-lifecycle-flake.md create mode 100644 docs/decisions/records/ci/docs-only-detect-shallow-safe.md create mode 100644 docs/decisions/records/ci/docs-only-skip-steps.md create mode 100644 docs/decisions/records/ci/format-gate-folder-mode.md create mode 100644 docs/decisions/records/ci/functional-e2e-harness.md create mode 100644 docs/decisions/records/ci/gitea-milestone-filter-noop.md create mode 100644 docs/decisions/records/ci/infra-shaped-red-under-load.md create mode 100644 docs/decisions/records/ci/killed-job-triage.md create mode 100644 docs/decisions/records/ci/monitor-armed-at-pr-open.md create mode 100644 docs/decisions/records/ci/no-host-health-gating.md create mode 100644 docs/decisions/records/ci/peak-anon-measurement.md create mode 100644 docs/decisions/records/ci/root-screenshot-guard.md create mode 100644 docs/decisions/records/ci/runner-placement.md create mode 100644 docs/decisions/records/ci/small-lane-git-only.md create mode 100644 docs/decisions/records/ci/ui-e2e-harness.md create mode 100644 docs/decisions/records/ci/verify-locally-ci-confirms.md create mode 100644 docs/decisions/records/ci/web-test-per-test-timeouts.md create mode 100644 docs/decisions/records/concurrency/diff-scalar-fanout.md create mode 100644 docs/decisions/records/concurrency/etag-rotation-completion.md create mode 100644 docs/decisions/records/concurrency/force-write-non-ifmatch.md create mode 100644 docs/decisions/records/concurrency/idempotent-concurrent-add.md create mode 100644 docs/decisions/records/concurrency/ifmatch-rfc7232.md create mode 100644 docs/decisions/records/concurrency/replace-all-contract.md create mode 100644 docs/decisions/records/concurrency/schedule-item-child-identity.md create mode 100644 docs/decisions/records/docs/convention-docs-session-start.md create mode 100644 docs/decisions/records/docs/decision-lifecycle.md create mode 100644 docs/decisions/records/docs/decision-one-file-per-record.md create mode 100644 docs/decisions/records/docs/decision-optional-provenance.md create mode 100644 docs/decisions/records/docs/tracker-comment-retrofit.md create mode 100644 docs/decisions/records/ffmpeg/external-logo-graphics-engine.md create mode 100644 docs/decisions/records/ffmpeg/hls-cold-start-burst.md create mode 100644 docs/decisions/records/ffmpeg/qsv-decode-encode-split.md create mode 100644 docs/decisions/records/ffmpeg/qsv-extra-hw-frames-floor.md create mode 100644 docs/decisions/records/ffmpeg/remote-image-fetcher-bounded.md create mode 100644 docs/decisions/records/ffmpeg/work-ahead-slot-atomic.md create mode 100644 docs/decisions/records/ffmpeg/work-ahead-slot-release-never-negative.md create mode 100644 docs/decisions/records/graphics/channel-level-attachment.md create mode 100644 docs/decisions/records/graphics/channel-logo-caching.md create mode 100644 docs/decisions/records/iptv/base-url.md create mode 100644 docs/decisions/records/iptv/logo-drives-bug-preset.md create mode 100644 docs/decisions/records/locking/entitylocker-atomic-flags.md create mode 100644 docs/decisions/records/mcp/server-foundation.md create mode 100644 docs/decisions/records/media/lastscan-null-boundary.md create mode 100644 docs/decisions/records/media/remote-stream-probe-externaljson.md create mode 100644 docs/decisions/records/media/remote-stream-probe.md create mode 100644 docs/decisions/records/media/source-mgmt-write-api.md create mode 100644 docs/decisions/records/process/bom-format-detection-recipe.md create mode 100644 docs/decisions/records/process/branch-off-feature-branch.md create mode 100644 docs/decisions/records/process/build-concurrency-limits.md create mode 100644 docs/decisions/records/process/codex-cheap-worker-launch.md create mode 100644 docs/decisions/records/process/consistency-fix-new-code-scrutiny.md create mode 100644 docs/decisions/records/process/enumerate-workaround-behaviors-before-deleting.md create mode 100644 docs/decisions/records/process/foreign-worktree-plumbing-merge.md create mode 100644 docs/decisions/records/process/harden-with-runtime-posture-not-clamp.md create mode 100644 docs/decisions/records/process/independent-review-rubric.md create mode 100644 docs/decisions/records/process/issue-qualification-audit.md create mode 100644 docs/decisions/records/process/local-gate-before-push.md create mode 100644 docs/decisions/records/process/lock-ownership-enumerate-producers.md create mode 100644 docs/decisions/records/process/one-worktree-one-committing-agent.md create mode 100644 docs/decisions/records/process/parallel-session-claim.md create mode 100644 docs/decisions/records/process/per-agent-model-routing.md create mode 100644 docs/decisions/records/process/pr-routine-sequence.md create mode 100644 docs/decisions/records/process/review-disagreement-frontier-judge.md create mode 100644 docs/decisions/records/process/shared-tree-readonly.md create mode 100644 docs/decisions/records/process/subagent-drop-resume.md create mode 100644 docs/decisions/records/rulebuilder/relative-date-macros.md create mode 100644 docs/decisions/records/scan/collections-scan-status.md create mode 100644 docs/decisions/records/scan/getoraddfolder-db-lookup.md create mode 100644 docs/decisions/records/scan/jellyfin-mixed-content-library.md create mode 100644 docs/decisions/records/scan/musicvideo-server-identity.md create mode 100644 docs/decisions/records/scan/projection-failure-sweep-guard.md create mode 100644 docs/decisions/records/scan/zero-item-fetch-guard.md create mode 100644 docs/decisions/records/sched/auto-tune-foundation.md create mode 100644 docs/decisions/records/sched/autotune-detailpanel-members.md create mode 100644 docs/decisions/records/sched/autotune-per-channel-overrides.md create mode 100644 docs/decisions/records/sched/autotune-per-source-weights.md create mode 100644 docs/decisions/records/sched/clock-padding-existing.md create mode 100644 docs/decisions/records/sched/clock-padding-schedule-toggle.md create mode 100644 docs/decisions/records/sched/playbackorder-support-matrix.md create mode 100644 docs/decisions/records/sched/reshuffle-scoped-reset.md create mode 100644 docs/decisions/records/sched/seasonal-scheduling-existing.md create mode 100644 docs/decisions/records/sched/shuffle-source-builder.md create mode 100644 docs/decisions/records/sched/weighted-shuffle.md create mode 100644 docs/decisions/records/sched/weightedshuffle-editor.md create mode 100644 docs/decisions/records/scheduling/ondemand-guide-refresh-on-thaw.md create mode 100644 docs/decisions/records/security/artwork-content-type-sniff.md create mode 100644 docs/decisions/records/security/baseline-response-headers.md create mode 100644 docs/decisions/records/security/blazor-removal-auth-posture.md create mode 100644 docs/decisions/records/security/contract-freeze-honesty.md create mode 100644 docs/decisions/records/security/corp-same-origin.md create mode 100644 docs/decisions/records/security/csp-permissions-policy.md create mode 100644 docs/decisions/records/security/fail-closed-api-auth.md create mode 100644 docs/decisions/records/security/iptv-access-token-transport.md create mode 100644 docs/decisions/records/security/iptv-browser-token.md create mode 100644 docs/decisions/records/security/session-auth-dual-credential.md create mode 100644 docs/decisions/records/security/session-cutover-postify.md create mode 100644 docs/decisions/records/session/shared-checkout-refresh.md create mode 100644 docs/decisions/records/spa/add-to-layer.md create mode 100644 docs/decisions/records/spa/app-shell-extraction.md create mode 100644 docs/decisions/records/spa/autotune-detailpanel-slideover.md create mode 100644 docs/decisions/records/spa/channel-editor-create-logo.md create mode 100644 docs/decisions/records/spa/channel-renumber-prompt.md create mode 100644 docs/decisions/records/spa/channels-screen-extraction.md create mode 100644 docs/decisions/records/spa/collection-custom-order-ui.md create mode 100644 docs/decisions/records/spa/datetime-local-input.md create mode 100644 docs/decisions/records/spa/deco-templates-table.md create mode 100644 docs/decisions/records/spa/download-sample-gate.md create mode 100644 docs/decisions/records/spa/legacy-redirect-matcher.md create mode 100644 docs/decisions/records/spa/logs-page-size-local.md create mode 100644 docs/decisions/records/spa/playback-troubleshoot-poll.md create mode 100644 docs/decisions/records/spa/playout-reset-button.md create mode 100644 docs/decisions/records/spa/playouts-screen-extraction.md create mode 100644 docs/decisions/records/spa/rulebuilder-nesting.md create mode 100644 docs/decisions/records/spa/schedules-editor-draft-save.md create mode 100644 docs/decisions/records/spa/sidebar-collapsible-accordions.md create mode 100644 docs/decisions/records/spa/spa-rebuild-decision.md create mode 100644 docs/decisions/records/spa/templates-editor-table.md create mode 100644 docs/decisions/records/spa/topbar-primary-action.md create mode 100644 docs/decisions/records/spa/yaml-validator-textarea.md create mode 100644 docs/decisions/records/startup/parallel-orientation.md create mode 100644 docs/decisions/records/testing/e2e-cleanup-scope-by-pid.md create mode 100644 docs/decisions/records/testing/e2e-local-fresh-config-dir.md create mode 100644 docs/decisions/records/testing/live-e2e-prepush-timing.md create mode 100644 docs/decisions/records/testing/playwright-mcp-download-and-recovery.md create mode 100644 docs/decisions/records/testing/scripted-playout-golden-deferred.md create mode 100644 docs/decisions/records/testing/troubleshoot-path-cannot-test-branding.md create mode 100644 scripts/tests/fixtures/premigration/decisions.md create mode 100644 scripts/tests/fixtures/premigration/decisions/archive/old.md create mode 100644 scripts/tests/fixtures/premigration/decisions/topic.md diff --git a/CLAUDE.md b/CLAUDE.md index 2e30a062b..f691c13f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev . | Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` | | Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` | | Change a SPA screen convention | `docs/spa-conventions.md` | - | Establish / reverse a convention or decision | `docs/decisions.md` (lifecycle: add record, relocate predecessor to archive/) + the affected doc | + | Establish / reverse a convention or decision | a new `docs/decisions/records//.md` (filename = key; lifecycle: add record, `git mv` predecessor to `archive//`) + regenerate the catalog + the affected doc | | Add / remove / retitle a doc | `docs/README.md` index | The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check. diff --git a/docs/README.md b/docs/README.md index cb140bf04..873ac2e90 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ doc below, or that changes which sections a task signal points to.** | Adding/changing a UI-E2E browser flow | `docs/e2e-local.md` → "UI-E2E harness" + `scripts/e2e-ui.sh` | | What does a test suite cover | `docs/testing.md` | | Legacy Blazor route lookup | `docs/blazor-route-parity.md` (historical #91 phase (b) inventory) | -| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → `docs/decisions.md` + `docs/decisions/*.md` for full rationale. `docs/decisions/archive/` only for "what did the rule used to be." | +| "Why do we do X this way" / challenging a convention | **Catalog-first**: `docs/decisions/README.md` (active rows) → follow the row's link to `docs/decisions/records//.md` for full rationale. `docs/decisions/archive//` only for "what did the rule used to be." | ## Knowledge retrieval (MemPalace + catalog + Gitea) @@ -72,9 +72,13 @@ bounds, what's mined per issue): `docs/handoffs/chicorytv-issue-queue.md` → "K - **`docs/blazor-route-parity.md`** — historical record of the completed #91 phase (b) cutover: the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent (or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory. -- **`docs/decisions.md`** + **`docs/decisions/*.md`** — active decision records (lifecycle schema: - key/status/since/supersedes/superseded-by, plus optional `stale-after`/`Sources:` — - ersatztv#603). **Generated active view**: `docs/decisions/README.md` +- **`docs/decisions/records//.md`** — one active decision record per file, YAML + frontmatter (`key`/`title`/`status`/`since`/`supersedes`/`superseded-by`, plus optional + `stale-after`/`sources` — ersatztv#603), rationale prose in the body. The **filename is the key**, + so one-active-record-per-key is a filesystem property (ersatztv#610). `docs/decisions.md` and the + topic files remain as the lifecycle-schema narrative plus a "Records formerly in this file" index, + which is what keeps older date-based pointers resolvable. **Generated active view**: + `docs/decisions/README.md` (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. diff --git a/docs/decisions.md b/docs/decisions.md index 9ed5ad172..09bb83d5c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -82,3888 +82,133 @@ in-file entries. **In this file:** -- [2026-06 — REST API wraps existing MediatR handlers 1:1, no service layer](#2026-06--rest-api-wraps-existing-mediatr-handlers-11-no-service-layer) -- [2026-06 — UI rebuild is a React SPA (ChicoryTV) on the REST API, not a Blazor reskin](#2026-06--ui-rebuild-is-a-react-spa-chicorytv-on-the-rest-api-not-a-blazor-reskin) -- [2026-07 — Response DTOs live in `ErsatzTV.Core/Api`, file-scoped `#nullable enable`](#2026-07--response-dtos-live-in-ersatztvcoreapi-file-scoped-nullable-enable) -- [2026-07 — PUT-replace list endpoints derive `Index` from array order; alternate-schedules last row = catch-all default](#2026-07--put-replace-list-endpoints-derive-index-from-array-order-alternate-schedules-last-row--catch-all-default) -- [2026-07 — Templates editor in the SPA is a table, not Blazor's drag-calendar](#2026-07--templates-editor-in-the-spa-is-a-table-not-blazors-drag-calendar) -- [2026-07-07 — API artwork contract: rooted URLs produced server-side](#2026-07-07--api-artwork-contract-rooted-urls-produced-server-side) -- [2026-07-07 — Decode-style endpoints take a row id and look up server-side](#2026-07-07--decode-style-endpoints-take-a-row-id-and-look-up-server-side) -- [2026-07-07 — Season/episode/music-video drill-in via `parentId`, not new child-listing endpoints](#2026-07-07--seasonepisodemusic-video-drill-in-via-parentid-not-new-child-listing-endpoints) -- [2026-07-07 — Convention docs read at session start, updated in-PR](#2026-07-07--convention-docs-read-at-session-start-updated-in-pr) -- [2026-07-09 — Playback-troubleshooting completion feedback: poll status, no push channel](#2026-07-09--playback-troubleshooting-completion-feedback-poll-status-no-push-channel) -- [2026-07-09 — datetime-local instead of Chronic natural-language start parsing](#2026-07-09--datetime-local-instead-of-chronic-natural-language-start-parsing) -- [2026-07-09 — SPA gates Download Media Sample while a session is active](#2026-07-09--spa-gates-download-media-sample-while-a-session-is-active) -- [2026-07-09 — OpenAPI spec mirrors the runtime Newtonsoft serializer (#198)](#2026-07-09--openapi-spec-mirrors-the-runtime-newtonsoft-serializer-198) -- [2026-07-09 — YAML playout validator: paste-textarea instead of a server file path](#2026-07-09--yaml-playout-validator-paste-textarea-instead-of-a-server-file-path) -- [2026-07-09 — Channel numbers: prompt-driven sequential renumber instead of drag-to-reorder](#2026-07-09--channel-numbers-prompt-driven-sequential-renumber-instead-of-drag-to-reorder) -- [2026-07-09 — "Table, not calendar" convention also covers the deco-templates editor](#2026-07-09--table-not-calendar-convention-also-covers-the-deco-templates-editor) -- [2026-07-11 — Trash "See all" reuses library-browse paging; search stays capped per kind (#213)](#2026-07-11--trash-see-all-reuses-library-browse-paging-search-stays-capped-per-kind-213) -- [2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement](#2026-07-11--logs-page-size-is-a-client-local-preference-not-a-server-configelement) -- [2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`](#2026-07-11--logs-column-sorting-allow-listed-sortfieldsortdirection-on-get-apilogs) -- [2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode](#2026-07-09--per-playout-schedule-reset-button-dropped-reset-uses-the-server-default-build-mode) -- [2026-07-09 — Collection custom order: move up/down buttons, any-kind collections](#2026-07-09--collection-custom-order-move-updown-buttons-any-kind-collections) -- [2026-07-10 — Shared "Add to…" layer lives in `web/src/media/addTo/`; select-mode is an explicit toggle](#2026-07-10--shared-add-to-layer-lives-in-websrcmediaaddto-select-mode-is-an-explicit-toggle) -- [2026-07-10 — Schedule-item GET returns a flat, non-polymorphic DTO (`ScheduleItemResponseModel`)](#2026-07-10--schedule-item-get-returns-a-flat-non-polymorphic-dto-scheduleitemresponsemodel) -- [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 — 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) -- [2026-07-11 — Pre-removal Blazor rollback tag `blazor-final` (#205)](#2026-07-11--pre-removal-blazor-rollback-tag-blazor-final-205) -- [2026-07-11 — Async-op API contract normalization + playout build observability + F9 scan endpoints (#235)](#2026-07-11--async-op-api-contract-normalization--playout-build-observability--f9-scan-endpoints-235) -- [2026-07-11 — Post-commit side effects run on `CancellationToken.None` (generalized from #251 to #254)](#2026-07-11--post-commit-side-effects-run-on-cancellationtokennone-generalized-from-251-to-254) -- [2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired](#2026-07-12--external-collections-scans-get-an-authoritative-status-surface-271-the-spa-timeout-is-retired) -- [2026-07-11 — Blazor Server UI removed (#91 phase b)](#2026-07-11--blazor-server-ui-removed-91-phase-b) -- [2026-07-12 — Live-E2E is a required step for API write-path handler changes (#303)](#2026-07-12--live-e2e-is-a-required-step-for-api-write-path-handler-changes-303) -- [2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)](#2026-07-12--topbar-primary-action-button-wire-creates-drop-the-rest-238) -- [2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)](#2026-07-13--api-versioning-the-whole-api-surface-is-mounted-at-apiv1-additive-only-after-freeze-286) -- [2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)](#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172) -- [2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)](#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) -- [2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)](#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) -- [2026-07-16 — Auto-tuning enumerates via EF, persists via SmartCollection; additive coexistence (#69)](#2026-07-16--auto-tuning-enumerates-via-ef-persists-via-smartcollection-additive-coexistence-69) -- [2026-07-16 — Per-playout reshuffle = scoped Reset build; seed surfaced (#71)](#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71) -- [2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred](#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) -- [2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380)](#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380) -- [2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented](#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) -- [2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)](#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384) -- [2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)](#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406) -- [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70) -- [2026-07-17 — Docs-only CI skip gates STEPS in always-running required jobs, never `if:`-skips them (#416)](#2026-07-17--docs-only-ci-skip-gates-steps-in-always-running-required-jobs-never-if-skips-them-416) -- [2026-07-17 — Docs-only detect must be shallow-checkout safe: FETCH_HEAD + two-dot, not origin/main + three-dot (#416 follow-up)](#2026-07-17--docs-only-detect-must-be-shallow-checkout-safe-fetch_head--two-dot-not-originmain--three-dot-416-follow-up) -- [2026-07-17 — Pre-push guard: don't push a file whose working-tree copy is uncommitted (H13, #416 session)](#2026-07-17--pre-push-guard-dont-push-a-file-whose-working-tree-copy-is-uncommitted-h13-416-session) -- [2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)](#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) -- [2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)](#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164) -- [2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)](#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386) -- [2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)](#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425) -- [2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)](#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293) -- [2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)](#2026-07-18--collapsible-sidebar--nav-group-accordions-two-ctv-sidebar--localstorage-keys-labeled-groups-default-collapsed-396) -- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403) -- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip) -- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409) -- [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)](#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) -- [2026-07-19 — Health-check results are TTL-cached; `?refresh=true` forces a fresh run (#431)](#2026-07-19--health-check-results-are-ttl-cached-refreshtrue-forces-a-fresh-run-431) -- [2026-07-19 — The `format` gate runs `dotnet format whitespace . --folder`, not the full solution format (#469)](#2026-07-19--the-format-gate-runs-dotnet-format-whitespace----folder-not-the-full-solution-format-469) -- [2026-07-19 — CI `test` job reports a sampled true peak-anon, not cache-inflated `memory.peak` (#412)](#2026-07-19--ci-test-job-reports-a-sampled-true-peak-anon-not-cache-inflated-memorypeak-412) -- [2026-07-19 — Media-server remote-stream URLs are probed before use: a redirected 404 fails closed, everything else fails open, no toggle (#473)](#2026-07-19--media-server-remote-stream-urls-are-probed-before-use-a-redirected-404-fails-closed-everything-else-fails-open-no-toggle-473) -- [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)](#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) -- [2026-07-20 — External-JSON playout channels now probe the remote-stream URL too, closing the #473 scope gap (#480)](#2026-07-20--external-json-playout-channels-now-probe-the-remote-stream-url-too-closing-the-473-scope-gap-480) -- [2026-07-20 — `ILibraryRepository.GetOrAddFolder` resolves the folder from the DB, not the caller's `LibraryPath.LibraryFolders` navigation (#488)](#2026-07-20--ilibraryrepositorygetoraddfolder-resolves-the-folder-from-the-db-not-the-callers-librarypathlibraryfolders-navigation-488) -- [2026-07-20 — `JellyfinMusicVideoLibraryScanner` reconciles by library-scoped path diff + hard delete, not server itemId soft-trash (#494)](#2026-07-20--jellyfinmusicvideolibraryscanner-reconciles-by-library-scoped-path-diff--hard-delete-not-server-itemid-soft-trash-494) -- [2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library holding many kinds](#2026-07-20-489--jellyfin-mixed-content-libraries-map-to-one-library-holding-many-kinds) -- [2026-07-20 — One logo drives the bug via a shared ChannelLogo preset, not new schema (#67)](#2026-07-20--one-logo-drives-the-bug-via-a-shared-channellogo-preset-not-new-schema-67) -- [2026-07-22 — Channel-level graphics-element attachment + seeded On Now/Next text element (#74)](#2026-07-22--channel-level-graphics-element-attachment--seeded-on-nownext-text-element-74) -- [2026-07-20 (#498) — QSV decode is split from QSV encode via a single `QsvPreferNativeDecoder` bool](#2026-07-20-498--qsv-decode-is-split-from-qsv-encode-via-a-single-qsvprefernativedecoder-bool) -- [2026-07-20 — `runs-on: small` means git-only; the two `docker build` jobs move to `ubuntu-latest` (server-management#639)](#2026-07-20--runs-on-small-means-git-only-the-two-docker-build-jobs-move-to-ubuntu-latest-server-management639) -- [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 — HLS cold start is fixed with `-readrate_initial_burst`, not by raising the work-ahead limit (#350)](#2026-07-20--hls-cold-start-is-fixed-with--readrate_initial_burst-not-by-raising-the-work-ahead-limit-350) -- [2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58)](#2026-07-20--mcp-server-ersatztvmcp-built-fresh-over-frozen-apiv1-read--cautious-writes-58) -- [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-07-21 — External channel-logo URLs are downloaded and cached at save time; the render path never fetches a logo (#525)](#2026-07-21--external-channel-logo-urls-are-downloaded-and-cached-at-save-time-the-render-path-never-fetches-a-logo-525) -- [2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529)](#2026-07-21--qsv-hardware-frame-headroom-is-a-floor-not-an-operator-preference-529) -- [2026-07-21 — Check the worked issue before the decision corpus; a closed tracker's comments need no retrofit (#524)](#2026-07-21--check-the-worked-issue-before-the-decision-corpus-a-closed-trackers-comments-need-no-retrofit-524) -- [2026-07-21 — Work-ahead slots are claimed atomically by the caller, released by the transcode it hands them to (#536)](#2026-07-21--work-ahead-slots-are-claimed-atomically-by-the-caller-released-by-the-transcode-it-hands-them-to-536) -- [2026-07-21 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68)](#2026-07-21--an-on-demand-time-shift-rebuilds-the-channels-cached-xmltv-so-the-guide-cant-lag-playback-68) -- [2026-07-21 — Session end fast-forwards the shared checkout; a stale tree serves stale FILES (#541)](#2026-07-21--session-end-fast-forwards-the-shared-checkout-a-stale-tree-serves-stale-files-541) -- [2026-07-21 — `WorkAheadSlots.Release()` clamps before decrementing and reports unbalance in-band (#539)](#2026-07-21--workaheadslotsrelease-clamps-before-decrementing-and-reports-unbalance-in-band-539) -- [2026-07-21 — `from-lineup` advanced overrides express "clear to none" via a typed `clear` enum list (#135)](#2026-07-21--from-lineup-advanced-overrides-express-clear-to-none-via-a-typed-clear-enum-list-135) -- [2026-07-21 — Browser channel preview is a server-declared per-channel capability (#60)](#2026-07-21--browser-channel-preview-is-a-server-declared-per-channel-capability-60) -- [2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381)](#2026-07-22--sequential-yaml-playout-gets-a-golden-scripted-is-excluded-from-the-golden-net-by-construction-381) -- [2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392)](#2026-07-22--per-schedule-clock-boundary-padding-is-a-synthetic-content-less-pad-over-the-existing-per-episode-machinery-392) -- [2026-07-23 — Channel origin is immutable creation-provenance, stamped at insert, not a health signal (#414)](#2026-07-23--channel-origin-is-immutable-creation-provenance-stamped-at-insert-not-a-health-signal-414) -- [2026-07-23 — Facet-value typeahead is a new endpoint, allow-listed to text fields, no caching (#434)](#2026-07-23--facet-value-typeahead-is-a-new-endpoint-allow-listed-to-text-fields-no-caching-434) -- [2026-07-23 — Relative-date rule builder operators are a frontend-only mapping onto existing Lucene macros (#435)](#2026-07-23--relative-date-rule-builder-operators-are-a-frontend-only-mapping-onto-existing-lucene-macros-435) -- [2026-07-23 — Channel health = a server-derived `health` object on the channel DTOs, built-timeline detection (#415)](#2026-07-23--channel-health--a-server-derived-health-object-on-the-channel-dtos-built-timeline-detection-415) -- [2026-07-25 — Rule-builder group nesting is bounded-arbitrary depth (`MAX_GROUP_DEPTH`), not one level (#436)](#2026-07-25--rule-builder-group-nesting-is-bounded-arbitrary-depth-max_group_depth-not-one-level-436) -- [2026-07-25 — UI-E2E: headless Playwright flows in the existing `functional-e2e` job, browser baked into the CI image (#445)](#2026-07-25--ui-e2e-headless-playwright-flows-in-the-existing-functional-e2e-job-browser-baked-into-the-ci-image-445) - --- -## 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` mapping, handler-level fixes not controller papering · paths: `docs/rest-api.md`, `docs/api-conventions.md` §3 · issues: #2, #172 -**Mechanics:** `docs/api-conventions.md` §3 - -The REST API (#2, `docs/rest-api.md`) is thin controllers over the existing MediatR -Create/Update/Delete handlers — no new service/business-logic layer was introduced, since nearly -every handler already returns `Either`, which maps cleanly to HTTP status codes. -Latent handler bugs (missing existence checks, `KeyNotFoundException` risk, etc.) are fixed **at -the handler**, converting what would have 500'd into a proper 404/422 — not papered over in the -controller. Established across the #2a–#2e gap-issue PRs. Deep FK ids nested inside item-list -request bodies (e.g. a schedule item's `CollectionId`) are deliberately **not** existence-checked at -that depth, to avoid N+1 validation queries — precedent set by the schedules endpoints (#172); see -`docs/api-conventions.md` §3 for the up-to-date statement of this rule. - -## 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 — -DONE, merged via PR #148 (`ErsatzTV/LegacyUiRedirects.cs`, `feat/91-cutover` → main). **(b)** full -Blazor removal — gated on every route having an SPA equivalent; tracked route-by-route in -`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//*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//*ResponseModel.cs` and mirror the shape of -the corresponding Application-layer ViewModel — controllers never expose VM types directly. Because -`ErsatzTV.Core.csproj` sets `disable` project-wide, any response-model file -with an optional member needs its own `#nullable enable` pragma at the top (most already have one). -`ErsatzTV.Application` has no nullable context at all — do not add `?` annotations to types living -there; that's a Core/Api-layer-only convention. Full detail: `docs/api-conventions.md` §2. - -## 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 -client-supplied index/order field — established by `ReplaceScheduleItemsRequest.ToCommand` -(`Items.Select((item, index) => item.ToReplaceCommand(index))`). Separately, `ProgramScheduleAlternate` -and `PlayoutTemplate` rows (both `IAlternateScheduleItem`) are evaluated in `Index` order, -first-match-wins; the convention is to place the least-conditional (or unconditional) row **last** -so it acts as the catch-all default. Established by the alternate-schedules work (PR #179, -`AlternateScheduleSelector.cs`). - -## 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 -instead. This is an accepted, deliberate parity deviation — don't "fix" it to match Blazor's -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 ``, `ApiArtwork` helper · paths: `ErsatzTV.Core/Api/ApiArtwork.cs`, `ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs` · issues: none cited (PR #181, PR #183) -**Mechanics:** `ErsatzTV.Core/Api/ApiArtwork.cs` - -API response DTOs return artwork as rooted, directly-usable URLs (`/artwork/posters/...`, -`/artwork/thumbnails/...`, `/artwork/fanart/...`), plus passthrough for absolute `http(s)://` URLs -and Jellyfin/Emby proxy variants. Established by PR #181 -(`ErsatzTV.Application/LibraryBrowse/Queries/GetLibraryBrowseItemsHandler.cs`, private `Artwork(...)` -helper — comment: *"Returns a rooted, directly-usable artwork URL for the SPA's ``... the -SPA [needs it pre-rooted]"*), then generalized into the reusable `ApiArtwork` helper -(`ErsatzTV.Core/Api/ApiArtwork.cs`, PR #183). Root cause: the SPA has no ``, unlike -Blazor, so relative artwork paths that worked for Blazor pages 404 in the SPA. Do **not** reuse the -Application-layer Mappers used by Blazor (e.g. `MediaCards`/`Television` mappers) for new API -DTOs — those still return old Blazor-convention relative paths; map from the domain/VM directly and -root the path via `ApiArtwork`. - -## 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 -`GET /api/playouts/history/{id}` (`PlayoutController.GetHistoryDetails`, PR #182) — the row's raw -JSON (`Key`/`Details`) is decoded server-side into `PlayoutHistoryDetailsResponseModel`, the client -never round-trips the raw payload itself. - -## 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 -in by re-querying with it. Established across PRs #181/#183 (library-picker season drill-in, then -media-detail's season/episode/artist/music-video browsing). Avoids a combinatorial explosion of -per-kind child endpoints. - -## 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` -are the standing reference set every ChicoryTV session should read before starting work, and each -one carries an explicit "update this doc in the same PR" rule rather than deferring doc updates to -a follow-up. These docs **replace per-session recon** — an agent reads the index -(`docs/README.md`) and the relevant convention doc instead of re-deriving conventions from the code -each time it starts API/SPA/E2E/parity work. A testing map and a generated-endpoint index are -tracked as still-to-come under #185. Drafting this doc set also surfaced a drift in -`ApiControllerSecurityTests.cs`'s hardcoded controller registry (several controllers under -`ErsatzTV/Controllers/Api/` are missing from it — see `docs/api-conventions.md` §6) — tracked as a -follow-up under #184 rather than fixed inline, since it's a pre-existing gap, not something this -doc-drafting pass caused. - -## 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 -running (plus one poll on mount so a session started elsewhere still gates Play), rather than a -server push. The status endpoint returns `{ state, exitCode, speed, logs }`; the screen captures the -running→completed/failed transition in local component state and surfaces a completion notice -(success on exit 0, warning otherwise) — the SPA equivalent of the Blazor page's MediatR -`ICourier`/`ISnackbar` `PlaybackTroubleshootingCompletedNotification`. Chosen over SignalR/SSE -because the SPA has **no push channel** and troubleshooting sessions are short and user-initiated, so -a lightweight poll (started on Play, stopped on settle/unmount) is simpler than standing up a new -real-time transport. Speed thresholds and the "(Speed: Nx)" badge colors are copied verbatim from the -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 `` 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 -``, a **deliberate deviation** from the Blazor page, which parsed a -free-text field with `Chronic.Core.Parser` (natural language like "yesterday at 8pm"). The SPA has no -Chronic dependency and a picker is unambiguous; the selected local datetime is sent to -`playback.m3u8` as an ISO-8601 `start` param via `new Date(value).toISOString()`, which the -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 -only gated Download Results. Both downloads compete with the live transcode for I/O and the sample -archiver reads the same media file, so gating both during a session is strictly safer and costs -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` -(camelCase + a `FFmpegProfileId`→`ffmpegProfileId` special case + `[JsonProperty]` overrides such as -`ChannelResponseModel.FFmpegProfile`→`ffmpegProfile`), while `Microsoft.AspNetCore.OpenApi` generates the -spec from System.Text.Json metadata, whose camelCase drifted (`fFmpegProfileId`, `fFmpegProfile`). That -drift fed the SPA the wrong key. Rather than hand-patch the spec or change the wire format (breaking clients), -we added `NewtonsoftSchemaNamingTransformer` — an OpenAPI schema transformer registered on all three -documents that renames each schema property through the *same* Newtonsoft contract resolver the runtime uses, -so the spec matches the wire format by construction. A contract test -(`OpenApiSerializerContractTests`) serializes representative DTOs through the real runtime settings and pins -the spec property sets to them. Decision: **the wire format is the source of truth; the spec follows it via the -real contract resolver.** This also fixed a latent SPA bug (the channel-list "FFmpeg profile" column read -`fFmpegProfile` and always showed "Unassigned"). Issue #198. - -## 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 `