commit 3a349be6fcdc1b9c45146943cf75601a3af33814
parent 42dd30007b8f064c73ad06503f3a167f7195c245
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 20:51:24 -0400
plans: slice XP, as shipped — X posts are private; the ruling, the slices row, FACTS, the editor changelog
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 208 insertions(+), 1 deletion(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -20,6 +20,8 @@
- **A form whose save is refused keeps what you typed.** Every editor form put its plain fields back to the stored values when its save was refused — a site's ID rejected, a page size out of range, a slug already taken — so everything typed had to be typed again. A refused save now leaves every field as you left it, beside the reason: **Settings**; a site's form (new and existing); the hub's config on `/sites`; **Cut release**; a channel's form (new and **Configure**), **Rename** and **Delete**; a video's **Delete directory**; **Drive health timing** on `/storage`; the backup config on `/saved-videos`; the sync operation's controls; the **Digest**, **Diarization**, **Speaker attribution** and **Speaker work lane** settings; and the worker list on `/workers`. A save that succeeds behaves as before, with one difference you may notice: a drop-down, and a checkbox or choice that the page tracks as you change it (a cadence, a worker's **Enabled**, a social link's **Keep in header**, a site membership, a site's accent), now shows what was saved. A form's own drop-downs used to go back to what the page had loaded with until a reload, and a second save from the same page sent that old choice again; the others went back until the page next refreshed itself (every 5 seconds by default).
- **A media move no longer starts over a job that is writing into the channel, holds the channel's writers while it runs, and makes its copy match the source before it verifies — so a transcription or a download during a move cannot fail it.** A move that has waited its turn behind other moves now checks again when it starts: if a job is running on the channel, or an auto-queue lane is working on one of its videos, it stops at once and says which ("a transcription of abc123 is running (Transcribe all, job …) — wait for it or cancel it"), with nothing copied — and a job you have just cancelled counts until it has actually stopped ("is stopping … — wait for it to stop"); **Preview** says the same, and the Storage panel's blocked message now names the job too. While a move's marker stands, the channel is held: every lane skips it, and every job that reads or writes its media (single-video transcriptions, downloads and transcodes and the availability checks now included) refuses to start, including one that was already queued when the move began. The rack shows a **media held** chip in the channel's Tier cell and the Storage panel says "Held: its media is moving"; both go when the move finishes or its marker is cleared. The copy is now followed by a pass that makes the destination copy match the source — files the source no longer has are removed from the copy, never from the source — so a file written or deleted during the copy (a transcriber's scratch folder, say) no longer fails the check, and **Resume move** finishes a move whose copy holds such leftovers. Every file removed from a copy is listed in the move's log, and **Preview** says so when a copy from an earlier attempt is already there. If the source keeps changing, the move stops and lists what differs: extra on the destination, missing there, or changed. A new **Reconcile and resume** button beside **Resume move** lists those differences, makes the copy match and finishes the move, so no file has to be deleted by hand. The saved-video store's move does the same matching and the same check before it starts. Needs a rebuild and restart of the editor.
- **Connecting an X account opens your own browser, and the X fetchers can use your everyday browser's X login instead.** **Settings → X account session → Connect X account** used to open Playwright's bundled Chromium with its automation signals on (the "controlled by automated test software" bar, `navigator.webdriver`): Google's sign-in refused it and X's own login form stalled in it. It now opens your Chromium or Chrome when one is installed (`ARCHILYZER_X_BROWSER` names another; Playwright's bundled Chromium otherwise), without those signals. Google's sign-in may still refuse an embedded browser; X's password login is the reliable path. A new **Login source** choice (`social.x.cookieSource` in `settings.json`) says where the X fetchers' login comes from: **Browser login** hands gallery-dl `--cookies-from-browser` with your `cookiesFromBrowser` on every fetch, so the login lasts as long as you stay logged in to x.com in that browser and no window is needed; **Connected profile** is the session broker, as before. Left on **Automatic**, it is the browser login when `cookiesFromBrowser` is set and no profile is connected, and the profile otherwise. **Check** says which source is in use, whether an X login is visible in it and when it was last used (the browser's cookies are read from a private copy, never written; this reads Firefox's, and gallery-dl reads Chromium's itself). Needs a rebuild and restart of the editor.
+- **X posts can be kept off every public site.** **Settings → X account session** has a new choice, **Where X posts appear** (`social.x.visibility` in `settings.json`): **Public**, the default, builds an X channel's posts into every site that has the channel, as before; **Private** leaves every X channel out of every public site's build — its posts, its posts manifest entry and its place in the channel list, `site.json` and `corpus.json` — and builds it only into private sites (below). Nothing on disk changes and fetching goes on. A site already published changes on its next build and deploy, and a public site whose only posts were X posts loses its **Posts** box under **Search in**. Choosing the X login source no longer forgets this choice, and choosing this one keeps the login source.
+- **A site can be private: built for reading on this machine, never deployed and never listed.** A site's settings have a new **Audience** choice (`audience` in `site.json`; only `"private"` is written). A private site is refused by every deploy — **Build & deploy**, **Deploy**, `archilyzer deploy site`, `pnpm ops build-deploy` and `deploy-site`, **Build & deploy all** (which builds it and skips its deploy) and `docker/publish-site.sh` — before anything is uploaded, in a sentence naming the audience; **Build** still builds it. It is left off the homepage, the hub and every other site's footer whatever **List on the Archilyzer homepage and hub** says, it publishes no hub URL, and its `corpus.json` says `"audience": "private"`, so a build of it is refused too if the site is switched back to public before it is rebuilt. Point the MCP at a private site's build to ask about what only it holds.
## [0.11.0] - 2026-09-30
- **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites.
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -165,7 +165,7 @@ Never name the curated field `tags`. Never assume a `tags.json` is the keyword l
| --- | --- | --- |
| A video's visibility | `common/lib/availability.ts` (the `"unlisted"` state, `isUnlisted`), `common/lib/transcripts{,-server}.ts`, `common/components/shareUrl.ts`, `common/controller/buildIndex.ts` | The platform's own "unlisted" (reachable by link, not listed on the channel). |
| The hub's list has loaded | `export/app/components/hub/useHubSites.ts` — `listed` | `/hub-sites.json` has been answered and `/hub-summary.json` has settled. |
-| **A site the family lists** | `site.json` `listed` (`common/lib/siteSchema.ts` — `isListedSite`, `channelsOnlyOnUnlistedSites`) | Absent = listed; `false` keeps the site off the homepage, the hub and the other sites' footers, and out of the public totals. |
+| **A site the family lists** | `site.json` `listed` (`common/lib/siteSchema.ts` — `isListedSite`, `channelsOnlyOnUnlistedSites`) | Absent = listed; `false` keeps the site off the homepage, the hub and the other sites' footers, and out of the public totals. A PRIVATE site (`audience: "private"`, release 17 XP) is never listed, whatever `listed` says. |
A grep for either word finds all three; read the file before assuming which.
@@ -8251,6 +8251,20 @@ phase deletes from the destination.
**The source, not `cookieMode`, governs the X fetchers**: the browser source passes the spec
whatever the mode; the profile source keeps the old order (the jar, else the `"always"`-mode
spec, else a guest run).
+- **Where X posts may appear, `social.x.visibility`** (release 17 slice XP; `"public"` default |
+ `"private"`, kept by `sanitizeSocial` beside `cookieSource`). **saveSettings' merge is one level
+ deep, so a patch `{ social: { x } }` replaces the whole X block**: both X actions
+ (`xSessionActions.ts`) write over the block as it is (`socialXPatch`). The rule is
+ `common/lib/postsVisibility.ts` (`postsVisibleTo`, `publishedMemberSlugs`): while private, an X
+ channel (social, platform `twitter`) is built only into sites with `site.json` `audience:
+ "private"`; a public site leaves the channel out WHOLE (posts are all it holds) — its posts
+ manifest entry, posts tree, transcripts tree, channel list, `site.json` and `corpus.json` entry,
+ and `channel-sites.json`. Applied in `buildIndex`'s per-site loop (the site fingerprint names the
+ `withheld` members) and in `compose-site` (which prunes a previously shipped tree); the shared
+ posts tree and the LMDB posts sub-DB stay corpus-wide. A private site publishes no `hubUrl`
+ (`resolveHubUrl`), its `corpus.json` says `site.audience: "private"`, and every deploy path
+ refuses it or its bundle before any upload (`lib/builtExport.ts` `deployAudienceProblem`; the
+ bulk deploys skip it). Build & deploy all still BUILDS it.
- **gallery-dl 1.32.9** takes `--cookies-from-browser BROWSER[/DOMAIN][+KEYRING][:PROFILE][::CONTAINER]`
(yt-dlp's syntax plus `/DOMAIN`) and reads every browser it supports, Chromium's encrypted store
included, on each run. The spec is passed verbatim (`galleryDlCookieChoice`).
diff --git a/plans/release-17.md b/plans/release-17.md
@@ -263,6 +263,7 @@ one short Transcribe (the hook on a relocated channel), `/storage`, `df`; then n
| **T3** migration + records | `r17/media-tier-migrate` | `common/bin/migrate-media-tier.ts`, `archilyzer.ts` wiring, fixture tests (tmp "platter"), FACTS "A channel's media is tiered", AGENTS.md's six things → seven, SETTINGS.md/CHANNEL.md regen, the release record, changelog | T1, T2 | dry run; resume from each phase; idempotent rerun; `--reclaim`; refusal on a marker; the free-space stop |
| **U1** umtool roots + `out/` | `r17/umtool-media-root` | `paths.mjs` (`MEDIA_ROOT`, `CACHE_DIR`), `lib/report/storage.mjs`, `driver.mjs`, `build-video.mjs:2615`, `export.mjs`, `kinds.mjs`, `umtool doctor`, `umtool storage move-out`, the e2e env | — (∥ T1) | `test:scripts` (+ mover tests), `next-build-trace.test.mjs`, the capped umtool build with the corpus linked, umtool e2e |
| **U2** deliverables switch | `r17/umtool-deliverables` | manifest `storage` field, `deliverableDir`, `cut.mjs:96`, `deliver.mjs:362`, `umtool storage deliverables`, bench "Move deliverables", `umtool check` | U1 | umtool unit + e2e: cut and share through a linked `clips/` |
+| **XP** X posts are private (operator-requested, beside the media tier) | `r17/x-posts-private` | `common/lib/postsVisibility.ts` (new) + test, `settingsSchema.ts` + `social/xCookieSource.ts` (`social.x.visibility`) + SETTINGS.md, `siteSchema.ts` + `site.ts` (`audience`, `isListedSite`, `resolveHubUrl`) + SITE.md, `buildIndex.ts` (the per-site loop only), `bin/compose-site.ts` + an integration test, `lib/corpus.ts`, `lib/builtExport.ts`, `publish/build.ts` + tests, `controller/poolSummary.ts` + test, `docker/publish-site.sh`, `export/app/offline/page.tsx`, editor `settings/{xSessionActions.ts,page.tsx,components/{XSessionSection,XPostsVisibilityControl}.tsx}`, `sites/{actions.ts,components/SiteForm.tsx,lib/{buildAction,deployAction}.ts}`, e2e `x-session`, `sites-crud`, export `x-posts-private` | — (∥ all) | the compose integration test (public vs private site, flip back, an X-only public site); deploy refusals before wrangler and before the upload |
Order: 0a → D0 ∥ T1 ∥ U1 → T2 ∥ U2 → T3 → parent: records, ONE editor rebuild + restart, umtool rebuild +
restart (the restart is the operator's: the permission layer refuses the `0.0.0.0` bind) → the migration
@@ -321,6 +322,196 @@ hand; a dirent `isFile()` filter over a video dir hides it."** The `.relocating.
- The `en` track → 0 cues bug (index prefers `en` over `en-orig`; some `en` VTTs parse to 0 cues).
- A channel export/import **bundle** built on `mediaTier.ts`'s classifier — the slice after this release.
+## Slice XP — the ruling (2026-10-01)
+
+- **Every X post is hidden from the public, for now; the data is kept, and stays readable by the MCP
+ and umtool for the operator's own questions and tasks.** Fetching is not changed by this slice.
+- **A setting, `social.x.visibility`: `"public"` (default) | `"private"`**, beside
+ `social.x.cookieSource`, chosen on `/settings` in the X account session section as "Where X posts
+ appear", with one sentence saying what private means and that sites already published change on
+ their next build and deploy. `"private"`: every X channel's posts (`sourceKind: "social"`,
+ `platform: "twitter"`) are left out of every PUBLIC site build and built only into PRIVATE sites.
+ Nothing on disk changes; flipping back is a rebuild.
+- **A site audience, `site.json` `audience`: `"public"` (default, absent) | `"private"`**, on the
+ site's form with a sentence. A private site is **never deployed** — every deploy path refuses it
+ with a sentence naming the audience, before any upload, where the release 13 W3 wrong-site guard
+ runs; a build-only still works — and **never listed**: no `hubUrl`, in no homepage or hub listing.
+ Its `corpus.json` says `"audience": "private"`.
+- **One predicate, `postsVisibleTo(site, channelConfig, settings)`**, pure and tested in
+ `common/lib`, called from the index build and from compose. The Search in row's Posts toggle keeps
+ working from what the build shipped (a public site whose only posts were X posts has no posts
+ corpus and no Posts toggle) — verified, not special-cased. The MCP needs no change; umtool's report
+ pipeline is checked for where it reads posts.
+- Not in scope: stopping fetches; a per-platform toggle for Bluesky; deleting anything; editing
+ `transcripts/**` (the rollout — a private site holding every channel, the setting flipped, the
+ public sites rebuilt — is the parent's, through the editor's own writers).
+
## Record
+### Slice XP, as shipped — X posts are private (2026-10-01)
+
+Branch `r17/x-posts-private` off `main` `90bd8384`, worktree `~/Projects/r13-lows-export` (editor 5501,
+test 5511, export 5510), one Opus implementer, beside the media-tier slices. Scratch files `XP-*` in the
+job's `tmp`. The ruling is above ("Slice XP — the ruling").
+
+**The listing side is release 14 slice HS's.** HS shipped `site.json` `listed` (absent = listed), the
+one predicate `isListedSite`, and every listing that reads it: the homepage summary,
+`channel-sites.json`, the pooled stats, the hub's `hub-sites.json` (and so its `corpus.json` and
+`llms.txt`), every footer, and the form's **List on the Archilyzer homepage and hub** checkbox. This
+slice adds no listing plumbing of its own: `isListedSite` gains one clause — a private site is never
+listed, whatever `listed` says — and the checkbox stays as it is. What is new is the deploy refusal,
+the private site's empty `hubUrl` and its `corpus.json` word, and "private content is built only into
+private sites".
+
+**What it does.**
+- **`social.x.visibility`: `"public"` (default, absent) | `"private"`**, beside `social.x.cookieSource`
+ (`XSocialSettings` and `sanitizeSocial` in `common/social/xCookieSource.ts`, the social block's home;
+ its doc in `settingsSchema.ts`; SETTINGS.md regenerated). On `/settings`, at the foot of the X account
+ session section, **Where X posts appear** (`XPostsVisibilityControl.tsx`): "Public — every site that
+ has the channel" | "Private — private sites only", with: "Private leaves every X channel and its posts
+ out of every public site and builds them only into sites whose audience is private, which are never
+ deployed; nothing is deleted and fetching goes on. Sites already published change on their next build
+ and deploy." Written by `setXPostsVisibilityAction` through `saveSettings`; "public" is written as no
+ key.
+- **`site.json` `audience`: `"public"` (default, absent) | `"private"`** (`siteSchema.ts`, only
+ `"private"` written, `isPrivateSite` the one predicate; SITE.md regenerated). The site form has an
+ **Audience** select with a sentence; `saveSiteAction` keeps only `"private"`.
+- **The rule, `common/lib/postsVisibility.ts`** (pure, tested): `postsVisibleTo(site, config, settings)`
+ — an X channel (`sourceKind: "social"`, `platform: "twitter"`) goes only to a private site while the
+ setting is private; every other channel is untouched — and `publishedMemberSlugs`, a site's members
+ narrowed by it. **A public site leaves the X channel out whole**: posts are all a social channel holds,
+ so without them it would be an empty checkbox and a name in `corpus.json`. Narrowed by the same call in
+ `buildIndex`'s **per-site loop** (the summaries, subs, posts and digests manifests; the site
+ fingerprint gains `withheld`, only when non-empty, so flipping the setting or the audience rebuilds
+ the site and nothing else moves) and in `compose-site` (every shared tree it copies, so a tree a
+ public site shipped before is pruned). The shared posts tree and the LMDB posts sub-DB stay
+ corpus-wide. `channel-sites.json` maps a channel only to the sites whose build carries it
+ (`channelSitesOf` takes the narrowing) and the export's `/offline` page lists the same members.
+- **A private build says so and belongs under no hub**: `corpus.json`'s `site.audience` is `"private"`;
+ `resolveHubUrl` gives a private site no `hubUrl` (absent from its `site.json` and `corpus.json`).
+- **Never deployed.** `lib/builtExport.ts`: `siteDeployProblem(site)` — `Site "x" is private (audience:
+ private): it is built for reading on this machine and is never deployed. Build it without deploying,
+ or set its audience to public on its Settings tab` — `builtAudienceProblem(outDir)` (a bundle whose
+ `corpus.json` says private, so a site switched back to public cannot ship its private build) and
+ `deployAudienceProblem`, both. Asked first, before any upload, where release 13 W3's
+ `builtBundleProblem` is asked: `runDeployIntoLog` (the last word before wrangler),
+ `runDockerDeployAllPhase` (skipped with the sentence, not failed, so Build & deploy all is not red
+ while a private site exists — the site is still built), `deploySite` (`archilyzer deploy site`, before
+ the R2 upload), the editor's `deployExportAction` and `buildAndDeployAction` (before a job exists;
+ Build & deploy asks again before its upload), the host Build & deploy all fallback
+ (`basicBuildAndDeployAll`, skipped before the upload), and `docker/publish-site.sh` (before building,
+ from `site.json`, and over the built `corpus.json` before publishing). The ops routes `build-deploy`
+ and `deploy-site` answer the actions' sentence (400). **Build** still builds a private site.
+- **The Search in row's Posts toggle**: no code changed. `hasPostsCorpus` is "the site posts manifest
+ lists a channel", so a public site whose only posts were X posts ships `channels: []` and no Posts
+ box — pinned by the integration test (no `postScheme` either) and the export spec.
+
+**Where the rule lives — one deviation.** The prompt named the social-channel branch of `scanSource`
+(`buildIndex.ts:333-341`). That branch is corpus-wide: it feeds the shared posts tree that every site,
+and the MCP over a private build, read, so it must keep building X posts. The rule sits in the per-site
+loop (`buildIndex.ts` ~1943 and the fingerprint), a different hunk from T1's `scanSource` guard.
+
+**Found on the way, fixed here.**
+- **`saveSettings` merges one level deep, so a patch `{ social: { x } }` replaced the whole X block**:
+ choosing a login source would have erased the visibility, and the reverse. Both X actions now write
+ over the block as it is (`socialXPatch`); the e2e case pins both directions.
+- **compose-site trusted its per-site cache after ANOTHER site's compose.** `public/` is one directory
+ every site composes into in turn (the basic build); the cache is per site, and a stage whose source
+ had not changed was skipped. So composing site B, then site A again with no new data, shipped B's
+ summaries — its whole channel list — as A's: a public site composed after a private one holding every
+ channel would have listed the private site's channels. The site's own stages (summaries, stats,
+ duplicates) are now trusted only when `public/site.json` names the site; `site.json` is cleared at
+ the start of a compose and written at its end, so a compose cut short leaves nothing to trust. The
+ per-channel trees keep their signatures (copies of the shared trees, the same bytes whichever site
+ copied them). Docker builds have a per-site `public/` and were not affected.
+- **compose never ships a posts tree the site's posts manifest does not list** (the index build's
+ word), so a channel config compose fails to read — read as visible — does not ship X posts.
+
+**The MCP and umtool.**
+- **The MCP needs no change**: it reads a composed export (`mcp/src/sources.ts`, `--local <dir>` |
+ `TRANSCRIPT_LOCAL_DIR`). A private site is composed into a directory of its own, without touching
+ `export/public`, from the checkout root:
+ ```sh
+ pnpm archilyzer index # or any editor build: the index build writes the per-site manifests
+ BUILD_ARCHIVES=0 EXPORT_PUBLIC_DIR="$HOME/archives/<private-id>" \
+ EXPORT_INDEX_DIR="$PWD/export/.export-index" pnpm archilyzer compose site <private-id>
+ claude mcp add archilyzer -- pnpm --silent -C "$PWD" archilyzer mcp --local "$HOME/archives/<private-id>"
+ ```
+ (`EXPORT_PUBLIC_DIR` must be absolute — the command runs in `common/`; the compose cache lands beside
+ it, in `$HOME/archives/.compose-cache/`.) The site's editor **Build** works too: it composes into
+ `export/public` and builds into `export/out`. **The current registration, `--local
+ <checkout>/export/public`, reads whatever site was composed there last**: after a public site's build
+ it has no X posts, after the private site's it has them.
+- **umtool is unaffected**: the report pipeline's posts (the deck's posts room) are carried whole in the
+ report manifest — `posts[]` with `platform`, `date`, `text`, `url` (`validatePosts`,
+ `umtool/report-to-video/deck.mjs`), "added by editing the manifest" — and its cues come from the local
+ corpus or the archive `provenance.siteOrigin` names (videos only). It reads no public build's posts. A
+ sweep that looks posts up for a manifest goes through the MCP, pointed at the private build as above.
+
+**Commits**
+
+| Commit | What |
+|---|---|
+| `3ea76853` | `common:` `postsVisibility.ts` + test; `social.x.visibility`; `site.json` `audience` (`isListedSite`, `resolveHubUrl`); the per-site loop and compose narrowed; `corpus.json` `audience`; the deploy refusals (`builtExport`, `publish/build`) + tests; `channel-sites.json`; `/offline`; `publish-site.sh`; SETTINGS.md, SITE.md; the compose integration test |
+| `0195d52a` | `editor:` Where X posts appear; the X block written whole; the Audience select; the deploy actions refuse a private site |
+| `75ccbef3` | `editor(e2e), export(e2e):` `x-session` and `sites-crud` cases; `export/e2e/x-posts-private.spec.ts` |
+| `30c14aa0` | `common:` compose never ships a posts tree the index withheld; the cache trusted only over the site's own last compose |
+| `63002de3` | `common:` the per-channel trees keep their signatures across sites |
+| this commit | `plans:` this section, the ruling, the slices row; FACTS; the editor changelog |
+
+#### Gates (logs `$T/XP-*.log`)
+
+- **tsc** (all workspaces) clean before every commit; last at `63002de3`'s tree (`XP-tsc5.log`).
+- **common:** **2,499/2,499** at `63002de3`, 161 s (`main`'s count + the new `postsVisibility.test.ts`
+ 7, `compose-site.postsVisibility.test.ts` 4, `build.test.ts` +3, `poolSummary.test.ts` +1); 2,498 at
+ `75ccbef3`. `archilyzer docs files --check` and `settings example --check` exit 0. **Editor unit:**
+ 109/109. **Export unit:** 98/98. **Homepage unit:** 23/23. **mcp:** 271/271 (no change there).
+- **test:scripts:** 367 passed, 1 failed, 2 skipped of 370 (`XP-scripts2.log`; the first run had 2
+ failed). The failures are `scripts/queue-lock.test.mjs`'s "prints a banner naming the holder while
+ waiting" (and once "serves waiters in arrival order"): timing cases (200 ms and 150 ms staggers) run
+ at a load average of 23–30 while other suites held the e2e queue; alone, the banner case still fails
+ under that load (10/11). This slice does not touch `scripts/` (`git diff 90bd8384 -- scripts/` is
+ empty).
+- **Builds** at `75ccbef3` (the later commits touch only `compose-site`, a bin no Next app bundles): the
+ capped editor build with the corpus linked (`ln -sT`, `systemd-run --scope -p MemoryMax=6G`, the link
+ removed after) exit 0, 172 s; `pnpm --filter export exec next build` exit 0, 83 s (the `export/public`
+ links made, 0 dangling); `pnpm --filter homepage run build:nodata` exit 0, 47 s.
+- **Numbers tool:** none. **Privacy gate:** `git diff main --name-only | xargs grep -lc …` names one
+ file, `plans/FACTS.md`, whose 3 matches are all on `main` already; 0 in this slice's added lines.
+
+ | Run | At | Specs | Result |
+ |---|---|---|---|
+ | 1 (editor) | `75ccbef3` | `sites-crud`, `settings`, `x-session`, `forms-keep-input`, `deploy-page`, `site-publish-preview`, `sites-homepage` | **62 passed**, 0 failed, 4.0 min (after 35 min in the queue) |
+ | 2 (export) | `63002de3` | `x-posts-private` (new, 2), `search-in`, `posts-search` | **20 passed**, 0 failed, 3.0 min (after 7 min in the queue) |
+
+ New cases: `x-session` "where X posts appear persists, beside the login source and without it" (the
+ whole-block write both ways); `sites-crud` "a private site saves its audience, and every deploy
+ refuses it before any job" (the form, the file, `build-deploy` and `deploy-site` answering the
+ sentence with 400, back to public with the key gone); export `x-posts-private` — the posts manifest a
+ public site built with X private ships (no channel: no Posts box, no X post for "kappa") and the one a
+ private site ships (the Posts box, both X posts). **The export suite's data is route-mocked, never
+ built**, so the build rule itself is proved by `common/bin/compose-site.postsVisibility.test.ts`
+ through the real `buildIndex` and compose: a public and a private site over one video, one X and one
+ Bluesky channel; the flip back; a public site whose only posts were X posts (empty manifest, no
+ `postScheme`); a config compose cannot read; a public site composed after the private one.
+
+#### Found and left
+
+- **An X channel's manifest-only transcripts tree** (pageCount 0) would still be copied into a public
+ site when compose fails to read the channel's config: a directory named for the channel, holding no
+ content. The posts tree, the posts manifest, the channel list and `corpus.json` follow the index build
+ and do not carry it.
+- The `/sites` list does not mark a private site; its form and every deploy refusal do.
+- `queue-lock.test.mjs`'s two timing cases failed under the machine's load (below); not this slice's file.
+
+#### Decisions the operator could overturn
+
+| What I did | The alternative |
+|---|---|
+| A public site leaves an X channel out WHOLE (posts, posts manifest, channel list, `site.json`, `corpus.json`, `channel-sites.json`) | Keep the channel listed with no posts: an empty checkbox and a name in `corpus.json` |
+| Build & deploy all builds a private site and SKIPS its deploy, with the sentence | Fail its deploy: the run goes red every time while a private site exists |
+| A bundle whose `corpus.json` says private is refused even when the site is public now | Trust the site's current audience only (a stale private build could ship) |
+| `docker/publish-site.sh` refuses a private site (the `site` service is the host's public face) | Let it publish locally behind Caddy |
+| `social.x.visibility` lives in `xCookieSource.ts` with the rest of the social block | A module of its own |
+
## Rollout