# Release 5 — Rumble un-broken, visitor exports off per site, then ONE rollout Cut 2026-09-24 (late evening) off `main` `8b7f8924` (code `e172749b`, one-core Phase 3 complete). Two parallel slices with no file overlap, merged **R → X**, then one editor restart at the END (release 3 and 4 rolled the previous release out as a prelude; release 5 rolls out last because R is what un-breaks Rumble syncing and the operator should not wait a release for it). The owed one-sync md5 sweep rides along with that rollout. Site rebuilds (X) are separate from the restart. Where this file corrects `plans/rumble-sweep-pacing.md` or `plans/site-exports-off.md`, this file wins and the slice's record commit updates that file. Implementer rules: [`tools/implementer-rules.md`](tools/implementer-rules.md). ## Context - The live editor on :3001 still serves the `9ab10d77` build (`BUILD_ID` `XsKaA_drqdAbTguxUGVsn`). Release 4 is merged, not rolled out. `git diff --stat 9ab10d77..8b7f8924 -- umtool` is empty. - Rumble is broken two ways. (1) Every Rumble download 403s at `https://rumble.com/embedJS/u3/` (Cloudflare fingerprinting; upstream yt-dlp #17496, open since 2026-08-20); the editor never passes `--impersonate`, and the pipx venv (editable install of `~/Projects/yt-dlp-patched`, the binary the editor runs) has curl_cffi targets. The operator ran the probe 2026-09-24 — `yt-dlp --impersonate chrome --skip-download --print title ` printed a title. (2) A full sweep of `the-quartering-rumble` (7,866 listed) 429s at listing page 155, yt-dlp exits 1, the sync discards the partial listing (correctly) and never stamps `lastFullSweepAt`, so every sync was a sweep → 44 days without sync. The operator set `fullSweepIntervalMinutes: 0` on that channel as a stopgap. - Exports-off: the operator's five published sites drop their visitor export surfaces; the capability stays a per-site option, default ON, for the OSS release. - Owed, unchanged: the one-sync md5 sweep; 188 orphan `*.tmp-*` under `transcripts/`; Anilyzer production deploy; five sites to corpus spec 4; LM chat-only tier; `transcribeOne.ts:173`. ## Slice R — Rumble: impersonation everywhere, paced sweeps, incomplete ≠ failed Branch `one-core/r5-rumble`. There is no single `configArgs`: four arg-builders drift today and `probeChannelMeta` has none. | Builder | Where | Spawns | |---|---|---| | `configArgs` | `common/ytdlp/runYtdlp.ts:222-227` | `enumeratePlaylistUrls` :336 (sweep + paged walk + `fetchFlatPlaylistUrls`), `downloadSubsForUrl` :1097, `downloadOneAudio` :1269 | | `channelExtraArgs` | `common/ytdlp/channelArgs.ts:13` | `downloadOneManaged.ts` :460,654,991,1035,1103,1240 (the per-video managed download), `fetchWindowManaged.ts:201` | | inline | `common/ytdlp/metadataScan.ts:411-412` | metadata scan | | inline | `common/controller/checkAvailability.ts:210-218` (`extraArgs` :120) | quick availability check | | none | `common/ytdlp/runYtdlp.ts:401-448` `probeChannelMeta` | new-channel URL probe | 1. **One table, one builder.** `common/ytdlp/channelArgs.ts`: `PLATFORM_ARGS: Partial>` = `{ rumble: ["--impersonate", "chrome", "--sleep-requests", "1"] }` with a comment naming #17496 and the 2026-09-24 probe; `platformArgs(platform: Platform | null)`; `channelExtraArgs(config)` prepends `platformArgs(config.platform ?? detectPlatform(config.url))` (`common/lib/platform.ts:36-54`, `Platform` :5-22) before `ytdlpExtraArgs` so a channel override still wins. `configArgs` delegates to it (cookies first, as now); the two inline copies call it; `probeChannelMeta` gets `platformArgs(detectPlatform(url))` (a Rumble channel cannot even be created today — the probe 403s). Precedent: `resolveDownloadFormatSelector` (`common/ytdlp/downloadFormat.ts:41-58`). No new setting: the table is code; per-channel `ytdlpExtraArgs` remains the tweak surface. 2. **A 429 mid-enumeration is "incomplete", not "failed" and not a listing.** `enumeratePlaylistUrls` (`runYtdlp.ts:319-368`): on exit ≠ 0/101 classify the captured stderr with `classifyDownloadFailure` (`common/lib/availability.ts:231-263`; `rate_limit` matches `http error 429`); if `rate_limit` → throw a typed `EnumerationIncompleteError {platform, pagesReached, count}` (page from the last `Downloading page N` line). `syncFullSweep` (`:1544-1727`; `:1557` is the enumeration call) catches it BEFORE `acceptEnumeration`: records the backoff for the platform via `recordDownloadBackoff` (`common/jobs/downloadBackoff.ts:31-42`; `platformBackoff` is platform-keyed, `common/jobs/platformBackoff.ts`, set by the runner `autoRunner.ts:1936-1953`, read by the Sync gate `pipelineActions.ts:126-142`), logs one line ("sweep incomplete: 429 at page N of the listing, M entries — not a listing; next syncs are paged walks until the rumble cooldown ends"), then RUNS `syncPaged` for this sync instead of failing, and never stamps `lastFullSweepAt` (`touchLastFullSweep` is already inside `if (decision.accept)`). Exit-1-with- other-stderr keeps throwing as today. Tolerating exit 1 generally stays out. 3. **No re-sweep while the platform cools down:** `fullSweepDue` (`runYtdlp.ts:1339-1350`) also returns false when `platformCooldownRemainingMs` (`downloadBackoff.ts:19-27`) > 0 for the channel's platform. 4. **403 classification, small:** `classifyDownloadFailure` has no 403 pattern (the 2026-09-24 job read "aborted (network)" only because the traceback matched `/ssl/`). 403 from Cloudflare is a fingerprint block, not a rate: add a `"blocked"` pattern group only if the type is cheap to thread; otherwise record it and leave. 5. **Tests.** Unit: `channelArgs.test.ts` (rumble gets the four args before channel args; youtube gets none; override order), `enumeratePlaylistUrls` incomplete classification, `fullSweepDue` under cooldown. Fake yt-dlp (`editor/e2e/fixtures/bin/fake-ytdlp.mjs`; scenarios are URL sentinels, `ratelimit` → 429 + exit 1; no "exit 1 after N pages" mode): add a `sweep429` sentinel that prints N pages of urls then the 429 and exits 1, and have the fixture record argv so a spec can assert `--impersonate chrome` for a `rumble.com` URL. e2e: a Rumble-URL channel whose sweep hits `sweep429` → job succeeds as a paged walk, log has the "incomplete" line, `.auto-queue/state.json` has `platformBackoff.rumble`, `lastFullSweepAt` absent, the Sync button then reads "rumble is in a rate-limit cooldown" (`queues.spec.ts:38-73` pattern); `fetch-window.spec.ts:273-296` (429 → cooldown) stays green. 6. **Rollout notes:** after R is live, remove `fullSweepIntervalMinutes: 0` from `the-quartering-rumble` and watch one paced sweep complete; the first accepted listing after 44 days may legitimately shrink — the two-observation guard handles it. Numbers: none (no file format changes). ## Slice X — visitor exports off, per site Branch `one-core/r5-exports`. The editor does NOT mount `TranscriptModal`/`PlayerProvider` — the gate is export-only. There is no `pnpm ops site-config`: `site.json` is written only by the Settings form (`SiteForm.tsx` → `saveSiteAction` → `writeSite`). Four places mount the modal (SiteWorkspace, duplicates, HubHome, AskHub — all in `export/`). 1. **Schema key `transcriptDownloads`** (boolean, absent = on), the `archives` four-site pattern in `common/lib/siteSchema.ts` (type, docs, zod opt-out, write-if-non-default). Regenerate `SITE.md` with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts` (`--check` is pinned by `fileSchemaDocs.test.ts`). 2. **Form:** `SiteForm.tsx` checkbox beside `archives` — label "Per-video transcript downloads (Download menu, Copy Markdown, Copy download command)"; `actions.ts` reads it like `archives`. 3. **The gate:** `PlayerProvider({children, features?})` takes `features: { transcriptDownloads: boolean }` (default all on) into its context; `TranscriptModal` hides the three controls when off (Copy download command, the Download dropdown, Copy MD). Mount sites pass `features={{ transcriptDownloads: currentSite().transcriptDownloads !== false }}`: `export/app/(workspace)/SiteWorkspace.tsx`, `export/app/duplicates/page.tsx`; hub pages `HubHome.tsx` and `AskHub.tsx` read the hub's own key the same way — the hub has no site.json, so it is `transcriptDownloads` in `sites/_homepage/homepage.json`, passed through `hubSite()` and set by the editor's hub form on `/sites` (the hub is a published surface and follows the same key; default on). 4. **Tests.** Export e2e: a fixture site with `transcriptDownloads: false` → the three buttons absent (by accessible name), default → present; an `archives: false` fixture → no `/downloads` link, no zip manifest (new coverage); `phase3-files-numbers.ts` still reports zero unknown keys on both sides. Editor e2e: the site form round-trips the new checkbox. 5. **Rollout (after merge and the editor restart):** for each of jeralyzer, rekietalyzer, hasanalyzer, anilyzer, bonnellyzer: Settings form → untick archives and transcript downloads → save → build-site + deploy-site; then the hub form on `/sites` → untick transcript downloads → save → rebuild + deploy the hub. Verify per site: no Downloads link in the header or footer, and `/downloads` shows its empty state (the route still builds and answers 200); transcript modal has no Download/Copy MD/Copy download command, `/corpus.json` and one `page-0001.json` still 200. On the hub: a federated transcript's modal has none of the three either. `use-with-ai` and `llms.txt` already gate their archives prose. 6. Out of scope: gating the machine contract; "Copy share link"; umtool clip fetching; pruning the archives R2 volume. Numbers: `plans/tools/phase3-files-numbers.ts` before/after — the new key must not appear as unknown. ## Rollout at the end of release 5 (ONE restart) Template: `plans/one-core-phase-3.md` "Rollout 2026-09-24 — `9ab10d77` live on :3001". Steps: (1) offline round-trips `phase3-settings-numbers.ts live=settings.json` and `phase3-files-numbers.ts` over live sites/configs; (2) md5 baseline of `settings.json`, 6 `site.json`, 71 `config.json`; (3) detached `pnpm --filter editor build` into the live `.next`; (4) ONE restart: TERM the `pnpm run start` + `next-server` whose cwd is `/editor`, wait :3001 free, `setsid nohup pnpm run start -H 0.0.0.0`, poll `/` and `/tags`; (5) smoke = the 8 old/new view pairs stripped of live fields, `/api/view/` + `invalidate-cache` 404, `/api/widget/presets` 200, `?rev=` `changed:false`, pages 200 incl. `/channels` (rack + group Transcribe enabled with a count), `/jobs`, `/operations/diarization`, one channel, one video, `BUILD_ID` changed, no `ZodError`; (6) md5 sweeps after boot, after ONE sync of ONE channel (a Rumble channel proves R live), after one form save; (7) `the-quartering-rumble`: remove the sweep override and watch one paced sweep; (8) record + STATE. umtool: rebuild only if `git diff --stat .. -- umtool` is non-empty. ## Then — Phase 4 `plans/one-core.md:460-495` is the spec; deferred follow-ups are listed in `one-core-phase-3.md` "Next — Phase 4". ## Record ### Slice R, as shipped — Rumble: impersonation everywhere, paced sweeps, incomplete ≠ failed (2026-09-24) Branch `one-core/r5-rumble` off `main` `f4da04a9`, seven commits (four, then three after review), unmerged. Every Rumble request 403s at Cloudflare without a browser TLS fingerprint (yt-dlp #17496), and the `the-quartering-rumble` full sweep 429'd at page 155 and failed the whole sync every time for 44 days. There is now one arg builder with a platform table, and a 429 part-way through a full enumeration is *incomplete*: cooldown recorded, one log line, the paged walk runs, the job succeeds. | sha | what | |---|---| | `bca929a1` | `common/ytdlp/channelArgs.ts`: `PLATFORM_ARGS` (`rumble: --impersonate chrome --sleep-requests 1`, comment names #17496 and the 2026-09-24 probe), `platformArgs(platform)`, `channelPlatform(config)` = `config.platform ?? detectPlatform(config.url)`; `channelExtraArgs` = cookies → platform args → `ytdlpExtraArgs` (override last, wins). `configArgs` (runYtdlp) delegates to it; the metadata scan's and `checkAvailability`'s inline copies call it (`checkAvailability` falls back to `cookieArgs` only when the channel has no config); `probeChannelMeta` gets `platformArgs(detectPlatform(url))` — `detectPlatform("https://rumble.com/c/…")` is `"rumble"` (host suffix). `channelArgs.test.ts` (5) | | `0a4b9999` | `EnumerationIncompleteError {platform, pagesReached, count}` + `lastListingPage(stderr)` (last `Downloading page N` — the real wording is `[RumbleChannel] TheQuartering: Downloading page 155`, job `01M3AVGZC5EZ9GCD04R9QW6NWX`). `enumeratePlaylistUrls` classifies a non-0/101 exit's buffered stderr with `classifyDownloadFailure`; `rate_limit` → the typed error (message still starts `yt-dlp exited with code N`), anything else → the plain error as before. `syncFullSweep` catches it before `acceptEnumeration`, calls `opts.onPlatformBackoff?.("rate_limit")` (= `recordDownloadBackoff(platform, paths)`, wired by `pipelineActions.ts:215`, the same helper and `nextBackoff` schedule a download's 429 uses), logs `Full sweep incomplete: 429 at page N of the listing, M entries — not a listing; next syncs are paged walks until the rumble cooldown ends.`, then `return syncPaged(opts)`. No playlist write, no missing set, no `lastFullSweepAt`. `fullSweepDue` is now exported, async, and false while `platformCooldownRemainingMs(detectPlatform(url) ?? "unknown")` > 0 — the Sync gate's key. `sweepIncomplete.test.ts` (4) | | `84788fff` | Fake yt-dlp: every `--flat-playlist --print url` records `flat-playlist:full\|paged argv=` in `fake-ytdlp.invocations` (no argv log existed for enumeration; the per-cwd invocations file is the fixture's existing record, so no new env var); `sweep429` sentinel — a full enumeration prints 3 pages × 5 urls with RumbleChannel page lines on stderr, then the real 429 `ERROR:` line, exit 1; a paged walk (`-I`) lists normally. New `rumble-sweep.spec.ts` | | `7b50cb45` | record, `[Unreleased]` bullet, superseded note on `plans/rumble-sweep-pacing.md` | | `c145f7df` | Review fixes: the cooldown write in the incomplete branch is best-effort (try/catch as on the download path; a failure logs `Warning: could not record the cooldown …` and the paged walk still runs); the log line now reads `… M entries — not a listing. Ran the paged walk instead; syncs wait for the rumble cooldown, then the sweep is retried.` (syncs are REFUSED during the cooldown, `pipelineActions.ts:133-145`); enumeration comment back above `enumeratePlaylistUrls`, the error class has its own one-liner; `checkAvailability` adds `platformArgs(detectPlatform(url))` when the channel has no config (no unit test file exists for it) | | `a70df836` | `classifyDownloadFailure`: `/http error 403/` joins the `network` group, so a bare `HTTP Error 403: Forbidden` backs the platform off — no new class, no `download-outcome.json` change. New `common/lib/availability.test.ts` (1). Changelog bullet reworded to match | | (this) | record: commit table, gates, caveats | **Gates.** tsc (`pnpm -r … exec tsc --noEmit`) clean at every commit. common **1747** (1738 + 9 new), editor unit **72**, test:scripts **156 + 1 skip**, mcp **219**. `next build` editor and export both green. e2e, the 17-spec list (none missing: `rumble-sweep queues fetch-window availability availability-backfill maybe-missing reconcile scheduler pipeline sync-deep sync-break-on-existing channels-actions cookies-mode audio-check-scenarios metadata-scan-botcheck metadata-scan-softblock new-channel-onboarding`): **82 passed, 1 failed, 7.8 min** (after 8 min in the queue) — the failure is `pipeline.spec.ts:164`, `EEXIST: mkdir …/test-transcripts/channels` inside `resetData`'s `cp` at `helpers.ts:69` (a fixture-reset race before the test body ran, 301 ms); rerun `pipeline.spec.ts rumble-sweep.spec.ts`: **8 passed, 56 s**. `fetch-window.spec.ts`'s 429 → cooldown test green. **After review** (`c145f7df`, `a70df836`): tsc clean per commit; common **1748** (+1); editor unit **72**; `next build` editor and export green; e2e `rumble-sweep availability availability-backfill queues fetch-window pipeline`: **33 passed, 0 failed, 2.5 min** (after ~7 min in the queue). Numbers: none (no file format changes). **Found and left.** - **403 has no class of its own (item 4, partly done).** `DownloadFailureClass` is not cheap to widen: it is persisted in every per-video `download-outcome.json` (`common/lib/downloadOutcome.ts:105` — a new value is a sidecar format change), and it is a decision in the auto-runner (`autoRunner.ts:1941-1944`, backoff on `rate_limit | network`), in `runYtdlp.ts` (backoff + `abortOnError`, the managed-download loop ~:1029-1051) and in `downloadOneManaged.ts:1372` (where it is produced). Before `a70df836` a Cloudflare 403 read as `network` only when the traceback happened to contain "ssl" (the 2026-09-24 job did; a bare `HTTP Error 403: Forbidden` did not, and read `unknown`). It is now in the `network` group, so it backs the platform off. With `--impersonate` the 403 should stop occurring; if it recurs, a `blocked` class wants its own decision (no backoff, a sentence on the channel), not a pattern. - **The metadata scan passes `--sleep-requests 1` twice for Rumble** (its own, then the platform table's). Harmless (same value, last wins); left so the scan's own pacing stays for every platform. - **The paged walk can also 429.** A `rate_limit` on a ranged page throws the typed error out of `syncPaged`, and the sync fails exactly as before (its message is unchanged in prefix). The fallback deliberately runs only once per sync. - **Retry cadence after an incomplete sweep.** No last-attempt stamp is kept: once the platform cooldown ends (`nextBackoff`: 1 min, doubling, at most 30 min — `platformBackoff.ts:22-37`) the sweep is due again on the next sync, and any successful Rumble download clears the escalation (`clearBackoff`, `autoRunner.ts:1957`), so it restarts at 1 min. If live sweeps keep coming back incomplete, a `lastFullSweepAttemptAt` (retry no sooner than N hours) is the follow-up. - **Outside the slice: umtool still spawns yt-dlp without `--impersonate`.** `umtool/report-to-video/build-video.mjs` (`YTDLP` at :75) and `check-availability.mjs` (:32) build their own argv, so Rumble clip fetches and availability checks there will still 403. (The editor's clip-window fetch, `fetchWindowManaged.ts`, is covered.) - **`pipeline.spec.ts:164` EEXIST reset race** — first sighting: `resetData`'s `cp` hit `EEXIST: mkdir …/test-transcripts/channels` (`helpers.ts:69`) before the test body; green on rerun and in the post-review run. Watch for a second sighting. - **Commit trailers** name `Claude Opus 5.5 (1M context)` — the model that wrote them — not the `Claude Fable 5.1` line in `plans/tools/implementer-rules.md`. **Rollout.** Item 6 above stands: after R is live remove `fullSweepIntervalMinutes: 0` from `the-quartering-rumble` and watch one paced sweep (at 1 req/s, ~155+ pages ≈ 3 minutes of listing); the first accepted listing after 44 days may shrink — the two-observation guard owns it. ### Slice X, as shipped — visitor exports off, per site (2026-09-24) Branch `one-core/r5-exports` off `main` `f4da04a9`. One new `site.json` key, `transcriptDownloads` (boolean, absent = on), gating the transcript modal's three per-video export controls on the export site; the site form writes it. The hub, which has no `site.json`, gets the same key in `homepage.json`, written by the hub form. `archives: false` needed no code — it gained test coverage. | sha | what | |---|---| | `acf0c676` | `common/lib/siteSchema.ts`: `transcriptDownloads` in the `Site` type, `SITE_FIELD_DOCS` (after `duplicates`), a zod field that is off only on an explicit `false`, `siteToDisk` writes it only when `false` — the `archives` pattern, four places. `siteSchema.test.ts` extends the existing default / opt-out / siteToDisk / round-trip / writeSite tests. `SITE.md` regenerated (`--check` green) | | `d301d757` | `PlayerProvider({children, features?})`: `PlayerFeatures = {transcriptDownloads}`, `DEFAULT_PLAYER_FEATURES` all on, exposed as `usePlayer().features` (memoised on the flag, so an inline `features={{…}}` does not rebuild the context). `TranscriptModal` renders none of Copy download command, the Download dropdown and Copy MD when off; Share, the clip marks and every accessible name are unchanged when on. Mounts: `(workspace)/layout.tsx` → `SiteWorkspace` prop, `duplicates/page.tsx` directly, `HubHome`/`AskHub` via a prop from their server pages (`(workspace)/page.tsx`, `(workspace)/ask/page.tsx`) — all `currentSite().transcriptDownloads !== false` | | `a42b1737` | `SiteForm.tsx` checkbox after the archive size cap, label "Per-video transcript downloads (Download menu, Copy Markdown, Copy download command)"; `actions.ts` reads it like `archives`. `editor/e2e/sites-crud.spec.ts`: one new test round-trips BOTH opt-outs (no spec covered the `archives` checkbox before) | | `9958b42b` | export e2e: `transcript-downloads.spec.ts` (2), `archives-off.spec.ts` (2) | | `6950945a` | `plans/tools/phase3-files-numbers.ts` literal gains the key; this record; `site-exports-off.md` superseded note; changelog | | `c09ef9c7` | review fix: the leftover-key strip moves from the spec's `beforeAll` into `export/playwright.config.ts` (config load, before any spec) | | `c65c08c2` | review fix, the hub: `homepage.json` `transcriptDownloads` in `common/lib/homepage.ts` (type; read only as `false`; `writeHomepageConfig` writes it only when `false`), `hubSite()` passes it through (`export/app/lib/site.ts`), checkbox on `HomepageConfigForm.tsx` (same label) + `homepageActions.ts`. `common/lib/homepage.test.ts` (new, 2 tests — there was no homepage test), `export/e2e-hub/transcript-downloads.spec.ts` (2, the hub-mode suite), `sites-crud` round-trip of the hub form. `playwright.hub.config.ts` removes a leftover fixture `_homepage/` at load | | `41bbf92e` | record + changelog after review | | `4ec5af9b` | merge `main` `3adaea9b` (slice R); conflicts only in `editor/CHANGELOG.md` `[Unreleased]` and this file's `## Record`, both slices' text kept, R's first | | *(this commit)* | the merged-tree gates below | **Deviations / findings.** 1. *The hub has no `site.json`, so its key is in `homepage.json`.* In hub mode `currentSite()` is `hubSite()`, a `Site` synthesised from `sites/_homepage/homepage.json`. The first cut left the hub always on; review held the plan's decision (the hub is a published surface — and it federates exactly the five sites being turned off — so it follows the same key, default on), and `c65c08c2` adds `transcriptDownloads` to `homepage.json` under the same name and rules. `writeHomepageConfig` rebuilds the file from named fields, so the hub form is the key's writer. **`homepage.json` has no generated doc** (`file-schemas-docs.ts` covers `settings.json`, `site.json` and `config.json` only) and no zod schema; none was invented. The numbers tool does not read `homepage.json`, so it was not re-run for this commit. 2. *Client mounts cannot call `currentSite()`* (it reads `site.json` off disk). `SiteWorkspace`, `HubHome` and `AskHub` are `"use client"`, so each takes a boolean prop from its server parent; `duplicates/page.tsx` is a server component and passes the features itself. 3. *Export e2e varies per-site config by flipping the fixture.* The suite serves ONE site (`SITE_ID=testsite`) from one `next dev`; no spec varied `site.json` before (the per-site variants in the suite are route mocks of client data). `currentSite()` is uncached and `next dev` re-renders the layout per request, so the off test writes `transcriptDownloads: false` into `e2e/fixtures/sites/testsite/site.json`, asserts, and `afterEach` restores the bytes. A run killed mid-test leaves the key; `playwright.config.ts` strips it when the config loads, before any spec runs (`c09ef9c7` — the first cut stripped it in the spec's own `beforeAll`, which runs 36th of 37 files, after `player-control-bar` had already failed on it). The strip re-serialises the file, so after a killed run the fixture can differ from git in whitespace only. A second fixture site would need a second `next dev` of the same app dir. The hub-mode spec does the same with a `_homepage/homepage.json` that the fixture tree never commits; the hub config deletes a leftover. 4. *`archives: false` is a build-time switch, so its coverage is a compose run, not a page.* The Downloads page and its Header/Footer links key off `public/archives/manifest.json` (`hasArchives()`), which `compose-site` writes; the dev server reads the checked-out `export/public` (symlinked from the primary). `archives-off.spec.ts` runs `compose-site` into a scratch `EXPORT_PUBLIC_DIR` under the test's output dir over a scratch channel-less site: `archives: false` → no manifest, a pre-seeded stale one removed; key absent → a fresh manifest. The "no `/downloads` link" half follows from `hasArchives()` and is not rendered in a test. 5. *The actions themselves are not gated.* `copyDownloadCommand` / `downloadTranscriptFile` / `copyTranscriptMarkdown` stay on the context; the three buttons are their only callers (grep), and the data is in the visitor's browser either way — this removes the affordance, it is not access control. **Gates** (worktree root). - tsc `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`: clean. - common `pnpm --filter yt-dlp-transcript-common test`: **1738/1738** (assertions added inside existing tests; count unchanged). `file-schemas-docs.ts --check`: green. - editor unit (`editor/`, `tsx --test "app/**/*.test.ts"`): **72/72**. `pnpm run test:scripts`: **156 pass + 1 skip**. mcp: **219/219**. - `pnpm --filter editor exec next build`: ok (47 s). `pnpm --filter export exec next build`: ok (32 s); no dangling `export/public` links before either. - EXPORT e2e, full suite (`node scripts/worktree.mjs run -- pnpm --filter export run e2e`): **192 passed, 0 failed, 8.5 min** (188 + the 4 new). The fixture was back to its committed bytes afterwards (`git status` clean). - EDITOR e2e (`pnpm e2e` with `sites-crud site-publish-preview site-scope cut-release view-route settings`, all six exist; `sites-crud` is now the spec covering the `archives` checkbox): **50 passed, 0 failed, 1.7 min** (after ~7.5 min behind slice R's run on the queue). **Gates after review** (tip `c65c08c2`, same commands). tsc clean. common **1740/1740** (+2, `homepage.test.ts`). `file-schemas-docs.ts --check` green. editor unit **72/72**. editor `next build` ok (39 s), export `exec next build` ok (24 s), no dangling `export/public` links. EXPORT e2e, full suite: **192 passed, 0 failed, 7.5 min**. Export HUB suite (`… run e2e:hub`, the existing hub-mode run, `playwright.hub.config.ts`): **8 passed, 0 failed, 17 s** (6 + the 2 new). EDITOR e2e `sites-crud`: **13 passed, 0 failed, 41 s** (the spec closest to the hub form; no spec covered that form before). Fixture tree clean after all three (`git status`; no `_homepage/` left). test:scripts and mcp are untouched by the fixes (no file under their globs changed) and were not re-run. **Gates on the merged tree** (`4ec5af9b`, slice R + slice X — the tree to merge as-is). tsc clean. common **1750/1750** (R's tests + the 2 homepage tests). `--check` green. editor unit **72/72**. test:scripts **156 + 1 skip**. mcp **219/219**. editor `next build` ok (40 s), export `exec next build` ok (25 s). EXPORT e2e full: **192 passed, 0 failed, 6.3 min**. HUB e2e: **8 passed, 0 failed, 16 s**. EDITOR e2e (`sites-crud site-publish-preview site-scope cut-release view-route settings`): **51 passed, 0 failed, 1.7 min** (50 + the hub-form test). `git status` clean after. **Numbers** (`plans/tools/phase3-files-numbers.ts`, `TMPDIR` = the job's scratch). Inputs frozen once (`FREEZE_TO`) from the live corpus: 6 `site.json`, 71 `config.json`, 1,763 sidecars. Before = the tool on `f4da04a9` code (branch changes stashed); after = the branch with the literal updated (it asserts the literal equals `SITE_FIELD_DOCS`). **Unknown-key report: empty on both sides**, 77 files each. **Diff: 18 lines, exactly one per site** — each parsed site gains `"transcriptDownloads": true`; every `writeSite(getSite())` file is byte-identical (no site gains the key on disk). A third run over a scratch copy with jeralyzer's `site.json` carrying `transcriptDownloads: false` + `archives: false`: unknown keys still empty, both parse `false`, and the written file keeps both keys. **Left.** Rollout item 5 is the operator's, after merge: untick both on the five published sites and build + deploy each, then untick transcript downloads on the hub form and rebuild + deploy the hub. Verify per site as item 5 now says: no Downloads link in the header or footer, and `/downloads` shows its empty state (it still answers 200). ## Rollout 2026-09-24 (night) — `93dcb532` live on :3001 (releases 4 + 5 together) ONE restart, at the END of release 5, as the plan said: R is what un-breaks Rumble syncing. Editor only — `git diff --stat 9ab10d77..93dcb532 -- umtool` prints nothing, so umtool was not rebuilt or restarted (still serving on :3050). The live editor had served `9ab10d77` (release 3, `BUILD_ID` `XsKaA_drqdAbTguxUGVsn`) since the release-4 prelude. **Final suites on `93dcb532` first** (worktree `one-core-r5-exports` detached at the merge sha, whose tree equals X's gated tip `d948aa0e`; ports 3211/3210/3220; logs `final-e2e-r5-{editor,export,hub}.log`): editor **628 passed, 0 failed, 0 flaky, 39.4 min** (release 4's `lane-runner.spec.ts:361` load flake did not recur); export **192/192, 6.2 min** (188 + 4 new); hub **8/8, 16 s**. **Offline round-trips.** `phase3-settings-numbers.ts live=settings.json`: the written block equals `jq -S . settings.json` (diff empty, 1,353 scalar paths each side — the same count as release 4). `phase3-files-numbers.ts` over the LIVE `transcripts/`: 6 `site.json` + 71 `config.json`, every `unknown keys: []` (77 lines), no `WRITE THREW`; the output is 3,839 lines against release 4's 3,832 — the seven new lines are the `"transcriptDownloads": true` each site's parse now carries (X's key, default on) plus the hub's. **Build and one restart.** md5 baseline over `settings.json` + 6 `site.json` + 71 `config.json` (78 files). `pnpm --filter editor build` detached into the live `.next` while the old server served: exit 0, compiled in 14.3 s, `ƒ /api/view/[name]` in the route table; `BUILD_ID` `XsKaA_drqdAbTguxUGVsn` → **`FKE60BTWpUiUa94WCSxTO`**. Seven auto-lane jobs were in flight (1 backfill, 1 digest, 2 download, 2 transcribe, 1 transcribe), as at release 4's restart. Then TERM pnpm 525402 / next-server 525417 (cwd `editor/`), :3001 free, `setsid nohup pnpm run start -H 0.0.0.0` → next-server **888107**, `/` 200 after 2 s, `Ready in 163ms`, `/tags` 200. md5 sweep right after boot: identical to the baseline — boot rewrote nothing. **Smoke, started 4 min after boot** (`smoke10.sh`, the release-4 script). All eight old/new pairs equal on the FIRST pass with the live fields stripped: `pulse` 109 B, `workers` 1,116 B, `activeJobs` 2,652 B, `autoQueueStatus` 507,725 B (79 s), `schedulerStatus` 37,371 B, `widgetSync` 1,522 B, `widgetActionable` 1,148 B, `cleanable` 1,677 B. `/api/view/bogus` and `/api/view/invalidate-cache` 404; `/api/widget/presets` 200; `/api/view/pulse?rev=` and `/api/pulse?rev=` both `changed:false`; `cleanableBytes: null`. Pages, all 200: `/` 0 s, `/channels` 1 s, `/channels?sort=size` 0 s, `/jobs` 0 s, `/operations/diarization` **77 s**, `/settings`, `/storage`, `/tags`, `/channels/FearAnd`, `/channels/FearAnd/videos/03Bgz7vkgbs` 0–1 s. No `ZodError` in the start log (0). `SMOKE_FAIL=0`. **The surfaces, from the served HTML.** `/channels` is the rack (`sticky top-0` thead, "sort by Tier" and "sort by Transcribe" columns); `/channels?site=jeralyzer` renders 4 `data-testid="group-name"` groups, each with `sync|download|transcribe|digest|speakers group ` stations — the transcribe station renders its done state (`Transcribed`) for all four groups tonight, so there was no group with untranscribed audio to show a count on. Release 5's own surfaces: the site form (`/sites/jeralyzer`) shows "Per-video transcript downloads (Download menu, Copy Markdown, Copy download command)" beside "Generate downloadable archive zips on build", and the hub form on `/sites` shows the same checkbox. **The owed one-sync md5 sweep — and R's first live proof.** md5 sweep just before the sync: identical to the after-boot sweep (the smoke wrote nothing). `pnpm ops sync {"slug":"rekietalaw-rumble"} --wait` (job `01M3BARDZR528JS70BE17467WF`, 8 min 11 s, exit 0): the full-sweep spawn was `yt-dlp --flat-playlist --skip-download --print url --impersonate chrome --sleep-requests 1 https://rumble.com/c/RekietaLaw` — the platform table live — and it COMPLETED: "670 listed, was 666", "669 known, 670 in fresh listing, 2 maybe-missing", the listing accepted, 4 new videos on page 1. Every per-video spawn carried the same two flags, and all four downloaded: `DLOM_ARCHIVE RumbleEmbed v7dn1wy / v7dh02c / v7ddwkg / v7dce86` — of the 500 retained job logs this is the ONLY one with a Rumble archive line (four others carry the embedJS 403). Availability backfill wrote 4. md5 sweep after the sync: exactly one file moved, `channels/rekietalaw-rumble/config.json` (`lastFullSweepAt` 2026-08-21 → 2026-09-25T03:54:54, `lastSyncedAt` 2026-08-31 → 2026-09-25T03:54:53), nothing else. Live-found, not a regression: the maybe-missing confirmation of `v7e07us` reads `error` — the sidecar says `HTTP Error 410: Gone`, and has since 2026-08-21. A 410 is a removed video, not an error; `classifyAvailability` has no 410 pattern. Follow-up, small. `v7emb9a` was skipped ("no webpage_url in metadata.info.json"), as before. **Form save.** One Configure-form save with no field changed on `legal-mindset` (`pnpm ops channel-config` with `patch: {}`, the server action the form posts): `ok`, and the md5 sweep after it is identical to the after-sync sweep — release 4's save had already applied the one-time key reorder on that file, so this time nothing moved; `jq -S` equal. **Exports off on the five sites — the site form's one side effect.** The two switches were unticked through the live Settings form (`/sites/`, headless Playwright driving the real form, `xr-flip.cjs`), never by hand. On `anilyzer` the save wrote the two `false` keys AND materialised `order` on the one group and the one channel that had none (`legal-mindset`, added by the LM-filter work through the CLI): the group became `order: 11` (it was already last, and `sortGroups` keeps a missing order last, so nothing moved), the channel `order: 9` (placed alphabetically after `leaflit-rumble`; the array was reordered to match). Channel `order` is parsed into the schema (`siteSchema.ts:154`) and read by nothing under `common/lib` or `export/app`, so there is no visible effect. Accepted as the form's normal write — a human save does exactly this — and recorded per site below. **Step 7 — `the-quartering-rumble`'s first complete sweep in 44 days.** The stopgap `fullSweepIntervalMinutes: 0` was removed through the Configure-form writer (`pnpm ops channel-config` with `patch: {"fullSweepIntervalMinutes": null}` — null clears a field; the before/after `jq -S` diff is exactly that one key gone). Then `pnpm ops sync` (23:55:34): with `lastFullSweepAt` null the sync was a full sweep, and the editor's own spawn line was `yt-dlp --flat-playlist --skip-download --print url --impersonate chrome --sleep-requests 1 https://rumble.com/c/TheQuartering`. The paced walk went past page 155 (where the unpaced one 429'd three times on 2026-09-24) and on through page 310+ with no rate limit, ~9 min for the listing. Accepted: **"8045 listed, was 7866"**, "7936 known, 8045 in fresh listing, 4 maybe-missing" — the listing grew, it did not shrink, so the two-observation guard was not exercised — and the stored playlist is 8,045 lines. Page 1 had 50 entries, all 50 new; the job then downloads them one by one (each spawn impersonated and paced). **Orphan temp files, gone.** Operator-approved (2026-09-25 ~00:15) with two filters — older than 24 h, and the file's channel has no running job (`/api/view/activeJobs`): `find transcripts -name '*.tmp-*'` listed 188, all of them older than 24 h — 175 zero-byte `.auto-queue/state.json.tmp-2514131-*` from 2026-09-11 (the pre-priority editor's pid, before slice W's one write idiom) and 13 per-channel sidecar temps (7 `snapshot.json`, 3 `transcript`, 2 `availability.json`, 1 `download-outcome.json`, the newest from 2026-09-11 too). 188 deleted, 0 skipped (the one busy channel, `the-quartering-rumble`, had none), 2,519,597 bytes freed, 0 remaining (`r5-orphans.log`). **Exports off, deployed.** `pnpm ops build-index` (job `01M3BBJXQ01ECGZNEZ61QE0WKG`, 12.4 min), then one `build-deploy` job per site, serially. **anilyzer** (`01M3BC9JJQFADFX621KGPM971B`, 17 min: "Done in 734.17s. 6 site(s): 6 built", "[archives] disabled for this build — skipping", 25 channels composed, 227 files uploaded + 674 already there, deployed): verified at https://anilyzer.pages.dev — `/downloads` 200 with the empty state ("No archives have been published for this site yet"), no zip links, the home page has no link to `/downloads`, `/corpus.json` 200 at **spec 4** (26 channels, 29,751 videos), `transcripts/chibi-reviews/manifest.json` and `page-0001.json` 200, and a headless open of a transcript modal finds none of "Copy yt-dlp download command", "Download this … as a file", "Copy this … as Markdown" while "Copy share link" and the clip mark are still there. This is also the Anilyzer production deploy owed since the curated-tags release, and the first of the five sites at corpus spec 4. **bonnellyzer** (`01M3BD94S59YG01JJF6V7PBN27`, 28.5 min), **hasanalyzer** (`01M3BEXC9QRCTN90ZJ5WDGB6RS`), **rekietalyzer** (`01M3BFMRQJWSQKRM73NPNRQ1E5`, 11.9 min): the same six checks pass at each public URL — `/downloads` 200 with the empty state and no zip links, no home-page link to `/downloads`, `/corpus.json` 200 at spec 4 (bonnellyzer 4 channels / 8,079 videos; hasanalyzer 6 / 3,425; rekietalyzer 2 / 2,925), a channel manifest + `page-0001.json` 200, and the transcript modal without Copy download command / Download / Copy MD while "Copy share link" and the clip mark remain. **jeralyzer** (`01M3BGADKE9S7V6X15WK2VF065`, 12.1 min): the same six checks pass — spec 4, **30 channels / 31,463 videos** (30,886 at the 2026-08-07 build). `corpus.json` on every site no longer carries `bulkArchives`. The whole chain (index + five build-deploys, serial) took ~95 min; each build-deploy rebuilds the index itself, so the standalone index job was redundant. Order fills per site, exactly as predicted from the read-only check: anilyzer `legal-mindset` (channel + group), hasanalyzer `FearAnd` + `hasanthehun-x`, jeralyzer `thequartering-X`, none on bonnellyzer and rekietalyzer (their diffs are exactly the two keys). Group order unchanged everywhere. The checks are real: run against anilyzer BEFORE its deploy they found the zip links, two home links to `/downloads`, `bulkArchives` and all three modal controls. **Found on the way, not fixed.** (1) **jeralyzer `corpus.json` lists `thequartering-X` first — a posts-only channel with `videoCount: 0` whose transcripts manifest is a 404** (its posts manifest is 200). A client that walks every transcripts manifest hits a 404; the composer should omit the transcripts manifest URL (or emit an empty manifest) for a posts-only channel. Small follow-up in `common/bin/compose-site.ts`. (2) **The hub's "Save config" form dropped `"nav": []` from `homepage.json`** alongside adding `transcriptDownloads: false`. `nav` is not a `HomepageConfig` field, `parseHomepageConfig` never reads it and nothing under `homepage/app`, `export/app/lib/site.ts` or `compose-hub.ts` uses it — inert, but the writer rebuilds the file from named fields, which is the documented behaviour (SITE.md's sibling has no doc: `homepage.json` has none). (3) **The hub build has no deploy path.** `INSTANCE_MODE=hub` is `pnpm --filter export run build:hub` and nothing deploys its `out` (no `pnpm ops` action, no editor entry — `plans/one-core.md:84` already says "`build:hub` has no operator entry point"); the `homepage` package deploys to the `archilyzer` project but mounts no transcript modal. So the hub's key is SET but no hub was rebuilt or deployed tonight, and the federated hub could not be checked live because **https://archilyzer.pages.dev answers HTTP 522 on every path** (`/`, `/ask`, `/corpus.json`, `/hub-sites.json`; checked twice, the second time at 05:36 UTC) — not from this rollout, nothing deployed there. Operator: look at the Cloudflare project. The Phase 4 CLI slice is where `build:hub` gets its entry point. **Release 6 rode along on `main`, not on :3001.** After this rollout the operator approved an overnight plan (memory `overnight-2026-09-25`): the follow-ups slice merged as `4d97049f` and Phase 4 slice 1 as `cda35622` — see [`release-6.md`](release-6.md). Neither is rolled out; the next prelude does that, after `pnpm install --frozen-lockfile` in the primary (the umtool→common link and the aws-sdk move). The YouTube pacing investigation is filed as [`youtube-lane-pacing.md`](youtube-lane-pacing.md): the evening's three cooldowns were one Short retried 12× from the head of the queue, not a burst. **Step-7 sync, finished 01:50 (exit 0, 1 h 54 min, job `01M3BB8JJGN272R5HKJPS100F5`).** 97 distinct videos archived (285 archive lines), 0 download errors, availability backfill wrote 94; the channel now carries `lastFullSweepAt` = `lastSyncedAt` = 2026-09-25T05:49:49Z, no override key. The four maybe-missing confirmations all read `error`: each is `HTTP Error 410: Gone` raised inside `_curlcffi.py:343` — the impersonated transport working and the videos removed upstream. On this build a 410 is `error` (safe: nothing is deleted or pruned); the follow-ups slice already on `main` (`4d97049f`) classifies it `deleted` from the next rollout on — the live corpus proved that fix worth shipping within two hours of its being written.