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
4.2 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| scheduling.ondemand-guide-refresh-on-thaw | 2026-07-21 — An on-demand time shift rebuilds the channel's cached XMLTV so the guide can't lag playback (#68) | active | 2026-07-21 | none | none | When `PlayoutTimeShifter.TimeShift` slides an on-demand playout's materialized timeline forward on tune-in, it reports the channel numbers whose cached guide is now stale — the shifted channel **plus any channels that mirror it** — and `TimeShiftOnDemandPlayoutHandler` enqueues a `RefreshChannelData` for each, so every affected cached XMLTV fragment is regenerated from the just-shifted `PlayoutItem` rows. The guide and playback both read the same stored `PlayoutItem.Start/Finish`, but the guide is served from a **cached** projection — rewriting the rows without rebuilding the cache would leave the guide advertising a stale timeline. | on-demand resume, bookmark playout, freeze the clock when unwatched, guide/EPG desync, "guide showed S2E3 while tune-in played S1E1", XMLTV cache staleness, mirror channel guide · paths: `ErsatzTV.Infrastructure/Scheduling/PlayoutTimeShifter.cs`, `ErsatzTV.Application/Playouts/Commands/TimeShiftOnDemandPlayoutHandler.cs`, `ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs` · issues: #68 | `IPlayoutTimeShifter.TimeShift` returns `List<string>` (shifted channel + its mirrors on a non-zero shift, empty otherwise); handler `foreach`-enqueues `RefreshChannelData` on `CancellationToken.None` |
This is what #68 ("resume/bookmark for sequential channels") actually needed. The freeze-when-unwatched, resume-where-you-left-off behavior already existed as ChannelPlayoutMode.OnDemand: Playout.OnDemandCheckpoint persists the viewer's position, UpdateOnDemandCheckpoint advances it (monotonically, minus one segmenter-timeout of rewind-for-context) while watching, and PlayoutTimeShifter slides the whole schedule forward by now − checkpoint on the next tune-in so the item the viewer had reached is active again. Because the shift rewrites GuideStart/GuideFinish alongside Start/Finish, ErsatzTV freezes the guide and playback together — structurally avoiding the free-running-wall-clock desync the issue was filed about (a guide that keeps ticking while playback resumes from a saved spot).
The one gap was cache freshness, not the timing model. RefreshChannelData was enqueued by playout-build and channel/config edits, but not by the on-tune-in time shift, and GetChannelGuideHandler serves a cached .xml fragment (the live-recomputed SPA JSON guide already self-healed). So after a thaw an external EPG client (Jellyfin) could poll a guide reflecting the pre-shift timeline until the next incidental rebuild. BuildPlayoutHandler already refreshes the guide after it time-shifts — including a fan-out to every channel that mirrors the built channel, because RefreshChannelDataHandler refreshes only the one channel it is handed and does not cascade downstream. The tune-in path (TimeShiftOnDemandPlayoutHandler) was unpatched, so this decision replicates both halves: the source channel's guide and its mirrors' guides are rebuilt on thaw. A channel mirroring an on-demand source is an uncommon combination, but omitting it would leave the same desync one hop out.
Why the return-value plumbing rather than enqueuing inside the shifter. PlayoutTimeShifter lives in ErsatzTV.Infrastructure, which cannot reference the RefreshChannelData request type (an ErsatzTV.Application type), so the enqueue must happen in the Application-layer handler. TimeShift therefore returns Option<string> — Some(channelNumber) only when a non-zero offset was actually persisted, None on every early-out (wrong mode, active-and-unforced, empty playout) and on a zero-offset re-tune — so a guide rebuild fires exactly once per real thaw, never on a no-op. The zero-offset None gate is covered by a dedicated non-vacuous test.
Per-viewer resume was deliberately not built. OnDemandCheckpoint is a single value on the playout, so resume is per-channel, not per-viewer. #68 states per-channel suffices for a single household; multi-viewer identity would diverge from this model and is out of scope.