# Release 20 — the data model and the index (recorded dates, Twitch ids, the caption-track bug closed) Written 2026-10-09 from the "next batch" plan (releases 19–22), against `r18/integration` `4cffda3f`. Track A's, after release 19 ([`release-19.md`](release-19.md)); small. Rules: `plans/tools/implementer-rules.md` and release 19's rules line, unchanged. Record file: this file (shape of `plans/release-18.md`). ## Context - A VOD-mirror channel's videos are dated by their upload to the mirror, not by the stream they copy; coverage ("held videos by date, gaps", release 19 A9 `channel_coverage`) reads the wrong date for them. - A Twitch video's directory is `` while its record carries `v`; readers that join the two miss. - The `en` track → 0 cues bug (an `en` VTT that parses to 0 cues preferred over `en-orig`) was filed 2026-10-01. Main's caption-track merges since (`200a3105` transcripts/en-track-fallback: en-orig first, empty tracks fall through, cue-block VTTs parse, a one-shot index re-read; `e506f738` transcripts/multi-track) are the candidate fix. - `plans/stats-cache-key.md`: merged `10cefd15` (2026-09-28); the recount rolled out with release 15 (verified by release 19 C1). ## Slices | slice | class | what | after | |---|---|---|---| | **D1** recorded dates | `[unit]` | `recordedDate` for VOD-mirror channels, derived from the title by a per-channel rule; coverage reads it before the upload date | release 19 | | **D2** Twitch ids | `[unit]` | one normalization of the Twitch id (`` directory vs `v` record) at the index boundary | D1 | | **D3** the caption-track bug | `[unit]` + `[spec]` for the index | verify against the tree and a fixture that the `en` → 0 cues case is closed by `200a3105`; close it in STATE and FACTS, or fix what remains | — | **Owner:** the Track A implementer. **Merge order:** D3 may go first (verification); D1 → D2. Release end: the index spec(s) D3 names, then the full editor suite once if any editor code changed. ## Verification - Unit tests per slice; the index spec for D3; tsc clean per commit; `docs files --check` if a schema changes. - Counts-only privacy greps before each merge. ## Record (Each slice adds a "### Slice , as shipped" section here, before "## Rollout".) Track D of the overnight batch (2026-10-10), on `r20/d2-r20` off `r20/integration` (`585be292`), after release 21 D2 on the same branch; D3 first, then D1; D2 built, then held (below). Every fixture is synthetic; nothing read the corpus beyond config.json key names and shapes, and a count of two Twitch channels' directories (below). ### Slice D3, as shipped — the caption-track bug, closed **Verified against the tree.** The `en` → 0 cues bug (filed 2026-10-01) is closed by `200a3105`: one rule in `common/lib/videoStatus.ts` (`CAPTION_TRACK_RULE_VERSION` 1) — pin > `en-orig` > `en` > regional > `en-en-*` by name, then the first track that parses to at least one cue (`readEnglishVttCues`) — and `parseVtt` reads a document with no inline timing tag as plain cue blocks, the livestream `en` shape that used to parse to nothing. Every cue reader goes through the rule: the index, normalize, report compose, the editor's track reader, and umtool's `cues.mjs` copy (held equal by `captionTrack.test.ts`); `buildIndex.ts`'s other `parseVtt` call is the non-English and live-chat tracks. An index built under the old rule is re-read once for the records the rule reaches. **Added:** `controller/buildIndexCaptionTrack.test.ts` +1 through the real `buildIndex` — an empty `en-orig` before an `en` with text, and an empty `en` before an `en-US` with text (no `en-orig`), both read the track with text. FACTS gains "The caption-track rule" (and a superseded note on the old "a caption fixture must carry timing tags" line); STATE closes the bug. **Found and left (report only, as ruled): the stats cache does not see the caption-track pass.** `buildStats` keys a video's stat on its index `mtimes` record (`indexSignature`; `plans/stats-cache-key.md`, merged `10cefd15`, in the tree unchanged: schema 6, the whole record plus `indexKey`). The caption-track pass re-reads a record's cues and writes the SAME `mtimes` record back (no input file moved), so a record the pass took from no text to text keeps `hasTranscript: false` and its old `cueCount` in the stats until something else moves its record. ### Slice D1, as shipped — recorded dates for VOD-mirror channels **What it does.** `common/lib/recordedDate.ts` (pure, client-safe): a channel's `recordedDate: {titlePattern}` is a case-insensitive regex source naming the groups `year` (four digits, or two read as 20YY), `month` (1–12, or an English month name, whole or cut to three or more letters) and `day`; it must compile, name all three, and pass the download filter's safety check (at most 200 characters, no nested quantifier), or the config reader drops the whole rule. `deriveRecordedDate` gives `YYYYMMDD` for a real day no later than the upload date (a later title date is one the title mentions, not the recording's), else nothing. `coverageDate(summary)` is `recordedDate ?? uploadDate`, the hook coverage reads. - **Config:** `ChannelConfig.recordedDate` through `CHANNEL_CONFIG_COERCIONS` / `channelConfigSchema`, with a nested key table; CHANNEL.md regenerated (`archilyzer docs files`). - **The index** (`buildIndex.ts`): after a record's summary is settled (from metadata or a fresh `transcript.cues.json`), `recordedDate` is set from the channel's compiled rule or deleted. `recordedDateRule:` in the index meta holds the pattern a channel's records were derived under; a build that finds another re-processes that channel's records once (`Recorded dates: : its rule changed; N record(s) re-derived.`), a held channel left for a later build. No channel has a rule today, so the first build after the rollout re-processes nothing. - **`TranscriptSummary.recordedDate?`**, omitted when absent: it reaches a channel's published transcript pages only once that channel has a rule, and no page changes until then. - **Setting it:** the Configure form's Advanced section gains "Recorded date from the title (regex)" (`recordedDateTitlePattern`, refused with why when it could not be a rule; in `CHANNEL_FORM_FIELDS`, so clearing it clears the key), and `pnpm ops channel-config {"slug":…,"patch":{"recordedDateTitlePattern":…}}` sets it through the same parser. - **Coverage** (release 19 A9's `channel_coverage`) is not on this branch; `coverageDate` is what it reads at merge. **Which channels are mirrors** was read from config.json shapes only: no key marks one; the candidates are the channels whose name or URL says VODs, mirror or archive (listed in the track report). No channel's config was changed; the patterns are the operator's to write. ### Slice D2 — one Twitch id: held, operator ruling owed Built as `433815d4` and reverted on this branch; the build is kept on `r20/twitch-ids` (orchestrator, 2026-10-10: it changes published URLs, which is the operator's ruling). **What is there.** A Twitch VOD's directory is the canonical id, the URL's `/videos/` (`extractVideoId`); `summarize` takes yt-dlp's native `meta.id`, `v`, so the record's id and slug say `v`. Two channels carry Twitch VODs: `hasanabi` (180 directories) and `shondo-twitch` (37). No curated tag or site report references a `v` id (counts 0). **Readers that miss the join today** — each opens `data//` with the record's `v`: - MCP `fetch_clip` → the editor's fetch-window (`data/v/` holds no metadata, so no URL and no cache); - the evidence-clip tiers (`common/lib/evidenceClip.mjs`, `videoDirOf`) behind reports prepare and report-to-video's local sources, and `publish/reportMedia.ts`'s per-record availability read; - umtool's local cue lookup (`report-to-video/cues.mjs`, `data//transcript.cues.json`). **The two options:** - **(a) Records take ``** (what `r20/twitch-ids` does: `canonicalTwitchVideoId` in `summarize` and in `readNormalizedTranscript`, a one-shot index re-key, the player still given `v`). 217 public transcript URLs move from `…/v` to `…/` (hasanabi 180, shondo-twitch 37); the hub needs tombstones for the old ones. - **(b) The published `v` stays**, and the join normalizes on the directory side (a reader maps a Twitch record id `v` to `data//`). No URL moves. **Commits** | Commit | What | |---|---| | `04a075b3` | `common:` D3 — the fall-through index test; FACTS "The caption-track rule"; STATE closes the bug | | `96409e24` | `common:` D1 — the `recordedDate` rule, its derivation, the index pass, the form field; CHANNEL.md | | `433815d4` | `common:` D2 — `canonicalTwitchVideoId`, `normalizeSummaryId`, the one-shot re-key, the player (kept on `r20/twitch-ids`) | | `949f7e41` | `common:` revert of D2 — held for an operator ruling | **Gates.** `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit` clean at each commit. New tests: `lib/recordedDate.test.ts` 7, `controller/buildIndexRecordedDate.test.ts` 2 (derived, absent without a rule, a date after the upload dropped; a changed rule re-derives once, a removed one drops the field from the pages), `controller/buildIndexCaptionTrack.test.ts` +1, editor `app/channels/components/parseChannelForm.test.ts` 3 (D2's 6 left with its revert and live on `r20/twitch-ids`). The whole common suite 3,661/3,661 with D2 in (release 21 D2's tests included), and after the revert the touched index, normalize, caption-track and architecture tests 54/54, tsc clean; editor unit 228/228; export unit 118/118; mcp 293/293; `archilyzer docs files --check` and `docs env --check` clean. E2E, one run in the foreground (start of the queue after the heavy slot freed): `transcript-source.spec.ts` (D3's index spec, 4) and `ops-api.spec.ts` (13, the channel-config round trip among them) — 17 passed, 1.5 min. Release end per the plan: the full editor suite once, the orchestrator's. ## As it went **The whisper red: "Clear failure list" unmounted its own log** (`r20/whisper-fix`). `whisper.spec.ts:184` failed 3/3 as a whole file on `r20/integration` in start mode (it passed alone with `-g`, and in release 19's dev runs). Not a release 20/21 regression and not the harness: `FailedTranscriptionsSection` (TranscribeStage.tsx) rendered the clear controls — the `StreamActionLog` among them — only while `ids.length > 0`, so the refresh `StreamActionLog` fires at the end of its own run re-rendered the section with an empty list and dropped the log with "Cleared 2 entries" in it, the case plans/FACTS.md's "A run log lives in the panel's React state" rules out. Under `next dev` the slow refresh left the text up long enough for the assertion; `next start`'s fast one did not. The fix is DiagnosticsStage's: a `ran` flag set on click keeps the block mounted for the page's life, its button disabled at (0). Whole file 10/10 three runs in start mode; tsc clean; editor unit 237/237. ## Rollout