168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and docs/decisions/archive/<area>/<topic>.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
6.0 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| sched.seasonal-scheduling-existing | 2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented | active | 2026-07-17 | none | none | 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. | alternate schedules, playout templates, date-conditional, catch-all-last evaluation · paths: `AlternateScheduleSelector.cs`, `PlayoutBuilder`, `EffectiveBlock`, `DecoSelector`, `PlayoutScheduleEditors.tsx` · issues: #73, #70 | `AlternateScheduleSelectorTests`, `playoutTemplateCalendar.test.ts`; `channels.md` "Recipe: seasonal / holiday programming" |
#73 asked for "seasonal / date-conditional channels & schedule rules" — a PseudoTV holiday-channel analogue — on the stated premise that "ErsatzTV has no native date-conditional scheduling today; you'd hand-build it seasonally." That premise is false. The feature is first-class, shipped, SPA-reachable, unit-tested and already documented (upstream machinery carried into the fork). #73 is closed as already-implemented; the only deliverable was the docs recipe below.
- The predicate:
IAlternateScheduleItem(DaysOfWeek,DaysOfMonth,MonthsOfYear,LimitToDateRange,StartMonth/StartDay/StartYear?,EndMonth/EndDay/EndYear?), implemented byProgramScheduleAlternate(Classic — picks theProgramSchedulefor a date) andScheduling/PlayoutTemplate(Block — picks theTemplate+ optionalDecoTemplatefor a date). - The evaluator:
AlternateScheduleSelector.GetScheduleForDate— first match inIndexorder, broadest/unconditional row last = catch-all. Wired intoPlayoutBuilder(:572, :956),EffectiveBlock,BlockPlayoutFillerBuilder, andDecoSelector. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31 clamped) are already handled;AlternateScheduleSelectorTests+playoutTemplateCalendar.test.tspin it. - SPA:
PlayoutScheduleEditors.tsxships the "Limit to date range" control for both editors, with a per-date calendar preview (playoutTemplateCalendar.ts). - Nullable years are the seasonal switch (
AlternateScheduleSelector.cs:32-40): years default to the queried date's year, so blank years = repeats every year. The override branch requires bothStartYearandEndYear(only one → silently still yearly), and explicit years forcereverse = false, disabling wrap-around. This was the single most load-bearing undocumented behaviour and is now in the docs. - Correcting the issue's Deco hypothesis: the issue proposed "a Deco-like modifier". Decos carry no
dates —
DecoTemplateItem.StartTime/EndTimeare time-of-day. The date dimension lives exclusively on thePlayoutTemplate/ProgramScheduleAlternatejoin row. Composition is PlayoutTemplate (date range) → Template (time-of-day grid) → Block (content).
Why closed rather than #77-style "re-scope to verify + document": #77 had two real gaps (undocumented AND untested). #73 has neither — tests and docs both already existed on main — so even the #77 re-scope target was already met. Under the close-don't-park convention (same date), holding #73 open for a hypothetical future build is precisely the parking that convention forbids.
Rejected asks, and why:
- Per-schedule-item date predicate (the issue's literal ask; shipped design swaps the whole schedule per
date instead). Rejected as-designed: functionally equivalent for every seasonal use case — clone a schedule,
date-gate it, order it above the catch-all — at a cost of "clone a schedule". Moving the predicate onto
ProgramScheduleItemrows would touch the classic playout builder's core loop for zero new expressible behaviour, carrying regression risk in the exact engine #163/#380 just built golden nets around. - "Prioritize collection X during a date range" — routed to #70, not built here. The ask decomposes as
date scoping (shipped) × soft prioritization (the weighting primitive #70 is actively building across the
PlaybackOrderswitch sites). Once #70 lands, this is pure composition with no date-aware code in the weighting path. Building a second weighting primitive under #73 while #70 was mid-flight would have been a direct collision in the same milestone. - Date-gating a playout's default
Playout.DecoId— rejected: the gated path already exists (PlayoutTemplate.DecoTemplateId), and a date-conditional fallback is a contradiction in terms. - UI rename of "Alternate Schedules" / "Playout Templates" to something that reads as "seasonal" — rejected: inherited upstream vocabulary baked into routes, API paths and parity docs; churn for marginal gain. The discoverability gap is addressed with docs instead.
Known non-goal: the issue's "optionally, auto-surface a seasonal channel in the lineup during its window"
is genuinely absent — M3U/XMLTV lineup visibility is not date-conditional. It was speculative, has no concrete
use case (the practical equivalent: air a date-gated schedule year-round, or toggle ShowInEpg), and per
close-don't-park no placeholder issue was filed. Refile if a real need appears.
The real gap was discoverability, and it was a docs gap. The mechanism was documented as a mechanism
("Alternate Schedules… useful for seasonal programming") but never as a task — a user thinking "holiday
channel" has no reason to look under "Alternate Schedules", and on finding that line gets no worked steps.
Fixed with a task-shaped "Recipe: seasonal / holiday programming" section in channels.md (both engines,
plus the gotchas above) and a domain-model.md glossary row. No production code changed, so no live-E2E
(same reasoning as #77).