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.
18 KiB
contract_version, module, purpose, depends_on, language, complexity, estimated_loc, confidence, used_by, touches, assumptions, open_questions
| contract_version | module | purpose | depends_on | language | complexity | estimated_loc | confidence | used_by | touches | assumptions | open_questions | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1.0 | booth.manifest | 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. |
|
python | low | 150 | 0.85 |
|
|
|
|
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
@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
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:
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. whyin the zip manifest, or abooth lscolumn. One-liners over the same record, neither on the v1 path.- Enforcing that a booth MUST announce itself.
rsyncis 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.jsonis 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.