--- contract_version: "1.0" module: "booth.manifest" purpose: "A booth that says what it IS and who posted it. Today the index card shows a name, an item count and a countdown -- nothing about provenance or purpose -- so an agent that wants the operator to look at something has no way to make the booth say so, and posts a URL to the link board instead. That is job 5 (`Announce`), the job nobody named, and its absence is the measured cause of 145 dead link rows (69% of the board pointing at booths that no longer exist). This unit gives job 5 a home: each booth carries `.booth.json` -- `{handle, title, why, created}`, written by the CLI from `$ALTHING_HANDLE` -- and the index card and the booth page header render it. Enforcing the link rule WITHOUT giving job 5 a home first just makes it homeless; this is the home." depends_on: - "booth.items (the dotfile skip in `booth_items` -- `.booth.json` is excluded from tiles, counts and zips by the EXISTING `p.name.startswith('.')` rule at items.py:182, exactly as `.marks.json` is. No new exclusion rule is added or needed. Verified, not assumed: `test_a_manifest_is_not_an_item` asserts it.)" - "booth.marks (the `_write_raw` shape only -- temp file + os.replace, INV-5. Copied as a pattern, NOT imported: manifest.py must not depend on marks.py, because the CLI imports each module on its own.)" language: "python" complexity: "low" estimated_loc: 150 confidence: 0.85 used_by: - "booth.app.list_booths (the index card gains `manifest` -- one file read per booth, alongside the `marks_for` read already there)" - "booth.app.booth_view (the booth page header gains the same provenance line; a booth URL handed to the operator lands HERE, not on the index, and job 5 is literally 'operator, look at this')" - "booth.app.upload (a pickup booth announces itself as the Booth's own)" - "scripts/booth (`new` and `add` gain `--why` / `--title`; `link` announces the standing board)" touches: - "booth/manifest.py (new -- the record, the write, the lenient read)" - "booth/app.py (list_booths gains one key; booth_view gains one key; the /upload path writes a manifest and adds it to the `used` dedupe set at app.py:1091)" - "booth/templates/index.html (the provenance line on both lanes' cards -- kept AND ephemeral, or the kept lane silently keeps the old defect)" - "booth/templates/booth.html (the same line in the boothhead sub)" - "booth/templates/base.html (the .prov-* CSS)" - "scripts/booth (`new` / `add` flag parse; `link` board announcement; usage string; the header doc block)" - "tests/test_manifest.py (new)" - "tests/test_marks.py (test_stdlib_only's parametrize list gains `manifest`)" assumptions: - "THE MANIFEST IS A DOTFILE, and that is the whole integration story. `booth_items` skips `name.startswith('.')` (items.py:182), `zip_booth` skips it (app.py:351), and the legacy ask scan skips it (marks.py:656). So `.booth.json` costs nothing in item counts, galleries, zips or migration, and needs no new exclusion anywhere. This is the same reason `.marks.json` needed none. Settled -- do not re-derive it." - "WRITING A MANIFEST IS ACTIVITY. `.booth.json` is a dotfile but NOT a `.lock` dotfile, so `_newest_mtime` counts it (app.py:192 excludes only `..lock`). Creating or re-announcing a booth resets its TTL, which is correct: both are somebody touching it. The lock exemption exists for machinery that a READ path creates; this is a deliberate write." - "THE READ IS LENIENT AND THE FAILURE IS VISIBLE. `list_booths` reads every booth on every index load, so a manifest that cannot be parsed must never raise -- that is the v0.2.2 lesson, learned when a poisoned `.marks.json` returned 500 for `/` and `/healthz` across all 25 booths. `read_manifest` returns None for absent and a `Manifest` carrying `error` for damaged, and the card distinguishes them (`unannounced` vs `unreadable`). Silently treating damaged as absent would hide the one case somebody has to fix." - "THE WRITE IS ATOMIC (INV-5). Temp file + os.replace, because the CLI writes it in one process while the browser reads it in another. The pattern is copied from `marks._write_raw` rather than imported: `scripts/booth` imports each module directly under the system python3, and a cross-import between two stdlib-only modules is a second way for INV-1 to break." - "`booth/manifest.py` IS STDLIB-ONLY and joins the INV-1 list. `scripts/booth` imports it through a `python3 -c` heredoc with no venv, exactly as it imports `marks`, `asks` and `links`. `test_stdlib_only` is parametrized and gains `manifest`; that test is the only thing standing between a casual third-party import and `booth new` breaking on every fleet host." - "A MISSING MANIFEST IS NORMAL, NOT AN ERROR. All 26 live booths have none, and `rsync -a ./out/ nh3-dev:booth-data/my-run/` -- the documented path for every host that is not nh3-dev -- never runs the CLI at all, so unannounced booths keep arriving after this lands. The card marks them quietly and nothing refuses to render, expire, zip or sweep." - "THE BOOTH ANNOUNCES ITS OWN BOOTHS rather than exempting them. A pickup booth and the standing link board are created BY the service, so they are written with `handle: booth` -- which is true, not manufactured. The alternative was a pile of exemptions from the unannounced marker; this way there is one rule (a booth with no manifest is unannounced) and no special cases. `handle` therefore names an agent handle OR the service, and the field's docstring says so." - "NOTHING NEW IS ORDERED, so invariant 6 is discharged by having no list. The manifest is one flat record per booth. The index keeps its stated rule -- kept lane first, then ephemeral newest-first by `_newest_mtime` -- and U5 does NOT add a second ordering keyed on `created` (operator, 2026-09-22). A what-landed feed ordered by announcement time is a genuinely different surface: it needs its own stated rule, it competes with the existing order for what 'the third one' means, and it has nothing to sort the 26 manifest-less booths by. Parked for v1.1." open_questions: - "Whether `why` should also reach the zip manifest or a `booth ls` column. Both are one-liners over the same record and neither is on the v1 path; deferred rather than designed." - "Whether a booth should be able to RE-announce (a second `booth new` on an existing name currently rewrites the manifest and resets `created`). Specified below as: re-announcing updates `title`/`why` and PRESERVES the original `created`, because `created` is when the booth appeared and a second announcement is not a second appearance." --- # U5 — self-announcing booths ## The defect, stated precisely The index card is the only thing an agent can put in front of the operator, and it carries no information the agent chose. Name, item count, countdown, a thumbnail. Everything about *why this exists* has to travel some other way. So it travelled some other way. `booth link` exists because a session with something to show had no way to make the booth itself say "look at this", and the link board absorbed job 5 until **145 of its 210 rows (69%) pointed at booths that had already been swept**. The rot is not a link-board bug. The board was doing a job it was never shaped for, because the shaped thing did not exist. The lesson the measurement carries, and the reason this unit comes before any link-board enforcement: **enforcing the link rule without giving job 5 a home just makes it homeless.** ## The record ```python @dataclass(frozen=True) class Manifest: handle: str # $ALTHING_HANDLE, or "booth" for a booth the service made title: str # display name; falls back to the directory name why: str # ONE line: what the operator is looking at and why created: str # ISO-8601 with offset, from the first announcement error: str | None # set when the stored bytes could not be read ``` `.booth.json` on disk is the same four fields, no `error` — that one is a read-time verdict, not stored state. ## Signatures ```python MANIFEST_FILE = ".booth.json" HANDLE_MAX, TITLE_MAX, WHY_MAX = 64, 120, 200 def read_manifest(booth: Path) -> Manifest | None: """This booth's announcement, or None if it never made one. LENIENT, and never raises. `list_booths` calls this once per booth on every index page load, so a damaged file must cost that booth's provenance and nothing else — the same posture `marks_for` takes, for the reason v0.2.2 made expensive: a read that can raise, called in a loop over every booth, is a service-wide outage wearing a single-booth bug's clothes. Absent -> None. Present but unparseable, or not an object, or missing `handle` -> a Manifest carrying `error`, so the card can say `unreadable` rather than quietly showing the same thing as a booth that never announced. """ def write_manifest(booth: Path, handle: str, *, title: str = "", why: str = "") -> Manifest: """Announce a booth. Atomic (INV-5): temp file + os.replace. Re-announcing an existing booth updates `title` and `why` and PRESERVES the original `created` — `created` is when the booth appeared, and saying something more about it later is not a second appearance. A `created` that cannot be read back is replaced by now(). Empty `title` stores the booth's directory name. Every field is stripped of newlines and truncated: a `why` is one line by construction, not by convention, because it renders inside a card's sub-line. """ ``` ## What renders One line, on both surfaces, driven by the same record: | state | index card / booth header | |---|---| | announced, with a why | `booth-dev · pick the winning denoiser` | | announced, no why | `booth-dev` | | no manifest | `unannounced` (muted) | | damaged manifest | `unreadable` (muted) | **Both index lanes get it.** The kept lane renders first and is a separate block in `index.html`; patching only the ephemeral lane would leave the 15 kept booths — the durable, most-looked-at ones — with exactly the defect this closes. This is the `blurtoggle` lesson (three item branches, one macro) applied to two lanes. **The booth page header gets it too**, and that is deliberate scope, not creep: a booth URL handed to the operator lands on the booth page, never on the index. Job 5 is "operator, look at this", and the page he actually opens is where the answer has to be. ## The CLI surface Operator decision, 2026-09-22 — flags on the existing verbs, not a second verb: ```sh booth new r18-ab --why "pick the winning denoiser" booth add r18-ab out/*.png --why "second pass, sharper" --title "R18 A/B" booth new scratch # still legal — handle + created, no why ``` `handle` comes from `$ALTHING_HANDLE`, falling back to `$BOOTH_SOURCE` then `hostname -s` — the same resolution `booth link` already uses for its rows, so provenance means the same thing on the board and on the card. **Nothing existing breaks.** A bare `booth new x` / `booth add x f.png` keeps working and gains a manifest with no `why`; the flags are optional and order-independent after the positional arguments. The alternative — a separate `booth announce` verb — was rejected because a second step is the step that gets forgotten, which is the 69% rot's own mechanism. ## Scope — the blast-radius pass Graphify + grep, both run, because neither is sufficient alone (graphify is blind to function-local and DI-injected imports; grep misses transitive reach). **Every site that creates a booth directory:** | site | gets a manifest? | |---|---| | `scripts/booth new` (line 97) | yes — `$ALTHING_HANDLE` | | `scripts/booth add` (line 103) | yes — `$ALTHING_HANDLE` | | `scripts/booth link` (line 178) | yes — `handle: booth`, the standing board | | `app.upload` (app.py:1087) | yes — `handle: booth`, a pickup booth | | `marks._Locked.__enter__` (marks.py:267) | **no** — `mkdir(exist_ok=True)` on the write path; a mark written to a booth that does not exist is not an announcement, and manifest.py must not be imported by marks.py (INV-1 cross-import) | | `rsync` from another host | **no** — no CLI runs; this is why `unannounced` exists | **Every reader of a booth's facts:** `list_booths` (app.py:251) and `booth_view` — confirmed by `graphify explain list_booths` (15 edges, 4 test consumers) and by grep for `data_dir.iterdir` (two sites, both in app.py, both enumerating booths for exactly these two surfaces). **Sites that already exclude the new file and need no change**, each verified rather than assumed: `items.booth_items` (items.py:182), `app.zip_booth` (app.py:351), `marks.import_legacy_asks` (marks.py:656). **One site the first draft of this contract got WRONG, corrected by the seam review** (SR-1, below): `app.py:1091`'s `used: set = {UPLOAD_MARKER}` — the upload path's filename dedupe set — does **not** need to gain `MANIFEST_FILE`. ## Seam review — what the real sibling surfaces said Caller-side pass against the actual modules, not against their prose. Run after the cold contract panel was dispatched and before any code. **SR-1 — the upload-collision change is unnecessary, and so is the one already there.** `safe_upload_name` (app.py) does `base = base.lstrip(".")` with the comment "a leading dot would hide the file from every listing", so an uploaded file can never be named `.booth.json` — or `.uploaded`, which means the existing `UPLOAD_MARKER` entry in that set has never been able to matter either. Adding `MANIFEST_FILE` alongside it is consistency with a redundant guard, not a fix for a reachable collision. Do it or don't; what the contract may not do is claim it prevents something. **This is the exact class the seam review exists for: a scope item the contract asserted from its own reasoning and the sibling's real surface refutes.** **SR-2 — the atomic-write pattern transfers cleanly to a dotfile, verified not assumed.** `marks._write_raw` derives its temp name as `path.with_suffix(path.suffix + ".tmp")`. For a dotfile with an extension that is not obviously safe — `Path(".booth.json").stem` is `".booth"`, which looks alarming — but `.suffix` is `".json"` and the result is `.booth.json.tmp`. Checked against the interpreter. The temp file is itself a dotfile, so `booth_items` and `zip_booth` skip it and no reader can see it mid-write. **SR-3 — the dotfile skips are on `p.name`, and all three use `rglob` or `iterdir` over the booth.** `items.booth_items` (items.py:182), `app.zip_booth` (app.py:351) and `marks.import_legacy_asks` (marks.py:656) each test `p.name.startswith(".")`. A manifest at the booth root is skipped by every one of them. Confirmed by reading the three loops, not by trusting the claim. **SR-4 — `test_stdlib_only` is parametrized `["marks", "asks", "links"]`** (tests/test_marks.py:279) and gains `"manifest"` as a fourth entry. The test's docstring calls this INV-5 while `CLAUDE.md` calls it invariant 1; that inconsistency predates this unit and is left alone. **SR-5 — `.booth.json` is reachable over HTTP at `/b//.booth.json`.** `booth_file` refuses only path escapes and non-files, not dotfiles, so a remote session with no filesystem access can read a booth's announcement the same way it already polls `/b//marks.json`. That is a feature and it is now written down; there is no secret in a manifest, and the Booth has no auth by design. **SR-6 — `list_booths` returns plain dicts and the templates read them by key.** `b.manifest` resolves through Jinja's getitem fallback. A None manifest must be guarded with an explicit `{% if %}` rather than relying on `b.manifest.handle` rendering as Undefined, because the two lanes' cards differ and a silent Undefined in one of them is how the kept lane would quietly keep the old defect. ## Out of scope Deliberately deferred or never. Divergence here is not drift. - **A second index ordering keyed on `created`** — a "what landed" feed. Operator decision, 2026-09-22: parked for v1.1. It is a new ordered collection needing its own stated rule, it competes with the existing order for what "the third one" means, and it has nothing to sort the 26 manifest-less booths by. - **`why` in the zip manifest, or a `booth ls` column.** One-liners over the same record, neither on the v1 path. - **Enforcing that a booth MUST announce itself.** `rsync` is the documented path for every host that is not nh3-dev and never runs the CLI, so a refusal would break the documented workflow. The marker is the whole mechanism. - **Deleting, expiring or migrating anything based on the manifest.** U4 owns lifetime; this unit only describes. - **Any change to how items, marks, blur, keep or the link board work.** The manifest is a dotfile and every existing listing already skips it. - **Auth, or treating a manifest as trusted.** Standing non-goal; the Booth is LAN-internal and a hand-written `.booth.json` is a supported input. ## Invariants **INV-1 — one resolver for the manifest.** `read_manifest(booth)` is the only place `.booth.json` is opened. No route body, template or CLI verb parses it. Falsifiable: no `MANIFEST_FILE` read outside `manifest.py`. **INV-2 — the read cannot raise.** `read_manifest` returns for every input, including a directory that is not a booth, a `.booth.json` that is a list, one that is 4 GB, and one that is not UTF-8. Tested per case. **INV-3 — `created` survives re-announcement.** A second `write_manifest` on the same booth preserves the first `created`. **INV-4 — stdlib-only (INV-1 of `CLAUDE.md`).** `booth/manifest.py` imports nothing outside the standard library and nothing from `booth.*`. **INV-5 — the unannounced state is visible and distinct from the unreadable one.** Both render; they do not render the same thing.