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.
25 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 # 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.
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
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.
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.
"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 | 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.
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.
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. The example booth below
is the directory r18-ab, announced by the handle booth-dev:
| 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, 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
— 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; 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
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): 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
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. - Provenance ON a verbatim-
index.htmlbooth's own page. Five live booths serve the author's HTML raw, and the Booth owns no header there to put a line into — it currently reaches those pages through six regexes injected into arbitrary markup, which is precisely the defect U3 exists to fix. Their INDEX cards carry provenance like everything else; the page itself waits for U3's declared embed seam. Verified onpewpew-ui-brief: page renders 200, card readsunannounced.
Invariants
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-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. 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, 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 — 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.