# Release 18 — publishing as queueable stages (one index, serial builds, deploys checked live, stages run in the container) Written 2026-10-04 in plan mode against `main` `0a7a02e9` (releases 13–17 live; today's manual rollout of all six sites + hub done 13:34–14:09); implementation began 2026-10-06 against `main` `b8466d24`. Self-contained: the operator clears context after this. Rules: `plans/tools/implementer-rules.md` — one Opus implementer per slice in its own worktree (`pnpm wt add r18/`), one read-only Opus review per slice, the parent merges `--no-ff` on a clean tree (into `r18/integration`, fast-forwarded to `main` at the end — other sessions share the primary checkout); records state rulings never reasons; counts-only privacy greps before every merge; changelog bullets checked by eye; never run a whole e2e suite while agents work; the homepage build gate is `build:nodata`; the export build gate is `pnpm --filter export exec next build`; never boot a second editor against the real corpus; implementers' commits carry their OWN model's `Co-Authored-By` + the session line. Record file: this file (shape of `plans/release-17.md`). Path corrections against the tree as of 2026-10-06: `pagesDeploy.ts` is `common/lib/pagesDeploy.ts`; the e2e fake binaries live in `editor/e2e/fixtures/bin/` (so the fake wrangler is `editor/e2e/fixtures/bin/fake-wrangler.mjs`); `e2e/helpers.ts` is `editor/e2e/helpers.ts`. ## Context Publishing today is a handful of one-shot jobs: `build site ` reruns the whole data phase (index + stats + templates, 14 min today) for every site unless `--nodata`; from `/sites`, "Build index"/"Build stats" call `buildIndex()`/`buildStats()` IN-PROCESS on the editor's event loop (`editor/app/sites/lib/buildAction.ts:47-76`), which starves the dashboard (`scripts/archilyzer-ops.mjs:379-383`); every site and the hub build into ONE `export/out` and ONE `export/public` (`common/publish/build.ts:39-41`; `:347` "another job can rewrite export/out"); jobs are flat (no dependency, group or run id; `common/jobs/registry.ts:88-138`), the only chain is `build-deploy` = one function doing both (`buildAction.ts:120-195`); wrangler is fetched UNPINNED by `pnpm dlx` at deploy time (`build.ts:382`); nothing checks a deploy live; a withdrawn path stays on Cloudflare's edge until its TTL (the hub still serves 313 private X posts from a 7-day cache after today's deploy); the runtime image cannot run the homepage's source mirror (no python/pipx/ git-filter-repo) and "deploy runs on the host" (PUBLISH.md:639-641). Today's rollout proved the shape that works: ONE data phase, then each site `--nodata` (~1 min) + deploy (~1 min), serially. **Measured/verified 2026-10-04:** `buildIndex`/`buildStats` are incremental with internal LMDB fingerprints (`generation`, `siteFp:`, `statsFp:`, page sha1; result `shortCircuited`, `buildIndex.ts:2312`, `buildStats.ts:778`; `INDEX_SCANNED_AT_KEY` `:2289`) but expose NO external stamp; `build templates` is unconditional; `buildAll` basic mode already passes `skipData: i > 0` (`build.ts:838-847`); the Docker fan-out already builds into `exportBuildsDir//out` (`dockerSiteOutDir`, `:45-47`) over a read-only `.export-index`; `runManagedCommand` sets `record.child` so Cancel kills the process, `runManagedFunction` never does (`common/jobs/shutdownCancel.ts:15-18`); the scheduler is one FIFO per `queueKey`, concurrency 1, `queueKey ""` = unserialized (lane runners, `autoRunner.ts:2366-2404`); boot settles ghosts (`bootQueuedJobs.ts` `settleRunningJobMetas`); `docker/entrypoint.sh:289-293` `exec "$@"` runs any command; the image locates the downloader only via `YTDLP_BIN` (`common/lib/paths.ts:269`) and curls the latest release at build time (`Dockerfile:~310-323`); `~/Projects/yt-dlp-patched` is a yt-dlp SOURCE tree (no built binary). ## Rulings (operator, 2026-10-04; do not re-open) - Stages are independent queueable jobs driven by ON-DISK STATE (stamps), like the ingest lanes; ONE index build shared by every site build; builds and deploys queue one at a time. - A `publish` LANE (policy, pause gate, the ingest runner pattern) AND manual per-stage buttons / "Publish now". - The lane may deploy PRODUCTION only for a site that opts in via `site.json` (`publish.auto: "production"`); previews per site likewise; default off; private sites never deploy. - Two runners behind ONE stage contract: **single-container/local** (default everywhere: each stage is a child process of the editor's own process, in or out of a container — the Windows target is "Docker Desktop and nothing else") and **docker fan-out** (Linux hosts, opt-in, host-driven: the existing `Dockerfile.build` per-site containers). **No docker-in-docker** — the editor never gets the socket. Rejected alternative, recorded: a socket mount for memory isolation; answered by a heap cap on the child and serial stages. - The runtime image bakes python3 + pipx + pinned git-filter-repo and a PINNED wrangler, so the homepage (source mirror included) and deploys run from the container; Cloudflare credentials enter via `.env`. - The image allows substituting yt-dlp (the operator's patched build) WITHOUT bundling it: a runtime swap by default. - Withdrawn private content ships TOMBSTONES, never deletions; every deploy is checked live (plain + cache-busted). ## Model **Stage contract — `common/publish/stages.ts`** (pure table + runners; the only module that knows all stages): ```ts type StageKind = "update-index"|"build-site"|"deploy-site"|"build-hub"|"deploy-hub"|"build-homepage"|"deploy-homepage"; type StageRequest = { kind: StageKind; target: string /* "_index" | siteId | "_all" | "_hub" | "_homepage" */; runId: string; preview?: string; to?: "pages"|"local"; runner?: "local"|"docker"; force?: boolean; skipArchives?: boolean; indexAfter?: number; builtAfter?: number }; type Freshness = {state:"fresh"} | {state:"stale"; reason:string} | {state:"blocked"; reason:string}; type Stage = { kind; label; jobKind: `publish-${StageKind}`; queueKey: "publish"; needs(s: PublishStatus, r: StageRequest): Freshness; // pure, over the view argv(r: StageRequest): string[]; // ["stage", kind, target, ...flags] run(ctx: StageContext, r: StageRequest): Promise }; // inside the child, or the CLI type StageOutcome = { status: "ran"|"noop"; stamp: string; summary: string }; ``` - **Every stage job is a `runManagedCommand`** (`streamCommand.ts:198`): `/node_modules/.bin/tsx bin/archilyzer.ts stage [flags]`, cwd `common/`, the editor's env (+ `NODE_OPTIONS=--max-old-space- size=8192` for update-index). Cancel kills the child; the child traps SIGTERM and `runChildIntoLog` kills grandchildren (`next build`, wrangler, docker). Exit codes: 0 ran/no-op, 1 failed, 2 usage, 3 precondition not met, 130 cancelled. - **The publish lock — `common/publish/stageLock.ts`**: `/.publish.lock` `{pid, host, kind, target, since}`, taken `O_EXCL` by the stage child AND the CLI; stale when same host and the pid is dead (`processIsAlive`, `bootQueuedJobs.ts:111`); a live holder makes the taker wait (poll 5 s, one log line, cancellable). This is what keeps the editor and a CLI started with `docker compose exec` from building at once. - **Stamps — `common/publish/stamps.ts`** (atomic temp+rename; malformed = null = stale): ```ts // /stamp.json IndexStamp = { v:1; stampId; generation; scannedAt /*INDEX_SCANNED_AT_KEY*/; builtAt; templatesAt; commit|null; index:{shortCircuited; added; changed; removed; heldChannels[]}; stats:{shortCircuited; notIndexedYet; notIndexable}; sites: Record; hubSig } // //built.json (target = siteId | "_hub" | "_homepage") BuiltStamp = { v:1; stampId; target; kind:"site"|"hub"|"homepage"; indexStampId; inputSig; builtAt; commit|null; branch|null; runner:"local"|"docker"; audience; corpusGeneratedAt|null; files; bytes; archivesStaged; sourceCommit? } // //deployed.json DeployRecord = { builtStampId; builtAt; kind:"production"|"preview"|"local"; branch?; url|null; alias?; at; wrangler?; liveCheck: LiveCheck|null } DeployedFile = { v:1; target; production?; local?; previews: Record } LiveCheck = { at; url; plain: Probe; busted: Probe; expected: string|null; verdict:"ok"|"stale-edge"|"mismatch"|"unreachable"|"skipped"; tombstones?: {path; plain; busted; ok}[] } Probe = { status|null; generatedAt?; cfCacheStatus?; age?; cacheControl?; error? } ``` - **`inputSig`** (per site, computed by update-index): sha1 over the site's `.export-index/sites//` tree, its member channels' shared transcripts/subs/posts/digests trees (ignoring `manifest.json`), its stats dir and chart templates, the bytes of `site.json`, and the `archiveStorage` + `social.x.visibility` settings — using compose's own `dirSignature` (`compose-site.ts:593`) moved unchanged to `common/lib/dirSignature.ts`. Rule: a site is fresh exactly when compose would skip everything. `hubSig` = sha1(stampId, `homepage.json`, each listed site's `siteUrl` + title). - **Bundle layout**: `//out` is THE bundle every deploy ships, whichever runner built it. A build writes `/out.next`, then swaps (`out → out.prev`, `out.next → out`, rm `out.prev`); a leftover `out.next` is deleted first. Archive staging = `dockerSiteStagingDir` for both runners. `export/out` becomes a SYMLINK to the bundle built last (older readers and `serve out` keep working; `next build` removes the link itself, never its target). The hub's bundle is `_hub/out`; the homepage bundle stays `homepage/out` (the source gate withdraws from there, `build.ts:1106`), its stamps in `_homepage/`. - **Decided: compose into the shared `export/public`, then move `out/` per site.** `next build` copies `public/` into `out/` and `distDir` may not leave the project (Next docs `distDir.md`); a per-site public dir would mean replacing `export/public` by a symlink (what `build-site.sh` does in its throwaway container) — breaks the dev server and the worktree links on a host. The serial queue + the lock make the shared dir safe; compose's per-site cache still works (`public/site.json` names the site composed last, `compose-site.ts:743-749`). On the host the move is a same-fs `rename`; in the container `export/out` is an image layer and the builds dir a volume → EXDEV → `fs.cp` + swap (~1 min). ## Stages | job kind | target | fresh when | the child does | writes | |---|---|---|---|---| | `publish-update-index` | `_index` | a stamp exists and nothing below is newer than `stamp.scannedAt` | `buildIndex` → `buildStats` → `build templates` in ONE child; writes the stamp (a rerun is cheap: both report `shortCircuited`) | `stamp.json` | | `publish-build-site` | `` | no `changedChannels` since `built.builtAt`, `built.inputSig === stamp.sites[id].inputSig`, and `builtBundleProblem(/out)` null | `buildSiteSteps({skipData:true})` (`build.ts:79-109`) → `builtBundleProblem(export/out)` → move out + staging → symlink. Blocked "update the index first" with no stamp | `/built.json` | | `publish-build-site` | `_all --runner docker` | per site, as above | `build archives` on the host → `ensureBuildImage` → `runDockerBuildOne` per site (`build.ts:486-600`) → check + stamp per site. No engine → refused "the docker runner needs an engine on this host" | each `/built.json` | | `publish-deploy-site` | ` [--preview b] [--to local]` | `deployed[kind/branch].builtStampId === built.stampId` | today's guards over `/out` (`siteDeployProblem`, `builtSiteProblem`, `builtAudienceProblem`, `builtBundleProblem`); production refused when `built.branch` set and ≠ `main`; credential preflight; R2 upload from `/.r2-staging`; pinned wrangler `--branch main` (production) / `--branch b`; live check. `--to local` copies to `ARCHILYZER_SITE_OUT` (private still refused) | `/deployed.json` | | `publish-build-hub` | `_hub` | `built.inputSig === stamp.hubSig` | rm `public/site.json`, compose:hub (writes tombstones), `next build` `INSTANCE_MODE=hub`, `builtHubProblem`, move | `_hub/built.json` | | `publish-deploy-hub` | `_hub` | as deploy-site | `hubProjectProblem`, wrangler, live check + tombstone probes | `_hub/deployed.json` | | `publish-build-homepage` | `_homepage` | `indexStampId` current and `sourceCommit` = `main` HEAD when a repo is reachable | `buildHomepage()` (`build.ts:1071-1102`) incl. the source mirror | `_homepage/built.json` | | `publish-deploy-homepage` | `[--to local]` | as deploy-site | `publishedSourceProblem`, `homepageDeployArgs` (`--branch main`) | `_homepage/deployed.json` | - **What makes the index stale** (`needs()` of update-index, cheap, no LMDB): (1) any ingest job meta in `.jobs` (download/transcribe/digest/normalize/import/fetch-posts/tag kinds — the drainable kinds of `jobKinds.ts`) that ENDED `done` after `stamp.scannedAt` — read through the registry's archive reader, no new writer anywhere; (2) a config file newer than the stamp: `tags.json`, `search-aliases.json`, `duplicates*.json`, `sites/*/site.json`, `homepage.json`, the charts config; (3) **the settings the index reads, by signature, not the settings file's mtime** (S4 step 1, after S3 found every pause click, priority change and drive auto-pause staling the index): `indexSettingsSig` (`inputSig.ts`) over `socialLinks`, `homepageUrl`, `buildArchives`, `archiveStorage`, `social.x.visibility`, `maxTranscriptPageBytes` and the storage locations' roots, recorded as `settingsSig` in the index stamp and compared by `needs()` ("the settings the index reads changed"; a stamp without one reads as changed, once). Decided: mtimes/job completions decide WHEN to run, the index child decides WHAT changed (its fingerprints stat every sidecar and are the only correct detector; LMDB `generation` cannot see new files). - **A site is marked stale by its channels, before any index runs** (operator, 2026-10-05): per site, `changedChannels` = the member channels (by `site.json` membership, groups expanded) that have an ingest job meta (same drainable kinds as above, which carry `channelSlug`) ended `done` after `built.builtAt`, plus a config change (its `site.json`, `tags.json`, `search-aliases.json`, `duplicates*.json`) newer than the build. Two signals, two chips: `built` shows "stale: N channels changed (slug, slug, …)" from this cheap read, and after update-index runs the stamp's `inputSig` mismatch is the authoritative confirmation ("stale: data changed"). "Build all stale", Publish now and the lane use `changedChannels ∪ inputSig-mismatch` to choose sites; a site with neither is skipped. The same per-channel read gives the hub `changedChannels` across listed sites and the homepage its pool-level flag. - `--force` (manual buttons only) builds/deploys a fresh stage; Publish now and the lane never force. A code change (`built.commit` differs) shows a "code newer" chip and does not make a build stale. ## Lane - **`publish` is NOT added to `LANES`** (nine loops compile a channel tree per lane: `channelPriority.ts:831`, `channelWriters`, `storageWatch`, `operationBatch`, `channelSnapshot`, `autoQueueSchema:224,281`). Add `PIPELINE_LANES = ["publish"]` in `autoQueueTypes.ts`; widen `PauseLane` to `AutoQueueKind | "publish"`; `isGateHeld`/`withGateHeld` (`pauseGates.ts:97-120`) read/write `settings.publish.held`. - **Settings `settings.publish`** (`settingsSchema.ts`, SETTINGS.md regenerated): `{ enabled:false, held:false, checkEveryMinutes:10, refreshEveryMinutes:360, quietHours:{start,end}|null, runner:"local"|"docker" ("local"), previewBranch:"preview", hub:Policy ("off"), homepage:Policy ("off") }`. - **Per-site policy `site.json` `publish: { auto: "off"|"build"|"preview"|"production" }`** (`siteSchema.ts`, SITE.md, the site form): default off; a private site is clamped to "build"; "preview"/"production" require `cloudflareProject`. - **Runner `common/controller/publishRunner.ts`**: `startPublishRunner` copies `startAutoRunner`'s skeleton (`autoRunner.ts:2366-2404`): `runManagedFunction` kind `auto-publish`, queueKey `""`. Wakes every `checkEveryMinutes`; skips a pass when held, in quiet hours, or while any publish job is queued/running. A pass runs when the index is stale and `now − stamp.builtAt ≥ refreshEveryMinutes`, or there is no stamp: `planPublishRun(status, {deploys:"policy"})`, enqueued ONE stage at a time via `enqueueStage({…, background:true})`, awaiting each `done`; the gate is re-checked between stages (a hold stops dispatching, never kills a stage); drain finishes the current stage. `startAutoRunnersIfEnabled` also starts it; an idle boot leaves it off (`instrumentation.ts:218`). - **Enqueueing `common/controller/publishStages.ts`**: `enqueueStage(paths, req)` refuses a duplicate (same kind + target queued/running on `publish`) with `info:true` + the existing id; spec `{kind: jobKind, slug: target, params:{runId,…req}}` (`parseJobSpec` requires `slug`, `jobSpec.ts:66-82`; `channelSlug` unset). `enqueuePublishRun(paths, plan)` enqueues the whole plan at once; ordering is enforced by ON-DISK preconditions, not memory: builds carry `indexAfter=runStart` when update-index is in the run, deploys `builtAfter=runStart` when their build is. - **/jobs + boot**: `jobDetail.ts:12` shows `run · ` for publish kinds (one run reads as a group); `jobKinds.ts` gets the seven `publish-*` entries, replayable (`jobReplayRegistry.ts` re-enqueues from the spec); at boot (`bootQueuedJobs.ts:280`) publish kinds are treated like `sync`: cancelled "the publish lane re-derives stages from on-disk state", never requeued. ## Surfaces - **One state builder**: `common/controller/publishState.ts` `readPublishInputs(paths)` (stamps, sites, settings, input mtimes, recent job metas, live publish jobs) → `common/views/publishStatus.ts` `buildPublishStatus(inputs, now)` (pure): per target `{indexFresh, built, builtFromCurrentIndex, buildStale?: {reason:"channels"|"data"|"config"; changedChannels: string[]}, deployed, deployedIsBuilt, previewUrl?, liveCheck?, policy, deployProblem?, next, running?, queued[]}` + `{index, lane, plan}`; `planPublishRun` pure in the same view. The /sites panel, the lane, `archilyzer publish status` and `GET /api/ops/publish` all read it (one-core doctrine). - **/sites "Publish" panel** (`PublishPanel.tsx`, replaces "Build all sites"): one row per site + hub + homepage, chips `index | built | deployed | live`, buttons Build / Deploy preview / Deploy production / Deploy local (only with `ARCHILYZER_SITE_OUT`); **Publish now** = `enqueuePublishRun(plan(deploys:"policy"))` (every policy off ⇒ update-index alone); **Build all stale** builds every stale site regardless of policy; consoles are `JobLane` (`editor/app/sites/components/JobLane.tsx`). Pool: "Build index" keeps its name + `Build index output` label (`editor/e2e/helpers.ts:457`) and now enqueues update-index; "Build stats dataset" removed; normalize/archive unchanged. `/sites//publish`: Build & deploy = `enqueuePublishRun([build --force, deploy builtAfter])`; preview/production split stays. New `/operations/publish` (static route beside `[id]`): enable/hold/start/stop/drain, the settings form, last/next pass, the plan. `editor/app/sites/lib/publishActions.ts` is new; `buildIndexAction`/`buildStatsAction` deleted; `buildAction.ts`, `deployAction.ts`, `hubActions.ts`, `homepageDeployActions.ts` become thin `enqueueStage` calls. - **CLI (`archilyzer.ts`)**: `publish status [--json]`, `publish index`, `publish build [--runner docker] [--force]`, `publish deploy [--preview b] [--to local] [--force]`, `publish hub [--deploy] [--preview b]`, `publish homepage [--deploy]`, `publish now` — run the stage bodies in the CLI's process under the lock; `stage …` is the internal row the editor spawns. Old rows become printed aliases: `build site` = `publish index` (skipped by `--nodata`) + `publish build --force`; `deploy site` = `publish deploy`; `build all` = `publish build all --runner auto`. - **ops: ONE route `editor/app/api/ops/publish/route.ts`** (the `lane` route's precedent; adapters only): body `{verb:"index"|"build"|"deploy"|"hub"|"homepage"|"now", siteId|siteIds?, preview?, to?, force?, runner?, deploy?}` → `{jobs:[{target,kind,jobId,previewUrl?}], skipped}` (`--wait` follows every id); `GET` returns `PublishStatus`; `archilyzer-ops.mjs` gains `publish` in ACTIONS and `get publish`. Old routes stay as aliases (same bodies/responses; `skipData` accepted and ignored). ## Docker - **(a) Runtime image** (`Dockerfile` runtime-base `:304-307`): apt `python3 pipx python3-certifi python3-brotli python3-websockets python3-mutagen python3-pycryptodome python3-requests`; `PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install git-filter-repo==2.47.0` (a drift test in `buildImage.test.ts` pins it to `FILTER_REPO_PIPX_SPEC`). **Wrangler pinned as an exact devDependency of `common`** (one pin for host and image; the image ships workspace `node_modules`; nothing is fetched at deploy time); `wranglerBin(paths) = WRANGLER_BIN ?? /node_modules/.bin/ wrangler` replaces `pnpm dlx` (`build.ts:382,961`); `pagesDeploy.ts` gains `WRANGLER_MAJOR` for doctor. Build args `ARCHILYZER_COMMIT`/`ARCHILYZER_BRANCH` → ENV, filling the stamps' `commit`/`branch` where there is no `.git`. - **(b) Credentials + config dir**: `.env` carries `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, the `R2_*` keys (documented in `.env.example` + `envVars.ts` → ENVIRONMENT.md); the editor inherits them, so its deploy children do. Compose `x-app-env` gains `ARCHILYZER_CONFIG_DIR=/data/config/archilyzer` (`paths.ts:216` honours it): the scrub and denylist files live in the config volume (`docker compose cp` them in; never printed). - **(c) Host one-shots: `docker compose exec editor pnpm archilyzer publish `** (or `pnpm ops publish`). NOT `docker compose run --rm`: a second container means a second `export/public` and a second LMDB writer, and the lock cannot see pids across namespaces. The entrypoint catch-all stays for tools. - **(d) Linux fan-out runner**: `settings.publish.runner:"docker"` or `--runner docker` on a HOST editor runs the `build-site _all` stage above (same `built.json` stamps ⇒ deploy stages are runner-agnostic). In a container there is no engine and the stage refuses — no docker-in-docker. - **(e) Local targets**: `docker/publish-site.sh ` becomes `publish index && publish build "$1" && publish deploy "$1" --to local`; the `homepage` service mounts `builds` and serves `/data/builds/homepage` when non-empty, else the image's baked out; `deploy-homepage --to local` fills it. - **(f) Source mirror from the container**: `source.ts:766` defaults `sourceRepo` to `ARCHILYZER_SOURCE_REPO`; `docker-compose.source.yml` bind-mounts the host's git common dir read-only at `/data/source.git` and sets it; the entrypoint adds `git config --global --add safe.directory /data/source.git`. - **(g) Substituting yt-dlp** — default: the image's release binary; the hook is a RUNTIME swap, no rebuild: `YTDLP_BIN` in `.env` (passed by `env_file`) points at either a zipapp built on the host (`make yt-dlp` in the patched checkout) bind-mounted or copied into `/data/config/bin/`, or `/usr/local/bin/yt-dlp-from-source`, a wrapper baked into the image (`PYTHONPATH=${YTDLP_SOURCE_DIR:-/opt/yt-dlp-src} exec python3 -m yt_dlp "$@"`) used with `docker-compose.ytdlp.yml` mounting `${YTDLP_SOURCE_HOST_DIR}:/opt/yt-dlp-src:ro`. The image already sets `YTDLP_BIN` (`Dockerfile:359`), so "overridden" = differs from a new `ARCHILYZER_IMAGE_YTDLP=/usr/local/bin/yt-dlp`. Every boot prints `yt-dlp: (image|override)` (like the `vulkan:` line; `MISSING` warning, editor still starts); `update_ytdlp` (`entrypoint.sh:173`) skips with a warning when overridden. A build-time `--build-context ytdlp=…` zipapp stage is a follow-up. - **`archilyzer doctor` adds**: `yt-dlp` (path, version, image|override, auto-update conflict), `wrangler` (binary, major = `WRANGLER_MAJOR`), `cloudflare-auth` (token set or OAuth config present; never a value), `r2-keys` (only with a bucket), `config-dir` (exists, writable; counts only), `export-builds` (writable, free ≥ 1.5× bundles), `publish-lock` (dead pid), `index-stamp` (age; sites built from an older stamp), `source-repo` (homepage policy on, no `.git`); `filter-repo` stays. ## Deploy hardening - **Credential preflight**: no token and no OAuth config → refused before wrangler ("set CLOUDFLARE_API_TOKEN in .env"); wrangler's `Authentication error [code: 10000]` → `[deploy] REFUSED by Cloudflare — the API token was not accepted`. - **Live check `common/publish/liveCheck.ts`** (injectable `fetch`): `/corpus.json` plain and `?cb=`, 3 tries 10 s apart; records `cf-cache-status`, `age`, `cache-control`; compares `generatedAt` with `built.corpusGeneratedAt`; mismatch/stale-edge = WARNING, job still `done`; `E2E_LIVE_CHECK=skip`. - **Tombstones `common/publish/tombstones.ts`** while `social.x.visibility` is private: compose-site writes, per withheld member (`publishedMemberSlugs`), `posts//manifest.json` (manifest-only shape, `pageCount: 0`) and `posts//page-NNNN.json = []` for every N below the shared tree's `pageCount`; a site whose only posts were X gets an empty `posts/manifest.json` (`corpus.json` still advertises no posts). compose-hub writes the same for every X channel with a shared posts tree + `posts/manifest.json`, after removing `SITE_ONLY_PUBLIC_ENTRIES`. `renderHeadersFile` (`headers.ts:80`) gains `noStore: string[]` → `Cache-Control: no-store` (+ CORS) for `/posts/manifest.json` and `/posts//*` on a site, `/posts/*` on the hub; the committed fixture updated. **The hub's next deploy must contain, path for path:** `posts/manifest.json` (empty), `posts/thequartering-X/manifest.json` (`pageCount` 0), `posts/thequartering-X/page-0000.json` (`[]`), and the `_headers` `no-store` block for `/posts/*`; deploy-hub probes each plain + cache-busted (`LiveCheck.tombstones`). If the edge still serves the old objects, they expire ~2026-10-08 21:00. ## Migration - Kinds no longer created keep label-only `jobKinds.ts` entries (archived metas still read): `build-index`, `build-stats`, `build-export`, `build-deploy`, `build-all`, `build-deploy-all`, `deploy-export`, `build-hub`, `deploy-hub`, `build-deploy-hub`, `build-homepage`, `deploy-homepage`, `build-deploy-homepage`. - Deleted: `runDocker*AllPhase`, `basicBuildAndDeployAll`, `BuildAllSitesButton`, `BuildSitesPanel`, the index/stats parts of `BuildButtons`. ops aliases: `build-deploy {all}` = build every site + deploy each deployable site to production (explicit request ⇒ policy ignored); deploying a never-built site refuses "no build of X in — archilyzer publish build X"; `docker/publish-site.sh` is a wrapper. - Docs: PUBLISH.md rewritten around stages (replaces "deploy runs on the host" `:639-641` and the branch hazard `:85-88`); RUNNING_IN_DOCKER.md (Publish, Windows `:568`, yt-dlp `:223`, fan-out `:544`); AGENTS.md runtime-container section (stages run in the container; `exec`, not `run`); FACTS "The publish stages"; SITE.md, SETTINGS.md, ENVIRONMENT.md regenerated. Changelog bullets: Publishing is stages · One index for every site · Publish lane · Deploys are pinned and checked live · Withdrawn X posts ship tombstones · Substitute your own yt-dlp. ## Slices | slice | branch | owns | after | tests | |---|---|---|---|---| | **S1** stage core | `r18/stage-core` | `common/publish/{stages,stamps,stageLock,stageRun}.ts`; `common/lib/dirSignature.ts` (+ the import line in compose-site); `build.ts` (move-out, symlink, staging); `archilyzer.ts` (`stage` row, `publish index/build/deploy/hub/homepage`, aliases) | — | stamp round-trip + tolerance; lock (stale, wait, other host); the `needs()` table; move-out with injected EXDEV; argv pins in `build.test.ts`; aliases | | **S2** deploy hardening | `r18/deploy-hardening` | `common/lib/pagesDeploy.ts`; `common/publish/{deployStage,liveCheck,tombstones}.ts`; `headers.ts` + fixture; `compose-site.ts`; `compose-hub.ts`; `common/package.json` + lockfile (wrangler pin); `editor/e2e/fixtures/bin/fake-wrangler.mjs`; playwright env `WRANGLER_BIN`/`E2E_LIVE_CHECK` | S1's types (interfaces above; may start in parallel) | `--branch main`; auth classifier; live-check verdicts; tombstone paths; postsVisibility integration; fake-wrangler argv sidecar + `E2E_FAKE_WRANGLER_AUTH_FAIL` | | **S3** state + lane | `r18/publish-lane` | `common/controller/{publishState,publishStages,publishRunner}.ts`; `common/views/publishStatus.ts`; `settingsSchema`, `siteSchema` + generated docs; `autoQueueTypes`, `pauseGates`; `jobKinds`, `jobDetail`, `bootQueuedJobs`, `jobReplayRegistry`; `instrumentation.ts`; `archilyzer.ts` `publish status/now` | S1 | view/plan purity; dedupe; preconditions; runner (hold between stages, drain, quiet hours); boot category; sanitizers | | **S4** surfaces | `r18/publish-surfaces` | editor `sites/**`, `operations/publish/`, `api/ops/publish` + aliases, `archilyzer-ops.mjs`, specs | S3, S2 | new `publish.spec` (chips; Build index writes the stamp; build; preview deploy through the fake wrangler; Publish now with policies off = index only; one runId on /jobs; the auth-fail sentence); `publish-lane.spec` (hold + drain via stuck-job); `ops-api` (verbs, aliases, refusals); updated `build`, `deploy-page`, `site-scope`, `site-publish-preview`, `sites-homepage`, `duplicate-shorts`, `sites-crud` | | **S5** docker + doctor | `r18/docker-publish` | `Dockerfile`, `docker/*`, `docker-compose*.yml` (+ `source`, `ytdlp` overlays), `.env.example`, `doctor.ts` + test, `source.ts` (env default), `envVars.ts`, RUNNING_IN_DOCKER.md | S2 (`wranglerBin`); the image half may start at once | doctor cases; `buildImage.test` drift; the compose smoke | | **S6** records | `r18/records` | PUBLISH.md, AGENTS.md, FACTS, `plans/release-18.md` "as shipped", CHANGELOGs | all | docs gates | **Graph:** S1 → {S2, S3} → S4; S2 → S5; all → S6. Start together: S1, S2 (against the interfaces above), S5's image half. **Rollout (operator-owned restarts; the live editor must never be booted twice):** 1. Merge all slices (`--no-ff`, clean tree, counts-only privacy greps before each), run the gates. 2. ONE editor rebuild + restart (`~/reports/release-17/scripts/r17-{build,restart,smoke}.sh` pattern; update the guard sha). 3. Every site's policy still "off": `pnpm ops publish --json '{"verb":"now"}' --wait` = update-index only; poll `/` every 2 s meanwhile, every answer under 5 s (the starvation is gone). 4. `publish build jeralyzer` → `publish deploy jeralyzer --preview r18` → read the live-check verdict. 5. `publish hub --deploy` → read the tombstone probes (this closes today's loose end: the cached X shard). 6. Then, per site, `publish deploy ` (production) in today's order; then set policies (recommended: `build` for all six, `preview` where previews are wanted, `production` for none until a week of lane runs reads clean). 7. umtool/homepage untouched by the restart; `archilyzer doctor` after. ## Verification - Unit + build gates per slice (common, editor, export, homepage, scripts, mcp suites; tsc; editor/export/homepage builds; umtool's capped build). e2e: the editor suite with S4's spec list — wrangler never runs (`WRANGLER_BIN=fake-wrangler.mjs`). Capped export build: `pnpm --filter export exec next build` in a worktree. - Compose smoke on Linux (project `-p r18smoke`, a FIXTURE corpus copied into `$T`, never the real one): `exec editor pnpm archilyzer publish index && publish build && publish deploy --preview smoke`; with no token the deploy is refused before wrangler; with `CLOUDFLARE_API_TOKEN=bogus` the real pinned wrangler is refused by Cloudflare; both: `deployed.json` untouched, exit 1; `doctor` lists every new check; the boot log shows the `yt-dlp:` line. - Windows checklist (Docker Desktop, PowerShell): `copy .env.example .env` (set `WORKER_TOKEN`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`); `docker compose up -d --build`; open `http://localhost:8081` → /sites → Publish now (or `docker compose exec editor pnpm archilyzer publish now`); local preview: `docker compose --profile site up -d site`, `docker compose exec editor pnpm archilyzer publish deploy --to local`, open `http://localhost:8080`; `docker compose exec editor pnpm archilyzer doctor`; optional patched yt-dlp: set `YTDLP_SOURCE_HOST_DIR` + `YTDLP_BIN=/usr/local/bin/yt-dlp-from-source`, `docker compose -f docker-compose.yml -f docker-compose.ytdlp.yml up -d`. - Live: rollout steps 3–5 above, each with its verdict recorded in this file's "As it went". ## Open questions (settle during S1/S2, record the answer) 1. Where does the hub's `s-maxage=604800` come from (no repo rule sets it)? If a Cloudflare rule overrides edge TTLs, `no-store` may be ignored — the live check will show it; a purge-by-URL is not designed (no zone for `pages.dev`). 2. Is `main` the production branch of every Pages project (jeralyzer … jasolyzer, hub, archilyzer)? 3. Does a `generation` bump rewrite summaries of sites whose data did not change? If so `inputSig` is conservative (more rebuilds, never a wrong skip). 4. Should the lane also rebuild on a code change (`built.commit` differs)? Default no. 5. Six per-site bundles cost disk (`export/out` is ~491 MB for the largest site); hardlink or accept. ### Open questions, settled 1. **The hub's `s-maxage=604800`** is not the repository's: no rule it renders or ships sets it, so it is Cloudflare's side of a `*.pages.dev` hostname (S2 record, "Open question 1"). Whether `no-store` wins over it is read off the hub's next deploy — its live check records `cache-control`, `cf-cache-status` and `age` for `corpus.json` and each tombstone, plain and busted (rollout step 4). 2. **`main` is the production branch of every Pages project** as far as the records say without asking Cloudflare (S2 record, "Open question 2"): `--branch main` changes nothing for the eight projects. 3. **A `generation` bump rewrites every site's aggregates** (S1 record, "Open question 3"): `inputSig` is conservative — any data change anywhere stales every site's signature; `changedChannels` is the precise signal. A follow-up below. 4. **No** — a code change (`built.commit` differs) shows "code newer" and is not stale (S1 `needs()`; ruled 2026-10-06). 5. **Accept** the per-site bundles: ~500 MB × 6 against 225 GB free; no hardlinks (ruled 2026-10-06). ### Rulings settled during the release (2026-10-06; do not re-open) - **Private data is never named on the hub** (S2 H1): the hub tombstones only X channels that are a member of at least one non-private site. Members of unlisted or cited public sites ARE still tombstoned — consistent, and said to the operator as I1. - **S3's five reviewer questions:** keep the `snapshot.json` ingest signal (its cost is no-op stages); `common/publish/` is the home of `publishState` / `publishStages` / `publishRunner` (the layering test forbids controller → publish and publish → views); Stop does NOT cancel the stage in flight (a hold stops dispatching, never kills a stage; Cancel on /jobs does); `archilyzer publish now` goes on after a failure and exits with the worst code; keep the third pass trigger (policy work left, once per `refreshEveryMinutes`). - **The hub-bundle regression (`5c09cd7b`, 2026-10-05) never shipped in a release**: it is stated in the S2 record, not in the changelog. ## Assumptions the operator can overturn | assumed | alternative | |---|---| | `publish` is a pipeline lane outside `LANES`, configured in `settings.publish` | add it to `LANES`/`autoQueue` and special-case the nine channel-tree loops | | compose into the shared `export/public`, move `out/` per site | per-site `EXPORT_PUBLIC_DIR` behind an `export/public` symlink | | the index child runs and is trusted; job completions + config mtimes decide when | compare LMDB `generation` to the stamp (cannot see new files) | | wrangler as an exact devDependency of `common` | `npm i -g wrangler@ARG` in the image only | | the lane enqueues one stage at a time; Publish now enqueues all at once with on-disk preconditions | an in-memory chain / a parent job | | Publish now obeys policies (all off = index only) | Publish now builds everything stale | | `exec`, not `run --rm`, for host one-shots | dedicated compose `publish` services | | yt-dlp swapped at runtime (`YTDLP_BIN` + wrapper or zipapp); build-time context is a follow-up | a `--build-context ytdlp=…` zipapp stage now | | `export/out` stays as a symlink to the last bundle built | remove `export/out` | | the fan-out runner is host-only, opt-in | drop the fan-out entirely (single runner) | ## Follow-ups carried over (not scheduled; keep) - **From release 18's reviews:** `stageLock.ts` `removeIfUnchanged` read-then-rm race (two waiters on one stale lock); the lock does not cover a worktree whose `export/public` links into the primary's; `pnpm --filter … exec` collapses exit codes 2/3/130 to 1 (documented, PUBLISH.md); a `generation` bump stales every site's `inputSig` (sign summaries by content minus `generatedAt`); a site's live check does not probe its tombstones; `generatedAt` cannot tell two no-data deploys apart; the homepage's live check reads only `/` (compare `/source/manifest.json`'s commit); a cited site that withholds an X channel writes no tombstones; `builtExport.ts` `REPORT_DATA_FILES` hard-codes names instead of `revisions.ts` helpers, and report stills are unnamed; a Cancel arriving as wrangler exits 0 records nothing (harmless); no gitleaks / stagit in the image (a pinned gitleaks later); `.env` credentials reach site / homepage / umtool through the shared `env_file` (an editor-only credentials file); `toolProbe.mjs` counts a failing `--version` that printed output as present (umtool's doctor shares it); the stale 7.3 GB `archilyzer:local` image; the `snapshot.json` signal is loose (accepted). - **From S4:** a dangling `export/out` link (its bundle deleted by hand) fails a bare `next build` of the export app on `stat export/out` — the stages unlink it first, and the editor e2e drops its own; `pnpm ops lane` does not take the publish lane (its hold is the UI's or `settings.publish.held`); a click's run can reuse the lane's still-QUEUED background index update and start its foreground build first (noted in `publishStages.ts`; Retry); `build hub` and `build homepage` stay raw, unstamped builds (e2e:2origin runs `build:hub`); `jobs.spec` "tails its log" and `site-scope.spec` "charts is a site's tab" each failed once in a long run and pass alone (dev-mode first compiles under the stage children's load). - normalize/archive pool jobs still run in-process (move them to child processes). - `migrate-tier --reclaim` on the platter; the en/en-orig duplicate-track ruling + the `en` → 0 cues bug (212 degraded); "text on platter" per-channel option (low priority, /home has 225 GB free); Syncthing folder paused. - Release 16/17 leftovers unchanged: "Load more results" leaf resume, `/changelog/` phone overflow, the three flaky-spec fixes, the JSX entity sweep, build traces listing dot-dirs, Docker image for others (needs a GitHub home), X section by-hand check, `channel-rename.spec` race, RM L5 force-release, XL Firefox store discovery, the channel export/import bundle, realcandaceo's Rumble fact-check (Candace data stays local-only). ## Record (Each slice adds a "### Slice , as shipped" section here, before "## Rollout".) The sections are in merge order, not slice order: S2 (deploy hardening), S1 (the stage contract, the stamps, the lock, the bundles, the CLI), S5 (the image half), S3 (the status, the queue, the lane), S4 (the surfaces), S6 (the records). ### 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 ` for a preview; `wranglerBin(paths, env)` = `WRANGLER_BIN`, else `/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 `//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 — 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` (`/.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 failed or refused at the credential/destination step, 2 a request it cannot run — a bad branch name, local with a preview, the hub locally, a kind and target that do not match — 3 precondition not met: no build, a private site, no Pages project, a production build not of main, a bundle guard — the codes `needs()` gives the same refusals; 130 cancelled) whose message is the line the log already ends on, word for word, and `deployed.json` is untouched. `deploy-hub` and `deploy-homepage` take only `_hub` / `_homepage`, and a site deploy refuses either. Wrangler's own lines reach the log through the same `log()`, each ending in a newline. A local destination that holds the checkout, the corpus, the builds or the bundle, or is not empty and has no `index.html`, is refused before anything is emptied. Cancel reaches the live check; a deploy cancelled during it is recorded without one and ends 130. 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`, `signal`): `/corpus.json` plain and `?cb=` (`&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` (both serve this build, or plain does and busted failed); `stale-edge` (busted serves this build, plain answers 2xx with another `generatedAt`); `mismatch` (busted serves another); `unreachable` (neither answers 2xx, or the plain read fails); `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 — …` 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//` with its `ChannelPostsManifest` at `pageCount: 0` (`slugToPage: {}`, `maxPageBytes: 0` — no size) 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 that at least one non-private site carries, 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()`). **Ruling clarification (review H1): private data is never named on the hub.** The plan's "every X channel with a shared posts tree" is narrowed to the X channels that at least one site whose audience is not private carries (`site.channels`): only those could ever have been served by a public bundle, so only their paths can sit at the edge. An X channel only private sites carry, or no site, gets no tombstone — its slug appears nowhere in the hub's `public/` (tested). With X public, or no such 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//*` 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 --project-name

--branch ` 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://..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=` 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 --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 wrangler's output would show a preview URL. The live check reads `mismatch` only where it probes the production URL (a site with a `siteUrl`, the hub, the homepage) and the build's `generatedAt` differs from what production serves; a rebuild with unchanged data would still read `ok`. #### 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`. #### Deviations from the plan - **The hub's tombstone set** is narrower than the plan's wording: X channels a non-private site carries, not every X channel with a shared tree (review H1; the ruling clarification above). - **The cache-busted key on a retry** is `?cb=&try=N` (the plan: `?cb=`): a retry needs a key the edge has not seen either. - **A busted read that fails while the plain one serves this build is `ok`** — a visitor gets the build. The plan's table did not name that case. - **The homepage's live check reads `/` for a 2xx** (the homepage has no `corpus.json`); comparing `/source/manifest.json`'s commit is a follow-up. - **Exit 2** is used for request-shape refusals (bad branch name, local with a preview, the hub locally, a kind and target that do not match); the S1 argv parser may catch most of them first. - **Files outside the slice's list:** `common/lib/builtExport.ts` (`isTombstonePostsTree` + one clause of `builtHubProblem`), `common/publish/build.test.ts` (argv pins follow the spawn change), `pnpm-workspace.yaml` (`workerd: false`), and `common/lib/envVars.ts` + ENVIRONMENT.md (above). - **A site's tombstones are kept per withheld member, as the plan asks** (review M3, an operator ruling to keep or narrow): an X channel added to a public site after X went private is still tombstoned there, so the tombstone names its slug and page count — and, since the review, no page size. #### 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) | | `0f9621c4` | `plans:` this section; the editor changelog | | `801124c3` | `common:` the review fixes (below) | | this commit | `plans:` the review fixes in this section; the changelog's hub wording | #### 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; identifiers with the suffix the plan forbids: 0. | 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 | | A public site tombstones every withheld X member (the plan), with `maxPageBytes: 0` | Tombstone only members a public build ever served (a channel added after X went private is never named) | | A deploy cancelled during its live check is recorded (with no check) and ends 130 | Leave `deployed.json` untouched, though the bundle is live | #### Review fixes (review SHIP AFTER FIXES, `$T/s2-review.md`) | # | Fix | Commit | |---|---|---| | H1 | The hub's tombstones only for X channels a non-private site carries; `compose-hub.test.ts` adds an X channel on a private site only and one on no site — neither is named anywhere in the hub's `public/`; `tombstones.test.ts` pins the set. Record and changelog say so (ruling clarification: private data is never named on the hub) | `801124c3`, this commit | | H2 | Node 22 in the image (the pinned wrangler needs `>=22`) — S5's files, left to S5 | — | | M1 | wrangler's lines through `log()`, each ending in a newline (test) | `801124c3` | | M2 | `deploy-hub` / `deploy-homepage` pinned to `_hub` / `_homepage`; a site deploy refuses either; exit 2, nothing written (test: `deploy-homepage` with target `jeralyzer`) | `801124c3` | | M3 | Per-withheld-member tombstones kept on a site as planned; `maxPageBytes: 0` | `801124c3` | | L1 | The live check takes the stage's `signal`: reads abort (`AbortSignal.any` with the timeout), the interval sleep wakes, no retry after Cancel; a deploy cancelled mid-check is recorded without one, exit 130 (tests) | `801124c3` | | L2 | A failed plain read is `unreachable`, never `stale-edge` (verdict table test) | `801124c3` | | L3 | Request-shape refusals exit 2 | `801124c3` | | L4 | R2 and wrangler failures throw the line they logged (test) | `801124c3` | | L5 | `--to local` refuses a destination that holds the checkout, corpus, builds or bundle, or is non-empty with no `index.html` — naming the path, emptying nothing (tests) | `801124c3` | | L6 | doctor importing `wranglerOAuthConfigFiles`, `ARCHILYZER_SITE_OUT`'s `readBy` — at the S5 merge | — | | L7 | `#### Deviations from the plan`; the bold labels are headings | this commit | | L8 | Open question 2's sentence softened | this commit | | L9 | PUBLISH.md's `pnpm dlx wrangler pages project create` — S6 | — | Gates after the fixes: tsc (all workspaces) clean; **common 3,199/3,199**, 135 s (+6: `deployStage.test.ts` +5, `liveCheck.test.ts` +1; `fetchPosts.test.ts` passes again at a load average of 16–19); e2e below. | Run | At | Specs | Result | |---|---|---|---| | 4 (editor) | `801124c3` | the five specs of run 1 | **29 passed**, 0 failed, 2.4 min (no queue wait) | #### Wiring round (after S1 merged, 2026-10-06) `r18/integration` merged twice: at `0ce00f76` (the plan, S5's image half, Node 22, the host id, S1) and at `de1b9174` (S5's second half). | Commit | What | |---|---| | `3b467ac3` | merge of `r18/integration` `0ce00f76`. `editor/CHANGELOG.md` and this file: both sides kept. `envVars.ts`: S5's `CLOUDFLARE_API_TOKEN`, `XDG_CONFIG_HOME`, `ARCHILYZER_HOMEPAGE_OUT` rows kept, S2's marked copies deleted, their `readBy` (and `ARCHILYZER_SITE_OUT`'s) name `pagesDeploy.ts` / `deployStage.ts`; ENVIRONMENT.md regenerated. `stamps.ts`'s local `imageBuildFacts` re-exported from `lib/envVars.ts` | | `553a94ed` | **the wiring.** `stageBodies.ts`: `deploy-site`, `deploy-hub` and `deploy-homepage` run `runDeployStage` end to end (bundle guards, credential preflight, R2, the pinned wrangler through `wranglerBin`, the live check, `deployed.json`, `--to local`); S1's interim deploy wrapper — the `build.ts` deploy calls, its own local copy (`publishLocal`, `localSiteOut`, `localHomepageOut`) and URL watcher — is gone: one implementation. `stageRun.ts`: a `DeployStageError`'s exit code is the stage's, and its sentence (logged by the stage) is not printed again — the runner adds only `[stage] : FAILED (exit 1)`. `deployStage.ts` / `liveCheck.ts` now import `BuiltStamp`, `DeployRecord`, `DeployedFile`, `LiveCheck`, `Probe` from S1's `stamps.ts` and use its readers and `recordDeploy` | | `6bccf923` | **the hub blocker — found on main 2026-10-05, fixed here** (below) | | `f1541070` | merge of `r18/integration` `de1b9174` (S5's second half): `stamps.ts` takes S5's one-line `imageBuildFacts` re-export; `envVars.ts` merged clean (one row per name, `readBy` combined); ENVIRONMENT.md regenerates unchanged | | this commit | `plans:` this table; the changelog's hub bullet | **What changed to fit S1's shapes (S1's win):** `builtAt`, `at` (DeployRecord, LiveCheck) are ms numbers, not ISO strings; `Probe.age` is a number of seconds; `built.json` is read through S1's strict `readBuiltStamp` (a stamp missing any field is no build) and `deployed.json` written through `recordDeploy`. Production now also refuses a build with **no** branch recorded (a detached HEAD, an image built without `ARCHILYZER_BRANCH`), S1's rule. `builtAfter` is `needs()`'s alone (stages.ts `needsDeploy` knows a no-op build's `checkedAt`); the stage's own copy is gone. "`--to local` needs ARCHILYZER_SITE_OUT / ARCHILYZER_HOMEPAGE_OUT" is S1's sentence and exit 3. The stage's `needs()` runs first and answers most refusals (no build, private, no project, production branch, freshness) with S1's `StageFailure`; `runDeployStage` asks them again as the last word before wrangler. A kind/target mismatch from the `stage` row is refused by S1's argv parser before either. One S1 test changed: its `--to local` destination is seeded with an `index.html`, since the stage refuses to empty a directory that does not look like a bundle it made. **The hub blocker.** `HUB_FORBIDDEN_TREES` (`lib/builtExport.ts`) listed `reports` and `m`, but the export app renders its report and moment routes into EVERY build — `reports/index.html`, `reports/_none/…`, `m/_none/…` — so since 2026-10-05 every hub bundle was refused ("still carries a site's data (reports, m)"; S1's smoke hit it). `reports` and `m` are off the list; `hubReportDataIn` refuses the report DATA a site's reports stage writes there instead, by the names `lib/report/views.ts` gives them: `reports/index.json`, `m/index.json`, `reports//page.json`, its `citations.{json,csv}`, its exports (`report.{html,pdf,md}`, `evidence-pack.zip`), its history (`history.json`, `history/repo/`), and any `m/**/moment.json`. `media` stays on the list; a tombstone-only `posts/` still passes and a real post does not (`builtExport.test.ts`). Gates after the wiring (at `f1541070`): tsc (all workspaces) clean; **common 3,281/3,281**, 162 s (one run at `6bccf923`, before the second merge, was 3,277/3,278 at a load average of 23–27: `fetchPosts.test.ts`'s timing case again); **editor unit 142/142**. Smoke (`$T/s2-smoke.sh`, the `s1-smoke-build.sh` pattern over a scratch corpus in `$T/s2-smokec`, never the real one; `WRANGLER_BIN` = the fake, `E2E_LIVE_CHECK=skip`): `publish index` 0; `publish build smoke` 0 (61 s); **`publish hub` 0 (61 s) — the bundle carries `reports/index.html` and `m/_none/` and passes `builtHubProblem`**, `_hub/built.json` written; `stage deploy-site smoke --preview smoke` 0 through the stage row (fake wrangler argv `pages deploy /smoke/out --project-name w3c-never-real --branch smoke`, `[preview]` line, live check `skipped`, `deployed.json` `previews.smoke` with ms times) — the preflight passed on this host's `wrangler login` file, the no-credential refusal is the unit tests'; the same again: no-op (fresh); `E2E_FAKE_WRANGLER_AUTH_FAIL=1`: exit 1, `[deploy] REFUSED by Cloudflare — the API token was not accepted` once, then `[stage] deploy-site smoke: FAILED (exit 1)`, no record; `stage deploy-hub _hub --preview smoke` 0 (`previews.smoke` in `_hub/deployed.json`); `stage deploy-homepage smoke` refused by S1's argv parser ("the target is _homepage", exit 2 — the step's 1 was `pnpm exec`'s); `publish deploy smoke --to local` 0 (195 files into the scratch `siteout`, `local` recorded with `liveCheck: null`). | Run | At | Specs | Result | |---|---|---|---| | 5 (editor) | `f1541070` | the five + `build`'s neighbours: `deploy-page`, `site-publish-preview`, `sites-homepage`, `build`, `site-scope`, `cut-release` | not run: the export webServer timed out (120 s) — the smoke, run while this one waited 16 min in the queue, had composed the hub into this worktree's `export/public` (no `summaries/`). `export/public` restored (`git clean -X` + the fixture links), then run 6 | | 6 (editor) | `f1541070` | the same six | **35 passed**, 0 failed, 2.3 min (no queue wait) | **The hub fix is a record, not a changelog line:** the regression (main `5c09cd7b`, 2026-10-05) is in no release, so no user ever saw it. #### Round-2 review cleanups (review SHIP, `$T/s2-review-2.md`) | # | Fix | Commit | |---|---|---| | L1 | `localDestProblem` resolves symlinks (realpath of the destination's nearest existing ancestor, and of every protected root) and refuses a destination INSIDE the builds dir, `export/` (so `export/out`, the link to the last bundle) or the corpus, as well as one containing them or the checkout; tests: `ARCHILYZER_SITE_OUT=/out` refused with the bundle untouched, and a directory inside each of the three refused and not made | `c5f9100f` | | L2 | The deploy body's exit codes agree with `needs()`: a private site, no Pages project (site or hub), a production build not of main, and the bundle guards (incl. the homepage's source gate) exit 3, not 1 | `c5f9100f` | | L3 | `deployStage.ts`'s header: S1 has landed; `builtAfter` is `needs()`'s; the exit codes per step | `c5f9100f` | | L4 | `stageRun.test.ts` proves the local copy by files the destination did not have (`corpus.json`, `site.json`) and the replaced `index.html` | `c5f9100f` | | L7 | The smoke row: the mismatched homepage deploy exits 2; the changelog's tombstone bullet says "a channel only on a private site, or on no site"; the "hub builds again" changelog bullet removed (the statement above stays) | `c5f9100f` | | S5 smoke | A malformed token (`CLOUDFLARE_API_TOKEN=bogus`) gets `Invalid request headers [code: 6003]` / `Invalid format for Authorization header [code: 6111]` from Cloudflare; both now read as `[deploy] REFUSED by Cloudflare — the API token was not accepted` (9109, a well-formed wrong token, already did); a test line each | `51ef7fd1` | Gates after the cleanups (at `51ef7fd1`, after merging `r18/integration` `f9f7cfbf`, S5 complete, clean): tsc clean; **common 3,282/3,282**; **editor unit 142/142**. ### Slice S1, as shipped — the stage contract, the stamps, the lock, per-target bundles and the CLI (2026-10-06) Branch `r18/stage-core` off `ce66f2d3` (the plan commit on `r18/integration`), worktree `~/Projects/r18-stage-core` (editor 7201, test 7211, export 7210 — `pnpm wt list`'s #42), one Opus implementer, beside S2 and S5's image half. Scratch files `s1-*` in the job's `tmp`. The plan is "Model", "Stages" and "CLI" above; the deploy bodies are S2's to rewire, the status view, the lane and `publish status|now` S3's. **What it does.** - **`common/publish/stages.ts`** — the only module that knows every stage: `StageKind`, `StageRequest`, `Freshness`, `Stage`, `StageOutcome` as "Model" lists them (+ an optional `allowMissingMedia` on the request, for `build site --allow-missing-media`); `STAGES` (seven, `jobKind: publish-`, `queueKey: "publish"`); a PURE `needs()` per stage over **`NeedsInput`** — the minimal PublishStatus-shaped input S3's view satisfies: `index: {stamp, lastIngestDoneAt, configChangedAt}`, per site / `hub` / `homepage` a `TargetState` `{built, deployed, changedChannels, configChangedAt, bundleProblem, deployProblem?, pagesProblem?}`, and the homepage's `mainHead`. `stageArgv` / `parseStageArgs` (inverse, round-trip tested) and `STAGE_FLAGS`. - **`needs()`, row by row.** update-index: stale with no stamp, an ingest ended `done` after `stamp.scannedAt`, or a config newer than it. build-site: BLOCKED "update the index first" with no stamp (forced too), "waiting for the index update this run started" under `indexAfter`, "the index has not seen site X" when the stamp has no entry; then stale by `changedChannels` ("N channels changed (a, b, …)"), a config change after `builtCheckedAt(built)`, an `inputSig` mismatch ("data changed"), a bundle problem, never built; `--force` stale; a `built.commit` that differs is NOT stale. `_all`: per site. deploy-*: blocked with no build ("no build of X — archilyzer publish build X"), a private target (every kind), no Pages project (pages kinds only), a bundle problem, `builtAfter` (unless the bundle matches the index this run updated — see the review fixes), and a PRODUCTION deploy of a bundle whose `branch` is not `main` — a null branch (a detached HEAD, an image built without `ARCHILYZER_BRANCH`) is refused the same way; fresh exactly when `deployed[kind/branch].builtStampId === built.stampId`. build-hub: `built.inputSig === stamp.hubSig` (+ `changedChannels`). build-homepage: `indexStampId` current and `sourceCommit === mainHead` when a repository answers. - **`common/publish/stamps.ts`** — `IndexStamp`, `BuiltStamp`, `DeployRecord`, `DeployedFile`, `LiveCheck`, `Probe` exactly as listed; paths (`/stamp.json`, `//{built,deployed}.json`); atomic writes (`writeJsonAtomic`); tolerant reads (missing, unparseable or wrongly shaped = null); `recordDeploy` keeps every other record; `newStampId` sorts by time; `imageBuildFacts` (the image's `ARCHILYZER_COMMIT` / `ARCHILYZER_BRANCH`, empty = null — S5's names, read by name here until S5's helper of the same name replaces it). `BuiltStamp.checkedAt?` (review fix): when a later no-op build last found the bundle still matching its inputs. - **Commit and branch in a stamp (the S4 seam):** `ARCHILYZER_COMMIT` / `ARCHILYZER_BRANCH`, when set, WIN over git, each on its own — the runtime image bakes them (no `.git`), and S4's e2e webServer sets `ARCHILYZER_BRANCH=main`, because a worktree's branch is never `main` and production refuses any other. Else git's HEAD and branch; a detached HEAD records `branch: null`. - **`common/publish/stageLock.ts`** — `/.publish.lock` `{pid, host, kind, target, since, pidStart}`, `open(…, "wx")`; the host is `ARCHILYZER_HOST_ID` when set (S5 fixes one in compose), else `os.hostname()`; stale when same host and the pid is dead (`processIsAlive`), answers with another `/proc//stat` start time, or names a process that STARTED AFTER the lock's `since` (starttime/100 + `/proc/stat` btime, 2 s slack; a recreated container's pid 1) — with no `/proc`, pid-alive alone; another host's is never stolen, and its wait line names both hosts and how to clear it; a torn file is taken over after 60 s; a live holder is waited for (5 s poll, ONE log line, the signal cancels the wait); release removes only its own. - **`common/publish/stageRun.ts`** — `runStage(req)` (in-process, under the lock, never throws: `{code, outcome, message}`), exit codes 0 / 1 / 2 / 3 / 130, `stageMain` (the child: SIGTERM/SIGINT abort the stage, a second one SIGKILLs the child process groups and exits, tree-kill on), and **`stageCommand(paths, req)`** — `/node_modules/.bin/tsx bin/archilyzer.ts stage [flags]`, cwd `common/`, the caller's env + `NODE_OPTIONS=… --max-old-space-size=8192` for update-index only — what S3's `enqueueStage` hands `runManagedCommand`. - **`common/publish/stageBodies.ts`** — every body but update-index first asks its `needs()` over the state ON DISK (`readNeedsInput`: the stamps, the bundles, `siteDeployProblem`, the Pages project, `main`'s head; no job metas): blocked → exit 3, fresh and not forced → a no-op (a build's no-op writes `checkedAt`). **update-index**: `buildIndex` → `buildStats` → the chart templates in ONE process, then the stamp — `generation`, `scannedAt` and each site's `siteFp`/`statsFp` (sha1 of the LMDB keys, read-only), each site's `inputSig` and the `hubSig` (`common/publish/inputSig.ts`). An index that rebuilt nothing and whose every signature is unchanged KEEPS its stamp id (status `noop`), so the builds made from it stay current. **build-site**: `buildSiteBundle`, then `built.json`; `_all` local = each stale site in turn (failures collected); `_all --runner docker` = no engine → exit 3 "the docker runner needs an engine on this host", else `build archives` on the host → `ensureBuildImage` → `runDockerBuildOne` per stale site at `maxParallelBuilds` → `builtBundleProblem` → `built.json` (`runner: "docker"`). **build-hub** / **build-homepage**: `buildHubBundle` / `buildHomepage` (source mirror included; `sourceCommit` from `homepage/out/source/manifest.json`). **deploy-site / hub / homepage**: today's `deploySite` / `deployHub` / `deployHomepage` over the TARGET'S bundle (`outDir` / `stagingDir` options added), then `deployed.json` (`url` = the deployment URL the log line names, `alias` = the preview alias, `liveCheck: null` — S2's); `--to local` copies the bundle's contents into `ARCHILYZER_SITE_OUT` (a site) or `ARCHILYZER_HOMEPAGE_OUT` (the homepage — S5's name), private refused. - **`inputSig`** (`common/publish/inputSig.ts`) signs, with compose's `dirSignature`: the site's whole `.export-index/sites//` tree (chart-templates.json by its BYTES — `build templates` rewrites it every run), each PUBLISHED member's shared transcripts/subs/posts/digests tree (`manifest.json` ignored, a manifest-only tree as compose's constant), `site.json`'s bytes, the `sites//` dir, the global aliases, curated tags and duplicates files (size + mtime), `archiveStorage` + `social.x.visibility` + `buildArchives`, and — what the export BUILD renders beyond compose (review fix) — the resolved social links, the resolved hub url and the footer's sibling sites (`resolveRelatedSites(site, listSites())`: each sibling's url, title and listing). The rule: a site is fresh exactly when compose AND the export build would produce the same bundle. A superset of their inputs: conservative. `hubSig` = sha1(stampId, homepage.json, each listed site's id + siteUrl + title). - **`common/lib/dirSignature.ts`** — compose's `dirSignature`, moved unchanged; `compose-site.ts` imports it (that line, and its now-unused `createHash` import removed, are the only compose-site edits). - **`common/publish/build.ts`** — per-target bundles: `bundleDir(paths, target)` (= `dockerSiteOutDir` for a site), `installBundle(src, /out)` (rename into `out.next`, `out → out.prev`, `out.next → out`, `out.prev` removed, a leftover `out.next` deleted first; EXDEV → `fs.cp` + remove, injectable `BundleFs`), `recoverInterruptedInstall` (an `out.prev` with no `out` is renamed back before a build or an install touches the target; the old `built.json` is removed before the swap and the new one written last), `unlinkExportOut` (before a build: a link at export/out is removed so a failed build cannot leave an older bundle there), `pointExportOutAt` (export/out → a RELATIVE symlink, replaced atomically), `stageSiteArchives` (the host compose's `.r2-staging/` moved to `dockerSiteStagingDir`, a no-op where they are one place), `bundleCounts`, `corpusGeneratedAtIn`, `buildSiteBundle`, `buildHubBundle`. `ensureBuildImage`, `runDockerBuildOne`, `runHostScript`, `runWithConcurrency` are exported. A build container gets `EXPORT_BUILDS_DIR=/tmp/archilyzer-builds` (`CONTAINER_BUILDS_DIR`) so its own `publish build` lock never lands on the host mount. Every existing export is unchanged; the editor's actions still build into `export/out` (S4 rewires them). - **`common/bin/archilyzer.ts` + `common/bin/publish.ts`** — rows `publish index` (the SAME child the editor spawns, for its heap), `publish build [--runner local|docker|auto] [--force] [--skip-archives]`, `publish deploy [--preview b] [--to local] [--force]` (`all` passes over, one line each, only a private site and — to Pages — a site with no project; every other refusal is a failure, the rest are still tried and the run exits 1), `publish hub [--deploy] [--preview b] [--force]`, `publish homepage [--deploy] [--preview b] [--to local] [--force]`, and the internal `stage --run-id …`. A comment marks where S3's `publish status` / `publish now` rows go. **Aliases, printed first**: `build site ` = `publish index` (not with `--nodata`) + `publish build --force`; `build all` = `publish index` + `publish build all --runner auto`; `deploy site ` = `publish deploy `. **Deviations from the plan** (one sentence each): 1. `publish index` runs the stage child (`stageCommand`, the 8 GB heap) rather than the body in the CLI's process: the index and stats builds of the real corpus have always run with that cap (export's `build:index`), and the CLI's own node has the default heap. 2. `build all`'s alias runs `publish index` first — the old row always ran the data phase, and building every site from a stale index would not be what it said. 3. Tree-kill lives in `common/jobs/runChild.ts` (`setKillChildTrees`, off by default; a stage child and the publish CLI turn it on): each child leads its own process group and a cancel signals the group, then SIGKILLs what is left once the leader exits. The editor's in-process jobs are unchanged. 4. `.gitignore` gains `/export/out` (no trailing slash): `**/out/` matches only a directory, and the link showed as untracked — which `release cut --commit` refuses. 5. Inside the docker per-site build container (`ARCHIVES_READONLY=1`, set by `docker/build-site.sh`) `publish build` builds IN PLACE and stamps nothing — the container hands export/out back and the host stamps it — and asks no stamp, so the editor's existing Build all (host `build:data`, no stamp) keeps working until S4 rewires it. The container still writes `/out` with build-site.sh's `rm` + `cp`, not through `out.next` (S5's file). 6. `StageRequest.allowMissingMedia` (optional) carries `build site --allow-missing-media` through the stage. 7. The bundle-layout tests are a new `common/publish/bundle.test.ts`, not `build.test.ts`, which S2 also edits. 8. `ARCHILYZER_COMMIT` / `ARCHILYZER_BRANCH` / `ARCHILYZER_HOMEPAGE_OUT` are read by name through an `env[name]` helper: they are declared in `lib/envVars.ts` on S5's branch, not on this one (`envVars.test` stays green here; after the merge the helper can be S5's `imageBuildFacts`). **Open question 3, settled: yes — a `generation` bump rewrites EVERY site's aggregates.** `generation` is bumped whenever any record anywhere is added, changed or removed (`buildIndex.ts` ~:2036), and every site's fingerprint carries `gen` (~:2083), so every site is rebuilt: its summary pages (`writeJsonAtomic`, no sha1 skip for site pages), its four manifests (fresh `generatedAt`) and its `tag-counts.json`. Their mtimes move, compose would re-copy the summaries, and every site's `inputSig` changes. So `inputSig` is conservative as the plan expected: any data change anywhere makes every site stale (more rebuilds, never a wrong skip); `changedChannels` is the precise per-site signal. A follow-up could sign the site's summaries by content less `generatedAt`. **Found, not fixed (not this slice's files).** - **Every hub bundle is refused since `5c09cd7b` (2026-10-05).** `builtHubProblem` (`common/lib/builtExport.ts`) refuses a hub `out/` that holds `reports/` or `m/` ("still carries a site's data (reports, m)"), but the export app's own `/reports/` and `/m/[...moment]` routes render `out/reports/index.html` (+ `__next.*.txt`) and `out/m/…` in EVERY build, the hub's included — `export/public` had neither when measured. So `deployHub` (old path and new) and `publish hub` refuse every hub; rollout step 5 is blocked until the check looks for report DATA (e.g. `reports/index.json`, a `reports//page.json`) or the hub stops rendering those routes. Measured in the S1 smoke (a scratch corpus; `publish hub` exit 1 after a 119 s build). - `pnpm --filter … exec` (and so `pnpm archilyzer`) reports a stage's exit 2 / 3 / 130 as 1; the editor spawns tsx directly and sees the real code. - The worktree export build gate needs a FULL site's compose in `export/public`; the primary's held a cited site's (no `summaries/`) at the time, and `/` failed to prerender against it. The gate was run over a scratch site composed into the worktree's own `public/` (then cleaned and re-linked). **Left for the other slices.** S2: rewire the three deploy bodies (`stageBodies.ts` `deployStage`) to its deploy stage, import `LiveCheck`/`Probe` from `stamps.ts`, fill `DeployRecord.liveCheck`/`wrangler`. S3: build `PublishStatus` to satisfy `NeedsInput` (job metas → `lastIngestDoneAt` / `changedChannels`, config mtimes), spawn `stageCommand`, the `publish status|now` rows, the `publish-*` job kinds. S4: the editor's actions still call `buildSite` / `deploySite` on export/out. S5: `docker/publish-site.sh` and `build-site.sh` (the swap), and the envVars names above. | commit | what | |---|---| | `ffa95b73` | `dirSignature` moves to `lib/dirSignature.ts`, unchanged; compose imports it | | `0122cd1b` | the stamp files and the publish lock (+ tests) | | `75403df3` | per-target bundles: install, link, archive staging; `buildSiteBundle`/`buildHubBundle`; deploy `outDir`; tree-kill | | `0a7d88ac` | the seven stages, the runner, `inputSig`, the CLI rows and the aliases | | `e040bb3f` | tests: `needs()` per row, argv, bundle install (EXDEV), inputSig, stages over a scratch corpus, tree-kill, CLI | | `3033d9f2` | stamps fall back to the image's commit/branch; `deploy-homepage --to local` → `ARCHILYZER_HOMEPAGE_OUT` | | `89b16716` | `.gitignore`: `/export/out` | **Review fixes** (review `s1-review.md`: SHIP AFTER FIXES; the coordinator's list, plus S5's host-id note): | sev | fix | commit | |---|---|---| | HIGH | `inputSig` also signs the resolved social links, the hub url, `buildArchives` and the footer's sibling sites; one test each | `b88cbe8d` | | MEDIUM | the lock's host is `ARCHILYZER_HOST_ID` (else the hostname); a pid whose process started after the lock's `since` is not its holder (`/proc` start time, injectable; no `/proc` = pid-alive alone); a foreign host's wait line names both hosts and how to clear it | `99a8a939` | | MEDIUM | a run's no-op build no longer holds its deploy: under `builtAfter` the bundle counts as current when it matches the current index (`inputSig` / `hubSig` / `indexStampId`) and that index ran at or after `builtAfter`; a no-op build writes `built.checkedAt`, and `changedChannels` / config changes are measured against `builtCheckedAt` = max(builtAt, checkedAt) — S3's chip uses the same | `d1d19add` | | LOW | a detached HEAD records `branch: null`, and production refuses null like any branch but `main` | `d1d19add` | | SEAM | `ARCHILYZER_BRANCH` / `ARCHILYZER_COMMIT` win over git in the stamps (S4's e2e sets `ARCHILYZER_BRANCH=main`) | `d1d19add` | | MEDIUM | `publish deploy all` skips only private and (to Pages) project-less sites; every other refusal fails the run (exit 1) after the rest | `32f8a37d` | | LOW | an interrupted install (`out.prev`, no `out`) is restored before anything else; `built.json` is removed before the swap, written last | `32f8a37d` | | LOW | a second SIGTERM / Ctrl-C (CLI and stage child) SIGKILLs the detached process groups before exiting (`killChildTreesNow`) | `32f8a37d` | | LOW | the `stage` usage names `--allow-missing-media` | `32f8a37d` | Not taken (the review's other lows, left for S6's list): the read-then-`rm` race in `removeIfUnchanged`; the lock's place beside the checkout's builds dir while a worktree's `export/public` links into the primary's. **Gates** (all from the worktree root): tsc clean at every commit; common **3219 passed** (58 new: stamps 6, stageLock 8, stages 21, bundle 7, stageRun 6, inputSig 4, runChild 2, `_cli` +4); editor unit **142**; `test:scripts` **596 + 3 skipped**; mcp **289**; export unit **116**; homepage unit **23**; `pnpm --filter editor exec next build` ok (402 s, the machine busy); `pnpm --filter export exec next build` ok (59 s, over a scratch full site — see "Found"); `pnpm --filter homepage run build:nodata` ok (44 s); umtool's capped build ok (38 s). e2e (editor suite, `s1-specs.txt`: build, deploy-page, site-publish-preview, sites-homepage, duplicate-shorts, cut-release, ops-api): **52 passed, 0 failed, 2.5 min** — after a first launch died on "Timed out waiting 120000ms from config.webServer" (the linked primary `export/public` held a cited site's compose, so the export dev server 500ed on `summaries/manifest.json`); re-run with a scratch FULL site composed into the worktree's own `public/` (FACTS :3485's "Copy a composed fixture site into it"), cleaned after. **After the review fixes:** tsc clean; common **3229 passed** (10 new: inputSig +1, stageLock +3, stages +2, stageRun +2, bundle +1, runChild +1); e2e (same seven, same seeding) **52 passed, 0 failed, 2.1 min**. Numbers tool: none. Live smoke over a scratch corpus (`s1-smoke-build.sh`): `publish index` (9 s) → `publish build smoke` (85 s, bundle installed by rename, export/out a relative link) → again: no-op → `stage deploy-site … --to local` without the env: refused → `publish deploy smoke --to local`: copied + recorded → `publish hub`: refused by `builtHubProblem` (above) → `build site smoke --nodata`: alias printed, forced rebuild. ### Slice S5, as shipped — the image half: the container can publish, yt-dlp can be substituted, the doctor checks it (2026-10-06) Branch `r18/docker-publish` off `r18/integration` `ce66f2d3`, worktree `~/Projects/r18-docker-publish` (editor 7001, test 7011, export 7010), one Opus implementer. Scratch files `s5-*` in the job's `tmp`. The plan is "Docker (a)–(g)" and "`archilyzer doctor` adds" above. This is the IMAGE HALF: S1 and S2 had not merged, so the doctor's `wrangler`, `publish-lock` and `index-stamp` checks, the `WRANGLER_BIN` / `E2E_LIVE_CHECK` declarations and the publish-stage smoke are the second half (below, "Left"). **What was found before building.** - `ARCHILYZER_CONFIG_DIR` is already honoured by `getPaths()` (`paths.ts:218`), and `FILTER_REPO_PIPX_SPEC` (`git-filter-repo==2.47.0`) already existed in `source.ts:113` — the drift test pins the Dockerfile to it. - `source.ts` reads the repository with `git --git-dir=`, so `ARCHILYZER_SOURCE_REPO` must name a git DIR (the host's common dir), not a work tree — which is what the overlay mounts. - `git filter-repo --version` prints the script's hash (`a40bce548d2c`), never `2.47.0`; the version is read from `pipx list` (`git-filter-repo 2.47.0`). - `env_file: .env` (x-app) already passes every key of `.env` to every app; `x-app-env` sets none of the credentials, so nothing overrides them. Verified by reading the merged `docker compose config`. - Debian's `python3-pycryptodome` installs as `Cryptodome` (yt-dlp tries it first) and bookworm's `python3-websockets` is 10.4 — below what yt-dlp's websockets handler wants, so a from-source yt-dlp runs without that handler (optional; only some live-stream extractors use it). **What it does.** - **Dockerfile.** runtime-base and runtime-cuda (which repeats it) add `python3 python3-venv pipx` and yt-dlp's optional modules; `PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install git-filter-repo==2.47.0`; `ARCHILYZER_IMAGE_YTDLP=/usr/local/bin/yt-dlp` beside `YTDLP_BIN`; `/usr/local/bin/yt-dlp-from-source` → `docker/yt-dlp-from-source.sh` (refuses with a sentence and exit 127 when no `yt_dlp/` package is mounted). `ARCHILYZER_COMMIT` / `ARCHILYZER_BRANCH` build args become ENV as the LAST layer of each of the three targets, so a new commit re-runs one ENV layer. The three targets and the glibc ordering are untouched; wrangler is not installed globally. - **Entrypoint.** Every editor boot prints `yt-dlp: (image|override)` after the optional self-update — `MISSING` (with the first stderr line) when it is not there or does not run; the editor still starts. image vs override compares the two paths after `readlink -f`. `update_ytdlp` skips an override with a two-line warning. `/data/source.git` (or `ARCHILYZER_SOURCE_REPO`) is added to git's `safe.directory` once — only when absent, so a restarted container does not pile up entries. `ARCHILYZER_CONFIG_DIR` is made (700, empty) when absent. The `homepage` service serves `ARCHILYZER_HOMEPAGE_OUT` (`/data/builds/homepage`) when it is non-empty, else the baked `homepage/out` — chosen at boot. `exec "$@"` stays. - **Compose.** `x-app-env` gains `ARCHILYZER_CONFIG_DIR=/data/config/archilyzer` and `ARCHILYZER_HOMEPAGE_OUT`; `x-app.build.args` passes the two build facts from the shell; the `homepage` service mounts `builds`. New overlays: `docker-compose.source.yml` (`ARCHILYZER_SOURCE_HOST_DIR`, default `./.git`, read-only at `/data/source.git`; sets `ARCHILYZER_SOURCE_REPO`) and `docker-compose.ytdlp.yml` (`${YTDLP_SOURCE_HOST_DIR:?…}` read-only at `/opt/yt-dlp-src`; sets `YTDLP_BIN=/usr/local/bin/yt-dlp-from-source`). - **`source.ts`.** `sourceRepoFor(ctx, cwd)` — `ARCHILYZER_SOURCE_REPO` first, then the checkout's common dir — replaces both `commonDir` call sites (the publish and `publishedSourceProblem`). A variable naming a path that is not there is a `SourceRefusal` naming it (never the "no repository" sentence). - **`docker/publish-site.sh`** is a wrapper: usage, the early private refusal, then `publish index`, `publish build `, `publish deploy --to local` (S1's CLI rows, by name). - **`envVars.ts`** (ENVIRONMENT.md regenerated): `CLOUDFLARE_API_TOKEN`, `ARCHILYZER_SOURCE_REPO`, `YTDLP_SOURCE_HOST_DIR`, `YTDLP_SOURCE_DIR`, `YTDLP_AUTO_UPDATE`, `XDG_CONFIG_HOME` (runtime); `ARCHILYZER_HOMEPAGE_OUT`, `ARCHILYZER_IMAGE_YTDLP`, `ARCHILYZER_COMMIT`, `ARCHILYZER_BRANCH`, `ARCHILYZER_SOURCE_HOST_DIR` (docker); the R2/account rows and `ARCHILYZER_CONFIG_DIR` say where they come from in Docker. Exported for S1's stamps: `IMAGE_COMMIT_ENV`, `IMAGE_BRANCH_ENV` and `imageBuildFacts(env)` → `{commit, branch}` (an empty baked value is null). - **Doctor.** New section `downloader` (`yt-dlp`: path, version by exit status, `image` | `host` | `override`; an override that does not run, or one beside `YTDLP_AUTO_UPDATE`, warns); new section `publish` (`cloudflare-auth`: the token SET, or wrangler's login config by PATH, never read; warns only when a site names a `cloudflareProject`; `r2-keys`: only with `archiveStorage.bucket`, names the unset keys; `export-builds`: writable, free ≥ 1.5× the `/out` bundles, a missing dir is a note); `source publish` gains `source-repo` (`ARCHILYZER_SOURCE_REPO` naming nothing FAILS — the publish refuses; no repository is a note, a warning once the operator's files exist; main's commit when it reads) and `config-dir` (exists, entries counted, writable). No value is printed; still read-only. - **RUNNING_IN_DOCKER.md**: "Publish the archive" (stages in the container; `exec`, never `run --rm`; Cloudflare from `.env`; the homepage and its `/source` mount, the config volume), "Substituting yt-dlp", "Two build runners, and the container has one" (replaces the fallback section), the Windows checklist, what is in the image, troubleshooting. `.env.example` names every new variable (not `E2E_LIVE_CHECK`: a test knob does not belong in a real instance's `.env`). **Commits** | Commit | What | |---|---| | `060927fb` | `publish:` `sourceRepoFor` — `ARCHILYZER_SOURCE_REPO` first; a missing path refuses by name; 1 test | | `f0dfa39a` | `docker:` the image (python, pipx, filter-repo, the yt-dlp hook, build facts); entrypoint; compose + two overlays; `publish-site.sh` wrapper; envVars + ENVIRONMENT.md; 2 drift tests in `buildImage.test.ts` | | `e1a3b49b` | `doctor:` `downloader/yt-dlp`, `publish/{cloudflare-auth,r2-keys,export-builds}`, `source publish/{source-repo,config-dir}`; 5 tests | | `90722346` | `docker:` `.env.example` | | `32c16698` | `docker:` the entrypoint makes the config dir | | `ed5b5db8` | `docs:` RUNNING_IN_DOCKER.md | | this one | `plans:` this section; the editor changelog | #### Gates (logs `$T/s5-*.log`) - **tsc** (all workspaces) clean at every commit (91 s at `ed5b5db8`). - **common:** **3,169/3,169** (new: `doctor.test.ts` +5, `buildImage.test.ts` +2, `source.test.ts` +1). **Editor unit:** 142/142. **mcp:** 289/289. **test:scripts:** 595 passed, 1 failed, 3 skipped (599) — `queue-lock.test.mjs` "prints a banner naming the holder while waiting" at a load average of 24, with the editor build running; the file alone afterwards: 11/11. The slice touches nothing under `scripts/`. - **Build:** `pnpm --filter editor exec next build` exit 0, 253 s. - **Image:** `docker buildx build --target runtime` in a builder capped at 8 GB (`--driver-opt memory=8g memory-swap=8g`), `WHISPER_BUILD_JOBS=4`: exit 0 in 280 s from an empty builder cache, 241 s for the rebuild after the doctor commit. `archilyzer:r18smoke` is **1.76 GB** (`docker image inspect .Size`). **Only `--target runtime` was built.** `runtime-vulkan` (trixie apt, the same pipx install) and `runtime-cuda` (ubuntu 24.04: `pipx`, `python3-pycryptodome`, `python3-brotli` from universe) carry the same package list unverified by a build — each to be built once, capped, before the rollout. - **Compose smoke** (`-p r18smoke`, the `channel-with-counts` e2e fixture + `sites/testsite` copied into `$T/s5-corpus` and bind-mounted over the corpus volume, `ARCHILYZER_FETCH_MODEL=none`, `ARCHILYZER_IDLE_BOOT=1`, the editor alone): the boot log shows `yt-dlp: /usr/local/bin/yt-dlp 2026.08.19 (image)`; `exec editor pnpm archilyzer doctor` lists `downloader/yt-dlp` ok (image), `publish/cloudflare-auth` and `export-builds` (notes), `filter-repo` ok (`git filter-repo a40bce548d2c`), `source-repo` and `config-dir` (exit 1 only for the model the smoke skipped); `python3 -c "import yt_dlp"` exit 1 and `yt-dlp-from-source --version` exit 127 with its sentence; `pipx list` → `git-filter-repo 2.47.0`; `ARCHILYZER_COMMIT`/`BRANCH` baked. With `docker-compose.ytdlp.yml` over a two-file `yt_dlp/` package and `YTDLP_AUTO_UPDATE=1`: the entrypoint's skip warning, `yt-dlp: /usr/local/bin/yt-dlp-from-source 2099.01.01.s5-smoke (override)`, the wrapper exit 0, the doctor's `yt-dlp` WARN. `safe.directory` has one entry after a `docker restart`. With `docker-compose.source.yml` over a throwaway repo in `$T`: `source-repo` ok with its main, `rev-parse` as root works, the mount is read-only. The `homepage` service serves the baked build, then the builds volume's after a file lands there and it restarts. `down -v` after. - **e2e:** none for this slice. **Numbers tool:** none. **Publish-stage smoke** (`publish index/build/deploy` in the container): not run — S1's CLI rows are not on this branch; it is the parent's after S1/S2 merge. **Deviations from the plan, one sentence each.** - `ARCHILYZER_HOMEPAGE_OUT` and `ARCHILYZER_SOURCE_HOST_DIR` are new names the plan did not list: the first is where `deploy-homepage --to local` writes (S1 must read it), the second lets a worktree mount the primary's `.git`. - `python3-venv` is installed beside `pipx` (pipx makes a venv); the plan's package list omitted it. - The build facts are ENV in each final target rather than once in runtime-base, so a commit does not invalidate the Vulkan apt layer. - `config-dir` not writable is a note, not a warning: the publish only reads the rules. - The source-repo "homepage policy on" grade waits for S3's `settings.publish.homepage`; today the warning keys off the operator's files existing (the doctor's existing "intends" signal). - `E2E_LIVE_CHECK` and `WRANGLER_BIN` are not declared yet: nothing on this branch reads them, and the envVars test refuses a declaration nothing names (S2 adds the reads). **Left for the second half (after S1/S2 merge).** - Doctor `wrangler` (the binary from `wranglerBin(paths)`, its major against `WRANGLER_MAJOR`), `publish-lock` (a dead pid in `.publish.lock`, via `stageLock.ts`), `index-stamp` (age; sites built from an older stamp, via `stamps.ts`); `export-builds` to read `built.json` `bytes` instead of walking. - `WRANGLER_BIN` / `E2E_LIVE_CHECK` in `envVars.ts` (whichever of S2/S5 lands second). - The publish-stage smoke in the container (rollout's shape: `publish index && publish build && publish deploy --preview smoke`, no token → refused before wrangler, a bogus token → refused by Cloudflare), plus `exec editor pnpm archilyzer source publish --check` over a throwaway repo with throwaway rules copied into `/data/config/archilyzer` (the container's mirror over the read-only, foreign-owned mount, which the doctor's `rev-parse` alone does not prove). - The envVars dedupe at the merges: S2 also declares `CLOUDFLARE_API_TOKEN` and `ARCHILYZER_HOMEPAGE_OUT` (one row per name, `readBy` unioned); S1's `stamps.ts` imports `imageBuildFacts` from `lib/envVars` instead of its own. `cloudflare-auth` grades through S2's `cloudflareCredentialProblem` (which accepts `CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`). - Building `runtime-vulkan` and `runtime-cuda` once (above). **Found and left.** - The image has no gitleaks and no stagit: a source publish from the container skips the secret scan (with its WARNING; the literal audit still runs) and has no history pages. RUNNING_IN_DOCKER.md says so; a pinned gitleaks in the image is a follow-up. - `.env` (now carrying the Cloudflare token and the R2 keys) reaches `site`, `homepage` and `umtool` through the shared `env_file`, as it always did; nothing serves or prints it. An editor-only credentials file is an option, not done. - The shared tool probe (`toolProbe.mjs`) counts any output from a failing `--version` as presence, so the `tools/yt-dlp` row reads `ok` with the wrapper's sentence as its "version" when no checkout is mounted; the new `downloader/yt-dlp` row asks by exit status and is the honest one. Not changed: the probe is shared with `umtool doctor`. - `docker images` reported the previous `archilyzer:local` (6 weeks old) at 7.3 GB; this build is 1.76 GB. Not investigated. **Review fixes** (review `SHIP AFTER FIXES`, no highs; the four asked for now) | Commit | Fix | |---|---| | `ba83cbff` | `docker-compose.source.yml`: long bind syntax, `create_host_path: false` — a missing host `.git` now fails `up` ("bind source path does not exist: …", verified) instead of becoming an empty root-owned dir | | `fb1a1f24` | `YTDLP_AUTO_UPDATE`: the doctor reads it with the entrypoint's exact-match rule (`1`, `true`, `yes`, `on` as written; `TRUE` is off in both), +1 test loop; RUNNING_IN_DOCKER.md states the rule | | `07bc2395` | RUNNING_IN_DOCKER.md: no gitleaks or stagit in the image — the container's source publish skips the secret scan (with its warning) and the history pages; pinned gitleaks a follow-up | | `e9fe6bdd` | the record: only `--target runtime` was built (vulkan and cuda unverified, left for the rollout); the second half's added items; found and left | | `908c7c4a` | **Node 22** (S2's review: the pinned wrangler 4.147.0 has `engines.node >=22.0.0`, so every deploy from a Node 20 image would exit 1): `NODE_IMAGE` and `RUNTIME_IMAGE` `node:22-bookworm-slim` (glibc 2.36, unchanged — the glibc rule holds), the Vulkan overlay `node:22-trixie-slim`, runtime-cuda `NODE_MAJOR=22` on ubuntu 24.04. Two drift tests in `buildImage.test.ts`: one Node major across all of them (the native modules are built once, against the build stage's ABI; proven red with `NODE_MAJOR=20`), and that major ≥ wrangler's `engines.node` floor (skipped here — wrangler is S2's devDependency; S2's worktree has 4.147.0, `>=22.0.0`) | | `fc793037` | RUNNING_IN_DOCKER.md names Node 22 in the image's contents; this table | | `55d779a1` | **The publish lock's host identity** (S1's review: `os.hostname()` in a container is its id, new on every recreate, so a crashed holder's lock would look foreign forever; S1's `stageLock.ts` reads `ARCHILYZER_HOST_ID ?? os.hostname()`): `ARCHILYZER_HOST_ID: archilyzer-editor` on the **editor service's** `environment`, not `x-app-env` — site, homepage and umtool share the builds volume, and a container carrying the same id with its own pid namespace would judge the editor's live lock dead and take it (visible in `docker compose config` either way; checked: only the editor has it). envVars row, no TODO needed: the compose file names it, which the test accepts (`readBy` names `stageLock.ts`, S1's). RUNNING_IN_DOCKER.md: why the id is fixed, that `run --rm` would now carry it with other pids (one more reason for `exec`), and how to clear a foreign-host lock (`rm /data/builds/.export-builds/.publish.lock`, only when nothing is publishing) | | `c238970a` | SETUP.md: Node 22 — Next needs ≥ 20.9, deploying runs the pinned wrangler (≥ 22) | | `65d549e7` | this table | Re-run after the fixes at `07bc2395`: tsc (all workspaces) clean; `doctor`, `buildImage`, `source` and `envVars` tests **61/61** (`$T/s5-fix-tests.log`). Node 22, at `908c7c4a`: `buildImage.test.ts` 5 passed, 1 skipped (the wrangler floor); common tsc clean. `--target runtime` rebuilt in the capped builder, exit 0, 349 s; the image is 1.79 GB. Smoke (`-p r18smoke`, the same fixture, `down -v` after): `node --version` in the container is **v22.23.3**; the boot log's `yt-dlp: /usr/local/bin/yt-dlp 2026.08.19 (image)`; `/api/pulse` 200; the build stage's native modules load in the runtime — `lmdb` opens, writes and reads (`process.versions.modules` 127), `msgpackr-extract`'s binding loads; `archilyzer doctor` reports `node v22.23.3` ok and every S5 check, exit 1 only for the model the smoke skips. vulkan and cuda still unbuilt (above). **Second half** (S1 merged: `r18/integration` `0ce00f76`, a fast-forward of this branch; S2 not yet) | Commit | What | |---|---| | `0df61c8c` | `doctor:` **`publish/publish-lock`** over S1's `stageLock.ts` — free → ok; a live holder on this host → a note; the lock's own `holderIsGone` (dead pid, a different start time, a pid younger than the lock) → stale, with `rm /.publish.lock`; a lock that has not parsed past `LOCK_TORN_GRACE_MS` → torn, the same; another host's (`lockHostId(env)` differs) → named, never judged. Never cleared by the doctor. **`publish/index-stamp`** over S1's `stamps.ts` — id, age, generation; the built targets (sites, `_hub`, `_homepage`) whose `indexStampId` is older, as a warning with `publish build `; no stamp → "update the index first: archilyzer publish index" (a warning once anything is built or configured). **`export-builds`** sums each bundle's `built.json` `bytes` (a bundle no stamp describes is still walked). **`workspace/node`** grades against wrangler's `engines.node` when `common/node_modules/wrangler/package.json` is there (below it: a warning — every deploy refuses), read the way the image's drift test reads it. `stamps.ts`'s local `imageBuildFacts` became a re-export of `lib/envVars`'s (one definition; S1's `stamps.test.ts` 6/6 unchanged). 3 new doctor tests (lock: 5 states; stamp + bytes; node vs the floor) | | this one | `plans:` this table and the smoke; the doctor's changelog bullet names the new checks | Gates at `0df61c8c`: tsc (all workspaces) clean; `doctor`, `buildImage`, `source`, `envVars`, `stamps`, `stageLock` tests **82 passed, 1 skipped** (the wrangler floor — not installed here). **Compose smoke, second half** (`--target runtime` rebuilt from `0df61c8c`, 363 s, 1.79 GB; `-p r18smoke`; the e2e `curated-tags-channel` fixture — 3 videos with VTTs — and one site `s5site` copied into `$T/s5-corpus2`; `docker-compose.source.yml` over a throwaway repo in `$T`; editor + `site`; `down -v` after, nothing left): - `exec editor pnpm archilyzer publish index` → exit 0, 10 s (index +3, stats built, signatures, the stamp). - `publish build s5site` → exit 0, 76 s: "bundle installed at /data/builds/.export-builds/s5site/out (copied across filesystems)", 198 files, 5.3 MB. Again → "fresh — nothing to do", exit 0. - `publish deploy s5site --to local` → exit 0, 4 s, copied into `/data/builds/site`; the `site` service serves it: `corpus.json` names `s5site` (1 channel, 3 videos), `/` 200. - `publish deploy s5site --preview smoke` with no token: **skipped — the credential preflight lands with S2.** S1's deploy body still calls `build.ts`'s `deploySite` (unpinned `pnpm dlx wrangler`), which has no preflight to refuse before wrangler; the bogus-token run is the third round's. - `source publish --check` over the throwaway repo (read-only, host-owned, `safe.directory`) with throwaway scrub/denylist files put in the config volume by `docker compose cp` (mode 600): exit 0, 6 s — "check passed — would publish main 60a126e08086 as 60a126e08086: 12 files … nothing written"; gitleaks skipped with its WARNING and no history pages, as RUNNING_IN_DOCKER.md says. This is the review's open medium: the container's mirror over the `:ro`, foreign-owned mount works. - `doctor` (exit 1 only for the model the smoke skips): `node v22.23.3` ok, `downloader/yt-dlp` ok (image), `cloudflare-auth` note, `export-builds` "1 bundle, 5 MB" (from built.json), `publish-lock` "free", `index-stamp` "…, generation 1; 1 bundle built from it", `filter-repo` ok, `source-repo` ok (main 60a126e08086), `config-dir` "2 entries", `scrub rules` / `denylist` ok (1 each, mode 600 — counted). - `publish status` and `publish now` (RUNNING_IN_DOCKER.md names both) are S3's rows, not on this branch yet: `archilyzer: unknown command "publish status"` here. **Third round** (S2 merged: `r18/integration` `4f3daeea`, a fast-forward of this branch; `pnpm install --frozen-lockfile` brought in the pinned wrangler 4.147.0) | Commit | What | |---|---| | `056dfac9` | `doctor:` **`publish/wrangler`** — `wranglerBin(paths, env)` (the pin, or `WRANGLER_BIN`, named when set): not there → a note (a warning when a site names a project) naming `pnpm install`, a missing override FAILS; not executable → a warning; run with `--version`, whose major must be `WRANGLER_MAJOR` (else a warning), and a binary that does not start (Node below its floor) warns with its first stderr line. **`wrangler --version` writes a debug log under `~/.config/.wrangler/logs` on every run**, so the doctor sends it to a temp dir (`WRANGLER_LOG_PATH`) it removes — the doctor's header says so. **`cloudflare-auth`** grades with `cloudflareCredentialProblem` over `wranglerOAuthConfigFiles` (located, never read) and quotes the deploy's own sentence, so the two cannot disagree; the doctor's own login-path helper is gone. envVars `readBy`: `WRANGLER_BIN` adds `doctor.ts`; `ARCHILYZER_COMMIT`/`BRANCH` name `stageBodies.ts`'s `imageBuildFacts` (the duplicate rows were already resolved at the S2 merge). 1 test (6 states; the log dir is under the OS temp dir and gone after; nothing under HOME) | | this one | `plans:` this table and the smoke; the doctor's changelog bullet names the wrangler check | Gates at `056dfac9`: tsc (all workspaces) clean (167 s). **common: 3,281/3,282** — 1 failed, `controller/fetchPosts.test.ts` "a drain mid-page waits for the page's cursor…", a timing case, run while the image built beside it; the file alone afterwards: 8/8 (S5 touches nothing it imports). **Editor unit:** 142/142. `doctor.test.ts` + `buildImage.test.ts` 35/35 — **the wrangler-floor drift test now runs** (wrangler installed): Node 22 ≥ `>=22.0.0`, green, nothing skipped. **Compose smoke, third round** (`--target runtime` rebuilt from `056dfac9`, 351 s; the image is now **2.00 GB** — wrangler and its workerd in `node_modules`; the same fixture, `s5site` given a `cloudflareProject` so it is deployable; editor + `site`; `down -v` after): - In the container: `node --version` v22.23.3; `common/node_modules/.bin/wrangler --version` and `node common/node_modules/wrangler/bin/wrangler.js --version` both **4.147.0**, exit 0. No OAuth login files (`/root/.wrangler/…`, `/root/.config/.wrangler/config/default.toml` absent), no token. - `publish index` / `build s5site` / `deploy s5site --to local` → exit 0; `deployed.json` written (sha256 `086ca1ee23883d29…`, mtime noted). - `publish deploy s5site --preview smoke`, no credential → **exit 1**, `[deploy] REFUSED — no Cloudflare credentials: set CLOUDFLARE_API_TOKEN in .env (or run \`wrangler login\` on this machine). Nothing was sent to Cloudflare.`; wrangler never started; `deployed.json` byte- and mtime-identical. - The same with `CLOUDFLARE_API_TOKEN=bogus` → exit 1, `deployed.json` identical — but the log ends `[deploy] FAILED — wrangler exited 1.`, **not** the REFUSED sentence: Cloudflare answers a token that is not token-SHAPED with `Invalid request headers [code: 6003]` / `Invalid format for Authorization header [code: 6111]`, which `pagesDeploy.ts`'s `AUTH_FAILURE_RES` does not list. One more request with a well-formed wrong token (40 random characters): Cloudflare says `Invalid access token [code: 9109]` and the log ends on the exact **`[deploy] REFUSED by Cloudflare — the API token was not accepted`**, exit 1, no `deployed.json`. Two requests to Cloudflare in all. Finding for S2's file (not changed here): add codes 6003 and 6111 to the classifier — a mistyped or truncated token in `.env` is the likely real case. - `doctor` with no token: `node` ok "deploys: >= 22.0.0, wrangler 4.147.0"; `cloudflare-auth` WARN, quoting the preflight's sentence, naming `s5site`; `wrangler` ok "/repo/common/node_modules/.bin/wrangler 4.147.0 (the pin in common/package.json)"; `export-builds`, `publish-lock`, `index-stamp` ok. With the token set: `cloudflare-auth` ok "is set (never printed)". The count of wrangler log files under the container's `/root/.config/.wrangler/logs` was 3 before the doctor and 3 after (the deploys wrote those). S5 is complete with this round. Left for the rollout, as recorded above: building `runtime-vulkan` and `runtime-cuda` once. ### Slice S3, as shipped — the publish status, the stage queue and the publish lane (2026-10-06) Branch `r18/publish-lane` off `r18/integration` `0ce00f76` (S1 and S5's image half merged; `1f07ca2f` — S2 and S5 complete — merged in before the gates), worktree `~/Projects/r18-publish-lane` (editor 7201, test 7211, export 7210), one Opus implementer. Scratch files `s3-*` in the job's `tmp`. The plan is "Stages" (the two staleness bullets), "Lane", "Surfaces: One state builder", the CLI's `publish status|now` and "Migration"; S1's "Seams for the other slices" are what it codes against. No S1 file changed. **What it does.** - **One status, one plan — `common/publish/publishPlan.ts` (pure).** `buildPublishStatus(inputs, now)` folds the inputs into `PublishStatus`: per target (each site, `_hub`, `_homepage`) `indexFresh`, `built`, `builtFromCurrentIndex`, the build stage's own `needs()` answer, `buildStale {reason: channels | data | config, changedChannels}`, `codeNewer` (never stale), `deployed`, the policy's `deployKind` and record, `deployedIsBuilt`, `previewUrl`, `liveCheck`, `policy`, `deployProblem`, `next`, `running`, `queued[]`, the newest ended build/deploy job, and four chips (`index | built | deployed | live`, each `{tone, text}`); plus the index (chip, freshness, its jobs), the lane (`due` + `reason`, `blockedReason`, chip) and `plan` — what Publish now would run. Every freshness question is the stage's `needs()` over a `NeedsInput` built here; the status adds only what a stage child cannot see: `changedChannels` (a member channel with an ingest ended after `builtCheckedAt(built)` — so a no-op build clears it; the hub's are the listed sites' members, the homepage's every channel) and the config mtimes. Chip words: "no index yet", "stale: new data since the last index", "never built", "stale: N channels changed (a, b, …)", "stale: data changed", "stale: config changed", "built 12 min ago · code newer", "production · 3 min ago", "production: a newer build is not deployed", "never deployed (private)", and — from the newest ended stage, exit 3 — "waiting for its build" (a deploy that carried `builtAfter`), "waiting for the index" (a build that carried `indexAfter`), else "last deploy refused: precondition not met"; live: "live ok", "live: the edge serves an older build", …. - **`planPublishRun(status, {deploys: policy | none, builds: policy | stale, runStart, exclude, skipIndex})`**: update-index when the index is stale; per site (by id) its build when the stage's `needs()` says stale — changed channels, a signature that no longer matches the index, never built, its config, its bundle — and its deploy where `publish.auto` says (`preview` → `settings.publish.previewBranch`, with the alias URL); then the hub, then the homepage, by `settings.publish.hub|homepage` (the index update in the run moves them when their channels changed). Builds carry `indexAfter = runStart` when the index is in the run, deploys `builtAfter = runStart` when their build is. A policy-off site is never built (`skipped`: "stale, and its policy is off") unless `builds: "stale"` ("Build all stale"); a private or project-less target is never planned as a deploy (skipped with the reason). Never forced. `settings.publish.runner: "docker"` plans one `build-site _all --runner docker`. `views/publishStatus.ts` re-exports it: the view layer's name for it. - **`common/publish/publishState.ts` `readPublishStatus(paths)`** — the one function the /sites panel, `GET /api/ops/publish`, `archilyzer publish status` and the lane call. `readPublishInputs` reads the stamps and bundle problems (S1's `readNeedsInput`), the sites (membership = `site.json` `channels`) and their policies, the config mtimes of the plan's list (for the index tags.json, search-aliases.json, duplicates*.json, every site.json, homepage.json, the settings file, the charts config; for a site its site.json, its tags and aliases and the corpus-wide three; for the hub homepage.json and every site.json; for the homepage homepage.json), the ingest signal per channel, the live publish jobs, the newest ended stage per kind + target, the lane's memory, and the running commit and `main`'s head (git, memoized 30 s). Job metas are read through the registry and the `.jobs` sidecars, each terminal meta once per process. - **`common/publish/publishStages.ts`.** `enqueueStage(paths, req, {background})`: one `runManagedCommand` over S1's `stageCommand` on queue `publish`, spec `{kind: "publish-", slug: target, params: the request}`; a duplicate — the same kind, target and destination queued or running — is refused `{ok: false, info: true, jobId}`. `enqueuePublishRun(paths, plan, {runId})`: the plan in its order under one run id, each request carrying its step's preconditions; returns `{runId, jobs: [{kind, target, jobId, previewUrl?, existing?}], skipped, refused}`. `stageRequestFromSpec` reads a spec back through the stage row's own parser. - **The lane — `common/publish/publishRunner.ts`.** `startPublishRunner` (kind `auto-publish`, queueKey `""`) wakes every `checkEveryMinutes`; a pass is due when there is no stamp, when the index is stale and `now − stamp.builtAt ≥ refreshEveryMinutes`, or when a policy target is left stale and the last pass is at least that old; never while held, in quiet hours or while any publish stage is queued or running. A pass dispatches ONE stage at a time (`background: true`) and awaits its job's end, re-reading the status and re-planning before each next one (so the builds after its index update see the new stamp); a stage it ran is not run again in the pass; a failed index update ends the pass; a failed build drops its deploy; the gate is re-checked between stages — a hold, quiet hours or the lane switched off stop the dispatching, never a stage; a drain finishes the stage in flight and ends the runner `done`; Stop ends the runner at once and leaves a running stage to finish as its own job. `stopPublishRunner` / `drainPublishRunner` / `startPublishRunnerBlockedReason`; `publishLaneState.ts` holds the lane's memory (last check, next check, last pass and its summary). The editor's `instrumentation.ts` starts it below the idle-boot gate. - **Settings and site.json.** `settings.publish {enabled: false, held: false, checkEveryMinutes: 10 [1–1440], refreshEveryMinutes: 360 [0–43200; 0 = whenever stale], quietHours: {start, end} | null, runner: local, previewBranch: "preview" (an invalid name reads as it), hub: off, homepage: off}`; `site.json` `publish: {auto: off | build | preview | production}` — absent = off, only another policy written; a private site reads and is written as `build`; a site with no `cloudflareProject` reads as `build`, and a save of `preview`/`production` without one is refused ("publish.auto "production" deploys the site, and it has no cloudflareProject — …"). SETTINGS.md, settings.json.example and SITE.md regenerated. - **The pipeline lane.** `PIPELINE_LANES = ["publish"]` (`autoQueueTypes.ts`), not in `LANES`; `PauseLane = AutoQueueKind | "publish"`; `isGateHeld` / `withGateHeld` read and write `settings.publish.held`. The editor's lane pause action saves that block for `publish`, and the pause control has its words ("Hold the lane", `pause publishing` / `resume publishing`). - **Jobs.** The seven `publish-*` kinds (labels = the stages', replayable, not drainable) and `auto-publish` (drainable); `build-index`, `build-stats`, `build-export`, `build-deploy`, `build-all`, `build-deploy-all`, `deploy-export` known with NO label (/jobs shows their raw kind, as it always has; `jobs.spec` reads it) beside the six hub/homepage kinds that keep theirs. `isIngestKind`. /jobs reads a stage as `run · `. A queued publish stage at boot is cancelled as `publish` — "server restarted; the publish lane re-derives stages from on-disk state" — never re-queued. Retry re-enqueues a stage from its spec (same run id). - **CLI.** `archilyzer publish status [--json]` (the index, the lane, a row per target with its policy and chips and what is next, then the plan) and `archilyzer publish now` — the plan's stages one at a time IN THE CLI's PROCESS under the publish lock, the index update as `publish index`'s child, as the editor's queue would run them (the CLI has no queue); the rest are tried after a failure; exit 0, or the worst (1 over 3). **Deviations from the plan** (one sentence each): 1. The three modules are `common/publish/{publishState,publishStages,publishRunner}.ts`, not `controller/`, and the pure builder is `publish/publishPlan.ts` re-exported by `views/publishStatus.ts`: `architecture.test.ts` forbids controller → publish and publish → views, and the runner must plan. 2. The runner is started from `instrumentation.ts` beside `startAutoRunnersIfEnabled`, not inside it (dispatch may not import publish); the idle boot leaves it off the same way. 3. "The drainable kinds" became an explicit `isIngestKind` set: every drainable per-channel kind (tested) plus the non-drainable writers of the same text and posts (the auto-download unit, single imports, transcriptions and downloads, the availability checks, the forum import, the feed backfill, the cues sweep); the lane runners' metas stay `running` for days and are not in it. 4. A channel's `snapshot.json` mtime is also an ingest signal: the transcription, digest and backfill lanes' units make no job record at all, and each requests the channel's report regeneration when it settles. 5. A duplicate stage is the same kind, target AND destination: a production deploy behind a preview of the same site is not refused. 6. A pass is also due when a policy target is left stale (a failed stage, a policy just turned on) and the last pass is `refreshEveryMinutes` old — else a fresh index would never retry it. 7. The lane re-plans before each stage instead of enqueueing a plan computed once. 8. Stop does not cancel the stage in flight (it is its own job, with its own Cancel); the plan did not say. 9. Files beyond the slice's list, each a line or a table entry: `common/lib/site.ts` (writeSite's refusal), `common/lib/{settingsDocs,fileSchemaDocs}.ts` (the doc tables), `editor/app/operations/actions.ts` and `editor/app/components/lanes/pauseControl.tsx` (the widened `PauseLane` needs its save and its words). **Exports added to S1's files:** none. **Found, not fixed.** - The settings file is an index input (the plan's list), and the settings file is written by every pause click, priority change and drive auto-pause: each makes the index "stale" — one short-circuited index update per refresh interval, no rebuild (the signatures are unchanged). - The snapshot signal is loose: a report regenerates after any channel job, failed ones included; the cost is a short-circuited index and no-op builds. | commit | what | |---|---| | `8b55064a` | jobs: the seven `publish-*` kinds, `auto-publish`, the label-only kinds; `isIngestKind`; `run · `; boot category `publish` | | `1df0e276` | settings: `settings.publish`, `site.json` `publish.auto`, `PIPELINE_LANES`, the publish gate; SETTINGS.md, SITE.md, example | | `105c2663` | publish: `publishPlan.ts` — the status builder and the planner; `views/publishStatus.ts` | | `3adafcb7` | merge `r18/integration` `1f07ca2f` (S2, S5) | | `0081d1dd` | publish: `readPublishStatus`, `enqueueStage` / `enqueuePublishRun`, the runner, the lane's memory | | `344ffee0` | editor: the runner at boot; Retry for publish stages | | `0527be27` | cli: `publish status [--json]`, `publish now` | **Gates** (all from the worktree root): tsc clean at every commit; common **3339 passed** (57 new: publishPlan 23, publishRunner 10, publishStages 6, publishState 2, publishNow 2, jobKinds 3, jobDetail 3, bootQueuedJobs 1, settingsSchema 2, siteSchema 4, pauseGates 1); editor unit **142**; `test:scripts` **596 + 3 skipped**; mcp **289**; export unit **116**; homepage unit **23**; `pnpm --filter editor exec next build` ok (51 s); `pnpm --filter export exec next build` ok (27 s, over the committed fixture compose linked into the worktree's `export/public` — the primary's holds a reports-only compose); `pnpm --filter homepage run build:nodata` ok (15 s); umtool's capped build ok (19 s, link removed). e2e (editor suite, `s3-specs.txt`: lane-runner, auto-queue, jobs, jobs-filters, settings, sites-crud, site-scope, build, scheduler, operation-settings; the worktree's `export/public` seeded with the fixture compose, cleaned after): **88 passed, 0 failed, 6.2 min**. Numbers tool: none. CLI smoke over a scratch corpus (`s3-smoke.sh`, one site on `build`): `publish status` → "no index yet", plan `update-index _index`, `build-site smoke`; `publish now` → both ran (40 s, the build under `indexAfter`); `publish status` → "fresh", "built just now", nothing to run; `publish now` again → "nothing to do"; the policy switched off → "stale: config changed", "stale, and its policy is off". The scratch compose was removed from `export/public` after. **Left for the other slices.** S4: the /sites Publish panel, `/operations/publish`, `GET|POST /api/ops/publish` read and call the names in "Seams" below; S4's e2e sets policies through `site.json` / `settings.publish`. S5's doctor: the `source-repo` grade can key on `settings.publish.homepage` now. S6: PUBLISH.md (the lane, the policies, `publish status|now`), FACTS. **Seams (the names S4 calls).** `readPublishStatus(paths?, {now?, laneKnown?})` → `PublishStatus` (`publish/publishState.ts`; types from `views/publishStatus.ts`); `planPublishRun(status, {deploys, builds, runStart?})` (Publish now = `{deploys: "policy"}`; Build all stale = `{builds: "stale", deploys: "none"}`); `enqueuePublishRun(paths, plan, {runId?})` and `enqueueStage(paths, req, {background?})` (`publish/publishStages.ts`, with `newPublishRunId`, `PUBLISH_QUEUE`, `stageRequestFromSpec`); the lane: `startPublishRunner(paths?)`, `stopPublishRunner()`, `drainPublishRunner()`, `startPublishRunnerBlockedReason(settings?)` (`publish/publishRunner.ts`), its hold `pauseLaneAction("publish")` / `resumeLaneAction("publish")` (`editor/app/operations/actions.ts`) and `isGateHeld(settings, "publish")`; the policies `sitePublishPolicy(site)`, `sitePublishProblem(site)`, `settings.publish`. ### Slice S4, as shipped — the publish surfaces: /sites Publish panel, /operations/publish, the ops API (2026-10-06) Branch `r18/surfaces` off `r18/integration` `85a38e92` (main `edadc712` merged in first, step 0, as `f2fd11a7`; then main's own red pin fixed, `85a38e92`), built in the integration worktree `~/Projects/r18-integration` (editor 7101, test 7111, export 7110) by the orchestrating Opus session — this session can run git only in its own worktree, so the prepared `r18-publish-surfaces` worktree was not used — with two Opus helpers (the build.ts deletion; the review). Scratch files `s4-*` in the job's `tmp`. The plan is "Surfaces", the step 1 pre-fix and S3's "Seams". **What it does.** - **The index is judged by the settings it reads** (step 1). `indexSettingsSig` (`publish/inputSig.ts`) signs `socialLinks`, `homepageUrl`, `buildArchives`, `archiveStorage`, `social.x.visibility`, `maxTranscriptPageBytes` and the storage locations' roots; update-index records it as `settingsSig` in the stamp and `needs()` says "the settings the index reads changed" when it differs (an older stamp reads as changed, once). The settings file's mtime is no longer an index input: a pause click, a priority change or a drive auto-pause no longer stales the index. The charts config stays an mtime (its own file). - **/sites → Publish** (`sites/components/PublishPanel.tsx`) replaces "Build all sites", the batch panel and the hub's and homepage's build sections: a row per site, then the hub, then the homepage, each with the status's four chips (index | built | deployed | live), its policy and what is next. A site row: **Build**, **Deploy preview** (box "preview branch", starts on `settings.publish.previewBranch`; status "preview problem"), **Deploy production**, **Deploy local** (only with `ARCHILYZER_SITE_OUT`); a private site says it never deploys, a site with no project says so. The hub and homepage rows keep their names ("Build hub", "Deploy hub", "Build homepage", "Deploy homepage", "Deploy after build", an empty preview box = production, testid `homepage-ships`, group "Homepage build" / "Hub build"). Above the rows: **Publish now**, **Build all stale**, the index chip, the lane chip (a link to /operations/publish) and the plan Publish now would run. Each button is one JobLane; the hub's config form stays below as "Hub config". - **The Pool**: **Build index** is the update-index stage (no queue control: it is a publish stage); **Build stats dataset is removed** (the stats are part of the index update); normalize and the archives unchanged. - **A site's Publish tab**: Build & deploy (exact names kept) = the index update when stale, the build `--force`, the production deploy with `builtAfter`; "Build static export" without "Skip data rebuild"; "Deploy to production" / "Deploy preview"; "Last deployed" is this site's `deployed.json` — production, the last preview, the live-check verdict — and "Built" its `built.json`. - **One console per run** (`sites/lib/publishRunStream.ts`): the run's jobs' streams joined in order under `=== (job ) ===`; the verdict is the first job not `done`; a job of the run that ends `cancelled` cancels the jobs after it (the console's Cancel is the run's — else it would wait on a stage queued behind something else). - **The actions** (`sites/lib/publishActions.ts`, `"use server"`, over `publishCore.ts`): `publishNowAction`, `buildAllStaleAction`, `updateIndexAction`, `buildTargetAction`, `deployTargetAction`, `buildAndDeployTargetAction`, and the lane's `start|stop|drainPublishLaneAction`. Every one enqueues a plan through `enqueuePublishRun` under one run id; a manual build or deploy is forced, and a build carries the index update first when the index is not fresh. A deploy is refused before any job in the deploy stage's own words: steps 1–3 of `runDeployStage` moved unchanged into `resolveDeployRequest` (`publish/deployStage.ts`), which the stage and the surfaces both ask ("no build of X in

— archilyzer publish build X" is one sentence). - **/operations/publish** (static route beside `[id]`): the runner pill, Start / Drain / Stop (`Start publish lane` …), the hold (`pause publishing` / `resume publishing`), lane on/held/quiet, a pass due or not and why, last check / next check / last pass / last decision, the runner's job log, the plan a pass would run, and `settings.publish`'s form (`savePublishSettingsAction`: enabled, check/refresh minutes, quiet hours, runner, preview branch — refused with the deploy buttons' sentence —, hub and homepage policies). The page refreshes itself every 5 s. `setLaneHeld` revalidates it and /sites for the publish lane; the operations board links it. - **SiteForm**: "Publish policy" (Off / Build / Preview / Production) writes `site.json` `publish.auto`; `writeSite`'s refusal of a deploying policy with no Pages project is the form's error. A save no longer drops the key (the form rebuilt the site without it). - **ops API**: `POST /api/ops/publish` `{verb: index | build | deploy | hub | homepage | now | stale, …}` → `{ok, runId, jobs: [{target, kind, jobId, previewUrl?, existing?}], skipped, refused}` (+ `jobId` with one job; every request refused → 400), a key a verb does not take is a 400; `GET` → the publish status. The eight old routes are aliases with their bodies and answers (`build-index` ignores `queueKey`, `build-site` ignores `skipData`; `build-deploy` `all` = every deployable site to production; per site the DEPLOY job is the one reported). Shared adapter code in `api/ops/_publish.ts`. `pnpm ops publish` and `pnpm ops get publish`. - **CLI**: `deploy hub` / `deploy homepage` print and run `publish hub|homepage --deploy-only` (new flag) through the deploy stage (S2 review I2). `build.ts` loses `buildSite`, `deploySite`, `buildAll`, `deployHub`, `deployHomepage`, `runDockerBuildAllPhase`, `runDockerDeployAllPhase` and what only they used (`runDeployIntoLog`, `runPagesDeployIntoLog`, `homepageDeployArgs`, the outcome types): 1452 → ~980 lines. - **A stage ends "Done"**: `[stage] : Done — …` / `Done (no-op) — …` (was "done"/"no-op"); the e2e `buildIndex()` helper waits for that line. - **e2e seams**: `EXPORT_NEXT_BIN` (`build.ts nextBuildStep`, ENVIRONMENT.md) runs ` build` in place of `pnpm exec next build` for a site's and the hub's build — the test server points it at `e2e/fixtures/bin/fake-next.mjs`, which copies the composed public dir to export/out (compose runs for real; the export app is never rebuilt beside its dev server); `ARCHILYZER_BRANCH=main`; a dummy `CLOUDFLARE_API_TOKEN` (the preflight; the fake never sends it); the fake wrangler's mode sidecar `/.fake-wrangler-mode.json` `{"authFail": true}`. **Deviations from the plan** (one sentence each): 1. Built on a branch of the integration worktree (`r18/surfaces`), not in `r18-publish-surfaces`: this session could run git only in its own worktree. 2. A manual Build (row, tab, hub, homepage) enqueues the index update first when the index is not fresh — the data phase a build used to run — so "Build & deploy" is two jobs only when the index is fresh. 3. Manual deploys are forced (the plan's "--force (manual buttons only)"); `POST publish {verb: "deploy"}` forces only with `force: true`. 4. `EXPORT_NEXT_BIN` and the fake `next` are new: the plan's publish.spec needed a real build, and a real `next build` of the export app inside the editor's e2e would race its dev server. 5. A cancel of a run cancels its later jobs (publishRunStream) — the plan did not say; without it the hub and homepage rows' consoles never settled. 6. The /sites "Hub" heading is "Hub config" (the panel's row is "Hub"); the homepage's own section is gone (its row is in the panel). 7. GET /api/ops/publish answers `{ok: true, …status}` (the status at the top level, so `lane.held` reads directly). 8. sites-homepage.spec's "run Build index" line is the row's chips now ("no index yet", "update the index first"), as planned. 9. `writeSite` (e2e helper) passes `audience` through. 10. Files beyond the slice's list: `videoChoreCards.test.ts` (main's red pin, on integration), `source.test.ts` (the manifest refusal kept a test), two comments in `builtExport.test.ts`. **Found and fixed on the way.** - main was red: `videoChoreCards.test.ts` pinned every import from `./cards` as a chore card, and main's multi-track merge added `TranscriptTracksReader` there (`85a38e92`, on integration). - The site form dropped `site.json` `publish` on every save (S3 added the key, the form never knew it). - The e2e build stage left the worktree's `export/out` linked to a test bundle that resetData then deleted, and a bare `next build` of the export app failed on it (`5a77682e`: the suite's setup and teardown drop such a link). **Found and left** — in "Follow-ups carried over" above. | commit | what | |---|---| | `f2fd11a7` | (integration) merge `main` `edadc712` — multi-track captions, en-track fallback, Wayback, Odysee/BitChute spacing; the changelog keeps both sides | | `85a38e92` | (integration) main's red pin: the chore-card test leaves the transcript reader out | | `195e54f8` | publish: the index judged by `settingsSig`, not the settings file's mtime (step 1) | | `2b533c6c` | publish: `resolveDeployRequest`; a stage ends "Done" | | `a30581f7` | editor: the /sites Publish panel, the site Publish tab, `POST|GET /api/ops/publish` and the eight aliases | | `e360f7da` | editor: /operations/publish; the site form's publish policy | | `ce20b387` | cli: `deploy hub|homepage` → `publish hub|homepage --deploy-only` | | `4cb8e81b` | ops: `pnpm ops publish`, `pnpm ops get publish` | | `514e9674` | publish: build.ts loses the pre-stage entry points (helper agent) | | `fce11b60` | e2e seams (`EXPORT_NEXT_BIN`, the mode sidecar, `ARCHILYZER_BRANCH`), the moved labels' specs | | `4813bafd` | e2e: publish.spec, publish-lane.spec, ops-api / jobs / duplicate-shorts | | `6882b507` | e2e: alerts past Next's route announcer; the job page's first compile; the changelog fold | | `5a77682e` | e2e: setup / teardown drop a test `export/out` link | | `6a697701` | the review's fixes (below) | **Gates** (worktree root; logs `$T/s4-*.log`, `$T/s4f-*.log`): tsc clean at every commit; common **3394 passed**, **3395** after the review round (9 tests went with the deleted build.ts code; new: settingsSig 2, `EXPORT_NEXT_BIN` 1, the source manifest refusal 1, the CLI flags 2); editor unit **142**; `test:scripts` **599 + 3 skipped**, **600 + 2 skipped** after the round (the ops client's publish test new); mcp **292**; export unit **116**; homepage unit **23**; `pnpm --filter editor exec next build` ok (47 s); `pnpm --filter export exec next build` ok — first FAILED (`stat export/out` ENOENT: the e2e build stage's link left dangling by resetData; fixed by `5a77682e`), then ok over the committed fixture compose linked into the worktree's `export/public`; `pnpm --filter homepage run build:nodata` ok (15 s); umtool's capped build ok (19 s, link removed). e2e (editor suite, `$T/s4-specs.txt`: publish, publish-lane, ops-api, build, deploy-page, site-scope, site-publish-preview, sites-homepage, duplicate-shorts, sites-crud, digest, jobs, lane-runner): first run **104 passed, 4 failed, 9.6 min** — two spec bugs (Next's route announcer is an alert too), `jobs.spec` "tails its log" and `site-scope.spec` "charts is a site's tab" (both pass alone: **5 passed, 0 failed, 46 s**); after the fixes and the review round **109 passed, 0 failed, 8.8 min**. The whole suite runs once at the end of the release (step 4). **Review** (`$T/s4-review.md`, a separate Opus agent, read-only): **SHIP AFTER FIXES**, nine findings, all fixed in `6a697701`: | # | finding | fix | |---|---|---| | 1 | a console's Cancel cancelled only the run's first job — a no-op once it had ended, so a queued production deploy could not be stopped from the console | `cancelPublishRunAction`: every live stage with the console job's run id, newest first; every publish console uses it (`JobLane` takes `cancelAction`) | | 2 | a part another run had queued (`existing`) could be the console's job, be cascaded, and was not waited on | `RunPart.existing`; never the console's job, never cascaded | | 3 | publish-lane.spec's queue holder started its clock before the page compiled | taken after the page is up, 45 s | | 4 | a preview with no branch name fell through to production | refused; `wantedPlan` throws on it | | 5 | `POST publish {build, runner: docker}` skipped the stale index | the index update first, `indexAfter` on the containers' build | | 6 | the hub/homepage verbs dropped a preview on a local deploy | refused with the deploy verb's sentence | | 7 | `build-site` / `build-deploy` `all` lost the top-level `jobId` | the run's last job; comments and the changelog say so | | 8 | `--deploy-only --force` dropped the force; `--deploy --deploy-only` passed | force reaches the deploy; the pair is a usage error | | 9 | one target's throw was the whole request's 500 | that target's refusal | Cleanups taken: `fanOutSiteJobs` deleted; `followRun` refuses an empty run; `enqueueRun` releases its kept branches on a throw; the plan's list keys carry the preview; saving the lane on starts its runner; the foreground/background window noted in `publishStages.ts`. From the S6 drafts' readings, in the same commit: the charts page no longer points at "Build stats dataset"; the source gate's refusal names `archilyzer publish homepage` (only a stage stamps what a deploy ships); `build hub` stays a raw, unstamped build and says so (e2e:2origin's `build:hub`); three stale comments. ### Slice S6, as shipped — the records (2026-10-06) Branch `r18/records` off `r18/integration` `dddbc183` (S4 merged), in the integration worktree, by the orchestrating session with two Opus drafting agents (PUBLISH.md; FACTS.md) writing to scratch only — their drafts were read, checked against the code at the S4 merge and installed here. Docs only, except the describe strings the generated docs come from (`settingsSchema.ts` `buildPipeline` + `maxParallelBuilds` + the runner comment, `siteSchema.ts` `audience`, `envVars.ts` `SITE_ID`). **What it does.** - **PUBLISH.md** rewritten around the stages (1078 lines, from 843; the source-mirror, R2, cost-abuse, previews, reports and MCP sections — ~715 lines — kept): "Publishing is stages" (the stage table, the stamps, what makes the index stale incl. the settings signature, the publish lock with `ARCHILYZER_HOST_ID` and clearing a dead holder's lock, exit codes and the `pnpm --filter … exec` collapse of 2/3/130 to 1, Cancel); "The three ways to drive it" as one table (editor / `pnpm ops publish` / `archilyzer`) with the aliases; "In the runtime container" (`exec`, never `run --rm`); "The publish lane" and the policies; deploy hardening (the pinned wrangler, `--branch`, the main-only production rule, the credential preflight and the Cloudflare refusal sentence, the live check's verdicts, `deployed.json`); tombstones and `no-store`; previews from the bundle; R2 staging under the bundle; "Building every site in containers" as the opt-in docker runner (host only, refused in a container). Every anchor another doc links to is kept. - **AGENTS.md** "The runtime container": `Dockerfile.build` is the opt-in docker runner's; `publish-site.sh` is three stages; stages run in the container through `exec` under the publish lock (`ARCHILYZER_HOST_ID`); the docker runner refuses in a container; no docker-in-docker. - **plans/FACTS.md**: a new `## The publish stages` (the stages, stamps, lock, bundles, `needs()` per stage and who asks it, exit codes — the `pnpm` collapse measured on pnpm 11.26.0 —, the queue, runs and lane, the surfaces incl. the run-wide Cancel, the deploy stage, the hub's bundle check, tombstones, the `publish/` layering, the e2e seams), and 17 one-line "Superseded by release 18" markers after the facts it made stale (the plan's eleven ranges — one, 5352–5357, turned out still true and got its own marker only for `all`'s new `jobId` — and five more found stale: `buildStatsAction`, "wrangler has no fake", `deployExportAction`, the old CLI list and exit codes). - **plans/STATE.md** "Now": release 18 complete on `r18/integration`, not on `main`, nothing live; what is owed, in order; what changes for the operator. - **Generated docs from their sources**: SETTINGS.md and `settings.json.example` (`buildPipeline` is the docker runner's), SITE.md (`audience` names the deploy stage's paths), ENVIRONMENT.md (`SITE_ID`); README.md and SETUP.md say what `pnpm build` runs now. RUNNING_IN_DOCKER.md (S5) and SETUP.md's Node 22 (S5) were already done. - **Changelogs**: editor `[Unreleased]` — "One publish at a time", "`export/out` is now a link" and the aliases folded into "Publishing is stages" (eight r18 leads: the six planned, the Docker yt-dlp, the doctor); export one bullet (withdrawn posts leave an uncached stand-in); homepage one bullet (built and deployed from the image, as a stage). - **This file**: "Open questions, settled" (Q1–Q5), "Rulings settled during the release", the reviews' leftovers under "Follow-ups carried over", and an index of the record's sections (they are in merge order). **Gates** (logs `$T/s6-*.log`): tsc clean; `docs env --check`, `docs files --check`, `settings example --check` clean; the schema doc tests pass; `pnpm --filter editor exec next build` ok; `pnpm --filter homepage run build:nodata` ok; counts-only privacy greps over the slice's diff: the refused identifier suffix 0, home paths 0 in added lines, no `transcripts/` content, no operator reasons. The whole editor suite runs once more in step 4 (S6 touched `common/`). ## Rollout (Steps 1–7 above; "### As it went" is written as the rollout runs.) ### As it went **Step 4 — final gates on `r18/integration` `bf796701`** (S6 merged; logs `$T/fin-*.log`): tsc clean; common **3395**; editor unit **142**; `test:scripts` **600 + 2 skipped**; mcp **292**; export unit **116**; homepage unit **23**; `docs env|files --check`, `settings example --check` clean; `pnpm --filter editor exec next build` ok (47 s); `pnpm --filter export exec next build` ok (25 s, the worktree's `export/public` linked to the committed fixture compose); `pnpm --filter homepage run build:nodata` ok (14 s); umtool's capped build ok (21 s, link removed). **The whole editor suite: 715 passed, 3 failed, 12 skipped, 54.0 min** — `dashboard-answers.spec` "/ and /jobs answer while two large reports regenerate" (`/` in 5839 ms against a 5000 ms budget, another session's server busy beside it), `site-scope.spec` "charts is a site's tab" (its second long-run failure: the picker was used before React hydrated it) and `widget.spec` "act=1 shows a compact needs-work list"; the three alone: **3 passed, 0 failed, 55 s**. The picker race is closed (`SiteScopeSelect` stamps `data-hydrated`, the spec waits for it): `site-scope.spec` **13 passed, 0 failed, 1.2 min**. Counts-only privacy greps over `main...r18/integration` (86 commits): home paths 0 in added lines, no `transcripts/` content; the refused identifier suffix appears once, in this plan's own rules line (present since the plan's first commit). The changelogs gain lines only directly under `[Unreleased]`; no cut landed on `main` meanwhile (`main` = `edadc712`, an ancestor: a fast-forward). **After step 4:** the Eva tags were found published on hasanalyzer and bonnellyzer; `tags/site-scope` (a curated tag's `sites`) merged `fc515d3b` — common 3397, editor 142, `tags.spec` 11 passed. `main` then moved (`f6137a04`: video metadata refresh, posts' slug from their directory, in-page report links) and was merged in, `ff9c6b7d` (the changelog kept both sides): tsc clean, common **3407**, editor unit **146**, scripts **601 + 2 skipped**, mcp **292**, export unit ok, editor build ok, e2e (ops-api, jobs, publish) **38 passed, 0 failed, 2.2 min**. **Step 5 — not done by the implementing session**: it ran git only in its own worktree, so it cannot fast-forward `main` in the primary checkout. Owed, with the rollout, to the operator (`~/reports/release-18/RUNBOOK.html`; scripts `~/reports/release-18/scripts/r18-{build,restart,smoke,publish-now,jeralyzer-preview,hub}.sh`, guarded on `bf796701`). **Wave 0 (2026-10-09) — `main` `e921f82f` merged in again**, `4cffda3f`: six conflicts, both sides kept (`jobDetail` reads a publish stage's run and a fetch-windows batch; `GET_ARG_OPTIONAL` keeps `tags`, `channels` and `publish`; PUBLISH.md keeps the "three ways to drive it" table with main's fetch-windows row and main's two-step reports paragraph; the changelogs release 18 first). `ac5832de`: the jobKinds invariant names fetch-windows as drainable but not ingest (it writes only the clip cache). Gates: tsc clean; common **3465**; editor unit **159**; `test:scripts` **677 + 3 skipped**; mcp **292**; export unit **116**; homepage unit **23**; `docs env|files --check`, `settings example --check` clean; builds ok — editor 119 s, export 60 s (over the committed fixture compose: the primary's `export/public` holds a cited-only compose), homepage `build:nodata` 37 s, umtool capped 67 s. **The whole editor suite on `ac5832de`: 711 passed, 10 failed, 12 skipped, 71 min**, run while the machine sat at load 43 (three implementers' unit suites and a peer render): six `audio-check-scenarios`, two `auto-queue`, `dashboard-answers`' 5 s budget, `undownloaded` "one-click whisper". The four specs alone: **43 passed, 2 failed** (audio-check "happy path" and "mid-stream corruption" — the fixture's corrupt checkpoint landed before any `.good` existed); the audio-check spec alone again: **14 passed, 1 failed** — "happy path", its first test against a cold test server both times, green in the full run. No code on the audio-check path changed on either side of the merge. The rollout scripts are re-guarded on `ac5832de`. Step 5 (fast-forward `main` to `r18/integration`) is still the operator's. **Rollout (2026-10-09).** Step 5: the operator fast-forwarded `main` to `ac5832de`. Step 6.1: the editor build into the live `.next` (82 s, capped) and one restart (`/` 200 in 5 s; 5 queued jobs re-queued, 4 running closed as interrupted; no ZodError). Step 6.2, Publish now with every policy off: the index update alone (+37 ~5207 -0, 10 sites signed); `/` polled 343 times, **0 over 5 s**. Step 6.3: jeralyzer built (615 files, 2.35 GB) and deployed as preview `r18`, live check ok. Step 6.4: the hub to production, ok; 4 withdrawn X channels shipped as tombstones, all 9 probes 200 with `no-store`, plain and cache-busted. Step 5b: the Eva tags scoped to anilyzer (curated tags re-derived 4), hasanalyzer and bonnellyzer rebuilt and deployed to production — neither publishes an `eva-*` tag, anilyzer keeps all three. Production, each live check ok: jeralyzer, rekietalyzer, anilyzer (675 files, 2.87 GB), jasolyzer. The homepage was republished from `main` `49163c4d` (the session-link hotfix) — see the source-mirror records. Owed to the operator: each site's Publish policy and the publish lane (no ops route for either on `main`; release 19 A4 adds the lane), `archilyzer doctor`, `docker buildx rm r18s5-capped`, the vulkan/cuda targets' first build, and pruning the `r18-*` worktrees.