commit 92cc9d6d94a6ff1c069d9109091b6b7e8a6e1750
parent 1e7f5c9847a7c190f28b7f2897d8fa0a01a9fbbc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 26 Sep 2026 13:09:16 -0400
plans: FACTS — the sw.js-copy and compose-output-swap workarounds superseded by L1 (link-safe playwright config + _publicFile); release 10 facts for S4 (renderSvgPng, the glyph-outline lockup and its fontTools pin, FONTCONFIG_FILE scope, opt-in render.brand + end card, the ImageMagick date-chunk identity method), L1 (subs 404/retry/settled, the empty-scope rule, the homepage's order) and L2 (isSoftBlock and its consumers, unprobed = unverified, the 60 s boot wait, the hung mount)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
| M | plans/FACTS.md | | | 149 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
1 file changed, 145 insertions(+), 4 deletions(-)
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -968,6 +968,9 @@ client) and `mcp/src/search.ts` (server). MCP has 11 tools (`mcp/src/server.ts:1
worktree then runs against a hub, not a site. Run the export suites first, or restore the
site fixture (`corpus.json` from the duplicates-page worktree, remove the hub files, recopy
`site-sw.js`) before them. All of those paths are gitignored, so nothing shows in `git status`.
+**Since release 10 L1 (2026-09-26)** the compose writes land in the worktree's own files, never
+through its `export/public` links (`common/bin/_publicFile.ts`), so the restore is the per-path
+seed again (`plans/tools/implementer-rules.md`); the export config re-copies `sw.js` itself.
- `editor/playwright.config.ts` — editor on `PORT ?? 3011`, export on `EXPORT_PORT ?? 3010`.
`E2E_MODE=start` → `pnpm start:test`, otherwise `pnpm dev:test` (`:10-11`).
@@ -6467,9 +6470,15 @@ Line numbers are `plans/FACTS.md` lines at `e172749b`, before this record's in-p
C1b): `export/playwright.config.ts` copies `service-worker/site-sw.js` to `public/sw.js` when the
config loads, and in a worktree that path is a link to the primary's generated hub `sw.js`. A
site-mode export e2e in a worktree therefore overwrites the primary's hub service worker (10,027 →
- 10,043 B). Replace `export/public/sw.js` with a copy before any export e2e in a worktree; the
- proper fix (unlink before copy in the config) is a release-10 low. `build-hub` regenerates the
+ 10,043 B). ~~Replace `export/public/sw.js` with a copy before any export e2e in a worktree; the
+ proper fix (unlink before copy in the config) is a release-10 low.~~ `build-hub` regenerates the
file, so a deploy is never wrong, only a worktree run in between.
+ **SUPERSEDED 2026-09-26 (release 10 L1, on `main` since `5c0a6ef9`): do NOT copy `sw.js` any
+ more.** `export/playwright.config.ts:51-55` `fs.rmSync`s `public/sw.js` before its
+ `copyFileSync` (`rmSync` reads the path with lstat, so it removes the link, never its target).
+ Proved on the integrated tree: the export suite ran with the worktree's `sw.js` a LINK (to a
+ sentinel in the job's scratch) and left the target byte-identical (`release-10.md`, "Integration
+ (S4 + L1 + L2), as merged"). The per-path seed of the `export/public` links stays.
- **The homepage build refuses symlinks that point outside the project**, so a worktree's
`homepage/public` data (~60 MB, `homepage-summary.json` + stats pages) must be COPIED from the
primary, not linked.
@@ -6575,10 +6584,19 @@ Next is 16.2.3 (`export/node_modules/next/package.json`). The plan and every sli
build:hub` (`export/e2e-2origin/globalSetup.ts:142`), whose `compose:hub` `writeFile`s
`hub-sites.json`, `corpus.json`, `llms.txt`, `robots.txt`, `_headers` and `sw.js` into
`paths.exportPublicDir` (`common/bin/compose-hub.ts:79-137`). `writeFile` follows a symlink, so a
- worktree with no corpus would overwrite the primary's live hub pool with an empty one. Swap those
- links for plain copies before the run and relink after (every brand slice did; the primary's files
+ worktree with no corpus would overwrite the primary's live hub pool with an empty one. ~~Swap those
+ links for plain copies before the run and relink after~~ (every brand slice did; the primary's files
kept their size and mtime). `rm` of the no-index `hub-summary.json` (`:59-62`) removes the link,
not the target.
+ **SUPERSEDED 2026-09-26 (release 10 L1, on `main` since `5c0a6ef9`): do NOT swap the compose
+ outputs for copies any more.** `common/bin/_publicFile.ts` — `writePublicFile` / `copyPublicFile`
+ `rm` the path first (`:19-30`), `ownDir` replaces a linked directory with an empty real one
+ (`:32-39`) — and every public write of `compose-hub.ts` (`:70`, `:106-136`; `sw.js` was already
+ `rm`-first at `:142`) and `compose-site.ts` (`:78-189`, `:611`, `:743-766`, `:789`, `:820`, `:912`)
+ goes through them. So a worktree compose replaces each link with its own file and the primary's
+ target is untouched. Proved on the integrated tree: `e2e:2origin` with `TWO_ORIGIN_REBUILD=1` and
+ the links left in place (`release-10.md`, "Integration (S4 + L1 + L2), as merged"; L1's own runs
+ before it). A worktree run leaves plain files where it wrote; the next per-path seed relinks them.
- **`pnpm wt` port blocks are `git worktree list` order**, which is the admin dirs under
`.git/worktrees/` sorted by name (`scripts/worktree.mjs:101-109`). Adding `brand-found-line` put it
at #1 ahead of `diet-series`, which moved to #2 (reconfirms the release 9 fact above); removing
@@ -6596,3 +6614,126 @@ Next is 16.2.3 (`export/node_modules/next/package.json`). The plan and every sli
families are gone; the hub opens on the dark base in Signal (`export/app/layout.tsx:36-44`,
`defaultBase: "dark", accent: DEFAULT_ACCENT`), server-rendered, and `--chart-3` is fixed per base
(a violet), no longer the archilyzer block's green. Historical, left in place.
+
+## Release 10 — brand S4, lows L1 + L2 (verified 2026-09-26, `main` @ `5c0a6ef9`)
+
+Merged S4 `dcb04f61` → L2 `41ddc382` → L1 `5c0a6ef9`, then gated together on `brand/found-line`
+(`release-10.md`, "Integration (S4 + L1 + L2), as merged"). Records: `brand-and-themes.md` "Slice
+S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below was re-read on
+`5c0a6ef9`.
+
+### S4 — Archilyzer Media
+
+- **`renderSvgPng(svg, {width, height})` renders ANY SVG to a PNG** (`common/lib/brandIcons.ts:31-42`:
+ satori lays out one `<img>` of the SVG as a data URI, resvg rasterises it). `renderIconPng`
+ (`:44-50`) is now a call to it, and its bytes did not change (the nine icon PNGs checked, S4).
+ It runs under tsx, so a CLI renders brand PNGs with no `rsvg-convert` (`common/bin/brand-media.ts`).
+- **The Archilyzer Media lockup's letters are outlines, not text** (`mediaLockupSvg`,
+ `common/lib/brandMedia.ts:232`), so drawing it needs no font anywhere (rsvg, satori, ImageMagick).
+ The outlines are the generated `common/lib/brandMediaGlyphs.ts` (do-not-edit header;
+ `common/bin/gen-media-glyphs.py`, a fontTools instancer at wdth 118, reading the Archivo umtool
+ vendors). `MEDIA_GLYPH_SOURCE` (`:28-33`) records the font's sha256 and **the fontTools that made
+ it, `4.65.0`**. The parity test (`brandMedia.test.ts:147-165`) diffs the file against a fresh
+ `--stdout` run, and SKIPS, saying why, without python3 + fontTools or under another fontTools
+ version (another version may round the instancer differently; that is not a stale file). It ran,
+ not skipped, on this machine.
+- **The spec's 5.334 em wordmark width is unkerned.** Archivo's GPOS kerns `rc` −14 and `ze` −20
+ units, which gives 5.300 em. `mediaLockupGeometry` (`brandMedia.ts:125`) keeps the spec's width
+ and spreads the difference as one adjustment per gap (the canvas's `textLength` +
+ `lengthAdjust="spacing"`); MEDIA at `MEDIA_RATIO` 1.035 (`:72`) then gets 0.3002 em tracking.
+- **`FONTCONFIG_FILE` is set only on the brand preset's own `magick` / `rsvg-convert` children**,
+ never process-wide: `childOpts(render, opts)` (`umtool/report-to-video/brand.mjs:161-164`) returns
+ `opts` itself when the manifest has no `render.brand`, else the same with
+ `FONTCONFIG_FILE = fonts/fonts.conf` (`:44`). Every such call site passes through it
+ (`brand-cards.mjs:51,63,174,211,293`; `render-cards.mjs:241,248,308,318`). ffmpeg's `drawtext`
+ takes the vendored face as a `fontfile=` path (`MONO_FONT_FILE`, `:45`), which is why the one
+ static Plex Mono is vendored. The fonts (`umtool/report-to-video/fonts/`, OFL, unmodified: Plex
+ carries a Reserved Font Name) are NOT installed system-wide.
+- **`render.brand` is opt-in, and no brand means the same object back.** `resolveBrandRender`
+ (`brand.mjs:97`), `brandManifest` (`:122`), `childOpts` and `brandFaces` each return their input
+ itself when there is no `render.brand`. `brandManifest` is called at the END of `selectVariant`
+ (`build-video.mjs:155`), the one door the build, `verify-build.mjs:30`, `compose-chrome.mjs:539`
+ and umtool's export go through, and by `render-cards.mjs:1532`. **Under a brand it appends an end
+ card** (`{type:"card", id:"end", style:"end", hideRail:true, chapter:"End"}`, 20 s by default,
+ `END_CARD_DEFAULT_SECONDS`, `:48`), unless the timeline has its own `style:"end"` card or
+ `render.endCard` is `false` / `null` (`endCardConfig`, `:83`; `null` is off so resolving twice keeps
+ it off). A timeline entry already named `end` is an error. umtool's project page lists the raw
+ timeline, so the appended card is built and chaptered but not listed there.
+- **Byte identity of a report-to-video render: ImageMagick's PNG date chunks are the only noise.**
+ ImageMagick 7 stamps the wall clock into three `tEXt` chunks (`date:create`, `date:modify`,
+ `date:timestamp`) of every PNG it writes and ignores `SOURCE_DATE_EPOCH`, so the UNCHANGED code
+ run twice differs in exactly those chunks (S4's control: 334 files, 201 raw-identical, 133
+ date-chunk-only, 0 different). Compare PNGs with those chunks and any `tIME` dropped and every
+ other byte (IHDR, IDAT) kept; x264 mp4s here are raw-identical run to run. The method is S4's
+ `$T/s4-idiff.mjs` (job scratch, not in the repo); rewrite it from this paragraph if needed.
+- **umtool's new-project form offers the brand from data**: `BRAND_CHOICES` in
+ `umtool/report-to-video/brand-ids.mjs` (no imports: the registry is read by client components),
+ declared on the report-video kind (`umtool/lib/projects/kinds.mjs:83`), rendered by
+ `components/NewProjectMenu.tsx:155-170`; `umtool new --brand archilyzer-media`. The server page
+ passes the registry to the client form, so the served `/` HTML carries the choice's label — a
+ curl check tells whether a running umtool has the build.
+
+### L1 — hub lows
+
+- **A member's subs manifest: 404 = empty, one retry, never fails the archive**
+ (`common/components/SearchDataContext.tsx:343-378`, the rule at `:415-425`). A 404
+ (`ArchiveHttpError` status 404) resolves at once to an empty manifest, one request. Any other error
+ (network, 5xx, or a 404 served WITHOUT CORS — `serve` on a self-hosted archive does that, and the
+ browser reports it as a network error) is retried once (`retry: 1`) and then counts as SETTLED:
+ the archive is `ready`, its videos are searched, only its live chat is missing, and nothing says
+ so (a new low). Official members send CORS on their 404s (checked live on
+ `https://jeralyzer.pages.dev/subs/<missing>.json`).
+- **An empty scope is never ready unless the provider is `progressive`**
+ (`SearchDataContext.tsx:456-466`: `allSettled = (progressive || scoped.length > 0) && …`).
+ `MultiSiteDataProvider` has two users: the hub's front page (`HubHome.tsx:40`, `progressive`:
+ settles on an empty scope, results read "No videos match", the chips say why) and `/ask`
+ (`AskHub.tsx:44`, not progressive: with every chip off the composer stays disabled and shows
+ `NO_ARCHIVES_IN_SCOPE`, `export/app/ask/hubScopeCopy.ts:7`, `data-testid="ask-blocked"`,
+ `role="status"`; the [bracketed] words render as a link to `/`). The same rule closes the instant
+ before the hub's list arrives. A hub with zero archives therefore reads "Loading transcripts…"
+ forever on `/ask` (a new low). Both surfaces take their list from `useFederatedSites()`
+ (`export/app/components/hub/useHubSites.ts:49`), so an archive off on the front page is not fetched
+ on `/ask`.
+- **The official instances' order is the homepage's key and tiebreak.** The homepage sorts its
+ public sites by `transcribed.total` descending, then `siteTitle.localeCompare`
+ (`common/lib/homepageSummary.ts:437-441`); `hub-summary.json` keeps that order, and
+ `officialInstances(members, summary)` (`common/lib/hubSummary.ts:190-214`) sorts the hub's members
+ by their index in `summary.sites` (tiebreak: the `hub-sites.json` order), then appends any member
+ the summary does not name. Colour: the site's accent, else the summary's, else
+ `seriesColor(index in the summary)` — the homepage card's colour; an unnamed member takes
+ `seriesColor(listed + k)`, so it never shares a named one's. `useHubSites` lists the official
+ instances only once `/hub-summary.json` has SETTLED (found, missing or unreadable;
+ `useHubSummary.ts:45`, `networkMode: "always"`), so they never reorder a moment later. The order
+ is applied in the browser: `hub-sites.json` on disk is unchanged.
+
+### L2 — runner lows
+
+- **YouTube's soft block is `isSoftBlock`, `/isn['’]?t available,? try again later/i`**
+ (`common/lib/availability.ts:179-181`), checked FIRST by `parseUnavailableFromStderr` (→ `error`,
+ `:189`) and `classifyDownloadFailure` (→ `rate_limit`, `:271`, even over a per-video class the
+ caller already holds). Its consumers are the existing `rate_limit` paths: the manual download batch
+ (cooldown + abort), the auto runner (`common/jobs/unitOutcome.ts:63-69`: platform backoff, the
+ video deferred), `fetchWindowManaged.ts:246-250`, the metadata scan (`metadataScan.ts:492-500`,
+ block kind `"soft-block"`, which changes only the log wording; the one cookie retry is kept), a
+ metadata prefetch (`downloadOneManaged.ts:722-745`: a `rate_limit` prefetch ends the video with
+ no attempt 1), and the availability check (`checkAvailability.ts:~300`: the first `rate_limit`
+ probe sets `blocked`, the rest are skipped, one `recordDownloadBackoff`, `:337`). "Try again
+ later" is what marks it: a bare "Video unavailable", "…removed by the uploader", a terminated
+ account, a ToS removal, Rumble's `HTTP Error 410: Gone` and "This content isn't available." with
+ no retry advice stay `deleted` / per_video.
+- **`verifyBeforeClean`: a tier C suspect the check did not probe is `unverified`**
+ (`common/controller/verifyBeforeClean.ts:186-211`; `probedIds` from `checkAvailability.ts:92`).
+ Two ways to be unprobed: the check was blocked before it, or it has no `webpage_url`. It is never
+ judged on its old `availability.json`, which could say `public` and clear an irreversible audio
+ delete. It is skipped with no marker and retried next sweep.
+- **The boot pass waits at most `STORAGE_PASS_WAIT_MS` = 60 s for the storage pass**
+ (`common/jobs/bootQueuedJobs.ts:128`; `waitForStoragePass` `:130-169` races a DERIVED promise, so
+ the storage pass is never cancelled and a throw counts as finished; `settleAfterStoragePass`
+ `:173`, called from `editor/instrumentation.ts:138`). Timeout line: `[boot] storage pass still
+ running after 60 s; settling queued jobs without it (…)`, then `[boot] storage pass finished N s
+ after …` when it ends.
+- **A hung mount still holds the re-queues behind it.** After the timeout, a job re-queued for a
+ channel on an UNMOUNTED drive is refused at once (the media guard's `stat` gets ENOENT). On a HUNG
+ mount the guard's own `stat` (`common/lib/channelMedia.ts`) hangs on the same syscall, and since
+ re-queues run one at a time, every re-queue after it stays `queued` until the mount answers.
+ Cancels all run first. Left, not fixed (`bootQueuedJobs.ts:113-125`).