feat(manifest): U5 — booths that say who posted them and why
The index card showed a name, an item count and a countdown, and nothing
the poster chose. An agent with something to show therefore had no way to
make the booth say "look at this" and posted a URL to the link board
instead — which is why 145 of that board's 210 rows (69%) ended up
pointing at booths that had already been swept. The board was absorbing a
job it was never shaped for. This is the shape.
Each booth carries `.booth.json` — {handle, title, why, created} — written
by the CLI from $ALTHING_HANDLE, and the provenance line renders on both
index lanes and on the booth page header.
WHAT IS WHERE
- booth/manifest.py, stdlib-only and importing nothing from booth.* either:
scripts/booth imports it under the system python3 with no venv, and a
cross-import between two stdlib-only modules is a second way for that
invariant to break. It joins the shared test_stdlib_only list and keeps
a stricter copy of its own.
- The read is lenient and cannot raise. list_booths touches every booth on
every index load, so a manifest that cannot be parsed costs that booth's
provenance and nothing else. That is the v0.2.2 lesson applied before the
same mistake rather than after it.
- Absent and damaged render differently — `unannounced` and `unreadable`.
Folding "cannot be read" into "never said" would hide the one case
somebody has to go and fix.
- Re-announcing preserves `created`. A second `booth add` sharpening the
why is not a second appearance of the booth.
- The write is atomic (invariant 5); the temp file is itself a dotfile, so
no listing can see it mid-write.
THREE OPERATOR CALLS, 2026-09-22
Flags on the existing new/add verbs rather than a separate `announce` verb
(a second step is the step that gets forgotten, which is the rot's own
mechanism). Unannounced booths get a quiet marker rather than nothing — the
convention is only adoptable if the gap is visible. U5 adds provenance only
and does NOT add a second index ordering keyed on announcement time; that
is a different surface needing its own stated rule, parked for v1.1.
NO EXEMPTION LIST
A pickup booth and the standing link board are created by the service, so
they announce themselves with handle `booth`, which is true rather than
manufactured. One rule — a booth with no manifest is unannounced — instead
of a growing set of special cases.
ALSO
tests/test_booth.py's keep/release assertion was slicing the page on the
bare word `boothhead`, which has lived in the stylesheet far longer than
the assertion has; it was reading CSS and passing on luck, and went red the
first time a new rule landed above the old one. Same assertion, aimed at
the markup. A U5 test had the mirror-image bug: pytest derives tmp_path
from the test name and the index renders data_dir, so a test named
`test_an_unannounced_booth_says_so` put the needle in the haystack itself
and passed against a template that did not yet exist.
310 tests (304 before this unit's CLI half). Live service restarted, 26/26
booth pages verified 200, end-to-end smoke through the real CLI.
NOT TAGGED. The cold contract-review panel is still in flight and the
code-review and bug-hunt gates have not run. Tagging with a gate
outstanding is what made v0.2.0 premature.
This commit is contained in:
+25
-1
@@ -141,6 +141,12 @@ from booth.inline import ( # noqa: E402
|
||||
has_placeholders,
|
||||
place as place_asks,
|
||||
)
|
||||
from booth.manifest import ( # noqa: E402
|
||||
MANIFEST_FILE,
|
||||
SERVICE_HANDLE,
|
||||
read_manifest,
|
||||
write_manifest,
|
||||
)
|
||||
from booth.links import ( # noqa: E402
|
||||
LINK_LOCK,
|
||||
LINKS_FILE,
|
||||
@@ -273,6 +279,11 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
# — which is why marks live in one file per booth rather than a sidecar
|
||||
# per mark. This loop runs on every index page load.
|
||||
marks = marks_for(child)
|
||||
# The booth's own announcement — who posted it and why. One more small
|
||||
# read per booth, beside the marks read already here, and `read_manifest`
|
||||
# cannot raise for the same reason `marks_for` must not: this loop runs
|
||||
# over EVERY booth on every index page load.
|
||||
manifest = read_manifest(child)
|
||||
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
|
||||
thumb_url = None
|
||||
thumb_blurred = False
|
||||
@@ -290,6 +301,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
{
|
||||
"name": child.name,
|
||||
"name_url": quote(child.name, safe=""),
|
||||
"manifest": manifest,
|
||||
"count": len(items),
|
||||
"kinds": kinds,
|
||||
"thumb_url": thumb_url,
|
||||
@@ -748,6 +760,10 @@ def create_app(
|
||||
},
|
||||
"booth_marks": marks_for_target(marks, None),
|
||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||
# The same provenance line the index card carries. Deliberate:
|
||||
# a booth URL handed to the operator lands HERE, never on the
|
||||
# index, and job 5 is "operator, look at this".
|
||||
"manifest": read_manifest(booth),
|
||||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
||||
},
|
||||
)
|
||||
@@ -1086,9 +1102,17 @@ def create_app(
|
||||
dest = data_dir / booth_id
|
||||
dest.mkdir(parents=True)
|
||||
(dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed)
|
||||
# A booth the SERVICE made says so, rather than being exempted from the
|
||||
# unannounced marker. One rule instead of an exemption list, and the
|
||||
# handle is true: nobody's agent posted this, the browser did.
|
||||
write_manifest(dest, SERVICE_HANDLE, title=booth_id,
|
||||
why="browser upload, for pickup")
|
||||
|
||||
total = 0
|
||||
used: set = {UPLOAD_MARKER}
|
||||
# Both markers are belt-and-braces: `safe_upload_name` strips leading
|
||||
# dots, so an uploaded file can never be named either of them. Listed
|
||||
# anyway so the set says what the directory already contains.
|
||||
used: set = {UPLOAD_MARKER, MANIFEST_FILE}
|
||||
try:
|
||||
for i, f in enumerate(files):
|
||||
name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""A booth's own announcement — who posted it, and why.
|
||||
|
||||
U5. The index card used to show a name, an item count and a countdown, and
|
||||
nothing the poster chose. An agent with something to show therefore had no way
|
||||
to make the booth say "look at this" and posted a URL to the link board
|
||||
instead — which is why 145 of that board's 210 rows (69%) ended up pointing at
|
||||
booths that had already been swept. The board was absorbing a job it was never
|
||||
shaped for. This is the shape.
|
||||
|
||||
.booth.json -> {"handle": ..., "title": ..., "why": ..., "created": ...}
|
||||
|
||||
⚠ STDLIB ONLY, and it imports nothing from `booth.*` either.
|
||||
|
||||
`scripts/booth` — the CLI every fleet session uses — imports this module
|
||||
directly under the system `python3` with no venv, through a `python3 -c`
|
||||
heredoc no AST extractor can see. A single third-party import here breaks
|
||||
`booth new` and `booth add` on every host, and the failure surfaces in an
|
||||
agent's session rather than in ours. The ban extends to sibling `booth` modules:
|
||||
importing `marks` to reuse its atomic write would drag marks' own import list
|
||||
into this one's, so the four-line pattern is copied instead. `test_stdlib_only`
|
||||
in tests/test_manifest.py is the only thing standing here.
|
||||
|
||||
Contract: docs/contracts/u5_booth_manifest.contract.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
MANIFEST_FILE = ".booth.json"
|
||||
|
||||
# A `why` renders inside a card's sub-line, so it is one line by construction
|
||||
# rather than by convention — enforced at the WRITE so nothing downstream has to
|
||||
# remember. The caps are display budgets, not storage limits.
|
||||
HANDLE_MAX = 64
|
||||
TITLE_MAX = 120
|
||||
WHY_MAX = 200
|
||||
|
||||
# The handle a booth created by the service itself carries. A pickup booth and
|
||||
# the standing link board are made by the Booth, not by an agent, and saying so
|
||||
# is true rather than manufactured — which is the whole reason there is no
|
||||
# exemption list. One rule: a booth with no manifest is unannounced.
|
||||
SERVICE_HANDLE = "booth"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
"""One booth's announcement.
|
||||
|
||||
`handle` is an althing agent handle, or `SERVICE_HANDLE` for a booth the
|
||||
Booth made. `error` is a read-time verdict and is never stored.
|
||||
"""
|
||||
|
||||
handle: str
|
||||
title: str
|
||||
why: str
|
||||
created: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _one_line(value, limit: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return " ".join(value.split())[:limit]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def read_manifest(booth: Path) -> Manifest | None:
|
||||
"""This booth's announcement, or None if it never made one.
|
||||
|
||||
LENIENT, AND IT NEVER RAISES (INV-2). `list_booths` calls this once per
|
||||
booth on every index page load, so a read that can raise is a service-wide
|
||||
outage wearing a single-booth bug's clothes. That is not hypothetical: a
|
||||
poisoned `.marks.json` did exactly that to `/` and `/healthz` across all 25
|
||||
live booths, and the fix shipped in v0.2.2. Same posture, applied before the
|
||||
same mistake rather than after it.
|
||||
|
||||
Absent -> None. Present but unreadable -> a Manifest carrying `error`, so a
|
||||
card can say `unreadable` instead of quietly showing the same thing as a
|
||||
booth that never announced (INV-5). Folding the two together would hide the
|
||||
one case somebody has to go and fix.
|
||||
|
||||
Only `handle` is required. A hand-written manifest is a supported input —
|
||||
the file is plain JSON in a folder the operator owns, and half the point of
|
||||
the Booth is that a booth is just a directory.
|
||||
"""
|
||||
booth = Path(booth)
|
||||
path = booth / MANIFEST_FILE
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
return _broken(booth, f"cannot be read: {exc}")
|
||||
if not text.strip():
|
||||
return _broken(booth, "is empty")
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except ValueError as exc:
|
||||
return _broken(booth, f"is not valid JSON: {exc}")
|
||||
if not isinstance(raw, dict):
|
||||
return _broken(booth, "is not a JSON object")
|
||||
|
||||
handle = _one_line(raw.get("handle"), HANDLE_MAX)
|
||||
if not handle:
|
||||
return _broken(booth, "names no handle")
|
||||
return Manifest(
|
||||
handle=handle,
|
||||
title=_one_line(raw.get("title"), TITLE_MAX) or booth.name,
|
||||
why=_one_line(raw.get("why"), WHY_MAX),
|
||||
created=_one_line(raw.get("created"), 64),
|
||||
)
|
||||
|
||||
|
||||
def _broken(booth: Path, reason: str) -> Manifest:
|
||||
return Manifest(handle="", title=booth.name, why="", created="",
|
||||
error=f"{MANIFEST_FILE} {reason}")
|
||||
|
||||
|
||||
def write_manifest(booth: Path, handle: str, *, title: str = "",
|
||||
why: str = "") -> Manifest:
|
||||
"""Announce a booth, atomically (CLAUDE.md invariant 5).
|
||||
|
||||
Temp file + `os.replace`, because the CLI writes this in one process while
|
||||
the browser reads it in another — a reader must never see a half-written
|
||||
document. The temp file is itself a dotfile (`.booth.json.tmp`), so no
|
||||
listing, gallery or zip can see it mid-write either.
|
||||
|
||||
RE-ANNOUNCING PRESERVES `created` (INV-3). It is when the booth APPEARED,
|
||||
and saying something more about it later is not a second appearance —
|
||||
`booth add` on an existing booth is the common case, where the poster drops
|
||||
the second batch and sharpens the why. A `created` that cannot be read back
|
||||
is replaced rather than guessed at: a stamp that is silently wrong is worse
|
||||
than one that is silently new.
|
||||
"""
|
||||
booth = Path(booth)
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
prior = read_manifest(booth)
|
||||
created = prior.created if prior and not prior.error and prior.created else _now()
|
||||
|
||||
record = Manifest(
|
||||
handle=_one_line(handle, HANDLE_MAX) or SERVICE_HANDLE,
|
||||
title=_one_line(title, TITLE_MAX) or booth.name,
|
||||
why=_one_line(why, WHY_MAX),
|
||||
created=created,
|
||||
)
|
||||
path = booth / MANIFEST_FILE
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(
|
||||
{"handle": record.handle, "title": record.title,
|
||||
"why": record.why, "created": record.created},
|
||||
ensure_ascii=False, indent=2,
|
||||
) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, path)
|
||||
return record
|
||||
@@ -0,0 +1,17 @@
|
||||
{# THE ANNOUNCEMENT — who posted this booth and why. Defined ONCE and called
|
||||
from both index lanes and the booth page header: the kept lane is a separate
|
||||
block, and patching only the ephemeral one would leave the durable,
|
||||
most-looked-at boards with exactly the defect this closes.
|
||||
|
||||
Four states, and `unannounced` is distinct from `unreadable` on purpose —
|
||||
folding "cannot be read" into "never said" hides the one case somebody has to
|
||||
go and fix. The classes are the test hooks; the words are for the operator. #}
|
||||
{% macro provenance(m) -%}
|
||||
{% if m is none %}
|
||||
<div class="prov prov-none">unannounced</div>
|
||||
{% elif m.error %}
|
||||
<div class="prov prov-broken" title="{{ m.error }}">unreadable</div>
|
||||
{% else %}
|
||||
<div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why">{{ m.why }}</span>{% endif %}</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
@@ -255,6 +255,22 @@
|
||||
.card .name:hover{text-decoration:none;color:var(--aus-bright-cyan)}
|
||||
.card .sub{color:var(--fg-3);font-size:.72rem;font-family:var(--font-mono);letter-spacing:.03em;margin-top:.3rem}
|
||||
|
||||
/* THE ANNOUNCEMENT — who posted this booth and why (U5). Same size and
|
||||
rhythm as .sub above it, because it is the same class of information: a
|
||||
second line of card metadata, not a heading. The handle carries the only
|
||||
colour, so a scan down the index reads as a column of posters. */
|
||||
.prov{margin-top:.28rem;font-size:.72rem;font-family:var(--font-mono);
|
||||
letter-spacing:.03em;color:var(--fg-3);line-height:1.45;
|
||||
overflow-wrap:anywhere}
|
||||
.prov-who{color:var(--fg-2)}
|
||||
.prov-why{color:var(--fg-3)}
|
||||
/* Quiet on purpose. 26 booths arrived before this convention existed and
|
||||
rsync keeps making more, so the marker has to be visible-if-you-look and
|
||||
never a badge shouting 26 times. `unreadable` gets the warning tint
|
||||
because, unlike `unannounced`, it is something somebody has to fix. */
|
||||
.prov-none{color:var(--fg-muted);font-style:italic}
|
||||
.prov-broken{color:var(--aus-bright-yellow,#e8c547);font-style:italic;cursor:help}
|
||||
|
||||
.wipe{position:absolute;top:.5rem;right:.5rem;margin:0}
|
||||
/* ★ keep, mirroring .wipe on the other shoulder of the card. Same
|
||||
hover-to-reveal language as .release in the kept lane. */
|
||||
@@ -408,6 +424,9 @@
|
||||
.boothhead h1{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.5rem;
|
||||
letter-spacing:-.01em;word-break:break-word;flex:1 1 auto;color:var(--fg-0)}
|
||||
.boothhead .sub{color:var(--fg-3);font-size:.74rem;font-family:var(--font-mono);letter-spacing:.06em}
|
||||
/* Its own row under the title, not another chip in the flex line — a `why`
|
||||
can run to WHY_MAX and would otherwise shove the zip link around. */
|
||||
.boothhead .prov{flex:0 0 100%;margin-top:-.35rem}
|
||||
.wipe-lg{position:static}
|
||||
/* red-outline danger button — legible on the dark canvas, fills on hover */
|
||||
.wipe-lg button{width:auto;height:auto;padding:.42rem .85rem;border-radius:var(--radius-md);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_provenance.html" import provenance %}
|
||||
{# The blur toggle, defined ONCE. There are three item branches in this file
|
||||
(doc / media / other) and the first cut of this feature patched only one of
|
||||
them, so docs rendered with no control at all. A macro makes "patched two of
|
||||
@@ -58,6 +59,7 @@
|
||||
<h1>{{ name }}</h1>
|
||||
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
||||
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||||
{{ provenance(manifest) }}
|
||||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||||
kept lane on the index. Remove rows with the per-row ×, or release the
|
||||
board from the index and wipe it from there. #}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_provenance.html" import provenance %}
|
||||
{% block content %}
|
||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label class="drop" for="booth-files">
|
||||
@@ -39,6 +40,7 @@
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
</div>
|
||||
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
||||
release-then-find-it-in-the-other-lane, on the theory that two
|
||||
@@ -110,6 +112,7 @@
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
</div>
|
||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
||||
|
||||
Reference in New Issue
Block a user