commit c6fce8148e47f3f5a3b1b22feabc89f536f16bde
parent 428a54e67ce4962550119321abbfbc5a437becce
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 20:21:49 -0400
plans: FACTS anchors for the view route and the settings schema
New section verified at a7501cb3: /api/view/[name], the total VIEWS
table, VIEW_CONTRACT, the pulse text guard, the eight rewrites (API paths
are rewritten, never redirected), usePolledPayload, zod and the schema
modules, saveSettings as the only editor importer of writeSettings, the
generated SETTINGS.md / example, allow-list 9, the two numbers tools.
Anchors into deleted routes and the old settings.ts layout are corrected
in place where they sit in live tables, and indexed where they are
historical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
| M | plans/FACTS.md | | | 154 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
1 file changed, 143 insertions(+), 11 deletions(-)
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -189,7 +189,7 @@ All three are **already selectable** — `common/lib/transcriptionApps.ts:248-25
Exposed to the settings form via `listTranscriptionApps()` (`:272`), consumed at
`editor/app/settings/page.tsx:45`. Per-worker `appId` means a mixed fleet already works
-(`common/lib/settings.ts:837`).
+(`Worker.appId`, `common/lib/workers.ts:102`; this anchor was `settings.ts:837` before 2026-09-23).
**Nothing measures relative speed.** Phase 0 fills that gap — record the numbers below.
@@ -919,11 +919,11 @@ anything with a settings surface gets one live run and one assertion.
| Replay handlers | `editor/app/jobs/jobReplayRegistry.ts:75-236` | Bucket kinds re-derive ids from the live snapshot (`:50-57`), never a frozen list. |
| Snapshot buckets | `common/controller/channelSnapshot.ts:57-143` | 21 buckets, all `string[]` of video ids. Readers default `?.length ?? 0`. `undownloadedIds` is deliberately **outside** `buckets` (`:144`). |
| Channel-work sections | `editor/app/components/channelWork/sections.tsx` | `SectionConfig` + `channelWorkSections()` + `sectionsFor(op)`; rendered by `ChannelWorkTable.tsx` (a SERVER component — `primaryAction` is a function). Eight sections: three on `/operations/download`, two on `/operations/transcription`, one on `/operations/digest`, two (`operation: null`) on `/cleanup`. Counters live in `editor/app/lib/actionable/loadActionable.ts`; the review half is `editor/app/review/lib/loadReview.ts`. Unit-tested in `sections.test.ts`. |
-| Widget sync payload | `editor/app/api/widget/sync/route.ts:15-26` | Comment at `:10-14` states it deliberately stays "a handful of scalars". `buildWidgetSyncPayload()` (`:30`) is exported for SSR seeding. |
+| Widget sync payload | `common/views/widgetSync.ts:22` (`WidgetSyncPayload`) | Comment at `:18` states it deliberately stays "a handful of scalars". `buildWidgetSyncPayload(inputs)` (`:174`) is also used for SSR seeding. Served as view `widgetSync`; `/api/widget/sync` is a rewrite (2026-09-23; the route file is gone). |
| Pipeline band | `editor/app/components/dashboard/PipelineBand.tsx:63-114` | Pure props, no fetching. `<Instrument dotClass=…>` encodes state color. |
-| Operations board | `editor/app/operations/page.tsx` | `/operations`. Rail + sync row, SSR-seeded, polls `/api/auto-queue/status` every 3 s. `data-board="operations"` carries `data-hydrated`. (The arbiter bar was here until slice 1.3.) |
+| Operations board | `editor/app/operations/page.tsx` | `/operations`. Rail + sync row, SSR-seeded, polls `/api/view/autoQueueStatus` every 3 s (`useOperationsStatus.ts:26`; the old path is a rewrite since 2026-09-23). `data-board="operations"` carries `data-hydrated`. (The arbiter bar was here until slice 1.3.) |
| One operation | `editor/app/operations/[id]/page.tsx` | `/operations/<id>`, **routed off `operationCatalog()`** — an unknown id is `notFound()`, a new registry entry needs no route work. Every operation with a lane (`pauseLaneFor`) renders `RunnerOperationView` inside `<section data-lane="transcription"\|"download"\|"digest"\|"backfill">`; since slice 1.3 that is the only lane section on the page. |
-| Retired editor routes | `editor/next.config.ts` `redirects()` | `/auto-queue` → `/operations` and `/actionable` → `/operations`, both **temporary (307)**, not permanent — a 308 on a self-hosted admin surface is a support call with no remedy. Query strings pass through. The API paths `/api/auto-queue/{status,control}` and `/api/widget/actionable` did **not** move. |
+| Retired editor routes | `editor/next.config.ts` `redirects()` | `/auto-queue` → `/operations` and `/actionable` → `/operations`, both **temporary (307)**, not permanent — a 308 on a self-hosted admin surface is a support call with no remedy. Query strings pass through. The API paths `/api/auto-queue/{status,control}` and `/api/widget/actionable` did **not** move — since 2026-09-23 `/api/auto-queue/status` and `/api/widget/actionable` are `rewrites()` onto `/api/view/<name>` (`:139-148`). **API paths are rewritten, never redirected.** |
---
@@ -4142,7 +4142,7 @@ never pulls `reader-fs.ts` into a client chunk.
`getWorkerPool(`, `getPaths(`, `getSettings(`, `getAutoRunnerStatus(`, `readChannelStat(`,
`readJobMeta(`, `listChannelBriefs(`, `listChannelConfigs(`, `readSchedulerState(`,
`readAutoQueueState(`, `computeLeafPending(`, `readWorkerDefaults(`, `diskGate(`, `Date.now(`.
- A comment that mentions one with its parenthesis fails it; reword. **The allow-list is 10.**
+ A comment that mentions one with its parenthesis fails it; reword. **The allow-list is 10** (9 since slice 4a, 2026-09-23).
- **Views import common RELATIVELY.** `common/package.json` exports `"./views/*": "./views/*.ts"`
(a string) and both test-glob brace lists include `views`; `noCorpusWalkInRenderPaths.test.ts`
walks `common/views` too. A `.tsx` under `views/` would need its own exports line.
@@ -4159,7 +4159,8 @@ never pulls `reader-fs.ts` into a client chunk.
`operations/channelPriorityView.ts` (86; `readPriorityView` stays), `scheduler/status.ts`
(32; `resolveHeartbeatSeconds` imports `runTick`, never from views),
`widget/lib/syncInputs.ts` (25). `operations/lanes.ts` is DELETED. `api/widget/sync/route.ts`
- and `api/pulse/route.ts` export exactly `GET` and `dynamic`. Every editor import of a shell
+ and `api/pulse/route.ts` export exactly `GET` and `dynamic` (both DELETED 2026-09-23 by slice 2;
+ the handlers are in `api/view/views.ts`). Every editor import of a shell
is a value import; every payload TYPE is imported from `yt-dlp-transcript-common/views/*`.
- **`/api/pulse` rev bytes are unchanged**; `views/pulse.test.ts` asserts the hashed string
through an identity digest, so a reorder names what moved.
@@ -5228,7 +5229,8 @@ Three branches off `4ac8ceda`, merged in order: `1a011d96` alone, then `tags/rul
### S0-pause — the four legacy pause fields are DELETED, and `held` defaults per lane
- Gone from `SiteSettings`, from every sanitizer, and from `writeSettings`' merge literal
- (`common/lib/settings.ts:1713-1718`): `transcriptionsPaused`, `downloadsPaused`,
+ (`common/lib/settings.ts:1713-1718` at the time; since 2026-09-23 the writer parses through
+ `siteSettingsSchema`, `settings.ts:242`): `transcriptionsPaused`, `downloadsPaused`,
`digest.digestsPaused`, and the inverted `backfill.enabled`. A `settings.json` that still
spells one is read past on load and **loses it on the next write**. `legacyGateHeld` and
`migrateHeldToLanes` no longer exist anywhere in the tree.
@@ -5236,9 +5238,10 @@ Three branches off `4ac8ceda`, merged in order: `1a011d96` alone, then `tags/rul
`migrateSweepsToLanes` (slice 1.3's sweep-scope migration), which is live. Its header
(`:6-12`) says the pause migration is the part that went.
- **The default is the retired fields' reading, preserved.** `defaultHeldFor(lane)`
- (`common/jobs/autoQueuePolicy.ts:684-686`) is `lane === "backfill"`, applied by
- `sanitizeAutoQueue` at `:749` (`held: typeof r.held === "boolean" ? r.held : defaultHeldFor(lane)`)
- and by `defaultAutoQueuePolicy` at `:695`. The three paused-flags defaulted false; the
+ (`common/jobs/autoQueuePolicy.ts:684-686` then; `common/lib/autoQueueSchema.ts:204-206` since
+ 2026-09-23) is `lane === "backfill"`, applied by
+ `sanitizeAutoQueue` at `:749` (now `autoQueueSchema.ts:269`) (`held: typeof r.held === "boolean" ? r.held : defaultHeldFor(lane)`)
+ and by `defaultAutoQueuePolicy` at `:695` (now `:217`). The three paused-flags defaulted false; the
inverted `backfill.enabled` defaulted false and therefore shipped the backfill lane HELD,
which is why backfill's default is the odd one. Defaulting became REQUIRED rather than
merely tidy: from slice 1.4 until S0-pause an absent `held` had a legacy field to fall back
@@ -5246,7 +5249,7 @@ Three branches off `4ac8ceda`, merged in order: `1a011d96` alone, then `tags/rul
- **The backfill lane is off twice over on a fresh install** — unarmed (`enabled: false`) and
held. That is gate B kept as two deliberate acts, not one. Asserted through the sanitizer,
where a reader would actually hit it:
- `common/jobs/autoQueuePolicy.test.ts:390` *"sanitizeAutoQueue: a lane naming no gate gets
+ `common/jobs/autoQueuePolicy.test.ts:396` (was `:390`) *"sanitizeAutoQueue: a lane naming no gate gets
the default its retired field gave it"*.
- **A grep for `downloadsPaused` still hits, and it is not a survivor.**
`common/views/workers.ts:48,109` has a view-model field of that name, computed
@@ -5371,3 +5374,132 @@ that). It refuses rather than rebuilding: "deploy the export" is the operator
saying *ship what is there*. `buildAndDeployAction` needs no check — it builds.
The ops route surfaces it as the 400/`skipped` reason it already returns for any
action error.
+
+
+## One-core Phase 3 slices 2 + 4a (verified 2026-09-23, `main` @ `ae2fa5a9`)
+
+Every `file:line` below was grepped at `ae2fa5a9`. Records: `one-core-phase-3.md`, "Release
+2026-09-23" and the two "as shipped" sections.
+
+### One polling route — `/api/view/[name]` (slice 2, merged `8d6e84f6`)
+
+- **The route.** `editor/app/api/view/[name]/route.ts` (41 lines): `dynamic = "force-dynamic"`
+ (`:8`), `isViewName` checks the name against `VIEW_NAMES` (`:26-28`) **before any input is
+ constructed** — an unknown name is a 404 with no corpus read (`:37-39`) — then
+ `VIEWS[name](request)` (`:40`). No auth and no `EDITOR_TEST_ROUTES` guard, by design: the
+ eight routes it replaced had none, and the header (`:15-21`) says no `/api/test/*` name may
+ ever join the tuple.
+- **The table is total.** `editor/app/api/view/views.ts:36`,
+ `VIEWS: Record<ViewName, (req) => Promise<Response>>` — a name in the tuple with no handler
+ is a tsc error. Each handler is a lazy closure that assembles its OWN inputs exactly as its
+ old route did; there is no shared constructor, and adding one is the regression the header
+ (`:19-35`) names.
+- **`VIEW_CONTRACT`** in `common/views/names.ts:46-55` declares each view `observe` or
+ `construct`; `VIEW_NAMES` is the tuple at `:20-29`, `ViewName` at `:31`. `pulse` is the only
+ `observe` view.
+- **Pulse observes, checked as text.** `editor/app/api/view/pulseView.ts:30` calls
+ `computePulse(observeInputs())`. `editor/app/api/view/views.test.ts:33-38` is the
+ context-blind ban list (`liveInputs(`, `getRegistry(`, `getSettings(`, `getWorkerPool(`);
+ `:40-55` requires `observeInputs(` in `pulseView.ts` and bans the four; `:57-67` applies the
+ same ban to the dispatcher `[name]/route.ts`. A comment naming one with its parenthesis
+ fails it. `:79-84` is the compile-time `CleanableChannelRow` → `CleanableChannel`
+ assignability check.
+- **API paths are rewritten, never redirected.** `editor/next.config.ts:139-148`: eight
+ `rewrites()` entries, `/api/pulse`, `/api/jobs/active`, `/api/workers`,
+ `/api/auto-queue/status`, `/api/scheduler/status`, `/api/widget/{sync,actionable,cleanable}`
+ → `/api/view/<name>`. A rewrite is server-internal: method, status, body and query string
+ (`?rev=`) pass through, so a pinned widget or a script on an old path keeps working. The
+ comment at `:117-138` explains why; the e2e suite still asserts at the OLD paths on
+ purpose. `redirects()` (`:100-114`) is for PAGES only. `/api/widget/presets` is not a view
+ and keeps its own route file.
+- **Eight route files are deleted**: `api/pulse`, `api/jobs/active`, `api/workers`,
+ `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
+ 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`),
+ every tick an `AbortController` aborted on cleanup combined with
+ `AbortSignal.timeout(pollTimeoutMs(pollMs))` via `AbortSignal.any` (`:94-96`);
+ `pollTimeoutMs = max(10 s, 3 × pollMs)` (`:43`); `{ immediate?: boolean }` (`:37`,
+ `false` for the SSR-seeded operations board and sync console). Used by `DashboardCockpit`,
+ `JobsTable`, `MonitorWidget`, `useOperationsStatus`, `SyncConsole`. **Not** by
+ `components/pulse.ts` (its own `?rev=` loop, `:35,:88`) or `WorkersView.tsx` (its own
+ `setTimeout` loop, `:56-61`) — both poll `/api/view/*` directly.
+- **Numbers tool:** `plans/tools/phase3-view-numbers.ts` — offline, read-only, `now` frozen;
+ dumps `widgetActionable`, `cleanable`, `widgetSync` as sorted-key JSON.
+
+### One settings schema — zod (slice 4a, merged `ae2fa5a9`)
+
+- **zod is a `common` dependency** (`common/package.json:69`, `^4.3.6`). It must never reach a
+ client bundle: the three zod seams live in `common/lib/settingsFieldSchemas.ts`
+ (`settingsField` `:49`, `workersSchema` `:53`, `channelPrioritySchema` `:57`,
+ `autoQueueSchema` `:61`), NOT beside their sanitizers, because `workers.ts`,
+ `channelPriority.ts` and `autoQueueSchema.ts` are value-imported by `"use client"` forms.
+- **The schema.** `common/lib/settingsSchema.ts`: `siteSettingsSchema = z.object({…})` at
+ `:1436` (31 top-level fields, each `.describe()`d), `SiteSettings = z.infer<…>` at `:1543`,
+ `defaults()` = `siteSettingsSchema.parse({})` at `:1548-1549`, `defaultSiteSettings()` at
+ `:1555`. Every field is `z.unknown().catch(undefined).transform(coerce)` over the old
+ clamp/sanitizer — no `.default()`, no `.passthrough()`. The types, constants, clamps and
+ block sanitizers that were in `lib/settings.ts` live here now.
+- **`common/lib/settings.ts` is I/O only — 250 lines, was 1,783.** `export * from
+ "./settingsSchema"` (`:52`), so no importer changed. `getSettings` `:124-126` =
+ `finishRawMigrations(siteSettingsSchema.parse(premigrateRaw(raw)), raw)` (`premigrateRaw`
+ `:90`, `finishRawMigrations` `:107`); `writeSettings` `:235`, parsing at `:242-244`
+ (`deriveWorkerShadow` `:187`, `validatedSocialLinks`) then tmp + rename. **Every anchor of
+ the form `settings.ts:<n>` recorded before 2026-09-23 predates this layout — line numbers
+ under 250 included.**
+- **The auto-queue sanitizer moved** from `jobs/autoQueuePolicy.ts` to
+ `common/lib/autoQueueSchema.ts` (the picker stays and re-exports every moved name):
+ `defaultHeldFor` `:204-206`, `defaultAutoQueuePolicy` `:208` (`held` at `:217`), the `held`
+ fallback in the policy sanitizer `:269`, `sanitizeAutoQueue` `:277`. The allow-list entry
+ `lib/settings.ts -> jobs/autoQueuePolicy` is burned: **`common/architecture.test.ts`
+ `ALLOWED` (`:64`) has 9 entries**, was 10.
+- **Nested keys are documented by type.** `common/lib/fieldDocs.ts:14`,
+ `FieldDocs<T>` requires one string per key of `T` (optional keys and every union member's
+ keys included), so an undocumented new nested field is a tsc error. 24 `*_FIELD_DOCS`
+ records across `settingsSchema.ts` (9), `channelPriority.ts` (4), `autoQueueTypes.ts` (3),
+ `storageLocations.ts` (3), `workers.ts` (3), `transcriptionApps.ts` (1), `digest.ts` (1).
+- **One editor writer.** `editor/app/settings/saveSettings.ts`: `saveSettings(patch:
+ Partial<SiteSettings>)` (`:56-58`) over the pure `mergeSettingsPatch` (`:42-54`) — a
+ plain-object patch value merges ONE level over the current block; arrays, scalars and
+ objects nested inside a block replace. It is **the only file under `editor/app` that
+ imports `writeSettings`**, and it is not a `"use server"` module. 19 `saveSettings(` calls
+ in 11 action files. Not `saveSettingsBlock(block, patch)`: `channels/actions.ts` writes
+ `channelPriority` and the four `autoQueue` roots in one write (header `:26-30`). The e2e
+ helper `writeSettings` (`editor/e2e/helpers.ts:114`) is a different function with the same
+ one-level rule.
+- **Generated docs.** `common/bin/settings-example.ts` writes `settings.json.example` and
+ `SETTINGS.md` (`:27-28`) from `renderSettingsExample` / `renderSettingsMarkdown`
+ (`common/lib/settingsDocs.ts:61,232`); `--check` (`:33,:38`) writes nothing and exits 1 on
+ drift. Regenerate: `pnpm --filter yt-dlp-transcript-common exec tsx
+ bin/settings-example.ts`. `common/lib/settingsDocs.test.ts` pins both files to the
+ generator (`:15-20`), that the example parses back to the defaults (`:30`), and that every
+ block has a complete nested key table (`:40,:51`). **Never hand-edit `SETTINGS.md` or
+ `settings.json.example`**; `SETTINGS.md:3` says so. The example deliberately omits
+ `workers` (an absent key synthesizes one worker; `workers: []` would mean none until the
+ next save).
+- **`archiveStorage` is a required key now** (zod 4 cannot express an optional key that is
+ always emitted); it always was emitted at runtime.
+- **Numbers tool:** `plans/tools/phase3-settings-numbers.ts` — prints `getSettings()` and what
+ `writeSettings(getSettings())` would put on disk (to a scratch copy under `os.tmpdir()`,
+ never the measured file), for the live file, the example, the e2e fixture and both
+ entrypoint seeds.
+
+### Anchors above this section that are now stale (not rewritten in place)
+
+Historical entries keep their text as a record of what was true then. These anchors point at
+files deleted by slice 2 or at the pre-slice-4a `settings.ts` layout; use the entries above
+instead:
+
+- `api/pulse/route.ts:*` (the heal-consumer and running-badge notes) → `api/view/pulseView.ts`
+ and `common/views/pulse.ts`.
+- `api/widget/sync/route.ts:*`, `api/widget/actionable/route.ts:*`,
+ `api/scheduler/status/route.ts` → `common/views/{widgetSync,widgetActionable}.ts`,
+ `editor/app/scheduler/status.ts`, served through `api/view/views.ts`.
+- `common/lib/settings.ts:<n>` in any entry dated before 2026-09-23, whatever `n` (the per-block
+ sanitizers, the retired-pause-field table, the writer's merge literal) → `common/lib/settingsSchema.ts` /
+ `common/lib/settings.ts:235-250`.
+- `common/jobs/autoQueuePolicy.ts:684-749` (`defaultHeldFor`, `sanitizeAutoQueue`) →
+ `common/lib/autoQueueSchema.ts:204-277`.