commit 7c0b0556f4121b9f19118e68b8cd251514b8a649
parent 882f0c781afc628f463b17a7ad7850f90ab9cb9e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 20:50:47 -0400
plans: slice T1 as shipped — the classifier, the media link and the two guards; FACTS; the editor changelog
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 222 insertions(+), 0 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -16,6 +16,7 @@
- **A form whose save is refused keeps what you typed.** Every editor form put its plain fields back to the stored values when its save was refused — a site's ID rejected, a page size out of range, a slug already taken — so everything typed had to be typed again. A refused save now leaves every field as you left it, beside the reason: **Settings**; a site's form (new and existing); the hub's config on `/sites`; **Cut release**; a channel's form (new and **Configure**), **Rename** and **Delete**; a video's **Delete directory**; **Drive health timing** on `/storage`; the backup config on `/saved-videos`; the sync operation's controls; the **Digest**, **Diarization**, **Speaker attribution** and **Speaker work lane** settings; and the worker list on `/workers`. A save that succeeds behaves as before, with one difference you may notice: a drop-down, and a checkbox or choice that the page tracks as you change it (a cadence, a worker's **Enabled**, a social link's **Keep in header**, a site membership, a site's accent), now shows what was saved. A form's own drop-downs used to go back to what the page had loaded with until a reload, and a second save from the same page sent that old choice again; the others went back until the page next refreshed itself (every 5 seconds by default).
- **A media move no longer starts over a job that is writing into the channel, holds the channel's writers while it runs, and makes its copy match the source before it verifies — so a transcription or a download during a move cannot fail it.** A move that has waited its turn behind other moves now checks again when it starts: if a job is running on the channel, or an auto-queue lane is working on one of its videos, it stops at once and says which ("a transcription of abc123 is running (Transcribe all, job …) — wait for it or cancel it"), with nothing copied — and a job you have just cancelled counts until it has actually stopped ("is stopping … — wait for it to stop"); **Preview** says the same, and the Storage panel's blocked message now names the job too. While a move's marker stands, the channel is held: every lane skips it, and every job that reads or writes its media (single-video transcriptions, downloads and transcodes and the availability checks now included) refuses to start, including one that was already queued when the move began. The rack shows a **media held** chip in the channel's Tier cell and the Storage panel says "Held: its media is moving"; both go when the move finishes or its marker is cleared. The copy is now followed by a pass that makes the destination copy match the source — files the source no longer has are removed from the copy, never from the source — so a file written or deleted during the copy (a transcriber's scratch folder, say) no longer fails the check, and **Resume move** finishes a move whose copy holds such leftovers. Every file removed from a copy is listed in the move's log, and **Preview** says so when a copy from an earlier attempt is already there. If the source keeps changing, the move stops and lists what differs: extra on the destination, missing there, or changed. A new **Reconcile and resume** button beside **Resume move** lists those differences, makes the copy match and finishes the move, so no file has to be deleted by hand. The saved-video store's move does the same matching and the same check before it starts. Needs a rebuild and restart of the editor.
- **Connecting an X account opens your own browser, and the X fetchers can use your everyday browser's X login instead.** **Settings → X account session → Connect X account** used to open Playwright's bundled Chromium with its automation signals on (the "controlled by automated test software" bar, `navigator.webdriver`): Google's sign-in refused it and X's own login form stalled in it. It now opens your Chromium or Chrome when one is installed (`ARCHILYZER_X_BROWSER` names another; Playwright's bundled Chromium otherwise), without those signals. Google's sign-in may still refuse an embedded browser; X's password login is the reliable path. A new **Login source** choice (`social.x.cookieSource` in `settings.json`) says where the X fetchers' login comes from: **Browser login** hands gallery-dl `--cookies-from-browser` with your `cookiesFromBrowser` on every fetch, so the login lasts as long as you stay logged in to x.com in that browser and no window is needed; **Connected profile** is the session broker, as before. Left on **Automatic**, it is the browser login when `cookiesFromBrowser` is set and no profile is connected, and the profile otherwise. **Check** says which source is in use, whether an X login is visible in it and when it was last used (the browser's cookies are read from a private copy, never written; this reads Firefox's, and gallery-dl reads Chromium's itself). Needs a rebuild and restart of the editor.
+- **A channel's text stays on the fast disk when its media moves, so a slow or unplugged media drive no longer holds its transcripts.** A channel's big files — the audio and the raw live-chat replay — can now live in the channel's own `media` folder, on this disk or another, while its transcripts, cues, metadata and every other small file stay in `data/` where they always were; each big file that moves leaves a small link behind, so everything that opens it by name still finds it. New downloads, transcodes and live-chat normalizes put their big files there as they finish, and every cleanup that deletes audio removes the file the link points to, not just the link. What that changes when a media drive is stalled, unplugged or mid-move: **the index and stats builds read the text only and are never held by it**, the channel's report still refreshes (its media size reads as unknown until the drive answers), **digests keep running — even during a move of that channel's media** — and so do normalize, the availability checks, the metadata scan and clip eviction. Transcription, downloads, the backfill lane and anything else that opens the audio are held as before. **A channel moved the old way — its whole `data/` on the other drive — is now shown as "Media layout retired" and held by everything, the builds and digests included, until `archilyzer storage migrate-tier <channel>` brings its text home;** every refusal says so.
## [0.11.0] - 2026-09-30
- **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites.
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -3871,6 +3871,44 @@ actions reject a channel with running or queued jobs before they enqueue.
(`channelMedia.ts:40`) is the in-flight marker: present means "in transition" to every guard,
its `phase` is what lets an interrupted move resume, `deleteChannel` and `renameChannel` refuse
while it exists, and `clearRelocationMarker` (`:160`) removes it and nothing else.
+### Release 17 slice T1 — the media tier's model, and which guard holds what (2026-10-01)
+
+Supersedes, where they differ, the facts in the section above and in "The index build's hold",
+"`cues.mjs` carries a reachability twin" and the drive-health notes below; those passages are kept
+as they were measured.
+- **The model.** A big file — `isTierable(name)`: `audio.<ext>` and `transcript.live_chat.json`, never
+ `source-media.*` or a partial (`common/lib/mediaTier.ts`, pure; `classifyEntry` is media / text /
+ scratch by NAME) — may be a RELATIVE link `data/<id>/<name> -> ../../media/<id>/<name>`;
+ `channels/<slug>/media` is a real dir (tiered in place), ONE absolute link to `<root>/<slug>/media`
+ recorded as `config.mediaDir` (relocated), or absent (classic). `data/` is always a real dir on the
+ corpus disk. The hook (`tierMediaFile` & co., `lib/mediaTier-server.ts`) is called after every
+ media finalisation (transcode, both download-outcome writes, a batch `runYtdlp` mode, a live-chat
+ normalize that wrote) and never throws; the link carries the file's mtime (`lutimes`), and the
+ live-chat freshness check `lstat`s the raw, so the index never stats a media file. Every deleter of
+ a video-dir entry goes through `removeMediaFile` / `removeVideoDirMedia`.
+- **`inspectChannelMedia`** now describes the `media` link + `mediaDir`, returns `mediaLink` and
+ `text: { dir, readable }`, and has a seventh status, **`legacy`**: a `data` link or a recorded
+ `dataDir` (the retired whole-directory layout), answered without a call to the far drive, text
+ unreadable. Memo key: slug + `mediaDir` + `dataDir`.
+- **Two guards.** `assertChannelMediaReachable` (only `ok`/`in-place` pass) for a kind with
+ `needsMedia` ("opens or writes the BIG file"); `assertChannelTextReadable` (passes unless `legacy`,
+ `data/` not a dir, or a marker with `scope: "tier-migration"`) for a kind with the new `needsText`
+ (14 kinds flipped, pinned in `jobs/jobKinds.test.ts`). `runManagedFunction` asks the one the kind
+ declares, at enqueue and at start.
+- **Who is held by what.** The index and stats builds, the snapshot, `normalizeAllTranscripts`,
+ `saveShardConfigAction`, clip eviction and the digest batch read the TEXT tier and hold only on an
+ unreadable text tier; the digest lane holds on `isTextHeld` (= `legacy`) or unreadable text
+ (`isChannelHeldForLane`, `controller/autoRunner.ts`); transcription, download and backfill lanes,
+ the backfill batch and `normalizeAllLiveChat` keep the media hold. A media move's marker
+ (`scope` absent or `"media"`) holds media writers only; `channelWriters(slug, { mediaOnly })`
+ answers that set.
+- **The snapshot's bytes** are three siblings: `totalMediaBytes` (tierable names, through one
+ `onDrive(mediaDir)` per video for the links; ABSENT, with `totalAudioBytes`, when the media tier
+ could not be read), `totalTextBytes` (new), `totalClipsBytes` (no longer inside media).
+- **`onDrive` by file kind.** `channelMediaStall` keys on `mediaDir` (else the retired `dataDir`);
+ `channelTextStall` on the retired `dataDir` only. `readChannelStat` and the recency tail reads pass
+ a drive only for a legacy channel. `rootOfUnknownPath` strips `<root>/<slug>/media` and `/data`.
+
## Channel priority (verified 2026-09-11) — one tier per channel, four compiled trees
Branch `channel-priority/s5`, off S0's `28bfee3`, merging `s1`–`s4` and closing the twelve
@@ -4201,6 +4239,10 @@ corrected twice.
### `cues.mjs` carries a reachability twin of `assertChannelMediaReachable`
+**Release 17 slice T1:** it is now the twin of `assertChannelTextReadable` — it refuses a `legacy`
+channel (a `data` link or a recorded `dataDir`, mounted or not) and a marker only when its `scope` is
+`tier-migration`; relocated MEDIA is read past. The paragraphs below describe the old twin.
+
`umtool/report-to-video/cues.mjs`'s `checkChannelReachable` replicates
`inspectChannelMedia` / `assertChannelMediaReachable` (`common/lib/channelMedia.ts:320-327`,
which carries the cross-reference comment) in plain `.mjs`, because umtool's bins run under
diff --git a/plans/release-17.md b/plans/release-17.md
@@ -323,4 +323,183 @@ hand; a dirent `isFile()` filter over a video dir hides it."** The `.relocating.
## Record
+### Slice T1, as shipped — the classifier, the media link and the two guards (2026-10-01)
+
+Branch `r17/media-tier-model` off `main` `7f4901f1`, worktree `~/Projects/plans-export-header-first-search`
+(editor 4201, test 4211, export 4210 — `pnpm wt list`'s block #12), one Opus implementer, beside D0 and U1.
+Scratch files `T1-*` in the job's `tmp`. The plan is "The model (A′)" §§1–4 above plus the `channelWriters`
+option and the umtool twin; the mover and every editor surface are T2's.
+
+**What it does.**
+- **The classifier** (`common/lib/mediaTier.ts`, pure): `classifyEntry(name)` is `media` / `text` / `scratch`
+ by name over `mediaFiles.ts`'s anchored predicates; `isTierable` is narrower (`audio.<ext>` and
+ `transcript.live_chat.json` — never `source-media.*`, never a partial); `classifyVideoDir`. The table test
+ pins 32 names, the four the slice table names among them.
+- **The hook and the deleter** (`common/lib/mediaTier-server.ts`): `tierMediaFile` moves a finished big file
+ into `channels/<slug>/media/<id>/` and leaves the relative link `../../media/<id>/<name>` —
+ `tiered | left | already`; never throws; a classic channel, a dangling or stalled `media` link, a full disk
+ leave the file real; the per-video `mkdir` is non-recursive; EXDEV copies atomically; the link replaces the
+ name by a rename (no window with nothing there); a failed same-disk move is undone. `tierVideoDir`,
+ `tierChannelMedia({ since, createMediaDir })`, `removeMediaFile` (derefs only inside the channel's own
+ `media/<id>/`), `removeVideoDirMedia`, `channelMediaLink`, `relocatedMediaDir`, `tierLinkTarget`.
+- **Every media finalisation calls it:** `transcodeAudio` after its rename (so the app extraction, the
+ audio-checked download and the video page's Transcode), `downloadOneManaged` before both download-outcome
+ writes, `runYtdlp`'s five media-writing modes after the child returns (`since` the run's start, in a
+ `finally`), `normalizeLiveChat` after it writes the cues. **Every deleter derefs:** the three cleanup
+ sweeps, `purgeSupersededAutoSubs`, `backfillReacquire`, the app extraction's source discard, the audio-checked
+ source discard, `fixIncompleteTranscript` (both), and the video page's file delete, bulk audio removal and
+ directory delete (`removeVideoDirMedia` first). Grep gate
+ `git grep -n "remove(path.join(.*videoDir\|rm(path.join(videoDir" common editor`: **0 hits** (the module's own
+ `rm`s take a joined path).
+- **`config.json`:** `mediaDir` (CHANNEL.md regenerated by `bin/file-schemas-docs.ts`); `dataDir` documented
+ RETIRED and still parsed.
+- **`inspectChannelMedia`** describes `channels/<slug>/media` + `mediaDir`, returns `mediaLink` and
+ `text: { dir, readable }`, and has a seventh status, **`legacy`** (a `data` link or a recorded `dataDir`,
+ answered from the corpus disk alone, text unreadable, its detail naming `archilyzer storage migrate-tier
+ <slug>`). The marker parses an optional `scope` (`media` | `tier-migration`). Memo key: slug + `mediaDir` +
+ `dataDir`. `assertChannelTextReadable` beside the media guard; `isTextHeld`, `HELD_REASON.legacy`;
+ `channelMediaStall` keys on `mediaDir` (else the retired `dataDir`), `channelTextStall` on the retired
+ `dataDir` only; `rootOfUnknownPath` strips `<root>/<slug>/media`.
+- **Which guard holds what.** The index and stats builds, the snapshot, `normalizeAllTranscripts`,
+ `saveShardConfigAction`, clip eviction and the digest batch ask the text tier (`text.readable` /
+ `assertChannelTextReadable`); the digest lane is held by `isTextHeld` or unreadable text, the other three
+ by `isMediaHeld` (`isChannelHeldForLane`), and the pick→run marker backstop lets the digest lane through a
+ media move; the backfill batch and `normalizeAllLiveChat` keep the media guard. Fourteen kinds flip
+ `needsMedia` → `needsText` (the plan's list), and `runManagedFunction` asks the text guard for them, at
+ enqueue and at start. `channelWriters(slug, { mediaOnly })` keeps `kindNeedsMedia` jobs and the non-digest
+ lanes' units (no caller yet; T2's mover passes it).
+- **`onDrive` by file kind.** The snapshot reads the text directly; a video's tiered links are statted
+ together as one `onDrive(mediaDir)` call, only while the media is `ok`/`in-place`; a drive that does not
+ answer leaves `totalMediaBytes` and `totalAudioBytes` ABSENT and the snapshot is still written. Byte
+ fields: `totalMediaBytes` (tierable names), `totalTextBytes` (new), `totalClipsBytes` (no longer inside
+ media). `readChannelStat` and the recency tail reads pass a drive only for a legacy channel.
+- **umtool's twin** (`report-to-video/cues.mjs`) is now the twin of the TEXT guard: it refuses a legacy
+ channel, mounted or not, and a marker only when its `scope` is `tier-migration`; relocated media is read past.
+
+**Deviations from the plan** (one sentence each):
+1. `JobKindMeta.needsText` (+ `kindNeedsText`) is new: a kind flipped off `needsMedia` would otherwise run
+ unguarded on a legacy channel whose `data/` link dangles and read it as empty.
+2. The tier link carries the file's times (`lutimes`) and `normalizeLiveChat`/`isLiveChatCuesFresh` `lstat`
+ the raw replay: the raw is media now, and the index's freshness check would otherwise reach the media
+ drive per video.
+3. `normalizeLiveChat` tiers only when it wrote the cues (not on "fresh"), so an export build's normalize
+ pass moves nothing.
+4. `normalizeAllLiveChat` (corpus-wide, no slug for `runManagedFunction`) skips a channel whose media is not
+ reachable — the plan's "`normalize-live-chat` refused".
+5. `evictClipWindows` asks the text tier (`clips/` is never tiered) — its kind is in the flip list.
+6. The classifier's scratch also takes `*.part`, `*.ytdl` and the hook's own `.<name>.tierlink-<pid>`.
+7. `relocatedDataDir` stays exported (the retired shape) for the mover, the re-point, the rename and
+ `storageActions.ts` until T2 replaces it with `relocatedMediaDir`.
+8. `MediaLocationBadge.tsx` (T2's) gained the two `legacy` table entries the plan names (`Media layout
+ retired`), because its two `Record<ChannelMediaStatus, …>` tables fail tsc without them.
+9. The `isChannelHeldForLane` helper is new, so the lane decision is tested over real inspect answers.
+10. `markerHoldsText`: a scope-less marker whose target is the retired `<root>/<slug>/data` shape (the old
+ mover, until T2 writes `scope`) holds the text too, in the TS guard, the digest-lane backstop and the
+ cues twin — the plan's "refuse only `tier-migration`" would let a digest write into a `data/` the old
+ mover is copying.
+11. The hook writes nothing while a `.relocating.json` stands on the channel (the file stays real), a
+ backstop for a writer that started before a move.
+
+**Skipped until T2 rebases them** (28, each `{ skip: T1_SKIP }` with the reason string; they build the retired
+layout and expect it to read `ok`): `relocateChannelMedia.test.ts` 13 — "out: copies, links, records the
+target, keeps mtimes and reclaims the source", "abort from an onLog hook leaves the source intact, and the
+rerun completes", "back: restores a real directory, clears the config and reclaims the target", "out @ swap:
+crash before the rename — …", "out @ swap: crash after the config write — …", "out @ reclaim: the rerun
+sweeps every parked copy …", "back: an inconsistent channel is refused, …", "back: an unreachable channel is
+refused", "out @ swap: a directory timestamp is settled …", "out @ swap: a file the target is missing is
+mirrored …", "a resume with a stale extra dir on the destination completes", "reconcile: an extra and a
+changed file …", "reconcile: a marker past the copy phase resumes, …"; `renameChannel.test.ts` 1 — "rename
+re-points a convention-shaped relocated media dir"; `storageLocations.test.ts` 7 — "channelsOnLocation
+buckets ok / unreachable / moving …", "re-point rewrites both channels' links and configs, …", "re-point
+refuses a target that has no media …", "a failure on the second channel rolls the first one back", "re-point
+refuses a busy channel and names it", "a rerun after a crash finishes the channels that were left", "a
+channel killed between its symlink and its config write is resumed, …"; `storageWatch.test.ts` 7 (reason
+"release 17 T2 rebases the storage watch on mediaDir"; `storageWatch.ts` still reads `config.dataDir`) — "a
+channel whose target is gone is auto-paused, …", "the drive coming back restores the tier it overwrote",
+"write: false reports the transition …", "a drive that blips for one pass is never paused", "the restore
+needs only one good pass", "one missed probe stalls the location; …", "the stall clears only after two clean
+probes in a row".
+
+**Re-premised (this slice's own guards, not skipped):** `channelMedia.test.ts` (the media link; 7 legacy /
+text cases), `buildIndex.test.ts` (the hold now comes from an unreadable text tier — the channel put on the
+retired layout with its drive away — plus case (j): an unmounted MEDIA drive does not hold the index; the
+write spy no longer counts a symlink's target as a written path), `buildStats.test.ts` ((i) likewise, (i2)
+new), `storageStall.test.ts` (a stalled media drive: counts, recency and the snapshot are read with no call on
+it; the legacy cases keep the old gates; M3 is now "a tiered file's stat never answers → the snapshot is
+written, its media bytes unknown"), `channelSnapshot.test.ts`, `evictClipWindows.test.ts`,
+`doctor.test.ts`, `run-operation.test.ts`, and umtool's `cues.test.mjs` (6 cases).
+
+**Open questions, answered.**
+1. **What `import-one` writes:** `importVideoAction` (`editor/app/channels/[slug]/pipelineActions.ts:465`)
+ runs `downloadOneManaged` for one URL — media, subtitles, metadata, the archive line, then the roster. A
+ media writer: it keeps `needsMedia`, and its media are tiered by `downloadOneManaged`'s hook.
+4. **Who hardcodes `<root>/<slug>/data`:** only the mover family — `relocatedDataDir` and its callers
+ (`relocateChannelMedia.ts`, `renameChannel.ts`, `storageLocations.ts`'s re-point, `storageActions.ts`'s
+ marker check), `deleteChannel`'s `<root>/<slug>` reclaim and `lib/storageLocations.ts`'s and
+ `savedVideoStore.ts`'s comments — all T2's; and `storageHealth.ts`'s `rootOfUnknownPath`, which now takes
+ both suffixes. Nothing in umtool, mcp, docker or `scripts/`. So `<root>/<slug>/media` beside
+ `<root>/<slug>/data` is as planned.
+
+**`isFile()` over a video dir, the census** (a dirent `isFile()` is false for a link): `channelSnapshot.ts`'s
+byte loop — rewritten (`lstat`, links statted through the watchdog); `channelSnapshot.ts`'s `dirFileBytes`,
+`evictClipWindows.ts:145` and `clipWindow-server.ts:49` — over `clips/`, never tiered, fine;
+`downloadOneManaged.ts:430` (`discardPrefetchDir`) — a tiered link keeps the directory, the safe direction;
+`relocateDir.ts:70` (`measureTree`) — keeps `isFile()` by the plan. **For T2:** `videos/[id]/page.tsx:67`
+(`loadVideoDir`) and `videos/page.tsx:61` hide a tiered link today — on a tiered channel the video page and the
+videos list would not show the audio until T2 lands.
+
+**Found and left.**
+- For T2: `views/storage.ts`'s "N of it is fetched clip windows" and `storageLocations.ts`'s
+ `mediaBytes`/`clipsBytes` rollups still treat clips as part of `totalMediaBytes`; `channelRow.ts` and
+ `freeUpSelection.ts` read `totalMediaBytes`, now the media tier alone. The old mover still records
+ `dataDir`, so a Move media on this branch alone produces a `legacy` channel.
+- For T3: the migration's links should carry each file's times (`lutimes`, as the hook does), or every
+ migrated live chat's cues read as stale and the next index build re-parses the raw from the media drive;
+ `buildIndex` re-parses a stale raw replay directly (no watchdog) and skips the track when it cannot.
+- `generateChannelSnapshot` passes a null config (a `config.json` with no valid `handling`) to the guard,
+ which then reads no relocation — as before this slice.
+- `removeVideoDirMedia` cannot clear `media/<id>/` while the media drive is unmounted (its `lstat` fails);
+ the video page's delete then leaves those bytes behind.
+
+**Commits**
+
+| Commit | What |
+|---|---|
+| `9e74dff0` | `common:` the classifier (`mediaTier.ts`) and the hook/deleter (`mediaTier-server.ts`) + tests |
+| `14870c6f` | `common:` the model — `mediaDir` (CHANNEL.md regenerated), `legacy`, the text guard, `isTextHeld`, the builds/snapshot/normalize/shards/eviction/digest batch on the text tier, the snapshot's three byte fields, `needsText` and the fourteen flips, `mediaOnly`; tests re-premised; the 28 T2 skips |
+| `18d4acb0` | `common, editor:` every media finalisation tiers (transcode, both outcome writes, the batch modes, live-chat normalize), every deleter derefs, the link carries the file's mtime |
+| `7779b40f` | `common, umtool:` `isChannelHeldForLane` + its test, `normalizeAllLiveChat`'s media guard, the flips pinned, the text-guard job test, the call-site hook tests, the cues twin as a text guard |
+| `4892ddc0` | `common, umtool:` `markerHoldsText` (the old mover's scope-less `…/data` marker holds the text), the hook writes nothing under a marker |
+| this commit | `plans:` this section, FACTS ("Release 17 slice T1"), the editor changelog |
+
+#### Gates (logs `$T/T1-*.log`)
+
+- **tsc** (all workspaces) clean at every commit; last at `4892ddc0`.
+- **common:** at `7779b40f` **2,529 passed, 0 failed, 28 skipped** (2,557; `main`'s 2,528-test run had 49
+ failures on this branch's first pass, all re-premised or skipped as above). At `4892ddc0` 2,530 passed,
+ 1 failed, 28 skipped: the failure is `storageHealth.test.ts`'s "M4: a healthy 64-wide walk … at half the
+ budget" timing case under a machine load of 22–28 (other sessions); the file passes 36/36 twice in
+ isolation right after. New tests: `mediaTier.test.ts` 35, `mediaTier-server.test.ts` 16,
+ `mediaTierHooks.test.ts` 4, `channelMedia.test.ts` 23 (rewritten), plus cases in `channelWriters`,
+ `autoRunner`, `streamCommand`, `jobKinds`, `buildIndex` (j), `buildStats` (i2), `storageStall`,
+ `channelSnapshot`, `evictClipWindows`, `storageHealth`.
+- **Editor unit:** 109/109. **test:scripts:** 304 passed, 2 skipped (306) — a first run had the two
+ `queue-lock.test.mjs` timing cases fail under load; the rerun is clean. **umtool `cues.test.mjs`:** 23
+ passed, 1 skipped (LIVE).
+- **Build:** the capped editor build (`systemd-run --scope -p MemoryMax=6G`, `next build`): exit 0, 172 s,
+ at `7779b40f`.
+- **e2e** (from the worktree root, `$T/T1-specs.txt`: maybe-missing, video-page, cleanup-holds,
+ cleanup-actionable, auto-queue, digest, jobs-channel, channel-storage) at `7779b40f`: **78 passed, 5
+ failed, 8.9 min** (after 33.6 min in the queue). The 5 are all `channel-storage.spec.ts`, all the retired
+ layout reading `legacy`, as expected until T2 rebases the mover: "relocate a channel's media to another
+ root, and move it back" (:80), "the /channels bulk move queues one job per channel and skips the rest"
+ (:250), "a bulk move puts every job on one queue and skips a channel with nothing to move" (:391), "the
+ Storage panel moves to a location picked by name" (:484), "Sync all skips a channel whose media drive is
+ not mounted" (:1091 — now says `media legacy: … run archilyzer storage migrate-tier test-youtube`). Not
+ re-run after `4892ddc0` (unit-covered; a marker rule the specs do not reach).
+- **Privacy gate:** 0 added lines carry the user or host name (`git diff 7f4901f1`, counts only; the one
+ file the whole-file grep names is `plans/FACTS.md`, with the same count as on `main`).
+- **Numbers tool:** none.
+
+
## Rollout