# One core — Phase 3, slice 1: `common/views/` ## Context `plans/one-core.md` §Phase 3 is the umbrella: views in the core, editor as shell, four slices. This is the slice-level plan for **slice 1 only** — decided with the operator 2026-09-14. It starts from `main` after the `/channels` rack landed (`1bc8765`, record in `editor-channels-rack.md`); the O2–O9 rollout proceeds in parallel and S0-pause still waits on its boot. Slice 3 (which rewrites `ChannelsTable.tsx` again) is not planned here; landing the rack first is what keeps that slice a rebase-free rewrite of one file. The slice: every pure view-model the editor computes from live state moves into `common/views/`, takes its singletons as **arguments**, and is proven pure by the layer guard. The editor keeps thin shells at the old paths with the old names, so no consumer and no route changes. ## Why (what the code says) Surveyed 2026-09-14 against `main` @ `1bc8765`. Trust these over re-deriving them. - **The guard is a relative-specifier grep over an explicit list.** `common/architecture.test.ts`: `FORBIDDEN` at `:31` (`lib`, `jobs`, `components` rows), `ROOTS` at `:45` (explicit; a `views/` dir is invisible until added), `ALLOWED` = 11 entries, the last being `components/StreamActionLog.tsx -> jobs/streamCommand` ("becomes a view-model in common/views/ (phase 3)") — this slice **burns it** (11 → 10). `IMPORT_RE` matches static imports; `:137` skips every non-relative specifier, so "no react/next in views" needs a NEW bare-specifier check. The stale-entry test forces the burn and the type move into one commit. - **Every candidate file imports common by package name** (`yt-dlp-transcript-common/...`); common's own modules import each other relatively (`controller/autoRunner.ts` has 20 `../lib/` imports). Moved files MUST be rewritten to relative form or the layer guard never sees an edge from `views/`. - **Registration is explicit twice.** `common/package.json` exports wildcard per dir — add `"./views/*": "./views/*.ts"` (a string, never an array: FACTS:3213; `*` spans `/`, precedent `lib/archive/*`). The test script (`:44`) is a brace list at two depths — add `views` to both. tsconfig needs nothing. `common/controller/noCorpusWalkInRenderPaths.test.ts:28` scans `editor/app` only — widen to `common/views` in the same commit. - **The never-construct rule.** `editor/app/api/pulse/route.ts:36-53`: `getRegistry()` / `getWorkerPool()` / `getScheduler()` CREATE singletons and `/api/test/invalidate-cache` clears them between specs (was ~16 flaky specs), so pulse reads `globalThis.__ytt*__` directly. `getAutoRunnerStatus` also constructs (`controller/autoRunner.ts:232-240,284`). A view that takes its singletons as arguments makes the rule structural. That is the slice. - **`React.cache` keys on `paths` identity** (`editor/app/lib/requestCache.ts:24-26`; the file states why it must stay in the editor). Views take briefs as a parameter. - **Already pure, move as renames:** `channelFlow.ts` (582), `stageStatus.ts` (628), `flow/tone.ts` (41), `pipelines/band.ts` (275, zero imports), `pipelines/buildBands.ts` (248), `channels/lib/channelGroupSections.ts` (321), `jobs/jobRowView.ts` (94), `jobs/jobRows.ts` (331), `components/lanes/laneState.ts` (79), `widget/lib/builtInPresets.ts` (63), the pure half of `operations/channelPriorityView.ts` (119). - **Not pure yet:** `jobs/active/buildActiveJobs.ts` (461: fs log tail `:143`, `diskGate` `:347`, `readJobMeta`/`readChannelStat`, `Date.now()` `:233,:271`, five getters, `getAutoRunnerStatus` `:395`); `workers/buildWorkers.ts` (58: types imported from the `"use client"` `WorkersView.tsx:16-66`, `readWorkerDefaults` sync fs); `operations/lanes.ts` (131) + `operations/status.ts` (179: `readAutoQueueState` read 4× at `:122`); `api/widget/sync/route.ts` (331: builder + type INSIDE the route with `NextResponse`; `app/page.tsx:20` imports the builder out of a route file — the smell); `api/pulse/route.ts` (161); `scheduler/status.ts` (53: imports `heartbeat.ts` → `runTick`, a runner). - `ArchiveReader` (Phase 2) is the pattern, not the port: editor views read live state, not a published archive. ## Layout ``` common/views/ inputs.ts the port — LiveInputs / ObserveInputs / RegistryReader / PoolReader; TYPES ONLY streamAction.ts StreamActionView (the burn); zero imports; StreamActionResult is assignable to it jobRowView.ts ← editor/app/jobs/jobRowView.ts (types only; imported by "use client" files) jobRows.ts ← jobs/jobRows.ts + jobRows.test.ts (13) laneState.ts ← components/lanes/laneState.ts + test (4); imports only ./pipeline/tone activeJobs.ts ActiveJobsPayload/ActiveLaneView/DiskStatusView + buildActiveJobsPayload(inputs), liveJobRows(inputs, filter), rowsFromJobsPage(page), stuckJobIds(inputs) workers.ts WorkersPayload/WorkerView/WorkerTask (out of WorkersView.tsx) + buildWorkersPayload(inputs), sync autoQueueLanes.ts ← operations/lanes.ts; buildAutoQueueLanes(inputs), sync channelPriority.ts PriorityView, pendingByLeafFromCounts, laneFocusSummary — the pure half of channelPriorityView.ts (readPriorityView stays in the editor) autoQueueStatus.ts ← operations/status.ts; buildAutoQueueStatusPayload(inputs), sync widgetSync.ts WidgetSyncPayload + buildWidgetSyncPayload(inputs) ← api/widget/sync/route.ts:27-327 schedulerStatus.ts ← scheduler/status.ts; buildSchedulerStatusPayload(inputs), sync pulse.ts PulsePayload + computePulse(observe) ← api/pulse/route.ts:20-122 builtInPresets.ts ← widget/lib/builtInPresets.ts (its test STAYS in the editor: it imports ./config) channelGroupSections.ts ← channels/lib/channelGroupSections.ts + test (16) pipeline/ the only nest — these five import each other band.ts ← components/pipelines/band.ts buildBands.ts ← components/pipelines/buildBands.ts + test (15) stageStatus.ts ← channels/[slug]/lib/stageStatus.ts + stageOrder.test.ts (2) channelFlow.ts ← channels/[slug]/lib/channelFlow.ts + channelFlow.test.ts (12) tone.ts ← channels/[slug]/components/flow/tone.ts ``` Payload TYPE names and wire shapes unchanged everywhere; only paths move. `common/jobs/syncScheduler.ts`'s `buildScheduleView` stays in `jobs/` (views may import jobs). ### The port `views/inputs.ts`, types only. `JobRegistry` is not an exported class, so use `ReturnType` via `import type`, as pulse does today at `route.ts:56`: ```ts export type RegistryReader = Pick, "list" | "get">; export type PoolReader = Pick; export type LiveInputs = { paths: Paths; settings: SiteSettings; registry: RegistryReader; scheduler: Scheduler; pool: PoolReader; now: () => number; }; export type ObserveInputs = { registry: RegistryReader | null; scheduler: Pick | null; pool: PoolReader | null; snapshotGeneration: number; settingsMtime: number; changelogMtime: number; digest: (s: string) => string; }; ``` ### The one constructor New `editor/app/lib/liveInputs.ts` (~40 lines): `liveInputs()` calls the five getters + `now: Date.now` — the only editor file allowed to call all five for a view; `observeInputs()` reads `globalThis.__ytt*__` (the `declare global` block moves here from `pulse/route.ts:54-61`; `__yttSnapshotScheduler__?.generation ?? 0` as a number — its type is not exported), the two `statSync` mtimes, and `digest = sha1/base64url`. Per-view extras are assembled in each noun's shell, not here, so B and C never edit this file. It imports `getChannelBriefs` from `./requestCache` (three other importers; do not absorb it). ### The guard change (`common/architecture.test.ts`), one commit with registration | edit | shape | |---|---| | `FORBIDDEN` | `lib: [..., "views"]`, `jobs: [..., "views"]`, new `controller: ["views"]`, new `views: ["components", "ytdlp", "bin", "social"]` — downward only | | `ROOTS` | append `"views"` (the line that makes it real) | | `ALLOWED` | delete the `StreamActionLog.tsx -> jobs/streamCommand` entry; `common/components/StreamActionLog.tsx:5` imports `StreamActionView` from `../views/streamAction` (it reads only `ok/error/info/jobId/stream`, `:140-147`, never `done`, so no `jobs/` type is needed) | | failure prose `:165-169` | add "views/ may not import components/" | | new `BARE_FORBIDDEN` | `{ views: /^(react\|react-dom\|next\|server-only\|client-only)(\/\|$)\|^node:\|^yt-dlp-transcript-common(\/\|$)/ }` — the self-name is listed because a package-name self-import hides from the relative check | | new `bareEdges()` | same walk + `IMPORT_RE`, roots in `BARE_FORBIDDEN`, **skip `*.test.ts`** (tests import `node:test`), collect `${rel} -> ${spec}` | | new test | `views/ imports nothing from react, next, node: or the package's own name` — guard-the-guard (views walk saw > 0 files) then `deepEqual([], bareEdges())` | | new test | `views/ never calls a singleton getter, a disk reader or the clock` — textual ban over non-test `views/` files, the `noCorpusWalk` trick: `getRegistry(`, `getScheduler(`, `getWorkerPool(`, `getPaths(`, `getSettings(`, `getAutoRunnerStatus(`, `readChannelStat(`, `readJobMeta(`, `listChannelBriefs(`, `listChannelConfigs(`, `readSchedulerState(`, `readAutoQueueState(`, `computeLeafPending(`, `readWorkerDefaults(`, `diskGate(`, `Date.now(` | | `common/package.json` | `"./views/*": "./views/*.ts"`; both test-glob brace lists gain `views` | | `noCorpusWalkInRenderPaths.test.ts:28` | `ROOTS = [EDITOR_APP, common/views]` for the walk test; the "cheap readers are referenced" test keeps scanning `editor/app` only (the readers are shell calls and stay there) | Consequence, stated: `views/` may value-import `controller/` and `jobs/` only for pure helpers (`digestCountOf`, `digestWorkOf`, `excludedDownloadIdSet`, `autoRunnerJobKind`, `laneDispatchRoot`, `buildScheduleView`, `isGateHeld`); every reader is injected. The textual ban is the cheap proof. ### Per-builder refactor | builder | pure signature in `views/` | the shell passes | shell file (old path, old names, same values) | who repoints (D) | |---|---|---|---|---| | activeJobs | `buildActiveJobsPayload(i: ActiveJobsInputs)`, `liveJobRows(i: LiveJobRowsInputs, filter)`, `rowsFromJobsPage(page)`, `stuckJobIds(i)`. `ActiveJobsInputs = LiveInputs & { readChannelStat(slug); readJobMeta(id); tailLog(id); disk: DiskGateStatus; autoRunnerStatus(kind) }` — readers injected as functions because slugs are only known after `registry.list()`. `LiveJobRowsInputs` has no `disk`/`tailLog`/`autoRunnerStatus` | the `node:fs/promises` 8 KB tail (`:136-163`) moves INTO the shell as `readLastLogLine`; `diskGate(paths, settings, {mode:"observe"})`; `getAutoRunnerStatus` | `jobs/active/buildActiveJobs.ts` → ~45 lines; `listJobRows(paths, opts) = rowsFromJobsPage(await listAllJobs(paths, opts))`; all nine value consumers untouched | `DashboardCockpit`, `PipelineBand`, `LaneDeck`, `WidgetControls`, `MonitorWidget` (types) | | workers | `buildWorkersPayload(i: Pick & { workerDefaults })`, sync | `readWorkerDefaults(paths)` | `workers/buildWorkers.ts` → 6 lines; `WorkersView.tsx` imports the three types from views and re-exports them until D | same five + `WorkersView` | | autoQueueLanes | `buildAutoQueueLanes({ settings, briefs, workerSummary })`, sync | folded into the status shell | `operations/lanes.ts` keeps `export type { LaneWorker }` until D, then deleted | `LaneHeader`, `RunnerOperationView` | | autoQueueStatus | `buildAutoQueueStatusPayload(Pick & { priority: PriorityView; runner; state: AutoQueueState; pending; lanes })`, sync | `readPriorityView()` (stays in editor), `readAutoQueueState(paths)` ONCE (was 4×, note in commit body), `computeLeafPending` ×4, `getAutoRunnerStatus` ×4, `getChannelBriefs` | `operations/status.ts` → ~40 lines keeping the name; route and pages untouched | nine `operations/components/*` type importers | | widgetSync | `buildWidgetSyncPayload({ settings, now, state: SchedulerState, briefs })`, sync | `readSchedulerState`, `getChannelBriefs`, `Date.now()` | **no shell**: new `widget/lib/syncInputs.ts` exports `widgetSyncInputs()`; the route becomes `GET = NextResponse.json(buildWidgetSyncPayload(await widgetSyncInputs()))` and exports only `GET`/`dynamic`; `app/page.tsx:20` → views + syncInputs | same five (types) | | schedulerStatus | `buildSchedulerStatusPayload({ settings, now, state, channels, heartbeatSeconds })`, sync | `readSchedulerState`, `listChannelConfigs`, `resolveHeartbeatSeconds()` (reads env + imports `runTick`; never from views) | `scheduler/status.ts` → ~25 lines | `operations/syncRow.ts`, `SyncConsole.tsx`, `operations/[id]/page.tsx:26` | | pulse | `computePulse(o: ObserveInputs): { rev; activeJobs; runningJobs; busy }` = `route.ts:63-122` with globals → `o.*`, `createHash` → `o.digest`, `mtime()` → two numbers; rev bytes unchanged | `observeInputs()` | **no shell**: the route keeps `GET` (idle fast path, then `cleanableTotalBytes` — out of scope, stays) | `components/pulse.ts:4` (type) | Two contracts to keep exactly: `liveJobRows` never heals (four server components call it with a filter, `buildActiveJobs.ts:222-235`); `buildActiveJobsPayload` always heals (`scheduler.complete` at `:341` stays in the view, asserted against `createScheduler()`). ## Dependency graph ``` A registration + guard + inputs/liveInputs + streamAction burn + ALL pure renames ← serial, first ├── B jobs group: views/workers.ts, views/activeJobs.ts (+ shells, purity tests) └── C console group: schedulerStatus, autoQueueLanes+channelPriority+autoQueueStatus, widgetSync, pulse D repoint every TYPE import; delete the type re-exports and the empty operations/lanes.ts; record ← serial, on the merged tip ``` After A, **B and C share no file**: | file | owner | |---|---| | `common/package.json`, `architecture.test.ts`, `noCorpusWalk…`, `lib/liveInputs.ts`, `widget/page.tsx` | A only | | `app/page.tsx` (`:20`) | C only | | the five shared client files (`DashboardCockpit`, `PipelineBand`, `LaneDeck`, `WidgetControls`, `MonitorWidget`) | A, then D | | `operations/[id]/page.tsx`, the nine `operations/components/*` | D only | | `buildActiveJobs.ts`, `buildWorkers.ts`, `WorkersView.tsx` | B only | | `operations/{lanes,status,channelPriorityView}.ts`, `scheduler/status.ts`, `api/widget/sync/route.ts`, `api/pulse/route.ts`, `components/pulse.ts` | C only | ## Slices ### A — registration, guard, port, burn, pure renames (serial, first) - **A1** guard + registration + `views/inputs.ts` + `views/streamAction.ts` (entry deleted, `StreamActionLog.tsx:5` repointed) + `lib/liveInputs.ts` + noCorpusWalk widening. One commit: the stale-entry test forces the burn and the type move together. - **A2** `git mv` the pipeline five (+3 tests) → `views/pipeline/`; rewrite package-name imports to relative; repoint `flow/*` (9), `PipelineStageCard`, `channels/[slug]/page.tsx`, `videos/page.tsx`, `videoRowsServer.ts:6` (`server-only`, stays), `StateBand.tsx`, `HoldSieve`. - **A3** `git mv` `laneState`, `jobRowView`, `jobRows`, `channelGroupSections`, `builtInPresets` (+4 tests); repoint `LaneCard/LaneDeck/LaneRail`, `JobsTable/JobProgressBars/LaneStrip/RunningJobsList/OperationDetail/MonitorWidget`, `channels/{page,groupActions,components/*}`, `widget/{page,builder/page,PresetsRow}`, `builtInPresets.test.ts` (stays), and `buildActiveJobs.ts:27-39`. ### B — jobs group (parallel with C, off A's tip) - **B1** workers: `views/workers.ts` + `workers.test.ts`; `buildWorkers.ts` → shell; `WorkersView.tsx` re-exports the three types. - **B2** activeJobs: `views/activeJobs.ts` + `activeJobs.test.ts`; `buildActiveJobs.ts` → shell with `readLastLogLine`. ### C — console group (parallel with B, off A's tip) - **C1** schedulerStatus. **C2** lanes + channelPriority + status (`readAutoQueueState` once). **C3** widgetSync + `widget/lib/syncInputs.ts`; the route exports only `GET`/`dynamic`; `app/page.tsx:20` repointed. **C4** pulse: `computePulse` + the `observeInputs()` call in the route. ### D — repoints and record (serial, on the merged tip) Repoint every TYPE import to `yt-dlp-transcript-common/views/*`; delete the type re-exports (`WorkersView.tsx`, `operations/lanes.ts`); numbers; `editor/CHANGELOG.md`; the Shipped record below; the full 533. ## Invariants - Every payload type name, every wire field, `/api/pulse`'s `rev` bytes, `/api/widget/*` paths: unchanged (FACTS:2697). - `views/` imports common relatively; no bare specifier but real third-party packages; no `node:*`. - No `views` module value-imports a reader (`controller/channels`, `jobs/jobMeta`, `jobs/listJobs`, `jobs/syncSchedulerState`, `jobs/workerDefaults`, `lib/diskSpace`, autoRunner's readers) — types only. - Allow-list 11 → 10; nothing added. No rendering change. No route path change. - Untouched: `loadActionable`, `loadCleanup`, `heartbeat/runTick`, client `pulse.ts`, all routes' paths, settings schema. ## Tests to ADD Guard: the two new architecture tests; the widened corpus-walk test asserts the views root yielded > 0 files. Per builder, in `common/views/*.test.ts` with stub `{list,get}` registry, `createScheduler()`, stub pool, fixed `now`: - `activeJobs.test.ts`: `builtAt === now`; `tailLog` called only for stuck rows; `readChannelStat` once per distinct slug; slot without record → `readJobMeta` → phantom row; terminal record holding a slot → `scheduler.complete` called and the row drawn once; `liveJobRows` never calls `complete`; stopped runner → `unavailable`. - `workers.test.ts`: tasks grouped by `workerId` for running transcribe tasks only; `canStopPartial` per worker; `defaultEnabledIds` null vs list; `downloadsPaused` off `isGateHeld`. - `pulse.test.ts`: all-null singletons → `activeJobs 0`, `busy false`, deterministic rev; rev moves with `progress.current` and with `snapshotGeneration`; identical inputs → identical rev. - `widgetSync.test.ts`: two briefs → sums; a snapshot lacking `eligible` → `null` for that kind only; `kinds` sorted reachable-desc then id; `nextRunAt`/`overdue` off `buildScheduleView`. - `autoQueueStatus.test.ts`: cooldowns filtered by `now` and sorted; `held` from `pool.isPaused()` for transcription, `isGateHeld` otherwise; `policy.root` compiled vs stored. - `autoQueueLanes.test.ts`: `worker#1/#2` fold to one row with `slots 2`; untagged remote worker not listed; llm worker listed by tag. - `schedulerStatus.test.ts`: pass-throughs; `channels` is `buildScheduleView`'s output. Moved as-is: 62 tests in six files. Common 1159 → ~1,246; editor unit ~110 → ~48. ## Verification Every commit: `pnpm -r exec tsc --noEmit`. Every sub-slice, from its worktree: `pnpm --filter yt-dlp-transcript-common test`; `pnpm -C editor exec tsx --test "app/**/*.test.ts"`; `pnpm --filter editor exec next build` (the only proof the client bundles resolve `views/{laneState,jobRows,pipeline/band,builtInPresets,channelGroupSections}` and nothing dragged `node:fs` into a `"use client"` graph); `pnpm --filter export exec next build` once on A. e2e (detached, queue lock, worktree, composed fixture site copied into `export/public`): | sub-slice | specs first | |---|---| | A | `channel-line`, `channel-stage-selection`, `channel-work`, `channel-groups`, `channels-counts`, `pipeline`, `jobs`, `jobs-active-order`, `jobs-channel`, `widget`, `dashboard` | | B | `jobs*`, `jobs-batch-tasks-drain`, `jobs-reorder`, `jobs-retry`, `workers`, `worker-remote`, `worker-unit`, `dashboard`, `dashboard-paths`, `widget` | | C | `auto-queue`, `lane-runner`, `operation-settings`, `scheduler`, `pulse`, `widget`, `dashboard` | | D / merged tip | the full 533 | ## Traps for the implementer - Relative imports or the guard is blind; the self-name is in `BARE_FORBIDDEN` so a forgotten rewrite fails loudly. - `ROOTS` is explicit; the guard-the-guard assertion is what catches a forgotten `views`. - Tests import `node:test` — the bare check skips `*.test.ts`; never loosen the regex to get green. - Textual bans are context-blind: a comment saying "we do not call getRegistry() here" fails the ban test; reword. - Exports map entry is a string, never an array; a `.tsx` under `views/` would need an explicit line. - `React.cache` keys on `paths` identity: pass `liveInputs().paths` straight through, never spread it. - `getAutoRunnerStatus` constructs: an extra supplied by shells that already construct; `observeInputs()` never touches it. - `stageStatus.ts:13-16` is a type-only import of `channelMedia` (node:fs) imported by client components — keep `import type`; only `next build` can see a violation. - One noun, one file: `deriveLaneState` is value-imported by the `"use client"` `LaneDeck.tsx:11`; co-locating it with `activeJobs.ts` (which value-imports `controller/autoRunner` → `node:path`) kills the client bundle. - `WorkersView.tsx` is `"use client"`: B moves the types out and leaves `export type` until D; never let the view import from it "for now". - A route file ends up exporting only `GET`/`dynamic`; `widgetSyncInputs()` goes in `widget/lib/syncInputs.ts`. - `git mv` then edit, add by path; never boot against `transcripts/`; `next build` never in the primary checkout. - `builtInPresets.test.ts:4` imports `./config` — the test stays in the editor. ## Numbers (from the inventory) Pure renames move 2,662 non-test lines; builders 1,423 lines out, ~250 back as shells; net `editor/app` shrink ≈ 3,800 lines plus 1,636 test lines. 20 files under `views/`. Allow-list 10. ## Cadence Fable plans and reviews. A: one Opus implementer, worktree `one-core/phase-3-s1a`, lands on `main` first (three commits). B and C: two Opus implementers in parallel on `one-core/phase-3-s1b` / `-s1c` from A's tip. D: one implementer on the merged tip, then the full 533. Standing prompt rules: plan path, fish `commit -F` under `$CLAUDE_JOB_DIR/tmp`, the two trailers, add by path, never boot against `transcripts/`, e2e detached + Monitor (no polling), commit small, the report contract (shas, exact gate outputs, divergences, undone). ## Record Filled in as sub-slices ship: sha range, actual gate numbers, every divergence from this plan. ### A — shipped 2026-09-14 Branch `one-core/phase-3-s1a` off `1faa987`, four commits `91b06b3` → `8c43231`, fast-forwarded onto `main`; worktree and branch deleted. **Allow-list 11 → 10, nothing added. No rendering change, no route path moved.** | commit | what | |---|---| | `91b06b3` | the guard sees `views/`: `FORBIDDEN` rows, `ROOTS`, the `StreamActionLog` entry burned, `BARE_FORBIDDEN` + `bareEdges()`, the textual ban; `views/inputs.ts`, `views/streamAction.ts` (+ compile-time assignability test), `editor/app/lib/liveInputs.ts`; exports map and test globs; corpus-walk test widened | | `bc070b3` | `views/pipeline/{band,buildBands,stageStatus,channelFlow,tone}.ts` + 3 tests, imports rewritten relative, every importer repointed | | `44c65e9` | `views/{laneState,jobRowView,jobRows,channelGroupSections,builtInPresets}.ts` + 3 tests; `builtInPresets.test.ts` stays in the editor | | `8c43231` | review fix: the assignability assertion was a DISTRIBUTIVE conditional (`A extends B`), so a non-assignable union member collapsed to `true | never` = `true`; now `[A] extends [B]` | Gates (worktree #2): tsc clean after every commit; common **1224/1224** (1159 + 62 moved + 3 new); editor unit **59/59**; editor and export `next build` clean; e2e subset of 16 specs (`channel-groups, channel-line, channels-counts, channel-stage-selection, channel-work, dashboard-paths, dashboard, jobs-active-order, jobs-batch-tasks-drain, jobs-channel, jobs-filters, jobs-reorder, jobs-retry, jobs, pipeline, widget`) **92 passed, exit 0** in 4.6 min. `common/views/` holds 19 files, 4,420 lines (2,755 non-test, of which 2,662 moved). Divergences, each because the code said so: 1. **The plan file is its own commit (`1faa987`) on `main`**, not part of A1, so every worktree branches with it in-tree. 2. **The pulse route's `declare global` block was deleted, not moved.** `common/jobs/{registry,scheduler,workerPool}.ts` already declare the three globals; the route's copy only existed to spell `ReturnType`. C4 finishes the route. 3. **`liveInputs.ts` does not import `getChannelBriefs`.** `LiveInputs` has no briefs field; shells that need briefs import `./requestCache` themselves (C's status shell and `widget/lib/syncInputs.ts`). 4. **`buildBands.test.ts`'s `StateBand.tsx` guard reads across the package boundary** (`readFileSync` of `editor/app/components/pipelines/StateBand.tsx`, textual, not an import). Kept: it is the check that catches a client module exporting a callable helper, which `next build` does not. 5. **A repointed three files the conflict table assigns to B/C** — `operations/lanes.ts`, `buildActiveJobs.ts:27-39`, `operations/components/{OperationRail,OperationDetail,railStates}` — one import line each; B and C fork from A's tip, so no conflict. 6. `views/inputs.ts` comments reworded so no banned name appears with its parenthesis (the plan's trap, hit as predicted). Seen once and not caused here: `controller/relocateChannelMedia.test.ts` "out @ swap: crash before the rename" failed on one run (`2 file(s) still differ (first: .d..t...... ./)`, an rsync directory-mtime race) and passed on the immediate re-run. A pre-existing flake; if it recurs it gets its own note in FACTS. ### B — shipped 2026-09-14 Branch `one-core/phase-3-s1b` off `8c43231`, rebased onto `bb52dbd` and fast-forwarded: `169874e` (workers) → `dfcb5f6` (activeJobs). Worktree and branch deleted. Gates (worktree #2): tsc clean after each commit; common **1239/1239** (+15: 5 workers, 10 activeJobs); editor unit 59/59; editor `next build` clean; e2e subset of 13 specs (`jobs*` ×6, `workers`, `worker-remote`, `worker-unit`, `dashboard`, `dashboard-paths`, `widget`) **68 passed, exit 0** in 4.3 min. Shells: `buildActiveJobs.ts` 461 → 120 (the 8 KB `readLastLogLine` tail lives there by design; the shell proper is ~45), `buildWorkers.ts` 58 → 20. Views: `activeJobs.ts` 417 + 361 test, `workers.ts` 112 + 191 test. Divergences: 1. **The injected readers are `channelStat` / `jobMeta`, not `readChannelStat` / `readJobMeta`.** The plan named the fields with the `read` prefix AND put `readChannelStat(` / `readJobMeta(` on the textual ban, which is context-blind, so `i.readChannelStat(slug)` fails it. The fields were renamed; the guard was not loosened. 2. Two comments reworded for the same ban (`Date.now()` and `getAutoRunnerStatus` mentions). 3. `WorkersView.tsx` lives at `workers/components/WorkersView.tsx`; its now-unused `WorkerRuntimeState` import was dropped. 4. `stuckJobIds` in the shell samples the disk gate via the same input assembler as the payload — as before, since it delegated to `buildActiveJobsPayload()`. ### C — shipped 2026-09-14 Branch `one-core/phase-3-s1c` off `8c43231`, rebased onto B's tip and fast-forwarded: `c30496d` (schedulerStatus) → `4931909` (lanes + channelPriority + status) → `2ff7d2c` (widgetSync + `widget/lib/syncInputs.ts`) → `5ddc158` (pulse). Worktree and branch deleted. Gates (worktree #3): tsc clean after each commit; common **1249/1249** on its own tip (+25: schedulerStatus 2, autoQueueLanes 6, autoQueueStatus 5, widgetSync 5, pulse 7); editor unit 59/59; editor `next build` clean; e2e subset of 8 specs (`auto-queue`, `dashboard`, `dashboard-paths`, `lane-runner`, `operation-settings`, `pulse`, `scheduler`, `widget`) **82 passed, exit 0** in 2.9 min. `/api/pulse` rev bytes unchanged — the hashed parts, their order and the `|` join are the same; `pulse.test.ts` asserts the hashed STRING through an identity digest so a reorder names what moved. **`readAutoQueueState` is read once per poll, not four times.** Shells: `operations/lanes.ts` 131 → 11 (types only, D deletes it), `operations/status.ts` 179 → 72, `channelPriorityView.ts` 119 → 86, `scheduler/status.ts` 53 → 32, `api/widget/sync/route.ts` 331 → 22, `api/pulse/route.ts` 161 → 52. Divergences: 1. **`api/widget/sync/route.ts` kept one `export type { WidgetSyncPayload }`** until D: its only importers are the five shared client files, which are D's to repoint. 2. `operations/lanes.ts` re-exports three types, not one (`LaneWorker`, `LaneOperationView`, `AutoQueueLanesPayload`) — a pure superset of the old surface. 3. `now` is a number on `schedulerStatus` and `widgetSync` (one-shot folds; the plan's shells pass `Date.now()`) and a function on `autoQueueStatus` (the plan's `Pick`). 4. `channelFocusName` stays in the editor with `readPriorityView`, its only caller. 5. `operations/status.ts` is 72 rather than ~40 because of the `byLane` helper that folds the four runner statuses and pending counts into the `Record` the view takes. **Merged tip `5ddc158`**: tsc clean; common **1264/1264**; editor unit 59/59. The two `"use client"` importers of a view (`components/pulse.ts`, `WorkersView.tsx`) are type-only. ### D — shipped 2026-09-15 Branch `one-core/phase-3-s1d` off `5ddc158`, rebased onto `36ab02c` and fast-forwarded: `3a20156` (repoints + deletions) → `838da4a` (changelog). Worktree and branch deleted. Gates (worktree #2): tsc clean after each commit; common 1264/1264; editor unit 59/59; editor and export `next build` clean; **the full editor suite 533 passed, exit 0**, in 38.8 min (the 23-min baseline was measured on an idle machine; nothing flaked and nothing was re-run). 18 files repointed to `views/*` with `import type`; `operations/lanes.ts` deleted; the seven `export type` re-export blocks deleted; both routes export exactly `GET` and `dynamic`; every remaining editor import of a shell is a value import (17 lines). Divergences: `components/pulse.ts`, `api/pulse/route.ts` and `operations/[id]/page.tsx` needed nothing (C4 had already left them final; the page names no payload type); `channelPriorityView.ts` also lost a dead VALUE re-export of `pendingByLeafFromCounts` / `laneFocusSummary` (no importer since C2); no type had to be added to a view. ## Slice 1 — shipped 2026-09-15 `main` `1faa987` (plan) → `838da4a`, 15 code commits plus three records, all fast-forwards. **Every payload the editor draws is a pure function in `common/views/`, handed its singletons, its readings and its clock; the layer guard proves it (allow-list 11 → 10, two new tests, the textual ban); nothing rendered, routed or serialized changed.** | | | |---|---| | `common/views/` | 34 files: 4,163 non-test lines (2,662 moved as renames), 2,969 test lines | | `editor/app` | −5,338 lines net (`--no-renames`: 78 files, +357 / −5,695) | | common tests | 1159 → **1264** (62 moved + 3 guard/assignability + 15 B + 25 C) | | editor unit | 92 → 59 | | allow-list | 11 → **10**, nothing added | | live behaviour | `readAutoQueueState` once per `/operations` poll, was 4× | What slice 2 inherits: the shells (`buildActiveJobs.ts`, `buildWorkers.ts`, `operations/status.ts`, `scheduler/status.ts`, `channelPriorityView.ts`, `widget/lib/syncInputs.ts`) and `lib/liveInputs.ts` are the whole editor-side surface of a view; `getAutoRunnerStatus` and `computeLeafPending` are still called four times per poll from the status shell (not claimed fixed; the reads are in-memory). > **Corrected 2026-09-22 (`bad9ea43`).** That last sentence described the shape slice 1 left > and is no longer true of `computeLeafPending`, and its parenthetical was wrong when it was > written: the reads were **not** in-memory. `computeLeafPending` walked the channel configs > and `.auto-queue/state.json` itself, so the four-lane poll read the corpus four times per > tick. It now takes an optional third argument — > `computeLeafPending(kind, paths = getPaths(), shared?: {configs?, state?})`, > `common/controller/autoRunner.ts:919-927` — and > `buildAutoQueueStatusPayload` (`editor/app/operations/status.ts`) reads configs and state > ONCE via `Promise.all` and hands the same pair to all four lanes. `getAutoRunnerStatus` is > still called per lane and is genuinely in-memory. ## Release 2026-09-23 — slices 2 and 4a `main` `54cf1b31` → **`ae2fa5a9`**. Two branches, both off `54cf1b31`, merged in this order: 1. **`one-core/phase-3-s2` → `8d6e84f6`** — one polling route. Five commits, `5397f83c` `074f09a2` `edf56370` `4288009b` `c205b270`. 2. **`one-core/phase-3-s4a` → `ae2fa5a9`** — one zod schema for `settings.json`, one editor writer, the example and the key table generated. Seven commits, `50215ab5` `e120a2a5` `f1abe024` `85478527` `0316e988` `5b43dc8d` `81deae69`, then `main` merged in as `7035316c`. **Why that order:** slice 2 first, so a rewrite sits in front of every polling request on a green `main` *before* the settings reader underneath those views changes. Had the order been reversed, a regression in the schema would have surfaced through eight route files that were about to be deleted, and slice 2's before/after numbers would have been measured across two changes at once. **Scope, decided with the operator 2026-09-23.** This release is slice 2 and slice 4a only. Slice 4a is `settings.json` alone — `site.json`, channel `config.json` and the sidecars are slice 4b. Slice 3 and slice 4b are the next release. zod is adopted (one dependency, in `common/`), which spends the veto `one-core.md` left open. Gate B from Phase 1 rode along as a prelude — a settings change through the existing surface plus a record, no code ([`one-core-phase-1.md`](one-core-phase-1.md#gate-b--passed-2026-09-23)). **Gates on merged `main` `ae2fa5a9`:** `tsc --noEmit` clean across the workspace; common **1663** (1625 + 13 from slice 2 + 25 from slice 4a); editor unit **67** (59 + 4 + 4); `pnpm run test:scripts` **156 + 1 skip**; mcp **219**; editor and export `next build` green, with `ƒ /api/view/[name]` in the route table and no zod in any client chunk. Full editor suite on `ae2fa5a9`: 621 passed, 0 failed, 36.7 min (600 before this release) Export suite: 188 passed, 0 failed, 5.8 min (unchanged) **Record corrections made while checking the two records below against the branches** (2026-09-23; where a record and the code disagreed, the code won): slice 2 is five commits, not four — the review fix `c205b270` came after the record was written, and it moved the `widgetActionable` row mapping out of `views.ts`, so the replacement is 176 lines + an 84-line test at the tip, not 185 + 66; editor unit is 63 at that tip, not 61. Slice 4a's table did not list its own record (`5b43dc8d`) or the review fix (`81deae69`), and the review fix added two settingsDocs wiring tests, so common on that tip is 1650, not 1648. Both records said "unmerged"; both are merged. ### Rollout 2026-09-24 — `b836ea8a` (+ two prelude commits) live on :3001 The editor on :3001 had served a `54cf1b31` build since 2026-09-14; this put release 2 in front of the operator. Editor only — `git diff --stat 54cf1b31..b836ea8a -- umtool` is empty and umtool's one `common` import (`lib/momentUrl`) is unchanged, so umtool was not rebuilt. **Two code commits on `main` first, both to the same stale sentence.** `c0a90a37` — the backfill lane form (`LaneSettingsForm.tsx`) derives the re-acquire figures from the widget-sync payload plus one statfs of the transcripts root (`getFreeBytes`, the same call behind the jobs payload's `disk.freeBytes`); the hand-typed "836 / ~76,270 / 91× / 45 GB" stays as the fallback when no figures are passed. `1e27f7c3` — found live: the lane-wide `backfill.reachable` folds attribution-text, whose input is the transcript, so the first build rendered "78,146 videos still have media on disk". The page now reads the **diarization** kind's entry from `backfill.kinds` — the one operation whose missing input is audio, which is what the toggle fetches. Live it reads "679 videos still have media on disk and 74,411 would need re-downloading — 110× the reachable work, against 269.43 GB free", the successor of the dated sentence and the same 679 gate B measured. **Procedure, as in release 1:** offline round-trip first — `plans/tools/phase3-settings-numbers.ts live=settings.json`, written block equal to `jq -S . settings.json` (diff empty, no key added); then `pnpm --filter editor build` detached into the live `.next` while the old server kept serving; then TERM the process whose cwd is `editor/`, wait for :3001 to free, `setsid nohup pnpm run start -H 0.0.0.0`. Done twice (build 7 on `c0a90a37`, build 8 on `1e27f7c3`): `BUILD_ID` `CXAYTdyUXFjcEqCiMCwQ-` → `M-ujB5s8O4_phVlvBejMG` → `vXFdg-SWIkdh7XpWLEqCQ`, both route tables with `ƒ /api/view/[name]`. **Smoke, by hand = `view-route.spec.ts`:** `/api/view/` and `/api/view/invalidate-cache` 404; `/api/widget/presets` 200; `/api/view/pulse?rev=` and `/api/pulse?rev=` both `changed:false` (build 8; on build 7 a download was in flight and the rev never held still); `/api/widget/cleanable` `cleanableBytes: null` (the old cleanable path is gone, not `-1` — the spec's fixture value); `/`, `/operations/diarization` (Holding, re-acquire off), `/settings`, `/storage`, `/jobs`, `/tags` all 200; `settings.json` md5 `8e03ec3f…` unchanged across both boots; no `ZodError` in either start log. The eight old/new pairs: `pulse`, `workers`, `widgetSync`, `widgetActionable`, `cleanable` byte-equal on build 7 (ignoring `builtAt` / `disk.freeBytes` / `now`); `activeJobs`, `autoQueueStatus`, `schedulerStatus` equal once the live-only fields (`heldMs`, a running download's `fraction` / ETA `detail`) are stripped — a live server is not the idle fixture. Build 8 changed nothing under `app/api`, so the pair evidence carries. **What the smoke could not clear, and why.** Corpus-wide pages took 47–240 s and several probes timed out for ~25 min after each boot. The cause is not the build: every boot re-runs the recency pass (`[recency] dating 8000 of 8883 undated candidates`, once per Next module graph, memoised in-process), the scheduler re-queues the overdue sync-all (46 syncs held, 2 running), and a metadata scan resumes — all against a platter drive (`sdb`, the relocated media root) that an in-flight ffmpeg remux and, later, the 4b numbers script were saturating (`/proc/pressure/io` "full" 58–66 %; the NVMe was idle). Two restarts in one hour paid that boot cost twice; `ARCHILYZER_IDLE_BOOT=1` exists for exactly this and was not used because the lanes were meant to resume. Sharp fails to load in the primary and every worktree (`require('sharp')` throws); builds do not need it and it predates this release. ## Release 2026-09-24 — rollout, slice 3a, slice 4b `main` `b836ea8a` → **`ef88ac4d`**. A prelude on `main`, then two branches, both off `1e27f7c3`, merged in this order: 0. **Prelude, on `main`:** `c0a90a37` and `1e27f7c3` (the backfill lane form's re-acquire figures, derived, then taken from the diarization kind), release 2 put live on :3001 from `1e27f7c3`, and the rollout record `8772dca3` (the section above). 1. **`one-core/phase-3-s3a` → `3241fed2`** — one drawing per noun: the channel row and the job in flight. Eleven commits, `ab7a4739` `155efeb9` `01f08670` `f63260d8` `0d20c42a` `797078c2` `4a784486` `c944475f`, the record `b4890c78`, the review fixes `001f91bc`, the record update `f3432d44`. `main` had moved only by `8772dca3` (plans), so the merge adds no code to the branch tip. 2. **`one-core/phase-3-s4b` → `ef88ac4d`** — the rest of the file schemas: `site.json`, channel `config.json`, the sidecars. Ten commits, `03164485` `bb7fe82c` `2bf65626` `e75047f8` `f6a08bd1` `7c03d7c9`, the review fixes `973e59ee`, the record and numbers tool `c6d955ac`, `main` (`3241fed2`) merged in as `7dfd7508`, and the follow-up `b3cebcf8` (the merged-tip gates). `ef88ac4d`'s code is byte-identical to `7dfd7508`'s. **Why 3a first:** the plan's rule, decided before the worktrees were cut — merge the UI slice first, then let 4b merge `main` and re-run its whole gate set, so a schema regression shows through the NEW tables (`ChannelsTable`, `JobRow`), not through components about to be deleted. The two branches share no code file (only `editor/CHANGELOG.md` and this record, the two files the `7dfd7508` merge conflicted on), so the order cost nothing; 3a was also ready first (review fixes in, post-review e2e green), and the release's end state was then measured once, as a whole, on `7dfd7508`. **Scope, decided with the operator 2026-09-24.** Release 2 goes live first, as a prelude, so both branches start from the code the operator runs (the live editor had served a 2026-09-14 build for ten days). Slice 3 is split: **3a** = the channel row and the job in flight; **3b** = the `stages/*` status logic and the `VideoPanel.tsx` split, the two largest page-local refactors of the phase, which touch none of 3a's files. **4b ships in the same release as 3a.** Not folded in: the Anilyzer production deploy, the other five sites to spec 4, the LM chat-only tier. **Gates, as the two records state them.** 3a on its branch tip (code-identical to `3241fed2`): tsc clean after every commit; common **1677**; editor unit **67**; `test:scripts` 156 + 1 skip; editor and export `next build` exit 0 (at `b4890c78`); e2e 41 spec files **280/280** before the review fixes and **280/280** (13.0 min) after them; numbers diff empty. 4b on the merged tip `7dfd7508` (code-identical to `ef88ac4d`): tsc clean; common **1723** (1709 + 3a's 14); editor unit **67**; `test:scripts` 156 + 1 skip; mcp **219**; editor and export `next build` green, `ƒ /api/view/[name]`, no zod in either `.next/static`; numbers tool diff empty over 3,832 lines; e2e the plan's 34 specs **190/190** (12.7 min). Final full suites on `ef88ac4d`: editor **621 passed, 0 failed, 36.3 min**; export **188 passed, 0 failed, 5.9 min** (run from a worktree detached at `ef88ac4d`, ports 3311/3310, log `final-e2e-r3.log`) **Record corrections made while checking the two records against the code** (2026-09-24; where a record and the code disagreed, the code won): - Slice 3a's record said "unmerged; ten commits". It is eleven — the record update `f3432d44` came after the count was written — and merged as `3241fed2`. It also did not carry the post-review e2e run (run 3: 280/280, 13.0 min), which is now in it. - Slice 4b's commit table named its own record "(this)"; it is `c6d955ac`. - Slice 4b's "14 JSON writers still on the per-pid temp name" lists **16 write sites in 13 files** (`failedTranscriptions`, `duplicateShorts` and `scanCorruptMedia` have two each); `git grep 'tmp-${process.pid}' -- common` at `ef88ac4d` finds all 16. The count is corrected in the record, `one-core.md` and FACTS; the list itself was right. - `FACTS.md`'s slice-2 entry put `widgetActionableRows` at `loadActionable.ts:162`; since 3a it is `:80`. Corrected in place; the other anchors 3a and 4b made stale are listed at the end of FACTS' 2026-09-24 section. - Comments in code that named the pre-4b shape (`digest-server.ts`, `siteSchema.ts` ×2, `controller/channels.ts`, `clipWindow-server.ts`) or the pre-3a home of `reportStateOf` (`ChannelWorkTable.tsx`) were corrected, and the three `SUB_FILE_RE` comments now say `sidecar()` enforces the rule — comment-only edits, line counts unchanged. ### Rollout 2026-09-24 — `9ab10d77` live on :3001 Release 3 (`ef88ac4d`, slices 3a and 4b) plus the three plans/comment commits after it, put in front of the operator. Editor only — `git diff --stat 1e27f7c3..9ab10d77 -- umtool` prints nothing, so umtool was not rebuilt or restarted (it keeps serving on :3050). **Offline round-trips first.** `phase3-settings-numbers.ts live=settings.json`: the written block equals `jq -S . settings.json` (diff empty, 1,353 scalar paths each side, no key added; the live file is 90,513 sorted bytes, up from release 3's 89,871 because the operator's settings moved, not the code). `phase3-files-numbers.ts` over the LIVE `transcripts/` (not a frozen tree): 6 `site.json` + 71 `config.json`, every `unknown keys: []`, no `WRITE THREW`, and the 3,832-line output is **identical** to release 3's frozen-input run (`s4b-numbers-merged.txt`) — none of those files has moved since the freeze. Write-back bytes: all six `site.json` identical; **66 of 71** `config.json` identical, and the other five (`cornbreadman`, `elfpire-eva`, `legal-mindset`, `omnivods-odysee`, `the-quartering-rumble`) differ in key order only (`jq -S` equal on a scratch write) — the one-time schema-order reorder slice 4b named, and the same md5s release 3 measured. **Build and one restart.** `pnpm --filter editor build` detached into the live `.next` while the old server served: exit 0, ~42 s, `ƒ /api/view/[name]` in the route table; `BUILD_ID` `vXFdg-SWIkdh7XpWLEqCQ` → **`XsKaA_drqdAbTguxUGVsn`**. Then TERM pnpm 255275 / next-server 255325 (cwd `editor/`), :3001 free, `setsid nohup pnpm run start -H 0.0.0.0` → next-server 525417, `/` 200 after 2 s, `/tags` 200. One restart for the release. **Smoke, started 8 s after boot.** The eight old/new pairs, with `heldMs, fraction, detail, now, rev, builtAt, updatedAt, elapsedMs, etaMs, disk.freeBytes` stripped: `pulse` (109 B), `workers` (1,113 B), `activeJobs` (3,225 B), `schedulerStatus` (37,343 B), `widgetSync` (1,522 B), `widgetActionable` (1,513 B), `cleanable` (1,677 B) equal on the first pass. `autoQueueStatus` differed on the first pass only because the world moved in the 71 s between the two fetches: a YouTube 429 cooldown opened (`cooldowns: [] → [{youtube, fails 10}]`), the download runner took its next focus video (`CAi9jNrHetw → ncdPaDSqt-c`, `quarteringvlogs` focus pending 2 → 3) — all live state. Re-fetched two minutes later it was equal (508,672 B, 2 s). `/api/view/bogus` and `/api/view/invalidate-cache` 404; `/api/widget/presets` 200; `/api/view/pulse?rev=` and `/api/pulse?rev=` both `changed:false` (downloads were in flight — 2 auto-download, 2 auto-transcribe, 1 transcribe, 1 auto-digest, 1 auto-backfill, 1 refresh-report — and the rev still held); `cleanableBytes: null`. Pages, all 200: `/` 2 s, `/channels` 0 s, `/channels?sort=size` 0 s, `/jobs` 2 s, `/operations/diarization` **62 s**, `/settings`, `/storage`, `/tags`, `/channels/FearAnd`, `/channels/FearAnd/videos/03Bgz7vkgbs` 0 s each. No `ZodError` in the start log (0). **The new surfaces, from the served HTML.** `/channels` is the rack: a `sticky top-0` thead, 71 sticky identity cells, a sortable **Tier** column ("sort by Tier"). The grouped view renders only with a single site in scope (`page.tsx`: groups partition a site), so it was checked on `/channels?site=jeralyzer` — 4 `data-testid="group-name"` section headers (`default`, Archives, Guest Appearances, Extended Universe), each `scope="rowgroup"` and `md:sticky md:top-[var(--thead-h)]` — and `?site=anilyzer` (12). `/jobs` is the one JobRow table: 50 `` rows, 3 of them `data-live`. `/operations/diarization` says **Holding**, "0 in flight" (the lane is held, so `InFlightList` renders nothing — it returns null on no items), and "**693** videos still have media on disk and **74,413** would need re-downloading — 107× the reachable work, against 276.24 GB free" (679 / 74,411 / 110× / 269.43 GB at the last rollout). **md5 sweeps** over `settings.json` + 6 `site.json` + 71 `config.json` (`settings.json` `ed53f672…`). (a) Right after boot, and again 3 min later just before the form save: identical to the pre-build baseline — boot rewrote nothing. (c) One Configure-form save with no field changed on `legal-mindset`, through `pnpm ops channel-config` with `patch: {}` (it lays the empty patch over `channelConfigToFormData(existing)` and calls `updateChannelAction`, the server action the form posts): only `channels/legal-mindset/config.json` changed, `87ee6758…` → `7dd35dac…` — exactly the md5 the offline write-back predicted; `diff <(jq -S) <(jq -S)` empty, the raw diff moves `name` after `platform` and `cookieMode` after `downloadFilter` (key order only). `settings.json` and every `site.json` unchanged. (b) **Owed; YouTube held a 429 cooldown across three attempts (18:43, 19:13, 19:41).** `pnpm ops sync {"slug":"FearAnd"} --wait` was refused by the editor each time — "youtube is in a rate-limit cooldown" with 1634 s, 1534 s and 1680 s remaining: auto-download kept hitting HTTP 429 and re-arming it, so no sync job ran. A non-YouTube channel was not substituted. The md5 sweep after the third refusal, against the post-form-save sweep, moved one file: `channels/the-quartering-rumble/config.json` (`ac111558…` → `fed252e8…`, written 19:36:06), which gained `fullSweepIntervalMinutes: 0` and was re-emitted in schema key order (`lastSyncedAt` unchanged). That is not this rollout: it is the operator's step 1 of [`rumble-sweep-pacing.md`](rumble-sweep-pacing.md), applied through the editor; the file is `jq -S` equal to its release-3 frozen copy once that key is removed. Syncs stamp through `patchChannelConfig`, so the expected effect of a sync is a `lastSyncedAt` (and, on a full sweep, `lastFullSweepAt`) change on the synced channel's file only; that remains unmeasured for this build. **Boot cost, as observed:** small. The API views and every page but one answered in 0–3 s from 8 s after boot; `/operations/diarization` took 62 s and the first `/api/auto-queue/status` ~70 s, both corpus-wide, both while the recency pass ran (`[recency] dating 8000 of 8886 undated candidates`, twice — once per Next module graph). IO pressure at boot was `full avg10 7.26` (58–66 % at the last rollout, when a remux was saturating the platter), which is the difference from the 25 minutes release 2 paid. ### Slice P, as shipped — /channels rack polish (2026-09-24) Branch `one-core/phase-3-p` off `4130aca1`, eleven commits (the ten below and this record, amended after review), not merged — the parent merges; slice W merges after it (W folded nothing under `editor/app/channels/components`). The operator's ask: fix every table and z-index problem on /channels ("channel rows scroll OVER the group-based controls"). Also, the group Transcribe station must stop refusing youtube-handling channels with a whisper-specific sentence. | sha | what | |---|---| | `5ac3e8ca` | `rackLayout.ts` — one named layer ladder (popover z-40 > thead z-30 > group header md:z-20 > identity z-10, plus the deck), `RACK_IDENTITY`, `RACK_BRIDGE`; every class site reads it; unit test for the width invariant and the order; `data-testid="channels-rack"`; flat-path overflow comment; `channels-rack-layers.spec.ts` | | `7a0b3d75` | the transcribe station counts what its button queues: handling branch deleted, `transcribeStationIds` (one fold, both buckets, exclusions), the group action runs `transcribeAutoSubsBucketAction` + `transcribeMissingAction`, `KIND_FOR` lists both kinds, method-free strings; unit + e2e | | `564f767d` | `channels-rack-audit.spec.ts` — 12 screenshots behind `RACK_SHOTS` | | `99b06b40` | audit fix A: a group header's name and stations pin left (`sticky left-2`, capped at the measured `--rack-w`) | | `53bbd482` | audit fix B: an opened Advanced panel scrolls itself into view (`nearest`) | | `476c8470` | audit fix D: the section rule is the th's inset shadow, not the ``'s collapsed border | | `6ff63cbf` | audit fix C: the scroll region is `isolate` (its own stacking context); both rack specs wait for hydration | | `e0f731fa` | review F1/F2: the no-transcript half is queued BY ID (`transcribeBucketAction`, `downloadedNoTranscript`); the scan only with no snapshot; `KIND_FOR.transcribe` gains `whisper-bucket-downloaded-no-transcript`; a refused half is logged | | `4f951ecc` | review F3/F5: below md an opened Advanced panel keeps a `scroll-mb-48` so it stops above the screen-pinned deck; the region comment names layers by key | **Root cause 1 — rows over the group controls.** It was a z-index TIE, broken by DOM order. The group header (`ChannelGroupHeaderRow`, `md:sticky … md:z-20`) holds the five station buttons. The pinned Slug cell had been raised to `z-20` by **`20ee34db`** (2026-09-13) to close a sub-pixel seam against the checkbox cell. Scrolled down and right, every later row's slug band painted over the header, because it comes later in the DOM. The z bump was never needed. `left-8` (32 px) pins the Slug cell 4 px inside the `w-9` (36 px) checkbox cell. Two sibling cells at one z-index paint in DOM order, so the Slug cell already covers the overlap at z-10. The rack plan's order (`plans/editor-channels-rack.md:198-204`) was right. `278d4463` shipped it as 30/20/10, and `20ee34db` put the slug cell on the header's level. A second tie went unstated: the popover and the thead were both z-30, and the popover won on DOM order only. Both ties are gone. After the fix, grep finds no `z-` literal in `editor/app/channels/components/` outside `rackLayout.ts`. **Root cause 2 — "a youtube-handling channel never runs whisper".** `stationWorkFor` refused `handling !== "transcribe"`. But buckets are decided by files, never by handling: `downloadedNoTranscript` is whisper work for every channel, and the runner drains it for every channel. The channel page already replaces auto-captions for any handling. So the station never counted what the runner would do. It now counts `|downloadedNoTranscript| + |downloadedAutoSubsOnly|` after the download exclusions. The two buckets are disjoint, and one batch cannot cover both, so the button queues two jobs: - `whisper-bucket-auto-subs` over the auto-caption ids; - `whisper-all` when there are captionless videos, or when the channel has no report. Both jobs run on `TRANSCRIPTION_QUEUE`. The dedupe spans both kinds. `queued` counts channels, and `jobIds` carries the whisper-all id when both jobs were queued. **By id, after review:** with a snapshot, the no-transcript half is `transcribeBucketAction` over exactly `transcribeStationIds(…).missing` (`whisper-bucket-downloaded-no-transcript`, replayable as `downloadedNoTranscript`), not the `whisper-all` scan. `downloadedNoTranscript` does not filter excluded ids, so a scan would also transcribe a video downloaded before it went private: 2 on the label, 3 transcribed. The scan remains only for a channel with no report. The dedupe now spans all three kinds, including the channel page's own bucket job. If one half is refused while the other queues, the refusal is logged (`console.warn`): the ok arm of `StreamActionResult` has no message field to carry it. A combined job kind was rejected: it would need a replay spec and a /jobs label, and it would mirror nothing, since the channel page runs two jobs. A social account is still ineligible (`"social account"`), and that is now the only way `notEligible` can be reached. **The divergence, on purpose.** The rack's transcription band still counts `downloadedNoTranscript` alone (`channelSnapshot.ts:583-585`), and the stage title lists auto-captions as informational (`stageStatus.ts:344-352`). On a youtube channel with many auto-caption-only videos, the station's figure is now higher than the band's. That is the ask: the station counts exactly what its button queues. It is not a bug. **The audit.** Viewports 1440×900 and 390×844. Shots: grouped top; grouped and flat scrolled bottom-right; deck open scrolled to the bottom; deck parked over the column header (390×640 / 1440×640); Advanced popover open. Baseline shots are in `$T/p-shots-before/` (pre-fix, plus the testid only). Fix C's before-shot is in `$T/p-shots-c-before/`. The final set is in `$T/p-shots-after/` (12 PNGs). `$T` = `/home/user/.claude/jobs/c0baff27/tmp`. | shot | finding | fix | |---|---|---| | desktop-grouped-scrolled, layers spec | the pinned slug band paints over the pinned group header and its stations (root cause 1) | `5ac3e8ca` | | desktop/mobile-grouped-scrolled | the group header's name and five stations scroll off to the left with the table; only "16 channels" stays in view | `99b06b40` | | desktop/mobile-popover-open, layers spec at 1280×720 | the Advanced panel on a row near the bottom (below md: near the right edge too) is clipped by the scroll region, with its selects out of reach | `53bbd482` | | desktop-grouped-scrolled (after `5ac3e8ca`) | a gap under the pinned group header: the ``'s collapsed `border-t-2` belongs to the table grid, so it stays behind when the th pins | `476c8470` | | mobile-deck-over-thead | the z-30 thead paints over the screen-pinned z-20 deck ("18 selected", tier select), because the region formed no stacking context | `6ff63cbf` | | mobile-* | below md the thead never pins. The region scrolls on both axes, so `sticky top-0` pins to the region and not to the document | recorded, not fixed — the documented trade-off (`ChannelsRack.tsx:152-155`, `channels/page.tsx:332-336`) | | mobile-deck-open | the deck at the end of the scroll sits in flow after the last row and covers nothing; while scrolling, it covers what passes under it, as a pinned bar does | recorded, no defect — no padding needed | | every shot | the Next dev-tools badge at the bottom left | recorded, dev-only | | desktop/mobile-popover-open (after) | while a panel is open, the region's scroll extent grows and a blank band shows under the last row | recorded, not fixed (see "Left") | | mobile-deck-over-thead (after) | the region's `-mx-4` edge shows the thead checkbox and the row edge in the 16 px gutter beside the deck | recorded, not fixed — cosmetic, predates the slice | | mobile-grouped-* | "Derived data off" wraps under the station line | recorded, not fixed — a wrap at 390 px, not a defect | | mobile popover under the deck (review) | with the rack isolated, the deck paints over an overlapping panel, and the scroll-into-view stopped the panel under it | `4f951ecc` | | desktop/mobile-*-scrolled | the Build/Tier columns show as a sliver under the pinned slug band | recorded, no defect — columns scroll under a pinned identity column | **The layers spec fails on the pre-fix code.** The run was the spec's first revision, with two tests: the header case and a popover case on `slow-a`. It ran on the `4130aca1` components plus the testid only. The header hit-test failed at step (1): the point inside the slug band landed on the slug cell. The popover case failed because the last row's panel was clipped. That became finding B. The final spec has three tests: - the header case, with the Sync station check added by fix A; - the popover case, moved to `rack-03`; - a `slow-a` scroll-into-view case, added by fix B. After the fixes: **3/3**. The spec does NOT exercise the popover-vs-thead tie: `rack-03`'s panel opens below the thead. That order is held by `rackLayout.test.ts`'s token check only. **Gates.** - tsc: clean after every commit. - common: **1727** (1723 − 1 rewritten + 5 new in `channelGroupSections.test.ts`); **1728** after review (the reviewer's walk). - editor unit (`tsx --test "app/**/*.test.ts"`): **69** (67 + 2 `rackLayout.test.ts`). - `test:scripts`: 156 + 1 skip. **e2e.** Every run was detached, from the worktree root: - baseline (audit + layers, pre-fix): 10 passed, 2 failed (the expected pair above), 1.0 min. - mid 1 (audit + layers + channel-groups): 20 passed, 1 failed (finding B), 1.3 min. - mid 2 (audit + layers): 13 passed, 2 failed. Both were clicks and measures before hydration. Fixed in the specs (`6ff63cbf`). - fix-C before-shot (isolate removed): 2/2, 0.3 min. - after (audit + layers): **15 passed, 0 failed**, 0.9 min. - after review (`channel-groups`, `channels-rack-layers`, `channel-priority`): **19 passed, 0 failed**, 1.1 min. - full list (`$T/p-specs.txt`, 24 files, every named file present, none dropped): **146 passed, 0 failed, 0 flaky, exit 0, 11.8 min**. **Numbers.** `phase3-view-numbers.ts`, primary's `transcripts/` and `settings.json`, read-only. The first before/after pair, hours apart, differed: nuxanor-kick went from 4 to 3 untranscribed, hasanabi dropped out, digest eligibility went from 76,519 to 76,521. That is the live editor transcribing, not code. None of P's files is in the tool's import graph. Run back to back, main (primary checkout `77f63356` = `4130aca1` + one plan file) and the branch gave **diff empty, 5,119 bytes each**. **Builds** (at `6ff63cbf`): editor `next build` exit 0, 82 s, route table lists `ƒ /api/view/[name]`. Export `next build` exit 0, 54 s. **Left.** - The mobile thead does not pin (a documented trade-off). - `20ee34db`'s `min-w-52` on the tier cell is unchanged. - An opened Advanced panel lengthens the region's scroll extent while it is open, because it is absolute inside the scroll box. Closing it restores the extent. ### Slice W, as shipped — one write idiom (2026-09-24) Branch `one-core/phase-3-w` off `main` `4130aca1`, five commits, unmerged. Slice 4b left **16 JSON write sites in 13 files** on the per-pid temp name `${file}.tmp-${process.pid}`, plus the text and binary tmp + rename writers, plus two modules (`metadataScanStore`, `autoQueueState`) that had grown their own per-module-copy write counters to dodge the collision. All of them now go through `common/lib/jsonFile-server.ts`, byte-for-byte. | sha | what | |---|---| | `db9e3f44` | `writeFileAtomic(file, data: string \| Buffer, {mkdir, mode})` under `writeJsonAtomic` (now `jsonText` → `writeFileAtomic`): the same per-path chain on `globalThis`, the same `${file}.tmp-${pid}-${seq}-${random}` name. `mode` goes to `writeFile(tmp, data, {mode})`, i.e. onto the temp at creation, before the rename — the ordering `xSessionBroker` had. `copyFileAtomic(src, dest, {mkdir})` on the same chain (decided: the saved-video move folds rather than stays). No new sync twin. Tests: string / Buffer / empty bytes, mode 0o600 on the file and on every temp seen, one chain shared by the two writers on one path (40 interleaved writes, last issued lands), copy | | `9bfd15cd` | Race class: `rosterStore.writeRoster` (mkdir), `maybeMissingStore.writeMaybeMissing` (no mkdir), `metadataScanStore.writeMetadataScan` (no mkdir) on `writeJsonAtomic`. The counters `metadataScanStore.ts:188-191` and `autoQueueState.ts:141` deleted; `autoQueueState` on `writeJsonAtomic` (mkdir). New `autoQueueState.test.ts` "overlapping writes do not collide on the tmp file" (12 concurrent writes, last issued lands, no temp left); `metadataScanStore.test.ts:253` and `rosterStore.test.ts:184-186` unchanged and green | | `ae6ed9d2` | Process-global + per-video JSON: `syncSchedulerState`, `workerDefaults`, `widgetPresets`, `homepage` (mkdir `homepageDir` = the file's parent), `migrate-channel-priority`, `relocateDir.writeDirMarker`, `shard.saveShardConfig`, `duplicateShorts` ×2, `scanCorruptMedia` ×2, `backupSavedVideos` manifest, `normalizeLiveChat`, `normalizeTranscript`, `videoActions.ts` remark (`'{"transcription":[]}\n'` → `writeJsonAtomic(file, {transcription: []}, {indent: 0})`). Compact without newline (`{indent: 0, newline: false}`) for the two reports and the two cue files. mkdir exactly where a site had one. New `controller/compactJsonWriters.test.ts` (the four compact writers' bytes = `JSON.stringify` of their parse, no newline; passes on the parent too) and the literal pinned in `jsonFile-server.test.ts` | | `588fd7e9` | Text / binary: `failedTranscriptions` prune + clear, `runYtdlp.writePlaylistFile`, `xSessionBroker.writeCookieJar` (`{mkdir: true, mode: 0o600}`), `savedVideo-server.moveFileCrossDevice` (`copyFileAtomic`), `sites/lib/cutReleaseAction.ts`, `videoActions.ts` VTT promote. One comment on `storageWatch.ts`'s `let timer` (a per-copy singleton, not a temp name, one caller) | | `be4769de` | `plans/tools/phase3-writers-numbers.ts`, this record, the changelog bullet | | (review fixes) | record wording (188, writes-not-a-lock, `transcribeOne.ts:173`), changelog scope, the mode test made non-vacuous — see below | `videoActions.ts` changed at its two write sites and one added import line only — no export renamed, no signature changed (slice 3b owns its import list). **After commit 4,** `git grep -n 'tmp-${process.pid}' -- common editor`: ``` common/controller/buildIndex.ts:971: tmpPath = `${outPath}.tmp-${process.pid}`; common/controller/buildStats.ts:220: const tmp = `${outPath}.tmp-${process.pid}`; common/controller/transcode.ts:31: `audio.tmp-${process.pid}.${opts.targetFormat}`, common/controller/transcribeOne.ts:142: const tmpBase = `transcript.tmp-${process.pid}`; common/lib/jsonFile-server.test.ts:65: assert.ok(a.startsWith(`/x/config.json.tmp-${process.pid}-`)); common/lib/jsonFile-server.ts:9:// of them spelling the temp file `${file}.tmp-${process.pid}`. That name is the common/lib/jsonFile-server.ts:136: return `${file}.tmp-${process.pid}-${seq}-${randomBytes(4).toString("hex")}`; ``` The two export page writers, as planned, **plus two the plan did not list**: `transcode.ts:31` and `transcribeOne.ts:142` are not writers of ours — they name the output file an external process (ffmpeg; whisper / chough) writes, which the code then renames. Nothing to fold; left and named. The last three hits are the shared writer itself, its history comment and its test. `git grep 'rename(tmp'` over `common editor` finds only `buildIndex`, `buildStats`, `transcode`. **Out of scope by name:** `buildIndex.ts:971` (the streaming `createWriteStream` page writer) and `buildStats.ts:220` (a hand-joined array) — restructuring, not a fold; `scripts/diarize.mjs`; everything under `umtool/`. And one the grep cannot see: `transcribeOne.ts:173` writes the remote transcript straight to `transcript.json` (`writeFile(transcriptPath, bytes)` — no temp, no rename), so a crash mid-write can leave it truncated; it never had the tmp idiom. A candidate for `writeFileAtomic` in a later slice. The module-level `storageWatch` timer and the TTL caches in `autoRunner.ts` / `recencyIndex.ts` are not temp names. **Behaviour changes (intended).** (1) Every folded write is chained per absolute path with every other `writeFileAtomic`/`writeJsonAtomic`/`copyFileAtomic` in the process, across module copies — the roster's writers in `runYtdlp`, `quickAvailabilityCheck` and the `pipelineActions` server action now have their WRITES serialised. That is not a lock: a `load → merge → writeRoster` from two actors can still lose one merge, exactly as before (the read-modify-write lock is `withJsonFileLock`, which these callers do not take). (2) A failed write removes its temp; the old code left it. (3) Temp names changed shape (`.tmp---`); the remark's temp was `transcript.tmp-.json` and is now `transcript.json.tmp-…`. Nothing reads temp names. **Found and left: 188 orphan temps in the live corpus.** `transcripts/.auto-queue/` holds **175** `state.json.tmp-2514131-NNNN` files, all dated 2026-09-11 — the day `/home` hit 100 % — **173 of them 0 bytes** (two are partial, 16–20 KB): each a failed `writeFile` (ENOSPC) the old code never cleaned. Thirteen more elsewhere: seven `snapshot.json.tmp-`, three sidecar temps (`availability.json`, `download-outcome.json`) — all from pre-4b writers — and three `transcript.tmp-` whisper output bases (`transcribeOne`, an external process's file). The new writer cannot leave more of the first kind; the existing ones are corpus files and were **not touched** — the operator's to delete (`find transcripts -name '*.tmp-*'` lists all 188). **Numbers** (`plans/tools/phase3-writers-numbers.ts`, one process, never writes the corpus, never boots a server, clock frozen, only names present on both sides). Inputs frozen once (`FREEZE_TO`, 1,787 files) and both runs read the frozen tree: the parent `4130aca1` from a detached scratch worktree, the branch from `588fd7e9` + the tool. Per writer and sample it loads through the module's reader, writes through the module's writer into scratch, and prints the md5 of the bytes plus whether they equal the OLD idiom's bytes (`JSON.stringify(v, null, 2) + "\n"`, or `JSON.stringify(v)` for the compact four). Coverage: 53 rosters, 52 maybe-missing, 1 metadata-scan, scheduler, auto-queue (33,004 B), worker defaults, widget presets, homepage, the duplicates report (empty scan), the media-scan report (a merge of the live 27,756 B report), a relocation marker (synthetic — none live), the backup manifest, 100 `transcript.cues.json` and 100 `live_chat.cues.json` re-normalized. 323 lines each side, **315 `old-idiom=same`, 0 DIFF, 0 THREW, 0 leftover temps; `diff` before/after empty** (`w-numbers-{before,after}.txt`). Not measurable without a live sample: shard configs (none live), duplicate overrides (the live file has no clusters), media-scan overrides (no file), the priority migration (a CLI whose only write is the default-options `writeJsonAtomic`, pinned by `jsonText`'s tests) — and the remark literal, pinned by a unit test. **Gates** (worktree `one-core-phase-3-w`, ports 3301/3311/3310/3320): - `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit` — clean before every commit. - common **1733/1733** (1723 + 4 `writeFileAtomic`/`copyFileAtomic` + 1 literal + 1 `autoQueueState` overlap + 4 compact-writer bytes); editor unit (`tsx --test "app/**/*.test.ts"`) **67/67**; `test:scripts` **156 + 1 skip** (a first run while this slice's own e2e held the machine lock failed the queue-lock banner test — the lock was taken; re-run with it free, green); mcp **219/219**. - `pnpm --filter editor exec next build` exit 0, `ƒ /api/view/[name]` in the table; `pnpm --filter export exec next build` exit 0; `ZodError|_zod` over both `.next/static`: 0 files each. - e2e, the prompt's 20 specs (all exist): **140 passed, 0 failed, 10.7 min**. **Review fixes** (review verdict: ship after fixes; four nits, none blocking). The heading's orphan count is corrected from 173 to 188, the body's total. The record now says the roster writers' writes are serialised but a load-merge-write is not locked, and lists `transcribeOne.ts:173` as out of scope. The changelog no longer says "every file": it names the four temp names that stay. The mode test (`jsonFile-server.test.ts`) could pass vacuously, because a `fs.watch` could see no temp. It now intercepts `rename` (patched on `node:fs/promises` + `syncBuiltinESMExports`) and stats the temp at that moment, asserting exactly one temp, already 0o600. With `mode` removed from `writeFileAtomic` it fails. Gates after: tsc clean, common **1733/1733**, editor unit **67/67**. No runtime code changed, so the builds, e2e and numbers were not re-run. **Merged with `main` `77a32de2` (slice P) as `44dd843f`**. The merge conflicted only in `editor/CHANGELOG.md` and this file, and both sides were kept, P then W. Gates on the merged tip: - tsc clean. - common **1738/1738**: W's 1733 plus P's 5. - editor unit **69/69**: 67 plus P's 2. - `test:scripts` **156 + 1 skip**, run once the e2e lock was free. - mcp **219/219**. - Editor `next build` exit 0, with `ƒ /api/view/[name]` in the route table. Export `next build` exit 0. No `ZodError|_zod` in either `.next/static`. - Numbers tool: inputs re-frozen (1,787 files). The before run is `main` `77a32de2` (the old writers), from a detached scratch worktree; the after run is `44dd843f`. 323 lines each, 315 `old-idiom=same`, 0 DIFF/THREW/LEFT, **diff empty** (`w-m-numbers-{before,after}.txt`). - e2e, the same 20 specs, on ports 3411/3410: **140 passed, 0 failed, 8.8 min**. ### Slice 3b, as shipped — the video page's chore cards, one module each (2026-09-24) Branch `one-core/phase-3-s3b` off `main` `4130aca1`, one code commit and this record. Main is merged in once slices P and W are on it; the post-merge gates are added below then. **What, and why it is smaller than the inventory said.** Both premises of the 3b inventory above were stale. - *Channel pipeline — nothing to do, and nothing was done.* The nine files under `channels/[slug]/components/stages/` (3,866 lines) hold no duplicated status logic. `computeStageStatuses` (`common/views/pipeline/stageStatus.ts:192`) and `computeChannelFlow` (`channelFlow.ts:220`) are the single fold, each called once, in `channels/[slug]/page.tsx:256` and `:308`; every stage card takes derived id-lists and sums as props and keeps only form state. The bookend chores are hand-listed once, `stageOrder` at `page.tsx:278-286` around `GROUP_STAGES` (`stageStatus.ts:53-106`). The stage half of 3b was already satisfied; no stage file is touched. - *Video page — the registry split already existed.* `videoOperationPanels.ts` + `OperationPanel.tsx` render digest / diarization / attribution after `` (`videos/[id]/page.tsx:254-266`). What was still inline in `VideoPanel.tsx` (1,839 lines) was ~16 card bodies for video CHORES, which no registry entry owns. 3b split those, and only those, one module per card, behaviour-free. | sha | what | |---|---| | `315cec1f` | `video page: one module per chore card` — 15 card modules + `Heading.tsx` + `videoFiles.ts` under `videos/[id]/components/cards/`; `VideoNavStrip.tsx` and `PipelineStatusStrip.tsx` beside `VideoPanel`; `VideoPanel.tsx` 1,839 → 455 (the assembly); `lib/videoChoreCards.ts` (+test) | | (this commit) | this record, changelog | **The move.** Each function moved verbatim with its state hooks, its imports and the server-action import(s) it calls — the 13 names from `../videoActions` now sit in the card that uses them (`downloadVideoPipelineAction` in two: `RedownloadSection` and `ShortAudioBanner`). Children stay with their only caller: `PersistedSourceVideo` in `SourceVideoSection.tsx`, `DeleteFileButton` in `FilesList.tsx`. Two helpers shared by two cards got small modules: `cards/Heading.tsx` (both per-file rows) and `cards/videoFiles.ts` (`VideoFile`, `WHISPER_FILENAME`, `CANONICAL_VTT`, the extension predicates, `mediaUrl`). `VideoPanel` re-exports `VideoFile` and `FetchedWindow`, so neither page's import changed. The only edits inside moved bodies are four comments whose "above"/"below"/":360" pointed at a neighbour that is now in another file. `videoActions.ts` is untouched (slice W owns it). `git show --numstat`: `VideoPanel.tsx` +40 −1,424; cards + strips +1,522 (the 1,366 moved lines plus each module's directive and imports); `videoChoreCards.ts` + test +190. **Why 455 lines, not ~300.** The `VideoPanel()` function itself — gating booleans, summaries, and the fifteen cards' `PipelineStageCard` wrappers — is 360 lines, and keeping ORDER and gating exactly was the rule. Driving the JSX from the hand-list would shorten it and was allowed, but every card's gate and props differ, so the literal reads better; the list's test pins the two together instead. **`lib/videoChoreCards.ts`** — the video page's twin of the channel page's bookends: fifteen rows `{id, component, shown, why}` in render order (download-outcome, incomplete-transcript, short-audio, availability-history, download, transcode, transcribe, transcript-source, mark-untranscribable, source-video-persistence, fetched-windows, archive-media, truncated-check, files, danger), each saying why it is a chore and not an operation. The download and transcribe rows are the EXTERNAL operations' per-video run surface, which `videoOperations.ts` deliberately leaves out of its reader. Not folded into the registry. Not listed: the nav strip, the status strip, the umtool link and the one-line "media archived" status. `videoChoreCards.test.ts` reads `VideoPanel.tsx` as text (a node test cannot load client components) and pins that the cards it renders, in order, and the cards it imports from `./cards/`, are exactly the list's. **Labels.** `$T/s3b-labels.py` extracts every `aria-label` (literal, template, and both arms of a conditional), `"aria-label":` spread, `role`, `label`/`buttonLabel`/`runningLabel`/ `actionLabel`/`title`/`id` literal and every `…stage summary…`/`for ${x}` template. Over the old `VideoPanel.tsx` and over the new tree (`VideoPanel.tsx`, the two strips, `cards/*`): 126 entries each, sorted, **identical — md5 `3f89e5c1c9f79ad1a04ceea4568f0500` both**. **Gates.** tsc (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) clean at `315cec1f`. common **1723/1723** (unchanged — no common file touched); editor unit **67 → 70** (+3 `videoChoreCards.test.ts`); `test:scripts` 156 pass + 1 skip of 157. Builds: editor `next build` exit 0, route table lists `ƒ /api/view/[name]`; export `next build` exit 0. **e2e** (run 1, at `315cec1f`, from the worktree root, detached, ports 3311/3310): the 19 specs named in the slice prompt, all present — attribution, auto-report-refresh, channel-storage, cleanup-holds, digest, do-not-clean, download-format-guard, fetch-window, incomplete-transcript, reconcile, reconstruct-download-url, saved-videos, storage-locations, tags, transcript-source, truncated-check, undownloaded, video-page, whisper-video — **108 passed, 0 failed, exit 0, 9.8 min** (16 min wall, the rest behind the queue lock), first run, nothing re-run. **Numbers tool: none for this slice** — no payload, view or file on disk changes; the label diff above is the equivalent check. **Found and left.** - `VideoNavStrip`'s prop type declares a `umtoolUrl` it never reads (the umtool link is drawn by `VideoPanel`) — moved as-is. - `pnpm --filter export run build` runs `build:data` first, which writes a fresh `index.mdb` under the worktree's own (absent) `transcripts/`, then fails on `compose-site: SITE_ID env var is required`. Harmless here (0 sites, nothing written or removed under the linked `export/public`; the stray `transcripts/` was deleted), but it is the wrong command for a worktree gate — `pnpm --filter export exec next build` is the one release 3 used. - Commit trailer: `315cec1f` carries `Claude Opus 5.5 (1M context)`, the model that wrote it, not the rules file's `Claude Fable 5.1`. **After merging `main` (`ddad13f4`, slices P and W) — merge `2e322e37`.** Conflicts only in `editor/CHANGELOG.md` and this file, resolved by keeping both sides in P, W, 3b order; `main` changed nothing under `videos/[id]/` but W's import lines in `videoActions.ts`, which 3b never touched. Gates on the merged tip: tsc clean; common **1738/1738**; editor unit **72/72** (main's 69 + 3); `test:scripts` 156 + 1 skip of 157; editor `next build` exit 0 (`ƒ /api/view/[name]` listed); export `next build` exit 0. **e2e** run 2, the same 19 specs, detached from the worktree root: **108 passed, 0 failed, exit 0, 6.5 min**, first run. Labels, `4130aca1`'s `VideoPanel.tsx` against the merged tree: 126 entries each, identical, md5 `3f89e5c1c9f79ad1a04ceea4568f0500` both. ## Release 2026-09-24 (evening) — rollout prelude, slice P, slice W, slice 3b `main` `9ab10d77` → **`e172749b`**. **Phase 3 is complete with this release.** A prelude on `main`, then three branches, all off `4130aca1`, merged in this order (all three merges are dated 2026-09-24, 19:50–20:14 local): 0. **Prelude, on `main`, plans only.** `4130aca1` is the rollout record: release 3 (`9ab10d77`) is live on :3001 (the "Rollout 2026-09-24 — `9ab10d77`" section above). `bbad0977` records the owed one-sync md5 sweep. Three plan files were filed while the slices ran: `77f63356` ([`site-exports-off.md`](site-exports-off.md)), and `42caf3cc` + `cc89abfa` ([`rumble-sweep-pacing.md`](rumble-sweep-pacing.md)). **The one-sync sweep is still owed.** `pnpm ops sync {"slug":"FearAnd"} --wait` was refused three times (18:43, 19:13, 19:41), because auto-download kept re-arming a YouTube 429 cooldown. No other channel was substituted. The sweep would show that a sync stamps `lastSyncedAt` through `patchChannelConfig` on the synced channel's file and on nothing else. It is the one step of the release-3 rollout left unmeasured. 1. **`one-core/phase-3-p` → `77a32de2`**: the /channels rack polish. Eleven commits: - code: `5ac3e8ca`, `7a0b3d75`, `564f767d`, `99b06b40`, `53bbd482`, `476c8470`, `6ff63cbf`; - the record, `0a717b79`; - the review fixes, `e0f731fa` (F1/F2) and `4f951ecc` (F3/F5); - the record update, `ca130aa6` (F4). Review verdict: **ship after fixes** (F1 medium, F2/F3 low, F4/F5 nits). All five were taken. `main` had moved by plans only, so `77a32de2`'s code is `ca130aa6`'s. 2. **`one-core/phase-3-w` → `ddad13f4`**: one write idiom. Eight commits: - code: `db9e3f44`, `9bfd15cd`, `ae6ed9d2`, `588fd7e9`; - the record, numbers tool and changelog, `be4769de`; - the review fixes, `71264112`; - `main` (`77a32de2`) merged in as `44dd843f`; - the merged-tip record, `ce8a4764`. Review verdict: **ship after fixes** (four nits, none blocking, all taken). `ddad13f4`'s tree is `ce8a4764`'s. 3. **`one-core/phase-3-s3b` → `e172749b`**: the video page's chore cards, one module each. Four commits: `315cec1f` (code), the record `9db2f291`, `main` (`ddad13f4`) merged in as `2e322e37`, and the merged-tip record `aa6a76e7`. Review verdict: **ship**, with three informational findings and nothing to fix. `e172749b`'s tree is `aa6a76e7`'s. **Why that order.** P went first because it is the UI slice with the largest e2e surface. Its figures (the transcribe station) read through `common/views/channelGroupSections.ts`. W had to prove its byte identity on a tree that already held P. So W merged `main` (`77a32de2`) and ran its whole gate set again on `44dd843f`, the numbers tool included, with inputs re-frozen and `77a32de2` as the before side. 3b came last and was mechanical. It touches only `videos/[id]/components/**` and `lib/videoChoreCards*`. The one file it shares with W is `videoActions.ts`, which 3b does not edit (W changed two write sites and one import line). Every merge conflicted only in `editor/CHANGELOG.md` and this file, and each was resolved by keeping both sides, in P, W, 3b order. **Scope.** Release 4 is P, W and 3b. **3b is only the `VideoPanel` split.** Its stage half was already done by the flow work (`computeStageStatuses` `stageStatus.ts:192` and `computeChannelFlow` `channelFlow.ts:220`, each called once, at `channels/[slug]/page.tsx:256` and `:308`). No stage file changed. **Release 4 is not rolled out.** :3001 still serves `9ab10d77` (`BUILD_ID` `XsKaA_drqdAbTguxUGVsn`). The next release's prelude does the rollout. **Gates, as the three records state them.** - **P**, at the review-fix tip `ca130aa6`: - tsc clean after every commit; - common **1728**, editor unit **69**, `test:scripts` 156 + 1 skip; - e2e after review (`channel-groups`, `channels-rack-layers`, `channel-priority`): **19/19**, 1.1 min; - before review: the 24-file list **146/146** in 11.8 min, and builds green at `6ff63cbf`; - `phase3-view-numbers.ts`: diff empty, 5,119 bytes each side. - **W**, on the merged tip `44dd843f` (W + P): - tsc clean; - common **1738**, editor unit **69**, `test:scripts` 156 + 1 skip, mcp **219**; - editor and export `next build` exit 0, `ƒ /api/view/[name]` listed, no `ZodError|_zod` in either `.next/static`; - `phase3-writers-numbers.ts`: 323 lines each side, 315 `old-idiom=same`, 0 DIFF/THREW/LEFT, diff empty; - e2e, the 20 specs: **140/140**, 8.8 min. - **3b**, on the merged tip `2e322e37` (all three): - tsc clean; - common **1738**, editor unit **72** (re-run on `e172749b` for this record: 72/72), `test:scripts` 156 + 1 skip; - both builds exit 0; - e2e, the 19 specs: **108/108**, 6.5 min; - labels: 126 entries each side, md5 `3f89e5c1…` both. Final full suites on `e172749b`: editor **624 passed, 1 failed, 12 skipped, 43.1 min** — the one failure is `lane-runner.spec.ts:361` "the digest and backfill lanes dispatch at the same time", a timing assertion (the two lanes' pick windows missed overlapping by 23 ms: the digest lane's fake work is instant and its 12 picks finished in 281 ms before the backfill lane's first pick, on a machine also running the export suite); re-run three times on the same detached worktree, **5/5, 5/5, 5/5** (`rerun-lane-{1,2,3}.log`); it passed in every earlier full run, no slice touches the runners, and the runner's state write is fire-and-forget (`autoRunner.ts:1206`, `void writeAutoQueueState(…)`) so W's serialised write chain cannot move a pick — classed a load flake, not a regression; export **188 passed, 0 failed, 6.9 min** (run from the worktree `/home/user/Projects/one-core-phase-3-s3b`, detached at `e172749b`; the editor suite bound the primary's TEST ports 3011/3010 — a detached HEAD gets offset 0 from `scripts/worktree.mjs` — which were free, so no collision with the live :3001; logs `final-e2e-r4-editor.log` / `final-e2e-r4-export.log`) **Record corrections made while checking the three records against the code** (2026-09-24; where a record and the code disagreed, the code won): - **Slice P**: - The record said "not merged". It is merged, as `77a32de2`. - It said "the ten below and this record, amended after review". The table lists **nine** code commits. The record is **two** commits, `0a717b79` and `ca130aa6`; it was not amended. The total of eleven stands. - The pre-review paragraph says `jobIds` "carries the whisper-all id when both jobs were queued". At `e0f731fa` it carries the id of the **first ok half**, which is the no-transcript bucket job (`whisper-bucket-downloaded-no-transcript`) when both halves queue (`groupActions.ts:116-126`). The whisper-all scan runs only for a channel with no snapshot (`:97`). The record's "By id, after review" sentence already says this. The earlier bullets describe the pre-review code. - The band/station divergence anchor `channelSnapshot.ts:583-585` is a comment. The band's own fold is `common/views/pipeline/buildBands.ts:168-172`, where `transcription.reachable += downloadedNoTranscript` and nothing else. That comment (`channelSnapshot.ts:586-587`) still names the retired path `editor/app/components/pipelines/buildBands.ts`. This release changes plans only, so it was left as is. - The audit table cites the mobile-thead trade-off at `ChannelsRack.tsx:152-155`. On `main` that range is the `heldCount` comment, and the trade-off comment is at **`:159-162`** (`channels/page.tsx:332-336` is right). - **Slice W**: - The record said "five commits, unmerged", with an unsha'd "(review fixes)" row. The branch has eight commits: the review fix is `71264112`, then the merge `44dd843f` and `ce8a4764`. It is merged, as `ddad13f4`. - In the post-commit-4 grep block, `jsonFile-server.test.ts:65` is **`:68`** on `main`, because the review fix added imports above it. - "`git grep 'rename(tmp'` … finds only `buildIndex`, `buildStats`, `transcode`" leaves out `jsonFile-server.ts:175`, the shared writer's own rename. - **Slice 3b**: the record said "one code commit and this record". The branch has four commits (see above), and it is merged, as `e172749b`. - **This record's date.** It was asked for as "2026-09-25". The merge commits are dated 2026-09-24 in local time (2026-09-25 in UTC), and the plans date by local time. ## Next — Phase 4 Phase 3 is complete. Slices 1, 2, 3a, 3b, 4a and 4b have all shipped, and so have P and W, the two slices this release added. Phase 4 (CLI, entry points, config, docs) is next; its slices are in [`one-core.md`](one-core.md#phase-4--cli-entry-points-config-docs-3-slices), with the starting points as inventoried on `e172749b`. Two plans filed during release 4 go first, as their own releases, and each needs a code slice and a rollout: - **Visitor exports off on the published sites**, [`site-exports-off.md`](site-exports-off.md). This is a per-site option, off on the operator's instances and on by default for the OSS release. It needs a rebuild and a deploy of the five published sites. Its anchors were taken on `4130aca1`; re-verify them before cutting the slice. - **Rumble**, [`rumble-sweep-pacing.md`](rumble-sweep-pacing.md). Step 0 is a per-platform yt-dlp args table carrying `--impersonate chrome`, because Rumble's embed endpoint answers 403 to every download (upstream #17496). Step 2 paces a full sweep of a large channel. Step 1 (`fullSweepIntervalMinutes: 0` on `the-quartering-rumble`) was applied by the operator on 2026-09-24. Step 2 edits `runYtdlp.ts`, which W touched (only the playlist writer), so cut it from `main`. **Rollout owed.** Release 4 is not live. :3001 still serves `9ab10d77`, and the next release's prelude rolls it out. The one-sync md5 sweep owed since the release-3 rollout rides along with it: one sync, then a sweep showing only the synced channel's `config.json` moved. **Left by slice W, by name** (`git grep 'tmp-${process.pid}' -- common editor` at `e172749b`): - `common/controller/buildIndex.ts:971`, the streaming `createWriteStream` page writer; - `common/controller/buildStats.ts:220`, a hand-joined array. These two are restructuring work, not folds. - `common/controller/transcode.ts:31` and `common/controller/transcribeOne.ts:142` name the output of an external process (ffmpeg; the transcription app), which the code then renames. There is nothing to fold. - `common/controller/transcribeOne.ts:173`, `if (bytes) await writeFile(transcriptPath, bytes)`, writes the remote transcript straight to `transcript.json` with no temp and no rename. A crash mid-write can truncate it. **This is the one real candidate** for `writeFileAtomic`. - Also out of scope: `scripts/diarize.mjs` and everything under `umtool/`. **For the operator: 188 orphan temp files in `transcripts/`**, none of them touched: - 175 `.auto-queue/state.json.tmp-2514131-NNNN`, all dated 2026-09-11 (the day `/home` was full), 173 of them 0 bytes; - seven `snapshot.json.tmp-`; - three sidecar temps; - three whisper `transcript.tmp-` output bases. `find transcripts -name '*.tmp-*'` lists them all. The new writer cannot leave more of the first kind, so deleting them is safe once nothing is running. **Recorded by slice P, not fixed:** - Below md the thead does not pin. This is the documented trade-off (`ChannelsRack.tsx:159-162`, `channels/page.tsx:332-336`). - An open Advanced panel lengthens the region's scroll extent: a blank band shows under the last row until the panel closes. The alternative is opening upward near the bottom. - The region's `-mx-4` edge shows the thead checkbox and the row edge in the 16 px gutter beside the mobile deck. Cosmetic, and older than P. - At 390 px, "Derived data off" wraps under the station line. - `20ee34db`'s `min-w-52` on the tier cell is unchanged. - `channels-rack-layers.spec.ts` does not exercise the popover-vs-thead order. Only `rackLayout.test.ts` holds it. **Recorded by slice 3b:** - `VideoNavStrip`'s `umtoolUrl` prop is never read. - `pnpm --filter export run build` is the wrong gate in a worktree: it runs `build:data` into a worktree-local `transcripts/` and then needs `SITE_ID`. Use `pnpm --filter export exec next build`. **Deferred follow-ups from the 2026-09-23 inventory, still open on `e172749b`:** - `plans/tools/phase1-numbers.ts` sums `missingInput` straight from the snapshot (`COUNT_FIELDS`, `:57`) and never filters `handling: "youtube"`. - `usePolledPayload`'s hang recovery is not unit-tested (`lib/usePolledPayload.test.ts` has one test, which pins `pollTimeoutMs`). - The nested settings blocks are each one sanitizer-backed `settingsField`, not a zod object. Converting them is optional. - `editor/scripts/measure-nav.mjs:64-71` still requests the old API paths. These are **six** paths (`/api/pulse`, `/api/widget/{cleanable,actionable,sync}`, `/api/jobs/active`, `/api/workers`), not eight at `:64-69` as the inventory said, and they answer through the rewrites. Its numbers therefore measure rewrite + view. - Accepted, not owed: nothing prunes stale per-app entries from the `transcriptionApps` shadow. - `common/controller/channelSnapshot.ts:586-587` names a retired path for `buildBands.ts`. It is now `common/views/pipeline/buildBands.ts`. Comment only. **Phase 4 starting points, checked on `e172749b`:** - `editor/app/sites/lib/buildDeployCore.ts` is 578 lines and imports nothing from `editor/**`: only `node:*`, `@aws-sdk/*` and `yt-dlp-transcript-common/*`. It has two callers, `sites/lib/buildAction.ts` and `sites/lib/deployAction.ts`. The move to `common/publish/build.ts` has no editor import to cut. The two `@aws-sdk/*` dependencies are declared in `editor/package.json` only, so they move with the file. - `common/bin/settings-example.ts` and `common/bin/file-schemas-docs.ts` both carry `--check`, so each becomes a one-line `archilyzer` subcommand. - `PUBLISH.md` does not exist yet. Phase 4 slice 3 creates it (absorbing `DEPLOY_DOCKER.md` and `DEPLOY_CLOUDFLARE.md`). **Owed outside one-core (operator):** the Anilyzer production deploy (the preview is up); the other five sites to corpus spec 4; the LM chat-only tier (shipped, not configured). ## Slice 2, as shipped — one polling route (2026-09-23) Branch `one-core/phase-3-s2` off `54cf1b31`, five commits, merged to `main` as `8d6e84f6` (2026-09-23). *Checked against the branch 2026-09-23: the table, the line counts and the editor-unit count were corrected for the review-fix commit `c205b270`.* | commit | what | |---|---| | `5397f83c` | `common/views/{cleanable,widgetActionable}.ts` + tests; the two widget routes call them; seven type importers repointed off `api/widget/*/route` | | `074f09a2` | `common/views/names.ts` (`VIEW_NAMES`, `ViewName`, `VIEW_CONTRACT`) + test; `editor/app/api/view/{[name]/route.ts,views.ts,pulseView.ts,views.test.ts}`; eight route files deleted; eight `rewrites()`; `e2e/view-route.spec.ts` | | `edf56370` | `usePolledPayload` `git mv` to `editor/app/lib/`; JobsTable, useOperationsStatus, SyncConsole folded onto it; every client poll URL is `/api/view/` | | `4288009b` | `plans/tools/phase3-view-numbers.ts`, two stale comments, this record | | `c205b270` | review fixes — deviation 7 below | **One route, eight rewrites, no redirect.** `/api/view/[name]` is `force-dynamic`, checks the name against `VIEW_NAMES` before any handler runs (unknown ⇒ 404), and dispatches into a total `Record`, so a name with no handler is a tsc error. It has no auth and no `EDITOR_TEST_ROUTES` guard, same as the eight routes it replaces. Each handler constructs its own inputs exactly as its old route did; there is no shared constructor in the dispatcher. Pulse is the one `observe` view; `views.test.ts` reads `pulseView.ts` as text, requires `observeInputs(` and bans `liveInputs(`, `getRegistry(`, `getSettings(`, `getWorkerPool(`. `/api/widget/presets` stays its own route. Heal/no-heal unchanged. Gates: | gate | result | |---|---| | `pnpm -r exec tsc --noEmit` | clean after every commit | | common tests | **1638/1638** (was 1625; +13: cleanable 3, widgetActionable 6, names 4) | | editor unit (`tsx --test "app/**/*.test.ts"`) | **61/61** at `4288009b` (was 59; +2: pulse textual guard, cleanable assignability); **63** after `c205b270` (+1 dispatcher ban, +1 `pollTimeoutMs`) | | `pnpm run test:scripts` | 156 pass / 1 skip / 0 fail | | `next build` (editor) | clean; `├ ƒ /api/view/[name]` (dynamic), `├ ƒ /api/widget/presets` kept | | e2e subset, 20 specs (`pulse`, `auto-refresh`, `dashboard`, `dashboard-paths`, `widget`, `jobs`, `jobs-active-order`, `jobs-channel`, `workers`, `worker-remote`, `auto-queue`, `lane-runner`, `scheduler`, `ops-api`, `disk-space`, `perf-budget`, `backfill`, `channel-storage`, `channel-rename`, `view-route`) | **174 passed, 0 failed, exit 0**, 8.0 min, one run, nothing re-run | **Numbers.** `plans/tools/phase3-view-numbers.ts` (offline tsx, read-only loaders, no server) dumps `widgetActionable`, `cleanable` and `widgetSync` as sorted-key JSON over the real corpus (`TRANSCRIPTS_DIR` → the primary checkout's `transcripts/`), with `now` frozen at 2026-01-01Z so `widgetSync.scheduler.{nextRunAt,overdue}` cannot drift. Before (at main, route bodies copied into a temporary variant of the script, since the views did not exist yet) vs after (the committed script, importing the views): **diff empty**, 4,574 bytes each. Line counts: the eight deleted routes were 190 lines; their replacement is 176 at the branch tip (route 41, `views.ts` 78, `pulseView.ts` 57) + an 84-line test — 185 + 66 at `074f09a2`, before `c205b270` moved the row mapping out of `views.ts` and added the dispatcher ban. `common/views/` gains 144 non-test lines (`names` 55, `cleanable` 42, `widgetActionable` 47) and 129 test lines. `editor/app` over commits 1–3, `--no-renames`: +421 / −345. Deviations: 1. **`e2e/auto-refresh.spec.ts`: one line changed.** It counts the BROWSER's own requests by `pathname === "/api/pulse"`; the client now sends `/api/view/pulse`, so it would count 0. It matches `/api/view/pulse` now. No assertion that calls an old path was edited — those are the rewrite's regression test. 2. **The cleanable assignability check lives in the editor** (`api/view/views.test.ts`, `[A] extends [B]`): `CleanableChannelRow` is an editor type a common test cannot import. The view's row type keeps the wire name `CleanableChannel` (MonitorWidget imports it). 3. **The hook gained `{ immediate?: boolean }`** (default true) and an unmount guard on `refetch`. useOperationsStatus and SyncConsole are SSR-seeded and never fetched on mount; they pass `immediate: false`. Both carried the unmount guard by hand. 4. **Behaviour change: `setInterval` → serial polling + a timeout** for those two. The trade-off, named: serial polling means a slow response can no longer stack requests behind it, but on its own it also meant a fetch that NEVER settled stopped the poll for good — the old `setInterval` loops recovered from a hang by firing again regardless. The review fix gives every tick an `AbortController` (aborted in the effect cleanup, so an unmount or disable never leaves a request in flight) combined via `AbortSignal.any` with `AbortSignal.timeout(pollTimeoutMs(pollMs))`, where `pollTimeoutMs = max(10 s, 3 × pollMs)`; an abort or timeout is a failed tick and still schedules the next one. `refetch` gets the same timeout. Cadences unchanged (3 s, 5 s). The hook has no React harness in the editor unit suite, so the unit test (`lib/usePolledPayload.test.ts`) pins `pollTimeoutMs` only; the hang recovery itself is not unit-tested. 5. **JobsTable's `enabled` is `polling` state**, seeded from the props and adjusted during render with `if (polling !== anyLive) setPolling(anyLive)` — React's documented "adjust state when a prop changes" form. It is needed because the poll's result feeds the rows `anyLive` derives from, so the hook must be called first. It cannot loop: `setPolling` only toggles the hook's timer effect and does not change `polled` in the same render, so the immediate re-render computes the same `anyLive`, the guard is false, and it settles after one extra render; new `polled` data only arrives from a completed fetch. "An idle page makes no requests", freshest-snapshot-wins, `mergeJobRows` and the never-nulled `data` are unchanged. 6. **Two comments named deleted routes**: `PipelineBand.tsx:140` and the `widget/lib/syncInputs.ts` header now say the old path is rewritten to `/api/view/…`. 7. **Review fixes (`c205b270`, one commit after `4288009b`)**: the hook timeout above; the `CONSTRUCTORS` text ban now also covers `api/view/[name]/route.ts`, so a hoisted constructor in the dispatcher fails a test; `view-route.spec.ts` also strips `disk.freeBytes` from the activeJobs comparison (a live statfs, equal only while the fixture's disk gate is off); the widgetActionable row mapping is one exported `widgetActionableRows` in `lib/actionable/loadActionable.ts`, called by both the view handler and the numbers tool; three more comments naming deleted routes fixed (`common/views/inputs.ts`, `common/lib/operations.ts`, `PipelineBand.tsx`); an `editor/CHANGELOG.md` entry. After the mapping moved, the numbers were re-checked: the tool against a fresh read no longer matched the morning's `before` file, because the live corpus had moved (videos 78,238 → 78,254, new sync timestamps — the running editor syncing, not this code). So the comparison was re-run at ONE instant: the pre-slice variant (route bodies copied inline) and the committed tool back to back over the same corpus — **diff empty**. 8. **Commit trailers** on commits 2–3 were rewritten with `filter-branch` after e2e exited (they had picked up the wrong model line); the shas above are post-rewrite. Code unchanged by the rewrite. Not done here, by design: no batch route, no auth on views, `operations/status.ts`, `requestCache.ts` and `liveInputs.ts` untouched. The full editor suite was not run; only the 20-spec subset above. ## Slice 4a, as shipped — one settings schema (2026-09-23) Branch `one-core/phase-3-s4a` off `main` `54cf1b31`, seven commits; `main` (slice 2) merged in as `7035316c`, then merged to `main` as `ae2fa5a9` (2026-09-23). Shas are after the trailer rewrite. *Checked against the branch 2026-09-23: the last two rows and the post-review common count were added.* | sha | what | |---|---| | `50215ab5` | zod `^4.3.6` joins `common` (lockfile +3 lines, no new resolution); the auto-queue defaults/clamps/tree normalisation/lane gate move from `jobs/autoQueuePolicy.ts` to `lib/autoQueueSchema.ts` (picker stays, re-exports every moved name); allow-list entry `lib/settings.ts -> jobs/autoQueuePolicy` burned, **10 → 9**; `autoQueuePolicy.test.ts` repointed (imports only), 59/59; `plans/tools/phase3-settings-numbers.ts` added | | `e120a2a5` | `workersSchema`, `channelPrioritySchema`, `autoQueueSchema` — zod seams over the existing sanitizers, in `lib/settingsFieldSchemas.ts` | | `f1abe024` | `lib/settingsSchema.ts`: `siteSettingsSchema` (31 fields, `.describe()` on each), `SiteSettings = z.infer`, `defaults()` = `defaultSiteSettings()` = `parse({})`; `lib/settings.ts` reduced to I/O (1,783 → 250 lines) and `export *`s the schema module; `settingsSchema.test.ts` | | `85478527` | `editor/app/settings/saveSettings.ts` + unit test; 19 call sites in 11 files converted to patches; `writeSettings` is imported by one editor file | | `0316e988` | `common/bin/settings-example.ts` (+`--check`), `lib/settingsDocs.ts`, generated `settings.json.example` + `SETTINGS.md`, `settingsDocs.test.ts`; SETUP.md points at SETTINGS.md | | `5b43dc8d` | this record; changelog entry | | `81deae69` | review fixes — every nested key documented (`lib/fieldDocs.ts`, 24 `*_FIELD_DOCS` records), see below | **What moved.** Every type, constant, clamp and block sanitizer that was in `lib/settings.ts` is in `lib/settingsSchema.ts` and re-exported, so no importer changed. `getSettings` = `finishRawMigrations(siteSettingsSchema.parse(premigrateRaw(raw)), raw)`: sweeps→lanes and mediaRoot→locations rewrite the raw input (keyed on the raw file's absence); legacy `transcribe*`→app registry and worker synthesis run after the parse, keyed on the raw object's `transcriptionApp` / `workers` absence. `writeSettings` = `parse({...deriveWorkerShadow(next), socialLinks: validatedSocialLinks(...)})` + tmp/rename; the two validators still throw. Every field is `z.unknown().catch(undefined).transform(coerce)` over the pre-existing clamp or sanitizer; no `.passthrough()`, no `.default()`. **Deviations from the spec, and why.** 1. *The zod seams are not beside their sanitizers.* `workers.ts`, `channelPriority.ts` and (through `jobs/autoQueuePolicy`) `autoQueueSchema.ts` are value-imported by `"use client"` forms (WorkersConfigForm, ChannelTierSelect, LadderRung, …); a zod import there would ship zod to the browser, contradicting "zod cannot reach a client bundle". The three schemas live in `lib/settingsFieldSchemas.ts` (server-only importers); the sanitizers keep their homes, names and signatures. Verified: no `ZodError`/`_zod` in `editor/.next/static` or `export/.next/static` after both builds. 2. *`archiveStorage` lost its `?`.* zod 4 cannot express an optional key that is always emitted (`.optional()` omits it when absent; a transform returning `T | undefined` is a required key). It was always emitted at runtime, so the shape test pins it as required; tsc across the workspace needed no change. 3. *`saveSettings(patch)` not `saveSettingsBlock(block, patch)`* — as instructed: `channels/actions.ts` writes `channelPriority` and all four `autoQueue` roots in one write. 4. *The example omits `workers`.* `defaultSiteSettings().workers` is `[]`; a copied template spelling `workers: []` would mean zero transcription slots, where an absent key synthesizes one. Stated in SETTINGS.md. 5. *Only the 31 top-level comments moved into `.describe()`.* The nested block types (`DigestSettings`, `DiarizationSettings`, …) keep their per-field comments on the hand-written types, because each block is one sanitizer-backed field, not a zod object. **Behaviour changes (all intended, all small).** - A settings.json containing `null` threw in `getSettings` (`parsed[key]` on null); it now reads as the empty file, like `[]`, `3` and `{`. - `adminTitle`, `cookiesFromBrowser`, `archiveStorage.*` are trimmed on read as they always were on write (one schema). The live file has no untrimmed values. - `storage/actions.ts`: adding/editing a location used to rebuild the storage block from two keys and so **erased `storage.savedVideosLocationId`**; the one-level merge keeps it. - `/settings` form (`editor/app/settings/actions.ts:207`): it used to pass `transcriptionApp: DEFAULT` + `transcriptionApps: {}` with the stored workers. When the stored list was `[]`, main's worker shadow therefore synthesized a default whisper-cpp worker with NO config; the branch patches only the form's own fields, so the shadow synthesizes from the STORED app and its stored config (better). And nothing now prunes stale per-app entries from the `transcriptionApps` shadow — the old `{}` did — since the shadow is rollback-only and still rewritten from workers on every save (accepted). **Dead example keys removed**: `transcribeBin`, `transcribeModel`, `transcribeArgs` (the pre-multi-app spelling, migrated on read). **Numbers** (`plans/tools/phase3-settings-numbers.ts`, `getSettings()` sorted-key JSON). The live settings.json was re-saved at 19:24 mid-slice — the operator's Gate B change, applied through the backfill lane form (lane held, `allowRedownload` off; that save also stripped the retired `backfill.enabled`, as every save does), not a stray write — so the comparison runs both builds over the SAME frozen inputs: the live file as of 19:24, the pre-slice example, the e2e fixture, and both `docker/entrypoint.sh` seeds (parakeet, whisper). Main `54cf1b31` vs branch tip: **empty diff, 3,846 lines**. After review the tool also prints what `writeSettings(getSettings())` puts on disk — written to a scratch copy under `os.tmpdir()`, never the measured file (the frozen inputs' md5s were re-checked after the run). Re-run over the same frozen inputs: **read AND write both diff-empty, 7,691 lines**, no write threw. The expected `backfill.enabled` line never appeared: `sanitizeBackfill` already dropped it at main, so it was not in `getSettings()` output before or after. The regenerated example of course parses differently from the old one (no legacy `whisper-cli`/`firefox` keys) — by design. **Entrypoint seed.** Both seeds (`workers[0]` enabled local parakeet / whisper-cpp, `parallelTranscriptions: 1`) parse to byte-identical settings through main and through the schema (included in the numbers above). The entrypoint does not read the example. **Review fix (`81deae69`, one commit after the record).** SETTINGS.md now documents every NESTED key, not only the 31 top-level ones: each block type carries a `_FIELD_DOCS: FieldDocs` record beside it (`lib/fieldDocs.ts`; the mapped type requires one entry per key, optional keys and every union member's keys included, so an undocumented new field is a tsc error). The per-field comments moved out of the types into those records — 24 records across `settingsSchema.ts` (the seven blocks + `SocialLink` + the newly named `ArchiveStorageSettings`), `storageLocations.ts` (settings, location, volume), `workers.ts` (worker, remote, llm), `transcriptionApps.ts` (`AppInstanceConfig`), `digest.ts` (`DigestAppConfig`), `autoQueueTypes.ts` (policy, tree node, match) and `channelPriority.ts` (document, focus, entry, and `autoPaused`, now the named type `ChannelAutoPause`). `settingsDocs.ts` renders each as a key · default · description table under its block (lane-policy defaults per lane, so `held`'s `[false,false,false,true]` is visible). Also: the `settingsField` comment no longer implies zod guards a throwing sanitizer (`z.unknown().catch` cannot fire — totality is each coercion's); the docs say `workers: []` means no transcription only until the next save; SETTINGS.md warns that a copied example pins every default, `held` included. **Gates.** tsc (`pnpm -r --workspace-concurrency=1 exec tsc --noEmit`; the parallel `-r` form was OOM-killed, exit 137) clean after every commit. common **1625 → 1648** (+20 schema, +3 docs) at `5b43dc8d`, **1650** after `81deae69` (+2 docs wiring tests); `test:scripts` 156 pass + 1 skip of 157 (unchanged); mcp 219/219; editor unit **59 → 63**; `next build` editor and export clean. **e2e** (from the worktree root, detached, ports 3311/3310): auto-queue, backfill, digest, diarization, attribution, scheduler, cadence-ui, storage-locations, channel-storage, workers, worker-remote, parakeet, parakeet-partial, chough, transcription-app-migration, disk-space, channel-priority, settings — **157/157 passed, exit 0, 9.9 min**, first run, nothing re-run. ## Slice 3a, as shipped — one drawing per noun (2026-09-24) Branch `one-core/phase-3-s3a` off `1e27f7c3`, eleven commits (the eight below, this record `b4890c78`, the review fixes `001f91bc` and the record update `f3432d44`), merged to `main` as `3241fed2` (2026-09-24). *Checked against the code 2026-09-24: the commit count and the merge were corrected, and the post-review e2e run added below.* | sha | what | |---|---| | `ab7a4739` | `common/views/channelRow.ts` (`ChannelRowView`, `buildChannelRowView`, `reportStateOf` moved, `ChannelRowPriority`, `ChannelRowMedia`, `channelVolumeOf`) + `actionableCounts.ts`, both tested; `loadActionable` count helpers become wrappers | | `155efeb9` | rack split: `ChannelsRack.tsx` (chrome) + shared `ChannelsTable.tsx`; `channelColumns.tsx` registry; `/channels` builds rows with the builder | | `01f08670` | dashboard on the shared table; "Needs work" seed = `buildWidgetActionablePayload(widgetActionableRows(rows))`; `PrioritizeButton` moved; `dashboard/ChannelsTable.tsx` + `types.ts` deleted | | `f63260d8` | `ChannelWorkTable` = server shell over the shared table; `Row` and `isStaleOrMissing` deleted | | `0d20c42a` | `jobs/components/JobRow.tsx` (table / card / compact) + `JobRowActions` + `JobRowHeading`; JobsTable, RunningJobsList, LaneStrip on them; compact bars; `lib/formatElapsed.ts` | | `797078c2` | `AutoRunnerInFlight.jobId` (type + the two `onChildJob` callbacks); `fromInFlight` + `source: "runner"`; InFlightList on `JobRow compact` | | `4a784486` | widget `ActiveJobsStrip` on `JobRow compact`; its private row, bars, glyph tables deleted | | `c944475f` | fix: column ids/presets move to plain `channelColumnPresets.ts` (see deviation 1) | **Gates.** tsc clean after every commit. common **1677** (1663 + 5 actionableCounts + 6 channelRow + 3 fromInFlight); editor unit **67**; `test:scripts` 156 + 1 skip. **e2e**, one detached run from the worktree root, 41 spec files (both lists; every named file exists): **280 passed, 0 failed, 0 skipped, 0 flaky, exit 0, 14.7 min** — the second run; the first was stopped in its first minute on the bug `c944475f` fixes. **Numbers** (`phase3-view-numbers.ts`, primary's `transcripts/` + `settings.json`, read-only): main `1e27f7c3` (a clean detached checkout) and the branch at `4a784486`, back to back — **diff empty, 5,925 bytes each**. The `autoQueueStatus` view gains the optional per-unit `jobId`; the numbers tool does not cover that view. Deviations: 1. **A column registry by id, not column objects.** A server shell cannot pass `cell: (row) => ReactNode`. And a server component that imports a VALUE from a `"use client"` module gets a client reference, not the value (`WORK_COLUMNS.filter is not a function` on every operation page — invisible to tsc), so the ids, sort keys, `PipelineColumn` and presets live in the directive-free `channelColumnPresets.ts`; the cells stay in the client registry. 2. **The rack split** into `ChannelsRack` (focus / volume / instrument bars, scroll region + `--thead-h`, selection + free-up, the deck outside the scroll box) and the shared table; group-header `colSpan` is counted from the drawn columns. 3. **LaneStrip keeps its lane line**; its runner job's buttons are `JobRowActions`, so it now also offers Force-release while that job runs, and Cancel only while running/queued. 4. **Runner `jobId`**: three lines in `autoRunner.ts`; the private `childJobIds` map stays private. A `source: "runner"` row links `Job ` only when `inRegistry`. 5. `formatElapsed` went to `editor/app/lib/formatElapsed.ts` (dispatch.ts re-exports), not `common/lib/format.ts`: that file was the parallel slice's, and it is not `formatDuration`. 6. `channelVolumeOf` (the page's `volumeOf`) lives in `channelRow.ts`; the builder takes an optional `mediaLocationLabel` so the dashboard still names the badge's location from the media target. 7. Work tables use the rack's report cell (`report age for `, "stale"/"missing"); an unset last sync still reads "never". Actions are left-aligned everywhere. 8. The dashboard's Sync is `ChannelSyncButton` (same `sync `, same `role="alert"`). 9. Widget job lines now show `detail`, as every other job row does. 10. Group sections still carry `ChannelStat` (config included) to the client — pre-existing, untouched. 11. Commits 1–7 and the fix carry an Opus 5.5 trailer, not the header's; left as they are. 12. **Card actions follow the table's rule** (`JobRowActions`): a RunningJobsList card now offers Retry on a failed replayable job, Force-release on a stuck one, no Cancel on a finished one, and its status pill takes `statusColor` (done/failed coloured, not muted). 13. **Dashboard cosmetics**: the handling cell is the rack's (`font-mono text-[11px]` muted); the slug link lost `underline-offset-2 hover:text-brand`; rows lost `align-top`; Sync is `ChannelSyncButton`, which drains its stream and reads "job …" / "done (job …)" where the old inline button cancelled the stream and said "queued". **Builds** (at `b4890c78`): editor `next build` exit 0, route table lists `ƒ /api/view/[name]`; export `next build` exit 0. **Bisect hazard.** From `f63260d8` up to (not including) `c944475f`, every operation page throws at render (`WORK_COLUMNS.filter is not a function`). History is not rewritten; skip that range when bisecting. **e2e after the review fixes** (run 3, same 41 files, detached from the worktree root): **280 passed, 0 failed, exit 0, 13.0 min.** **Review fixes (`001f91bc`), from the slice review, all eight in one commit:** 1. Dim only in the rack (`sticky`): the dashboard and work tables never dimmed, and a build-excluded channel in a work list is not out of any pipeline. 2. /channels projects the group sections to `{slug}` (`ChannelGroupSectionView`, in `common/views/channelGroupSections.ts`) before they reach the client — deviation 10 is resolved: no ChannelConfig reaches the browser from /channels. 3. Work tables' report cell: "stale · " / "missing", stamp in `title` (the rack's is unchanged) — deviation 7 no longer loses the date. 4. `suppressHydrationWarning` through `Td` on the date cells. 5. Compact JobRow: one-line heading, truncated `detail` with the full text in `title` (deviation 9's overflow). 6. InFlightList: each unit's real job kind (`auto-download-unit`, `auto-transcribe`, `auto-digest`, `auto-backfill`, a `Record` mirroring autoRunner's, which a client cannot import) and no status pill (`show.statusPill`); `fromInFlight` keeps `status: "running"`. 7. Lane line: Force-release only when the runner job is stuck (deviation 3 narrowed). 8. Dead exports removed (loadActionable's `reportStateOf` re-export, function-form column labels + `ChannelHeadCtx`, JobRow's internal helpers). ## Slice 4b, as shipped — the rest of the file schemas (2026-09-24) Branch `one-core/phase-3-s4b` off `main` `1e27f7c3`, eight commits, then `main` (`3241fed2`, slice 3a) merged in — merge sha and the post-merge gates in the follow-up below. | sha | what | |---|---| | `03164485` | `lib/jsonFile-server.ts` (+test): `readJsonFile` (+sync twin) → `{ok, value} \| {ok:false, reason: absent\|unreadable\|unparseable}`; `writeJsonAtomic` — unique temp name, writes chained per absolute path; `writeJsonAtomicSync` for the three synchronous stores. The 7 private copies (digest-server, chartsStore, aliasesStore, curatedTagsStore, buildIndex, buildStats, compose-homepage) and the inline tmp JSON writes in site.ts, settings.ts, controller/channels.ts, runYtdlp.ts, channelSnapshot.ts, the 7 single-file sidecar servers and the write halves of savedVideo-/posts-/clipWindow-server folded. `getSettings` reads through `readJsonFileSync`. Every file keeps its bytes | | `bb7fe82c` | `lib/sidecar-server.ts` (+test): `sidecar(filename, schema, {indent})` → `{filename, path, load, write, remove}`; throws at declaration on a `SUB_FILE_RE` match; `SIDECAR_FILENAMES`; `sidecarField(coerce)`. 8 pairs / 9 files declared, each shape check an exported `coerceX`, the old `loadX`/`writeX`/`xPath` names kept. `SUB_FILE_RE` exported (`videoStatus.ts:88`); `diarization.test.ts` uses it | | `2bf65626` | `lib/siteSchema.ts` (+test): per-key `settingsField` object over the existing parsers + one object step (defaultGroupId, membership groupIds, re-emit every key); `parseSite`, `siteToDisk`; the Site types, `SITE_ID_RE` and the three parsers moved in; `site.ts` = I/O + resolvers, `export *`. `SITE_FIELD_DOCS`, `SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS`, `RELATED_SITE_GROUP_FIELD_DOCS`; `CHANNEL_GROUP_FIELD_DOCS` in `channelGroups.ts` | | `e75047f8` | `CHANNEL_CONFIG_COERCIONS` in `channelConfig.ts` (pure) — the one coercion set, extracted verbatim; `parseChannelConfig` composes it zod-free. `lib/channelConfigSchema.ts` (server-only zod, +test) composes the same functions + `stripUndefined`. `CHANNEL_CONFIG_FIELD_DOCS` (the type's comments moved in), `AUDIO_CHECK_FIELD_DOCS`, `DOWNLOAD_FILTER_FIELD_DOCS`, `CHANNEL_CONFIG_KEYS` (from the docs record), `CHANNEL_SYNC_STATE_KEYS` | | `f6a08bd1` | `controller/channels.ts`: `readChannelConfigFile`, strict throwing `writeChannelConfig`, `patchChannelConfig(paths, slug, patch, {unset})` under `withJsonFileLock`. Repointed: runYtdlp's three stamps (`updateConfigField` deleted), fetchPosts, relocateChannelMedia ×2, storageLocations ×2, renameChannel, socialActions, the channel form (`{unset: CHANNEL_FORM_FIELDS}`), both exclude toggles, the scheduler's bulk cadence save. buildIndex/buildStats readers folded (+tests) | | `7c03d7c9` | `bin/file-schemas-docs.ts` (+`--check`), `lib/fileSchemaDocs.ts` (+test) → root `SITE.md`, `CHANNEL.md`; settingsDocs' table helpers exported; SETUP.md, AGENTS.md links; FACTS `SUB_FILE_RE` anchor corrected | | `973e59ee` | review fixes — below | | `c6d955ac` | `plans/tools/phase3-files-numbers.ts`, this record, changelog | **Plan correction — `build:index` is not read-only.** The plan said to time `pnpm --filter export build:index` "on the real corpus … read-only over `transcripts/`". It is not: the build writes its LMDB to `paths.lmdbPath = $TRANSCRIPTS_DIR/index.mdb` (14 GB on the live corpus), which is not env-overridable. Pointed at the primary's `transcripts/` it would have rewritten production's index under the live editor. Both timed runs instead used a scratch corpus (`$CLAUDE_JOB_DIR/tmp/s4b-buildindex.sh`): a directory of SYMLINKS to the live `channels/`, `sites/`, `saved-videos/` and the corpus-wide JSON files, with no `index.mdb`, and `EXPORT_PUBLIC_DIR` in scratch — so every read is the real corpus, every write is scratch, and each run is a cold full build (the incremental path would skip the very per-video coercions being timed). **Deviations.** 1. *No mtime freshness in `sidecar()`* — no reader consumes one. 2. *The channel coercions live in `channelConfig.ts`, and the zod schema wraps them.* `channelConfig.ts` is value-imported by six `"use client"` modules, so `parseChannelConfig` cannot delegate to zod. `CHANNEL_CONFIG_COERCIONS` there is the one set; both `parseChannelConfig` and `channelConfigSchema` compose it; a test pins that the two agree over generated inputs. 3. *`writeJsonAtomic` takes `newline` as well as `indent`.* The plan's rule ("`\n` only at indent 2") would have changed bytes: attribution/diarization are compact WITH a newline, the chart/alias/tag stores indented WITHOUT one, the export pages compact without one. 4. *A synchronous `writeJsonAtomicSync`* for chartsStore/aliasesStore/curatedTagsStore (a synchronous API cannot await the chain; nothing else runs while it does). 5. *The site types and parsers moved into `siteSchema.ts`* (4a's settings pattern, to avoid a `site.ts` ↔ `siteSchema.ts` cycle). And zod 4 OMITS an input-absent key whose transform returns `undefined`, so the object step re-emits every key in `SITE_KEYS` order — the always-emit shape `parseSite` has always had. 6. *`migrateToSites.ts:101` is not on `readChannelConfigFile`*: it reads the legacy raw `group` key the schema drops. 7. *`patchChannelConfig` returns what it wrote (or null) and holds a per-path lock.* The two callers that used to fall back to their own copy of a missing config still do, through `writeChannelConfig`: relocateChannelMedia (move out) and renameChannel. 8. *doNotClean / excludeTruncatedCheck keep their rule*: ANY parseable JSON — even `null` or `3` — reads as a marker (`{setAt:""}`), so the coercion is not "non-null object". 9. *storageLocations' rollback restores only `dataDir`* (the one field the job changed), not the whole config it found at its start. 10. *relocateChannelMedia's move-out does not throw on a null patch* (the review asked for a throw at both sites; storageLocations throws). The move-out has no ledger to roll back and the swap has already happened when the config is written, so a throw would leave a swapped link and an unrecorded `dataDir`; it writes the job's own copy of the config instead — the same thing its existing no-config branch did. **Behaviour changes (all intended).** - A social fetch no longer reverts Configure-form edits made while it ran (`fetchPosts` wrote back the config it read at its start; it now patches `lastSyncedAt`). - A sync over a `config.json` that is not valid JSON used to throw at the stamp; the stamp is now skipped (`readChannelConfig` already answered null, so the scheduler never picks such a channel). - `resolveEffectiveAvailability`'s download-outcome side reads through `loadDownloadOutcome`, so it now needs the full shape check. A read-only scan of all 57,897 live `download-outcome.json` files: 1,120 carry an `availabilityClass`, **0** answer differently. - **Config writes now come out in SCHEMA key order.** Before, a form save or toggle wrote its caller's spread order (form keys appended last); syncs already normalised it through `updateConfigField`. Semantically nothing moves, but `transcripts/` is its own git repo: the first form save or toggle per channel may show a one-time key-reorder diff there. The numbers tool (parse → write) does not exercise this. **Review fixes (`973e59ee`).** - The jsonFile state (`tmpSeq`, write chains, file locks) lives on `globalThis.__yttJsonFile__` — the house pattern (`jobs/registry.ts`, `controller/autoRunner.ts`) — because Next can load the module once per bundle layer (instrumentation-armed runners vs server actions), and two copies would each start the counter at 0 and hold separate locks. The temp name gains 4 random bytes (`${file}.tmp-${pid}-${seq}-${hex}`). A test imports a second module instance and checks both share one chain. - storageLocations' re-point THROWS on a null patch, so the ledger rolls the link back instead of recording `configWritten` with no `dataDir` on disk. relocateChannelMedia's move-out, on a null patch, writes the job's own copy instead (deviation 10). - `writeChannelConfig` returns what it wrote, so the patch parses once; the site `z.object` is built once at module load (`parseSite` fills `siteId`); a dead import removed. - SITE.md / CHANNEL.md prose corrected (the always-written site keys named; "absent means inherit" only for the overrides; the two whole-config fallbacks named; the doubled `downloadFilter`/`audioCheck` headings removed), with two new pinning tests. **Not every JSON writer is on the shared writer.** The 14 below are JSON writers still on the per-pid temp name `${file}.tmp-${process.pid}` — not "non-JSON", as a draft of this record said. *(Recounted at `ef88ac4d`: the list is 16 write sites in 13 files, not 14.)* `controller/failedTranscriptions.ts:33,54`, `controller/maybeMissingStore.ts:54`, `controller/rosterStore.ts:235`, `controller/duplicateShorts.ts:640,734`, `controller/scanCorruptMedia.ts:393,454`, `controller/shard.ts:56`, `controller/backupSavedVideos.ts:118`, `controller/relocateDir.ts:139`, `jobs/syncSchedulerState.ts:122`, `jobs/workerDefaults.ts:61`, `lib/widgetPresets.ts:83`, `lib/homepage.ts:129`, `bin/migrate-channel-priority.ts:206`. **`maybeMissingStore` and `rosterStore` are per-channel files written from several lanes — the same race class this slice fixes for `config.json`. Owed, later.** The non-JSON writers (normalizeTranscript/LiveChat cues, `runYtdlp` playlist, xSessionBroker, videoActions, cutReleaseAction, transcode, transcribeOne, savedVideo's copy, the umtool ones) are out of scope. The chain is per process: a CLI beside the live editor is not covered (the rename is still atomic). Also noted by review, accepted: `channelConfig.ts` and `channelGroups.ts` are value-imported by client modules, so the four channel/group `*_FIELD_DOCS` string records ship to the browser (a few KB, no zod — the grep below). **Numbers** (`plans/tools/phase3-files-numbers.ts`, one process, never writes the corpus). The inputs were FROZEN once (`FREEZE_TO`) into a scratch tree — 6 `site.json`, 71 `config.json`, and the first ≤300 dirs per sidecar filename in sorted slug × sorted id order: 1,763 sidecar files (attribution 259, diarization 300, availability 300, download-outcome 300, transcribe-outcome 300, do-not-clean 1, exclude-truncated-check 3, ai-digest 300, ai-digest.overrides 0 — none exist in the corpus) — because the live corpus moves under the running editor. The script prints each site parse + the file `writeSite(getSite())` writes, each config parse + md5 of `writeChannelConfig(readChannelConfig())`, and per sidecar md5 of the canonical load + md5 of `write(load())` (load only for the two markers, whose only writer stamps the clock). `main` (a detached worktree at `1e27f7c3`) vs the branch at `e75047f8`, `f6a08bd1` and — after the review fixes — `973e59ee` (`s4b-numbers-fix.txt`): **diff empty, 3,832 lines**, each time. **Unknown-key report: empty** for all 77 files. 0 null loads. Parity beyond the corpus (ad hoc, main's function vs the branch's): `parseSite` over 20,000 random inputs — deepStrictEqual and key order equal; `writeSite` over 3,000 random sites — byte-identical files or the identical throw; `parseChannelConfig` over 20,000 random inputs — deepStrictEqual and key order equal. **`build:index` timing** (cold full build, scratch corpus as above; the build's own `Done in` figure — 77,624 transcripts, 6 sites built): | run | when | machine | time | |---|---|---|---| | before-main (`1e27f7c3`) #1 | 14:03–14:29 | I/O-loaded: the live editor's sync-all + metadata scan and an ffmpeg remux on the platter drive (`/proc/pressure/io` "some" 60–78 %) | 1542.74 s | | branch (`973e59ee`) | 14:29–14:57 | same load, partly | 1699.98 s | | before-main #2 | 15:54–16:22 | quiet | 1691.98 s | Branch vs before-main #2: **+0.5 %** — within the 5 % gate, **not a regression**; run #1 is the outlier (the load shifted which run it slowed; the numbers are not a controlled benchmark). No profiling was needed. Run #2 was taken by the coordinator with the same harness. **Gates on the branch tip before the merge (`973e59ee` + this commit):** | gate | result | |---|---| | `pnpm -r --workspace-concurrency=1 exec tsc --noEmit` | clean after every commit | | common tests | **1709/1709** (1663 before; +46: jsonFile 9, sidecar 8, siteSchema 11, channelConfigSchema 7, channels +5, fileSchemaDocs 6) | | editor unit | 67/67 | | `pnpm run test:scripts` | 156 pass / 1 skip | | mcp | 219/219 | | `next build` editor / export (at `7c03d7c9`) | green; `├ ƒ /api/view/[name]`; `grep -rl 'ZodError\|_zod'` over both `.next/static` prints nothing | | `file-schemas-docs --check` | clean | | numbers tool vs `main` | diff empty (above) | | e2e editor, the plan's 34 specs, at `7c03d7c9` | **190 passed**, 0 failed, 11.1 min, exit 0 — every spec the plan named exists; none dropped | | e2e editor after the review fixes (`973e59ee`): storage-locations, channel-storage, channel-rename, sites-crud, channels, digest, availability | **63 passed**, 0 failed, 3.7 min | | e2e export: site-branding, related-sites, pwa-search, ask-chat | **33 passed**, 0 failed, 1.0 min | **Merged with `main` `3241fed2` (slice 3a) as `7dfd7508`** — conflicts only in this file and `editor/CHANGELOG.md`, both sides kept, 3a first. Gates on the merged tip: | gate | result | |---|---| | `pnpm -r --workspace-concurrency=1 exec tsc --noEmit` | clean | | common tests | **1723/1723** (1709 + slice 3a's 14) | | editor unit | 67/67 | | `pnpm run test:scripts` | 156 pass / 1 skip | | mcp | 219/219 | | `next build` editor / export | both green; `├ ƒ /api/view/[name]`; `grep -rl 'ZodError\|_zod'` over both `.next/static` prints nothing | | numbers tool vs `main` (frozen inputs) | diff empty, 3,832 lines | | e2e editor, the plan's 34 specs | **190 passed**, 0 failed, 12.7 min, exit 0 | The export build first failed on the merged tip with `ENOENT export/public/tags.json`. Not this slice: the live editor's `build-deploy` job `01M3AADF600BDC99T2WPH1KBQB` (14:21–14:37, an incremental build:index on the real corpus plus a Jeralyzer deploy) regenerated the primary's `export/public/` without a `tags.json` (Jeralyzer has no curated tags), which left this worktree's per-path symlink to it dangling. The stale link was removed and the export build re-run green.