diff --git a/docs/contracts/u5_booth_manifest.contract.md b/docs/contracts/u5_booth_manifest.contract.md new file mode 100644 index 0000000..5f53f19 --- /dev/null +++ b/docs/contracts/u5_booth_manifest.contract.md @@ -0,0 +1,244 @@ +--- +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. + +## 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.