RELEASE
planning/2.8.5/plans/PUBLIC_EXHIBIT_BUILD_PLAN.md
sha256 0b04311a2d2a8dce · 9541 bytes ·
original held in the private archive
# Public Exhibit Build — boundary design + `--public` variant plan
**Status:** BUILT (2026-07-23). The unified exhibit-asset scheme AND the `--public` variant build are
now implemented (`museum/generate.mjs --public` → `museum/publish.mjs` → the self-contained
`museum/public/` staging tree). Owner decided the three open sub-decisions (§4): prototypes PUBLIC,
rooms PUBLIC minus their image grids, output to a `museum/public/` staging subtree. What remains is the
EXPOSE layer (name-substitution + credential scan + the separate public repo + Cloudflare) — the NEXT
P2 step, deliberately NOT done here (§6).
**Home:** `planning/2.8.5/plans/`, next to `CAPTURE_PIPELINE_PLAN.md` — its natural successor.
Archive-only; nothing here is ever pushed public (root `CLAUDE.md`, the one hard rule). Note the
irony this plan is about: THIS repo can never be public; the *public exhibit* is a **separate** tree
built from generated output only.
---
## 0. The ruling (owner, 2026-07-23) — Option B
> Curated exhibits are PUBLIC and self-contained. The raw "capture everything" room galleries
> (429 images / 172 MB) stay a PRIVATE-archive affordance and are NOT in the public build.
This is the curation law made concrete ("capture everything, exhibit a curated subset"). The public
tree carries **what is exhibited**, not all 172 MB. Bundling the galleries wholesale is explicitly
**rejected** — it would double the archive's image storage and dump the raw index onto the public
wall.
---
## 1. THE EXHIBIT-ASSET SCHEME (built — the foundation)
One convention for every image a curated exhibit RENDERS. Implemented in `museum/generate.mjs`
(`bundleExhibitAsset()` header + `EXHIBIT_ASSET_KINDS`):
```
museum/site/assets/<kind>/<name> kind ∈ { intent, reality, bugs } (+ future: gallery)
```
- Referenced with an **IN-SITE relative path** (`assets/<kind>/<name>`), never a
`href(TMP_DIR, …archive-original)` that escapes the served root.
- `<name>` is **PATH-DERIVED and STABLE** — an address only moves if the SOURCE is renamed, never on
a content edit (the "addresses must not move" ruling):
- **intent** — basename of the source mockup (`operations-412.png`)
- **reality** — the capture identity `<game>-<panel>-<width>` (`fnv-operator-412.png`)
- **bugs** — `sha8(sourceRel)-basename` (collision-safe across bugs sharing a basename)
- `bundleExhibitAsset(kind, name, srcAbs)` is the shared direct-copy bundler for kinds whose source is
an archive original (**intent, bugs**). **reality** keeps its own ledger+sha256 cache staging (a
normal build must never launch a browser) but writes to the same `assets/reality/` convention.
- A future **gallery** kind (owner-curated individual gallery picks promoted to the public wall) drops
straight into this scheme — `assets/gallery/<name>` — no new mechanism.
**Why this scheme and not a flat `assets/exhibit/`:** intent and reality already ship at
`assets/intent/` and `assets/reality/`; a flat relocation would MOVE those published addresses. The
scheme formalises the existing per-kind layout instead of breaking it.
---
## 2. THE PUBLIC / PRIVATE BOUNDARY
### PUBLIC (self-contained, served-render-guarded)
- The curated exhibit pages: **`intent-vs-reality.html`, `bugs.html`** — the set named by
`PUBLIC_EXHIBIT_PAGES` in the generator.
- Their in-bundle assets: `assets/intent/`, `assets/reality/`, `assets/bugs/`.
- Shared, self-contained chrome: `assets/robco.css`, the search shards, the lobby, growth, the room
ACCOUNTS (prose), the doc pages (raw bytes, in-site).
### PRIVATE-archive-only (excluded from the public build)
- **Room galleries** — `gallery-*.html` (429 imgs / 172 MB). Each cell is a
`href(TMP_DIR, …planning/…)` embed that resolves only against the raw archive on disk. **Never
bundled.**
- **Every out-of-site "OPEN ORIGINAL / OPEN PROTOTYPE" anchor link** — these point at raw archive
originals (`../../planning/…`, `../../library/…`) that do not exist in a public tree. Present at:
`generate.mjs` prototype cards (`OPEN PROTOTYPE →`), sparse-room holdings (`OPEN →`), and the
standing-tools file list. They are private "open the raw source" affordances.
### What is already TRUE today
The escape breaks **nothing** right now: the private archive is opened as local files, where every
`../../` ref resolves. It is strictly a *future* publication concern — which is exactly why the fix is
designed with the public build, not retrofitted onto the private one.
---
## 3. What is IMPLEMENTED now (bounded, shipped this pass)
1. The unified exhibit-asset scheme (§1) — `bundleExhibitAsset()`, `EXHIBIT_ASSET_KINDS`,
`bugPhotoName()`; intent bundling refactored onto it.
2. **Bug Museum photos bundled** — `bugs/*/record.md` provenance photos now copy into `assets/bugs/`
with path-derived names and render from in-site paths (was `../../planning/…`, which 404'd when
served).
3. **Served-render assertion scoped to the public set** — `assertServedImages()` runs over
`PUBLIC_EXHIBIT_PAGES` (`intent-vs-reality.html`, `bugs.html`), serving the tree over a real
localhost origin and GET-ing every `<img>`. It **passes without the 172 MB** because the private
galleries are not in the set. It deliberately does NOT guard the galleries — guarding them would
(correctly) fail while they escape, which is the whole point of keeping them private.
*Verified:* two consecutive regens byte-identical; internal links 0 broken; served images 8/0 broken
(6 intent-vs-reality + 2 bug photos); no browser launched.
---
## 4. The `--public` variant — BUILT (`museum/publish.mjs`)
`node museum/generate.mjs --public` (re)builds the private site first (so it is current — no stale
race with the last sync), then DERIVES `museum/public/` from it via `derivePublicTree()`:
1. **Copies the private site EXCEPT `gallery-*.html`** (the 20 raw image-grid pages / 172 MB stay
private). Rooms remain as public pages; their `OPEN GALLERY · N images` link is neutralised to a
`.mz-private` span ("held in the private archive"), not a dead link.
2. **Ref-driven transform of every page** (non-script segments only): each `href`/`src` is classified —
in-tree → kept; an archive prototype `.html` → **bundled** into `assets/prototypes/<sha8-basename>`
and rewritten in-site; anything else out-of-tree/excluded → **neutralised** (anchor stripped to a
`.mz-private` span). This is exhaustive by construction, so ZERO out-of-site refs can survive.
3. **Emits `public-manifest.json`** (pages, excluded galleries, asset counts — stable/sorted).
4. **THE GATE — `assertPublicSelfContained()`**: serves the whole `museum/public/` tree over a real
localhost origin and HTTP-GETs EVERY href + src on EVERY page (deduped), asserting **zero** broken /
out-of-site refs. The generalisation of `assertServedImages` to publish scope — the real
self-containment guarantee. On any breakage it throws (public staging NOT swapped).
5. **Atomic swap** into `museum/public/`; **ritual-not-gate** (throws → main()'s catch → exit 0, prior
public tree intact).
**Owner sub-decisions (resolved 2026-07-23):** prototypes **PUBLIC** (28 `.html` / 1.2 MB bundled);
rooms **PUBLIC minus their image grids**; output to a **`museum/public/` staging subtree** (committed;
later populates the separate public repo). The future `assets/gallery/` kind (§1) remains the path for
individually curating gallery images onto the public wall — never un-privating the raw galleries.
*Verified:* excluded 20 galleries; bundled 28 prototypes; neutralised 297 out-of-site anchors;
served-link check over the whole tree = 0 broken; an independent scan finds 0 escaping/broken refs
across 4399 local refs; two `--public` runs byte-identical (reproducible).
---
## 5. NORMAL builds vs `--public`
A normal `node museum/generate.mjs` NEVER touches `museum/public/` — it is a ritual like `--capture`,
refreshed only on `--public`. So a normal build/regen leaves the committed public tree clean, and
`--public` reproduces it byte-identically.
---
## 6. NEXT P2 step — the EXPOSE layer (NOT built here)
The self-contained public TREE is done; exposing it is the next step and is deliberately out of scope
this session. It runs AS A TRANSFORM OVER `museum/public/`, after it is verified self-contained and
before it populates the public repo:
- **Name-substitution** — owner's real name → `zerckzzy`, with a **fail-closed guard**: a post-scan
that ABORTS the expose if the real name (or any configured secret) survives anywhere in the tree.
*Plug-in point:* a text pass in `museum/publish.mjs` `transformPage()` (already isolates non-script
segments) OR a separate pass over `museum/public/` right before the publish push. Marked in the
`publish.mjs` header.
- **Credential / token scanning** — a fail-closed scan of the whole tree for keys/tokens/emails before
any push.
- **The `Robco-Exhibit` public repo + Cloudflare + the verify-private-then-expose sequence** — stand up
the separate public repo (this archive can NEVER be public), push the substituted+scanned tree, wire
hosting.
---
## 7. What this build explicitly did NOT do
- Did **not** bundle the 172 MB galleries (the curation law; Option B).
- Did **not** run the EXPOSE layer (§6) — no name-substitution, no credential scan, no public repo, no
publish. Self-contained tree only.
- Did **not** move existing `assets/intent/` or `assets/reality/` addresses.
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).