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
5.0 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| sched.autotune-per-source-weights | 2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425) | active | 2026-07-18 | none | none | 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. | Auto-Tune, per-source weights, MultiCollection, WeightedShuffle · paths: `AutoTunedChannelRequest`, `OwnedByChannelId` migration · issues: #425, #70, #383, #386, #385 | `AddCollectionOwnedByChannelId` migration; `WeightedShuffleCollectionEnumerator` |
Per-source rotation weights (3× Show A, 1× Show B) and query corrections (exclude / add-untagged) for
an auto-tune channel are supplied at bulk-create time — an optional sources: [{sourceId, weight, excluded}] list on each AutoTunedChannelRequest (the same DetailPanel-wizard surface #385 added its
per-channel advanced/templateId/logo to). There is no post-hoc PUT .../weights, no lazy
upgrade/downgrade, and no idempotent desired-state handler: the auto-tune DetailPanel (#383/#386) is a
create-wizard, and #425's Done-when is "create → built playout". (An earlier plan draft assumed a PUT on an
existing channel; the shipped flow is create-time, matching #385.)
Structure — Option A (reuse #70), not a new fake-collection weight path. When any source is customized
(a non-default weight, an exclusion, or an added out-of-axis id), the channel is backed by a system-owned
MultiCollection of per-source SmartCollections, weights on MultiCollectionSmartItem.Weight, and its
single-item lineup points at the MultiCollectionId with PlaybackOrder.WeightedShuffle — the exact path
WeightedShuffleCollectionEnumerator already consumes (GetMultiCollectionCollections forwards the per-row
weight). Rejected: threading a weight map through GroupIntoFakeCollections (the fake-collection path a lone
SmartCollection takes, which hardcodes weight 1) — it derives its group keys at runtime, is shared with the
unrelated FillWithGroupMode, and would need a parallel weight-persistence home. When no source is
customized (all weights 1, nothing excluded/added) the channel keeps the #69 single-SmartCollection
fair-share shape — cheaper, and identical output for TV since the fake path already groups per show.
Discriminators. A source's member query is discriminator-only (membership is fixed at tune time): TV uses
type:episode AND show_title:"X" (episode docs carry no parent-show id in the index — show_title is the
only per-show field, the same one #69's TvShow axis already uses; a post-create rename empties the member
until re-tuned), and movies use the stable, rename-proof type:movie AND id:{mediaItemId} (a movie is the
played item). The remainder is ({base}) AND NOT ({d1} OR {d2} …) over every materialized ∪ excluded
discriminator — a partition of the base set, so no item is counted twice or dropped. Exclude = omit the
member and keep it in the NOT-list (else its items leak back through the remainder); add-untagged = an
ordinary member whose id isn't in the base set (no genre clause, so it just airs).
Materialization bound is axis-dependent — "weight N" means N× each other source. TV materializes
every base show as its own member (un-weighted shows keep per-show fair-share; a single merged remainder
would regress them to item-proportional, so a 200-episode show would swamp a 20-episode one) plus one live
remainder at weight 1 for shows/episodes added after tune-in (empty at create → harmlessly skipped by
WeightedShuffleCollectionEnumerator's ActiveSources filter). MovieGenre materializes only the touched
movies (weighted or added) and leaves every un-touched base movie in ONE remainder whose weight = its member
count — exactly equivalent to materializing each movie individually, because the fake path already pools
movies uniformly, without hundreds of rows. Cost note: a large TV genre materializes one SmartCollection per
show (dozens–hundreds), each a cheap stored term query; a future optimization could cap/warn.
Ownership + lifecycle. The MultiCollection and its member/remainder SmartCollections carry a nullable
OwnedByChannelId (dual-provider migration AddCollectionOwnedByChannelId, indexed). Owned rows are hidden
from the user-facing collection lists and cascade-cleaned when the channel is deleted (the
ProgramScheduleItem → MultiCollection cascade removes the dangling flood item). Names embed a per-create
token (at-mc:{token}, at:{token}:{n}) because both names are unique varchar(50) and the channel id
isn't known until CreateChannelFromLineup runs; ownership is stamped immediately after. Non-weighted (#69)
channels set no ownership, so their pre-existing orphan-on-delete behavior is unchanged. The create is
non-atomic across the two handlers (mirrors #69) with best-effort rollback of the artifacts on channel-create
failure.