commit 6c507ab1b60be2cced0c5ce8b76b9ac724fd5c20
parent 9f2bbf9157363914a962680c972560d6b548264d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 8 Sep 2026 01:37:05 -0400
plans: slice 1.5 is shipped, and Phase 1 is closed out
The 1.5 record: what the two bucket-lane entries carry and why they are
hand-folded, both halves of the proof, and every divergence — the download
union order the plan had backwards, the 882 that is not 2,754, the claim space
that made the projection safe, and the band double-fold the e2e assertion
caught after two commits of claiming nothing had moved.
Plus a Phase 1 summary: five slice ranges, 107 files, 18 deleted files at 5,273
lines, tests 876 to 908, and what stays for the deletion slice with its exact
condition. STATE.md leads with Phase 1 and keeps the history below it;
FACTS.md gains the whole-phase anchors; one-core.md says shipped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 530 insertions(+), 56 deletions(-)
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -3374,3 +3374,159 @@ EMPTY** (2,629 lines each), which is a direct test of the fallback: the live fil
`transcriptionsPaused: true`, `downloadsPaused: false`, `digest.digestsPaused: false`,
`backfill.enabled: true` and no `held` anywhere, and the script's four `held <lane>` lines
still read `true/false/false/false` through `isGateHeld`.
+
+## One-core Phase 1 (verified 2026-09-08) — four lanes, one runner
+
+The whole-phase anchors. The slice-1.4 entry above stays as the detail on where a lane's
+pause lives; this is everything else a session needs before touching dispatch. Branch
+`one-core/phase-1`, `7f294df` → head, 33 commits, unmerged, off Phase 0's `af2a360`. The
+record with every divergence is `plans/one-core-phase-1.md`.
+
+**The model in one sentence:** the LANE is the dispatch noun (a queue key, a policy tree, a
+runner, a pause, a console), the OPERATION is the work noun (a registry entry with a state
+per video), and a leaf in a lane's tree names channels and, where a lane carries more than
+one operation, an operation.
+
+### The four lanes, and which half of the model each draws from
+
+`LANES` / `AutoQueueKind` (`common/lib/autoQueueTypes.ts`) is
+`["transcription", "download", "digest", "backfill"]`, and `PauseLane` is an alias of it.
+Two of them draw BUCKETS and two draw OPERATIONS, which is the split every function in
+`jobs/autoQueuePolicy.ts` keys on:
+
+| lane | draws | its work list |
+|---|---|---|
+| `download` | buckets | `snapshot.backfill.download.ids` = `partialDownloads` ∪ `undownloadedIds` |
+| `transcription` | buckets | `snapshot.backfill.transcription.ids` = `downloadedNoTranscript` ∪ `failedListed` |
+| `digest` | operations | `snapshot.backfill.digest.ids` |
+| `backfill` | operations | `snapshot.backfill.{diarization,attribution-text,attribution-diarized}.ids` |
+
+- **`bucketsForKind` / `optInBucketsForKind` / `selectableBucketsForKind` return `[]` for the
+ operation lanes**, and `operationsForLane` returns `[]` for the bucket lanes — download and
+ transcription are `EXTERNAL_OPERATIONS` with no `state()` or `run()`, so there is no
+ `Operation` to hand back even though they now have a snapshot entry. The lane asks
+ `bucketLaneOperationId(lane)` (`lib/operations.ts`) for its work list's KEY instead; it is
+ `pauseLaneFor` read backwards, off the same `ExternalOperation.runner` declaration.
+- **`defaultBucketsForPolicy` describes the POPULATION; `defaultDrawsForPolicy` is what the
+ runner projects.** The first is what `policyDrawsBucket` asks ("would the runner draw this
+ bucket for this channel"). The second returns `[<operation id>, ...optIn if
+ replaceAutoSubs]` on a bucket lane — one list, named for the operation, with the opt-in
+ auto-captions buckets still strictly at the tail.
+- **The lane's work list is projected into `ChannelWork.buckets`, not `.operations`**, and
+ that placement is load-bearing. `buildPendingByLeaf` claims a video as
+ `${operation}\0${id}`, so an operation draw and a bucket draw are DIFFERENT claim spaces: a
+ video an explicit retry-bucket leaf claimed would be claimed a second time by a catch-all
+ drawing the operation. On a bucket lane the operation and the union are the same work, so
+ it shares the bucket space — and `laneOperationIds` still answers `[]` there, which is why
+ `dropCompleted`, `leafOperationIds` and `markCompleted` were untouched by 1.5.
+- **`bucketLaneWorkIds(lane, snapshot)`** (`jobs/autoQueuePolicy.ts`) is ONE fold with two
+ callers: `generateChannelSnapshot` writes it, and `autoRunner.buildChannelWork` falls back
+ to it for a snapshot with no entry. That is the whole migration — **no live snapshot is
+ regenerated by 1.5**, so all 68 take the fallback until their next regen, and the two sides
+ cannot disagree because they are the same function.
+- **`bucketIdsFrom(source, name)`** knows the one asymmetry: `undownloadedIds` is a TOP-LEVEL
+ snapshot field and every other bucket is under `snapshot.buckets`.
+
+### Where `held` lives
+
+One key, `autoQueue[lane].held`, absent-is-not-false, with the four retired fields read
+through `legacyGateHeld` in `common/lib/laneMigration.ts`. Full detail in the slice-1.4
+entry above, including why that function is not in `pauseGates.ts` (an import cycle:
+`settings → pauseGates → operations → controller → settings`). **The same cycle is why
+`bucketLaneOperationId` is in `lib/operations.ts` while the fold that uses it is in
+`jobs/autoQueuePolicy.ts` and takes the name as a parameter** — `lib/settings.ts` imports
+`autoQueuePolicy` for its sanitizers, so `autoQueuePolicy` importing `operations` would open
+the same door.
+
+### The migration files
+
+- **`common/lib/laneMigration.ts`** — pure, two functions, no I/O.
+ `migrateSweepsToLanes(parsed)` runs on the PARSED settings file (absent-from-the-file is
+ its trigger, and a merged object can no longer tell absent from default);
+ `migrateHeldToLanes(merged)` runs on the MERGED object AFTER `sanitizeDigest` /
+ `sanitizeBackfill`, because two of the four fields live in those blocks. Tested in
+ `common/jobs/laneMigration.test.ts` — in `jobs/` because `architecture.test.ts` forbids
+ `lib/` importing `jobs/` and the migration must be asserted THROUGH `sanitizeAutoQueue`.
+- **`laneRootFromScope`** is the ONE leaf builder: `armLaneAction` and the sweep migration
+ both call it, so "arm on channels X" and "migrate `sweepChannels: X`" are the same
+ function. Its optional `available` collapses a scope naming every operation to `[]`.
+- **The migrated leaf ids are `digest-all` / `backfill-all`**, which only that builder
+ produces — visible in the numbers diff, which is how the migration was checked on the live
+ file rather than asserted.
+
+### `controller/operationBatch.ts` — one executor
+
+Replaces `digestBatch.ts` (647) and `backfillBatch.ts` (937). `openOperationRun` resolves the
+engine, the duplicate-cluster plan and the fan-out envelope ONCE PER RUN; `runOperationUnit`
+takes that prepared run, not a bag of per-video arguments. `laneLimit(settings, live)` takes
+the lane on a tagged `live` union. `runOperationBatch({lane, …})` is one function with one
+result type; `runDigestChannelJob` / `runBackfillChannelJob` keep their job kinds and queue
+keys and call into it.
+
+Four things in it that only a long-lived runner can break, all already paid for: the metered
+spend cap and the disk-floor latch survive the run context's 60 s refresh; the refresh waits
+for the lane to be idle and carries the WHOLE `live` object across (`opened.live =
+laneRun.run.live`) or a unit settling after the swap leaks a lease; a failed engine open is
+covered by the TTL too; and the digest unit calls `digestVideo` rather than `op.run()`
+because `OperationRunOutcome` cannot carry a cost and routing it through `run()` would
+silently disable the spend cap.
+
+**The `laneFor` trap is closed.** The GPU idle-only rule asks
+`laneYieldsToTranscription(laneForOperation(op.id) ?? op.lane)` on `contendsFor`, not on the
+field EXISTING. `laneForOperation` (`controller/operationLane.ts`) resolves the registry
+first and then `EXTERNAL_OPERATIONS`' declared lanes; **`sync` still resolves `null`** — it
+is a per-channel cadence, the same answer `pauseLaneFor` gives it.
+
+### The snapshot memo, and what it is not on
+
+`readChannelSnapshotShared` (`controller/channels.ts`) memoizes the PARSE keyed on
+`(mtime, size)`. It exists because `buildChannelWork` runs on the runner's scheduling tick
+AND on a three-second status poll folding four lanes — ~6.5 MB of JSON re-parsed eight times
+a tick. Measured: the poll is 86–102 ms before the two lanes went live, 386–413 ms after with
+no memo, **256 ms with it**. It is deliberately NOT on `readChannelSnapshot`, which the
+snapshot GENERATOR calls. `resetChannelSnapshotMemo` is wired into
+`/api/test/invalidate-cache`.
+
+### The `cheapest` composition
+
+`digestBatch.ts` sorted by duration and then again, stably, by upload date — so the live
+meaning was **date primary, duration as the tiebreak within a day**, the opposite of the
+order the two `sort()` calls read in. That is one `cheapestComparator` now, with the date
+half fixed at newest-first (`digest.recencyOrder` retired rather than migrated: it was only
+the date HALF of a composition). Unknown durations sort LAST. `cheapest` on any other lane is
+accepted by the sanitizer and falls back to LISTED — `recencyOrdering` returns a null
+comparator, which everywhere in the runner means "do not sort".
+
+### `.auto-queue/state.json` is written WHOLE
+
+`writeAutoQueueState` serializes every lane, so the four runners share ONE in-memory object
+on the auto-runner singleton (`sharedAutoQueueState`). Two runners each holding their own
+copy meant every persist clobbered the other lanes' pick log and fairness memory — invisible
+with one slow lane, immediate with two fast ones. Anything else that read-modify-writes that
+file (`jobs/downloadBackoff.ts` does) can still lose picks; a new writer must take the shared
+object. Its tmp path carries a per-write counter, not just the pid.
+
+### The band that is folded once
+
+`buildOperationBands` (`editor/app/components/pipelines/buildBands.ts`) folds
+`addExternalBands` for download and transcription and `addRegistryEntry` for everything
+else — and the registry fold now SKIPS the two external ids. `/channels` passes
+`[...EXTERNAL_BAND_IDS, ...allOperations]` in, so once slice 1.5 wrote
+`snapshot.backfill.download`, folding it on top of `addExternalBands` doubled every one of
+that band's five numbers (a three-video channel read "6 done of 6"). The `/operations` rail
+(`operations/lanes.ts`) and the channel page (`channelFlow.ts`) pass registry ids only and
+were never affected.
+
+**The bands and the snapshot entries are two different measures and must stay so.** The
+band's transcription `reachable` is `downloadedNoTranscript` ALONE — the retry bucket is not
+in it, and on the live corpus that is 881 against the lane's 882; its `blocked` is what the
+entry calls `missingInput`; its `eligible`/`present` are the playlist and
+`totals.downloaded`/`totals.transcribed`. `EXTERNAL_BAND_IDS` is now
+`EXTERNAL_OPERATIONS.map(o => o.id)` rather than a hand-written pair.
+
+### The `editor/content` symlink still blocks e2e in the primary checkout
+
+Unchanged from the Phase 0 entry, plus one thing Phase 1 learned twice: **`git add -A` stages
+it** (it is untracked, not ignored), which carries the Turbopack panic into every worktree of
+that commit. Add by path. A worktree also needs a composed fixture site copied into its
+`export/public` or the export webServer 500s and Playwright times out at 120 s.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -3,65 +3,91 @@
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-07 — **one-core Phase 0 shipped** on branch `one-core/phase-0`
-(`df5eb48` → `1691c4f`, six commits, not merged): **guardrails and dead weight.** The umbrella
-plan is [`one-core.md`](one-core.md); its "Phase 0, as shipped" section is the detail and
-[`FACTS.md`](FACTS.md#one-core-phase-0-verified-2026-09-07) the anchors. Every item is a
-deletion, a move or a guard. **Nothing on disk in a corpus changed and nothing on the wire
-changed** — proved, not asserted: a fixture corpus composed through `build-index` +
-`compose-site` before and after item 5 gives 16 public files and 7 index files that are
-**identical apart from `generatedAt`** (and the archive zip's entry mtimes; its contents diff
-clean).
-
-- **`scripts/report-to-video` → `umtool/report-to-video`**, package `umtool-report-to-video`.
- A rename only. The survey undercounted: **eight** import sites, not four.
-- **`common/architecture.test.ts`** is the layering. Back-edges **17 lines / 16 edges → 13 /
- 12**. The four burned down were type-only and now live in `common/lib/autoQueueTypes.ts`,
- re-exported by `jobs/autoQueue{Policy,State}.ts` so no import site outside `common/lib`
- changed. The allow-list fails on a NEW edge *and* on a stale entry, so it can only shrink;
- each of the 12 names the slice that removes it. **Nine wait on phase 1** —
- `lib/operations.ts` → four controllers is the registry's `run()` closures, THE structural
- back-edge, and it inverts at phase 1 step 4.
-- **`common/package.json` has an `exports` map**; `mcp/package.json` finally declares
- `yt-dlp-transcript-common` (it had been resolving by hoisting). One trap paid for:
- `"./components/*": ["./components/*.tsx", "./components/*.ts"]` **type-checks and does not
- build** — Turbopack takes the first entry of a fallback array and stops. The 25 `.ts`
- modules under `components/` are listed explicitly; a new one needs a new line.
-- **Deleted**: root `sites/`, `common/bin/migrate-to-sites.ts` (the *controller* stays — the
- Sites page's Migrate button calls it), `create-archives.sh`'s commented tail,
- `export/.compose-cache/`, and `PARALLEL_TRANSCRIBE_LIMIT` from three doc tables.
- `export/.r2-staging/` (2.5 GB) deliberately untouched. **Moved**: the three bake-off
- harnesses (3,828 lines, 58% of `common/bin/`) to `plans/tools/`.
-- **One `pageFileName`, one `CONTRACT`.** The page-shard name existed **six** times, not
- three. `CONTRACT` in `lib/corpus.ts` owns eight values plus the layer names; the seven
- existing constants re-export its fields.
-
-**Decision taken here:** the bake-off move needed one new file, `common/bin/_lmdb.ts`, because
-`plans/` is not a workspace package and a bare `import "lmdb"` resolves upward from the
-importing file. Making `plans/tools` an eighth workspace member was rejected — it would add a
-`Dockerfile` COPY and a package to a workspace this plan exists to shrink.
+**Last updated:** 2026-09-08 — **one-core Phase 1 shipped** on branch `one-core/phase-1`
+(`7f294df` → HEAD, 33 commits, not merged): **dispatch is one scheduler, and the lane is the
+noun.** The slice-level record — every sha range, every divergence, both operator gates — is
+[`one-core-phase-1.md`](one-core-phase-1.md); the umbrella is
+[`one-core.md`](one-core.md) and the anchors are
+[`FACTS.md`](FACTS.md#one-core-phase-1-verified-2026-09-08). **No live number moved**: the
+before/after diff of `plans/tools/phase1-numbers.ts` over the real 78,000-video corpus is
+EMPTY for 1.1, 1.2, 1.4 and 1.5, and for 1.3 is 18 added lines / 0 removed — the two sweeps'
+scopes migrating into lane trees, both `enabled: false`.
+
+- **1.1 — four lanes in the model.** `AutoQueueKind` is `transcription | download | digest |
+ backfill`, exported as `LANES`, and `PauseLane` is an alias of it. `retainLeaves` was
+ DELETED rather than de-moded: `pending[leaf]` is what the leaf drew, so a post-hoc filter
+ over it can only re-apply a rule the draw already applied. The rule moved into the draw.
+- **1.2 — the runner runs operations.** `digestBatch.ts` (647) + `backfillBatch.ts` (937) →
+ one `controller/operationBatch.ts`. The status poll costs ~150 ms more for the two new
+ lanes and a `(mtime, size)` parse memo gives ~140 ms of it back. Three runner-only bugs
+ review found (a spend cap a 60 s TTL could reset, a context swapped under an in-flight
+ unit, a dead engine re-probed every tick) and one the e2e spec found: **every runner held
+ its own copy of `.auto-queue/state.json`, which is serialized whole**, so each persist
+ clobbered the other lanes' pick log. They share one object now.
+- **1.3 — the sweeps and the arbiter retire.** Arming is tree authoring; ten settings fields
+ migrate on read (`common/lib/laneMigration.ts`, a pure function tested through the
+ sanitizer) and are then deleted with their sanitizers and forms. `reach` and
+ `digest.recencyOrder` are RETIRED, not migrated, and the note says why. The one rendered
+ change in the whole phase is here and was kept on purpose: the /jobs strip's four lane rows
+ fold `gateHeld` now, so a held transcription row reads *Holding* where it read *Idle*.
+- **1.4 — pause is lane state.** One key, `autoQueue[lane].held`, behind the unchanged
+ `isGateHeld` / `withGateHeld`. The four legacy fields survive as `legacyGateHeld`'s
+ read-time input in `lib/laneMigration.ts` — NOT in `pauseGates.ts`, which would have pulled
+ the operation registry into every reader of settings.json. The transcription
+ intent-vs-pool asymmetry is intact.
+- **1.5 — one work list per lane in the snapshot.** `backfill.download` and
+ `backfill.transcription` are the fold of each lane's default buckets, so
+ `snapshot.backfill[op].ids` is where all four lanes' work lives; the runner reads it and
+ falls back to the same fold, which is the migration (nothing is regenerated).
+ `plans/tools/phase1-worklist-check.ts` checks all 68 live snapshots read-only: **OK**,
+ download 9, transcription 882, zero already carrying an entry.
+
+**Two operator gates are outstanding, and one of them gates a merge.**
+
+- **Gate A** — the digest lane's runner drives a production pass and comes back after a
+ restart. Run it from **`one-core/phase-1-gate-a`** (`0438a72`), which is the code as 1.2
+ left it; the step-by-step runbook, including the exact `settings.json` block and the
+ channel to point it at, is in `one-core-phase-1.md`'s "Operator gate A" section. It gates
+ the MERGE of 1.3 onward, not their authorship, which is why the branch exists.
+- **Gate B** — before the backfill lane is ever `enabled` in production, decide
+ `backfill.allowRedownload` and the channel scope. As configured today it would re-fetch
+ audio for ~66,540 videos. The migration left the lane disabled; the tree's channel leaves
+ are the scope from now on.
**BLOCKER FOR ANY EDITOR e2e IN THE PRIMARY CHECKOUT, and it is not ours.**
`editor/content` is an untracked **symlink to `/home/user/Projects/recipe-content`** (made
-2026-08-31, after the last recorded e2e on 2026-08-30). Tailwind's automatic source detection
-follows it and Turbopack **panics** compiling `editor/app/globals.css`:
-`FileSystemPath("editor").join("../../recipe-content") leaves the filesystem root`. The dev
-server never serves a page, so `pnpm e2e` dies before the first spec. **Reproduced on `main`
-at `5ffb0e1` with the same panic**, so it is environmental, not a Phase 0 regression — and
-`pnpm --filter editor exec next build` is unaffected (production builds do not run the dev
-source scan). The operator should remove or move that symlink.
-
-**The suite itself is green.** Run from a detached `git worktree` of `1691c4f` (no such
-symlink there): **511 passed, 0 failed, 23.8 min**, one worker, serial behind the queue lock.
-Nothing to compare against FACTS.md's known-failing list — that list was superseded
-2026-07-30 and the run had no reds at all. One extra step a worktree needs: its
-`export/public` holds only the tracked static assets, so the export webServer 500s and
-Playwright times out after 120 s. Seed it with a composed fixture site (the same one used for
-the wire diff) and it serves 200.
-
-**Next:** phase 1 (dispatch: one scheduler), 4–5 slices, starting with persisting the arbiter.
-Read `common/architecture.test.ts`'s allow-list first — it is the shortest accurate statement
-of what is still tangled.
+2026-08-31). Tailwind's source detection follows it and Turbopack **panics** compiling
+`editor/app/globals.css`, so `pnpm e2e` dies before the first spec. Reproduced on `main` at
+`5ffb0e1`, so it is environmental. Every Phase 1 slice ran its suite from a `git worktree`
+instead — which needs one extra step, a composed fixture site copied into its `export/public`
+so the export webServer answers 200 instead of 500. **`git add -A` stages that symlink**, and
+a commit made that way carries the panic into every worktree of it: add by path.
+
+**One known flake, reported and not fixed:** `video-page.spec.ts:216` ("Delete directory
+wrong-id confirmation shows an error") asserts a video directory still exists after a refused
+delete. It red once in 1.3's full run and passed in 1.4's and 1.5's, and passes 20/20 re-run
+alone on the same commit; nothing in this phase deletes a video directory.
+
+**The last full suite: 514 passed, 1 failed of 515, 25.1 min** from a worktree of `2133d94`.
+The red was slice 1.5's own new band assertion and it was right — writing the two new
+snapshot entries doubled `/channels`' Download and Transcribe coverage, because that page
+passes the external ids into `buildOperationBands` and `addRegistryEntry` folded them on top
+of `addExternalBands`. Fixed in `d8754d2`; the eight specs that could see it re-run from a
+worktree of that commit, **87 passed, 0 failed**.
+
+**Next:** phase 2 (the contract: one `ArchiveReader`), 3 slices. Read
+`common/architecture.test.ts`'s allow-list first — it is the shortest accurate statement of
+what is still tangled, it shrank by one across phase 1, and no slice added an entry.
+
+**Previously:** 2026-09-07 — **one-core Phase 0 shipped** on branch `one-core/phase-0`
+(`df5eb48` → `1691c4f`, six commits, not merged): **guardrails and dead weight**, every item a
+deletion, a move or a guard. `common/architecture.test.ts` is the layering (back-edges 16 → 12,
+the list can only shrink); `scripts/report-to-video` → `umtool/report-to-video`;
+`common/package.json` has an `exports` map; one `pageFileName` and one `CONTRACT`. **Nothing on
+disk in a corpus changed and nothing on the wire changed** — proved with a fixture corpus
+composed through `build-index` + `compose-site` before and after, identical apart from
+`generatedAt`. Detail: "Phase 0, as shipped" in [`one-core.md`](one-core.md); anchors in
+[`FACTS.md`](FACTS.md#one-core-phase-0-verified-2026-09-07).
**Previously:** 2026-08-31 — **the transcode operation is removed** (`904f1a9` → `f717a36`): it never fired in production. The census over 68 channels found the `untranscoded` bucket empty in every one, both `failed-transcodings` files 0 bytes, and only four channels even passing the stage's gate — and `resolveAudioFile` falls back to any real audio file anyway, so transcription never needed it. Gone: four controllers, four job kinds, the channel stage and station, the catalog entry and with it `appliesTo`/`operationApplies` (catalog 8 → 7). Kept: `transcodeAudio` and the download path, the per-file *Transcode …* rows, both wrong-format sweeps — whose bucket is `wrongFormatAudio` now. The slice-5 leftovers rode first (`measure-nav.mjs`, `BUILD_KINDS`, `EditorAliasesClient`). Plan: [`editor-transcode-removed.md`](editor-transcode-removed.md).
diff --git a/plans/one-core-phase-1.md b/plans/one-core-phase-1.md
@@ -1243,3 +1243,287 @@ whole reason the sanitizer refuses to default the key.
- **1.5 touches `generateChannelSnapshot`, not this.** No snapshot, bucket or
operation count moved in 1.4; the numbers script's snapshot section is unchanged
and remains the baseline.
+
+## 1.5, as shipped
+
+`4cb226e` (the entries) → `3912e4f` (the runner draws a lane) → `9daf8c6` (the
+live-corpus check) → `2133d94` (the e2e) → `a7922a3` (the projection stops
+copying) → `d8754d2` (the band the e2e caught) → the commit carrying this note,
+which cannot name its own sha. On `one-core/phase-1` off `1a4524b`.
+
+**Numbers: the before/after diff of `plans/tools/phase1-numbers.ts` over the live
+corpus is EMPTY** — 2,629 lines each, byte-identical. That is the slice's own
+migration test, twice over. No snapshot is regenerated, so the script's generic
+`Object.keys(snapshot.backfill)` walk finds the same four operations on all 68
+channels; and every `computeLeafPending` total is unchanged because the runner's
+new default draw — the lane's work list — is the same array the bucket union
+folded to, by construction.
+
+Verification: `tsc --noEmit` clean in common, editor, export, mcp, homepage,
+umtool and `plans/tools`; `yt-dlp-transcript-common` **908** tests (896 before:
+**+12** — eight in `autoQueuePolicy.test.ts`, three in `channelSnapshot.test.ts`,
+one in `operations.test.ts`), `yt-dlp-transcript-mcp` **205**, root
+`pnpm test:scripts` 71 passed / 1 skipped; `next build` clean; full editor e2e
+from a `p15-e2e` worktree of `2133d94` — **514 passed, 1 failed, 25.1 min**, one
+worker behind the queue lock. 515 total: 514 at 1.4 plus this slice's one new
+test. 1.3's `video-page.spec.ts:216` flake passed.
+
+**The one failure was this slice's own band assertion, and it was right** — see
+below. `d8754d2` fixes it, and the eight specs that could see the fold
+(`backfill`, `channels`, `channels-counts`, `channel-line`, `lane-runner`,
+`auto-queue`, `attribution`, `digest`) were re-run from a worktree of that
+commit: **87 passed, 0 failed, 4.6 min**. The only other code after the full run
+is `a7922a3`, which changes an allocation and nothing else.
+
+### What it writes, and why the two entries are hand-folded
+
+`generateChannelSnapshot` now writes `backfill.download` and
+`backfill.transcription`. They are the only entries in that map with no
+`Operation.state()` behind them — download and transcription are
+`EXTERNAL_OPERATIONS`, registered for the dependency graph and dispatched by
+their own runners — so `foldBucketLaneEntry` states the four numbers rather than
+counting classifications, and is EXTRACTED beside `foldBackfillEntry` for exactly
+the reason that one was: inside the generator it could only be reached with lmdb,
+an archive reader and a corpus on disk.
+
+| field | download | transcription |
+|---|---|---|
+| `ids` | `partialDownloads` ∪ `undownloadedIds` | `downloadedNoTranscript` ∪ `failedListed` |
+| `missing` | `ids.length` | `ids.length` |
+| `stale` / `partial` / `blocked` / `deferred` | 0 | 0 |
+| `missingInput` | 0 — the input is the listing | `buckets.noTranscript.length` |
+| `eligible` | `totals.downloaded + missing` | `totals.transcribed + missing + missingInput` |
+
+`stale` and `partial` staying 0 is what keeps `channelSnapshot.test.ts`'s
+invariant (`ids.length === reachableOperationWork(entry)`) true for these entries
+too, and `eligible` is defined so `presentOperationWork()` gives back
+`totals.downloaded` / `totals.transcribed` exactly — pinned by a unit test and,
+over all 68 live channels, by the check script. Videos excluded from download and
+untranscribable ones are in neither half: they are this operation's
+not-applicable, the same exclusion `foldBackfillEntry` applies.
+
+### The proof, both halves
+
+**(a) The projection equivalence, as a unit test.** Four cases in
+`autoQueuePolicy.test.ts` — both bucket lanes × `replaceAutoSubs` on and off —
+build the same tree over the same corpus under the bucket projection and the
+operation projection and assert the pending map deep-equals AND that draining
+both yields the same `<leaf>/<video>` sequence. A fifth asserts a snapshot with
+no entry falls back to an identical list.
+
+The corpus is SYNTHETIC rather than one of the e2e fixtures, and that is a
+divergence worth naming: there are no snapshot fixtures on disk (`snapshot.json`
+is generated at run time by every spec that needs one), and a synthetic one can
+carry the three traps a fixture does not — a video in two default buckets
+(`t1` in both `downloadedNoTranscript` and `failedListed`, so dedup order
+decides), an `undownloadedIds` that is NOT sorted, and a `match.bucket` retry
+leaf sitting above a catch-all so the claim space is exercised.
+
+**(b) `plans/tools/phase1-worklist-check.ts`, over the 68 live snapshots.**
+Read-only, regenerating nothing. It re-derives the union BY HAND from the literal
+bucket names in the literal priority order — the independent half, so the check
+is not the fold testing itself — and compares it to `bucketLaneWorkIds`, to
+`foldBucketLaneEntry(...).ids`, and to `reachableOperationWork`. It also asserts
+that **no live snapshot carries an entry yet** (the loudest possible failure if
+something wrote into `transcripts/`) and that `bucketsForKind` still matches the
+list the script spells out.
+
+```
+channels = 68
+missingSnapshots = 0
+snapshotsAlreadyCarryingAnEntry = 0
+download work list = 9 download present = 79041
+transcription work list = 882 transcription present = 78153
+OK
+```
+
+### Divergences from the 1.5 bullets, and why
+
+- **The download entry's order is `partialDownloads` ∪ `undownloadedIds`, not the
+ other way round.** The bullet's parenthetical — and the "Facts that shape the
+ slices" line above it — spell `undownloadedIds ∪ partialDownloads`, but
+ `DOWNLOAD_BUCKETS` is `["partialDownloads", "undownloadedIds"]` (the
+ half-fetched file before the one not started), and the bullet's own normative
+ clause is "the union `defaultBucketsForPolicy` draws". Writing the parenthetical
+ order would have made the entry disagree with the fallback the runner takes for
+ every un-regenerated snapshot, which is a change to WHICH VIDEO the download
+ lane picks first. `undownloadedIds` still keeps playlist order inside the union
+ and is never sorted. **Live impact zero — `partialDownloads` sums to 0 across
+ all 68 channels** — which is precisely why nothing but a test would have caught
+ it, and there is one.
+- **The transcription total is 882, not the 2,754 the brief expected.** 2,754 is
+ the union with `replaceAutoSubs` ON: 881 `downloadedNoTranscript` + 1,872
+ `downloadedAutoSubsOnly` + 1. The DEFAULT union is `downloadedNoTranscript` ∪
+ `failedListed` = 881 + 25, and **24 of those 25 are already in the first list**,
+ so the deduped answer is 882. The opt-in auto-captions bucket is a policy
+ switch, not a corpus fact, and folding it into a snapshot entry would make
+ `replaceAutoSubs` a property of the archive; it enters where it always has, at
+ the TAIL of `defaultDrawsForPolicy`.
+- **The lane's work list is projected into `ChannelWork.buckets`, under the
+ operation's id — not into `.operations`.** This is the change that makes "no
+ number moves" true rather than merely intended. `buildPendingByLeaf` claims a
+ video as `${operation}\0${id}`, so an operation draw and a bucket draw live in
+ DIFFERENT claim spaces: a video an explicit `failedListed` leaf had claimed
+ would have been claimed a second time by a catch-all leaf drawing the operation,
+ and appeared twice in one tick. On a bucket lane the operation and the bucket
+ union are the same work and there is no second operation to keep apart from, so
+ it shares the bucket claim space. `laneOperationIds` therefore still returns
+ `[]` for the two bucket lanes, and `dropCompleted`, `leafOperationIds`,
+ `markCompleted` and `runOperationPick` are untouched.
+- **`defaultBucketsForPolicy` is unchanged; a new `defaultDrawsForPolicy` sits
+ beside it.** The two answer different questions and 1.4's `policyDrawsBucket`
+ asks the first one ("would the runner draw this BUCKET for this channel", about
+ the population). The second is what the runner projects. Merging them would have
+ made `policyDrawsBucket` answer `false` for `downloadedNoTranscript` on a lane
+ that plainly draws it.
+- **`/channels` bands still keep their own fold, and the special case that went is
+ the ID LIST.** The bullet asks the bands to draw from the operation entries with
+ no rendered number changed; those two halves cannot both hold, and the
+ arithmetic is why. The band's transcription `reachable` is
+ `downloadedNoTranscript` ALONE — the retry bucket is not in it — so reading
+ `entry.ids` would take a rendered corpus figure from **881 to 882** per channel
+ fold and, on the `failedListed` channels, visibly; its `blocked` is
+ `noTranscript − untranscribable − downloadedNoTranscript`, which the entry calls
+ `missingInput`; and its `eligible`/`present` are the playlist and
+ `totals.downloaded`/`totals.transcribed`, coverage measures the entry STATES
+ rather than counts. Two honest answers to two different questions. What did stop
+ being a special case: `EXTERNAL_BAND_IDS` is `EXTERNAL_OPERATIONS.map(o => o.id)`
+ rather than a hand-written pair — the same declaration `pauseLaneFor` and the new
+ `bucketLaneOperationId` read — and `addExternalBands`' header now names the two
+ numbers that keep it, so the next reader does not have to re-derive the refusal.
+ `backfillLaneOperationEntriesOf` already filtered on `BACKFILL_QUEUE`, so the
+ four surfaces that sum the lane never saw the new entries at all.
+- **`laneForOperation` resolves EXTERNAL_OPERATIONS, not the whole catalog.** Sync
+ keeps its null: it is a per-channel cadence, not a per-video pipeline, which is
+ the same answer `pauseLaneFor` gives it. The old comment's fear — "a caller would
+ reserve the backfill queue for work no backfill operation can do" — is now two
+ assertions in the flipped test: neither declared lane is `BACKFILL_QUEUE`. The
+ one caller that reserves a key from this answer,
+ `operationJobs.runOperationChannelJob`, is only ever handed a registry id.
+- **`bucketIdsFrom` returns the STORED array, not a copy** (`a7922a3`). The first
+ version returned `readonly string[]` and `buildChannelWork` spread it — 68
+ channels x four buckets per scheduling tick and per three-second status poll,
+ up to 11,000 strings each, which is the allocation the snapshot parse memo
+ exists to avoid. Nothing mutates the list, and handing out the stored array is
+ what the code did before this slice.
+- **`bucketLaneOperationId` lives in `lib/operations.ts` and the fold in
+ `jobs/autoQueuePolicy.ts`, deliberately apart.** The obvious shape — one
+ function in `autoQueuePolicy.ts` — would have made `jobs/autoQueuePolicy.ts`
+ import `lib/operations.ts`, and `lib/settings.ts` imports `autoQueuePolicy` for
+ its sanitizers: that is the runtime cycle `settings → operations → controller →
+ settings` the 1.4 note documents avoiding for `legacyGateHeld`, arriving by a
+ different door. So the fold takes the work-list NAME as a parameter and the
+ controllers supply it. **No entry was added to `architecture.test.ts`.**
+
+### What the band assertion caught, and `d8754d2` fixed
+
+**Writing the two entries doubled two rendered numbers on `/channels`, and the
+e2e test written to prove they had not moved is what found it.** A three-video
+channel's row read *Download — 0 can run now · 6 done of 6*.
+
+`buildOperationBands` seeds the two external bands, folds `addExternalBands` per
+snapshot, and then folds `addRegistryEntry` for every id in `operationIds` —
+and `/channels` passes `[...EXTERNAL_BAND_IDS, ...allOperations]`, because it
+wants a column for every band. Until this slice that was harmless:
+`snapshot.backfill.download` did not exist, so `addRegistryEntry` returned early.
+The moment it existed, every one of the band's five numbers was counted twice.
+
+**This is exactly the trap `backfillLaneOperationEntriesOf` was written for, at
+the one surface that does not go through it.** Its header says so: the four
+surfaces that summed `Object.values(snapshot.backfill)` broke the day the map
+stopped being one lane, and they were fixed by filtering on the DECLARATION.
+`buildOperationBands` never summed the map — it indexes it by id — so it looked
+immune, and the id list it is handed is what made it not. The registry fold now
+skips the ids `addExternalBands` owns, with a unit test that fails without the
+filter.
+
+Worth saying plainly because the slice's own claim was "no rendered number
+moves": that claim was FALSE for two commits, and the only reason it is true now
+is that the assertion was written as an invariance check in the browser rather
+than as a re-reading of numbers copied out of an older build.
+
+### Carried into the deletion slice
+
+- **When `sanitizePolicy` gains a default for `held`, backfill's must be `true`.**
+ `sanitizeBackfill` defaults `enabled: false`, and `legacyGateHeld` reads
+ `backfill.enabled` INVERTED — so a fresh install with no `backfill` block reads
+ as HELD today. Defaulting `held: false` alongside deleting the fallback would
+ quietly un-hold the backfill lane on every settings file that has never been
+ written. The other three default `false`.
+- **`editor/app/jobs/active/buildActiveJobs.ts` (~`:407`) reads `isGateHeld` for
+ the transcription lane**, which is a UI surface reading STORED intent where
+ `pauseGates.ts` says every UI surface must read the worker pool. Pre-existing —
+ it predates 1.4 and 1.4 did not move it — and named here rather than fixed,
+ because the fix is a decision about what the /jobs strip means when the pool and
+ the file disagree, not a rename.
+- The 1.4 traps above all still stand, including the two greps
+ (`backfill.enabled` needs its own).
+
+## Phase 1, as shipped
+
+`7f294df` → `2133d94` plus this commit, **33 commits on `one-core/phase-1`** off
+`af2a360` (Phase 0's head). Five slices:
+
+| slice | range | what it did |
+|---|---|---|
+| 1.1 | `7f294df` → `3ced7dd` | four lanes in the model; `retainLeaves` deleted, not de-moded |
+| 1.2 | `83954f1` → `0438a72` | one `operationBatch.ts`; the runner runs operations on all four lanes |
+| 1.3 | `253826b` → `451c454` | the two sweeps and the arbiter retire; ten settings fields migrate then go |
+| 1.4 | `0040e39` → `1a4524b` | pause is `autoQueue[lane].held`, with the four legacy fields as a read-time fallback |
+| 1.5 | `4cb226e` → this commit | one work list per lane in the snapshot |
+
+**107 files, 9,357 insertions, 7,284 deletions** (101 files / 7,441 / 7,198
+outside `plans/`). **Eighteen files deleted, 5,273 lines**: `backfillBatch.ts`
+937, `digestBatch.ts` 647, `backfillSweep.ts` 517, `SweepLane.tsx` 422,
+`arbiter.ts` 416, `digestSweep.ts` 368, `sweepPreview.test.ts` 321,
+`SweepPlan.tsx` 264, `backfillBatch.test.ts` 231 (renamed to
+`operationBatch.test.ts`), `arbiter.test.ts` 177, `sweepPreview.ts` 167,
+`sweepPlan.ts` 163, `SweepScope.tsx` 154, `sweepRecency.ts` 141,
+`OrderReach.tsx` 133, `ArbiterBar.tsx` 113, `arbiterWork.ts` 67,
+`resume-backfill-sweep/route.ts` 35. Plus, in place: three job kinds, two boot
+resume hooks, ten settings fields with their sanitizers and forms, four sweep
+server actions, the arbiter's two actions, and `operations/lanes.ts` at 370 → 131.
+
+**Tests: common 876 → 908 (+32), mcp 205 → 205, e2e 511 → 515.**
+`architecture.test.ts`'s allow-list **shrank by one** (`lib/sweepPlan.ts ->
+controller/planOrder`, which died with the file) and **no entry was added by any
+slice** — the guard fails on a stale entry too, which is how that was checked
+rather than asserted.
+
+**Numbers: the before/after diff of `plans/tools/phase1-numbers.ts` over the live
+corpus is EMPTY for 1.1, 1.2, 1.4 and 1.5, and for 1.3 is 18 ADDED lines and zero
+removed** — the migrated `autoQueue.digest` / `.backfill` blocks, both
+`enabled: false` with one catch-all leaf, which is exactly what the file's
+disarmed, unscoped sweeps meant. Every pre-existing line is byte-identical in all
+five. **No live number moved across the phase.** The one rendered-text change
+anybody will see is named in 1.3's note and was kept on purpose: the /jobs strip's
+four lane rows now fold `gateHeld`, so a held transcription row reads *Holding*
+where it read *Idle*.
+
+### What stays, and what gates what
+
+- **Four legacy pause fields** — `transcriptionsPaused`, `downloadsPaused`,
+ `digest.digestsPaused`, `backfill.enabled` — remain in `SiteSettings` and in
+ their sanitizers as `legacyGateHeld`'s input and nothing else. `backfill.enabled`
+ has no writer left at all. The deletion condition is exact: **the live
+ `settings.json` carries all four `held` keys**, which happens on its first write
+ through the editor after 1.4. The two carried notes above belong to that slice.
+- **`sanitizePolicy` must still never default `held`**, and the backfill default
+ when it finally does is `true`. See above.
+- **Gate A** — the digest lane's runner driving a production pass and resuming
+ after a restart — is run from **`one-core/phase-1-gate-a`** (`0438a72`, the code
+ as 1.2 left it) and gates the MERGE of 1.3 onward, not their authorship. The
+ runbook is in the 1.2 section.
+- **Gate B** — before the backfill lane is ever enabled in production, decide
+ `backfill.allowRedownload` and the channel scope. As configured today it would
+ re-fetch audio for ~66,540 `missingInput` videos. The 1.3 migration left the lane
+ `enabled: false`; the tree's channel leaves are the scope from now on.
+- **`video-page.spec.ts:216`** ("Delete directory wrong-id confirmation shows an
+ error") is a known ordering flake, reported in 1.3 and 1.4 and not fixed: it
+ passes 20/20 when re-run alone on the same commit, and nothing in this phase
+ deletes a video directory.
+- **e2e cannot run in the primary checkout** while the untracked `editor/content`
+ symlink exists. Every slice ran from a `git worktree`, seeded with a composed
+ fixture site so the export webServer answers 200. `git add -A` in this repo
+ stages that symlink into the commit and carries the panic into the worktree —
+ add by path.
diff --git a/plans/one-core.md b/plans/one-core.md
@@ -183,6 +183,14 @@ Makes the later deletions safe and cheap.
### Phase 1 — Dispatch: one scheduler (5 slices)
+**SHIPPED 2026-09-08** on branch `one-core/phase-1` (`7f294df` → head, 33 commits,
+unmerged, off Phase 0's `af2a360`), all five slices. The record — every slice's sha range,
+every divergence, both operator gates and the totals — is
+[`one-core-phase-1.md`](one-core-phase-1.md); the anchors are
+[`FACTS.md`](FACTS.md#one-core-phase-1-verified-2026-09-08). **No live number moved**, and
+both gates (A: the digest lane's production pass, from `one-core/phase-1-gate-a`; B: the
+backfill lane's re-download scope) are the operator's and still outstanding.
+
Finishes `unified-operations-model.md` steps 5 and 6 and deletes what they retire. This is
the phase with live numbers on a 78,000-video corpus; every slice is measured
before/after with offline `tsx` over the real corpus (`plans/tools/phase1-numbers.ts`),