commit f924bf1e3d5d345acc4426cd87b020e3193e1ea1
parent edd9fb3bd831490e1d32a045d137150399feb4da
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 16:50:10 -0400
plans: release 3 record — rollout, slice 3a, slice 4b (main @ edd9fb3b)
one-core-phase-3.md: "Release 2026-09-24" (prelude, merge order, scope, gates, record
corrections; final suites left as a placeholder); next-release heading retitled to slice 3b.
one-core.md: item 3 and item 4 "as shipped" italics, status blockquote, Deleted line.
FACTS.md: slices 3a + 4b section and its stale-anchor list; widgetActionableRows anchor fixed.
STATE.md: head, decisions log (2026-09-24), still owed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
| M | plans/FACTS.md | | | 473 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | plans/STATE.md | | | 174 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- |
| M | plans/one-core-phase-3.md | | | 85 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- |
| M | plans/one-core.md | | | 66 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
4 files changed, 750 insertions(+), 48 deletions(-)
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -5419,7 +5419,7 @@ Every `file:line` below was grepped at `ae2fa5a9`. Records: `one-core-phase-3.md
`api/auto-queue/status`, `api/scheduler/status`, `api/widget/{sync,actionable,cleanable}`.
The two route-resident builders became pure views: `common/views/cleanable.ts`,
`common/views/widgetActionable.ts`; the per-channel row mapping is one exported
- `widgetActionableRows` (`editor/app/lib/actionable/loadActionable.ts:162`), called by the
+ `widgetActionableRows` (`editor/app/lib/actionable/loadActionable.ts:80`), called by the
view handler and by `plans/tools/phase3-view-numbers.ts`.
- **One client poller.** `editor/app/lib/usePolledPayload.ts` (moved from `widget/lib/`):
serial polling (the next tick is scheduled only after the previous one settles, `:103`),
@@ -5506,3 +5506,474 @@ instead:
`common/lib/settings.ts:235-250`.
- `common/jobs/autoQueuePolicy.ts:684-749` (`defaultHeldFor`, `sanitizeAutoQueue`) →
`common/lib/autoQueueSchema.ts:204-277`.
+
+## One-core Phase 3 slices 3a + 4b (verified 2026-09-24, `main` @ `ef88ac4d`)
+
+Records: `one-core-phase-3.md`, "Release 2026-09-24 — rollout, slice 3a, slice 4b" and the two
+"as shipped" sections.
+
+### One drawing per noun — slice 3a (merged `3241fed2`)
+
+Every `file:line` below was grepped at `3241fed2`; none of the cited code files changed
+between `3241fed2` and `ef88ac4d`, so they hold at `ef88ac4d`. Record: `one-core-phase-3.md`, "Slice 3a,
+as shipped" (deviations 1–13 and the eight review fixes).
+
+**The channel row**
+
+- **One builder, no config on the wire.** `common/views/channelRow.ts`: `ChannelRowView`
+ `:76-116`, `buildChannelRowView` `:176-218`. The header `:30-34` is the rule — the view
+ carries the six config fields a cell draws, never `config` (cookie paths, yt-dlp args,
+ download filter). `reportStateOf` moved here from `loadActionable.ts` (`:139-150`,
+ three states `missing`/`stale`/`current`); `channelVolumeOf` (the page's old `volumeOf`)
+ `:162-174`; `neutralChannelPriority()` `:51-60` is what a table with no Tier column hands
+ the builder. The optional `mediaLocationLabel` (`:129-133`, used `:206-211`) exists so the
+ dashboard still names the badge's location from the media TARGET
+ (`editor/app/page.tsx:106-108`); every other caller gets the volume's label.
+- **One set of per-channel counts.** `common/views/actionableCounts.ts` (`undownloadedCountOf`
+ `:40` … `cleanExtraFormatsBytesOf` `:106`); `loadActionable.ts:46-68` are one-line
+ `actionable*` wrappers over them, so no caller moved. The dashboard's Videos / Digest-to-do
+ cells and the widget's "Needs work" strip read the same functions; the dashboard's "Needs
+ work" SSR seed is `buildWidgetActionablePayload(widgetActionableRows(rows))`
+ (`editor/app/page.tsx:115`) — the same two calls the `widgetActionable` view makes.
+- **Columns are a REGISTRY BY ID.** Ids, sort keys, `PipelineColumn` and the three presets are
+ in the directive-free `editor/app/channels/components/channelColumnPresets.ts`
+ (`ChannelColumnId` `:8`, `ChannelSortKey` `:30`, `PipelineColumn` `:46`, `RACK_COLUMNS`
+ `:63`, `DASHBOARD_COLUMNS` `:79`, `WORK_COLUMNS` `:89`); the cells are
+ `CHANNEL_COLUMNS` in the `"use client"` `channelColumns.tsx:114` (keyed by every id but
+ `select` and `pipelines`, which the table draws itself), which re-exports the presets for
+ client importers (`:19-28`). **WHY the split: 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. **tsc cannot catch it**;
+ only a render does. A server importer must take ids/presets from `channelColumnPresets.ts`
+ (as `ChannelWorkTable.tsx:13-15` does). Why ids and not column objects:
+ `channelColumns.tsx:30-39` — a function cannot cross the server/client boundary as a prop;
+ what a server caller puts in a cell travels as data or a rendered element in `rowExtras`.
+- **`ChannelsRack` vs `ChannelsTable`.** `ChannelsRack.tsx` (250 lines, header `:23-28`) is
+ the /channels chrome: focus line, volume bar, instrument bar (grouping, band legend), the
+ one scroll region and its `--thead-h` measurement (`:132-138`), selection state and the
+ free-up selection, and the `ChannelSelectionDeck` **outside** the overflow box (`:229`). It
+ draws `ChannelsTable` with `RACK_COLUMNS`, `sticky` and `theadRef` (`:210-226`).
+ `ChannelsTable.tsx` (`:183`) is rows, cells, sort, group-header rows and selection
+ checkboxes for all three callers: the rack, `DashboardCockpit.tsx:134-142`
+ (`DASHBOARD_COLUMNS`, no sort, no selection) and `ChannelWorkTable.tsx:106-118`
+ (`WORK_COLUMNS`, `extra` dropped when a section has none, `:85-87`). Group-header
+ `colSpan` is counted from the drawn columns, `pipelines` counting one per pipeline
+ (`ChannelsTable.tsx:246-254`) — no literal.
+- **Dimming is rack-only and per cell.** `ChannelsTable.tsx:527-530`: `opacity-60` only when
+ `sticky` AND (build-excluded OR tier `paused`). The comment `:513-526` gives both rules:
+ never on the `<tr>` (opacity makes a stacking context that traps the Tier cell's popover;
+ that cell's registry entry is `dim: false`), and never outside the rack (a build-excluded
+ channel in an "undownloaded" work list is not out of any pipeline).
+- **`ChannelWorkTable` is a server shell.** `ChannelWorkTable.tsx:18-23`: `SectionConfig`'s
+ `primaryAction` is a function, so the section, heading and empty paragraph stay on the
+ server and each row's count / extra / actions cross as `rowExtras` (`:70-84`). Its rows are
+ `buildChannelRowView` with neutral inputs (`workRowOf`, `:128-139`). The aria contract the
+ four specs locate by is in the header (`:25-33`); never `role="status"` in it (`:35-37`).
+- **Group sections reach the client as slugs only.** `ChannelGroupSectionView`
+ (`common/views/channelGroupSections.ts:66-72`) is a section with `channels: {slug}[]`;
+ `/channels` projects to it before render (`editor/app/channels/page.tsx:314-325`), so no
+ `ChannelConfig` rides along with a section. `ChannelsRack`, `ChannelsTable`,
+ `ChannelGroupLine` and `ChannelGroupHeaderRow` all take the view type.
+- **`PrioritizeButton`** is `editor/app/components/actions/PrioritizeButton.tsx`, used by
+ `DashboardCockpit.tsx:19,120` (it was a private function in the deleted dashboard table).
+ The dashboard's Sync is `ChannelSyncButton` (same `sync <slug>` name).
+- `MediaBadgeInput` survives as an alias, `= ChannelRowMedia`
+ (`editor/app/components/MediaLocationBadge.tsx:35`); its old `Pick<…>` is gone.
+
+**The job in flight**
+
+- **One row, three variants.** `editor/app/jobs/components/JobRow.tsx`: `JobRow` `:308`,
+ `variant: "table" | "card" | "compact"` (`:44`); `show?: Partial<JobRowShow>` merges over
+ per-variant `DEFAULTS` (`:46-80`, merge at `:329`). `JobRowShow` (`:32-42`): `tasks`,
+ `jobBar`, `headingProgress`, `eta`, `actions`, `links`, `log`, `elapsed`, **`statusPill`**
+ (off where every row is running by construction). The `table` variant ignores `show`
+ (`:330`). `JobRowHeading` (`:194`) is private.
+- **One action rule.** `JobRowActions` `:143-176`: Retry a failed replayable job, Reorder a
+ queued one, Drain a drainable running one, Cancel running/queued, Force-release running OR
+ stuck — `forceRelease="stuck"` (`:145-150`) narrows it to stuck only.
+- **Who draws it.** `JobsTable.tsx:336-340` (`table`); `RunningJobsList.tsx:27-34` (`card`,
+ `show={{ log: true }}`); the widget's `ActiveJobsStrip`
+ (`widget/components/MonitorWidget.tsx:985`, row at `:1014-1025`, `compact`,
+ `actions: false`, `links: false`); `operations/components/InFlightList.tsx:70-84`
+ (`compact`, `links`, `elapsed`, `statusPill: false`, `tasks: false`). **`LaneStrip` is not
+ a `JobRow`** (`LaneStrip.tsx:17-20`): it keeps its lane line (section, `<h2>`, dot, word,
+ note) and uses only `JobRowActions` with `forceRelease="stuck"` (`:84`).
+- **The bars** are `JobProgressBars.tsx` alone: `TASK_KIND_VERB` `:23`, `TASK_KIND_GLYPH`
+ `:40`, `METRIC_PREFIX` `:164`, `compactProgressText` `:182`. Compact keeps the widget's
+ glyphs ("↓ 5/10"), full keeps the suite's names ("Transcribing id") — header `:17-20`.
+- **`fromInFlight`** (`common/views/jobRows.ts:349-365`, comment `:337-348`) maps an
+ `AutoRunnerInFlight` unit to a `JobRowView`. **Id rule** (`:354`): the unit's `jobId` when it
+ has one, else synthetic `${runnerJobId ?? kind}:${videoId}`. **`inRegistry`** is
+ `Boolean(unit.jobId)` (`:361`); `status` is always `"running"`; a unit with a `note` (the
+ metadata-scan unit) drops `videoId` and says the note. `source: "runner"` (`:363`; union at
+ `common/views/jobRowView.ts:96-100`): `JobRow`'s `linksToJob` (`JobRow.tsx:125-130`) links
+ `Job <id>` for a runner row only when `inRegistry`. `InFlightList.tsx:15-20` maps each lane
+ to its unit's JOB kind (`auto-download-unit` / `auto-transcribe` / `auto-digest` /
+ `auto-backfill`), a `Record<AutoQueueKind, string>` mirroring `autoRunner.ts` because a
+ client cannot import that module.
+- **`inFlight.jobId` is set in exactly two places**: the two `onChildJob` callbacks in the
+ runner loop, `common/controller/autoRunner.ts:1896` (metadata scan) and `:1911` (download
+ `launchUnit`). Field declared `:187-189`. The private `childJobIds` map (`:1245`) stays
+ private. A transcription, digest or backfill unit is a task on the runner's own job and
+ never gets one.
+- **`formatElapsed` lives in the editor**, `editor/app/lib/formatElapsed.ts:4` ("42s",
+ "3m07s", "2h05m"), re-exported by `operations/components/dispatch.ts:188-189` so
+ `LaneHeader` did not move. Not `common/lib/format.ts`: slice 4b owned `common/lib` during the
+ release, and it is not `formatDuration` (a media duration that renders 0 as "").
+
+**Tools and history**
+
+- **The numbers tool does not cover `autoQueueStatus`.** `plans/tools/phase3-view-numbers.ts`
+ dumps `widgetActionable`, `cleanable`, `widgetSync` only; slice 3a's one wire change (the
+ optional per-unit `jobId` in `autoQueueStatus`) is outside its diff by construction.
+- **Bisect hazard: `f63260d8` up to, not including, `c944475f`** (`f63260d8`, `0d20c42a`,
+ `797078c2`, `4a784486`): every operation page throws at render
+ (`WORK_COLUMNS.filter is not a function`). History is not rewritten;
+ `git bisect skip f63260d8^..c944475f^`.
+
+### The rest of the schemas — slice 4b (merged `ef88ac4d`)
+
+Every `file:line` below was grepped at `7dfd7508`, whose code is byte-identical to
+`ef88ac4d` (the merge adds only the `b3cebcf8` record lines). The record is `one-core-phase-3.md`, "Slice
+4b, as shipped" (deviations 1–10, behaviour changes, review fixes `973e59ee`).
+
+**One JSON reader, one atomic writer — `common/lib/jsonFile-server.ts`**
+
+- `readJsonFile` `:72` / `readJsonFileSync` `:84` return `{ok:true, value}` or
+ `{ok:false, reason: "absent"|"unreadable"|"unparseable"}`, and never throw. `absent` covers
+ ENOENT and ENOTDIR.
+- `writeJsonAtomic(file, value, {indent, newline, mkdir})` `:158`. The value is serialised at
+ the call. Writes to one `path.resolve`d path are CHAINED (`:165-176`): the last write issued
+ is the one that lands, and a rejected write does not block the next.
+ `writeJsonAtomicSync` `:183` is for the three synchronous stores (`chartsStore`,
+ `aliasesStore`, `curatedTagsStore`); it has no chain and uses the same temp name.
+- `withJsonFileLock(file, fn)` `:205` serialises a whole READ-MODIFY-WRITE cycle. Only
+ `patchChannelConfig` takes it.
+- **Temp name:** `${file}.tmp-${pid}-${seq}-${hex8}` (`tmpPathFor` `:132-135`, 4 random
+ bytes).
+- **State is on `globalThis.__yttJsonFile__`** (`:112-129`: `tmpSeq`, `chains`, `locks`).
+ **WHY:** Next can load the module once per bundle layer. The runners are armed from
+ `instrumentation.ts` and the form saves are server actions, so two module copies would
+ each start `tmpSeq` at 0 and each hold their own locks. That is the house pattern
+ (`jobs/registry.ts`, `controller/autoRunner.ts`). `jsonFile-server.test.ts:67` imports a
+ second module instance and checks that the two share one chain.
+- **Bytes are per caller, not a house style.** `jsonText` is at `:105`, and `indent`/`newline`
+ are options (header `:35-39`):
+ - Attribution and diarization: compact, with a trailing newline.
+ - Export pages (buildIndex, buildStats, compose-homepage): compact, no newline.
+ - Chart, alias and tag stores: indented, no newline.
+ - Everything else: indented, with a newline.
+ The six local `function writeJsonAtomic` left in `buildIndex.ts:404`, `buildStats.ts:238`,
+ `bin/compose-homepage.ts:40`, `aliasesStore.ts:23`, `chartsStore.ts:50` and
+ `curatedTagsStore.ts:61` are one-line wrappers that pin those bytes over the shared writer.
+ They are not copies.
+- **Per process, not per machine.** A CLI such as `migrate-channel-priority.ts` running beside
+ the live editor is not covered (header `:26-29`). The rename is still atomic, so there is
+ never a torn file, but the last rename wins.
+
+**Sidecars — `common/lib/sidecar-server.ts`**
+
+- `sidecar(filename, schema, {indent, newline})` `:67` returns
+ `{filename, path, load, write, remove}`.
+ - `load` never throws. Absent, unparseable, a coercion that rejects and a coercion that
+ throws all read as null (`:85-96`).
+ - `write` goes through `writeJsonAtomic` and `remove` is `rm -f`.
+ - `sidecarField(coerce)` `:47` is `z.unknown().transform(coerce)`, which is 4a's
+ `settingsField` without the `.catch`.
+ - A sidecar load does NOT strip unknown keys (`sidecar-server.test.ts:156`).
+- **The naming guard runs at declaration** (`:72-76`): a filename that `SUB_FILE_RE` matches
+ throws at module load, so a misnamed sidecar fails boot and every test that imports it.
+ `SUB_FILE_RE` is exported from `common/lib/videoStatus.ts:88` for this purpose.
+ `SIDECAR_FILENAMES` `:65` is enumerated by `sidecar-server.test.ts:43` (nine names, with all
+ eight modules imported).
+- **No mtime freshness** (header `:26-27`), because no reader consumes it.
+- **The eight declarations.** Each shape check is an exported `coerceX` in the same file, and
+ the old `loadX`/`writeX`/`xPath` names are kept by destructuring the declaration:
+
+ | File | Declaration | Coercion |
+ |---|---|---|
+ | `attribution-server.ts` | `:27` | `:9` |
+ | `availability-server.ts` | `:26` | `:14` |
+ | `diarization-server.ts` | `:20` | `:7` |
+ | `digest-server.ts` | `:95` (machine), `:96` (overrides) | `:40`, `:69` |
+ | `doNotClean-server.ts` | `:16` | `:10` |
+ | `downloadOutcome-server.ts` | `:27` | `:9` |
+ | `excludeTruncatedCheck-server.ts` | `:18` | `:8` |
+ | `transcribeOutcome-server.ts` | `:20` | `:7` |
+
+ The two markers (doNotClean, excludeTruncatedCheck) read ANY parseable JSON, including
+ `null` and `3`, as present (`{setAt:""}`; deviation 8). `availability-server` reads the
+ outcome side through `loadDownloadOutcome`.
+- **Out of `sidecar()` by name:** `savedVideo-`, `clipWindow-`, `posts-`, `transcripts-` and
+ `digestContext-server`. Only the JSON write halves of the first three moved to
+ `writeJsonAtomic`.
+
+**`site.json` — `common/lib/siteSchema.ts` + `site.ts`**
+
+- **The schema:**
+ - `siteFieldsSchema` `:216` has one `settingsField` per key over the existing parsers.
+ - `siteSchema` `:253-273` adds ONE object step: it resolves `defaultGroupId`, drops a
+ membership `groupId` that names no group, and **re-emits every key in `SITE_KEYS` order**
+ (`:270`). **WHY:** zod 4 omits an input-absent key whose transform returns `undefined`,
+ and `parseSite` has always emitted every key, present and `undefined` (comment
+ `:247-252`).
+ - `SITE_KEYS` `:276` is taken from `SITE_FIELD_DOCS` `:92`.
+- **`parseSite(siteId, raw)`** `:280-285` never throws. A non-object or an array reads as `{}`.
+ The `z.object` is built once at load, and `parseSite` sets `siteId` after the parse.
+- **`siteToDisk`** `:290-326` is pure. It ALWAYS writes `siteId`, the three titles,
+ `homeTagline`, `groups`, `defaultGroupId` and `channels`; everything else is written only
+ when it is not the default.
+- **What moved in from `site.ts`:** the `Site` types, `SITE_ID_RE` `:128`, `isValidSiteId`
+ `:130`, `parseSiteChannels` `:137`, `parseSiteUrl` `:162` and `parseRelatedSites` `:173`,
+ plus the docs records `SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS` `:50` and
+ `RELATED_SITE_GROUP_FIELD_DOCS` `:63`. `CHANNEL_GROUP_FIELD_DOCS` is
+ `channelGroups.ts:22`.
+- **`site.ts` is I/O plus resolvers.** It does `export * from "./siteSchema"` (`:33`), so no
+ importer moved.
+ - `getSite` `:174` throws only on a malformed id (`:175-176`) and reads through
+ `readJsonFileSync` (`:180-181`).
+ - `writeSite` `:214`: the throwing validations, then `writeJsonAtomic(…, siteToDisk(…),
+ {mkdir:true})` (`:244-248`).
+ - `siteChannelIndex` `:118`.
+
+**Channel `config.json` — `channelConfig.ts` (pure) + `channelConfigSchema.ts` (zod)**
+
+- **One coercion set, zod-free.** `CHANNEL_CONFIG_COERCIONS` is at `channelConfig.ts:350-396`
+ (`-?` mapped type, so every key must have an entry).
+ - `parseChannelConfig` `:406-414` composes it without zod.
+ - `channelConfigSchema.ts:45-75` wraps the same functions as `settingsField(coerce)`, and
+ `channelConfigSchema` `:84-89` is the transform the server uses.
+ - **WHY two compositions:** six `"use client"` modules value-import `channelConfig.ts`, so a
+ zod import there would ship zod to the browser (deviation 2).
+ - `channelConfigSchema.test.ts:137` pins that the two agree, key order included, over
+ generated inputs. `:38` pins docs = coercions = schema keys, with the sync-state keys
+ inside.
+- **The OMIT rule.** An invalid OPTIONAL key is omitted, never defaulted, so `"k" in config`
+ means the file set it. zod emits a key whose transform returned `undefined` when that key
+ was present in the input, so `stripUndefined` (`channelConfigSchema.ts:78-82`) runs after
+ the object parse. The strip is one level deep: `coerceAudioCheck` (`:313`) and
+ `coerceDownloadFilter` (`:288`) build objects with no undefined members.
+- **Key records.** `CHANNEL_CONFIG_FIELD_DOCS` `:147` (the type's comments moved in),
+ `AUDIO_CHECK_FIELD_DOCS` `:57`, `DOWNLOAD_FILTER_FIELD_DOCS` `:81`, and
+ `CHANNEL_CONFIG_KEYS` `:200` (the emission order and the unknown-key oracle).
+ `CHANNEL_SYNC_STATE_KEYS` `:208-212` holds `lastSyncedAt`, `lastFullDownloadAt` and
+ `lastFullSweepAt`: stamped by sync, sweep and download, never by the form.
+ `isChannelShape` `:268` is the "is it a channel at all" gate.
+- **`*_FIELD_DOCS` count:** 31 records, up from 4a's 24. The seven new ones are three in
+ `siteSchema.ts`, three in `channelConfig.ts` and one in `channelGroups.ts`. The four
+ channel/group records ship to the browser because their modules are client-imported. That
+ is a few KB and no zod, and review accepted it.
+
+**The reader, the strict writer, the patcher — `common/controller/channels.ts`**
+
+- **`readChannelConfigFile(file)`** `:165-170`. It never throws, and answers null for absent,
+ unreadable, not JSON, or not a channel. `readChannelConfig(paths, slug)` `:172` wraps it,
+ and `buildIndex.ts:296` and `buildStats.ts:158` call it directly. The header `:152-157` names
+ the raw readers that bypass it: `channelMedia.ts:179` (the `dataDir` guard) and the legacy
+ migrations (`migrateToSites.ts:101` for `group`, `bin/migrate-channel-priority.ts` for
+ `excludeFromSync`, which also WRITES raw at `:206`).
+- **`writeChannelConfig` is STRICT** (`:474-488`). It parses on the way out, which drops
+ unknown and invalid keys, THROWS on a value with no valid `handling`, and returns what it
+ wrote. **Rule: callers pass a parsed config.** The non-test callers are `createChannel`
+ `:528`, the patcher `:516`, and the two whole-config fallbacks,
+ `relocateChannelMedia.ts:774` and `renameChannel.ts:175`. **The e2e fixtures seed raw on
+ purpose:** `editor/e2e/helpers.ts:429` has its own `writeChannelConfig(slug, raw)`, and
+ `scheduler.spec.ts:15-16` writes its configs directly. Neither goes through this function.
+- **`patchChannelConfig(paths, slug, patch, {unset})`** (`:503-518`) is the only way to change
+ some keys. Under `withJsonFileLock` it re-reads the file NOW, deletes `unset`, assigns
+ `patch` and writes strictly. On a null re-read it writes nothing and returns null.
+ - **Callers:**
+ - `runYtdlp.ts:1814,1820,1828` (the three stamps)
+ - `fetchPosts.ts:190` (`lastSyncedAt` only)
+ - `relocateChannelMedia.ts:771,990`
+ - `storageLocations.ts:635,732`
+ - `renameChannel.ts:169`
+ - `socialActions.ts:59`
+ - the form save `editor/app/channels/actions.ts:317-319` (`unset: CHANNEL_FORM_FIELDS`,
+ `parseChannelForm.ts:37`)
+ - the two exclude toggles `:469-470,486-487`
+ - the scheduler's bulk cadence save `editor/app/scheduler/actions.ts:95`
+ - **What a null patch does:** `storageLocations.ts:732-737` THROWS on it so the ledger rolls
+ the link back. `relocateChannelMedia.ts:764-775` writes the job's own copy instead
+ (deviation 10: the swap has already happened and there is no ledger to roll back).
+ - `updateConfigField` is deleted.
+
+**Generated docs**
+
+- `common/bin/file-schemas-docs.ts` writes root `SITE.md` (198 lines) and `CHANNEL.md` (64)
+ from `renderSiteMarkdown` / `renderChannelMarkdown` (`common/lib/fileSchemaDocs.ts:55,139`).
+ `--check` writes nothing and exits 1 on drift, and `fileSchemaDocs.test.ts:20` makes the
+ same claim.
+- Regenerate with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`.
+ **Never hand-edit either file.** `SITE.md:3` and `CHANNEL.md:3` say so.
+- `SETUP.md:259-260,270` and `AGENTS.md:196-197,225-226` link to them.
+
+**Rules**
+
+- **zod only in `*Schema(s).ts` and `*-server.ts`.** The complete list of zod importers is
+ `settingsFieldSchemas.ts`, `settingsSchema.ts`, `siteSchema.ts`, `channelConfigSchema.ts`
+ and `sidecar-server.ts`, plus their tests. Nothing under `editor/app`, `export/app` or
+ `common/components` imports the new server modules. `grep -rl 'ZodError\|_zod'` over both
+ `.next/static` prints nothing.
+- **Config writes come out in SCHEMA key order.** Before, a form save or toggle wrote the
+ caller's spread order. Nothing changes semantically, but `transcripts/` is its own git repo,
+ so the **first form save or toggle per channel may show a one-time key-reorder diff**
+ there. That is expected, not corruption.
+- **`build:index` is NOT read-only.** `pnpm --filter export build:index` writes its LMDB to
+ `paths.lmdbPath = $TRANSCRIPTS_DIR/index.mdb` (14 GB live), and that path cannot be
+ overridden by env. **Never point it at the primary's `transcripts/` while the editor runs.**
+ To time it, use a scratch corpus of SYMLINKS to the live `channels/`, `sites/`,
+ `saved-videos/` and the corpus-wide JSON, with no `index.mdb` and `EXPORT_PUBLIC_DIR` in
+ scratch. Every read is then real, every write is scratch, and every run is a cold full
+ build. The harness was `$CLAUDE_JOB_DIR/tmp/s4b-buildindex.sh`. The branch measured +0.5 %
+ against a quiet `main` run (1699.98 s vs 1691.98 s, 77,624 transcripts).
+
+**Owed: JSON writers still on the per-pid temp name `${file}.tmp-${process.pid}`**
+
+Verified at `ef88ac4d` with `git grep 'tmp-${process.pid}' -- common`. The slice record calls
+these "14"; the list is **16 write sites in 13 files**:
+
+- `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**,
+which is the same race class 4b fixed for `config.json`.
+
+Not in the 14:
+
+- The export build's streamed page writers `buildIndex.ts:971` and `buildStats.ts:220`. These
+ are JSON writers still on the per-pid name, not in the list above only because there is one
+ writer per build. They are owed with the rest (16 + 2).
+- `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`.
+- The non-JSON writers.
+
+**The numbers tool** is `plans/tools/phase3-files-numbers.ts` (317 lines). It is one process,
+never writes the corpus, and uses only names present on both sides.
+
+- **Freezing:** `FREEZE_TO=/abs/dir` copies exactly what a run reads into a scratch tree, and
+ both runs then take `LIVE_TRANSCRIPTS_DIR=` that tree.
+- **Output:**
+ - each site parse, plus the file `writeSite(getSite())` writes
+ - each config parse, plus the md5 of `writeChannelConfig(readChannelConfig())`
+ - per sidecar, the md5 of the canonical load and of `write(load())` (load only for the two
+ markers)
+ - an unknown-key report
+- **Result:** at 4b, `main` vs the branch is an empty diff over 3,832 lines (6 sites, 71
+ configs, 1,763 sidecar files).
+- **Not covered:**
+ - the key-order change, because parse → write never reorders
+ - the patch paths
+ - the `savedVideo`/`posts`/`clipWindow` writes
+ - the 14 writers above
+
+### Anchors above this section that are now stale (not rewritten in place)
+
+Line numbers are `plans/FACTS.md` lines as of `ef88ac4d`. As in the 2026-09-23 list, the
+historical entries keep their text; use the subsections above.
+
+Slice 3a (merged `3241fed2`) deleted or moved:
+
+- `:56` — `MonitorWidget.tsx:628`'s `METRIC_PREFIX` → `editor/app/jobs/components/JobProgressBars.tsx:164`;
+ the widget has no bars or glyph tables of its own.
+- `:2763` — `dashboard/ChannelsTable.tsx:69`, `dashboard/types.ts:21`: both files deleted; the
+ dashboard's channel cells are `channelColumns.tsx`'s `videos` / `digestReachable` entries
+ over `ChannelRowView.work` (`common/views/channelRow.ts:92-97`).
+- `:2539` — "dashboard / `NeedsWorkPanel` / `ChannelsTable`" reading `actionableNoDigestCount`:
+ the dashboard table reads `digestReachableCountOf` (`common/views/actionableCounts.ts:89`)
+ through the builder.
+- `:2791-2792` — `reportStateOf(brief)` is in `common/views/channelRow.ts:139`, not
+ `loadActionable.ts`; `isStaleOrMissing(row)` is deleted (with `ChannelWorkTable`'s `Row`).
+- `:3046-3047` — `MonitorWidget.tsx:1047-1086,1121,1139,1176` (the widget's private `JobRow`,
+ `JobProgressBar`, `TaskBar`, `jobProgressText`) are deleted; the widget draws `JobRow`
+ `compact` from `ActiveJobsStrip` (`MonitorWidget.tsx:985`).
+- `:3104-3105` — "`components/LaneStrip.tsx` … byte-identical extraction": `LaneStrip`'s
+ runner buttons are `JobRowActions` now (`LaneStrip.tsx:84`).
+- `:4356-4357` — "`InFlightList` renders the note (and links the CHANNEL)": it still does, but
+ through `fromInFlight` + `JobRow compact` (`InFlightList.tsx:63-84`), not its own markup.
+- `:3888`, `:3903` — "`ChannelsTable` ends in TWO sibling bulk bars" and "`colSpan` is
+ `10 + columns.length`": the bars became `ChannelSelectionDeck` (in `ChannelsRack.tsx`,
+ outside the scroll box), and `colSpan` is counted from the drawn columns
+ (`ChannelsTable.tsx:246-254`). The merge-conflict lesson stands; the shape does not.
+- `:5422` (slice 2, above) — `widgetActionableRows` is `loadActionable.ts:80`, not `:162` (the
+ count helpers above it became one-line wrappers). **Corrected in place.**
+
+Slice 4b (merged `ef88ac4d`) deleted or moved:
+
+- **Named things.** FACTS names none of `updateConfigField`, the private `writeJsonAtomic`
+ copies or `MediaBadgeInput` (which slice 3a kept as an alias, so it is not deleted anyway).
+ The `SUB_FILE_RE` entry (`:134-140`) was already corrected by 4b and is right:
+ `videoStatus.ts:88`, exported.
+- `:166` — the "Reusable helpers" row "tmp+rename write idiom | `normalizeTranscript.ts:139-141`
+ | `${path}.tmp-${process.pid}`" now points readers at the per-pid name 4b retired for JSON.
+ For JSON, the idiom is `writeJsonAtomic` (`common/lib/jsonFile-server.ts:158`), or
+ `sidecar()` (`common/lib/sidecar-server.ts:67`) for a per-video sidecar. The
+ normalizeTranscript line is a non-JSON cue writer that is still per-pid.
+- `:3182` — "`SITE_ID_RE` **re-spelled**, because `common/lib/site.ts` imports `node:fs`":
+ `SITE_ID_RE` is in `common/lib/siteSchema.ts:128`, which imports zod. It is still
+ server-only, so the re-spelling in `activeSite.ts` still stands, now for that reason.
+- `:3211` — `getSite` "(`common/lib/site.ts:356`, sync)" → `site.ts:174`, throw at `:175-176`.
+- `:3804` — `siteChannelIndex` "(`lib/site.ts:324`)" → `site.ts:118`.
+- `:2884-2887` — `digest-server.ts:91-98` (the `version` fallback in `loadDigestOverrides`) is
+ now inside `coerceDigestOverrides` (`digest-server.ts:69`), and the loader is the
+ declaration at `:96-107`. `attribution-server.ts:36-38` (the `hasAttribution` note) does not
+ apply: the file is `coerceAttribution` `:9` plus the declaration `:27-37`, and it has no
+ `hasAttribution`.
+- `:4378` — "the sanitizer stores it only alongside a real filter": the sanitizer is
+ `coerceDownloadFilter` (`common/lib/channelConfig.ts:288`).
+- `:5449-5455` (slice 4a's own entry) — `settings.ts` is **245** lines, not 250. Every anchor
+ moved up by 3:
+ - `getSettings` `:121-123`
+ - `premigrateRaw` `:87`
+ - `finishRawMigrations` `:104`
+ - `deriveWorkerShadow` `:184`
+ - `writeSettings` `:232`, with the parse at `:239-242`
+
+ The writer ends in `writeJsonAtomic(file, merged)` (`:244`), not an inline tmp + rename, and
+ `getSettings` reads through `readJsonFileSync`.
+- `:5236` — `settings.ts:242` → `:239`.
+- `:5506` — "`common/lib/settings.ts:235-250`" → `:232-245`.
+- **Line shifts only (text still true).** These moved because 4b edited the files:
+ - `buildIndex.ts`
+ - `:233` → `:234`
+ - `:165` → `:166`
+ - `:146-154` → `:147-155`
+ - `:694` → `:680`
+ - `:830-832` → `:816-818`
+ - `:636-679` → `:622-665`
+ - `:1088-1118` → `:1074-1104`
+ - `:1065-1240` → `:1051-1226`
+ - `:1765` → `:1751`
+ - `:309-315` → `:303-309` (FACTS `:4182,4299,4396,4431,4453`)
+ - `channelSnapshot.ts`
+ - `:57-143` → `:58-144`
+ - `:607-619` → `:608-620`
+ - `:997` → `:998`
+ - `:1167-1174` → `:1168-1175`
+ - `:1378-1383` → `:1379-1384`
+ - `curatedTagsStore.ts`
+ - `:94-113` → `:92-111`
+ - `applyTagAssignments` is at `:208` (FACTS `:178` says `:198`; it was already off before
+ 4b)
+ - `aliasesStore.ts:68-73` → `:66-71`
+ - `videoStatus.ts:143-158` → `:145-160`
+ - `relocateChannelMedia.ts`: `relocationRootProblem` → `:169` (FACTS `:3752`; the line now
+ lands on the function, where before 4b it was off by 4)
+ - `editor/app/channels/actions.ts`
+ - `:761` → `:754` (FACTS `:3808`; `saveChannelPriorityAction`)
+ - `:609` → `:602`
+ - `:618-621` → `:611-614`
+ - `:571` → `:564`
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,44 +3,56 @@
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.
-**Last updated:** 2026-09-24 — **release 2 is LIVE on :3001** (`1e27f7c3` = `b836ea8a` +
-two prelude commits; rollout record:
+**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)),
-and **slices 3a + 4b are in flight** on `one-core/phase-3-s3a` and `one-core/phase-3-s4b`
-(worktrees off `1e27f7c3`). Previous: 2026-09-23 — **one-core Phase 3 slices 2 and 4a are on
-`main` @ `ae2fa5a9`, and gate B is done.** Base `54cf1b31`; two branches merged in the order
-`one-core/phase-3-s2` → `8d6e84f6`, then `one-core/phase-3-s4a` → `ae2fa5a9` (with `main`
-merged into it as `7035316c`). Release record, gates and the next release's inventory:
-[`one-core-phase-3.md`](one-core-phase-3.md#release-2026-09-23--slices-2-and-4a); anchors:
-[`FACTS.md`](FACTS.md#one-core-phase-3-slices-2--4a-verified-2026-09-23-main--ae2fa5a9).
-
-- **Slice 2 — one polling route.** `/api/view/[name]` serves eight named views from one total
- handler table; the eight old API paths are `rewrites()` (not redirects), so pinned widgets
- and scripts keep working; one client poller, `editor/app/lib/usePolledPayload.ts` (serial,
- abort + timeout). No number moved (`plans/tools/phase3-view-numbers.ts`, empty diff).
-- **Slice 4a — one settings schema.** zod joins `common`; `common/lib/settingsSchema.ts` is
- every key, default and clamp; `lib/settings.ts` is I/O (1,783 → 250 lines); every editor
- save goes through `saveSettings(patch)`; `SETTINGS.md` + `settings.json.example` are
- generated by `common/bin/settings-example.ts` and pinned by a test. Allow-list 10 → 9.
- Read AND write of every real settings file diff-empty
- (`plans/tools/phase3-settings-numbers.ts`). One fix: saving a storage location no longer
- erases `storage.savedVideosLocationId`.
-- **Gate B — done 2026-09-23**, reachable media only: `backfill.allowRedownload: false`,
- `autoQueue.backfill.held: true`, lane still `enabled: false`, set through the lane form on
- `/operations/diarization`. Record:
- [`one-core-phase-1.md`](one-core-phase-1.md#gate-b--passed-2026-09-23). Arming the lane is a
- later, separate act; expected work then is the ~679 reachable.
-
-**Gates on `ae2fa5a9`:** tsc clean; common 1663; editor unit 67; scripts 156 + 1 skip; mcp
-219; editor and export builds green. Full e2e: see the release record.
-
-**Next release:** Phase 3 slice 3 (one drawing per noun) and slice 4b (`site.json`, channel
-`config.json`, sidecars) — inventory in the release record.
+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:
+`<final-suite-counts>` (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):** nothing from release 2 — the lane form's 836 hint is live figures since `c0a90a37` /
-`1e27f7c3` (679 reachable, 74,411 needing media on 2026-09-24).
+(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`).
**Previously:** 2026-09-22 — **the release *curated-tags follow-ups + debts sweep* is on
`main` @ `766e0873`.** Base `4ac8ceda`; three branches merged in the order
@@ -1509,6 +1521,100 @@ parallel task), Phase 0 transcription benchmark (a separate bottleneck).
## Decisions log
+**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
+cut, so both branches start from the code the operator is actually running and neither
+carries a fix the other lacks. The operator chose this order on 2026-09-24: the live editor
+had served a 2026-09-14 build for ten days, and release 2 had to be in front of the operator
+before more code landed on top of it.
+
+**Slice 3 is split into 3a and 3b (2026-09-24).** 3a = the channel row and the job in flight
+(one table, one job row); 3b = the channel pipeline's `stages/*` status logic and the
+`VideoPanel.tsx` split. The inventory made the halves unequal and independent: 3a's
+surfaces are shared renderers with e2e coverage by aria name, 3b is two large page-local
+refactors (nine stage files; a 1,839-line panel) that touch none of 3a's files. Shipping 3a
+alone kept one release reviewable alongside 4b — the operator's call on 2026-09-24, made
+when the inventory showed 3b to be the two largest files of the phase.
+
+**Channel columns are a registry by id, not column objects (2026-09-24).** The work tables are
+drawn by a server shell whose section configs hold functions, and a function cannot cross the
+server/client boundary as a prop — so callers name columns by id and the cells live in the
+client registry. The ids and presets live in a directive-free module because a server
+component importing a VALUE from a `"use client"` module gets a client reference, not the
+value; tsc passed and every operation page threw (`WORK_COLUMNS.filter is not a function`,
+bisect range `f63260d8`..`c944475f`). Found by the first e2e run, fixed forward.
+
+**`LaneStrip` keeps its lane line (2026-09-24).** `one-core.md` said the job renderer would
+replace it. A lane is not a job: it has a state word, a reason, an in-flight count and at most
+one runner job, and the suite addresses it by section and heading. Only its runner job's
+buttons became `JobRowActions` (Force-release narrowed to a stuck runner). The plan's claim is
+corrected in its "as shipped" italics.
+
+**The runner records `jobId` on its in-flight units (2026-09-24).** `AutoRunnerInFlight` gains an
+optional `jobId`, set only in the two `onChildJob` callbacks (a download unit is its own
+registry job); the private `childJobIds` map stays private. Without it the operations board's
+in-flight row could not link a download unit to its job page, and `fromInFlight` could not
+tell a registry job from a task on the runner's job (`inRegistry`). Three lines in
+`autoRunner.ts`; the view gains one optional field, which the numbers tool does not cover.
+
+**Re-acquire figures come from the diarization kind, not the lane sum (2026-09-24).** The
+backfill lane form's "N videos still have media on disk" first read the lane-wide
+`backfill.reachable`, which folds attribution-text — whose input is the transcript — and
+rendered 78,146. The toggle fetches AUDIO, so the figure is the **diarization** kind's entry
+in `backfill.kinds`, the one operation whose missing input is audio (`1e27f7c3`, found live).
+It reads 679 reachable / 74,411 to re-download, the same 679 gate B measured.
+
+**Two restarts in an hour paid the boot cost twice (2026-09-24).** Every boot re-runs the
+recency pass, re-queues the overdue sync-all and resumes a metadata scan, against a platter
+drive that was already saturated; corpus pages took 47–240 s for ~25 min after each start.
+`ARCHILYZER_IDLE_BOOT=1` exists for this and was not used because the lanes were meant to
+resume. Next time: batch prelude commits into ONE build and restart, or boot idle for the
+smoke and restart once armed.
+
+**No mtime freshness in `sidecar()` (2026-09-24).** `one-core.md` item 4 listed "mtime
+freshness" among the four things a sidecar declaration provides. No reader consumes one: every
+sidecar reader asks "what does the file say", and staleness is judged elsewhere, for example
+`isCuesJsonFresh` and the digest's `derivedFrom`. So it was not built (slice 4b, deviation 1).
+
+**The channel-config write is strict, and there is one patcher (2026-09-24).**
+`writeChannelConfig` parses on the way out and THROWS on a value that is not a channel (no
+valid `handling`), like `writeSettings`, rather than writing a file every reader would then
+treat as absent. Every caller passes a parsed config, so it fires only on a bug; the e2e
+fixtures seed raw through their own helper.
+
+Partial writers go through `patchChannelConfig(paths, slug, patch, {unset})`, which re-reads
+the file under a per-path lock. The old callers spread a copy read minutes earlier, so a social
+fetch wrote back its start-of-run config over form edits made while it ran. The patch fixes
+that, and `unset` is how the form clears an override.
+
+**The jsonFile write chain and locks live on `globalThis` (2026-09-24, review).** Next loads a
+module once per bundle layer. `instrumentation.ts`-armed runners and server actions can hold
+separate copies, and each copy would start its temp counter at 0 and hold its own locks. That
+would bring back the temp-name collision the module exists to fix, and it would miss the
+stamp-during-form-save race. `globalThis.__yttJsonFile__` is the house pattern
+(`jobs/registry.ts`, `controller/autoRunner.ts`), and the temp name also carries 4 random
+bytes, so its uniqueness never rests on a shared counter.
+
+**`build:index` is not read-only (2026-09-24).** The plan said to time it "read-only over
+`transcripts/`". In fact it writes `$TRANSCRIPTS_DIR/index.mdb` (14 GB live), and that path
+cannot be overridden by env, so pointing it at the primary would have rewritten production's
+index under the live editor. Offline timing runs use a scratch corpus of symlinks with no
+`index.mdb`, so reads are real, writes are scratch, and every run is a cold full build.
+
+**Channel coercions stay in the pure module, and the schema wraps them (2026-09-24).** Six
+`"use client"` modules value-import `channelConfig.ts`, so `parseChannelConfig` cannot
+delegate to zod without shipping zod to the browser. `CHANNEL_CONFIG_COERCIONS` there is the
+one set. `parseChannelConfig` (zod-free) and `channelConfigSchema.ts` (server) both compose
+it, and a test pins that they agree, key order included (deviation 2).
+
+**`siteSchema` re-emits every key, in order (2026-09-24).** zod 4 omits an input-absent key
+whose transform returns `undefined`, so the per-key object alone would drop `socialLinks`,
+`accent`, … from a file that does not spell them. `parseSite` has always emitted every key,
+present and `undefined`, so the object step rebuilds the output from `SITE_KEYS`: the same
+shape and the same order, checked over 20,000 random inputs against `main`'s parser
+(deviation 5).
+
**One-core Phase 3 is the direction (2026-09-23).** Not PLAN.md's derived-corpus phases and
not the umtool backlog. Operator, via the question tool.
diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md
@@ -569,9 +569,75 @@ boot cost twice; `ARCHILYZER_IDLE_BOOT=1` exists for exactly this and was not us
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.
-## Next release — slice 3 and slice 4b
-
-Not started. The facts below were checked against `ae2fa5a9` on 2026-09-23.
+## 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 two branches share no code file — only `editor/CHANGELOG.md` and this
+record, the two files the `7dfd7508` merge conflicted on — so either order was safe, and 3a
+was ready first (review fixes in, post-review e2e green). Merging it first let 4b, the slice
+still being gated, re-run its whole gate set on a tip that already carried 3a
+(`7dfd7508`), so the release's end state was measured once, as a whole, rather than each
+slice only on a base without the other.
+
+**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`: <final-suite-counts — filled after the run>
+
+**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.
+
+## 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.**
@@ -835,8 +901,10 @@ channel-priority, settings — **157/157 passed, exit 0, 9.9 min**, first run, n
## Slice 3a, as shipped — one drawing per noun (2026-09-24)
-Branch `one-core/phase-3-s3a` off `1e27f7c3`, unmerged; ten commits (the eight below,
-this record `b4890c78`, and the review fixes `001f91bc`).
+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 |
|---|---|
@@ -899,6 +967,9 @@ Deviations:
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.
@@ -932,7 +1003,7 @@ slice 3a) merged in — merge sha and the post-merge gates in the follow-up belo
| `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 |
-| (this) | `plans/tools/phase3-files-numbers.ts`, this record, changelog |
+| `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
@@ -1009,7 +1080,7 @@ being timed).
**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:
+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`,
diff --git a/plans/one-core.md b/plans/one-core.md
@@ -311,9 +311,11 @@ 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-23.** 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 3 and slice 4b (`site.json`, channel `config.json`, sidecars) are the next release.
+> **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).
1. **`common/views/`**: move `buildActiveJobs`, `buildWorkers`, `lanes.ts`,
@@ -335,6 +337,27 @@ https://jeralyzer.pages.dev/corpus.json` before and after, byte-identical output
composes the same components with `compact`; `widget/builder` keeps laying out those
components. `VideoPanel.tsx` (1586) splits into per-operation panels the registry
already enumerates (`videoOperationPanels.ts`).
+ *As shipped for the channel row and the job in flight (slice 3a, merged `3241fed2`,
+ 2026-09-24): **the channel row** is one pure builder, `common/views/channelRow.ts`
+ (`buildChannelRowView` → `ChannelRowView`, which carries no `config`), over the same
+ per-channel counters the actionable census uses (`common/views/actionableCounts.ts`).
+ Columns are a **registry looked up by id**, not column objects passed as props: 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 — so the ids and the
+ `RACK_COLUMNS` / `DASHBOARD_COLUMNS` / `WORK_COLUMNS` presets live in the plain
+ `channelColumnPresets.ts` and the cells in the client `channelColumns.tsx`. The rack split
+ into `ChannelsRack` (chrome: focus, volume and instrument bars, scroll region, selection
+ deck) and the shared `ChannelsTable`, which the dashboard and `ChannelWorkTable` (now a
+ server shell) also draw; `components/dashboard/ChannelsTable.tsx` and
+ `ChannelWorkTable`'s `Row` are deleted. **The job in flight** is one
+ `jobs/components/JobRow.tsx` with `variant` `table` | `card` | `compact` and one
+ `JobRowActions`, drawn by `JobsTable`, `RunningJobsList`, the widget's `ActiveJobsStrip`
+ and `InFlightList` — the last through a new pure adapter, `fromInFlight`, for which the
+ runner now records each download unit's registry `jobId`. The widget's private row,
+ bars and glyph tables are deleted. **`LaneStrip` keeps its lane line**: a lane is not a
+ 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.*
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
@@ -354,9 +377,40 @@ https://jeralyzer.pages.dev/corpus.json` before and after, byte-identical output
write, which a one-block signature would split. It is a helper the actions call, not a
server action. `SETTINGS.md` + `settings.json.example` are generated. `site.json`, channel
`config.json` and the sidecars are slice 4b.*
-
-Deleted: six payload builders from the app, seven API routes, two channel tables, three
-job renderers, eight stage status derivations, ~1,400 lines of hand sanitizers.
+ *As shipped for `site.json`, channel `config.json` and the sidecars (slice 4b, merged
+ `ef88ac4d`, 2026-09-24): all three are on zod, following 4a's pattern of
+ `settingsField(coerce)` over the parser that already existed, with no `.default()` and no
+ `.passthrough()`. **`site.json`** is `common/lib/siteSchema.ts`, and `site.ts` is left
+ holding the I/O and the resolvers. **Channel `config.json`** is
+ `common/lib/channelConfigSchema.ts`. Its coercions stay zod-free in `channelConfig.ts`
+ (`CHANNEL_CONFIG_COERCIONS`), because six `"use client"` modules value-import that file;
+ the schema wraps the same functions. The mutable sync state is declared as
+ `CHANNEL_SYNC_STATE_KEYS` (`lastSyncedAt`, `lastFullDownloadAt`, `lastFullSweepAt`), and the
+ file is not split. There is one reader (`readChannelConfigFile`) and a **strict** writer
+ (`writeChannelConfig`), which throws on a value that is not a channel. There is also one
+ read-modify-write patcher, `patchChannelConfig(paths, slug, patch, {unset})`, which every
+ writer after creation uses except two whole-config fallbacks. `updateConfigField` is
+ deleted. **Eight sidecar pairs (nine files)** are each one
+ `sidecar(name, sidecarField(coerceX))`, which provides the read, the atomic write, the
+ remove and the `transcript.<x>.<y>` naming guard (a throw at declaration). **mtime
+ freshness was not built**, because no reader consumes it. **`SITE.md` and `CHANNEL.md` are
+ generated** (`common/bin/file-schemas-docs.ts`, with `--check`). **One JSON reader and one
+ atomic writer** live in `common/lib/jsonFile-server.ts`. That module gives each write a
+ unique temp name, chains writes per absolute path, and pins its state on `globalThis`, so
+ one server has one chain even when Next loads the module twice. It replaced seven private
+ `writeJsonAtomic` copies and the inline tmp writes on these files. Not done: **16 JSON
+ write sites in 13 files are still on the per-pid temp name** (the record said "14"),
+ including `maybeMissingStore` and
+ `rosterStore`, which are per-channel files written from several lanes. They are owed, and
+ 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.*
+
+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.
### Phase 4 — CLI, entry points, config, docs (3 slices)