commit ca4be1962ddb125caf249a0d4ec6b6d3bd4aa100
parent 0b3a3c5f4fb54828d17f2fcf6dc1698729ef1812
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 8 Sep 2026 00:35:12 -0400
plans: slice 1.4 is shipped, and the record says where a lane's gate lives now
FACTS gains the reader/writer table, the import-cycle reason `legacyGateHeld`
sits in laneMigration.ts rather than pauseGates.ts, and the exact condition for
deleting the four retired fields; the phase-1 plan gains the as-shipped note with
the empty numbers diff, the e2e figure and its one red, seven divergences and
the traps for 1.5. Two comment-only
fixes ride along: `withGateHeld` no longer claims every writer was rewritten
(two of the three only ever preserved a value), and laneMigration.ts's header
admits it now holds two migrations.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 257 insertions(+), 3 deletions(-)
diff --git a/common/lib/laneMigration.ts b/common/lib/laneMigration.ts
@@ -1,3 +1,10 @@
+// THE RETIRED FIELDS, ON READ. Two migrations live here, one per slice, and
+// they share a shape: a settings key that used to live somewhere else is filled
+// in from the field it replaced, in getSettings, when — and only when — the new
+// spelling is absent. Slice 1.3's is the sweeps' scope becoming a lane's tree;
+// slice 1.4's is four pause flags becoming one `held` per lane, at the foot of
+// the file.
+//
// THE SWEEPS' LAST ACT: their persisted scope becomes a lane's tree.
//
// Two sweeps armed themselves through ten fields on `settings.digest` and
diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts
@@ -104,9 +104,11 @@ export function isGateHeld(settings: SiteSettings, lane: PauseLane): boolean {
// fields are migration INPUT now: once `held` is on disk, `isGateHeld` stops
// reading them, so a writer that also flipped `downloadsPaused` would be
// maintaining a value nothing consults — which is how two sources of truth
-// start. Every other writer of a pause was rewritten to come through here in
-// the same slice, for the mirror-image reason: flipping only the legacy field
-// would be silently ignored.
+// start. The one other CONTROL over a lane's gate — "Run the backfill lane",
+// in operations/settingsActions.ts — comes through here too, for the
+// mirror-image reason: a form still flipping the retired field would now be
+// silently ignored. (The two settings forms that merely PRESERVE a retired
+// field are not writers; they preserve the whole autoQueue beside it.)
//
// SPREAD-AND-OVERRIDE, never a rebuilt literal: the policy also carries the
// lane's TREE, its order and its snooze, and a literal here would drop an
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -3308,3 +3308,69 @@ opens the LMDB index and writes `plans/bakeoff/round.partial.json`. Run them und
bare `import "lmdb"` resolves from the importing file upward and the repo root's
`node_modules` holds only `tsx`.
+
+## one-core Phase 1 slice 1.4 (2026-09-08) — a lane's pause is a key on the lane
+
+**Supersedes the "four gates, three polarities" half of the 2026-08-28 slice-7 census
+above.** That entry is still the right description of what pauses look like from the UI
+(one control, one action pair, one polarity known in one file); what changed is WHERE the
+answer is stored.
+
+**One key: `autoQueue[lane].held`** (`common/lib/autoQueueTypes.ts`, `AutoQueuePolicy`).
+Optional, and **absent is not `false`** — `sanitizePolicy` passes a boolean through and
+leaves anything else `undefined`, deliberately, because every settings.json in existence
+spells one of the four retired fields and no key, and defaulting it would read a paused
+corpus as running.
+
+| retired field | polarity | now |
+|---|---|---|
+| `transcriptionsPaused` (`settings.ts:156`) | held = true | `autoQueue.transcription.held` |
+| `downloadsPaused` (`:161`) | held = true | `autoQueue.download.held` |
+| `digest.digestsPaused` (`:452`) | held = true | `autoQueue.digest.held` |
+| `backfill.enabled` | **inverted** — held = false | `autoQueue.backfill.held`, not inverted |
+
+All four fields **remain in the type and in their sanitizers**; they are the migration's
+INPUT and nothing else reads them. They are deleted in a later slice, once the live
+`settings.json` carries all four `held` keys (i.e. after one write through the editor).
+
+- **`isGateHeld(settings, lane)`** reads `settings.autoQueue?.[lane]?.held` and falls back
+ to `legacyGateHeld` when it is `undefined`. Both still touch only the named lane's field —
+ `laneGuards.test.ts` casts a `{digest}`-only object to `SiteSettings`.
+- **`legacyGateHeld` lives in `common/lib/laneMigration.ts`, not in `pauseGates.ts`**, and
+ the reason is an import cycle worth remembering: `getSettings` must run the copy on every
+ read, and `pauseGates.ts` imports `lib/operations.ts` (for `pauseLaneFor`), which imports
+ the controller layer. Putting it in pauseGates would pull the whole operation registry
+ into every reader of settings.json — `bin/` scripts, the MCP server, the export build.
+- **`withGateHeld` writes the new key only** and spreads the policy. What a rebuilt literal
+ would now drop is the lane's TREE, its order and its snooze.
+- **`getSettings` copies legacy → `held`** via `migrateHeldToLanes(merged)`, placed AFTER
+ `sanitizeDigest`/`sanitizeBackfill` (two of the four fields live in those blocks) and
+ therefore not beside `migrateSweepsToLanes`, which runs on the PARSED file. It never
+ unholds a lane, returns the input object unchanged when every lane already carries a key,
+ and treats `held: false` as an answer rather than an absence.
+- **The transcription asymmetry is unchanged.** The stored value is intent;
+ `getWorkerPool().isPaused()` is live state; `editor/instrumentation.ts` re-applies the
+ pool from `isGateHeld(getSettings(), "transcription")` at boot; no UI surface reads the
+ stored value. That is why the e2e fallback proof uses the DIGEST lane — a fixture flag on
+ transcription is not observable through any page by design.
+- **Only the two operation lanes hold through `limit()`.** `laneLimit` returns
+ `{limit: 0, hold: {reason: "paused"}}` for digest and backfill; the download lane's gate
+ is asked in `next()` (idle reason `downloads-paused`) and transcription's is the worker
+ pool's slot count. One gate, three shapes.
+- **Writers.** `withGateHeld` is reached by `pauseLaneAction`/`resumeLaneAction`
+ (`operations/actions.ts`) and by `saveBackfillLaneSettingsAction` — the "Run the backfill
+ lane" checkbox, still a deliberate second writer of one key, whose `defaultChecked` is now
+ `!held` from `isGateHeld`. `saveAutoQueueAction` spells every policy field explicitly and
+ therefore carries `held` beside `snoozeUntil` (asserted in `auto-queue.spec.ts`).
+ `armLaneAction`, `disarmLaneAction` and `snoozeAutoQueueAction` spread the policy and are
+ safe by construction. `settings/actions.ts` and `saveDigestSettingsAction` PRESERVE their
+ retired field rather than rebuilding it, because that is what an unmigrated file reads.
+- **`AutoQueueReach`, `AUTO_QUEUE_REACHES`, `sanitizeAutoQueueReach` and
+ `OrderReach.tsx` are deleted.** The component is `LaneOrder.tsx`; every caller had passed
+ `reach={null}` since slice 1.3.
+
+**Numbers: the before/after diff of `plans/tools/phase1-numbers.ts` over the live corpus is
+EMPTY** (2,629 lines each), which is a direct test of the fallback: the live file carries
+`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`.
diff --git a/plans/one-core-phase-1.md b/plans/one-core-phase-1.md
@@ -1064,3 +1064,182 @@ two of its four sentences) is unreachable — every caller passes `null`.
- **`backfill.enabled` is a lane gate AND a form checkbox.** 1.4's "delete the
four legacy fields" step has to decide what "Run the backfill lane" writes;
today it writes the same field the pause does, on purpose.
+
+## 1.4, as shipped
+
+`0040e39` (the model, the gate and the read-time copy) → `3ab52ba` (the editor's
+writers) → `fabde02` (the dead `reach` code) → `63242e4` (fixtures, specs and the
+three-surface e2e) → `960f51a` (the one spec the full run caught) → the commit
+carrying this note, which cannot name its own sha. On `one-core/phase-1` off
+`451c454`.
+
+**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 not a formality here,
+it is the slice's own fallback test: the live `settings.json` carries
+`transcriptionsPaused: true`, `downloadsPaused: false`, `digest.digestsPaused:
+false`, `backfill.enabled: true` and **no `held` anywhere**, so the script's four
+`held <lane>` lines (`true/false/false/false`) are produced entirely by the legacy
+path through `isGateHeld`.
+
+Verification: `tsc --noEmit` clean in common, editor, export, mcp, homepage and
+umtool; `yt-dlp-transcript-common` **896** tests (890 before: **+6** — three in
+`pauseGates.test.ts` replacing two, so net +1; four `migrateHeldToLanes` cases in
+`jobs/laneMigration.test.ts`; one hold-not-stop case in `operationBatch.test.ts`),
+`yt-dlp-transcript-mcp` **205**; `next build` clean; full editor e2e from a
+`p14-e2e` worktree of `63242e4` — **513 passed, 1 failed, 25.3 min**, one worker
+behind the queue lock. **514 total: 512 at 1.3, plus this slice's two new tests.**
+1.3's `video-page.spec.ts:216` flake passed this time.
+
+**The one failure was mine, and the full run is what caught it.**
+`operation-settings.spec.ts:104` ("the lane's switch survives a save of the
+operation form beside it") ticks "Run the backfill lane", saves the diarization
+form beside it, and asserted the switch by reading `backfill.enabled` — the field
+the checkbox stopped writing three commits earlier. The property and the clicks
+are unchanged; only the key it reads back moved, and checked now means
+`held: false`. Fixed in `960f51a` and re-run from a worktree of that commit —
+`operation-settings.spec.ts`, `lane-runner.spec.ts` and `backfill.spec.ts`
+together, **31 passed, 0 failed**.
+
+**How it was missed, because the shape recurs:** the bullet's grep is
+`transcriptionsPaused|downloadsPaused|digestsPaused` over `editor/e2e`, and the
+FOURTH field is spelled `backfill.enabled` — a name too generic to grep for
+blindly, which is why the census that found it (`grep -rn "backfill\.enabled"`)
+was run over `common/` and `editor/app` with `e2e/` EXCLUDED, to keep the output
+readable. The lane's inverted field is the one that needs both greps.
+
+### Where a lane's gate is written, and by whom
+
+| surface | control | writes |
+|---|---|---|
+| dashboard deck (`LaneDeck`) | Pause / Hold, all four lanes | `autoQueue[lane].held`, via `pauseLaneAction`/`resumeLaneAction` → `withGateHeld` |
+| monitor widget rail | the same deck | the same |
+| runner console (`LaneHeader`) | Hold the lane | the same |
+| `/workers`, `/jobs` strip | Pause Transcriptions / Downloads | the same (transcription flips the POOL first) |
+| `LaneSettingsForm` | "Run the backfill lane" | `autoQueue.backfill.held`, via `saveBackfillLaneSettingsAction` → `withGateHeld` — a deliberate second writer of one key |
+| lane console policy form | Save policy | carries `held` through `saveAutoQueueAction`, beside `snoozeUntil` |
+| `armLaneAction` / `disarmLaneAction` / `snoozeAutoQueueAction` | arm, disarm, snooze | nothing — they spread the policy and carry `held` for free |
+| `/settings` form, digest settings form | — | nothing; they PRESERVE their retired field and the whole `autoQueue` |
+| `getSettings` | — | nothing on disk: it fills `held` in memory, and the next write persists it |
+| `editor/instrumentation.ts` | boot | nothing; it READS `isGateHeld(…, "transcription")` and applies it to the pool |
+
+Every reader is `isGateHeld`, unchanged: `laneGuards` (digest), `operationBatch`
+(backfill), `autoRunner` (download), `operations/status.ts`, `api/widget/sync`,
+`buildActiveJobs`, `buildWorkers`, `channels/groupActions`, `pipelineActions` and
+the numbers script.
+
+### What stays, and the condition for deleting it
+
+`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and
+`backfill.enabled` **remain in `SiteSettings` and in their sanitizers**, as the
+migration's input and nothing else — no reader outside `legacyGateHeld` is left,
+and `backfill.enabled` now has no WRITER at all. They go in a later slice, and the
+condition is exact: **the live `settings.json` carries all four `held` keys**,
+which happens on its first write through the editor after this slice ships. Until
+then a file that has never been written is still read correctly, and that is the
+whole reason the sanitizer refuses to default the key.
+
+### Divergences from the 1.4 bullets, and why
+
+- **`legacyGateHeld` lives in `common/lib/laneMigration.ts`, not in
+ `pauseGates.ts`.** The bullet has `isGateHeld` "falling back to the legacy
+ field", which reads as "in that file"; it cannot be. `getSettings` has to run the
+ copy on EVERY read of settings.json, and `pauseGates.ts` imports
+ `lib/operations.ts` (for `pauseLaneFor`), which imports the controller layer. A
+ pause fallback defined there would pull the entire operation registry into every
+ reader of settings.json — `bin/` scripts, the MCP server, the export build — and
+ make a runtime cycle out of `settings → pauseGates → operations → controller →
+ settings`. So the four RETIRED fields are read beside the other retired fields,
+ and `isGateHeld` delegates. There is still exactly one place that knows the
+ inversion.
+- **The copy runs on the MERGED settings, not the parsed file.** `migrateSweepsToLanes`
+ takes `parsed` because "absent from the file" is its trigger; this one's trigger
+ is "the sanitized policy has no `held`", and two of the four fields it reads live
+ in the `digest` and `backfill` blocks, which are sanitized further down
+ `getSettings`. It is called after them.
+- **Only ONE writer of a legacy pause field actually existed, and it is the one
+ that was rewritten.** The bullet names three. `saveBackfillLaneSettingsAction`
+ genuinely flipped `backfill.enabled` from a checkbox and now goes through
+ `withGateHeld`. The other two — `settings/actions.ts`'s spread and
+ `saveDigestSettingsAction`'s `digestsPaused: dD.digestsPaused` — PRESERVE the
+ current value inside an object that also preserves the whole `autoQueue`, so
+ they cannot flip either half and cannot drift. Routing them through
+ `withGateHeld` would have turned two unrelated settings forms into pause
+ writers, which is a larger claim than the bug being avoided. Both carry a
+ comment saying they are preserving migration input.
+- **"limit() returns 0 for a held lane (all four)" is not a true statement, and
+ the test says what is.** Only the two OPERATION lanes hold through `limit()`:
+ `laneLimit` returns `{limit: 0, hold: {reason: "paused"}}` for digest and
+ backfill. The download lane's gate is asked in `next()` (idle reason
+ `downloads-paused`) and transcription's is the worker pool's slot count — one
+ gate, three shapes, which is the asymmetry `pauseGates.ts` has always
+ documented. So the unit test pins the two lanes where the claim holds, through
+ the NEW key and against a disagreeing legacy field in both directions, and
+ `lane-runner.spec.ts` proves the other half from the browser: `running` stays
+ true at `lane-held`, on all three surfaces.
+- **The e2e fallback proof uses the DIGEST lane, not transcription.** The bullet
+ offers `/operations/transcription` "or the status API"; neither can show it. No
+ UI surface may read transcription's stored value — every one of them reads the
+ pool, `/api/auto-queue/status` included (`status.ts:113-117`) — so a fixture
+ carrying `transcriptionsPaused: true` is correctly invisible to a page, and a
+ spec asserting otherwise would be asserting the bug that rule exists to
+ prevent. Digest's flag IS its gate, so the fallback is observable there.
+ Transcription's fallback is covered by `pauseGates.test.ts` and, over the live
+ corpus, by the numbers diff above.
+- **`OrderReach.tsx` is `LaneOrder.tsx`.** Deleting the `reach !== null` branch
+ leaves a component named for an axis it no longer has, on a page whose whole
+ argument is that a runner has no such axis. One import site.
+- **`LaneSettingsForm`'s sweep prose went with the checkbox.** It still told the
+ operator that the lane's scope "lives in the sweep controls directly above" —
+ controls slice 1.3 deleted. It names the rule list on the console now, and the
+ checkbox says it is the same gate as the Hold button.
+- **No `/api/test/settings` route was added.** The e2e helper `readJson(
+ "test-settings.json")` already reads the file the editor writes; a route would
+ have been a second way to ask.
+
+### What the specs pin now
+
+- `auto-queue.spec.ts` — the runner-page pause writes `autoQueue.download.held`;
+ a hold survives a rule reorder through `saveAutoQueueAction` (`held=true` is part
+ of the same poll that asserts the order landed); and **an unmigrated file** — the
+ retired `digest.digestsPaused`, no key — reads as held on `/api/auto-queue/status`
+ and on the console, and carries `autoQueue.digest.held` after the first toggle.
+- `lane-runner.spec.ts` (6) — the console, the dashboard and the widget each hold
+ the digest lane by writing the same key, with the other three lanes' keys
+ compared before and after each hold AND each release, and the runner asserted
+ `running` with `idleReason: "lane-held"` at every one.
+- `dashboard.spec.ts`, `backfill.spec.ts` (two tests), `widget.spec.ts` — the same
+ reads, moved off `downloadsPaused` and `backfill.enabled`; the backfill helper's
+ polarity stops being inverted.
+- `disk-space.spec.ts` — its three fixtures spell the manual pause as
+ `autoQueue.download.held`, which is what an editor-written file now looks like.
+
+### Traps for the 1.5 implementer
+
+- **`sanitizePolicy` must never default `held`.** An absent key is what makes the
+ legacy fallback fire; defaulting it to `false` reads every unwritten
+ settings.json as "no lane held" and resumes a paused corpus. The deletion slice
+ removes the FALLBACK and the four fields together — at which point defaulting it
+ becomes correct and is a required part of that change, not an optional tidy.
+- **`held: undefined` never reaches the file**: `JSON.stringify` drops it, so a
+ lane that has not been through `getSettings`' copy stays genuinely absent rather
+ than gaining a null.
+- **`plans/tools/phase1-numbers.ts` still prints `backfill.enabled`,
+ `transcriptionsPaused` and `downloadsPaused` off the settings TYPE, and
+ `digest.sweepEnabled`/`backfill.sweepEnabled` off the RAW FILE.** The deletion
+ slice moves the first three to the raw file or drops them; either way it is a
+ numbers-diff-visible change and has to be explained in that slice's note.
+- **`backfill.enabled` has no writer left.** It is frozen at whatever the file
+ says, which on the live corpus is `true`. Nothing reads it but the migration —
+ do not "fix" a surface to write it again.
+- **`laneGuards.test.ts` and `operationBatch.test.ts` cast partial objects to
+ `SiteSettings`.** `isGateHeld` reads `settings.autoQueue?.[lane]?.held` with
+ optional chaining for exactly that reason, while `legacyGateHeld` keeps the
+ original non-optional access to the digest and backfill blocks — an absent block
+ throws there, as it always did, rather than silently answering "not held" (which
+ for the inverted backfill field would be the WRONG default).
+- **Grep `editor/e2e` for `backfill.enabled`, not just for the three `*Paused`
+ names.** That asymmetry cost this slice its only red — see above. The deletion
+ slice touches the same four fields and will hit the same trap.
+- **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.