commit 9babe0ee3ff820042c1b64655f8e298ea1a60877
parent 9035703147877834e25046474645d95b1de140c0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 16:22:45 -0400
plans: release 17 — the media tier (plan + record file); step 0a done
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
| A | plans/release-17.md | | | 326 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 326 insertions(+), 0 deletions(-)
diff --git a/plans/release-17.md b/plans/release-17.md
@@ -0,0 +1,326 @@
+# Release 17 — the media tier: big files move, hot text stays on the SSD
+
+Written 2026-10-01 in plan mode against `main` `03be31b5` (releases 13–16 live). Rules:
+`plans/tools/implementer-rules.md` — one Opus implementer per slice in its own worktree, one read-only
+Opus review, the parent merges `--no-ff` on a clean tree; records state rulings never reasons; counts-only privacy greps before every merge; changelog bullets checked
+by eye; the homepage build gate is `build:nodata`, never `run build`; the corpus link is for `next
+build` only. The live editor on `:3001` is restarted ONCE, after T3 merges and BEFORE the migration
+runs (the scripts live in `~/reports/release-15/scripts/r15-{build,restart,smoke}.sh`; the guard sha
+there must be updated).
+
+## Context
+
+A "moved" channel today moves its whole `data/` directory to another drive (an absolute symlink
+`channels/<slug>/data → <root>/<slug>/data`, `config.dataDir`). That puts the hot text — transcripts,
+cues, `metadata.info.json`, every sidecar — on the slow platter, so every non-media operation on a
+relocated channel (index and stats builds, the video page, digests, normalize) waits on it, and a
+platter stall holds the whole channel. The big files alone belong on the slow drive and the text on
+the SSD; the same split for umtool (manifests stay, renders go). 13 of 76 channels are relocated whole
+today and are migrated by a one-off script. A channel export/import bundle is a LATER slice built on
+this release's file classifier — not part of this one.
+
+**Measured 2026-10-01** (the 13 relocated channels, MB): media (`audio.*`, `source-media.*`) 323,000;
+everything else 91,008 — of which raw `transcript.live_chat.json` 33,457 (rekietalaw 20,958,
+friday-night-tights 7,460, kirsche 4,706) — and `clips/` 15,408 (nuxanor-kick 15,317). `/home`: 1.5 TB,
+123 GB free, gate floor 5 GB, resume margin 2 GB. The index never opens a media file (presence by name
+from one `readdir`; every `stat` is a sidecar). yt-dlp writes cwd-relative into `data/<id>/` and its
+postprocessor, like the app's transcode, `rename()`s a temp file OVER the final name (a symlink there
+would be replaced by a real file). The saved-video store (`saved-video.json` pointer, EXDEV-safe move)
+is the existing per-big-object pattern; umtool's `SONG_DIR` is the existing symlink-root pattern.
+
+## Step 0 — the editor dashboard does not load (diagnosed 2026-10-01, read-only; fixed first)
+
+**Symptom:** `/`, `/channels`, `/jobs` give no response in 40 s; `/settings` answers in 1 s; `/api/pulse`
+200; `/api/jobs/active` and `/api/auto-queue/status` never answer. **Cause (measured):** the editor's
+main thread is pegged (~97 % of a core, 485 ticks/5 s; process 435 % across V8 threads; the Platter idle,
+no errors in the log). `generateChannelSnapshot` (`common/controller/channelSnapshot.ts`) runs ON THE MAIN
+THREAD with no yielding, driven by `common/jobs/snapshotScheduler.ts:192` as `refresh-report` jobs; two
+live ones (omnibased 3,260 videos, the-quartering) have parsed thousands of ~500 KB `metadata.info.json`
+files for over an hour, starving every request that needs the loop. The dashboard's 3 s poll of the
+auto-queue status (no memo, no single-flight in `common/views/autoQueueStatus.ts`; 96 s cold this
+afternoon) piles up behind it. Three more "running" `refresh-report` metas (the-quartering-rumble ×2 at
+03:21/03:29Z, the-quartering 07:14Z) predate the 12:54 restart: ghosts of the killed process
+(`common/jobs/shutdownCancel.ts:20` "graceful shutdown only"), never finalized — and slice RM's
+`channelWriters` reads running jobs by slug, so a move of the-quartering-rumble would refuse forever.
+
+**0a — done 2026-10-01 16:18:** the editor was restarted (`r15-restart.sh --force`; the build on disk
+was current, `Xfavvt2XajdNzOvvn0NtL`); `/` answered 200 after 3 s. The scheduler re-runs the two
+snapshots; `/` watched for ten minutes after (result in the Record).
+**0b — slice D0 `r17/dashboard-answers`** (small; beside T1; editor-side, one restart):
+1. snapshot generation yields: chunk the per-video loop (`setImmediate` every N videos) so the loop
+ serves requests between chunks — or run it in a worker thread; `refresh-report` concurrency 1 and
+ dedup per slug in `snapshotScheduler.ts`;
+2. `autoQueueStatus` view: single-flight + a short memo (≈ 3 s) so N pollers cost one digest;
+3. boot finalizes stale `running`/`queued` metas from a dead process as `interrupted` (`registry.ts`,
+ `editor/instrumentation.ts` already notes "anything a previous process left queued was queued before
+ this instant"); `channelWriters` ignores them;
+4. e2e: poll `/` and `/jobs` every 2 s while a large fixture snapshot regenerates — every response under
+ 5 s; a ghost `running` meta from a fake dead pid is `interrupted` at boot and does not block a move.
+Gates: common + editor unit, `jobs`/`channels`/`channel-storage` specs, the capped editor build.
+
+## Rulings (2026-10-01; do not re-open)
+
+- **Tier now, bundle later.** The classifier is a shared `common/lib` module the bundle slice reuses.
+- **Text = hot = SSD; media = cold = platter.** `transcript.live_chat.json` is COLD (media tier): read
+ once by `normalizeLiveChat`, which derives the small `live_chat.cues.json` (hot). `clips/` stays on
+ the SSD (a cache, age-evicted) — not tiered.
+- **The whole-directory layout is retired** after the migration; `config.dataDir` is tolerated one
+ release as `legacy` (held) and removed later.
+- **umtool:** render scratch (`out/`) goes to a media root by default; deliverables (`clips/`,
+ `share-*/`, final mp4s) move per project by a switch "like channels"; manifests, `revisions/`, the
+ caches and the cue cache stay put.
+- **Migration:** one-off script, smallest text first, a stop before the three big-text channels
+ (omnibased, rekietalaw, the-quartering-rumble) with the free space reported; the operator decides.
+- **A move holds only the channel's media writers**; a digest may run during a move.
+
+## The model (A′)
+
+```
+channels/<slug>/
+ config.json mediaDir: "<root>/<slug>/media" (absent = in place)
+ data/ REAL directory on the SSD, always: text, sidecars, clips/, scratch
+ <id>/audio.mp3 -> ../../media/<id>/audio.mp3 (RELATIVE link, one per big file)
+ <id>/transcript.live_chat.json -> ../../media/<id>/transcript.live_chat.json
+ <id>/transcript.json, metadata.info.json, live_chat.cues.json, clips/… (real files)
+ media ONE absolute symlink -> <root>/<slug>/media (relocated)
+ | a REAL directory on the SSD (tiered in place)
+ | absent (classic: real files in data/<id>/)
+<root>/<slug>/media/<id>/<file> (the bytes)
+```
+
+Why A′ over absolute per-file links or a pointer sidecar: readers keep opening `data/<id>/<name>` by
+path (no reader changes; the index unchanged); the platter path exists in ONE link + ONE config key per
+channel, so slice RM's mover — marker, writers hold, copy, mirror (`--delete` toward the copy only),
+verify, Reconcile, re-point, rename, delete — carries over by renaming `data`→`media`; relative links
+survive `reconcileVideoDirs`' renames and a channel rename; move back makes `media/` a real SSD dir and
+every link stays valid (no "untier"). A classic channel keeps real files until its first move, whose
+preflight tiers it in seconds (same-filesystem `rename` + `symlink`); e2e fixtures need no layout change.
+
+### 1. Classifier — `common/lib/mediaTier.ts` (pure, no fs; exported for the bundle slice)
+- `classifyEntry(name) → "media" | "text" | "scratch"`, BY NAME never by size, over `mediaFiles.ts`'s
+ anchored predicates: media = `isRealAudioFile(name) || isSourceMediaFile(name) || name ===
+ LIVE_CHAT_FILENAME` (`transcript.live_chat.json`); scratch = `isPartAudioFile`, `/^audio\.tmp-\d+\./`,
+ `/^\.audio\..*\.parakeet$/`, `/\.part-Frag\d+$/`, `/\.temp\./`, `/\.part\.(good|testing)$/`; text =
+ everything else. `CLIPS_DIR_NAME` is never tiered.
+- `isTierable(name)` = `isRealAudioFile(name) || name === LIVE_CHAT_FILENAME` — narrower than "media":
+ `source-media.*` stays real (`persistSourceVideo` would `rename` the LINK into the saved-video store)
+ and `audio.*.part` stays real (yt-dlp's resumable partial).
+- `classifyVideoDir(entries) → { media, text, scratch }`.
+
+### 2. Hook + helpers — `common/lib/mediaTier-server.ts` (fs, no controller import)
+- `channelMediaLink(paths, slug)` = `channels/<slug>/media`; `relocatedMediaDir(root, slug)` =
+ `<root>/<slug>/media` (replaces `relocatedDataDir`, suffix fixed); `tierLinkTarget(id, name)` =
+ `../../media/<id>/<name>`.
+- `tierMediaFile(videoDir, name) → "tiered" | "left" | "already"`: lstat a link or missing → already;
+ `channels/<slug>/media` not a directory (classic channel, or ENOENT through a dangling link when the
+ platter is unmounted) → "left" (the file stays real, readers unaffected, the next sweep tiers it);
+ `mkdir(media/<id>)` NON-recursive (a bare mountpoint is never filled); `rename` → EXDEV →
+ `copyFileAtomic` + rm (the `savedVideo-server.ts:70-80` pattern); `symlink(relative)`. Never throws
+ into a download.
+- `tierVideoDir(videoDir)`; `tierChannelMedia(paths, slug, { since?, createMediaDir? })` (the mover's
+ preflight passes `createMediaDir: true`, making `media/` a real dir when neither link nor dir exists).
+- `removeMediaFile(videoDir, name)` (readlink → rm target, rm link); `removeVideoDirMedia(videoDir)`.
+- **Every media-finalising site calls the hook** (one line each): `common/controller/transcode.ts:55`
+ after `rename(tmp, out)` (covers `downloadOneManaged.ts:278`, `audioCheckedDownload.ts:769`,
+ `videoActions.ts:128`); `downloadOneManaged.ts` `tierVideoDir(videoDir)` before both
+ `writeDownloadOutcome` calls (`:1001`, `:1629` — after `reconcileVideoDirs`, covers yt-dlp's own `-x`
+ and the legacy path); `runYtdlp.ts` batch runs (sync, download-from-playlist, download-missing, retry)
+ → `tierChannelMedia(paths, slug, { since: runStartedAt })` after the child returns; `normalizeLiveChat`
+ → `tierMediaFile(videoDir, LIVE_CHAT_FILENAME)` after writing the cues (and the download path's
+ live-chat write). `persistSourceVideo`/`unpersistSavedVideo` unchanged; `fetch-window` → `clips/` no hook.
+- **Every media deleter derefs** (today each would orphan the platter file): `cleanAudioFromTranscribed.ts:158`,
+ `cleanExtraAudioFormats.ts:75`, `removeWrongFormatAudio.ts:73`, `videoActions.ts:598`, `:490-503`
+ (`deleteFile`), `deleteVideoDirAction` (+ `removeVideoDirMedia`), `backfillReacquire.ts:506`. Grep gate:
+ `git grep -n "remove(path.join(.*videoDir\|rm(path.join(videoDir" common editor` hits only `mediaTier-server.ts`.
+- One reader changes: `editor/app/channels/[slug]/videos/[id]/videoActions.ts` `loadVideoDir` filters
+ `isFile()` on dirents → `isFile() || isSymbolicLink()`, media entries stat'd through `onDrive(mediaDir)`.
+ `measureTree` keeps `isFile()` (over `data/` it measures SSD text; over `media/` the real bytes).
+
+### 3. `config.json`
+- `mediaDir?: string` (`channelConfig.ts`, `channelConfigSchema.ts`, CHANNEL.md row): "Where this channel's
+ big files live when relocated: `channels/<slug>/media` is a symlink to it, `<root>/<slug>/media`. Absent =
+ in place. Written only by relocate / re-point / the tier migration."
+- `dataDir` stays parseable one release, documented RETIRED: a channel carrying it (or whose `data/` is a
+ link) is `legacy` and held until `archilyzer storage migrate-tier <slug>` runs. (`parseChannelConfig` is
+ allow-list; dropping the key would make `patchChannelConfig` erase it on the first stamp → `inconsistent`.)
+
+### 4. Statuses, guards, lanes, health (`common/lib/channelMedia.ts`, `channelMediaHold.ts`)
+- `ChannelMediaStatus` gains `"legacy"`; the others now describe `channels/<slug>/media` + `mediaDir`:
+ marker → `in-transition`; `data/` a link or `dataDir` set → `legacy` ("its media layout is the retired
+ whole-directory one — run archilyzer storage migrate-tier"); no media link/dir and no `mediaDir` →
+ `in-place`; link ≠ `mediaDir` or dir while `mediaDir` → `inconsistent`; `mediaDir` + link agree →
+ `stat(target)` via `onDrive(mediaDir)` → `ok | unreachable | stalled`.
+- `ChannelMediaLocation`: `dataDir` (always the real text dir), `relocated`, `target` (= mediaDir), new
+ `mediaLink`, `text: { dir, readable }`. `channelMediaStall` keys on `mediaDir`; `storageHealth.ts:529`
+ `rootOfUnknownPath` strips `<root>/<slug>/media`.
+- Two guards: `assertChannelMediaReachable` (unchanged: `ok | in-place` pass — jobs that open the big
+ file) and new `assertChannelTextReadable` (passes for all but `legacy`/unreadable `data/`).
+ `isMediaHeld` (+ `legacy`), new `isTextHeld = status === "legacy"`, `HELD_REASON.legacy`.
+- `jobKinds.ts` `needsMedia` keeps its name with the doctrine "opens or writes the BIG file". Flip to
+ false: `auto-digest`, `digest-channel-local/remote`, `digest-share-cluster`, `normalize-transcripts`,
+ `purge-superseded-auto-subs`, `fetch-window`, `evict-clips`, `metadata-scan`, `download-missing-subs`,
+ `check-availability`, `quick-availability-check`, `check-maybe-missing`, `check-kept-deleted`. Stay
+ true: every download kind, `sync`, `import-one`, `redownload-*`, `retry-bucket`, `persist-kept`,
+ whisper/transcribe, `diarize-channel`, `backfill-channel`/`auto-backfill`, `scan-media*`, `clean-*`,
+ `remove-wrong-format-audio`, `transcode-audio`, and `normalize-live-chat` (reads the cold raw file).
+- Walkers call the text guard: `buildIndex.ts` `scanSource`, `buildStats.ts:291` — **new rule: the index
+ and stats builds read the text tier only and are never held by the media tier** — `channelSnapshot.ts:744`,
+ `operationBatch.ts:1580`, `normalizeAll.ts:63`, `shardActions.ts:88`. Lanes (`autoRunner.ts:370-386,641`):
+ `isMediaHeld` for transcription/download/backfill, `isTextHeld` for digest.
+- `onDrive` keyed by FILE KIND: media reads wrap `onDrive(config.mediaDir, …)`, text reads pass no drive.
+ Change: `channels.ts:108`, `channelSnapshot.ts:753` (sidecar stats direct; one `onDrive(mediaDir)` per
+ video for its media entries; `DriveNotAnswering` → that channel's media bytes `null`, snapshot still
+ written), `recencyIndex.ts:258`, `videos/page.tsx:153`, `videos/[id]/page.tsx:96,201`, the file route
+ (`classifyEntry(name) === "media"` → `onDrive(mediaDir, stat)`; **ENOENT on a path whose `lstat` is a
+ symlink → 503 "drive not answering/unmounted" with `retry-after`, not 404**). Stall budget: text never
+ takes a slot. `storageWatch.ts` auto-pause stays one tier per channel.
+- `channelWriters(slug, { mediaOnly })`: jobs filtered by `kindNeedsMedia`, lane units from lanes ≠
+ digest; the move's `liveWriters`/`previewRelocation`/`assertNoWritersUnderMarker`/`channelMediaBusyReason` pass it.
+
+### 5. The mover — `relocateChannelMedia.ts` over `media/`
+- Marker `.relocating.json` unchanged shape, `target = <root>/<slug>/media`, optional `scope: "media" |
+ "tier-migration"` (readers ignore unknown keys; `cues.mjs`' twin too).
+- `moveOut` preflight, after the writers check and before `measureTree`: `tierChannelMedia(paths, slug,
+ { createMediaDir: true })`; then today's phases with `dataDir`→`channelMediaLink`, parked names
+ `media.relocated-<ts>` / `media.incoming`, `live: channelMediaLink` for `assertMirrorDirection`,
+ `patchChannelConfig(…, { mediaDir })`. Preview tiers a classic channel the same way (same-fs,
+ idempotent) and reports "N files tiered first".
+- `moveBack` identical, ending with `media/` a real SSD dir and `mediaDir` unset. Space check = `media/`
+ bytes + margin. `relocationRootProblem`/`assertRelocationRootPresent`/`rootOfRelocatedMediaDir`.
+- `repointStorageLocation` (`storageLocations.ts:698-770`): link `media`, key `mediaDir`;
+ `channelsOnLocation` keys on `mediaDir`; `locationOfDataDir` keeps its name (pure prefix test).
+ `renameChannel.ts:125-180` and `deleteChannel` (`channels.ts:590` → `rm(config.mediaDir)`) follow.
+
+### 6. Surfaces
+- Storage panel (`StorageStage.tsx`): two rows under one heading — `aria-label="media path"` (kept;
+ `target ?? "<channelDir>/media (in place)"`), new `"text path"` (always in place), `"text bytes"`,
+ `"media bytes"` (media tier only), `"free on media volume"` kept. Buttons keep their names (`Move
+ media`, `Preview`, `Resume move`, `Reconcile and resume`, `Clear marker`); the hold sentence gains
+ "its text stays readable". `MediaLocationBadge` keeps every label and adds `legacy: "Media layout
+ retired"` (danger).
+- Snapshot (`channelSnapshot.ts:877-890`): `totalTextBytes`, `totalMediaBytes` (media tier only),
+ `totalClipsBytes` (no longer inside media); an old snapshot without `totalTextBytes` reads as unknown.
+ `/storage` (`buildStorage.ts`, `views/storage.ts`): location rows show media-tier bytes; the internal
+ row "text N GB + clips N GB on the corpus volume, plus media of in-place channels"; `legacy` counts as
+ unreachable with "(n to migrate)". Rack chip and `mediaHold` unchanged.
+- umtool twin `umtool/report-to-video/cues.mjs:285-360`: refuse `legacy` (data link or `dataDir`);
+ refuse a marker only when `scope === "tier-migration"`.
+
+## The one-off migration — `common/bin/migrate-media-tier.ts`
+Wired as `archilyzer storage migrate-tier <slug>|--all [--dry-run] [--reclaim] [--order smallest]`
+(`archilyzer.ts:268` `script([...])` beside `migrate channel-priority`). Editor STOPPED (it cannot see
+the in-process registry; refuses on a marker and says so). Dry run writes nothing; idempotent;
+resumable from the marker's phase. Per channel with `config.dataDir = D` (`<root>/<slug>/data`):
+
+| phase | action |
+|---|---|
+| preflight | status must be `ok`; `assertRelocationRootPresent(root)`; walk `D` with `classifyVideoDir` → counts/bytes by kind; space: `textBytes + resumeMarginGB ≤ free(channelsDir) − 5 GB floor` |
+| `copy` | marker `{target: <root>/<slug>/media, direction: "out", phase: "copy", scope: "tier-migration"}`; NUL list of text + scratch entries (classifier, not globs); `rsync -a --partial --info=progress2 --from0 --files-from=$T/<slug>.txt D/ channels/<slug>/data.incoming/`; verify `rsync -a --dry-run --itemize-changes --from0 --files-from=… D/ data.incoming/` empty AND per-kind counts/bytes equal; then for every `<id>` and every `isTierable` name: `mkdir data.incoming/<id>`, `symlink("../../media/<id>/<name>", …)` (EEXIST = already) |
+| `swap` | platter: `rename(<root>/<slug>/data, <root>/<slug>/media)`; SSD: `symlink(<root>/<slug>/media, channels/<slug>/media)`; `unlink(channels/<slug>/data)`; `rename(data.incoming, data)`; `patchChannelConfig(slug, { mediaDir }, { unset: ["dataDir"] })` — each step observes the disk (`linkOrDirState`) first |
+| `reclaim` | only `--reclaim`: delete from `<root>/<slug>/media/<id>/` every entry that is not `isTierable` (the platter's text copies and scratch), drop empty dirs; without it the duplicates stay until `migrate-tier --reclaim` |
+| done | clear the marker; print bytes by kind and links made |
+
+No index rebuild: `rsync -a` keeps the text mtimes and the index stats only sidecars. The snapshot's new
+byte fields come on the next Refresh report. Order `--order smallest`: realcandaceo (6 MB text),
+nuxanor-kick, omnimirror, leaflit-rumble, cornbreadman, shondo-vods, piratesoftware, kirsche,
+friday-night-tights, HasanAbiVODs3 (≈ 36 GB of SSD with live chat cold → ~16 GB; plus 15 GB clips for
+nuxanor-kick) — then STOP and report free space before omnibased (15.2 GB), rekietalaw (8.9 GB with chat
+cold), the-quartering-rumble (10.3 GB). Run `purge-superseded-auto-subs` on omnibased/HasanAbiVODs3
+first (≈ 7.6 GB of `en-orig.vtt`). Live proof: realcandaceo (seconds) → Refresh report, one video page,
+one short Transcribe (the hook on a relocated channel), `/storage`, `df`; then nuxanor-kick; then `--all`.
+
+## umtool
+- `umtool/lib/paths.mjs`: `MEDIA_ROOT = resolve(/* turbopackIgnore: true */ process.env.UMTOOL_MEDIA_DIR
+ ?? REPORTS_ROOT)`, `MEDIA_TIERED`, `mediaMirror(abs)` = the project-relative path under `MEDIA_ROOT`;
+ `MEDIA_ROOT` joins `READ_ROOTS` (never `WRITE_ROOTS`). `CACHE_DIR` decoupled from `SONG_DATA`:
+ `UMTOOL_CACHE_DIR ?? $XDG_CACHE_HOME/archilyzer/umtool`; the e2e env sets it to `${FIXTURE}/cache`
+ (`playwright.config.ts:62,73`, `projects.spec.ts:318`, `report-longform.spec.ts:21`, `make-fixture.mjs`);
+ rollout runs `umtool index` once; `umtool doctor` reports both roots and a leftover old cache.
+- `umtool/lib/report/storage.mjs`: `ensureOutDir(projectDir)` (real dir → it; link → it; absent +
+ tiered → `mkdir(mediaMirror(projectDir)/out)` + `symlink`; absent + untiered → `mkdir`),
+ `moveDirToMedia(projectDir, name)` (rsync copy, mirror `--delete --info=del`, verify, `rename(name →
+ name.moved-<ts>)`, symlink, rm parked; idempotent) and `moveDirToLocal`. Called at `driver.mjs:74,87`,
+ `build-video.mjs:2615` (`outRoot` default), `export.mjs:71`. A dangling `out` link makes `mkdir -p
+ out/clips-raw` fail loudly (nothing materialised). tmp-then-rename sites (`cut.mjs:98-99`, clip route
+ `:52-55`) untouched: tmp and final share a directory either way. `umtool storage move-out
+ <project>|--all` moves existing `out/` trees (≈ 18 of the 20 GB in `~/reports`).
+- Deliverables: `video.manifest.json` gains `"storage": { "deliverables": "local" | "media" }` (absent =
+ local), written only through `lib/report/manifest.mjs`'s writer; `umtool storage deliverables <project>
+ --to media|local` and a bench button "Move deliverables" move `clips/` and every `share-*/` with the
+ movers (only when nothing is cutting/sharing), then set the field; `cut.mjs:96` and `deliver.mjs:362`
+ create their dir through `deliverableDir(project, name)` (links when `media`); relative references
+ (`clips/<id>.mp4`) stay valid through the link. `kinds.mjs` `SKIP_DIRS` adds `clips`, plus a
+ `SKIP_PREFIXES = ["share-"]` so an unmounted media drive is never descended. Final mp4s travel with
+ `out/`. `umtool check` learns the two values.
+
+## Slices (record: this file; `editor/CHANGELOG.md` + `umtool`'s changelog `[Unreleased]`)
+
+| slice | branch | owns | after | gates beyond the common set |
+|---|---|---|---|---|
+| **D0** dashboard answers | `r17/dashboard-answers` | `common/controller/channelSnapshot.ts` (the yielding loop only — T1 owns its guard/bytes changes, so D0 lands first and T1 merges main), `common/jobs/snapshotScheduler.ts`, `common/views/autoQueueStatus.ts`, `common/jobs/registry.ts` (stale-meta finalisation), `editor/instrumentation.ts`, `common/controller/channelWriters.ts` (ignore interrupted), a new e2e spec | — | see Step 0b |
+| **T1** classifier + model + guards | `r17/media-tier-model` | `common/lib/mediaTier.ts` + `mediaTier-server.ts` (+ tests), `channelConfig*.ts` + CHANNEL.md (`mediaDir`, retired `dataDir`), `channelMedia.ts` (`legacy`, `media` link, two guards), `channelMediaHold.ts`, `storageHealth.ts:529`, `jobKinds.ts` flips, `autoRunner.ts` lanes, the walkers' text guard (`buildIndex`, `buildStats`, `channelSnapshot` + byte fields, `operationBatch`, `normalizeAll`, `shardActions`), `channelWriters.ts` `mediaOnly`, the hook call sites (transcode, downloadOneManaged, runYtdlp, normalizeLiveChat, backfillReacquire), every deleter, `recencyIndex.ts`, `channels.ts`, `cues.mjs` twin | — | classifier table incl. `audio.en-orig.vtt`, `audio.tmp-2760235.mp3`, `source-media.temp.mp4`, `transcript.live_chat.json`; hook tier/already/left, EXDEV (injected `rename` throwing EXDEV), dangling `media` link creates nothing, non-recursive mkdir; deleters deref; `channelMedia.test.ts` + legacy cases; `jobKinds` flips pinned; "a stalled media location does not hold the index/stats/snapshot, holds the transcription lane, lets the digest lane run" |
+| **T2** mover + surfaces | `r17/media-tier-mover` | `relocateChannelMedia.ts` (over `media/`, preflight tier), `relocateDir.ts` (names), `storageLocations.ts` (lib + controller: re-point, rollups), `renameChannel.ts`, `deleteChannel`, editor `StorageStage.tsx`, `storageActions.ts`, `relocationJob.ts`, `bulkStorageActions.ts`, `MediaLocationBadge.tsx`, `channelRow.ts`, `videos/[id]/page.tsx` + `videoActions.ts` (`loadVideoDir`, file-kind `onDrive`), the file route 503, `videos/page.tsx`, `[slug]/page.tsx`, `buildStorage.ts`, `views/storage.ts`, `storageWatch.ts` | T1 | `relocateChannelMedia.test.ts` rebased on `media/` (classic → tiered → moved; interrupt at each phase + resume; reconcile; back leaves a real `media/`); `renameChannel`, `storageLocations`, `channels` tests; e2e `channel-storage`, `storage-locations`, `channels-storage-columns`, `bulk-actions`, `maybe-missing`, `video-page`, `fetch-window` (+ the new cases under Verification) |
+| **T3** migration + records | `r17/media-tier-migrate` | `common/bin/migrate-media-tier.ts`, `archilyzer.ts` wiring, fixture tests (tmp "platter"), FACTS "A channel's media is tiered", AGENTS.md's six things → seven, SETTINGS.md/CHANNEL.md regen, the release record, changelog | T1, T2 | dry run; resume from each phase; idempotent rerun; `--reclaim`; refusal on a marker; the free-space stop |
+| **U1** umtool roots + `out/` | `r17/umtool-media-root` | `paths.mjs` (`MEDIA_ROOT`, `CACHE_DIR`), `lib/report/storage.mjs`, `driver.mjs`, `build-video.mjs:2615`, `export.mjs`, `kinds.mjs`, `umtool doctor`, `umtool storage move-out`, the e2e env | — (∥ T1) | `test:scripts` (+ mover tests), `next-build-trace.test.mjs`, the capped umtool build with the corpus linked, umtool e2e |
+| **U2** deliverables switch | `r17/umtool-deliverables` | manifest `storage` field, `deliverableDir`, `cut.mjs:96`, `deliver.mjs:362`, `umtool storage deliverables`, bench "Move deliverables", `umtool check` | U1 | umtool unit + e2e: cut and share through a linked `clips/` |
+
+Order: 0a → D0 ∥ T1 ∥ U1 → T2 ∥ U2 → T3 → parent: records, ONE editor rebuild + restart, umtool rebuild +
+restart (the restart is the operator's: the permission layer refuses the `0.0.0.0` bind) → the migration
+(editor stopped for it) → live proof → records/STATE/memory. AGENTS.md's seventh non-optional thing: **"A
+media file in `data/<id>/` may be a relative symlink into `channels/<slug>/media/`. Remove one with
+`removeMediaFile`, never `rm`/`remove`; tier one with the hook after every media finalisation, never by
+hand; a dirent `isFile()` filter over a video dir hides it."** The `.relocating.json` bullet gains
+`scope`; the `clips/` bullet gains "on the SSD, never tiered".
+
+## Verification
+- Unit as the table says; plus the health split end to end: a stalled/absent media location → index,
+ stats and snapshot run; digest lane runs; transcription lane skips; `whisper-video` refused;
+ `normalize-transcripts` runs; `normalize-live-chat` refused.
+- e2e (`channel-storage.spec.ts` rebased): after Move media, `data/<id>/audio.mp3` is a link and
+ `transcript.live_chat.json` is a link; the videos list and the file route serve the transcript AND
+ stream `audio.mp3` through the link; a renamed-away media root (the `seedDriveChannel` + rename trick
+ from `buildStats.test.ts`) keeps the video page's text readable, the file route answers 503 for
+ `audio.mp3`, the rack shows "media held", the digest lane still picks the channel; Move back; a
+ `legacy` fixture shows "Media layout retired" and a media job's refusal names `migrate-tier`.
+ `migrate-tier` on a fixture: dry run, run, rerun, `--reclaim`, kill between `copy` and `swap`, resume.
+- Live: realcandaceo → nuxanor-kick → `--all --order smallest` with the stop at the big three; after each
+ channel: Refresh report, one video page, one short Transcribe, `/storage`, `df`.
+
+## Assumptions the operator can overturn
+
+| assumed | alternative |
+|---|---|
+| A′: relative per-file links into `channels/<slug>/media/`, `media/` is the unit of relocation | absolute per-file links (no `media/` tree): a per-video mover for out and back, O(files) rewrites on re-point/rename |
+| `dataDir` kept one release as `legacy` (held) | drop it now; the migration reads the raw file (`migrate-channel-priority.ts` pattern); risk: a stamp before migration erases the record |
+| a move holds media writers only; digests run during a move | hold every writer as RM does today (no `channelWriters` change) |
+| the `media` link is `channels/<slug>/media` (visible) | `.media` (hidden) |
+| platter reclaim is opt-in (`--reclaim`) | reclaim in the same run after verify |
+| the move's preview tiers a classic channel (same-fs renames) | preview only reports "N files to tier"; the job tiers |
+| umtool `out/` is a directory link made by the first writer; existing `out/` stay until `move-out` | resolve `out/` through a function (no link): ~20 join sites + `deckPreviewFile`'s base change |
+| auto-pause stays one tier per channel | a media stall pauses only the media lanes (per-lane `autoPaused`) |
+| `UMTOOL_MEDIA_DIR` unset by default (behaviour unchanged until set) | default to `settings.storage.locations[platter].root + "/umtool"` (umtool reads no settings today) |
+
+## Open questions (settled during T1/U1; the answer is recorded in the slice's section)
+1. What `import-one` writes (its controller was not found; `pipelineActions.ts:487` enqueues it) — assumed a media writer.
+2. The four `*.mp4` at umtool project roots — deliverables or inputs? Left untouched.
+3. `reconcileVideoDirs` on a tiered dir: links move with the files; `media/<extractorId>/` keeps its old name (valid, untidy) — a follow-up renames it.
+4. Whether the Platter's media root should be created as `<root>/<slug>/media` beside today's `<root>/<slug>/data` (the migration renames one to the other) — yes as planned; confirm no other tool hardcodes `<root>/<slug>/data`.
+
+## Follow-ups carried over (not scheduled; keep)
+- "Load more results" never resumes a leaf that settled at its cap (`runQueryTree`'s `setHitLimit` reaches running leaves only) — release 16 CK R-I1; wants a spec.
+- `/changelog/` overflows a 390 px phone (long inline code in `export/CHANGELOG.md:103`); wrap `<code>` in the changelog renderer, then drop the `test.fail` in `export/e2e/responsive.spec.ts`.
+- `export-search.spec` "Advanced reset does not touch filter checkboxes": the "Deleted" checkbox locator also matches a result card's "Select … for AI" box — `exact: true`.
+- `social-channel.spec` fetch-posts case runs 22–26 s of a 30 s budget — `test.slow()` or a 60 s timeout.
+- The JSX entity/whitespace hazard sweep: 22 texts in 19 files (FACTS).
+- The editor's and export's build traces list dot-directories; `REQUIRED_TOKENS` lacks `--chart-other`; export `WorkspaceView` `splitOn` one-paint flash.
+- The build-only "Build all" through the rebuilt build image when the machine is idle.
+- Docker for others: no pull-able image; needs a GitHub home first; the version isn't tied to `release cut`.
+- X section by-hand check (release 16 XL live).
+- `channel-rename.spec`'s refresh race; RM L5: a cancelled job whose function never returns holds the channel's move until a restart (offer Force release on a cancelled row).
+- XL L2/I-items: Firefox store discovery differs from gallery-dl's; the profile side's "logged in" check accepts any X domain.
+- The `en` track → 0 cues bug (index prefers `en` over `en-orig`; some `en` VTTs parse to 0 cues).
+- A channel export/import **bundle** built on `mediaTier.ts`'s classifier — the slice after this release.
+
+## Record
+
+## Rollout