Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 94bf53d32b09e7a69d7686681a3d151b88fdf710
parent 948de03474bd936018a0669a57bf451a09c730dc
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 09:10:09 -0400

plans: release 18 — slice S2 (deploy hardening) as shipped; the editor changelog

The record: the wrangler pin, the branch always named, the deploy stage and
its live check, tombstones and no-store, the fake wrangler; open questions 1
(no repo rule sets the hub's s-maxage) and 2 (main is every project's
production branch, from the deploy records); the gates; what is left; the
envVars rows to reconcile when S5 merges.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 2++
Mplans/release-18.md | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 196 insertions(+), 0 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- **Deploys are pinned and checked live.** wrangler is an exact dependency of the workspace (4.147.0), so a deploy runs the version installed with the code instead of whatever `pnpm dlx` fetched that day, and every deploy names its branch: production is `--branch main`, never taken from the checkout it ran in (where a "production" deploy from a feature branch used to land as a preview). The publish stages' deploy (release 18) refuses before wrangler runs when there is no Cloudflare credential at all — "set CLOUDFLARE_API_TOKEN in .env" — and says "REFUSED by Cloudflare — the API token was not accepted" when Cloudflare rejects one; it refuses a production deploy of a build made from a branch other than `main`. After each deploy it reads `corpus.json` at the site's address twice, as a visitor would and cache-busted, and records the verdict: ok, stale-edge (the deployment is right, Cloudflare's edge still serves an older copy), mismatch, or unreachable. A verdict short of ok is a warning in the log; the deploy itself succeeded. What each target last shipped, where, and how it read is kept in `deployed.json` beside its build. +- **Withdrawn X posts ship tombstones.** While X posts are private, a public site's build no longer just leaves an X channel's posts out: at every path they were served from it ships an empty stand-in — the channel's posts manifest with no pages, and an empty page for each page the channel has — served uncached. The hub, which carries no posts, ships the same for every X channel, with an empty posts manifest. Leaving a path out of a deploy does not take it off Cloudflare's edge, which kept serving a withdrawn copy for up to a week; a changed object at the same path replaces it. The hub's deploy reads each of those paths back. - **A cited moment at the very end of a recording prepares.** Prepare evidence media cuts a clip whose padding runs past the recording's end at the end (the recording's duration from its metadata), where it found no media for the padded span; a span that starts past the end is still refused. report-to-video keeps its strict rule. - **Exporting a changed report records a new revision of it.** `reports export` (and **Export reports** on a site's Reports tab, and the end of a prepare) commits a revision to the report's own git history, `sites/<site>/reports/<id>/history-git/`, whenever its `report.json` changed since the last one: the `report.json`, its Markdown export and the checksums of every export file, with a message of `Revision N` and a summary of the change. A re-export of an unchanged report records nothing. The commits carry the site's name and a `noreply@<site>.invalid` address with dates in UTC, never your git name, email or time zone. The Reports tab shows each report's revision, its commit and the last change under **Exports**, and the site's next build publishes the history. Add `history-git/` to the corpus repository's `.gitignore`. - **archive.org files come over BitTorrent when possible, else straight from archive.org — never through yt-dlp.** The chosen file of an archive.org import is fetched from the item's own torrent (`<identifier>_archive.torrent`, which lists archive.org as a web seed, so other peers take load off archive.org) with aria2c, only that file of the item, and seeded afterwards for 10 minutes or to a ratio of 1, whichever comes first; the log shows "torrent: <file> (n of m pieces, peers p, web seed yes)" and "seeding 10 min…". With no aria2c, a torrent that does not carry the file, or no progress for 5 minutes, it is downloaded directly from `archive.org/download/…` instead (resumable, backing off on 429/503), and the log says "fell back to direct download: <reason>". Every file is checked against archive.org's sha1/md5: a mismatch is downloaded once more directly, a second one fails the record. The record is written from the item's metadata: `metadata.info.json` with the file's page, the canonical id, the duration ffprobe measures and archive.org's playable copies of the file, the `archiveorg.json` provenance (a mirror's original title, date and uploader), and `audio.<fmt>` — an audio file already in the channel's format is used as is, anything else goes through the app's audio extraction, a video kept in the saved-video store when the channel keeps sources. An .avi/.mpeg/.flac/.wav original is fetched as archive.org's mp4 or mp3 of it. aria2c runs in its own process group: cancelling the job stops it and everything it started, and it stops itself if the editor exits. New settings block `archiveOrg` (`torrent`, `seedMinutes`, `seedRatio`, `stallMinutes`, `maxPeers`, `maxDownloadKiBps`, `maxUploadKiBps`), `ARIA2C_BIN`, an aria2c row in `archilyzer doctor`, and `aria2` in the runtime Docker images. diff --git a/plans/release-18.md b/plans/release-18.md @@ -353,6 +353,200 @@ Probe = { status|null; generatedAt?; cfCacheStatus?; age?; cacheControl?; error? (Each slice adds a "### Slice <X>, as shipped" section here, before "## Rollout".) +### Slice S2, as shipped — deploy hardening (2026-10-06) + +Branch `r18/deploy-hardening` off `ce66f2d3`, worktree `~/Projects/r18-deploy-hardening` (editor 6901, test 6911, +export 6910), one Opus implementer, beside S1 (stage core) and S5's image half. Scratch files `s2-*` in the job's +`tmp`. The plan is above ("Model", "Deploy hardening", "Docker (a)"). + +**What it does.** +- **wrangler is pinned:** `4.147.0` (released 2026-10-02, the current 4.x), an EXACT devDependency of `common` + (`common/package.json`, the lockfile). `pnpm-workspace.yaml` `allowBuilds` gains `workerd: false`: pnpm 11 refuses an + install with an unanswered build script, and a Pages deploy never runs workerd. The lockfile diff is large because + wrangler brings `supports-color@10`, and pnpm re-keys the `debug` peers of `next`, `eslint`, `serve` and + `eslint-config-next` with it — the same versions under new peer suffixes (release 6 saw the same churn). +- **`common/lib/pagesDeploy.ts`** (node-free, as before): `pagesDeployArgs` is now the argv AFTER the binary and + always names the branch — `--branch main` (`PRODUCTION_BRANCH`) for production, `--branch <b>` for a preview; + `wranglerBin(paths, env)` = `WRANGLER_BIN`, else `<repo>/common/node_modules/.bin/wrangler`; `WRANGLER_MAJOR = 4` + (a test holds the pin exact and at that major); `wranglerAuthFailureIn(line)` reads wrangler 4's refusals + (`Authentication error [code: 10000]`, `Invalid access token [code: 9109]`, `Unable to authenticate request [code: + 10001]`, the non-interactive "set a CLOUDFLARE_API_TOKEN" sentence, "You are not authenticated", a failed OAuth + refresh) and `CLOUDFLARE_AUTH_REFUSED` is the sentence it becomes; `cloudflareCredentialProblem(env, + oauthLoginPresent)` + `wranglerOAuthConfigFiles(home, env)` (`~/.wrangler`, `$XDG_CONFIG_HOME/.wrangler`, macOS + Preferences) are the preflight — `CLOUDFLARE_API_TOKEN`, or a `wrangler login` on disk (a host's), else "[deploy] + REFUSED — no Cloudflare credentials: set CLOUDFLARE_API_TOKEN in .env …". A value is never read beyond "set or not". +- **`build.ts`, only the spawns:** `runDeployIntoLog` and `runPagesDeployIntoLog` (hub and homepage) run + `wranglerBin(paths)` with `pagesDeployArgs` instead of `pnpm dlx wrangler`; `homepageDeployArgs` is + `pagesDeployArgs` (its own `--branch main` tail is gone — the argv names the branch once). So the legacy deploy jobs + already deploy production as `--branch main` with the pinned binary; they do NOT get the preflight, the classifier or + the live check — those are the stage's (`runDeployStage`), which S1's `stages.ts` wires. +- **`common/publish/deployStage.ts` — `runDeployStage(ctx, req)`**, one body for `deploy-site`, `deploy-hub`, + `deploy-homepage` over `<exportBuildsDir>/<target>/out` (the homepage: `homepage/out`) and its `built.json`, in this + order: the request (`previewBranchProblem`; local + preview refused; the hub has no local target); the target's own + refusals (`siteDeployProblem`, no Pages project, `hubProjectProblem`); the build — no `built.json` is exit 3 "no build + of X in <dir> — archilyzer publish build X", a `builtAfter` newer than `built.builtAt` is exit 3, the slot already + holding this `stampId` is a `noop` unless `force`; production refused when `built.branch` is set and is not `main`; + the bundle guards (`builtBundleProblem` — the stricter twin of `builtSiteProblem`, naming the directory — + `builtAudienceProblem`, `builtScopeProblem`; the hub's `builtHubProblem`; the homepage's `builtHomepageProblem` + + `publishedSourceProblem`); `--to local` copies into `ARCHILYZER_SITE_OUT` (contents replaced, never the directory; + the homepage into `ARCHILYZER_HOMEPAGE_OUT`, which the container sets; either unset is refused in its own sentence) + and records `local` — no + credential, no R2, no wrangler, no live check, a private site still refused; the credential preflight; the R2 upload + from `dockerSiteStagingDir` (`<id>/.r2-staging`), a preview logging `PREVIEW_SHARES_ARCHIVES_NOTICE`; the pinned + wrangler with the classifier on its stream; the live check; and LAST the record, written atomically (temp + + rename) into `deployed.json` (`production` / `local` / `previews[branch]`, other slots kept). A refusal or failure + throws `DeployStageError` (`exitCode` 1, 3 or 130) whose message is the line the log already ends on, and + `deployed.json` is untouched. The stamp types (`BuiltStamp`, `DeployRecord`, `DeployedFile`) are declared there with + the plan's field names until S1's `stamps.ts` lands. +- **`common/publish/liveCheck.ts` — `runLiveCheck`** (injected `fetch`, `sleep`, `now`, `env`): `<url>/corpus.json` + plain and `?cb=<builtStampId>` (`&try=N` on a retry), 3 tries 10 s apart until ok, records `cf-cache-status`, `age`, + `cache-control` and `generatedAt`, compares with `built.corpusGeneratedAt` (else the bundle's own `corpus.json`). + Verdicts (`liveCheckVerdict`, pure): `ok`; `stale-edge` (busted serves this build, plain does not); `mismatch` + (busted serves another); `unreachable` (neither answers 2xx); `skipped` (`E2E_LIVE_CHECK=skip`, nothing fetched). + The URL is the preview alias for a preview, the site's `siteUrl` (the hub's `homepage.json` `siteUrl`, the homepage's + `PROJECT_URL`) for production, else the deployment URL wrangler printed. The homepage has no `corpus.json`: it reads + `/` and asks only for a 2xx. The hub also probes `posts/manifest.json` and each withdrawn channel's + `manifest.json` + `page-0000.json` plain and busted (`LiveCheck.tombstones`); a corpus that reads ok with a + tombstone that does not is `stale-edge` when the busted read is the tombstone, else `mismatch`. + `liveCheckLines` is the log: `[live] ok — …`, or `[live] WARNING <verdict> — …` with every probe's status, + `generatedAt`, `cf-cache-status`, `age`, `cache-control`. A warning never fails the stage. +- **Tombstones, `common/publish/tombstones.ts`:** `writePostsTombstones` replaces each slug's served `posts/<slug>/` + with its `ChannelPostsManifest` at `pageCount: 0` (`slugToPage: {}`, the shared tree's `maxPageBytes`) and `[]` for + every page below the SHARED tree's `pageCount` — through `bin/_publicFile.ts`, never through a worktree link; + `withdrawnXChannels` (every X channel with a shared posts tree, only while `social.x.visibility` is private); + `tombstonePaths`, `tombstoneNoStoreForSite`, `tombstoneNoStoreForHub`. + - **compose-site** writes them for each withheld member (`site.channels` less `publishedMemberSlugs`) after the posts + reconcile and the posts manifest; with no posts manifest from the index it writes an empty one, replacing whatever + an earlier compose left there. `corpus.json` advertises no posts for them (it reads the posts manifest). + - **compose-hub** removes `SITE_ONLY_PUBLIC_ENTRIES` as before, then writes the tombstones and an empty + `posts/manifest.json`; `main` takes `settings` (default `getSettings()`). With X public, or no X channel, the hub + ships no `posts/` at all, as before. For the X channel the live hub served, that is, path for path, + `posts/manifest.json` (empty), `posts/thequartering-X/manifest.json` (`pageCount` 0), + `posts/thequartering-X/page-0000.json` (`[]`), and one `[]` page per further page its shared tree holds by then — + `compose-hub.test.ts` pins the three named paths with that slug. + - **`builtHubProblem`** (`lib/builtExport.ts`, outside this slice's list — one clause, needed or the hub could not + deploy its tombstones) accepts a `posts/` that `isTombstonePostsTree` (new, same file) says holds tombstones only: + an empty posts manifest, and per channel a `pageCount: 0` manifest with no `slugToPage` and only `[]` pages; one + real post, a listed channel or a stray file and it is a site's data again, refused as before. +- **`_headers`, `renderHeadersFile(generator, paths, { noStore })`** (`common/lib/archive/headers.ts`): after the + CORS lines, `# Withdrawn content (tombstones): never stored at the edge.` and one rule per path with `Cache-Control: + no-store`, plus `Access-Control-Allow-Origin: *` ONLY where no CORS rule above covers the path — every matching rule + applies and a repeated header is APPENDED (wrangler's `attachHeaders`, FACTS), so a second CORS line would serve + `*, *`. A site: `/posts/manifest.json` and `/posts/<slug>/*` per tombstone (its `/posts/*` CORS rule covers them); + the hub: `/posts/*` with both headers. No tombstones, no block: every other `_headers` is byte-identical. The + committed fixture is `headers.test.ts`'s snapshot strings (two new, plus a test that no rendered file sets one header + twice for a path). +- **The e2e fake, `editor/e2e/fixtures/bin/fake-wrangler.mjs`** (the siblings' style, `_watchdog.mjs`): `pages deploy + <outDir> --project-name <p> --branch <b>` appends `{argv, cwd, project, branch, outDir, files}` to + `.fake-wrangler.json` BESIDE the bundle and prints wrangler 4's success lines ending on "✨ Deployment complete! Take + a peek over at https://<branch>.<project>.pages.dev"; `E2E_FAKE_WRANGLER_AUTH_FAIL=1` prints wrangler's + `Authentication error [code: 10000]` block and exits 1 with no sidecar; `--version` answers `4.147.0`. + `editor/playwright.config.ts` documents both knobs and gives the test server `WRANGLER_BIN=<the fake>` and + `E2E_LIVE_CHECK=skip` (in `E2E_SERVER_ENV`). **No spec in the list spawned a deploy before this slice, and none does + now** (`deploy-page`, `site-publish-preview`, `sites-homepage`, `build`, `site-scope` all stop before a job or hold it + with `/api/test/stuck-job`); `site-publish-preview.spec.ts`'s header comment says the fake exists now. `publish.spec` + is S4's. +- **`envVars.ts` + ENVIRONMENT.md** (S5's file; `envVars.test.ts` fails on an undeclared read): this slice's rows are + `WRANGLER_BIN` (runtime), `E2E_LIVE_CHECK` and `E2E_FAKE_WRANGLER_AUTH_FAIL` (test). `CLOUDFLARE_API_TOKEN`, + `XDG_CONFIG_HOME` and `ARCHILYZER_HOMEPAGE_OUT` are S5's rows; this branch carries its own copies, marked with a + comment and placed clear of S5's hunks, so its tests pass before S5 lands. **On merging S5: keep S5's three rows, + delete the marked copies** (`envVars.test.ts` "names are unique" fails until then), add `common/lib/pagesDeploy.ts` + / `common/publish/deployStage.ts` to their `readBy`, and regenerate ENVIRONMENT.md (`archilyzer docs env`). + +**Open question 1 — where does the hub's `s-maxage=604800` come from?** Not from this repository. Every rule the repo +renders or ships was read: `renderHeadersFile` (CORS only, until this slice's `no-store`), `homepage/public/_headers` +(`public, max-age=300, must-revalidate` on `/downloads/*` and `/source/*`, the homepage only), the R2 upload +(`public, max-age=3600`, archives only), `r2-proxy` (the same, the Worker), and `export/serve.json` / +`homepage/serve.json` (`public, max-age=3600` — local `serve`, never deployed). `git grep -i 's-maxage\|604800'` +finds only `duration.ts`'s seconds-per-week and this plan. So the 7-day edge TTL is Cloudflare's side of a +`*.pages.dev` hostname — an account-level cache setting or Pages' own edge caching — which the repo cannot read and a +`pages.dev` project has no zone to purge. Whether `Cache-Control: no-store` from `_headers` wins over it is exactly +what the hub's next deploy shows: its live check records `cache-control`, `cf-cache-status` and `age` for +`corpus.json` and each tombstone, plain and busted, in `_hub/deployed.json`. If the plain reads still `HIT` the old +shard after the deploy, the verdict is `stale-edge` and the objects expire ~2026-10-08 21:00 as before. + +**Open question 2 — is `main` the production branch of every Pages project?** Yes, as far as the records can say +without asking Cloudflare (not called). Every production deploy of every project so far ran with no `--branch` from +the primary checkout on `main`, so wrangler sent `main`, and each one changed what the PRODUCTION URL serves — a +deploy to a branch that is not the production branch is a preview and leaves production alone: release 17's XP +rollout (2026-10-04, jeralyzer, rekietalyzer, hasanalyzer, anilyzer, bonnellyzer, jasolyzer and the hub, each read +back at its `*.pages.dev`), the homepage (`archilyzer`, 2026-09-26: wrangler printed "production branch `main`"; its +deploy has always passed `--branch main`), and the hub's first deploys (release 7). PUBLISH.md's "create the project +first" line is `wrangler pages project create <name> --production-branch main`. So `--branch main` changes nothing for +these eight projects. A project whose production branch is NOT `main` would now take a "production" deploy as a +preview: production unchanged, and the stage's live check at the site's public URL reads `mismatch` — the warning +names it. + +**Found on the way, fixed here.** +- compose-site left a stale `public/posts/manifest.json` from an earlier compose when the index wrote none for the + site; with tombstones to write it is now replaced by an empty one (`compose-site.postsVisibility.test.ts` case). A + site with neither tombstones nor an index manifest keeps the old behaviour. + +**Found and left.** +- **A site's live check does not probe its tombstones** — only the hub's does (the plan's scope). A site's tombstones + are written and served no-store; its `deployed.json` says nothing about them. +- **The site `generatedAt` is the summaries manifest's**, so a rebuild with no data change carries the same + `generatedAt` as the deploy before it: the live check cannot tell those two deployments apart. It tells a stale or + wrong deployment from the build's data, which is what it is for. +- **The legacy deploy paths** (`deploySite`, `deployHub`, `deployHomepage`, the docker Phase C) now run the pinned + binary with `--branch main`, but keep their old refusals: no preflight, no classifier, no live check, no record, + until S4 makes the editor's actions enqueue the stages. +- **A cited site withholding an X channel** composes no corpus at all, so it writes no tombstones (nothing of the + corpus was ever served from it). +- **`refuse` logs the sentence and throws it**: a runner that prints `err.message` after a failed stage will print it + twice. The wiring step (S1's `stages.ts`) should print only on a non-`DeployStageError`. + +**Commits** + +| Commit | What | +|---|---| +| `5f3dd404` | `common:` wrangler 4.147.0 pinned (devDependency, lockfile, `workerd: false`); `pagesDeployArgs` always names the branch; `wranglerBin` replaces `pnpm dlx` in `build.ts`; `WRANGLER_MAJOR`, the auth classifier and the credential preflight in `pagesDeploy.ts` (+ tests) | +| `82929307` | `common:` tombstones (`publish/tombstones.ts` + test), compose-site and compose-hub write them, `renderHeadersFile` `noStore`, `isTombstonePostsTree` + `builtHubProblem`; the postsVisibility, compose-hub, headers and builtExport tests | +| `cf920f8c` | `common:` `publish/deployStage.ts` + `publish/liveCheck.ts` (+ tests); `editor(e2e):` `fake-wrangler.mjs`, the playwright env; `envVars.ts` + ENVIRONMENT.md | +| `211b064f` | `common:` the homepage's local deploy is `ARCHILYZER_HOMEPAGE_OUT` (refused without it); the preflight is a token or a `wrangler login`; compose-site replaces a stale posts manifest beside tombstones; the `envVars.ts` rows split into this slice's and S5's (+ tests) | +| this commit | `plans:` this section; the editor changelog | + +#### Gates (logs `$T/s2-*.log`) + +- **tsc** (all workspaces) clean before every commit. +- **common:** **3,193/3,193** at `cf920f8c`, 134 s (the base's 3,161 + 32: `pagesDeploy.test.ts` +5, `headers.test.ts` + +3, `tombstones.test.ts` 4, `liveCheck.test.ts` 9, `deployStage.test.ts` 10, `compose-hub.test.ts` +1; the + postsVisibility and builtExport cases grew in place). At `211b064f` (+1, the homepage's local deploy): **3,193 of + 3,194**, 307 s at a load average of 33–35 from other sessions; the one failure is `controller/fetchPosts.test.ts`'s + "a drain mid-page waits for the page's cursor" (a 200 ms `setTimeout` race against the fake gallery-dl), which + failed again run alone at that load and passed at `cf920f8c`; this slice touches nothing under `controller/` or + `social/`. `deployStage.test.ts`, `pagesDeploy.test.ts`, `envVars.test.ts` and the postsVisibility test pass at + `211b064f`. `deployStage.test.ts` spawns the real fake wrangler. + **Editor unit** 142/142. **test:scripts** 596 passed, 0 failed, 3 skipped (599). **mcp** 289/289. **Export unit** + 116/116. **Homepage unit** 23/23. +- **Builds** at `cf920f8c`: `pnpm --filter editor exec next build` exit 0, 98 s; `pnpm --filter homepage run + build:nodata` exit 0, 34 s; umtool's capped build (corpus linked `-T`, `MemoryMax=5G`, the link removed) exit 0, 33 s; + `pnpm --filter export exec next build` exit 0, 99 s — over `plans/tools/compose-fixture-one-youtube-channel/public` + linked into `export/public`, NOT the primary's: the primary's `export/public` held a cited site's compose at the + time, whose `/` a plain export build cannot prerender (ENOENT `summaries/manifest.json`, exit 1, 47 s — the same on + any tree; no export file changed in this slice). +- **Numbers tool:** none. **Privacy gate:** counts only over this branch's added lines and this section — the source + denylist's 4 entries: 0 hits. + + | Run | At | Specs | Result | + |---|---|---|---| + | 1 (editor) | `cf920f8c` | `deploy-page`, `site-publish-preview`, `sites-homepage`, `build`, `site-scope` | 25 passed, **4 failed**, 8.3 min (no queue wait). `site-scope` ×3 (a navigation timeout, a `toHaveURL` timeout, `page.goto: net::ERR_ABORTED … frame was detached`) and `sites-homepage` ×1 (`apiRequestContext.get: read ECONNRESET`): this worktree's files were being edited while it ran (the dev server recompiles). None reaches a deploy | + | 2 (editor) | `cf920f8c` + the compose-site fix of `211b064f`, nothing edited during the run | `site-scope`, `sites-homepage` | **17 passed**, 0 failed, 2.3 min | + | 3 (editor) | `211b064f` | the five specs of run 1 | **29 passed**, 0 failed, 2.4 min (after 3 min in the queue) | + +#### Decisions the operator could overturn + +| What I did | The alternative | +|---|---| +| `pagesDeployArgs` returns the argv after the binary and always carries `--branch` | Keep `wrangler` as its first word for `pnpm dlx` callers (there are none left) | +| `workerd: false` in `allowBuilds` (pnpm 11 refuses an unanswered build script) | `true`: run workerd's install script, which a Pages deploy never needs | +| The preflight accepts `CLOUDFLARE_API_TOKEN` or a `wrangler login` file on disk (a host) | Token only — `.env` is the one supported way in the container | +| No-store rules carry CORS only where no CORS rule covers them | Repeat CORS on every no-store rule (serves `*, *` on a site, which browsers reject) | +| The deploy stage reads `built.json` and refuses (exit 3) without it, the homepage included | Fall back to the legacy `export/out` / `homepage/out` with no stamp | +| A deploy of the same `stampId` to the same slot is a no-op unless `force` | Always deploy (the plan's `needs()` already skips fresh stages; the no-op is the stage's own second word) | +| Only the hub's live check probes tombstones | Probe a site's too (a site's withheld channels are listed in no manifest a reader follows) | +| The homepage's live check reads `/` and asks only for a 2xx (it has no `corpus.json`) | Probe `/source/manifest.json` and compare its commit | + ## Rollout (Steps 1–7 above; "### As it went" is written as the rollout runs.)