# Release 9 — four fixes behind one restart `main` at `9247211e`, release 8 live on :3001 since 2026-09-25 14:43. Release 9 is the four small bugs the release-8 rollout found on the live editor, batched so they cost one editor restart: a queued job that ends `failed` after doing its work, a 30 s sleep after every video the download filter declined, YouTube with no request-level pacing, and a `/jobs` page that calls a slow scan stuck and keeps restart-orphaned jobs `queued` forever. The release-8 reviews' optional lows ride along. The same release also carries the hub's polish, which the operator asked for before the homepage links the hub again (`HUB_LINK_ENABLED = false`): C1 brings the hub into the homepage's look and copy with the homepage's own numbers, and C2 the federated-search UX. Flipping the homepage's hub link is a later step, not a slice. Rules: `plans/tools/implementer-rules.md`. Record file: this file. ## Record ### Slice F, as shipped — four fixes (2026-09-25) Branch `one-core/r9-fixes` off `main` `9247211e`, one Opus implementer, no sibling slices. **B1 — a queued job no longer ends `failed` after succeeding.** The scheduler starts the next queued job synchronously inside `complete()`, so that job inherits the async context of whatever finished the one ahead. When the chain began at a job with no request behind it (an auto-runner unit, a heartbeat sync), Next has no work store and `revalidatePath` throws `Invariant: static generation store missing`. Job bodies call it last, so the throw landed after the work was done: the teamrcn sync at 15:21 read `failed` and `pnpm ops … --wait` exited 1. `editor/app/lib/safeRevalidate.ts` — `safeRevalidate(paths, tags = [])` — swallows exactly that invariant (one `console.warn` per process, naming the paths) and rethrows anything else. It is swapped in at every job body (`fn`) and job hook (`onDone` / `afterRun` / `afterDone`), found by grep and by indentation, not from the prompt's list alone: 18 files, 63 call lines, including `repointJob.ts`'s `afterDone`, which the list did not name. Server actions that revalidate in their own request are untouched, including the ones that revalidate after `drainStream` (`review/actions.ts`, `refreshAllChannelSnapshotsAction`), because they are still inside it. The first e2e version queued the sync behind a `--test-slow` sync. That held the queue for minutes, and it could not fail: a job an ops request started carries that request's store. The final version holds `platform:youtube` with `/api/test/stuck-job?releaseAfterMs=4000`. The new parameter finishes the fake holder from a timer armed inside `workAsyncStorage.exit`, i.e. outside any request, and the route reports `detached` so the spec cannot pass for the wrong reason. The spec then queues an ops sync behind it. **Against the pre-fix sync body it fails with the live line** — `[error] Invariant: static generation store missing in revalidatePath /channels/slow-b`, status `failed` (`r9-e2e-prefix.log`) — and it passes with the fix. **B2 — no 30 s sleep after a video the download filter declined.** `runManagedDownloads` slept `sleepBetweenDownloadsSeconds` after every video. `declinedWithoutMediaFetch(outcome)` is true when every attempt was the `n: 0` metadata prefetch (or its cookie retry) and the video ended `skipped-filtered`. Any attempt `n >= 1` is a real fetch: the download attempts and the chat-only pass. A failed chat pass leaves the status `skipped-filtered` and still sleeps. As first shipped, a `per_video` failure that never got past the prefetch also skipped the sleep. The review narrowed that (see "Review fixes"): **every failure keeps the pace.** `runManagedDownloads` is exported with a `deps` seam (`downloadOne`, `sleep`). `managedDownloadsSleep.test.ts` drives the loop with canned outcomes: - a fetched video sleeps; - a filtered one does not; - a per-video failure at the prefetch sleeps; - the last video never sleeps; - a failed real fetch and a network failure at the prefetch both sleep; - a unit table for the predicate. **B3 — YouTube request pacing.** `PLATFORM_ARGS.youtube = ["--sleep-requests", "1"]`, with the comment citing `~/reports/release-7/data/q-429-report.md` finding 1. The prompt named `release-8/data`; the report is in `release-7/data`. The comment above the table now says what the pace is, and that the metadata scan and the clip window, which already pass `--sleep-requests 1`, carry it twice, harmlessly (yt-dlp keeps the last). The argv tests were updated deliberately: - `channelArgs.test.ts`: youtube gets the pace; a platform with no entry gets nothing; a channel's own `--sleep-requests 3` comes after and wins. - `platform-args.test.mjs`: +1 case, youtube carries the pace and a URL on no known platform carries nothing. The YouTube clip fetch and simulate now expect the pace. No e2e asserts a YouTube argv exactly. `rumble-sweep.spec.ts` only asserts Rumble lines, and `fake-ytdlp.mjs`'s `lastNonFlag` never sees the trailing `1`, because every spawn ends in a URL, `--load-info-json ` or `-a `. **B4a — `/jobs` flags a stall by quiet time, not age.** `JobRecord.progressAt` is stamped at the source. `noteProgress` (the `setProgress` every `runManagedFunction` job gets) stamps it only when the snapshot's numbers changed, and `recordTaskDuration` stamps it when a sub-operation finishes. `reconcileSlots` measures quiet time from the later of the start and that stamp. This deviates from the prompt's suggested view-side map, on purpose. A map is only fed while someone has `/jobs` open, so the first render after an hour away would either flag a healthy scan (if it seeded from the start) or hide a real stall for 10 more minutes (if it seeded from now). The stamp has neither problem, and the registry is in memory, so it is still evicted with the record. `jobRows.test.ts` +2: an hour of 6 s steps is never stuck, and progress frozen past 10 min is possibly-stalled. The same count re-reported is not a move. **Both fail on the old rule.** The `/api/test/stuck-job` fixture backdates `startedAt` with no `progressAt`, so `queue.spec.ts`'s force-release case still sees its stall. **B4b — boot settles the metas a restart left `queued`, and re-queues little.** `common/jobs/bootQueuedJobs.ts` (`settleQueuedJobMetas`) runs from `instrumentation.ts`: lazy-imported, voided and best-effort. It runs after the storage boot pass, and since the review fix it WAITS for that pass. Its scope is each `*.meta.json` with status `queued` that was queued before this boot and is not held by the live registry. A malformed meta is skipped. In order: 1. An idle boot (`ARCHILYZER_IDLE_BOOT`) and the e2e test server (`EDITOR_TEST_ROUTES=1`) cancel everything and re-queue nothing. 2. A `sync` is never re-queued: "server restarted; the scheduler re-derives syncs". 3. A meta queued more than 24 h (`REQUEUE_MAX_AGE_MS`) before the boot is cancelled: "queued before the last restart, stale". 4. A meta with no spec is cancelled with the reason. 5. Of what is left, only the NEWEST meta per kind + channel + bucket + params (params key-sorted) is re-queued, through `runJobSpec` (the path Retry uses). The old meta is closed `cancelled`, naming the new id. The others are cancelled as "superseded by a newer queued job ()". A refused or throwing re-queue is cancelled with its error. The log gets one line per re-queued job and one summary line: re-queued N, cancelled M, by reason. The reason is also appended to each job's `.log`, which makes the pair prunable, since pruning walks `.log` ids. Metas are written tmp + rename. `running` metas are left alone. `JobMeta` gains the optional `cancelReason`. **Live size, from the review's read-only count.** 1,396 metas, 392 `queued`: - 346 `sync` over 51 channels, in batches from 08-03 to 09-24; - 29 `whisper-all` from June and July; - about 7 others with a spec; - 10 with none. The first version would have called `runJobSpec` about 382 times at boot. Under the current rules, the syncs and everything older than 24 h are cancelled, so at most a handful of fresh non-sync jobs come back. `bootQueuedJobs.test.ts` has 9 cases over a temp `.jobs` dir with an injected re-queue. One is 7 duplicate syncs + 1 stale `whisper-all` + 1 fresh non-sync, which gives exactly 1 re-queue. Other cases cover duplicate specs written in a different key order, and a malformed meta next to a good one. It was not run against the real corpus. **Lows, all three done.** - **S:** `mergeBackoffEntry` merges `until` and `fails` separately, each to its max, in both the runner's merge and `recordDownloadBackoff`'s. `platformBackoff.test.ts` +1. - **V:** a changed `data/` mtime REBASES the channel's title memo instead of dropping it. One `readdir` keeps the titles of the dirs still present, only new dirs are read, and a removed dir's title goes with it. The memo case in `videoTitles.test.ts` now asserts 1 read for a new dir and a fall-through for a removed one. It pins a distinct mtime, because two changes inside one filesystem tick share one. - **H:** - `official.transcripts` is a per-site accumulator from the same pass that places each transcript. - A site outside `keptIds` gains no monthly key. - `gone` counts only with a `downloadedDate`. - `FamilyStats` is `lg:grid-cols-5` without a gone cell. - The stale comments in `ArchiveGrowthChart.tsx` :10-11 and `page.tsx` :125, and the test title, are fixed. `homepageSummary.test.ts` +3. No number on today's data moves. The review measured placed + unplaced = `official.transcripts` (49,767) and all 480 deleted records with a `downloadedDate`. | sha | what | |---|---| | `d927dad4` | B1: `app/lib/safeRevalidate.ts` + its unit test (3), swapped in at every job body and hook (18 files); first ops-api e2e case | | `37d263cb` | B2: `declinedWithoutMediaFetch`, the `deps` seam on `runManagedDownloads`, `managedDownloadsSleep.test.ts` (6) | | `ac55a23c` | B3: `PLATFORM_ARGS.youtube`, table comment, `channelArgs.test.ts` (+2), `platform-args.test.mjs` (+1) | | `efd75cb0` | B4a: `JobRecord.progressAt`, `noteProgress`, quiet-time stall rule, `jobRows.test.ts` (+2) | | `cc884951` | B4b: `bootQueuedJobs.ts` + test (6), `JobMeta.cancelReason`, the instrumentation boot pass | | `db18b73d` | low S: `mergeBackoffEntry` in both merges, test (+1) | | `49993934` | low V: title memo rebased on a new dir, test updated | | `c714bc14` | low H: `homepageSummary` counts + guard, `FamilyStats` grid, comments, test (+3) | | `78d165f6` | B1 e2e reworked: `stuck-job?releaseAfterMs` (detached release), the spec now reproduces the live failure pre-fix | | `0c3e0b9a` | `plans:` this record, the `[Unreleased]` bullets | | `be48f610` | (review HIGH-1, LOW-1, LOW-2, NIT-1) boot pass: no syncs, 24 h age cap, newest per spec, summary log; storage pass awaited; atomic meta writes; test 6 → 9 | | `11c947ab` | (review MED) B2 skips the sleep for `skipped-filtered` only; the per-video test now expects a sleep | | `23134785` | (review LOW-3) the B1 e2e releases its holder after 10 s, not 4 | | _this_ | `plans:` the review fixes in this record, the CHANGELOG and the gates | **Gates**, all from the worktree root. Heavy steps started at ≥ 3 GB available memory. - **tsc** (`pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`) was clean before every code commit. It was checked once to catch a planted error, so the silent `exit=0` is real. - **common: 1,836/1,836**, from 1,816 + 20: B2 +6, B3 +2, B4a +2, B4b +6, S +1, H +3; V's case was rewritten in place. - **editor unit: 78/78**, from 75 + 3 (`safeRevalidate.test.ts`). - **`test:scripts`: 162 pass + 1 skip of 163**, from 161 + 1 skip, +1 in `platform-args.test.mjs`. A first run during e2e run 1 failed `E2E_QUEUE=0 bypasses the queue entirely`, because this worktree's own e2e held the lock; the rerun with no e2e running is clean. - **mcp: 219/219.** - **Builds:** `pnpm --filter editor exec next build` ok, `pnpm --filter export exec next build` ok, and, because H touches `homepage/`, `pnpm --filter homepage exec next build` ok. No dangling `export/public` links. - **EDITOR e2e.** Spec list `r9-specs.txt`: `ops-api queues auto-queue lane-runner jobs-filters queue` (the grep for `possibly-stalled|STUCK|data-job-id` adds `queue.spec.ts`), plus `jobs-retry jobs-active-order jobs-channel` (the retry path B4b reuses), `rumble-sweep` (argv), `title-filter` (B2's filtered path), `pacing metadata-scan-botcheck sync-deep`, all `.spec.ts`. - Run 1 (`r9-e2e1.log`), on `c714bc14`: **84 passed, 2 failed, 13.4 min**. One failure was the first B1 spec, which timed out behind the slow sync (the rework above). The other was `auto-queue.spec.ts:218`, the run's first test, where the `/api/auto-queue/status` GET outlived the 30 s test timeout on a cold dev compile. It passed in run 2 with no change. - Pre-fix proof (`r9-e2e-prefix.log`): the reworked B1 spec with the sync body's `revalidatePath` restored: **1 failed**, with the live invariant in the job log. The fix was restored before any commit. - Run 2 (`r9-e2e2.log`), on `78d165f6`: **86 passed, 0 failed, 5.7 min**, with no queue wait. **Review fixes (review verdict SHIP AFTER FIXES, `r9-review.md`).** - **HIGH-1** (the boot-flood size), **LOW-1** (await the storage pass), **LOW-2** (malformed-meta test) and **NIT-1** (atomic meta writes) are all in `be48f610`. - **MED** (B2 keeps the pace after a per-video failure) is `11c947ab`. - **LOW-3** (10 s release window) is `23134785`. - **LOW-4** is left; see "Found and left". Re-gate on `23134785`: - tsc clean. - common **1,839/1,839** (1,836 + 3 boot-pass cases). - editor unit **78/78**. - EDITOR e2e `jobs-filters.spec.ts ops-api.spec.ts queues.spec.ts queue.spec.ts` (`r9-e2e3.log`): **57 passed, 0 failed, 2.7 min**. No e2e covers the boot pass. The test server is cancel-only by design, so the pass is pinned by its 9 unit cases. **Numbers: none** (per the prompt). No `settings.json`, `site.json` or `config.json` key changed. `JobMeta.cancelReason` and `JobRecord.progressAt` are additive and optional. **Found and left.** - **B2 lets filtered prefetches run back to back.** A filtered video's metadata prefetch is still one yt-dlp process against YouTube. Without the 30 s sleep, a run of declined videos is a run of prefetches, spaced only by process start-up and B3's one-second request pace inside each. The metadata scan does the same work at ~10/min with no 429s. If a filtered channel's download-missing ever 429s, the lever is a smaller sleep for prefetch-only videos, not the full one. - **Classification quirk, left as is.** YouTube's soft block ("This content isn't available, try again later") classifies as `deleted` → `per_video` in `classifyDownloadFailure`. So a soft block neither triggers the platform backoff nor aborts a batch, and it reads as a per-video property. It is why B2 keeps the sleep after every per-video failure. `classifyDownloadFailure` was not changed (review instruction). - **B4b leaves `running` metas.** A job running at a hard crash (no graceful shutdown) still reads `running` → `archived` on `/jobs`. Whether a half-done job should re-run is not a boot pass's call. - **`cancelReason` is not drawn on `/jobs`.** It is in the meta and appended to the job's log, which the row's log view shows. - **The rollout's first boot settles the 392 live `queued` metas.** Nearly all are cancelled with a reason, as above; only fresh (< 24 h) non-sync jobs, newest per spec, come back. Booting once with `ARCHILYZER_IDLE_BOOT=1` re-queues none. - **LOW-4, left:** `safeRevalidate`'s "once per process" warning is once per module instance. Next can load a module more than once, so it may warn a second time. - **The stuck-job harness reaches into a Next internal** (`next/dist/server/app-render/work-async-storage.external`). It is typed, test-route-only, and reported through `detached`, so a Next upgrade that moves it fails the spec loudly rather than letting it pass vacuously. ### Slice C1, as shipped — the hub in the family's register (2026-09-25) Branch `one-core/c1-hub-look` off `main` `9247211e`. **The operator's decision (2026-09-25 evening):** the hub (`https://archilyzer-hub.pages.dev`, the export app with `INSTANCE_MODE=hub`) gets the homepage's look and copy, and its shelf becomes "Official instances" showing the SAME per-site numbers the homepage shows. Scoping: `$T/r9-hub-scope.md` (read-only pass). The federated-search UX (`SearchDataContext.tsx`, `SearchResults.tsx`, `SearchSessionContext.tsx`) is C2's and untouched. **Theme.** `export/app/layout.tsx` branches on `instanceMode() === "hub"` (server-only, the file is a server component) to `defaultTheme="archilyzer" defaultMode="dark"`, exactly `homepage/app/layout.tsx`'s, and the hub's browser-chrome `themeColor` is the homepage's `#151b20`. Site builds keep `base`/`system` and `#2563eb`; `theme.spec.ts`/`theme-family.spec.ts` (site build) stay green. The built hub `index.html` carries `archilyzer` in its ThemeScript; a render of the built hub reads `data-theme="archilyzer"` + `.dark` with no stored preference. **`--chart-3`** in `[data-theme="archilyzer"]` was the `--state-gone` hex in both modes, so the third official instance (Bonnellyzer, in `seriesColor` order) drew as "gone" on the homepage chart and card. It is now a green, lightness-stepped away from the gone red so the two stay apart under CVD simulation too. Chosen by the dataviz validator (OKLab ΔE ×100, Machado CVD), against the neighbours in the fixed order: | mode | `--chart-3` | vs `--state-gone` normal / protan / deutan | vs chart-2 | vs chart-4 | contrast on `--chart-surface` | |---|---|---|---|---|---| | light | `#5a9e3a` (was `#a8412d`) | 25.9 / 21.8 / 11.2 | 26.9 | 30.3 | 3.29:1 on `#ffffff` | | dark | `#86c86a` (was `#c4553f`) | 29.1 / 27.4 / 17.1 | 20.8 | 26.6 | 8.03:1 on `#1a2229` | Hues near magenta read as the gone red at normal vision (ΔE ~10–11); darker greens collapse into it for deutans (ΔE 2–5). The palette's pre-existing validator failures are unchanged and not this slice's: chroma floor (`chart-1`, `chart-5` light; `chart-1`, `chart-2` dark), the chart-1↔chart-2 normal-vision floor (12.4 light / 9.0 dark), and the dark lightness band. Green also sits near `--success`; a chart series is not chrome state, so that was accepted. **The numbers, at build time.** `compose-hub.ts` now also writes `public/hub-summary.json` (`lib/hubSummary.ts`, version 1): `{version, generatedAt, official, sites[]}`, each site `{siteId, siteTitle, siteDescription?, siteUrl, channels?, recordings?, transcripts?, hoursArchived?, gone?, accent?}` — `transcripts` is the homepage summary's `transcribed.total`. It is `toHubSummary()` of the SAME `buildHomepageSummary` call the homepage makes: the input gathering (buildStats → read the whole-pool pages → channel→sites map → summarise) moved out of `compose-homepage.ts` into `common/controller/poolSummary.ts` (`buildPoolSummary`, `readStatsPages`, `channelSitesOf`), and both composes call it. The hub's whole-pool stats pages go to `/hub-stats` (staging, not served). **The file is optional:** no index (`paths.lmdbPath` absent) → not written and any stale copy removed; a failure → logged, removed, the hub build carries on. `compose-site.ts` removes it with `hub-sites.json`, so a site bundle never ships it. Not in `HUB_CORS_PATHS` (read same-origin); `builtHubProblem` unchanged. Why build time: a member's `corpus.json` carries only channel/video counts, so a live sum could never equal the homepage's; the hub is rebuilt alongside the sites. **Proved equal, read-only:** a scratch script (`$T/c1-verify.mts`) ran `readStatsPages` over the primary's `homepage/public/stats` (77,842 records), `listSites` over the primary's `sites/`, and `buildHomepageSummary` + `toHubSummary`, and compared with `toHubSummary` of the live `homepage/public/homepage-summary.json`: **MATCH** (official 5 sites / 63 channels / 75,785 recordings / 49,767 transcripts / 60,840 h / 480 gone). No LMDB was opened. `compose:hub` itself was NOT run against the real corpus: `buildStats` writes the index's `statsByPath` sub-DB, so it is not read-only, and the 15 GB index cannot be copied. In the worktree it ran with no corpus: "0 built-in pool site(s) …; no index to summarise — hub-summary.json skipped." The first real `hub-summary.json` is written by the parent's `build-hub`, whose log line says how many instances it covers. **The page, top to bottom, with its final copy** (`ArchiveShelf.tsx`, `HubStats.tsx`, `AddArchive.tsx`, new `useHubSummary.ts`): 1. **H1** `{official.hoursArchived} hours of speech from every official archive, searchable at once.` (no summary: `Every official archive, searchable at once.`; no summary AND no official instances: `Every archive you add, searchable at once.`). **Paragraph:** "One search runs across all of them, in your browser. You can add any other Archilyzer archive to your own search, below. It stays in this browser until you remove it." 2. **`Official instances`** (h2) + "The archives I run. Anyone can run their own." + one card per built-in member in `hub-sites.json` order: title linking out (`target="_blank"`, ↗), the summary's description, and channels · recordings · transcripts · hours, each omitted when absent; the `Installable`/`Data-only` badge. The stripe is the site's own `accent`; a site with none (all five today) falls back to `seriesColor(i)` at its index in the summary's `sites` — the homepage's `ArchiveCards` and chart order — so each hub card wears its homepage card's colour (the card's own index when there is no summary). Figures match by `siteId`, then by origin. Zero built-ins: "This hub has no official instances. Add an archive to search it." 3. **`Archives you added`** (h2, only with externals) + the same card with the descriptor's channel count only and the Remove control (`aria-label="Remove {title}"`), stripe its accent or `var(--brand)`, then the form (the paragraph under the H1 says how adding works, once). The Add button is the family's CTA (ink on ground, brand on hover), no longer a brand fill. 4. **Live line:** "Searching N archives · N channels · N transcripts right now." — the LIVE federation, added archives included; the code comment says so, the page does not. 5. `/ask` hub branch: "…for other ways to use every archive on this hub." (was "this archive"). `siteRegistry.ts` ALREADY: "That archive is already on this hub." (was "on your shelf"; no spec asserts it). **Dropped:** "The shelf" eyebrow, the "Your archives" H1, the Member/Added badges (the two sections replace them), the empty-shelf sentence. `data-testid="shelf-spine"` stays on every card in both sections. **Changed locators** (same commit as the copy): `getByRole("heading", { name: "Your archives" })` → `getByRole("heading", { level: 1, name: /searchable at once\./ })` in `e2e-hub/federation.spec.ts`, `e2e-hub/ask-grounding.spec.ts`, `e2e-2origin/federation.spec.ts`. `e2e-hub/federation.spec.ts` "adds an archive by URL and shows it on the shelf" is renamed "…shows it under Archives you added" and also asserts the h2 and `Remove Origin B`. Unchanged: `getByLabel("Archive URL")`, button `Add`, `getByTestId("shelf-spine")`, `p[role="alert"]`, `role="status"` `Added {title}.`, the `/ask` H1. New `e2e-hub/official-instances.spec.ts` (+2): with a stubbed `hub-summary.json` the official card shows its four figures, description and outbound link, the H1 carries the hours, and there is no Remove and no "Archives you added"; with it 404, the card renders with no `dl` and the fallback H1. | sha | what | |---|---| | `ec5f8ebe` | `export/app/layout.tsx`: the hub defaults to the archilyzer family, dark, and the homepage's theme colour; sites unchanged | | `6d847cdc` | `common/styles/tokens.css`: archilyzer `--chart-3` light `#5a9e3a`, dark `#86c86a` | | `812d1b86` | `common/lib/hubSummary.ts` (+ test, 5), `common/controller/poolSummary.ts`, `compose-hub.ts` writes `hub-summary.json` (+ `compose-hub.test.ts`, 1: no index → no file, stale removed, no index created), `compose-homepage.ts` on `buildPoolSummary`, `compose-site.ts` removes it, `publish/build.ts` doc | | `5a1bd3c0` | review fixes: `.gitignore` `/export/public/hub-summary.json`; H1 with no official instances; the add sentence once, scoped to this browser; official cards fall back to `seriesColor` in the homepage's order; generic hours in the CHANGELOG | | `dc9ba0dd` | the hub page (`ArchiveShelf`, `HubStats`, `AddArchive`, `useHubSummary`, `HubHome` comment), `/ask` wording, registry ALREADY copy, the spec locators, `official-instances.spec.ts` | **Gates** (on `dc9ba0dd`): tsc clean (per commit). Common tests **1,822/1,822** (1,816 + 6). test:scripts **161 passed + 1 skipped**; the first run, concurrent with the export builds, had 1 failure in `scripts/queue-lock.test.mjs` "prints a banner naming the holder while waiting" — a timing test this slice does not touch; the idle re-run passed. `pnpm --filter export exec next build` (site) ok; `compose:hub` then `INSTANCE_MODE=hub … next build` ok (11 static routes); both with the one known Turbopack warning. e2e, queued: **`e2e:hub` 11 passed** (9 + 2), 36 s (after ~6 min in the queue behind `one-core/r9-fixes`); export `theme.spec.ts theme-family.spec.ts site-branding.spec.ts` **8 passed**, 29 s; **`e2e:2origin`** (`TWO_ORIGIN_REBUILD=1`) **3 passed**, 44 s — the six compose-hub outputs in `export/public` swapped for copies before and relinked after; the primary's files were untouched (mtimes 18:09, `hub-sites.json` still the five-site pool). A render of the built hub with the verified summary and the real `hub-sites.json` (members' origins blocked) was checked at 1280 dark/light and 390 dark. The editor build, editor unit and mcp were not run: nothing they build or test changed beyond `common/`, which tsc and the common tests cover. **Numbers tools: none.** **Review (SHIP AFTER FIXES) re-gate** on `5a1bd3c0`: tsc clean; `hubSummary.test.ts` + `compose-hub.test.ts` 6/6; `e2e:hub` **11 passed**, 24.5 s. **Merged `main` (`ef0978a6`, release 9 slice F)** as `11e185c0`; `plans/release-9.md` and the CHANGELOG resolved by keeping both. Re-gate on the merged tree: tsc clean; common tests **1,845/1,845** (1,839 + 6); `pnpm --filter export exec next build` ok; `compose:hub` (worktree, no corpus: skipped) + `INSTANCE_MODE=hub … next build` ok; `e2e:hub` **11 passed**, 26.9 s (compose-hub outputs swapped for copies and relinked, as before). **Projection re-check** with main's `buildHomepageSummary` (`c714bc14`: official transcripts counted where placed, gone only when held), read-only over the primary's `homepage/public/stats` (77,842 records, written 18:26): **MATCH** against the live `homepage-summary.json` (22:26:11Z, written by the pre-change function) — nothing moved. `official.transcripts` 49,767, `official.gone` 480; per-site gone Jeralyzer 473, Anilyzer 2, Bonnellyzer 2, Hasanalyzer 3, Rekietalyzer 0 — identical before and after on this stats set. **Found and left:** - The hub build now does what the homepage build does: `buildStats` opens the index LMDB read-write and walks every channel's `data/`. An unmounted drive therefore undercounts those channels' figures in `hub-summary.json` with no warning — the same exposure the homepage and a site's `build:stats` already have, not a new one. - The branch `one-core/r9-fixes` changes `buildHomepageSummary` (`c714bc14`: official.transcripts counted where transcripts are placed). `hub-summary.json` is a projection of whatever it returns, so after both merge the hub and the homepage still agree — as long as both are rebuilt from the same commit. The MATCH above is against the pre-change function. - No official site sets `accent`, so every official card wears its homepage series colour. A `site.json` accent would override it on the hub only (the homepage ignores accents for its cards). - The live line counts what loaded; while members load it climbs, and a member that fails silently lowers it with no sign — C2's per-site state is the fix. - The H1 swaps from the fallback to the hours sentence when `hub-summary.json` arrives (client fetch, one frame of text change). Embedding it at build would need the hub page to read `public/` at build; left as a fetch so the e2e can stub it. - The chart-3 change repaints the homepage's third instance too; it needs a homepage rebuild to show. ### Slice C1b, as shipped — copy rulings + Ko-fi link (2026-09-25) Branch `one-core/c1b-hub-copy` off `main` `6820bd21`. **The operator's rulings (2026-09-25), amending C1:** Title Case on the h2 headings of the hub shelf and the homepage, so the two pages share one style; no subtitle where the title implies it ("The archives I run. Anyone can run their own." removed on both); no per-site description on the official-instance cards (hub and homepage), the four figures stay; a bare `Ko-fi` link, no copy, on the project's own surfaces only. The hub's H1 and its paragraph and the homepage's hero paragraph stay (they are not subtitles). C2's files (`SearchDataContext`, `SearchResults`, `SearchSessionContext`, `siteRegistry`, `HubHome`, `HubStats`, the `ask-grounding` / `federation` / `ask` hub specs) were not touched. **Copy, final:** - Hub (`ArchiveShelf.tsx`): h2 **Official Instances** (no paragraph under it; the grid's top margin moved onto the h2), h2 **Archives You Added**. The card is title (+ Remove on an added archive), then the figures and the `Installable`/`Data-only` badge; `siteDescription` is no longer read. `AddArchive.tsx`'s comment names the new heading. - Homepage (`page.tsx`, `ArchiveCards.tsx`): h2 **Official Instances** (no paragraph), h2 **What It Does**; the card is title then figures. The page's copy-rule comment now says so. - `FamilyStats`' cell label "Official instances" is a stat label, not a heading, and stays in the strip's sentence case (like "Hours of speech"); not in the ruling. **Ko-fi.** `KOFI_URL = "https://ko-fi.com/archilyzer"` beside `PROJECT_URL` in `common/lib/project.ts`. Export footer (`export/app/components/Footer.tsx`): `Ko-fi` in the "Built with Archilyzer" row, rendered **only when `instanceMode() === "hub"`** — a site build shows nothing. Homepage footer (`homepage/app/components/Footer.tsx`): the homepage has no "Built with" row, so the link sits in the **Elsewhere** column under the social icons; that column now always renders (it was gated on social links), the icon row only when links are set. **Layout without the description** (built hub served statically with the primary's `hub-sites.json` + `hub-summary.json` stubbed in; built homepage with the primary's `homepage/public` data), 1280 and 390, dark: the cards shorten by the description line; the figures stay aligned across a row (`mt-auto` on the figure block unchanged), and at 390 each card is title, figures, badge with no gap. Shots: `$T/c1b-hub-1280.png`, `$T/c1b-hub-390.png`, `$T/c1b-homepage-1280.png`, `$T/c1b-homepage-390.png`. **Changed locators:** `e2e-hub/official-instances.spec.ts` — `heading {level: 2, name: "Official instances"}` → `{level: 2, name: "Official Instances", exact: true}`; the card's `toContainText("Every word the member said.")` → `not.toContainText(…)` (the stub still carries a description, so the absence is proved); `getByText("The archives I run")` count 0; `heading "Archives you added"` → `"Archives You Added"`; new test "the hub footer carries a bare Ko-fi link" (href `KOFI_URL`, `_blank`, `noopener noreferrer`, text exactly `Ko-fi`). `homepage/e2e/marketing.spec.ts` — `heading "Official instances"` → `{level: 2, name: "Official Instances", exact: true}` + "The archives I run" count 0; new "the headings after the H1 are in Title Case" (`What It Does`, exact) and "the footer carries a bare Ko-fi link". `export/e2e/site-branding.spec.ts` — new "a site build carries no Ko-fi link" (no link named Ko-fi, no `a[href=KOFI_URL]`). **Left unchanged deliberately:** `e2e-hub/federation.spec.ts:151` `heading {level: 2, name: "Archives you added"}` (C2's file) — a Playwright role name without `exact` is a case-insensitive substring, so it matches "Archives You Added" and passes (it did: e2e:hub 12/12); C2 or a later slice may make it exact. Its test title still says "Archives you added" (a string, not a locator). | sha | what | |---|---| | `764f770e` | `common/lib/project.ts` `KOFI_URL`; Ko-fi in the hub footer (hub only) and the homepage footer's Elsewhere column | | `0e9192e9` | Title Case h2s, the "archives I run" line removed, no description on the official cards — hub `ArchiveShelf` (+ `AddArchive` comment), homepage `page.tsx` + `ArchiveCards` | | `35aafad3` | the spec locators above; Ko-fi specs (hub, homepage, site-mode absence) | **Gates** (on `35aafad3`): tsc clean (per commit). Common tests **1,845/1,845**. `pnpm --filter export exec next build` (site) ok; `compose:hub` (worktree, no corpus: "0 built-in pool site(s) …; hub-summary.json skipped") + `INSTANCE_MODE=hub … next build` ok (the known Turbopack warning) — the seven compose-hub outputs in `export/public` swapped for copies before and relinked after, primary's files untouched (`hub-sites.json` / `hub-summary.json` still 19:42); the built hub `index.html` carries the Ko-fi anchor. `pnpm --filter homepage exec next build` ok. e2e, queued (no wait): **`e2e:hub` 12 passed** (11 + 1), 45 s; export `site-branding.spec.ts theme.spec.ts` **8 passed**, 26 s; **homepage suite 24 passed**, 57 s, with the Official Instances branch exercised (summary present). Editor build, editor unit, test:scripts and mcp not run: nothing they build or test changed beyond a new constant in `common/lib/project.ts`, which tsc covers. **Numbers tools: none.** **Found and left:** - **Homepage worktree data must be COPIED, not symlinked.** `homepage/app/lib/snapshot.ts` reads `public/downloads/snapshot.json` at build, and Turbopack refuses a symlink that leaves the project root ("Symlink … is invalid, it points out of the filesystem root"). The four gitignored entries (`channel-sites.json`, `downloads/`, `homepage-summary.json`, `stats/`; ~60 MB) were copied from the primary. `export/public`'s per-path links work only because nothing there is imported at build. Worth a line in FACTS / WORKTREES.md. - **The export e2e harness writes the primary's `export/public/sw.js` through the worktree symlink.** `export/playwright.config.ts` copies `service-worker/site-sw.js` to `public/sw.js` at load; in a worktree that `sw.js` is a link to the primary's, so the site-spec run (20:13:39) replaced the primary's hub service worker (10,027 B, from the 19:42 hub compose) with the site one (10,043 B). Restored: the hub build's `out/sw.js` from this worktree is byte-identical to the primary's `service-worker/sw-hub.js`, copied back and its mtime set to `hub-sites.json`'s (19:42:24). The C1 gate statement "the six compose-hub outputs … relinked after" did not cover this path either. Fix candidate: unlink `public/sw.js` before the copy in `playwright.config.ts` (or add `sw.js` to the worktree-link exclusions). Not done here (not this slice's file). - The homepage lists instances in the summary's order (Jeralyzer first), the hub in `hub-sites.json` order (Anilyzer first); the colours agree per site. Pre-existing (C1), not a ruling. - Needs a rebuild + deploy of the hub AND the homepage; this slice did not run build-hub, deploy-hub or deploy homepage. ### Slice C2, as shipped — federated search (2026-09-25) Branch `one-core/c2-federated-search` off `main` `6820bd21`; merged `main` (`8eb55add`, slice C1b) as `9d20d3fc`. **The operator-approved target (2026-09-25):** the hub searches every official instance by default, a visitor can take any archive out, each archive's state is visible, the search runs as soon as one archive is in, a failed member is named and retryable, a result names its archive in text, and pages are fetched per archive behind its manifest. Scoping: `$T/r9-hub-scope.md` §3. **The provider** (`common/components/SearchDataContext.tsx`, `MultiSiteDataProvider`). `FederatedSite` gains `enabled` (default true): false means none of that archive's feeds are fetched (`enabled: false` on its manifest, subs, posts and alias queries; no page descriptors) and nothing already cached from it is merged — TanStack returns cached `data` for a disabled query, so every merge checks scope itself. Per archive, its state is derived from its manifest, page, subs and posts queries: `ready` (manifest in, every page in, and its subs + posts manifests settled — a posts 404 is an empty manifest, a subs error counts as settled), `failed` (manifest or a page errored and nothing of it is fetching), `loading` (otherwise, including while a Retry is in flight), `off`. `SearchDataValue` gains two OPTIONAL fields, set only by the multi-site provider: `federation: {sites: FederatedSiteState[], retry(origin)}` (`{origin, siteTitle, accent?, status, count?, error?}`, `count` = the manifest's `totalCount`) and `siteTitleOf(origin)`. `retry` is `refetchQueries` over this provider's five feeds (`manifest`, `summaries-page`, `subs-manifest`, `posts-manifest`, `search-aliases`) of that origin whose status is `error`. `summariesState.error` is no longer always null: it is set when EVERY in-scope archive failed (read by `/ask`'s corpus-error line). `SingleSiteDataProvider` is not touched. - **Merge.** An archive's records join the merged list only when it is `ready`, so the list grows one whole archive at a time; the merged list's identity is keyed on the ready set alone, so the search session (which re-runs its pipeline on every new `summaries` identity) re-runs once per archive, not per page or per manifest. The subs, posts and alias merges are keyed on the same ready set, so every contribution of an archive lands in one re-run and a failed archive contributes nothing — no videos, posts or chat (before: whatever pages had loaded, and its posts regardless). The synthetic merged manifest (HubStats' transcript figure) covers the ready archives. Channel groups and channels cover the in-scope archives whose manifest has arrived. - **Progressive.** A new `progressive` prop: `summariesReady` turns true at the first ready in-scope archive. Without it (the `/ask` hub, `AskHub.tsx`, unchanged), it waits until every in-scope archive has settled, ready or failed. HubHome passes it. The release-8 slice E rule holds unchanged: a restored query is `runHeld` in `SearchSessionContext`, independent of readiness, so it still waits for the visitor; a `qt=` link runs over the first archive and re-runs as each further one arrives. - **Speed.** Pages are requested per archive only once its manifest has settled and only while it is in scope (as before for the manifest gate; new for scope), and each page fetch goes through a per-origin gate of **6 in flight** (`PAGE_FETCHES_PER_SITE`), so a big member cannot take the browser's connections from the small ones. The page `queryFn` consumes TanStack's `signal`, so switching an archive off mid-load cancels its queries and a page still waiting at the gate bails unfetched. No new build-time data. **The page** (`export/app/components/hub/`). `useHubScope.ts` keeps the set of origins switched OFF in `localStorage["ytdlp-tb:hub-scope"]` (`{off: [...]}`, every access in try/catch), so an archive the browser has not seen — a new official instance, an added one — is searched. `HubScope.tsx` is the row of chips under the live line, above the query builder: one per archive on the page (official and added), `data-testid="hub-scope-chip-"` + `data-status`, a toggle button (`aria-pressed`, the archive's title and `loading…` / its record count / `failed` / `off`) and, when failed, a `Retry ` button. `HubStats` counts the archives in scope and says "videos" (the chips' word for the same figure; was "transcripts"). Every chip button and the line's Retry carry a literal `focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring` (the chip's `overflow-hidden` clipped the default outline). `common/components/SearchResults.tsx`: one line above the results when an in-scope archive failed, `role="status"`, `data-testid="hub-scope-status"`: "N of M archives answered. X did not, so its videos are not in these results. Retry" ("archive" when M = 1) — only once NOTHING in scope is loading, so M = ready + failed and every archive not counted is named; nothing while every archive is fine (the chips show loading). A result card's meta line reads `<archive> · <channel> · <date>` (`data-testid="result-source"`, from `ResultGroup.source`, set via `siteTitleOf` in `SearchSessionContext`); single-site groups carry no `source` key. The accent stripe and the `data-result-slug` contract are unchanged. **Specs.** New `export/e2e-hub/federated-search.spec.ts` (+5), two official members route-mocked with CORS: (1) both searched by default, both chips `ready`, each card names its archive, no status line; (2) toggling Origin B off removes its card and after a reload it stays off with **zero requests** to its origin, and back on it is fetched and returns; (3) Origin B's pages aborted → chip `failed` with `Retry Origin B`, "1 of 2 archives answered." / "Origin B did not", Origin A's card renders; fixed + the line's Retry → chip `ready`, line gone, both cards; (4) Origin B's page held → Origin A's search result renders while B is `loading`, and the same search covers B once it is released. (5, review) three members, B failed and C held → no line; C released → "2 of 3 archives answered." naming B. `federation.spec.ts`: "Archives You Added" exact (C1b's Title Case). Every existing hub test id / accessible name is unchanged. | sha | what | |---|---| | `af8b30ed` | provider: scope (`enabled`), per-archive state + Retry (`federation`), ready-only merge, `progressive`, per-origin page gate of 6, `error` when all failed, `siteTitleOf` | | `f7f7482f` | hub: `useHubScope`, `HubScope` chips, HubHome wiring (`progressive`), HubStats counts the archives in scope | | `d0225a20` | a result names its archive (`ResultGroup.source`); the "N of M archives answered." line with Retry | | `0b891b8f` | `e2e-hub/federated-search.spec.ts` (+4) | | `9d20d3fc` | merge `main` (`8eb55add`, C1b) — no conflicts | | `6a69c034` | `federation.spec.ts`: "Archives You Added", exact | | `a0bd7b02` | a chip's title says what its number counts and what pressing it does | | `40818bbb` | the merged list's identity keys on the ready set alone (no re-run when another manifest lands) | | `d54e6814` | review fixes: ready needs subs + posts settled and their merges key on the ready set; the answered line waits for every archive (+ `role="status"`, singular); focus rings; page `signal`; "videos"; Retry scoped to the five feeds; spec +1 | **Gates.** tsc clean (per commit; full workspace on `9d20d3fc`+ and on `40818bbb`). Common tests **1,845/1,845** (no new unit tests: the provider is a hook, covered by the e2e). test:scripts **162 passed + 1 skipped**. `pnpm --filter export exec next build` (site) ok, 29 s; `compose:hub` (worktree, no corpus: "0 built-in pool site(s) …; hub-summary.json skipped") + `INSTANCE_MODE=hub … next build` ok, 32 s. e2e, queued, all on the merged tree: **`e2e:hub` 15 passed** (pre-merge, 48.9 s), **16 passed** (merged, 53.2 s), **16 passed** (`40818bbb`, 47.0 s); **export full suite 195 passed**, 8.5 min (single site unchanged); **`e2e:2origin`** (`TWO_ORIGIN_REBUILD=1`) **3 passed**, 46.9 s on `a0bd7b02`, and **3 passed**, 1.0 min on `40818bbb`. **Review (SHIP AFTER FIXES) re-gate** on `d54e6814`: tsc clean; **`e2e:hub` 17 passed** (16 + 1), 52.9 s. The seven compose-hub outputs + `sw.js` in the worktree's `export/public` were swapped for copies before every build/e2e and relinked after; the primary's copies were not written by this slice (one change seen, `hub-summary.json` at 20:18:20, is the parent's build-hub, which wrote the other six at 20:17:53). Editor build/unit and mcp not run: nothing they build changed. **Numbers tools: none.** **Seen against the live members** (the built hub served locally with the real five-site `hub-sites.json`, service workers blocked so routes apply): first result card at 1.0–2.2 s; chips ready Hasanalyzer/Rekietalyzer ~1.6–2.0 s, Bonnellyzer ~2–2.9 s, Anilyzer ~2–4.5 s, Jeralyzer ~3.1–5.1 s. With Hasanalyzer's pages aborted: "4 of 5 archives answered. Hasanalyzer did not, …", its chip failed with Retry, 72,360 records from the other four listed. Checked at 1280 dark and 390 dark. **Found and left:** - The chip's number is the member's summaries `totalCount` — its record (video) count, the number the results header counts ("All videos (N)") — not the card's "transcripts" figure, which is the homepage's build-time `transcribed.total`. The chip's title says "N videos from X are in the search"; the live line now says "videos" too. - A member whose manifest loaded but whose pages failed keeps its channel group in the filter panel (the channels are known); its records are not searched. Hiding the group on failure would churn the groups on every Retry. - **Follow-up:** the `/ask` hub has no scope chips and searches every archive on the page, as before; the scope is the front page's. Honouring it is `useHubScope().isOn` in `AskHub`. It waits for every archive to settle (not progressive), so a chat never grounds in a half-loaded federation silently. - **Follow-up:** official cards have no `accent` (C1: they wear `seriesColor` on the page), so result stripes and chip dots appear only for an archive that sets one — the text attribution is what names the source. The fix is one shared fallback (a `shelfAccent(site, summary, i)` out of `ArchiveShelf`) used by HubHome and AskHub too. - `route()` does not see requests a service worker makes; a manual check of the built hub needs `serviceWorkers: "block"`. The e2e-hub suite runs `next dev`, where no SW is registered. ## Rollout ## Rollout 2026-09-25 (evening) — `0213f6c8` live on :3001 (third restart of the day) Merge order: release 9 slice F (`one-core/r9-fixes` `7a646efa`, review SHIP AFTER FIXES → re-read SHIP) merged as `ef0978a6`; slice C1 (`one-core/c1-hub-look` `545cf025`, which had merged main and re-gated) merged as `0213f6c8`. Scripts: `plans/tools/rollout/` with `REL=r9` (`4bdb1f5e`, step 0 of the evening plan), plus `$T/r9-{build,restart,hub-home,sync}.sh`. **Gate on the merged tree** (`7a646efa`, tree = `ef0978a6` minus a STATE commit): tsc 0; common 1,839; editor unit 78; scripts 162 + 1 skip; editor `next build` ok; e2e 14 specs 86/86, 5.9 min. C1's re-gate on `11e185c0` (main merged): tsc 0; common 1,845; export site build, `compose:hub` + hub build ok; `e2e:hub` 11/11; the projection of main's `buildHomepageSummary` (low H) over the primary's stats pages matched the live homepage summary exactly (5 / 63 / 75,785 / 49,767 / 60,840 / 480; gone per site 473, 2, 2, 3, 0). **Editor build** on the primary at `0213f6c8`: 40 s, `BUILD_ID` `9pC6zELQSzjdkIPDNsVox` → `P0VMdKX7gbdsaiS5GvorF`. umtool untouched (still `a9YAe_dwhuM6DiCPzIwMD`, not restarted). **md5** 81 files (one more than release 8: the new `paramounttactcl-x` channel). before → pre-restart: ONE change, `paramount-tactical/config.json` — written 19:35:47, the operator clearing the `downloadFilter.include: "Quartering"` on the Configure form (the plan's "for the rest" step; `lastFullDownloadAt` had been stamped at 19:19:27 by download-missing, before the sweep). pre-restart → after-boot: identical. **Restart** 19:40: live jobs were only the two auto runners (no operator job). Editor answered 200 after 3 s, `/tags` 200. **Boot pass (B4b):** 391 queued metas before → `[boot] stale queued jobs: re-queued 1, cancelled 390 (sync 345, superseded 1, stale 44)` → 1 queued meta after. The one re-queue was the paramount-tactical metadata-scan (`01M3CV5W6WG8QJW09G2F0N1V8A` → `01M3DF38BAH541K6A51XE8HXWG`), which ended **`done`** with nothing left to scan — the first live B1 proof: one `[safeRevalidate] no request store (a queued job ran outside a request); skipped revalidating /channels/paramount-tactical, /channels, /operations/[id] (page)` line in the start log, no `failed`. **B1 + B3 live proof:** `pnpm ops sync --json '{"slug":"teamrcn"}' --wait` exited **0** in 29 s, job `01M3DF7EQJB92BK2Q5QNN9H61R` `done` (release 8's ran the same work and read `failed`); its listing spawn carried `--sleep-requests 1` (`yt-dlp --flat-playlist … -I 1:50 --sleep-requests 1 https://www.youtube.com/@teamrcn`). B4a: no STUCK on a healthy job seen. B2 waits for the next download-missing with filter-declined videos (Paramount's were all settled before the release). **Smoke** (`plans/tools/rollout/smoke.sh`, 19:42 → 19:49): `SMOKE_FAIL=1` from exactly the two moving pairs — `/api/jobs/active` vs `/api/view/activeJobs` (the proof sync ran during the read) and `/api/auto-queue/status` vs `/api/view/autoQueueStatus` (same 513,122 bytes; the diff is `focusPending` 107 → 112 / 1,353 → 1,348 and the lane's current video: the auto-download lane walking Paramount Tactical between the two reads). Everything else green: 6 pairs identical, 2 404s, presets, pulse `changed=false` both ways, 11 pages 200, `BUILD_ID` `P0VMdKX7gbdsaiS5GvorF`, 0 ZodError, `DEFERRED_OK download.deferred well-formed ("ok 0")` (the fixed check), umtool 200, `/channels/paramount-tactical/videos` 200 with 42 open rows. **Hub + homepage (C1):** `pnpm ops build-hub` 65 s — log: `hub-summary.json covers 5 official instance(s).`; `deploy-hub` 27 s → https://archilyzer-hub.pages.dev (deploy `6e7115f9`). Live check (Playwright): `data-theme="archilyzer"`, H1 "60,840 hours of speech from every official archive, searchable at once.", `Official instances` with 5 `shelf-spine` cards each showing channels / recordings / transcripts / hours (Anilyzer 23 / 29,751 / 8,365 / 8,159), no eyebrow, the live "Searching …" line, `/hub-summary.json` 200. `archilyzer build homepage` 47 s (v5) + `deploy homepage` 32 s → https://archilyzer.pages.dev (deploy `37fc10cc`), H1 present, still no hub link (C3). **The two summaries differ by 3 recordings** (hub 75,800, homepage 75,803): the auto-download lane archived three Paramount videos in the two minutes between the builds; transcripts, hours, gone identical. **Paramount Tactical (A2), the same evening:** metadata-scan `01M3DBFV07NC0Z6KWZ1BG3K9BX` scanned the last 201 (0 errors; store 1,370 + 12 members-only errors of 1,473); download-missing `01M3DD3WBVYPPXB1NCQ1HDX8BM`: 1,381 missing, 92 complete, 12 members-only excluded, **1,369 settled by the filter with 0 requests and 0 sleeps**; keep-videos matched 1 (`NV1QqS9NOiU`, already kept). Both ops jobs read `failed` on the pre-release invariant. After the operator cleared the filter (19:35) the auto-download lane took the channel at lane pace: archive 92 → 115 by 19:50. **Found and left.** - The hub orders its cards by `hub-sites.json` (alphabetical); the homepage orders by transcripts. Same colours per site (C1's `seriesColor` fallback keys on the summary's order), different rows. - No official site sets `accent`; all five hub cards wear series colours. A `site.json` edit each. - The hub build now walks the pool's `data/` and opens the LMDB read-write (as the homepage build does); an unmounted drive undercounts silently — C1 record. - YouTube's soft block ("try again later") classifies as `deleted` → per_video → no backoff — F record. - The boot pass waits for the storage boot pass with no timeout (review LOW); `/jobs` does not show `cancelReason`; the safeRevalidate warning is once per bundle. **C1b deployed (20:17 → 20:22, from `8eb55add`).** `build-hub` 80 s (summary covers 5), `deploy-hub` 32 s → deploy `747b6a32`; `build homepage` 78 s + `deploy homepage` 37 s → deploy `e9954138`. Live: hub h2 "Official Instances", no "The archives I run", no card descriptions, footer `Ko-fi` → https://ko-fi.com/archilyzer; homepage the same (two "Official Instances", "What It Does", one Ko-fi link, still no hub link); Jeralyzer carries no Ko-fi link. Summaries: hub 75,880 / homepage 75,884 recordings (the lane again), the rest identical. **C2 + C3 deployed (20:47 → 20:51, from `eebcaa90`).** C2 review SHIP AFTER FIXES → fix `d54e6814` (readiness = summaries + subs + posts settled; status line hidden while loading, M = ready + failed, `role="status"`; focus rings; page fetches honour the abort signal; "videos"; Retry predicate scoped) → re-read SHIP (one LOW left: `retry: false` on the subs query would make a member with no subs corpus ready ~1 s sooner). Merged as `eebcaa90`. C3 = `e6fed1a0` (`HUB_LINK_ENABLED = true`). `build-hub` 71 s (covers 5) + `deploy-hub` 29 s → deploy `6ef7f472`; `build homepage` 59 s + `deploy homepage` 37 s → deploy `e09900af`. Live (Playwright, service worker blocked): first result at 1.6 s, all five archives ready at 3.5 s, chips `Anilyzer 29,751 | Bonnellyzer 8,079 | Hasanalyzer 3,425 | Jeralyzer 31,605 | Rekietalyzer 2,925`, no status line, a card reads `Jeralyzer · TheQuartering (Rumble) · 2026-09-25`, "All videos (75785)" → toggling Anilyzer off → 46,034 (exactly minus 29,751). Homepage: "Search all archives" → https://archilyzer-hub.pages.dev, Ko-fi link present. Summaries 75,945 (hub) / 75,947 (homepage) recordings — the lane, again. The 2026-09-25 evening plan is complete through step C. Worktrees for r9, c1, c1b, c2 removed.