# Storage locations: named, refreshable, re-pointable places a channel's media lives Status: **S0–S4 shipped 2026-09-19** (`4b55e3c`); **S5 + S6 shipped 2026-09-20** on `storage/locations-s5-s6` (unmerged) — see the two status blocks at the foot of this file. Facts pinned to `main` @ `6b4f25b`. Slices ship on `storage/locations-s{0,1,2,3,4}` branches; this file is updated as each lands. ## Context The rollout puts big channels on the platter (`sdb1`, ext4, UUID `09b598d2-b765-40bd-9466-06d8e16cb119`) and forgets about them until later. Today that disk is udisks-automounted at `/run/media/user/` with no fstab entry, and a relocated channel's `config.dataDir` is an absolute path. If the disk comes up somewhere else, or not at all, every channel on it reads `unreachable` and the only remedy is `ssh` and hand edits. The operator asked (2026-09-17) for **location entities in the editor** that check whether they are available (refreshable) and can be re-pointed to a different path. Decisions taken with the operator 2026-09-17: a new **`/storage`** page (twelfth nav entry, Machine group); re-point is **manual, one click, with a per-location opt-in `autoRepoint`**; the saved-video store is **not** a location in this sub-phase. The omnimirror incident (2026-09-13) is folded in as slice S1 because it is the same failure family: the relocate copy raced an in-flight auto-digest batch (its units make no job record, and the batch's reachability guard runs once at start), a sidecar written mid-copy left one directory mtime differing, `verifyCopy` refused, and the Storage panel offered no way to resume although the controller resumes from a marker. **Operator step today, before any of this ships** (verified on disk: the platter copy is byte-complete; drift is one directory timestamp `data/v4p31nz/`): `/jobs` idle for omnimirror → channel page → Storage → **Clear marker** → **Move media** to the same root (preview says "partial copy … resumes"; rsync fixes one mtime; swap and reclaim free 131 GB). ## Verified facts (2026-09-17, `main` @ `6b4f25b`) - `common/lib/channelMedia.ts` `inspectChannelMedia` (2 stats + 1 JSON read) is the per-channel truth; guards skip `unreachable`. Unchanged by this plan. - Zero channels are relocated in production; `settings.storage.mediaRoot` is the only stored root (`common/lib/settings.ts:853-882`; sanitizer never checks existence, drops relative). - `ChannelConfig` round-trips are whitelisted (`channelConfig.ts:225-279`): a new config field would need the type + parser. **This plan adds no config field**: a channel is on location L iff `config.dataDir` is under `L.root + "/"`. - Subprocesses go through `execa`; lib may use it (`lib/digestApps.ts:304`, `lib/git.ts`), but nothing reachable from a `"use client"` file may (next build fails on `node:child_process`). Bins live on `Paths` (`paths.ts:117 rsyncBin`, env `RSYNC_BIN`). - `getFreeBytes` (`lib/diskSpace.ts:29-43`) walks up on ENOENT: an unmounted path reports the parent volume. Only call it for an `available` location. - Host tooling: `findmnt`, `lsblk`, `blkid`, `udisksctl` present; `findmnt -J -T ` gives `{source,target,fstype,uuid,label}`; `findmnt -rn -S UUID= -o TARGET` gives the mountpoint; `findmnt --fstab -S UUID=` exits 1 (not in fstab); `/dev/disk/by-uuid/` exists. In Docker block devices are invisible and the root must be identity-bind-mounted (`RUNNING_IN_DOCKER.md:379-399`) — every identity probe **fails open** to "unknown". - Re-point precedent: `common/controller/renameChannel.ts:139-201` unlinks, symlinks and rewrites `dataDir` with a replay ledger. `relocateChannelMedia.ts` itself refuses a link that points elsewhere (`:692-695`) — re-point is new code on the rename pattern. - Resume precedent: `relocateChannelMedia` resumes a same-direction marker (`:450,:507-513`); `relocationJob.ts:59` already logs "(resumed an interrupted move)". The UI never calls it (`StorageStage.tsx`: `canMoveOut = !location.relocated`; marker → only "Clear marker"). - Auto-lane units are visible: `getAutoRunnerStatus(kind).inFlight[]` carries `channelSlug` (`autoRunner.ts:282-292, 1553-1558`); `storageActions.ts:65-78 activeJobsRefusal` only looks at the registry, so it could not see the digest unit that raced omnimirror. - Nav: `editor/app/lib/nav.ts` `NAV_GROUPS`, Machine = Jobs/Workers/Cleanup/Saved videos/ Settings; `nav.test.ts:19-22` asserts eleven; `plans/editor-operations-ia.md:70-76` states "fold, do not add" — decision 1 is a recorded exception. There is no `measure-nav` nav counter (`editor/scripts/measure-nav.mjs` times routes; it does not count). - e2e fakes are env-injected bins (`editor/package.json:8-9`, `e2e/fixtures/bin/fake-*.mjs`); `channel-storage.spec.ts` (393 lines) fills `aria-label="destination root"` and asserts `getByLabel(/^media location:/)`. ## Design ### Entity (settings) `StorageSettings = { locations: StorageLocation[]; defaultLocationId: string }` ```ts type StorageLocation = { id: string; // /^[a-z0-9][a-z0-9-]{0,63}$/, unique label: string; // blank → id root: string; // absolute, trailing "/" stripped, never existence-checked autoRepoint: boolean; // opt-in: re-point without asking when safe volume?: { uuid: string; fstype?: string; label?: string; mountpoint: string; relPath: string }; }; // identity learned at the last successful probe; root === join(mountpoint, relPath) ``` Availability is **never persisted** (refresh must not churn `settings.json` → pulse rev). `mediaRoot` migrates on read (`migrateMediaRootToLocations`, laneMigration rules: only when `locations === undefined`, identity otherwise, blank → empty list, absolute → one location `{ id: "default", label: "Default", root }` as default) and leaves the schema; the settings form field becomes a link to `/storage`. Rollback: an older binary sanitizes to `{ mediaRoot: "" }` — one string lost. Runbook: `cp settings.json settings.json.pre-storage-locations`. ### Probe (`common/lib/storageVolumes.ts`, server-only) `probeLocation(loc, bins) → { status, identity?, candidateRoot?, warning?, freeBytes? }` - `stat(root)` is a dir → `available`; `findmnt -J -T root` (3 s, `reject:false`) → identity or unknown; `freeBytes` only here. - root missing + `volume.uuid`: `findmnt -rn -S UUID=… -o TARGET` non-empty → `mounted-elsewhere`, `candidateRoot = join(target, relPath)`; else `lstat /dev/disk/by-uuid/` → `unmounted`; else `absent`. - root missing, no uuid → `missing`. - `warning` when available and mountpoint under `/run/media/` or `/media/`, or not in fstab: "automount — may not be present at boot; add `UUID= /mnt/platter nofail 0 2`, or rely on re-point". `mountByUuid(uuid, bins)`: `udisksctl mount -b /dev/disk/by-uuid/` (15 s, `reject:false`), offered only when `unmounted` and the binary resolves (`--version` probe memoised, as `digestApps.ts:301-313`). Bins on `Paths`: `findmntBin` (`FINDMNT_BIN`), `udisksctlBin` (`UDISKSCTL_BIN`). ### Layers | module | layer | role | |---|---|---| | `common/lib/storageLocations.ts` | lib, pure | types, `locationOfDataDir(dataDir, locations)` (longest root wins), `migrateMediaRootToLocations`, `defaultLocationRoot` — the ONLY storage module a `"use client"` file may import (types) | | `common/lib/storageVolumes.ts` | lib, execa | `probeLocation`, `mountByUuid` | | `common/controller/storageLocations.ts` | controller | `channelsOnLocation` (per-channel `inspectChannelMedia` roll-up), `preflightRepoint`, `repointStorageLocation` (job body), `maybeAutoRepoint`, `runStorageBootPass`, 10 s probe memo | | `common/views/storage.ts` | views, pure | `buildStorageRows({ locations, defaultLocationId, probes, rollups, registry, now })` — the Phase 3 pattern; the shell does I/O | No new `ALLOWED` entry; no config field; no route path change. ### The re-point job (`repoint-storage-location`) `jobKinds.ts` after `relocate-channel-media`: `drainable:false, replayable:false, queueKeyStrategy:"custom", needsMedia:false`; `queueKey = relocationQueueKey()` (serialises with moves); added to `snapshotScheduler.ts NO_REGEN_KINDS`. One at a time. - **Preflight** (nothing written): location exists; `newRoot` absolute dir, `!== root`; identity — recorded uuid and probed uuid both known and different → refuse "different disk"; every channel on the old root: link (not real dir), no marker, not busy, `//data` is a dir (refuse listing all missing), `relocationRootProblem` per slug. - **Per channel, sequential**, renameChannel ledger `{slug, oldTarget, unlinked, relinked, configWritten}`: unlink → symlink(newTarget) → `writeChannelConfig({...fresh, dataDir: newTarget})`; on throw replay in reverse with `.catch(()=>{})`, rethrow naming slug + step. Restored state is the pre-job `unreachable`, never `inconsistent`. - **Then the location**: `writeSettings` with `root = newRoot`, `volume` from the probe; in the ledger too. - **Idempotent rerun**: already-moved channels are no longer "on" the old root; a rerun finishes the rest or does the settings write alone. - **autoRepoint**: `refreshLocationAction` and the boot pass call `maybeAutoRepoint` when `loc.autoRepoint && status === "mounted-elsewhere" && preflight ok`; idle boot (`ARCHILYZER_IDLE_BOOT`) probes but never enqueues. ### Surfaces - `/storage` (server component, `force-dynamic`): `editor/app/storage/{page.tsx, buildStorage.ts (shell), actions.ts, lib/repointJob.ts, components/StorageLocationsTable.tsx, components/LocationForm.tsx}`. Rows: label, root, status badge (`aria-label="location status"`), identity, channels (`n ok / n unreachable / n moving`, `aria-label="location channels"`), free bytes when available, last probe, warning, actions **Refresh / Re-point to `` / Mount / Edit / Delete** (delete refused while any channel's `dataDir` is under the root). Wrapper `aria-label="storage locations"`, row `aria-label="storage location: "`, form fields `location id/label/root/auto re-point`, add button `add storage location`, outputs `Re-point output`. - Nav: `{ href: "/storage", label: "Storage", icon: HardDrive, keywords: "drive disk platter mount volume media location relocate cold" }` after Cleanup; `nav.test.ts` eleven → twelve; `nav.ts:27-30` header and `editor-operations-ia.md:70-76` record the exception (a location is a Machine noun, not a fold of Cleanup). - `editor/instrumentation.ts`: lazy-import `runStorageBootPass({ enqueue: !idle })` inside `try`, `void`ed. - `MediaLocationBadge`: `locationLabel?` → "on Platter" / "on Platter — unreachable"; tables never probe (not render-safe); projection at `channels/page.tsx:217-222` and the dashboard row builder via `locationOfDataDir`. - `StorageStage`: destination ``; `bulkRelocateChannelMediaAction(slugs, locationId)`. - Docs: `RUNNING_IN_DOCKER.md` §another drive (identity unknown in a container; re-point by path is the whole story); `AGENTS.md` corpus table one sentence; `editor/CHANGELOG.md`. ## Slices and dependency graph **S0 — one-commit bug fix, ships first, on its own.** Operator report 2026-09-17: on `/channels` a row's Advanced menu draws under later rows. Cause, verified: `ChannelsTable.tsx:607-609` puts `opacity-60` on the `` (excluded from build, or tier Paused — omnimirror is Paused, the "Media moving" badge is a coincidence). Opacity < 1 creates a stacking context on the row, so `ChannelTierSelect.tsx:188`'s `absolute z-30` popover is confined to it and every later row paints over it. Fix: dim per cell, not per row — pass `dim` to the row's cells and apply `opacity-60` on every `` EXCEPT the Tier cell (the one that hosts the popover), or dim by `text-muted-foreground` alone. Comment the reason at the site. e2e: `channels-*` specs stay green; add one assertion that a Paused row's Advanced popover is visible (`toBeInViewport` + a click on an inner control succeeds) in the spec that already opens Advanced. ``` S1 runner hardening (omnimirror) ──┐ S2 entity + probe + migration ──────┼──> S3 /storage page + actions + job + nav ──> S4 channel surfaces + docs ``` S1 ∥ S2 (no shared file). S3 needs S2. S4 needs S3. - **S1** — (a) `operationBatch.ts next()` (`:1689-1717`, before `classifyOperationUnit`): `readRelocationMarker` → log + `return null` (ends the run cleanly); `autoRunner.ts run(picked)` (`:1636-1650`, before the lane branch): marker → `outcome: "skipped"` (the `finally`'s `markCompleted` retires the unit for the session — state the trade-off in code). (b) `verifyCopy` (`relocateChannelMedia.ts:337-370`): when every drift line matches `/^\.d\.\.t/`, one more `rsync -a` pass and re-verify, `retried` flag, content drift still throws. (c) new `editor/app/channels/lib/mediaBusy.ts` `channelMediaBusyReason(slug)` = registry running/queued + `LANES.flatMap(k => getAutoRunnerStatus(k).inFlight).filter(u => u.channelSlug === slug)`; used by `storageActions.ts`, `bulkStorageActions.ts`, `[slug]/page.tsx` `blockedReason`. - **S2** — `lib/storageLocations.ts`, `lib/storageVolumes.ts`, `settings.ts` (type, defaults, sanitizer, migration at the `:1475` hook applied to the parsed block), `paths.ts` bins, `storageSettings.test.ts` rewritten, `SettingsForm.tsx:134-139` + `settings/actions.ts` (`:58-61,:160-165,:262-264`) drop `mediaRoot`, `bulkStorageActions.ts:80` / `[slug]/page.tsx:553` / `channels/page.tsx:358` read `defaultLocationRoot(settings.storage)` so S2 ships without UI change, `channel-storage.spec.ts:226,327` settings block updated. - **S3** — controller, views, job kind + NO_REGEN, `/storage` files, nav + test + IA note, instrumentation boot pass, `editor/package.json` `dev:test`/`start:test` gain `FINDMNT_BIN`/`UDISKSCTL_BIN` fakes (`e2e/fixtures/bin/fake-findmnt.mjs`, `fake-udisksctl.mjs`, driven by a control file `test-transcripts/.fake-findmnt.json`), `e2e/storage-locations.spec.ts`. - **S4** — badge, `StorageStage` (select + Resume move), `storageActions.ts` (destination by location id, `resumeRelocationAction`), deck + table + page props, `channel-storage.spec.ts` selectors, docs, changelog. ## Invariants - No data moves in any slice; re-point rewrites links and `dataDir` only. `inspectChannelMedia`, its statuses, and the umtool twin (`cues.mjs checkChannelReachable`) unchanged. - Refresh never writes availability; it writes identity only when it changed. - Every subprocess: `execa`, timeout, `reject:false`, fail-open to "unknown" — a correctly bind-mounted container channel is never declared unreachable by a probe. - No `execa`-importing module reachable from a `"use client"` file (proved by `next build`). - Allow-list stays 10; `views/` rules hold; no route retired, no wire shape changed. ## Tests to add - S1 (`relocateChannelMedia.test.ts`): dir-mtime-only drift verifies after one retry; extra file still refuses "The source has NOT been touched"; a marker written from the first unit's log ends `runOperationBatch` with the stop line and no second dispatch. - S2: `storageSettings.test.ts` (sanitizer rules, migration rules 1–3, idempotence, `defaultLocationId` fallback, stale `mediaRoot` beside `locations` ignored); `storageLocations.test.ts` (`locationOfDataDir` longest root, trailing slash, no match); `storageVolumes.test.ts` with a fake findmnt script in the tmp dir (five statuses, warning, timeout, missing binary → unknown). - S3: `controller/storageLocations.test.ts` (rollup; re-point happy path over two channels + settings; refuse missing target naming the slug; rollback of channel 1 when channel 2's symlink fails via a read-only channel dir; refuse busy; crash-rerun finishes); `views/storage.test.ts`; e2e `storage-locations.spec.ts` (list/status/channels/delete-refused/add; fake-findmnt mounted-elsewhere → Refresh → Re-point → `readlink` + `config.dataDir` + transcript route 200). - S4: `channel-storage.spec.ts` (select `cold`, badge `on Cold`, bulk select) + new Resume-move case (seeded `.relocating.json` phase copy → "resumed an interrupted move", marker gone). - `nav.test.ts` at twelve; `architecture.test.ts` green. ## Verification Per slice, from a worktree: `pnpm -r exec tsc --noEmit`; `pnpm --filter yt-dlp-transcript-common test`; `pnpm -C editor exec tsx --test "app/**/*.test.ts"`; `pnpm --filter editor exec next build`; e2e detached behind the queue lock — S1/S2 `channel-storage`; S3 `+ storage-locations`; S4 both + `channels-*`; full 533+ on the merged tip. Offline against the real host (never a second editor): `tsx -e` calling `probeLocation` on the platter's current automount → expect `available` with identity uuid `09b598d2-…` and the fstab warning; on `/mnt/platter/archilyzer-media` → `mounted-elsewhere` with candidate `/run/media/user//archilyzer-media`. ## Risks - Twelve nav entries contradicts the IA rule; recorded as an exception. Fallback if reconsidered: a `/cleanup` tab. - `udisksctl` under a service session may be polkit-denied: surface stderr, never retry. - S1(a) leaves a one-unit race window; (b) absorbs the mtime echo; "Resume move" absorbs the rest; (c) turns the case into a refusal with a reason. - Nested roots are allowed; longest match wins — say so in the sanitizer. - Follow-up, not here: the saved-video store as a location (absolute pointers need a rewrite). ## Cadence Plan file → `plans/storage-locations.md` (first commit). Fable reviews. S0 first, alone. S1 and S2 as two parallel Opus implementers on `storage/locations-s1` / `-s2`; S3 then S4 serially on the merged tip; full suite on the final tip. Standing rules: commit small, add by path, never boot against `transcripts/`, e2e detached + Monitor, report contract with shas, gate outputs, divergences. ## Status log - 2026-09-17 — plan committed on `main`. - 2026-09-17 — S1 (`storage/locations-s1`, `578867f`→`68d082d`) and S2 (`storage/locations-s2`, `c078e1f`→`79c0071`) reviewed and merged as `d146c51`. Real-host probe matched the plan (available + uuid + fstab warning; mounted-elsewhere with the automount candidate). Follow-up recorded: rename/delete channel actions still check the registry alone (`channels/actions.ts` `renameChannelAction`) — `channelMediaBusyReason` is a drop-in there. - 2026-09-17 — S3 (`storage/locations-s3`, `8824f5b`→`aed94d9`) reviewed and fast-forwarded onto main: `/storage`, controller + views, `repoint-storage-location` job, nav at twelve, boot pass, fake findmnt/udisksctl, `storage-locations.spec.ts`; common 1314/1314, e2e 6/6. Declined: a `spec` on the re-point job record (it is the Retry switch and the kind is not replayable). - 2026-09-17 — S0 (`storage/locations-s0`, `c13c5a8`→`d4ebf1d`) merged as `093e436`: the row dim is per cell, the Tier cell hosts the popover; `channel-priority.spec.ts` proves it with two adjacent paused rows and an `elementFromPoint` owner check (passes with the fix, fails without). Main now holds S0–S3; S4 in flight. ## S5 — unreachable media: re-check per unit, never materialise a mount, auto-pause + flag Operator ask 2026-09-18, after the omnimirror move landed: "Can that gracefully handle the case if the drive disconnects while the channel is on? Maybe an automatic disable and flag?" Facts verified at `main` @ `9ac8d38` (read-only Opus investigation, 2026-09-18): - **Guards at start exist, four of them**: `common/jobs/streamCommand.ts:266-292` `refuseForUnreachableMedia` (first statement of `runManagedFunction`, only when `opts.channelSlug` is set and the kind has `needsMedia`); `autoRunner.ts:610-630` `buildChannelWork` inspects every channel on every tick (`:617`) and drops it via `noteSkippedForMedia` (`:625-629`); `channelSnapshot.ts:619`; `operationBatch.ts:1593`. - **No per-unit re-check.** `autoRunner.ts:1673 run(picked)` and `operationBatch.ts:1723` (GUARD 5, `next()`) read only the relocation marker (S1). Auto-download units re-enter `runManagedFunction` per video (`autoRunner.ts:1911-1915`, kind `auto-download-unit`, `needsMedia`) so they are covered; auto-transcribe (`:1840`) and both operation lanes (`:1624`) run in-process with no job record. The lane runner jobs (`:2015`) pass no `channelSlug`, so guard 1 no-ops for them. - **Writers go through the link** (`downloadOneManaged.ts:437,623,1123` mkdir of `videoDir`; yt-dlp templates cwd-relative `data/%(id)s/…` at `runYtdlp.ts:258-303` with `cwd = channelDir` at `:307-312`; sidecar writers do no mkdir). A recursive mkdir through a DANGLING symlink fails (ENOENT on the leaf, EEXIST on `data`, then `stat` ENOENT) — reasoned from the code, not executed: **S5 adds the unit test**. - **The one absolute-path mkdir**: `relocateChannelMedia.ts:657` (`moveOut`) and `:873` (`moveBack`) `mkdir(target, { recursive: true })`. With the volume absent this creates `/run/media/user//…//data` on tmpfs; the link then stops dangling and every writer above lands in RAM undetected. - **Detection is passive**: `autoRunner.ts:554-570` `noteSkippedForMedia` / `noteMediaReachable` log once per transition and nothing reads them (`:576-578`); snapshot regen throws and the scheduler keeps the last good `snapshot.json`; badges are computed per request (`page.tsx:82`, `channels/page.tsx:204`, `[slug]/page.tsx:216`, `MediaLocationBadge.tsx:51`, `/storage` rollup `storageLocations.ts:127` → `views/storage.ts:198`). `/review` (`review/page.tsx:27,50`) knows nothing about media. `runStorageBootPass` runs only at boot (`instrumentation.ts:100-103`); `PROBE_MEMO_MS` is a request memo, not a timer. - **Channel-level pause mechanism**: `common/lib/channelPriority.ts:317 isChannelPaused`, tier `"paused"` (`:70`, filtered at `:254,332,545`). `pauseGates.ts` is lane-level — wrong granularity. ### Design **(a) Per-unit reachability, same shape as the S1 marker check.** Beside the marker read at `autoRunner.ts:1673`: `inspectChannelMedia`; status not `ok`/`in-place` → log `skipping /: media ` and `outcome: "skipped"` (same `finally` semantics as S1, same comment about bucket vs operation lanes). Beside GUARD 5 at `operationBatch.ts:1723`: unreachable → stop line + `return null`; generalise S1's `stoppedForRelocation: boolean` into `stopped?: "relocation" | "media-unreachable"` (keep the boolean as a derived getter if anything reads it; `operationJobs.ts:147,272` append the matching phrase). Cost: two stats and one JSON read per unit — state it. **(b) The move never materialises a mount.** New `assertRelocationRootPresent(root, settingsStorage, bins)` in `relocateChannelMedia.ts`, called immediately before both absolute-path mkdirs (`:657`, `:873`) and from `relocationRootProblem`: `stat(root)` must be a directory (the move creates only `/` and `//data`, never the root); and when `locationOfDataDir(root + "/x", locations)` (or `root` equals a location root) resolves to a location with a `volume.uuid`, `probeLocation` must answer `available` with a known identity whose uuid matches — otherwise refuse `destination root is (location ""); mount it or re-point first`. A root that is nobody's location is stat-only (documented: an unmounted fstab mountpoint directory is exactly the case a location protects against — add one to `/storage`). The umtool twin (`cues.mjs`) is unaffected; it only reads. **(c) Auto-pause + flag, hung off the transition that already exists.** `channelPriority` per-channel record gains `autoPaused?: { reason: "media-unreachable"; since: string; previousTier: Tier }` (type + parser + sanitizer, the lane-migration discipline: an older binary drops the field and leaves the tier as Paused — nothing lost but the automatic restore). Pure helpers in `common/lib/channelPriority.ts`: `autoPauseForMedia(settings, slug, now)` (no-op when the tier is already `paused` or `autoPaused` is set; otherwise records `previousTier` and sets `paused`) and `restoreAfterMedia(settings, slug)` (no-op unless `autoPaused` is present; restores `previousTier`, clears the field). A MANUAL tier change through the existing action clears `autoPaused` (the operator's word wins; a later return of the drive must not un-pause a channel the operator paused by hand). Wire: `autoRunner.ts:554-570` `noteSkippedForMedia` → `writeSettings(autoPauseForMedia(…))`; `noteMediaReachable` → `writeSettings(restoreAfterMedia(…))`. Both are behind the same once-per-transition latch that already exists there, so a flapping drive writes twice per flap, not per tick. Idle boot (`ARCHILYZER_IDLE_BOOT`) never writes. Flag surfaces: `/review` gains a "Media unreachable" section (built in `common/views/review.ts` or the existing review view: slug, location label, since, "auto-paused"); `MediaLocationBadge` gets `autoPaused?` → "auto-paused — media unreachable since "; the `/storage` rollup's `unreachable` count links to that review section; the channel page's tier control shows the reason beside Paused. Restore is logged on the lane (`[auto] : media reachable again, tier restored to `). The tick is the probe: `buildChannelWork` inspects every channel per tick whether or not it has work. Document the one blind spot — if every lane is held by a pause gate no tick runs, and the flag appears on the next request-time render only. ### Files `common/controller/autoRunner.ts`, `common/controller/operationBatch.ts`, `common/controller/operationJobs.ts`, `common/controller/relocateChannelMedia.ts`, `common/lib/channelPriority.ts` (+ test), `common/lib/settings.ts` (sanitizer for the new field), `common/views/review.ts` (or where `/review` builds), `editor/app/review/page.tsx`, `editor/app/channels/components/MediaLocationBadge.tsx`, the tier control, the `/channels` and dashboard row builders (project `autoPaused`), `editor/CHANGELOG.md`, `RUNNING_IN_DOCKER.md` (one paragraph: a bind mount that vanishes is the same case). ### Tests - `operationBatchRelocation.test.ts` (extend): a target dir removed after the first unit stops the batch with the media stop line and `stopped === "media-unreachable"`. - `relocateChannelMedia.test.ts`: root missing → refusal names the root and creates nothing (assert the parent chain does not exist afterwards); root present but its location probes `unmounted` (fake findmnt in the tmp dir, as `storageVolumes.test.ts`) → refusal names the location and status. - New `channelMedia.test.ts` case (or `lib/danglingLink.test.ts`): `mkdir(join(link, "x"), { recursive: true })` through a dangling symlink throws ENOENT and creates nothing — the invariant every writer relies on. - `channelPriority.test.ts`: auto-pause records `previousTier`; no-op on a manual Paused; restore clears; a manual tier change clears `autoPaused`; sanitizer round-trip; an older shape without the field parses. - `views/review.test.ts` (or the view's test): the section lists auto-paused channels. - e2e `channel-storage.spec.ts`: seed a relocated channel and an `autoPaused` record → badge text and `/review` section; Move media to a root whose location is `unmounted` per `.fake-findmnt.json` → inline refusal naming the location. No e2e for the lane transition itself (no harness; the unit tests own it). ### Verification The S3/S4 gate list. Full suite on the final tip. Offline against the real host: `tsx -e` `assertRelocationRootPresent` on the platter root → passes; on `/mnt/platter/x` (absent) → refuses; nothing created under `/mnt` afterwards. ### Status - 2026-09-20 — **S5 and S6 shipped** on `storage/locations-s5-s6` (unmerged). What landed differs from the design above in three ways, all deliberate: - **(a) is a watch, not a per-unit re-check.** The plan put an `inspectChannelMedia` beside the marker check in `autoRunner.run(picked)` and `operationBatch`'s GUARD 5. The operator's ask was "an automatic disable and flag", and a flag is a STATE — a per-unit refusal produces none, it just declines work more often. `common/controller/storageWatch.ts` is a five-minute pass that probes, auto-pauses and restores; the existing start-of-work guards are untouched, and the per-unit re-check is still available as a follow-up if a lane is ever seen writing into a dangling link. - **(c) is `reason: "storage"`, not `"media-unreachable"`**, and it lives beside the tier as a chip on `/channels` rather than as a `/review` section. `/review` is a follow-up. **Done 2026-09-21** in `storage/debts-1`: `common/views/review.ts` (`autoPausedRows`) over `settings.channelPriority`, a channel a PERSON paused is not a row, and Resume posts the `previousTier` through `setChannelTierAction` — the one priority writer. Anchors: [`FACTS.md`](FACTS.md#review-lists-what-the-machine-decided). - **(b), `assertRelocationRootPresent`, is NOT done.** The absolute-path `mkdir` in moveOut and moveBack can still materialise a mount when the volume is absent. It is the one piece of S5 left and it is self-contained. **Done 2026-09-21** in `storage/debts-1` (`1fccabac`): exported from `common/controller/relocateChannelMedia.ts:332`, called from `relocationRootProblem` (so preview and action agree) and again immediately before each copy phase's mkdir, and from `relocateSavedVideos.ts:366,580` for the saved-video store. It stats the root and, when that root belongs to a location carrying a learned `volume.uuid`, requires the probe's identity to match; it FAILS OPEN on unknown identity and is stat-only for a root nobody named as a location. Anchors: [`FACTS.md`](FACTS.md#assertrelocationrootpresent). - 2026-09-18 — written; dispatch after S4 merges (`storage/locations-s5`). - 2026-09-19 — S4 (`storage/locations-s4`, `c4384bd`→`f007263`) reviewed and merged as `4b55e3c`: badge names the location, destination by name with Resume move, bulk by name, docs + changelog. Full suite on the branch tip 527/538 under load, all 11 rerun green after two spec fixes (`a939e0e`, `f007263`). **S0–S4 complete.** Next: S5 (unreachable media) — see below. ## S6 — the storage surfaces the operator asked for (shipped 2026-09-20) Two asks on the evening of 2026-09-20, verbatim: *"I'd like to see disk space and current storage volume as columns on the channels menu, and let me filter by volume for easy checking of things that may need moving or other processing"* and *"It'd be nice to see a progress on relocate jobs since rsync gives progress"* — against an internal disk 96 % full (67 GB free, 523 GB of channel `data/` on it) with a 2 TB platter registered as `platter`. Shipped on `storage/locations-s5-s6`, in this order: 1. **Bytes.** `snapshot.totalMediaBytes` in the existing per-video walk; `channelsOnLocation` sums it with an `unknownBytes` count beside it; `views/storage.ts` exposes `bytesOnLocation` / `bytesInPlace` / per-row `freeBytes`. 2. **The corpus volume is a first-class row**, synthetic, id `internal`, first, with every action withheld and a link to its own channel list. The channel Storage panel's destination select offers it and routes to `moveChannelMediaBackAction`; "Move back in place" is no longer a second section. 3. **`/channels` is the working surface**: Location and Size columns, `?location=` filter, `?sort=size`, one free-space read-out per volume in a volume bar, and "free up N GB". 4. **Relocate progress**: `parseRsyncProgress` + a `relocate` JobTask + one log line per decile. 5. **The saved-video store is movable**: `relocateDir.ts` (the factored core), `relocateSavedVideos`, `settings.storage.savedVideosLocationId`, a `/storage` card. Every seam is pinned in [`FACTS.md`](FACTS.md#storage-locations-s5s6-verified-2026-09-20-branch-storagelocations-s5-s6). **Not done, and named rather than forgotten:** `assertRelocationRootPresent` (S5(b)) — the move can still `mkdir` a target under an absent mount; a `/review` section for auto-paused channels; and the per-unit reachability re-check in the two lane runners. **Two of those three landed on 2026-09-21 in `storage/debts-1`** (merged to `main` by fast-forward, `7589c50c` → `e109b6ac`): `assertRelocationRootPresent` (`common/controller/relocateChannelMedia.ts:332`, `1fccabac`) and the `/review` section (`common/views/review.ts` `autoPausedRows`, `bbc1899e`). **Only the per-unit reachability re-check in the two lane runners remains** — still deliberately deferred to the five-minute watch, and still available as a follow-up if a lane is ever seen writing into a dangling link.