Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 14f7ccdb4a38dcfe5975a5879c73833113ba1f49
parent d3b83b0c04098d7c1702f4a33c0c766c883c6606
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 19:11:57 -0400

plans: stats cache key — the re-review's touch-ups to the rollout, FACTS and STATE

Re-review (SHIP) R1, R2, R4, R5, the nits and the recommended follow-up:
every rollout command as typed from the checkout root (`pnpm archilyzer …`);
step 3 reads the index's `Diff:` line (thousands removed = a drive was
missing: mount it and re-run the index before the stats); the stats refusal's
ways out, mounting first; the hub built, its `hub-summary.json covers N` line
checked, and only then deployed; numbered preconditions; and the follow-up,
in the record and STATE, that the index build still treats an unmounted drive
as an empty channel. FACTS anchors refreshed after d3b83b0c.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mplans/FACTS.md | 49++++++++++++++++++++++++++++++-------------------
Mplans/STATE.md | 15+++++++++++----
Mplans/stats-cache-key.md | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
3 files changed, 127 insertions(+), 55 deletions(-)

diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -5705,7 +5705,7 @@ Every `file:line` below was grepped at `7dfd7508`, whose code is byte-identical - Export pages (buildIndex, buildStats, compose-homepage): compact, no newline. - Chart, alias and tag stores: indented, no newline. - Everything else: indented, with a newline. - The six local `function writeJsonAtomic` left in `buildIndex.ts:404`, `buildStats.ts:354`, + The six local `function writeJsonAtomic` left in `buildIndex.ts:404`, `buildStats.ts:376`, `bin/compose-homepage.ts:40`, `aliasesStore.ts:23`, `chartsStore.ts:50` and `curatedTagsStore.ts:61` are one-line wrappers that pin those bytes over the shared writer. They are not copies. @@ -5810,7 +5810,7 @@ Every `file:line` below was grepped at `7dfd7508`, whose code is byte-identical - **`readChannelConfigFile(file)`** `:165-170`. It never throws, and answers null for absent, unreadable, not JSON, or not a channel. `readChannelConfig(paths, slug)` `:172` wraps it, - and `buildIndex.ts:296` and `buildStats.ts:267` call it directly. The header `:152-157` names + and `buildIndex.ts:296` and `buildStats.ts:285` call it directly. The header `:152-157` names the raw readers that bypass it: `channelMedia.ts:179` (the `dataDir` guard) and the legacy migrations (`migrateToSites.ts:101` for `group`, `bin/migrate-channel-priority.ts` for `excludeFromSync`, which also WRITES raw at `:206`). @@ -5894,7 +5894,7 @@ which is the same race class 4b fixed for `config.json`. Not in the 14: -- The export build's streamed page writers `buildIndex.ts:971` and `buildStats.ts:336`. These +- The export build's streamed page writers `buildIndex.ts:971` and `buildStats.ts:358`. These are JSON writers still on the per-pid name, not in the list above only because there is one writer per build. They are owed with the rest (16 + 2). - `metadataScanStore.ts:188-206` and `autoQueueState.ts:141-156`. They carry a module-level @@ -6142,7 +6142,7 @@ complete with this release. - **The two write counters are deleted.** `git grep -n 'writeSeq\|nextWriteSeq' -- common editor` is empty. - **What `git grep -n 'tmp-${process.pid}' -- common editor` still finds:** - - `buildIndex.ts:971` and `buildStats.ts:336`, the export page writers, out of scope; + - `buildIndex.ts:971` and `buildStats.ts:358`, the export page writers, out of scope; - `transcode.ts:31` (ffmpeg's output, renamed at `:55`); - `transcribeOne.ts:142` (the transcription app's `outputBase`); - the shared writer's own comment `:9`, code `:136`, and test `:68`. @@ -7330,12 +7330,12 @@ on anchors elsewhere in this file: - **The stats cache is keyed on the metadata AND the index's own record for the video.** - `statsByPath` (`common/controller/buildStats.ts`) holds `{metaMs, idx, stat}` per - `[channelSlug, videoDir]` (`:87`). A stat is recomputed when `metaMs` or `idx` moved (`:458`). - - `idx` is `indexSignature` (`:109`) of buildIndex's whole `mtimes` record: `metaMs`, + `[channelSlug, videoDir]` (`:92`). A stat is recomputed when `metaMs` or `idx` moved (`:482`). + - `idx` is `indexSignature` (`:114`) of buildIndex's whole `mtimes` record: `metaMs`, `transcriptMs`, `subsMs`, `availabilityMs`, `digestMs` (every input that makes buildIndex re-process the video, `buildIndex.ts:585`) and `indexKey`. It is `NOT_INDEXED` (`"-"`, - `:102`) when the index has no record. - - The cues are read under the record's own `indexKey` (`:519`). The key buildIndex used when + `:107`) when the index has no record. + - The cues are read under the record's own `indexKey` (`:543`). The key buildIndex used when `transcript.cues.json` is fresh comes from that file's `uploadDate`, which a later metadata rewrite can differ from; the computed key is only the fallback for a video the index lacks. - Cost on the unchanged path: one LMDB get per video, and no file I/O. @@ -7356,20 +7356,25 @@ on anchors elsewhere in this file: `buildIndex.ts:2006`, when a build completes. The value is the time its scan began (`:564`). - **`notIndexedYet`:** metadata newer than `scannedAt`, or no build has completed yet. The video was downloaded since, and it heals on the first stats run after the next index build. - - **`notIndexable`:** the last scan saw the video and did not index it. That means no - `upload_date` (`buildIndex.ts:701`) or a processing failure. It stays until fixed, and is - logged as such rather than as pending (`:471`). + - **`notIndexable`:** older than `scannedAt`, and the index has no record. The build did not + index it: + - no `upload_date` (`buildIndex.ts:701`); + - a processing failure; + - or the channel's media was unreachable during that build, since buildIndex has no drive + guard. + + It stays until fixed, and is logged as such rather than as pending (`:500`). - **A transcript always has a date, and a caption video takes its captions' arrival.** - `resolveAcquisitionDates` (`:221`) tries, in order: + `resolveAcquisitionDates` (`:227`) tries, in order: 1. transcribe-outcome's `transcribedAt`; - 2. the mtime of the picked index transcript, which is `transcript.json`, else the caption VTT (`:235`); + 2. the mtime of the picked index transcript, which is `transcript.json`, else the caption VTT (`:241`); 3. `transcript.cues.json`; - 4. `downloadedDate` (`:239`). + 4. `downloadedDate` (`:245`). Whisper videos resolve as before. The one difference is an outcome sidecar whose date will not parse: it now falls through instead of giving null. -- **The downgrade guard.** A build never clears a cache that a NEWER schema wrote (`:402`). It - throws, naming both versions and `ARCHILYZER_STATS_ALLOW_DOWNGRADE` (`:124`). That variable is +- **The downgrade guard.** A build never clears a cache that a NEWER schema wrote (`:424`). It + throws, naming both versions and `ARCHILYZER_STATS_ALLOW_DOWNGRADE` (`:129`). That variable is declared in `envVars.ts` for a deliberate rollback. - The guard helps FUTURE bumps only. Schema-5 code has no guard, and would clear a schema-6 cache. - `STATS_SCHEMA_VERSION` is 6 (`stats.ts:11`). It versions the cache. The pages' version, @@ -7377,15 +7382,21 @@ on anchors elsewhere in this file: - `digestPlan.ts:395,447` and `duplicateShorts.ts:228` only warn on a mismatch, and read `value.stat` alone. - **An unmounted media drive is not an empty channel, for stats either.** - - `scanSource` asks `inspectChannelMedia` per channel (`:276`). A channel that is not `ok` or - `in-place` is "held": not rescanned, its cached stats kept and published (`:485`), and logged. - - A schema clear with any channel held REFUSES (`:421`), because the clear would drop that + - `scanSource` asks `inspectChannelMedia` per channel (`:294`). A channel that is not `ok` or + `in-place` is "held": not rescanned, its cached stats kept and published (`:509`), and logged. + - A schema clear with any channel held REFUSES (`:443`), because the clear would drop that channel's stats for good. + - The message names each held channel, with its storage location's label and no path. + - It lists the ways out, mounting first: mount its media; repair or re-point its location on + /storage; finish or clear its move; or, for a channel gone for good, delete it or set + `excludeFromBuild`. An excluded channel is skipped before the check. - This is the build's own guard. The job registry's `needsMedia` check (`streamCommand.ts refuseForUnreachableMedia`) is per channel and needs a `channelSlug`, so it never covered this pool-wide build, from the editor or from the CLI. - **buildIndex has no such guard.** An index build with a drive unmounted drops those channels' index records, and the site pages built from it lose them. + - Its `Diff: … -R removed` line (`buildIndex.ts:641`) shows it. + - A proper hold there is a follow-up slice (`stats-cache-key.md`, "Left"). - **One stats build at a time: an operator rule, not a lock.** - Two concurrent runs are harmless unless one clears the cache (a schema change) after the other has scanned. The other then collects a partly refilled `statsByPath` and publishes truncated diff --git a/plans/STATE.md b/plans/STATE.md @@ -3,8 +3,8 @@ The working memory for the local-AI derived-corpus work. Rewritten at the end of every session, before context is cleared. See [`README.md`](README.md) for the protocol. -**Now (2026-09-28, night): the stats cache key fix — built, reviewed (SHIP AFTER FIXES, fixes -done), not merged.** The branch is `fix/stats-cache-key`, and [`stats-cache-key.md`](stats-cache-key.md) +**Now (2026-09-28, night): the stats cache key fix — built, reviewed (SHIP AFTER FIXES, then SHIP +on re-review; every touch-up done), not merged.** The branch is `fix/stats-cache-key`, and [`stats-cache-key.md`](stats-cache-key.md) holds the record, the review and the rollout. FACTS has "The stats cache key". - **What it fixes:** the homepage showed Jasolyzer as 0 transcripts, 0 channels, 0 hours while it served 1,889 videos. Instance-wide it showed 49,798 transcripts of about 77,000. @@ -18,8 +18,15 @@ holds the record, the review and the rollout. FACTS has "The stats cache key". :3001 (until then, never press "Build stats dataset"). Then index, then one full stats pass of 10–30 min, then the homepage, the hub and the sites. The homepage deploy waits on release 12's step 0: it runs the source publish. -- **Merge note:** `homepage/social-visible` conflicts only in the changelogs' `[Unreleased]`. Keep - both sides. +- **Merge note:** against `homepage/social-visible` (tip `afc642fd`) the only conflict is + `homepage/CHANGELOG.md`'s `[Unreleased]`. Keep both sides. Against `main` there is none. +- **FOLLOW-UP, its own slice: the index build still treats an unmounted drive as an empty + channel.** It drops that channel's index records, and the next site build publishes the channel + as gone. + - The fix: give `buildIndex` the stats build's hold, or at least a refusal with an override. + - Schedule it before routine builds resume after this rollout. + - Until then, the rollout's step 3 `Diff:` check is the safeguard: thousands removed means a + drive was missing. **Now (2026-09-28, evening): release 12 — the source mirror — is merged to `main` and NOT rolled out.** [`release-12.md`](release-12.md) holds Q's and R's records, their reviews, "Merged" and diff --git a/plans/stats-cache-key.md b/plans/stats-cache-key.md @@ -62,7 +62,9 @@ The real numbers come from the first rebuild. | `e4c61c77` | `common:` the review's fixes (see "Review" below): the downgrade guard, the whole index record as the key with the cues read under its `indexKey`, "not indexed yet" and "not indexable" counted apart, an unmounted drive's stats kept (and a clear refused), and test hygiene. | | `9e61b119` | `common(test):` `source.test.ts` expands the `~` the kept-scratch log prints (release 12's test). | | `000273c0` | `common:` test (i) asserts the kept stats before the new result field. | -| this commit | `plans:` the record's review, gates and rollout; FACTS; STATE; the three changelogs. | +| `22ec383e` | `plans:` the record's review, gates and rollout; FACTS; STATE; the three changelogs. | +| `b30b52c1` | `common:` re-review R3 and R4: the "not indexable" line names an unreachable drive, and the held-channel refusal names every way out, without paths. Test (z) asserts the LMDB file is under the temp root. | +| this commit | `plans:` re-review R1, R2, R4 (record), R5 and the nits: the rollout's commands as typed, the index's `Diff:` check, the hub's summary check, numbered preconditions, and the index follow-up in the record and STATE. | - **Caption videos are dated by when their captions arrived** (the VTT's mtime), not by a later Normalize run. Whisper videos resolve as before. Their "Transcribed over time" curves move: on @@ -73,8 +75,9 @@ The real numbers come from the first rebuild. `buildStats` against the index as it stands, and they still do not build the index. - A video they meet before the index has it is keyed `NOT_INDEXED`. It is recomputed on the first run after the next index build. - - The log counts separately the videos downloaded since the last index build and the ones that - build skipped (no `upload_date`, or a failure). + - The log counts separately the videos downloaded since the last index build and the ones older + than it that it did not index: no `upload_date`, a failure, or their channel's media unreachable + during that build. - A refusal would stop these builds whenever the editor had downloaded since the last index build, which is almost always. - **Concurrent stats builds: an operator rule, not a lock** (review L3, brief item 5). @@ -92,13 +95,13 @@ no code defect; every fix was made on this branch. | Finding | Where | | --- | --- | -| L1: the record overstated which paths run old code | this commit: the rollout says only the in-process **Build stats dataset** runs old code, and step 1 is "rebuild the editor bundle, then restart"; the editor changelog says the same. | -| L2: wrong rollout commands | this commit: every command checked against `archilyzer --help` and `pnpm ops --help`. The homepage and the hub are each built and deployed ONE way, the hub before the sites, and `build-deploy` takes `{"all":true}`. | +| L1: the record overstated which paths run old code | `22ec383e`: the rollout says only the in-process **Build stats dataset** runs old code, and step 1 is "rebuild the editor bundle, then restart"; the editor changelog says the same. | +| L2: wrong rollout commands | `22ec383e`: every command checked against `pnpm archilyzer --help` and `pnpm ops --help`. The homepage and the hub are each built and deployed ONE way, the hub before the sites, and `build-deploy` takes `{"all":true}`. | | L3: concurrent runs; the removable drive | `e4c61c77`: an unmounted drive's channel is held and its stats kept, and a cache clear with one refuses. Rollout step 2 is the precondition. The lock was **left**: see "Concurrent stats builds" above. | | L4: test hygiene | `e4c61c77`: every `getPaths()` path is pinned under the temp root, the root is removed in `after`, and case (z) spies on node:fs and node:fs/promises (async and sync) and fails on any write outside it. | | L5: the "not in the index yet" line was wrong for skipped videos | `9bc3c635` + `e4c61c77`: `notIndexedYet` and `notIndexable`, logged apart, with case (h). | -| L6: the time estimate left out the USB drive | this commit: 10–30 minutes, with the reason; interruptible and resumes. | -| L7: no homepage changelog bullet | this commit: `homepage/CHANGELOG.md`, and the export bullet says "once the site is rebuilt". | +| L6: the time estimate left out the USB drive | `22ec383e`: 10–30 minutes, with the reason; interruptible and resumes. | +| L7: no homepage changelog bullet | `22ec383e`: `homepage/CHANGELOG.md`, and the export bullet says "once the site is rebuilt". | | The guard (ruled: add it) | `e4c61c77`: a build refuses to clear a cache a newer schema wrote, and names both versions and `ARCHILYZER_STATS_ALLOW_DOWNGRADE`. The variable is declared in `envVars.ts`; `ENVIRONMENT.md` is regenerated and `--check` is clean. Case (j) covers older → cleared, newer → refused (also the CLI, exit non-zero, cache untouched), and the override → cleared. | | O1 (ruled: do it) | `e4c61c77`: the cues are read under the `mtimes` record's `indexKey` (case (f)), and the key is the whole record (case (g)). About 40 lines with comments, and no new I/O on the unchanged path: the same one LMDB get per video. | | O2: date from the index's mtime | **Left.** The fresh readdir happens only on the recompute path, and it keeps the Whisper rule byte-identical to before. | @@ -122,6 +125,20 @@ no code defect; every fix was made on this branch. (a), (c), (d) and (z) pass there, as they should: (a) to (d) were fixed before the review. +### Re-review + +**Verdict: SHIP.** No code defect; five Low touch-ups and two nits, all made: + +| Finding | Where | +| --- | --- | +| R1: `archilyzer` is not on PATH | this commit: every rollout command is written as typed from the primary checkout's root, `pnpm archilyzer …`, as release 12's records spell it. Nothing else on the branch spells a bare command: the code's messages name none, and FACTS and the changelogs name scripts or pages. | +| R2: step 3 had no post-check | this commit: the index step now reads its `Diff:` line. Thousands removed means a drive was missing; mount it and re-run the index before step 4. | +| R3: "not indexable" blamed the video for a missing drive | `b30b52c1`: the line adds "or its channel's media was unreachable during that build (run an index build with every drive mounted)", and case (h) expects it. | +| R4: the refusal gave one way out | `b30b52c1` (message) and this commit (record): mount its media first; else repair or re-point its location on /storage, finish or clear its move, or delete the channel or set `excludeFromBuild`. The message carries no path: a held channel is named with its location's label. Case (i) asserts both. | +| R5: a hub build with no figures still deploys | this commit: step 6 builds the hub, checks the log for `hub-summary.json covers N official instance(s)`, and deploys only then. `hub-summary.json skipped: …` means stop. | +| Nits | this commit numbers the preconditions; `b30b52c1` asserts `paths.lmdbPath` is under the temp root in case (z). | +| Follow-up (recommended) | recorded under "Left" and in STATE: **the index build still treats an unmounted drive as an empty channel.** It needs its own slice before routine builds resume. Until then, R2's check is the safeguard. | + ## Gates (worktree, 2026-09-28, at the review fixes) - **tsc:** clean (43 s). @@ -132,7 +149,7 @@ no code defect; every fix was made on this branch. - `test:scripts`: 185 pass, 1 skipped; - mcp: 271 of 271; - homepage unit: 7 of 7. -- **Docs:** `archilyzer docs env --check` is clean. +- **Docs:** `pnpm archilyzer docs env --check` is clean. - **Builds:** `next build` succeeded for export (36 s), editor (55 s) and homepage (23 s). - **e2e:** - Before the review (at `6c7ff664`): @@ -145,8 +162,17 @@ no code defect; every fix was made on this branch. - Export and homepage were not rerun. The review fixes change no export or homepage code; the homepage fold is unchanged since `9a8bded7`. +- **At the re-review touch-ups (`b30b52c1`):** + - tsc is clean; + - `buildStats.test.ts` and `envVars.test.ts` pass 20 of 20; + - `pnpm archilyzer docs env --check` is clean; + - no e2e was run, since only wording and one assertion changed. + ## Rollout (operator) — follow it literally, in this order +Every command below is typed **from the primary checkout's root**. There is no `archilyzer` on +PATH, so it is `pnpm archilyzer …`. + **What runs which code.** - **The live :3001 editor runs its BUILT bundle** until it is rebuilt and restarted. The only stats path that runs inside that bundle is the **Build stats dataset** button (`buildStatsAction`, @@ -161,17 +187,18 @@ no code defect; every fix was made on this branch. and does the whole pass inside that job. **Preconditions for steps 3 and 4.** -- **The removable media drive is mounted.** `/storage` shows every location **Available**. - - The stats build now refuses a cache clear while any channel's media is unreachable. - - **The index build has no such guard.** Run with the drive absent, it drops those channels from - the index, and the next site build publishes them as gone. -- **No other index, stats or site build is running.** - - `/jobs` shows no `build-index`, `build-stats`, `build-site`, `build-deploy`, `build-hub` or - `build-homepage` job running or queued, on any queue. - - No CLI or spawned build is running: - `pgrep -af 'archilyzer\.ts (index|build|compose)'` prints nothing. Every CLI build and every - spawned data phase or compose goes through `archilyzer.ts`; the in-process editor jobs do not - show here, and `/jobs` covers them. +1. **The removable media drive is mounted.** `/storage` shows every location **Available**. + - The stats build now refuses a cache clear while any channel's media is unreachable. + - **The index build has no such guard.** Run with the drive absent, it drops those channels + from the index, and the next site build publishes them as gone. Step 3's `Diff:` check is what + catches it. +2. **No other index, stats or site build is running.** + - `/jobs` shows no `build-index`, `build-stats`, `build-site`, `build-deploy`, `build-hub` or + `build-homepage` job running or queued, on any queue. + - No CLI or spawned build is running: + `pgrep -af 'archilyzer\.ts (index|build|compose)'` prints nothing. Every CLI build and every + spawned data phase or compose goes through `archilyzer.ts`; the in-process editor jobs do not + show here, and `/jobs` covers them. **The steps.** @@ -181,35 +208,56 @@ no code defect; every fix was made on this branch. - **never press Build stats dataset**; - **start no site, hub or homepage build**. It would do step 4's full pass itself, inside that job, unannounced. -2. **Check the preconditions above.** -3. **Index.** Use `archilyzer index`, or `/sites` → **Build index** (`pnpm ops build-index --wait`). - It writes the same LMDB as the editor, so run it only with no build job running (precondition 2). -4. **Stats.** Use `archilyzer build stats`: the CLI, with the editor idle. The in-process button - stalls the editor for the length of the pass. +2. **Check preconditions 1 and 2 above.** +3. **Index.** Use `pnpm archilyzer index`, or `/sites` → **Build index** + (`pnpm ops build-index --wait`). It writes the same LMDB as the editor, so run it only with no + build job running (precondition 2). + + **Then check its `Diff:` line**, `Diff: +A added, ~C changed, -R removed, N total.`: + - with `pnpm archilyzer index` or `pnpm ops build-index --wait`, it is in the terminal output; + - from the button, it is in the `build-index` job's log on `/jobs`. + + **R should be 0, or a handful.** Thousands removed means a drive was missing during the build, + and those channels just left the index. Stop, mount the drive (precondition 1), and run step 3 + again before step 4. +4. **Stats.** Use `pnpm archilyzer build stats`: the CLI, with the editor idle. The in-process + button stalls the editor for the length of the pass. - The first run logs `Stats schema change (5 -> 6); clearing stats cache.` and re-extracts every video: **about 10–30 minutes, longer with a cold cache.** About a quarter of the video dirs are on the USB drive, at 4–5 random reads each. - It can be interrupted (Ctrl-C, or cancelling the job) and **resumes**: the schema is written at the clear, so the next run only finishes the rest. + - **If it refuses because a channel cannot be read,** the message names the channel and its + location. The ways out, in order: + 1. mount its media and run step 4 again; + 2. repair or re-point its location on `/storage`; + 3. finish or clear its move, from the channel's Storage panel; + 4. if the channel is gone for good, delete it or set `excludeFromBuild` in its config. - **Let it finish before step 5.** 5. **Homepage.** Use exactly ONE of: - - `archilyzer build homepage && archilyzer deploy homepage`; + - `pnpm archilyzer build homepage && pnpm archilyzer deploy homepage`; - `pnpm ops build-homepage --json '{"deploy":true}' --wait`. Building the homepage **also runs release 12's source publish**. So the homepage waits on release 12's rollout step 0: the denylist is complete and `source publish --check` is clean. The homepage and source in `main` at that moment must be the ones the operator has judged. -6. **Hub, before the sites** (in basic mode the hub and the sites share `export/out`). Use exactly - ONE of: - - `archilyzer build hub && archilyzer deploy hub`; - - `pnpm ops build-hub --json '{"deploy":true}' --wait`. +6. **Hub, before the sites** (in basic mode the hub and the sites share `export/out`). Build it, + check its log, and only then deploy it. Use exactly ONE pair: + - `pnpm archilyzer build hub`, then `pnpm archilyzer deploy hub`; + - `pnpm ops build-hub --wait`, then `pnpm ops deploy-hub --wait`. + + **Between the two,** the build's output (the compose line, `compose-hub: …`) must end with + `hub-summary.json covers N official instance(s)`, where N is the number of public sites (6 today). + **`hub-summary.json skipped: …` means the hub would deploy with no figures on its cards.** Stop + and fix the cause it names before deploying. 7. **The six sites:** `pnpm ops build-deploy --json '{"all":true}' --wait`. **Live check.** - The homepage's Jasolyzer card shows about 1,751 transcripts, 1 channel and about 3,808 hours. - `https://jasolyzer.pages.dev/stats/page-0000.json` has no record with `hasTranscript: true` and `transcribedDate: null`. -- Step 4's log names no held channel. +- Step 4's log has no `Channel …: … cached stat(s) are kept` line, which would mean a held + channel. ## Left @@ -217,8 +265,14 @@ no code defect; every fix was made on this branch. (`transcriptCoverage(undefined, d > 0)`), so MCP `get_video_metadata` tells it "covers only 0% — truncated". This is older than the cache bug and out of scope. The coverage should be null when there are no cues. -- **buildIndex treats an unmounted drive as an empty channel** and drops its index records. The - stats build no longer does; the index build still needs the precondition. +- **FOLLOW-UP, its own slice: the index build still treats an unmounted drive as an empty + channel.** + - What it does now: it drops that channel's index records, and the next site build publishes the + channel as gone. + - What it needs: `buildIndex` gets the same hold the stats build now has (keep the channel's + `mtimes`, cues and pages), or at least a refusal with an override. + - When: schedule it before routine builds resume after this rollout. + - Until then, the rollout's step 3 `Diff:` check is the safeguard. - **A cross-process lock for builds** (see "Concurrent stats builds" above). The rule stands in for it. - **O5:** a manual English caption (no inline timing tags) indexes as 0 cues (FACTS).