fix(manifest): fold in both cross-frontier panels — and a live hole in v0.2.2

Two four-arm artifact-only rounds landed together: the contract paraphrase
(against the pre-seam-review capture) and the code-vs-contract conformance
review (against the amended one), correctly firewalled from each other.
The conformance round found ZERO drift in the strict sense — the code is a
clause-for-clause implementation of the contract — and the weight of both
rounds landed one layer down, in what green tests structurally cannot
report. Full triage in persistent-memory.d/.

A LIVE HOLE IN RELEASED CODE, FOUND ON THE SIBLING MODULE

v0.2.2 adopted the RecursionError finding from the bug-hunt round and
closed half of it: `_hydrate_safe` guards hydration, but `json.loads` runs
above it in `_read_raw`, whose catch list covers neither RecursionError nor
MemoryError. A 400 KB file of nothing but brackets in any ONE booth
therefore still returned 500 for `/` and `/healthz` across every booth on
the service. Confirmed by running it before believing it.

Both modules now bound the read by `stat` before touching the bytes and
catch both classes anyway, so raising a bound later cannot quietly re-open
the hole. The strict half of the marks asymmetry refuses everything the
lenient half tolerates, or a file that reads as "no marks" gets replaced by
a write that believed it.

THE WHY-WIPE

`booth new x --why "..."` then `booth add x out/*.png` erased the sentence
the first command existed to record. Omitted flags meant empty strings and
empty strings overwrote. Two arms predicted it from the contract's wording
alone; every test here passed --why on both calls and so could not see it.
Omitted now means unchanged and an explicit --why "" still clears — the
shell carries the distinction by leaving the variable UNSET, not empty.

--title WAS WRITE-ONLY

Stored, flag-surfaced, rendered nowhere. 4/4, and independently top-ranked
by every arm of the paraphrase round. It lands on the booth page heading
with the directory name beside it, because the directory name is the
identity the operator navigates by and refers to positionally.

THREE TESTS THAT COULD NOT FAIL

- test_the_write_is_atomic asserted no *.tmp survived, which a plain
  write_text passes. It asserts the inode changes now. (The first
  replacement was ALSO vacuous — it spied on os.open, which Path.write_text
  reaches through io.open in C and never touches. Recorded in the test,
  because writing a second vacuous test while fixing the first is exactly
  the failure this round is about.)
- The INV-3 preservation test passed against an implementation that
  regenerated `created` every time, because _now() is whole-second
  resolution and back-to-back writes share a stamp. Seeded from 2019 now.
- test_announcing_is_activity passed whether or not _newest_mtime counted
  the manifest, because writing it bumps the directory mtime either way.
  The directory's clock is put back, leaving the file as the only thing
  that can keep the booth alive.

ALSO

- The title fallback skipped the normalizer the explicit value gets; a
  directory name may legally carry a newline and run to 255 bytes.
- Every writer derived the same .booth.json.tmp. Marks are protected from
  that by their flock; the manifest has none, so uniqueness stands in.
- test_stdlib_only was blind to relative imports in all four modules.
- INV-1 had no guard at all; INV-5 named two different promises; the
  negative render states were asserted on the index only.

Contract amended throughout: the 4 GB case is a stat-checked bound rather
than a return constraint, every field of an error-carrying record has a
stated value, INV-1 no longer contradicts INV-3, repo-wide rules are named
in words instead of by a colliding number, and touches admits the macro
partial the implementation added.

329 tests.
This commit is contained in:
Vuong Hoang
2026-09-22 01:29:27 -07:00
parent fac83de8f4
commit c015a917ee
9 changed files with 612 additions and 93 deletions
+143 -47
View File
@@ -4,7 +4,7 @@ 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.)"
- "booth.marks (the `_write_raw` shape only -- temp file + os.replace, per CLAUDE.md invariant 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
@@ -16,9 +16,10 @@ used_by:
- "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/app.py (list_booths gains one key; booth_view gains one key; the /upload path writes a manifest. It also adds MANIFEST_FILE to the `used` dedupe set -- CONSISTENCY, not a fix: SR-1 established the collision is unreachable because `safe_upload_name` strips leading dots, which is equally true of the `UPLOAD_MARKER` entry that has sat in that set since before this unit.)"
- "booth/templates/_provenance.html (new -- the provenance macro, defined ONCE and called from both index lanes and the booth header. Not in the first draft of this inventory: the implementation added the partial rather than repeating the four-state conditional three times, which is SR-6 plus the blurtoggle lesson, and the inventory lagged the decision.)"
- "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/booth.html (the provenance line in the boothhead, and the h1 renders `title` with the directory name beside it)"
- "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)"
@@ -27,14 +28,13 @@ 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."
- "THE WRITE IS ATOMIC (CLAUDE.md invariant 5, NOT this unit's INV-5). Temp file + os.replace onto a name no other writer derives, because the CLI writes it in one process while the browser reads it in another -- and because two `booth add` calls on one booth would otherwise share a scratch name, which the atomic-write promise says nothing about: it promises readers never see a partial file, not that writers never race. 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 CLAUDE.md invariant 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."
- "NOTHING NEW IS ORDERED, so CLAUDE.md invariant 6 (every ordered collection has a stated, deterministic rule) does not bind here -- there is no new collection for it to bind to. 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
@@ -60,15 +60,25 @@ just makes it homeless.**
```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
handle: str # an althing handle, or "booth" for one 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 = None # a read-time verdict; never stored
```
`.booth.json` on disk is the same four fields, no `error` — that one is a
read-time verdict, not stored state.
`.booth.json` on disk is the same four fields, no `error`.
**Every field on an error-carrying record has a stated value**, because the
templates render the record and a careless fill would re-raise the outage in
the renderer: `handle` and `why` and `created` are `""`, `title` is the
normalized directory name, and `error` says which of the six refusals fired.
`created` being `""` is what makes `write_manifest` treat a damaged prior as
having no stamp to preserve (INV-3).
Caps, all applied at the write and again at the read: `handle` 64, `title` 120,
`why` 200, `created` 64. Each is a **display budget**, not a storage limit —
they exist because these strings land in a card's sub-line.
## Signatures
@@ -77,6 +87,9 @@ MANIFEST_FILE = ".booth.json"
HANDLE_MAX, TITLE_MAX, WHY_MAX = 64, 120, 200
MANIFEST_MAX_BYTES = 64 * 1024
def read_manifest(booth: Path) -> Manifest | None:
"""This booth's announcement, or None if it never made one.
@@ -86,37 +99,77 @@ def read_manifest(booth: Path) -> Manifest | None:
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.
"NEVER RAISES" IS BOUNDED, NOT MERELY CAUGHT. An earlier draft of this
contract named a 4 GB file as a tested case and constrained only the RETURN
— which is letter-compliant and purpose-defeating: reading four gigabytes
per booth per index load recreates the same outage in slow motion. The size
is checked by `stat` BEFORE the bytes are touched, and the two exception
classes that are neither `OSError` nor `ValueError` — `MemoryError` from a
huge document, `RecursionError` from a deeply nested one — are caught as
well, so that raising the bound one day cannot quietly re-open the hole.
Absent -> None. Present but too large, unreadable, unparseable, 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.
def write_manifest(booth: Path, handle: str, *, title: str | None = None,
why: str | None = None) -> Manifest:
"""Announce a booth. Atomic per CLAUDE.md invariant 5: temp file +
os.replace, onto a temp name no other writer will pick.
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().
OMITTED MEANS UNCHANGED; `""` MEANS CLEAR. `title` and `why` default to
None. The ordinary sequence is `booth new x --why "..."` then
`booth add x out/*.png`, and while omission meant `""` the second command
silently erased the sentence the first one existed to record. The shell
carries the distinction by leaving the environment variable UNSET rather
than empty.
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.
Re-announcing PRESERVES the original `created` — `created` is when the
booth appeared, and saying something more about it later is not a second
appearance. A prior record carrying `error`, or one whose `created` is
`""`, is treated as having no stamp to preserve and gets `now()`: a stamp
that is silently wrong is worse than one that is silently new.
`title` falls back to the directory name, THROUGH the same normalizer the
explicit value gets — a directory name may legally carry a newline on POSIX
and may run to 255 bytes, and the fallback used to hand either straight
into a card's sub-line.
Every stored string is collapsed to a single line — all runs of whitespace,
not only newlines, because a tab or a forty-space indent renders as badly
in a sub-line as a newline does — and truncated to its cap.
An empty `handle` becomes `"booth"` rather than being refused: a manifest
naming no handle does not read back at all, and an unreadable file is the
worse outcome. Unreachable from the CLI, whose fallback chain always yields
something; a direct caller should pass a real one.
"""
```
## What renders
One line, on both surfaces, driven by the same record:
One line, on both surfaces, driven by the same record. The example booth below
is the directory `r18-ab`, announced by the handle `booth-dev`:
| state | index card / booth header |
| state | the provenance line, on an index card AND on the 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) |
| damaged manifest | `unreadable` (muted, warning tint, `title=` carries the reason) |
**`title` renders too, and on exactly one surface.** An earlier draft stored it,
surfaced a `--title` flag for it, and rendered it nowhere — a promise of a
display name with no display, caught 4-of-4 and ranked first independently by
every arm. It lands on the **booth page heading**, where there is room:
`<h1>R18 A/B <span class=h1-slug>r18-ab</span></h1>`. The **index card keeps
the directory name alone**, because that is the identity the operator navigates
by and refers to positionally, and CLAUDE.md invariant 6 is about exactly that
kind of reference surviving a re-render. When `title` equals the directory name
— the default — the heading is unchanged from today.
**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
@@ -143,10 +196,25 @@ booth new scratch # still legal — handle + created, no why
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.
working; the flags are optional and may sit on either side of the file
arguments, because a glob is usually last and a flag usually after it and
nothing enforces that. 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.
**A bare re-announce does not wipe what the last one said.** On a booth that has
never announced, a bare `new`/`add` writes `{handle, created}` with no `why`. On
one that HAS, an omitted flag leaves the stored value alone and only a supplied
one overwrites — `--why ""` still clears, which is a different intention. This
distinction is load-bearing rather than polite: `booth new x --why "…"` followed
by `booth add x out/*.png` is the ordinary sequence, and the naive reading
erases the sentence on the second command.
**The handle is the CLI's three-step chain**, not `$ALTHING_HANDLE` alone:
`${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s)}}`, identical to the one
`booth link` already uses for its rows, so provenance means the same thing on
the board and on the card. A session with no handle set still announces, as its
host.
## Scope — the blast-radius pass
@@ -174,8 +242,14 @@ 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`.
review** (SR-1, below): the upload path's `used: set = {UPLOAD_MARKER}` filename
dedupe set does **not** need to gain `MANIFEST_FILE`. The implementation adds it
anyway, as consistency with the equally-unreachable entry already there, and
says so in a comment rather than claiming it prevents anything.
⚠ **Line numbers in this section are the PRE-CHANGE coordinates** the
blast-radius pass was run against, kept because that is what makes the pass
auditable. They have moved; `grep` the symbol, do not trust the number.
## Seam review — what the real sibling surfaces said
@@ -253,19 +327,41 @@ Deliberately deferred or never. Divergence here is not drift.
## 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`.
Numbered INV-1..5 and local to this unit. Where a repo-wide rule is meant it is
named in words — "CLAUDE.md invariant 5", "CLAUDE.md invariant 6" — never by a
bare number, because an earlier draft used `INV-5` for both the repo's
atomic-write rule and this unit's render rule and the collision was caught
3-of-4.
**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-1 — one module knows the filename.** `booth/manifest.py` is the only
module that names `MANIFEST_FILE`. No route body, template or CLI verb opens or
parses `.booth.json`; `write_manifest` reads it back inside that module, which
is what INV-3 requires and is not an exception to this rule. Falsifiable and
tested: no other file under `booth/` contains the literal `.booth.json`.
**INV-2 — the read cannot raise, AND cannot cost the caller unboundedly.**
`read_manifest` returns for every input: an absent directory, a `.booth.json`
that is a list, a string, `null`, empty, not UTF-8, wrong-typed, missing its
handle, nested deeply enough to overflow the parser's stack, and one larger
than `MANIFEST_MAX_BYTES` — which is refused by `stat` before a byte is read,
because a bound that only constrains the RETURN recreates the outage in slow
motion. Tested per case, the size and depth cases included.
**INV-3 — `created` survives re-announcement.** A second `write_manifest` on the
same booth preserves the first `created`.
same booth preserves the first `created`. A prior record carrying `error`, or
one whose `created` is `""`, has no stamp to preserve and gets `now()`. Tested
against a stamp that could not have come from `now()` — `_now()` is whole-second
resolution, so back-to-back writes share a timestamp and a naive test passes
against an implementation that regenerates it every time.
**INV-4 — stdlib-only (INV-1 of `CLAUDE.md`).** `booth/manifest.py` imports
nothing outside the standard library and nothing from `booth.*`.
**INV-4 — stdlib-only, and sibling-free** (this is CLAUDE.md invariant 1
extended by one clause). `booth/manifest.py` imports nothing outside the
standard library and nothing from `booth.*` — a cross-import between two
stdlib-only modules is a second way for the repo rule to break. Relative
imports count; the AST walk sees them.
**INV-5 — the unannounced state is visible and distinct from the unreadable
one.** Both render; they do not render the same thing.
**INV-5 — unannounced and unreadable render DIFFERENT TEXT.** Not merely
different styling: the words differ (`unannounced` / `unreadable`), so the
distinction survives a stylesheet change and a reader who cannot see colour. A
one-pixel difference would satisfy a looser wording and encode nothing, and the
point is that one of the two states is something somebody has to go and fix.