commit a74bab1f0f5ab7c20c5d70aa19d1c2a686f73926
parent 596c29638db9d1b659999b6285133aaefd7167d4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 20:26:20 -0400
plans: release 4 record — Phase 3 complete (main @ 596c2963)
one-core-phase-3.md: the release-4 record (prelude, P/W/3b merges, order, gates,
record corrections) and "Next — Phase 4" in place of the slice-3b inventory.
one-core.md: 3b and W "as shipped", Phase 3 COMPLETE, Phase 4 starting points.
FACTS.md: release-4 anchors (rack ladder, station rule, band divergence,
writeFileAtomic/copyFileAtomic, cards) and the anchors this release made stale.
STATE.md: head, phase table, decisions. Final full-suite numbers pending.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
| M | plans/FACTS.md | | | 227 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | plans/STATE.md | | | 194 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------- |
| M | plans/one-core-phase-3.md | | | 254 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- |
| M | plans/one-core.md | | | 69 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
4 files changed, 630 insertions(+), 114 deletions(-)
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -2442,7 +2442,8 @@ operation on in Settings"), `channels/components/ChannelGroupLine.tsx:184` (lane
in Settings" title), `channels/groupActions.ts:108` (skip reason), `stages/DigestStage.tsx:210`
and `videos/[id]/components/DigestPanel.tsx:144` ("metered — disabled in Settings"). *(Both
were fixed by slice 3; the digest one now reads "metered — disabled on the Digest page" and,
-since slice 6, lives in `videos/[id]/components/DigestBody.tsx`.)* The
+since slice 6, lives in `videos/[id]/components/DigestBody.tsx`. The group skip reason is
+now "the lane is switched off on its operation page", at `groupActions.ts:186` on `e172749b`.)* The
disk-floor sentences ("lower the floor in Settings", four sites) stay true — the floor is not
moving. `ChannelForm.tsx:812`, `scheduler/*`, `workers/*` link to `/settings` for blocks that
stay.
@@ -3672,7 +3673,8 @@ four instances, and **every known instance on the video page is closed as of `12
`savedVideo` was truthy, `IncompleteTranscriptBanner` and `ShortAudioBanner` were dropped by
the parent when the run cleared the condition that drew them, and `PerFileTranscribeRow`
swapped its panel for "Transcribe disabled — transcript already present." when its own run
-wrote transcript.json (`hasTranscript` is server-derived at `VideoPanel.tsx:360`).
+wrote transcript.json (`hasTranscript` is server-derived at `VideoPanel.tsx:360`; since
+slice 3b, `VideoPanel.tsx:122` on `e172749b`).
**THE SHAPE TO COPY** is the same in all four and is what a fifth should be written as: the
parent renders the component unconditionally and passes the CONDITION down; the component holds
@@ -3681,11 +3683,14 @@ a `ranHere` flag set inside the `trigger` it hands `StreamActionLog`, returns nu
and keyed), and passes the now-cleared condition to `StreamActionLog`'s existing `disabled`
prop — so the panel that stays for its log is not a second Run button
(`VideoPanel.tsx:1085-1120` persist, `1550-1588` incomplete, `1638-1670` short-audio,
-`729-756` the transcribe row, at `12d1778`). A banner's
+`729-756` the transcribe row, at `12d1778`; since slice 3b each is its own file under
+`videos/[id]/components/cards/`: `SourceVideoSection.tsx:38,64`,
+`IncompleteTranscriptBanner.tsx:38-39`, `ShortAudioBanner.tsx:40-41`,
+`PerFileTranscribeRow.tsx:39,62` on `e172749b`). A banner's
`role="alert"` and warning aria-label are spread conditionally on that same condition, so the
box that outlives the warning stops answering `getByLabel("incomplete transcript")` —
`truncated-check.spec.ts:69` asserts exactly that. `PerFileTranscodeRow` (`:810` at `12d1778`)
-is NOT an instance: its `return null` depends on the file's extension against a static format
+(`cards/PerFileTranscodeRow.tsx:39` since 3b) is NOT an instance: its `return null` depends on the file's extension against a static format
list, which a transcode cannot change, and the rows are keyed by file name.
The persist case needed a fixture: `editor/e2e/fixtures/bin/fake-ytdlp.mjs:680-725` (at
@@ -5858,7 +5863,9 @@ Not in the 14:
- `metadataScanStore.ts:188-206` and `autoQueueState.ts:141-156`. They carry a module-level
`writeSeq` (`${file}.tmp-${pid}-${seq}`), which is unique per module copy, not per process —
the hazard review finding 1 fixed in `jsonFile-server` by pinning its counter on
- `globalThis`. Owed: the same fix, or `writeJsonAtomic`.
+ `globalThis`. Owed: the same fix, or `writeJsonAtomic`. **Done by slice W (`9bfd15cd`,
+ merged `ddad13f4`):** both counters deleted, both writers on `writeJsonAtomic`
+ (`metadataScanStore.ts:200`, `autoQueueState.ts:155`); see the release-4 section below.
- The non-JSON writers.
**The numbers tool** is `plans/tools/phase3-files-numbers.ts` (317 lines). It is one process,
@@ -5977,3 +5984,213 @@ Slice 4b (merged `ef88ac4d`) deleted or moved:
- `:609` → `:602`
- `:618-621` → `:611-614`
- `:571` → `:564`
+
+## One-core Phase 3 release 4 — slices P, W, 3b (verified 2026-09-24, `main` @ `e172749b`)
+
+Records: `one-core-phase-3.md`, "Release 2026-09-24 (evening)", and the three "as shipped"
+sections above it (P, W, 3b). Every `file:line` below was grepped at `e172749b`. Phase 3 is
+complete with this release.
+
+### The /channels rack's layer ladder — slice P (merged `77a32de2`)
+
+- **One ladder, one module.** `editor/app/channels/components/rackLayout.ts` is plain data,
+ deliberately not `"use client"`. It holds:
+ - `RACK_LAYERS` `:37-43`: `popover` z-40 > `thead` z-30 > `groupHeader` `md:z-20` >
+ `identity` z-10. `deck` z-20 sits beside the ladder, not in its order.
+ - `RACK_IDENTITY` `:53-56`: `checkboxWidth` `w-9`, `slugLeft` `left-8`.
+ - `RACK_BRIDGE` `:59`.
+
+ The header comment `:9-35` explains why the checkbox and Slug cells share **one** z. Two
+ sibling cells at one z-index paint in DOM order. The Slug cell comes later, so it covers
+ the 4 px the two cells overlap, and that overlap closes the seam `20ee34db` found. That
+ commit had closed the seam by lifting Slug to z-20, which TIED it with the group header:
+ scrolled down and right, every later row's slug band painted over the group header and
+ took its clicks.
+- **The width invariant** is `slugLeft` (32 px) < `checkboxWidth` (36 px), stated at `:45-52`.
+ Equal values reopen the sub-pixel seam. `rackLayout.test.ts:13` parses both numbers. `:19`
+ checks that the ladder's order is strict.
+- **Class sites read the tokens.** Every entry is a complete literal, because Tailwind v4
+ scans source for them, so never build one as `z-${n}`. The readers:
+ - `ChannelsTable.tsx:277,389,553`;
+ - `channelColumns.tsx:123,127`;
+ - `ChannelGroupHeaderRow.tsx:53`;
+ - `ChannelTierSelect.tsx:232`, with `max-md:scroll-mb-48` so the panel stops above the deck;
+ - `ChannelSelectionDeck.tsx:130`.
+
+ `grep -rn 'z-[0-9]' editor/app/channels/components --include='*.tsx'` is empty.
+- **The region is `isolate`** (`ChannelsRack.tsx:221`, comment `:212-217`), so the ladder is
+ ordered inside the region and not against the page. It carries
+ `data-testid="channels-rack"`. `ChannelSelectionDeck` stays **outside** it (`:245`).
+- A group header's name and stations pin left: `ChannelGroupHeaderRow.tsx:63`,
+ `sticky left-2 max-w-[calc(var(--rack-w,100%)-1rem)]`. The section rule is the th's inset
+ shadow (`:52`), not the `<tr>`'s border, because a collapsed border stays in the grid when
+ the th pins.
+
+### The transcribe station counts both buckets and queues by id — slice P
+
+- **The one fold** is `transcribeStationIds(snapshot)` (`common/views/channelGroupSections.ts:114-123`).
+ It returns `{missing, autoSubs}`:
+ - `missing` is `downloadedNoTranscript` minus `excludedDownloadIdSet`;
+ - `autoSubs` is `downloadedAutoSubsOnly` minus the same set.
+
+ The two lists are disjoint. `stationWorkFor`'s transcribe branch (`:187-199`) returns
+ `missing.length + autoSubs.length` and never consults `handling`. `"social account"`
+ (`:172-174`) is now the only ineligible case.
+- **The group action queues exactly those ids.** `transcribeChannel` is at
+ `editor/app/channels/groupActions.ts:93-127`:
+ - With a snapshot it runs `transcribeBucketAction(slug, ids.missing, …,
+ "downloadedNoTranscript")` (kind `whisper-bucket-downloaded-no-transcript`) and
+ `transcribeAutoSubsBucketAction(slug, ids.autoSubs)` (kind `whisper-bucket-auto-subs`).
+ - The scan, `transcribeMissingAction` (a `whisper-all` with no ids), runs **only** when there
+ is no snapshot (`:97`).
+ - Why by id: `downloadedNoTranscript` does not filter excluded ids, so a scan would also
+ transcribe a video downloaded before it went private.
+ - `jobIds` carries the first ok half's id (`:116`), and the other ok stream is cancelled.
+ A refused half is `console.warn`ed (`:122`), because `StreamActionResult`'s ok arm has no
+ message field.
+- **The dedupe spans all three kinds.** `KIND_FOR.transcribe` (`groupActions.ts:61-65`) is
+ `whisper-all`, `whisper-bucket-downloaded-no-transcript` and `whisper-bucket-auto-subs`.
+ `activeSlugsForKinds(KIND_FOR[station])` is at `:209`. All three run on
+ `TRANSCRIPTION_QUEUE`.
+- **The band/station divergence is on purpose.** The rack's transcription band counts
+ `downloadedNoTranscript` **alone**: `common/views/pipeline/buildBands.ts:168-172`
+ (`transcription.reachable += downloadedNoTranscript`, rule stated at `:104`). The stage
+ title lists auto-captions as informational only (`common/views/pipeline/stageStatus.ts:342-352`).
+ On a YouTube channel with many auto-caption-only videos, the station's figure is higher than
+ the band's. The station counts what its button queues; the band counts the default lane's
+ reachable work. `channelSnapshot.ts:581-587` warns that the two must not be merged; its last
+ line names the retired path `editor/app/components/pipelines/buildBands.ts`.
+
+### One write idiom — slice W (merged `ddad13f4`)
+
+`common/lib/jsonFile-server.ts` (268 lines):
+
+- The header `:1-5` says what is left on its own temp name: see
+ `one-core-phase-3.md`, "Slice W, as shipped".
+- Header `:33-35`: **the chain serialises WRITES, not read-modify-write cycles.** A
+ read-modify-write takes `withJsonFileLock` (`:251`), and only `patchChannelConfig` does.
+- `tmpPathFor` `:134-137` builds `${file}.tmp-${pid}-${seq}-${hex8}`. The state is on
+ `globalThis.__yttJsonFile__` (`:114-131`).
+- `chained(key, op)` `:152-163` is the one per-absolute-path chain. `replaceNow` `:166-180` is
+ mkdir?, temp, fill, `rename`. On any failure it runs `rm(tmp, {force: true})` and rethrows
+ the original error.
+- **`writeFileAtomic(file, data: string | Buffer, {mkdir, mode})` `:186-198`.** `mode` goes to
+ `writeFile(tmp, data, {mode})`, so the bytes are never more widely readable, even before
+ the rename (options doc `:139-147`).
+- **`copyFileAtomic(src, dest, {mkdir})` `:203-212`** copies into a temp beside `dest` and
+ renames. It is on the same chain, keyed on `dest`.
+- `writeJsonAtomic` `:217-223` is `jsonText` (`:107`) → `writeFileAtomic`.
+ `writeJsonAtomicSync` is at `:229`.
+- **Tests**, in `jsonFile-server.test.ts`:
+ - `:72` two module copies share one chain;
+ - `:132` exact string and Buffer bytes;
+ - `:147` mode 0o600 on the file and on the temp. It intercepts `rename` via `node:fs/promises`
+ + `syncBuiltinESMExports`; `:169` asserts that exactly one temp was seen;
+ - `:173` `writeFileAtomic` and `writeJsonAtomic` share one chain on one path;
+ - `:190` copy;
+ - `:202` the remark literal.
+
+ Also `controller/compactJsonWriters.test.ts` (the four compact writers' bytes) and
+ `jobs/autoQueueState.test.ts` (12 overlapping writes, the last one issued lands, no temp
+ left).
+- **Call sites** (a sample):
+ - `rosterStore.ts:236` (mkdir);
+ - `metadataScanStore.ts:200`;
+ - `autoQueueState.ts:155` (mkdir; the old counter's comment is replaced at `:139`);
+ - `xSessionBroker.ts:218` (0o600);
+ - `savedVideo-server.ts:78` (`copyFileAtomic`);
+ - `normalizeTranscript.ts:141` (`{indent: 0, newline: false}`);
+ - `videos/[id]/videoActions.ts:499` (VTT promote) and `:609` (the remark,
+ `{transcription: []}` at `{indent: 0}`).
+- **The two write counters are deleted.** `git grep -n 'writeSeq\|nextWriteSeq' -- common
+ editor` is empty.
+- **What `git grep -n 'tmp-${process.pid}' -- common editor` still finds:**
+ - `buildIndex.ts:971` and `buildStats.ts:220`, the export page writers, out of scope;
+ - `transcode.ts:31` (ffmpeg's output, renamed at `:55`);
+ - `transcribeOne.ts:142` (the transcription app's `outputBase`);
+ - the shared writer's own comment `:9`, code `:136`, and test `:68`.
+
+ `transcribeOne.ts:173` (`if (bytes) await writeFile(transcriptPath, bytes)`) has no temp
+ at all, so this grep cannot see it.
+- **Temp-name readers** match either shape: `videos/[id]/page.tsx:63` and `videos/page.tsx:50`
+ use `includes(".tmp-")`, and `cards/videoFiles.ts:43` uses `startsWith("audio.tmp-")`.
+
+### The video page's chore cards — slice 3b (merged `e172749b`)
+
+- **`editor/app/channels/[slug]/videos/[id]/components/cards/`** holds 17 files: the 15 card
+ modules, `Heading.tsx` (shared by the two per-file rows) and `videoFiles.ts` (`VideoFile`,
+ `WHISPER_FILENAME`, `CANONICAL_VTT`, the extension predicates, `mediaUrl`).
+ - Children stay with their only caller: `PersistedSourceVideo` is in
+ `SourceVideoSection.tsx:96` and `DeleteFileButton` in `FilesList.tsx:67`.
+ - `VideoNavStrip.tsx` (60 lines) and `PipelineStatusStrip.tsx` (51) sit beside `VideoPanel`,
+ not in `cards/`.
+- **`VideoPanel.tsx` is the assembly**: 455 lines (1,839 at `4130aca1`). `VideoPanel()` starts
+ at `:96`. `:49` re-exports `FetchedWindow` and `VideoFile`, so
+ `videos/[id]/page.tsx:33` and `channels/[slug]/videos/page.tsx:26` did not change.
+ `hasTranscript` is derived at `:122`.
+- **`lib/videoChoreCards.ts`** (149 lines) holds `VideoChoreCard` `:46` and
+ `VIDEO_CHORE_CARDS` `:58`: fifteen rows `{id, component, shown, why}` in render order.
+ Nothing imports it at runtime. `videoChoreCards.test.ts` (`:23`, `:28`, `:36`) reads
+ `VideoPanel.tsx` as text and pins three things: fifteen distinct components, the rendered
+ order equal to the list, and the `./cards/` imports equal to the list. The registry
+ operations (digest, diarization, attribution) still render through `videoOperationPanels.ts`
+ + `OperationPanel`, after `<VideoPanel>` in `videos/[id]/page.tsx`.
+- **The stage half needed no change.** `computeStageStatuses`
+ (`common/views/pipeline/stageStatus.ts:192`) and `computeChannelFlow` (`channelFlow.ts:220`)
+ are each called once, at `channels/[slug]/page.tsx:256` and `:308`. `stageOrder` is at
+ `:278-286`, around `GROUP_STAGES` (`stageStatus.ts:95`).
+- **The ranHere banners, now one file each.** This is the shape the 2026-09-08 entry above
+ describes:
+ - `SourceVideoSection.tsx:38,64`;
+ - `IncompleteTranscriptBanner.tsx:38-39`;
+ - `ShortAudioBanner.tsx:40-41`;
+ - `PerFileTranscribeRow.tsx:39,62`.
+
+ `PerFileTranscodeRow.tsx:39` is the static `return null`, which is not an instance.
+
+### Anchors this release made stale
+
+Line numbers are `plans/FACTS.md` lines at `e172749b`, before this record's in-place edits.
+
+**Corrected in place** (the grep the release-4 docs brief named:
+`VideoPanel.tsx:`, `groupActions.ts:`, `channelGroupSections.ts:`, `metadataScanStore.ts:`,
+`autoQueueState.ts:`):
+- `:2442`: `channels/groupActions.ts:108`. This is historical: slice 3 fixed the copy. The
+ skip reason now reads "the lane is switched off on its operation page", at `:186`. A
+ pointer was added.
+- `:3346`: `jobs/autoQueueState.ts:23-25`. **Holds**; W's edits are below it.
+- `:3675`, `:3683`: the `VideoPanel.tsx` anchors "at `12d1778`". Historical. Their current
+ homes (the ranHere list above) were added in place.
+- `:5573`: `channelGroupSections.ts:66-72` (`ChannelGroupSectionView`). **Holds**. P's edits
+ are below it, and the type is still at `:70`.
+- `:5858`: "`metadataScanStore.ts:188-206` and `autoQueueState.ts:141-156` … Owed". Both
+ counters were deleted by W (`9bfd15cd`), and the entry now says so.
+
+**Moved by P** (the 3a entry's text still holds; only the numbers changed):
+- `ChannelsRack.tsx` is 263 lines, not 250. The header `:23-28` holds. `--thead-h` is
+ `:136-142`, not `:132-138`. The deck is `:245`, not `:229`. `ChannelsTable` is drawn at
+ `:223-239`, not `:210-226`.
+- `ChannelsTable.tsx`:
+ - the function is `:184`, not `:183`;
+ - the `colSpan` count is `:247-255`, not `:246-254`;
+ - the dim is `:533-536`, not `:527-530`, with its comment at `:519-532`, not `:513-526`.
+- `channelColumns.tsx`:
+ - `CHANNEL_COLUMNS` is `:115`, not `:114`;
+ - the re-exports are `:20-29`, not `:19-28`;
+ - the registry-by-id comment is `:31-40`, not `:30-39`.
+
+**Moved by W** (the 4b entry for `jsonFile-server.ts`; the text holds unless noted):
+- `readJsonFile` is `:74`, not `:72`. `readJsonFileSync` is `:86`, not `:84`.
+- `writeJsonAtomic` is `:217`, not `:158`. The chain is `chained` `:152-163`, not `:165-176`.
+- `writeJsonAtomicSync` is `:229`, not `:183`. `withJsonFileLock` is `:251`, not `:205`.
+- `tmpPathFor` is `:134-137`, not `:132-135`. The state is `:114-131`, not `:112-129`.
+- `jsonText` is `:107`, not `:105`. The bytes header is `:37-41`, not `:35-39`. The
+ per-process header is `:28-31`, not `:26-29`.
+- The second-module-copy test is `jsonFile-server.test.ts:72`, not `:67`.
+- The 4b entry "**Owed: JSON writers still on the per-pid temp name**" (16 sites in 13
+ files) is **done**: W folded all of them. The two `failedTranscriptions` sites went onto
+ `writeFileAtomic` as text.
+- The "Reusable helpers" row at `:166` ("tmp+rename write idiom | `normalizeTranscript.ts:139-141`")
+ is historical. `normalizeTranscript.ts:141` is now `writeJsonAtomic(cuesPath, out,
+ {indent: 0, newline: false})`. The idiom for any file is `writeFileAtomic` /
+ `writeJsonAtomic`.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,58 +3,89 @@
The working memory for the local-AI derived-corpus work. Rewritten at the end of every
session, before context is cleared. See [`README.md`](README.md) for the protocol.
-**Live on :3001 (2026-09-24, 18:42):** `9ab10d77` (release 3), `BUILD_ID` `XsKaA_drqdAbTguxUGVsn`; smoke clean, boot and a form save rewrote nothing but the saved channel's key order; the one-sync md5 sweep is owed — YouTube held a 429 cooldown across three attempts (18:43, 19:13, 19:41) — [rollout record](one-core-phase-3.md#rollout-2026-09-24--9ab10d77-live-on-3001).
-
-**Last updated:** 2026-09-24 — **release 3: one-core Phase 3 slices 3a and 4b are on `main` @
-`ef88ac4d`.** Rollout prelude first (`c0a90a37`, `1e27f7c3`: the backfill lane form's
-re-acquire figures derived, release 2 live on :3001 — rollout record:
-[`one-core-phase-3.md`](one-core-phase-3.md#rollout-2026-09-24--b836ea8a--two-prelude-commits-live-on-3001)),
-then two worktrees off `1e27f7c3`, merged in the order `one-core/phase-3-s3a` → `3241fed2`,
-then `one-core/phase-3-s4b` → `ef88ac4d`. Release record:
-[`one-core-phase-3.md`](one-core-phase-3.md#release-2026-09-24--rollout-slice-3a-slice-4b),
-with the two "as shipped" sections below it; anchors:
-[`FACTS.md`](FACTS.md#one-core-phase-3-slices-3a--4b-verified-2026-09-24-main--ef88ac4d).
-Previous: 2026-09-23 — slices 2 and 4a on `main` @ `ae2fa5a9`, gate B done
-([record](one-core-phase-3.md#release-2026-09-23--slices-2-and-4a)); release 2 went live on
-:3001 on 2026-09-24 as `1e27f7c3`.
-
-- **Slice 3a — one drawing per noun (channel row + job in flight).** One pure channel-row
- builder (`common/views/channelRow.ts`, no config on the wire) and one per-channel counter
- set (`actionableCounts.ts`); one `ChannelsTable` drawn by the `/channels` rack
- (`ChannelsRack`), the dashboard and every operation page's work table, columns named by id
- from a plain preset module. One `JobRow` (`table` | `card` | `compact`) + `JobRowActions`
- drawn by `/jobs`, the card lists, the widget and the operations board's in-flight list
- (via `fromInFlight`; the runner now records a download unit's `jobId`). `LaneStrip` keeps
- its lane line. No number moved (`phase3-view-numbers.ts`, diff empty, 5,925 bytes). The
- `stages/*` status logic and the `VideoPanel` split are slice 3b.
-- **Slice 4b — the rest of the file schemas: `site.json`, channel `config.json`, sidecars.**
- `siteSchema.ts` and `channelConfigSchema.ts` on 4a's `settingsField(coerce)` pattern (the
- channel coercions stay zod-free in `channelConfig.ts`, which client code imports); one
- config reader, a strict writer and one locked read-modify-write patcher
- (`patchChannelConfig`, `updateConfigField` deleted); eight sidecar pairs each one
- `sidecar(name, schema)` with the `transcript.<x>.<y>` guard thrown at declaration; one JSON
- reader and one per-path-chained atomic writer (`jsonFile-server.ts`, state on
- `globalThis`); `SITE.md` + `CHANNEL.md` generated. No number moved
- (`phase3-files-numbers.ts`, frozen inputs, diff empty over 3,832 lines); `build:index`
- +0.5 %. First form save or toggle per channel may show a one-time key-reorder diff in
- `transcripts/` — expected.
-
-**Gates on `ef88ac4d`:** tsc clean; common 1723; editor unit 67; scripts 156 + 1 skip; mcp
-219; editor and export builds green (on `7dfd7508`, code-identical). Full e2e:
-`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`)` (3a's branch: 280/280 over 41 spec files; 4b's merged tip: the plan's
-34 specs, 190/190).
-
-**Next:** the release-3 rollout to :3001 (editor only — `git diff 1e27f7c3..ef88ac4d -- umtool`
-is empty and umtool's one `common` import, `lib/momentUrl`, is unchanged), then slice 3b
-(`stages/*` status logic, `VideoPanel.tsx` → per-operation panels).
-
-**Still owed (operator):** the Anilyzer **production** deploy (preview is up, see below); the
-other five sites to corpus spec 4; the LM chat-only tier (shipped, unconfigured). **Owed
-(code):** slice 3b (the nine `stages/*` cards' status logic + the `VideoPanel.tsx` split); the
-JSON writers still on the per-pid temp name — the record's 14 (16 write sites in 13 files,
-recounted) plus the export build's two page writers (`buildIndex.ts:971`, `buildStats.ts:220`),
-`maybeMissingStore` and `rosterStore` first (per-channel files written from several lanes);
-the two per-module-copy write counters (`metadataScanStore.ts`, `autoQueueState.ts`).
+**Live on :3001 (2026-09-24, 18:42):** still `9ab10d77` (release 3), `BUILD_ID`
+`XsKaA_drqdAbTguxUGVsn`. **Release 4 is on `main` but NOT rolled out**; the next release's
+prelude rolls it out. The one-sync md5 sweep from the release-3 rollout is still owed.
+YouTube held a 429 cooldown across three attempts (18:43, 19:13, 19:41). See the
+[rollout record](one-core-phase-3.md#rollout-2026-09-24--9ab10d77-live-on-3001).
+
+**Last updated:** 2026-09-24 (evening). **Release 4 is on `main` @ `e172749b`, and one-core
+Phase 3 is COMPLETE. Phase 4 is next.** The prelude was plans only: `4130aca1` (the rollout
+record) and `bbad0977` (the owed sync sweep). The three plan files `77f63356`, `42caf3cc` and
+`cc89abfa` were filed during the release. Three worktrees were cut off `4130aca1` and merged
+in this order:
+- `one-core/phase-3-p` → `77a32de2`;
+- `one-core/phase-3-w` → `ddad13f4`, which merged P and re-gated as `44dd843f`;
+- `one-core/phase-3-s3b` → `e172749b`.
+
+Release record:
+[`one-core-phase-3.md`](one-core-phase-3.md#release-2026-09-24-evening--rollout-prelude-slice-p-slice-w-slice-3b),
+with the P, W and 3b "as shipped" sections above it. What is still open is in
+[`one-core-phase-3.md`](one-core-phase-3.md#next--phase-4). Anchors are in
+[`FACTS.md`](FACTS.md#one-core-phase-3-release-4--slices-p-w-3b-verified-2026-09-24-main--e172749b).
+Previous: release 3, slices 3a and 4b, on `main` @ `ef88ac4d`
+([record](one-core-phase-3.md#release-2026-09-24--rollout-slice-3a-slice-4b)), live on :3001
+as `9ab10d77`.
+
+- **Slice P: /channels rack polish.**
+ - One layer ladder (`channels/components/rackLayout.ts`: popover > thead > group header >
+ identity, plus the deck). Channel rows no longer paint over a group's station buttons.
+ - Four audit fixes: pinned group controls, an Advanced panel that scrolls into view, the
+ section rule as an inset shadow, and the region as `isolate`.
+ - The Transcribe station counts `downloadedNoTranscript` + `downloadedAutoSubsOnly`, minus
+ exclusions, for every handling, and queues exactly those ids as two bucket jobs.
+ - No number moved (`phase3-view-numbers.ts`, diff empty).
+- **Slice W: one write idiom.**
+ - `writeFileAtomic` / `copyFileAtomic` sit under `writeJsonAtomic`, with one per-path chain
+ and one unique temp name.
+ - 26 sites were folded (19 JSON, 7 text/binary), and both per-module-copy write counters
+ are deleted. A failed write no longer leaves a temp file behind.
+ - No byte moved (`phase3-writers-numbers.ts`: 315 samples equal the old idiom's bytes, diff
+ empty).
+- **Slice 3b: the video page's chore cards, one module each.**
+ - `VideoPanel.tsx` went from 1,839 to 455 lines, the assembly only. It now draws 15 modules
+ under `videos/[id]/components/cards/`, pinned by `lib/videoChoreCards.ts` and its test.
+ - The stage half needed nothing: the flow work had already made `computeStageStatuses` /
+ `computeChannelFlow` the one fold.
+ - Labels are identical (126 entries, same md5).
+
+**Gates.**
+- On `e172749b`, re-run for the record: editor unit 72/72.
+- On 3b's merged tip `2e322e37`, whose tree equals `e172749b`'s: tsc clean, common 1738,
+ scripts 156 + 1 skip, both builds green, the 19 video-page specs 108/108.
+- On W's merged tip `44dd843f`: mcp 219, the 20 writer specs 140/140.
+
+Full e2e on `e172749b`: editor **<pending>**; export **<pending>** (worktree
+`one-core-phase-3-s3b` detached at `e172749b`, ports 3311/3310, logs
+`final-e2e-r4-editor.log` / `final-e2e-r4-export.log`)
+
+**Next.**
+1. **The exports-off release**, [`site-exports-off.md`](site-exports-off.md): a per-site
+ option, off on the operator's five published sites, which need a rebuild and a deploy.
+2. **The Rumble slice**, [`rumble-sweep-pacing.md`](rumble-sweep-pacing.md): step 0 is
+ `--impersonate chrome` through a per-platform args table (the embed endpoint 403s every
+ download, upstream #17496); step 2 is pacing the full sweep. The operator applied step 1
+ on 2026-09-24.
+3. **One-core Phase 4** (CLI, entry points, config, docs). `buildDeployCore.ts` imports
+ nothing from `editor/**`. `PUBLISH.md` does not exist yet.
+
+The release-4 rollout and the owed one-sync sweep ride in the prelude of whichever release
+comes next.
+
+**Still owed (operator):**
+- the Anilyzer **production** deploy (the preview is up, see below);
+- the other five sites to corpus spec 4;
+- the LM chat-only tier (shipped, not configured);
+- the **188 orphan temp files** in `transcripts/`, listed by `find transcripts -name '*.tmp-*'`.
+ 175 of them are `.auto-queue/state.json.tmp-*` from the 2026-09-11 full-disk day. They are
+ safe to delete while nothing runs.
+
+**Owed (code):**
+- `transcribeOne.ts:173` writes the remote transcript with no temp. It is the one real
+ `writeFileAtomic` candidate left.
+- The export page writers `buildIndex.ts:971` / `buildStats.ts:220` need restructuring,
+ which is out of W's scope.
+- The deferred follow-ups in the phase-3 "Next" section.
**Previously:** 2026-09-22 — **the release *curated-tags follow-ups + debts sweep* is on
`main` @ `766e0873`.** Base `4ac8ceda`; three branches merged in the order
@@ -1446,8 +1477,9 @@ of THAT plan, not of PLAN.md's phases above):
| 0 · Guardrails | **shipped** 2026-09-07 | |
| 1 · One scheduler | **shipped**; gate A passed 2026-09-13, **gate B done 2026-09-23** | Records in `one-core-phase-1.md`. |
| 2 · One `ArchiveReader` | **shipped** (on `main` since 2026-09-13) | |
-| 3 · Views in the core | **slices 1, 2, 4a shipped** (2026-09-15, 2026-09-23, 2026-09-23) | Slice 3 and slice 4b are the next release. `one-core-phase-3.md`. |
-| 4–6 | not started | Phase 4's `settings example` already exists (`common/bin/settings-example.ts`). |
+| 3 · Views in the core | **COMPLETE 2026-09-24** (`main` @ `e172749b`) | Slices 1, 2, 4a, 3a, 4b, P, W, 3b. `one-core-phase-3.md`. Release 4 is not yet live. |
+| 4 · CLI, entry points, config, docs | **next** (after exports-off and Rumble) | `buildDeployCore.ts` imports nothing from `editor/**`; `settings-example.ts` and `file-schemas-docs.ts` carry `--check`; no `PUBLISH.md` yet. |
+| 5–6 | not started | |
**Three corrections to this document's own claims, verified against the code 2026-07-29.**
Exploration for Phase 2 found the planning docs describing more unbuilt work than exists:
@@ -1523,6 +1555,60 @@ parallel task), Phase 0 transcription benchmark (a separate bottleneck).
## Decisions log
+**One-core Phase 3 is closed (2026-09-24, evening, `main` @ `e172749b`).** Release 4 (P, W,
+3b) ends it. Every item in `one-core.md` §Phase 3 is shipped or recorded as not needed. The
+stage half of item 3 was already done, and item 4's mtime freshness has no reader. Phase 4 is
+next, after the exports-off and Rumble releases. Release 4 was merged but not rolled out: the
+next release's prelude does that, as release 3's did.
+
+**The rack's rows-over-controls bug was a z-index tie, and two commits made it (2026-09-24,
+slice P).** `278d4463` shipped the rack plan's order as 30/20/10 (thead / group header /
+identity). `20ee34db` (2026-09-13) then lifted the pinned Slug cell to z-20 to close a
+sub-pixel seam against the checkbox cell. That put it on the group header's level, so every
+later row's slug band painted over the header, in DOM order. The bump was never needed. The
+Slug cell pins 4 px inside the checkbox cell (`left-8` < `w-9`), and at one z-index it already
+covers the overlap in DOM order. The fix is one named ladder in `rackLayout.ts`, a unit test of
+the width invariant and the order, and no `z-<n>` literal anywhere else under
+`channels/components/`. A second, unstated tie (popover and thead both at z-30) went with it.
+
+**The Transcribe station counts both buckets and queues by id (2026-09-24, slice P, review
+F1).** Buckets are decided by files, never by handling. `downloadedNoTranscript` is whisper
+work for every channel, so the old `handling !== "transcribe"` refusal was wrong. The figure
+is `downloadedNoTranscript` + `downloadedAutoSubsOnly`, minus the download exclusions. The
+button queues exactly those ids as two bucket jobs. It does not run the `whisper-all` scan,
+because `downloadedNoTranscript` does not filter excluded ids, and a scan would transcribe a
+video downloaded before it went private: one more than the label said. The scan runs only for
+a channel with no report. The dedupe spans all three kinds. The rack's band still counts
+`downloadedNoTranscript` alone. That divergence is on purpose: the station counts what its
+button queues.
+
+**Slice 3b was reduced to the VideoPanel split (2026-09-24).** The inventory's premise was
+stale. The nine `stages/*` files hold no duplicated status logic, because the flow work had
+already made `computeStageStatuses` / `computeChannelFlow` the one fold. The video page's
+registry panels already existed (`videoOperationPanels.ts`). What remained was the chore
+cards, which no registry entry owns. They were moved verbatim, one module per card, and
+nothing on the page changed. The assembly is 455 lines, not ~300. The literal JSX was kept
+over a list-driven render because every gate differs, and `videoChoreCards.ts` + its test pin
+the two together.
+
+**Slice W left five temp names by name (2026-09-24).** The two export page writers
+(`buildIndex.ts:971` streams, `buildStats.ts:220` joins by hand) need restructuring rather
+than a fold. `transcode.ts:31` and `transcribeOne.ts:142` name an external process's output
+file, so there is nothing to fold. `transcribeOne.ts:173` writes the remote transcript with no
+temp at all, and it is the real candidate for later. The 188 orphan temps in `transcripts/`
+are corpus files. They were left for the operator.
+
+**Exports-off and Rumble are the next two items (2026-09-24).** Both were filed while release
+4 ran: [`site-exports-off.md`](site-exports-off.md) and
+[`rumble-sweep-pacing.md`](rumble-sweep-pacing.md). Neither was folded into release 4. Its
+slices were already cut, exports-off needs a five-site rebuild and deploy, and the Rumble fix
+edits `runYtdlp.ts`, which W was touching.
+
+**The one-sync md5 sweep stays owed (2026-09-24).** It is the last step of the release-3
+rollout: one sync, then a sweep showing that only the synced channel's `config.json` moved.
+All three attempts were refused, because auto-download kept re-arming a YouTube 429 cooldown.
+No non-YouTube channel was substituted. It rides in the next rollout prelude.
+
**The rollout prelude went before the worktrees (2026-09-24).** Release 2 was put live on
:3001 — and the two commits it needed (`c0a90a37`, `1e27f7c3`, both the backfill lane form's
stale re-acquire sentence) landed on `main` — *before* `one-core/phase-3-s3a` and `-s4b` were
diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md
@@ -1053,57 +1053,213 @@ detached from the worktree root: **108 passed, 0 failed, exit 0, 6.5 min**, firs
Labels, `4130aca1`'s `VideoPanel.tsx` against the merged tree: 126 entries each, identical,
md5 `3f89e5c1c9f79ad1a04ceea4568f0500` both.
-## Next release — slice 3b and Phase 4 (inventory kept from 2026-09-23)
-
-Slices 3a and 4b shipped in the release above (2026-09-24); the slice 3b bullets and the
-deferred follow-ups below are what remains of this inventory — except the first follow-up
-(the lane form's "836" hint), which the prelude closed (`c0a90a37`, `1e27f7c3`). The facts
-below were checked against `ae2fa5a9` on 2026-09-23.
-
-**Slice 3 — one drawing per noun.**
-
-- *Channel row.* Two `ChannelsTable`s — `editor/app/channels/components/ChannelsTable.tsx`
- (966 lines, the rack) and `editor/app/components/dashboard/ChannelsTable.tsx` (192) — plus
- `editor/app/components/channelWork/ChannelWorkTable.tsx` (178). One table with a column set.
-- *Job in flight.* `jobs/components/RunningJobsList.tsx` (158), `jobs/components/LaneStrip.tsx`
- (88), `operations/components/InFlightList.tsx` (85) and the widget's own
- `ActiveJobsStrip`/`JobRow` (`widget/components/MonitorWidget.tsx:989,1033`) onto the
- `JobRowView` renderer with a `variant`. **`InFlightList` does not draw `JobRowView` today**:
- it draws `AutoQueueKindStatus.runner.inFlight` (`InFlightList.tsx:16,20`), so it needs a
- mapping to the row, not just a re-skin.
-- *Channel pipeline.* The status logic in the nine files under
- `channels/[slug]/components/stages/` (eight `*Stage.tsx` cards plus `OperationWork.tsx`);
- each stage keeps its body. `one-core.md` said eight.
-- *Video page.* `channels/[slug]/videos/[id]/components/VideoPanel.tsx` is **1,839 lines**
- (`one-core.md` said 1,586), with download, transcode and transcription still inline;
- `videoOperationPanels.ts` (`…/videos/[id]/lib/`) is the enumeration to split along.
-
-**Slice 4b — the rest of the schemas.** `site.json`; channel `config.json`, with its mutable
-sync state (`lastSyncedAt`, `lastFullSweepAt`, …) declared as such and the file NOT split;
-the sidecar `X.ts` / `X-server.ts` pairs → one `sidecar(name, schema)` each (read, atomic
-write, mtime freshness, the `transcript.<x>.<y>` naming guard). Slice 4a's pattern is the
-template: `settingsField(coerce)` over the existing sanitizer, no `.default()`, no
-`.passthrough()`, zod kept out of `"use client"` import graphs.
-
-**Deferred follow-ups, each checked still open on `ae2fa5a9`:**
-
-- The backfill lane form's hint text still says *"836 videos still have media on disk and
- ~76,270 would need re-downloading"* (`operations/components/settings/LaneSettingsForm.tsx:101-102`);
- the page measured 679 reachable / 74,046 no media at gate B. A stale measured figure in
- copy — fix the copy or derive it, in a code slice.
+## 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 **<pending>**; export **<pending>** (run from the worktree `/home/user/Projects/one-core-phase-3-s3b`, detached at `e172749b`, ports 3311/3310, 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-<pid>`;
+- three sidecar temps;
+- three whisper `transcript.tmp-<pid>` 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-65`) and never filters `handling: "youtube"`, so its `missingInput`
- is the whole bucket, not what a backfill pass could reach.
-- `usePolledPayload`'s hang recovery is not unit-tested: the editor unit suite has no React
- harness, so `lib/usePolledPayload.test.ts` pins `pollTimeoutMs` only (one test).
-- The nested settings blocks (`DigestSettings`, `DiarizationSettings`, …) are each one
- sanitizer-backed `settingsField`, not zod objects; their docs are the `*_FIELD_DOCS`
- records. Converting them is optional work, not a bug.
-- `editor/scripts/measure-nav.mjs:64-69` still requests the eight OLD API paths. They answer
- through the rewrites, so it measures rewrite + view; say so if its numbers are compared
- with an older run.
+ (`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 any more (slice 4a, behaviour changes).
+ 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)
diff --git a/plans/one-core.md b/plans/one-core.md
@@ -311,12 +311,14 @@ https://jeralyzer.pages.dev/corpus.json` before and after, byte-identical output
### Phase 3 — Views in the core, editor as shell (4 slices)
-> **Status 2026-09-24.** Slice 1 shipped 2026-09-15 (`838da4a`). Slice 2 shipped 2026-09-23,
-> merged as `8d6e84f6`; slice 4a (settings.json only) shipped 2026-09-23, merged as `ae2fa5a9`.
-> **Slice 3a (channel row + job in flight) shipped 2026-09-24, merged as `3241fed2`; slice 4b
-> (`site.json`, channel `config.json`, sidecars) shipped 2026-09-24, merged as `ef88ac4d`.**
-> Slice 3b (`stages/*` status logic, the `VideoPanel` split) is next.
-> Records, gates and the next release's inventory: [`one-core-phase-3.md`](one-core-phase-3.md).
+> **COMPLETE 2026-09-24, `main` @ `e172749b`.** Slice 1 shipped 2026-09-15 (`838da4a`).
+> Slice 2 shipped 2026-09-23 (merged as `8d6e84f6`), and slice 4a (settings.json only) the same
+> day (`ae2fa5a9`). Slice 3a (channel row + job in flight) and slice 4b (`site.json`, channel
+> `config.json`, sidecars) shipped 2026-09-24 (`3241fed2`, `ef88ac4d`). Release 4 shipped the
+> same evening: slice P (/channels rack polish, `77a32de2`), slice W (one write idiom,
+> `ddad13f4`) and slice 3b (the `VideoPanel` split, `e172749b`). Release 4 is not yet live
+> on :3001. Phase 4 is next.
+> Records, gates and what is still open: [`one-core-phase-3.md`](one-core-phase-3.md).
1. **`common/views/`**: move `buildActiveJobs`, `buildWorkers`, `lanes.ts`,
`channelFlow.ts` + `stageStatus.ts`, the widget sync builder and `pulse` out of
@@ -358,6 +360,28 @@ https://jeralyzer.pages.dev/corpus.json` before and after, byte-identical output
job, so only its runner job's buttons became `JobRowActions` — the "replaces …
`LaneStrip` …" above was wrong. The `stages/*` status logic (nine files, not eight) and
the `VideoPanel.tsx` split (1,839 lines, not 1,586) are slice 3b, not shipped.*
+ *As shipped for the channel pipeline and the video page (slice 3b, merged `e172749b`,
+ 2026-09-24):*
+ - ***The stage half was already done by the flow work, and nothing was changed.*** *The
+ nine `stages/*` files hold no duplicated status logic. `computeStageStatuses`
+ (`common/views/pipeline/stageStatus.ts:192`) and `computeChannelFlow`
+ (`channelFlow.ts:220`) are the one fold. Each is called once, in
+ `channels/[slug]/page.tsx:256-264` and `:308-315`. Every stage card takes derived id
+ lists and sums as props and keeps only its form state.*
+ - ***The video page's registry split already existed.*** *`videoOperationPanels.ts` and
+ `OperationPanel.tsx` render digest, diarization and attribution. What was still inline
+ in `VideoPanel.tsx` was the video CHORES, which no registry entry owns. They were split
+ verbatim, one module per card: 15 modules under `videos/[id]/components/cards/`, plus
+ `Heading.tsx` and `videoFiles.ts`, with `VideoNavStrip` and `PipelineStatusStrip`
+ placed beside the panel.*
+ - ***`VideoPanel.tsx` is the assembly, 1,839 → 455 lines, not ~300.*** *The `VideoPanel()`
+ function alone is 360 lines of gates, summaries and the fifteen `PipelineStageCard`
+ wrappers. The rule was to keep order and gating exactly. Every card's gate and props
+ differ, so the literal JSX was kept rather than driving it from a list.
+ `lib/videoChoreCards.ts` is that list: the fifteen chores in render order, each with
+ why it is a chore and not an operation. Its test reads `VideoPanel.tsx` as text and
+ pins the rendered order and the `./cards/` imports to the list. Labels are unchanged: 126
+ entries, identical md5.*
4. **One settings writer and one schema.** Adopt one schema library (recommend `zod`;
veto if unwanted) for `settings.json`, `site.json`, channel `config.json` and every
sidecar. `lib/settings.ts`'s ten `sanitizeX` + eight `defaultX` + ~40 clamp constants
@@ -405,15 +429,48 @@ https://jeralyzer.pages.dev/corpus.json` before and after, byte-identical output
listed in the slice record. The chain is per process, so a CLI running beside the editor is
not covered. `savedVideo`, `clipWindow`, `posts`, `transcripts` and `digestContext` stay
outside `sidecar()` by name; only their JSON writes moved to the shared writer.*
+ *As shipped for the remaining writers (slice W, merged `ddad13f4`, 2026-09-24):
+ `common/lib/jsonFile-server.ts` gained `writeFileAtomic(file, string | Buffer, {mkdir,
+ mode})` and `copyFileAtomic(src, dest, {mkdir})`, and `writeJsonAtomic` is now `jsonText`
+ → `writeFileAtomic`. All three use the one per-path chain on `globalThis` and the one
+ unique temp name. **26 sites were folded**, byte for byte: 19 JSON write sites (14 of the
+ 16 above, including the roster and maybe-missing; the two failed-transcriptions writes are
+ text. Plus metadata-scan, `autoQueueState`, the two cue normalizers and the remark
+ literal) and 7 text and binary ones (failed
+ transcriptions ×2, the playlist, the 0o600 cookie jar, the saved-video cross-device
+ move, the release cut, the VTT promote). **Both per-module-copy write counters are
+ deleted** (`metadataScanStore`, `autoQueueState`). A failed write now removes its temp
+ file. Left by name: `buildIndex.ts:971` (the streaming page writer) and
+ `buildStats.ts:220`, which need restructuring rather than a fold; `transcode.ts:31` and
+ `transcribeOne.ts:142`, which name an external process's output file; and
+ `transcribeOne.ts:173`, which writes the remote transcript directly with no temp.
+ `phase3-writers-numbers.ts`: 315 samples, all equal to the old idiom's bytes, diff
+ empty.*
Deleted: six payload builders from the app, eight API routes, two channel tables (the
dashboard's, and `ChannelWorkTable`'s own `Row` — slice 3a), three job renderers (the
widget's private `JobRow` and its bars, `RunningJobsList`'s card and `InFlightList`'s line
now draw `JobRow`; `LaneStrip` keeps its lane line — slice 3a), nine stage status
derivations (slice 3b), ~1,400 lines of hand sanitizers.
+*As shipped: the nine stage derivations were already gone, because the flow work had moved
+them into `computeStageStatuses`. 3b deleted nothing of that kind. What it removed from
+`VideoPanel.tsx` was 1,424 lines, moved into card modules. Slice W deleted the two write
+counters and the private tmp + rename code at 26 write sites.*
### Phase 4 — CLI, entry points, config, docs (3 slices)
+> **Next** (after the exports-off and Rumble releases, see
+> [`one-core-phase-3.md`](one-core-phase-3.md#next--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/*`, `yt-dlp-transcript-common/*`). Its two callers
+> are `sites/lib/buildAction.ts` and `deployAction.ts`. Item 1 has no editor import to cut.
+> The `@aws-sdk/client-s3` and `@aws-sdk/lib-storage` dependencies are in
+> `editor/package.json` only, so they move to `common/` with it.
+> - `common/bin/settings-example.ts` and `common/bin/file-schemas-docs.ts` both already carry
+> `--check`.
+> - `PUBLISH.md` does not exist yet.
+
1. **`buildSite` moves to the core.** `editor/app/sites/lib/buildDeployCore.ts` →
`common/publish/build.ts` (`buildSite(id, opts)`, `deploySite`, `buildAll(mode)`,
`composeHub`, `composeHomepage`). The editor actions call it as jobs; `docker/build-site.sh`