The out-of-scope block is load-bearing for the cross-frontier review gates — without negative constraints their signal-to-noise drops sharply, and both /heid-code-review and /heid-bug-hunt refuse to fire without one. Written for the reviewer, but it is the same list the roadmap gate produced: the what-landed feed is parked for v1.1, nothing enforces that a booth must announce itself (rsync is a documented path and never runs the CLI), and the manifest describes rather than decides — U4 owns lifetime.
265 lines
18 KiB
Markdown
265 lines
18 KiB
Markdown
---
|
|
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 `.<name>.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/<name>/.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/<n>/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.
|