Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
026a1fc392 | ||
|
|
70fb15886b | ||
|
|
a0448bdc24 | ||
|
|
5e41108cd3 |
@@ -258,6 +258,14 @@ declare_pick(booth, "batch", {
|
||||
# "unanswered", "complete", "notes", "answered_at", "answered_by"}
|
||||
```
|
||||
|
||||
**A pick can be about ONE item, not just the booth.** Pass `target` — an item's
|
||||
booth-relative path — and the question renders beside that artifact:
|
||||
|
||||
```python
|
||||
declare_pick(booth, "which-crop", {"prompt": "Which crop?", "options": ["tight", "wide"]},
|
||||
target="v3/DSC03389.jpg")
|
||||
```
|
||||
|
||||
**A partial answer is recorded, not refused.** A question left blank is a
|
||||
deliberate outcome — "none of these", "not yet", "ask me later" — so it lands in
|
||||
`unanswered`, stays absent from `answers` unless it carried a note, and
|
||||
@@ -272,6 +280,14 @@ files (`<stem>.ask.json` / `<stem>.answer.json`) are imported, never deleted:
|
||||
booth marks-import r18-ab # idempotent; the sidecars stay on disk
|
||||
```
|
||||
|
||||
If the stem is already a mark the declaration is skipped, but a legacy answer
|
||||
still gets adopted, so the operator's recorded choice is never stranded on disk.
|
||||
|
||||
**If a booth's `.marks.json` is damaged**, reads degrade to "no marks" so the page
|
||||
still loads, and every WRITE refuses with a 409 rather than replacing the file —
|
||||
which would otherwise wipe every judgment in that booth. Repair or move the file
|
||||
by hand; nothing deletes it for you.
|
||||
|
||||
## Upload for pickup
|
||||
|
||||
The reverse direction — put files in through the web, pick them up by id:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# The Booth — roadmap
|
||||
|
||||
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
||||
Current version: `0.2.0` (U1 + U2 landed; extracted from eshpfi 2026-09-21).
|
||||
Current version: `0.2.1` (U1 + U2 landed; extracted from eshpfi 2026-09-21).
|
||||
|
||||
## v1 target
|
||||
|
||||
|
||||
+68
-8
@@ -45,6 +45,7 @@ from fastapi.responses import (
|
||||
Response,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
try:
|
||||
@@ -123,6 +124,7 @@ from booth.asks import ( # noqa: E402
|
||||
)
|
||||
from booth.marks import ( # noqa: E402
|
||||
MARKS_FILE,
|
||||
MarksCorrupt,
|
||||
answer_pick,
|
||||
as_dict,
|
||||
declare_pick,
|
||||
@@ -168,12 +170,27 @@ def human_dur(seconds: float) -> str:
|
||||
|
||||
|
||||
def _newest_mtime(path: Path) -> float:
|
||||
"""Newest mtime among a folder and everything under it."""
|
||||
"""Newest mtime among a folder and everything under it — OUR LOCKS EXCEPT.
|
||||
|
||||
A booth's age is how long since somebody touched it, and a lock sidecar is
|
||||
machinery: `marks.py` and `links.py` each create one on the way into a
|
||||
read-modify-write, including one that turns out to change nothing. Counting
|
||||
it made reading-through-a-write-path look like activity, and a no-op mark
|
||||
POST on a dead booth reset its clock.
|
||||
|
||||
The exclusion is `.<something>.lock` — a DOTfile, which is the Booth's own
|
||||
namespace. An agent that posts a real artifact called `build.lock` still
|
||||
gets its clock counted. Everything else counts too, dotfiles included,
|
||||
because `.marks.json`, `.blurred` and `.pins` are the operator doing
|
||||
something.
|
||||
"""
|
||||
try:
|
||||
newest = path.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
for p in path.rglob("*"):
|
||||
if p.name.startswith(".") and p.name.endswith(".lock"):
|
||||
continue
|
||||
try:
|
||||
m = p.stat().st_mtime
|
||||
except OSError:
|
||||
@@ -477,6 +494,18 @@ PICKUP_WORDS = (
|
||||
).split()
|
||||
|
||||
|
||||
def _form_text(form, key: str) -> str:
|
||||
"""One form field as text, or "" for anything that is not text.
|
||||
|
||||
A multipart FILE part named `notes` parses to an UploadFile, not a str, and
|
||||
every downstream cleaner calls `.replace` on what it is handed. Coercing
|
||||
here keeps that decision in one place instead of one `isinstance` per call
|
||||
site — which is how `/note` came to have the guard and `/answer` not to.
|
||||
"""
|
||||
value = form.get(key)
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def safe_upload_name(name: str, fallback: str) -> str:
|
||||
"""Reduce a client-supplied filename to a safe basename (no path, no hidden)."""
|
||||
base = (name or "").replace("\\", "/").split("/")[-1].strip()
|
||||
@@ -578,6 +607,25 @@ def create_app(
|
||||
# test needs a handle on the env that the app actually renders with.
|
||||
app.state.templates = templates
|
||||
|
||||
@app.exception_handler(MarksCorrupt)
|
||||
async def _marks_corrupt(request: Request, exc: MarksCorrupt):
|
||||
"""A write was refused because the booth's mark file is damaged.
|
||||
|
||||
409, not 500: the service is fine and the request was well-formed — the
|
||||
state on disk is not, and the refusal is deliberate. Says what to do,
|
||||
because the alternative the operator will otherwise reach for is
|
||||
deleting the file, which is the thing being protected.
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": "this booth's .marks.json cannot be read, so nothing was written",
|
||||
"detail": str(exc),
|
||||
"why": "writing would replace every mark in the booth with just this one",
|
||||
"fix": "repair or move the file by hand; the marks panel still renders as empty",
|
||||
},
|
||||
)
|
||||
|
||||
ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours
|
||||
base_ctx = {
|
||||
"ttl_hours": ttl_display,
|
||||
@@ -740,13 +788,22 @@ def create_app(
|
||||
if spec.error is not None:
|
||||
raise HTTPException(status_code=400, detail=spec.error)
|
||||
who = request.client.host if request.client else ""
|
||||
# `notes` is whatever the form parser yielded. A multipart FILE part
|
||||
# named `notes` is an UploadFile, and `_clean_notes` calls `.replace` on
|
||||
# it — a 500 on hostile-but-legal input, where the sibling `/note` route
|
||||
# returns 400 for exactly the same class of value. Same parser, same
|
||||
# question, one answer.
|
||||
notes = _form_text(form, "notes")
|
||||
try:
|
||||
if spec.multi:
|
||||
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec.questions}
|
||||
qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec.questions}
|
||||
answer_pick(booth, mark_id, choice, form.get("notes", ""), who=who, qnotes=qnotes)
|
||||
qnotes = {q["key"]: _form_text(form, f"notes.{q['key']}")
|
||||
for q in spec.questions}
|
||||
await run_in_threadpool(answer_pick, booth, mark_id, choice, notes,
|
||||
who=who, qnotes=qnotes)
|
||||
else:
|
||||
answer_pick(booth, mark_id, form.get("choice"), form.get("notes", ""), who=who)
|
||||
await run_in_threadpool(answer_pick, booth, mark_id,
|
||||
form.get("choice"), notes, who=who)
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
|
||||
@@ -765,7 +822,8 @@ def create_app(
|
||||
target = raw_target if isinstance(raw_target, str) and raw_target else None
|
||||
text = form.get("text")
|
||||
try:
|
||||
mark = write_note(booth, target, text if isinstance(text, str) else "",
|
||||
mark = await run_in_threadpool(
|
||||
write_note, booth, target, text if isinstance(text, str) else "",
|
||||
who=request.client.host if request.client else "")
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
@@ -786,7 +844,9 @@ def create_app(
|
||||
raise HTTPException(status_code=400, detail="a flag needs a target")
|
||||
on = str(form.get("on", "1")) not in ("0", "", "false", "off")
|
||||
try:
|
||||
set_flag(booth, target, on, who=request.client.host if request.client else "")
|
||||
await run_in_threadpool(
|
||||
set_flag, booth, target, on,
|
||||
who=request.client.host if request.client else "")
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
|
||||
@@ -800,7 +860,7 @@ def create_app(
|
||||
mark_id = form.get("mark")
|
||||
if not isinstance(mark_id, str) or not mark_id:
|
||||
raise HTTPException(status_code=400, detail="which mark?")
|
||||
delete_mark(booth, mark_id)
|
||||
await run_in_threadpool(delete_mark, booth, mark_id)
|
||||
return _mark_redirect(name, form, "marks")
|
||||
|
||||
@app.post("/b/{name}/import-asks")
|
||||
@@ -812,7 +872,7 @@ def create_app(
|
||||
migrated from the page you are already looking at.
|
||||
"""
|
||||
booth = resolve_booth(name)
|
||||
import_legacy_asks(booth)
|
||||
await run_in_threadpool(import_legacy_asks, booth)
|
||||
form = await request.form()
|
||||
return _mark_redirect(name, form, "marks")
|
||||
|
||||
|
||||
+176
-17
@@ -59,6 +59,20 @@ from booth.asks import (
|
||||
valid_stem,
|
||||
)
|
||||
|
||||
class MarksCorrupt(RuntimeError):
|
||||
"""The mark file exists but cannot be parsed, and a WRITE was attempted.
|
||||
|
||||
The read path is deliberately lenient — `marks_for` returns [] so a review
|
||||
page still loads. The write path must not inherit that leniency: reading a
|
||||
damaged file as "no marks" and then atomically replacing it destroys every
|
||||
judgment in the booth from one click, silently. Shipped in v0.2.0 and found
|
||||
by a cross-frontier contract panel, not by the suite.
|
||||
|
||||
A page that renders without an annotation is recoverable. A file that
|
||||
overwrote the operator's judgment is not.
|
||||
"""
|
||||
|
||||
|
||||
MARKS_FILE = ".marks.json"
|
||||
MARKS_LOCK = ".marks.lock"
|
||||
SCHEMA_VERSION = 1
|
||||
@@ -176,6 +190,52 @@ def _fingerprint(entries: list[dict]) -> str:
|
||||
return json.dumps(entries, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def _read_raw_strict(booth: Path) -> list[dict]:
|
||||
"""Like `_read_raw`, but RAISES `MarksCorrupt` on a file it cannot parse.
|
||||
|
||||
Absent, empty and valid-but-empty are all "no marks yet" and are fine — the
|
||||
distinction that matters is bytes-present-but-unreadable, because that is the
|
||||
case where writing would destroy something.
|
||||
"""
|
||||
path = Path(booth) / MARKS_FILE
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc
|
||||
if not text.strip():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except ValueError as exc:
|
||||
raise MarksCorrupt(f"{path} is not valid JSON: {exc}") from exc
|
||||
if not isinstance(raw, dict) or not isinstance(raw.get("marks"), list):
|
||||
raise MarksCorrupt(f"{path} is not a marks document")
|
||||
entries = [e for e in raw["marks"] if isinstance(e, dict) and isinstance(e.get("id"), str)]
|
||||
if len(entries) != len(raw["marks"]):
|
||||
raise MarksCorrupt(f"{path} holds entries this version cannot read")
|
||||
return entries
|
||||
|
||||
|
||||
def read_error(booth: Path) -> str | None:
|
||||
"""Why this booth's marks cannot be read, or None if they can.
|
||||
|
||||
`marks_for` is lenient on purpose — a review page that will not load is
|
||||
worse than one missing an annotation — and that leniency turns an
|
||||
unreadable file into "no marks". For a BROWSER that is the right trade. For
|
||||
the CLI it is not: a session that asked a question and is told "no such
|
||||
pick" will conclude the question was never posted, when in fact the file
|
||||
holding it is damaged. A machine consumer can act on the difference, so it
|
||||
gets to ask.
|
||||
"""
|
||||
try:
|
||||
_read_raw_strict(booth)
|
||||
except MarksCorrupt as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def _write_raw(booth: Path, entries: list[dict]) -> None:
|
||||
"""Atomic replace, so a reader never sees a half-written document and a
|
||||
crash mid-write cannot truncate the file into a shorter — and therefore
|
||||
@@ -207,15 +267,30 @@ class _Locked:
|
||||
self.booth.mkdir(parents=True, exist_ok=True)
|
||||
lock = self.booth / MARKS_LOCK
|
||||
# `touch(exist_ok=True)` on an EXISTING file bumps its mtime, and a
|
||||
# booth's TTL is measured from its newest mtime including dotfiles — so
|
||||
# an unconditional touch would keep a booth alive just for being read
|
||||
# through a write path. Create it only when it is not there.
|
||||
# booth's TTL is measured from its newest mtime — so an unconditional
|
||||
# touch would keep a booth alive just for being read through a write
|
||||
# path. Create it only when it is not there.
|
||||
#
|
||||
# ONCE CREATED, THE LOCK FILE IS NEVER REMOVED (see __exit__).
|
||||
if not lock.exists():
|
||||
# Creating a directory entry bumps the DIRECTORY's mtime, which is
|
||||
# what `_newest_mtime` seeds from — so making our own lock file
|
||||
# would itself read as activity. Put the clock back: the lock is
|
||||
# machinery, and machinery is not the operator touching the booth.
|
||||
before = self.booth.stat()
|
||||
lock.touch()
|
||||
self._made_lock = True
|
||||
os.utime(self.booth, (before.st_atime, before.st_mtime))
|
||||
self._lf = lock.open("r+")
|
||||
fcntl.flock(self._lf, fcntl.LOCK_EX)
|
||||
self.entries = _read_raw(self.booth)
|
||||
try:
|
||||
# STRICT here, lenient in marks_for — see MarksCorrupt.
|
||||
self.entries = _read_raw_strict(self.booth)
|
||||
except MarksCorrupt:
|
||||
fcntl.flock(self._lf, fcntl.LOCK_UN)
|
||||
self._lf.close()
|
||||
self._lf = None
|
||||
raise
|
||||
self._before = _fingerprint(self.entries)
|
||||
return self
|
||||
|
||||
@@ -233,10 +308,17 @@ class _Locked:
|
||||
# would otherwise keep a dead booth alive forever.
|
||||
if exc_type is None and _fingerprint(self.entries) != self._before:
|
||||
_write_raw(self.booth, self.entries)
|
||||
elif self._made_lock and not (self.booth / MARKS_FILE).exists():
|
||||
# Nothing was written and this booth had no marks before: do not
|
||||
# leave a lock file behind as the only trace of a no-op.
|
||||
(self.booth / MARKS_LOCK).unlink(missing_ok=True)
|
||||
# THE LOCK FILE IS NEVER UNLINKED. It used to be, on the no-op path,
|
||||
# so a booth that had never been marked was left exactly as it was
|
||||
# found. That tidiness cost mutual exclusion outright: `flock` binds
|
||||
# to an INODE, so unlinking the lock while a second writer is blocked
|
||||
# on it leaves that writer holding an exclusive lock on a deleted
|
||||
# file, and the NEXT writer creates a fresh lock and takes it at
|
||||
# once. Two processes then run the read-modify-write concurrently,
|
||||
# the later `os.replace` drops the earlier one's mark, and both of
|
||||
# them obeyed the protocol. A zero-byte dotfile is the cheaper
|
||||
# thing to leave behind — `booth_items` skips it, the zip skips it,
|
||||
# and `_newest_mtime` exempts it so it cannot hold a booth open.
|
||||
finally:
|
||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||
lf.close()
|
||||
@@ -250,6 +332,25 @@ class _Locked:
|
||||
# ---- read -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _entry_type_error(entry: dict) -> str | None:
|
||||
"""The stored scalars this module refuses to guess at.
|
||||
|
||||
`_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on
|
||||
`(created, id)` — so a stored `text` that is a dict, or a `created` that is a
|
||||
number, raised AttributeError or TypeError out of the READ path. That is not
|
||||
a marks bug, it is an INDEX bug: `list_booths` reads every booth's marks on
|
||||
every page load and `/healthz` does the same, so one hand-edited or
|
||||
foreign-written file took down the front page for every booth on the
|
||||
service. A wrong type is a broken mark, and this module already knows how to
|
||||
render one of those.
|
||||
"""
|
||||
for name in ("created", "by", "text", "error"):
|
||||
value = entry.get(name)
|
||||
if value is not None and not isinstance(value, str):
|
||||
return f"{name} is {type(value).__name__}, not a string"
|
||||
return None
|
||||
|
||||
|
||||
def _hydrate(entry: dict) -> Mark:
|
||||
"""One stored entry -> one Mark, declarations normalized.
|
||||
|
||||
@@ -261,6 +362,15 @@ def _hydrate(entry: dict) -> Mark:
|
||||
"""
|
||||
mid = entry["id"]
|
||||
shape = entry.get("shape") if entry.get("shape") in SHAPES else NOTE
|
||||
bad = _entry_type_error(entry)
|
||||
if bad is not None:
|
||||
# `created` is dropped rather than coerced, which sorts the entry to the
|
||||
# TOP of the booth's marks: a mark nobody can read is the one that wants
|
||||
# looking at, and burying it under 270 items' worth of notes is how it
|
||||
# stays unnoticed. Deterministic, and stated — `("", id)` against
|
||||
# `(created, id)`.
|
||||
return Mark(id=mid, shape=shape, target=None, created="",
|
||||
error=f"unreadable mark: {bad}")
|
||||
target = entry.get("target")
|
||||
if not _valid_target(target):
|
||||
target = None
|
||||
@@ -308,11 +418,26 @@ def _hydrate(entry: dict) -> Mark:
|
||||
return Mark(**base, text=_clean_text(entry.get("text")))
|
||||
|
||||
|
||||
def _hydrate_safe(entry: dict) -> Mark:
|
||||
"""`_hydrate`, with the promise that it cannot raise.
|
||||
|
||||
`_entry_type_error` covers the shapes we know how to name; this is the
|
||||
backstop for the ones we do not, and it exists because of WHERE this runs.
|
||||
One unreadable mark must cost that mark, never the page — and on the index
|
||||
it is not even that booth's page, it is all of them.
|
||||
"""
|
||||
try:
|
||||
return _hydrate(entry)
|
||||
except Exception as exc: # noqa: BLE001 - deliberate
|
||||
return Mark(id=str(entry.get("id", "")), shape=NOTE, target=None,
|
||||
created="", error=f"unreadable mark: {exc}")
|
||||
|
||||
|
||||
def marks_for(booth: Path) -> list[Mark]:
|
||||
"""Every mark in a booth, oldest first, declarations normalized and answers
|
||||
folded in. ONE file read — which is the whole point of the storage shape."""
|
||||
entries = _read_raw(booth)
|
||||
marks = [_hydrate(e) for e in entries]
|
||||
marks = [_hydrate_safe(e) for e in entries]
|
||||
# (created, id) rather than created alone: two marks written in the same
|
||||
# second would otherwise order by however json listed them.
|
||||
marks.sort(key=lambda m: (m.created, m.id))
|
||||
@@ -357,16 +482,25 @@ def as_dict(mark: Mark) -> dict:
|
||||
# ---- write ------------------------------------------------------------------
|
||||
|
||||
|
||||
def declare_pick(booth: Path, mark_id: str, doc: dict) -> Mark:
|
||||
"""A session poses a pick.
|
||||
def declare_pick(booth: Path, mark_id: str, doc: dict, target: str | None = None) -> Mark:
|
||||
"""A session poses a pick, about the booth or about ONE item in it.
|
||||
|
||||
Validated through `normalize_ask` BEFORE anything is written, so a session
|
||||
cannot land a question the renderer would refuse. Re-declaring an existing
|
||||
id replaces the declaration and CLEARS its answer: the question changed, so
|
||||
the old judgment is not an answer to it.
|
||||
the old judgment is not an answer to it — and it may move the target, since
|
||||
a re-declaration is a new question.
|
||||
|
||||
`target` is an `Item.rel`, or None for the booth. It exists because the
|
||||
2026-09-09 ruling is that a question belongs WITH the artifact it is about: a
|
||||
four-voice audition wants the radio group under that voice. The record and
|
||||
the renderer both supported it before this parameter did, which meant a
|
||||
session could not actually produce one.
|
||||
"""
|
||||
if not valid_stem(mark_id):
|
||||
raise AskError("bad mark id: letters, digits, . _ - only")
|
||||
if not _valid_target(target):
|
||||
raise AskError("a pick's target must be a path inside the booth")
|
||||
normalize_ask(doc, mark_id) # raises AskError; nothing written yet
|
||||
with _Locked(booth) as lk:
|
||||
existing = lk.find(mark_id)
|
||||
@@ -375,7 +509,7 @@ def declare_pick(booth: Path, mark_id: str, doc: dict) -> Mark:
|
||||
entry = {
|
||||
"id": mark_id,
|
||||
"shape": PICK,
|
||||
"target": existing.get("target") if existing else None,
|
||||
"target": target,
|
||||
"created": existing.get("created") if existing else now_stamp(),
|
||||
"declaration": doc,
|
||||
"answer": None,
|
||||
@@ -551,10 +685,8 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
|
||||
created: list[dict] = []
|
||||
with _Locked(booth) as lk:
|
||||
have = {e.get("id") for e in lk.entries}
|
||||
by_id = {e.get("id"): e for e in lk.entries}
|
||||
for mtime, stem, decl, err in found:
|
||||
if stem in have:
|
||||
continue
|
||||
answer = None
|
||||
ap = booth / f"{stem}{ANSWER_SUFFIX}"
|
||||
try:
|
||||
@@ -563,11 +695,38 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
answer = loaded
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
prior = by_id.get(stem)
|
||||
if prior is not None:
|
||||
# The stem is already a mark, so the DECLARATION is not imported
|
||||
# — that is the idempotence rule, and a mark declared since the
|
||||
# sidecar outranks it. But a legacy ANSWER must not be stranded:
|
||||
# if the existing mark is an unanswered pick and the sidecar
|
||||
# holds the operator's choice, adopt it. Ordinary reads are
|
||||
# forbidden from looking at sidecars, so a skip here would lose
|
||||
# that judgment permanently.
|
||||
if (answer is not None
|
||||
and prior.get("shape") == PICK
|
||||
and prior.get("answer") is None):
|
||||
prior["answer"] = answer
|
||||
created.append(prior)
|
||||
continue
|
||||
|
||||
entry = {
|
||||
"id": stem,
|
||||
"shape": PICK,
|
||||
"target": None,
|
||||
"created": datetime.fromtimestamp(mtime).astimezone().isoformat(timespec="seconds"),
|
||||
# MICROSECONDS, not seconds. `found` is ordered by fractional
|
||||
# mtime and `marks_for` re-sorts on this string, so truncating
|
||||
# to the whole second threw away the only thing distinguishing
|
||||
# two sidecars written in the same second — and the `(created,
|
||||
# id)` tie-break then silently re-sorted them alphabetically,
|
||||
# reversing the order the importer had just established. The
|
||||
# ROADMAP states this import's order is `(mtime, name)`; an
|
||||
# order that is stated and not kept is worse than one never
|
||||
# claimed.
|
||||
"created": datetime.fromtimestamp(mtime).astimezone().isoformat(
|
||||
timespec="microseconds"),
|
||||
"declaration": decl,
|
||||
"answer": answer,
|
||||
}
|
||||
|
||||
@@ -13,11 +13,35 @@
|
||||
Works with JS off — plain form POST, every shape. An answered pick shows the
|
||||
recorded judgment and a collapsed "change" form, because the mark is the
|
||||
CURRENT judgment and not a log. #}
|
||||
{# A mark carrying `error` is sorted out FIRST, whatever shape it claims. A
|
||||
pick keeps its own ⚠ broken rendering below (richer — it has a declaration to
|
||||
show); a broken note would otherwise render as an empty <pre> with a withdraw
|
||||
button, indistinguishable from a note the operator wrote and then cleared,
|
||||
and a broken flag would link to a target that is not there. Unreadable state
|
||||
is visible state — the rule `_hydrate` states for picks, applied to all
|
||||
three. #}
|
||||
{% set broken = marks | selectattr('error') | rejectattr('shape', 'equalto', 'pick') | list %}
|
||||
{% set picks = marks | selectattr('shape', 'equalto', 'pick') | list %}
|
||||
{% set notes = marks | selectattr('shape', 'equalto', 'note') | list %}
|
||||
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | list %}
|
||||
{% set notes = marks | selectattr('shape', 'equalto', 'note') | rejectattr('error') | list %}
|
||||
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
|
||||
<section class="marks">
|
||||
|
||||
{% for a in broken %}
|
||||
<article class="mark mark-note is-broken" id="mark-{{ a.id }}">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state">⚠ broken</span>
|
||||
<span class="mark-id"><code>{{ a.id }}</code></span>
|
||||
<span class="board-spacer"></span>
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
|
||||
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<button type="submit" class="mark-x" title="withdraw this mark">×</button>
|
||||
</form>
|
||||
</header>
|
||||
<p class="mark-error">This mark could not be read: {{ a.error }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% for a in picks %}
|
||||
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
|
||||
<header class="mark-head">
|
||||
|
||||
@@ -94,7 +94,11 @@
|
||||
back to the flagged items. Always rendered on a gallery booth — the add-note
|
||||
field is a control, not a result, so it has to be there before the first
|
||||
mark exists. #}
|
||||
{% if not board %}
|
||||
{# `marks or not board`: the standing link board renders as a board rather than
|
||||
a gallery, and the add-note control would be noise on it — but the
|
||||
suppression was unconditional, so a pick declared on a booth that happens to
|
||||
carry a links.md had no form to answer it and nothing said so. #}
|
||||
{% if marks or not board %}
|
||||
{% include "_marks.html" %}
|
||||
{% endif %}
|
||||
|
||||
@@ -187,6 +191,12 @@
|
||||
{% else %}
|
||||
<pre class="textview doc-body">{{ it.rendered }}</pre>
|
||||
{% endif %}
|
||||
{# The doc branch had `markcontrols` and not `marknotes`, so the
|
||||
operator could point at a report and not write down why — on the
|
||||
one item kind whose whole content is prose. Exactly the
|
||||
"patched two of three" failure the blurtoggle macro above was
|
||||
written to prevent, recurring on the macro written to prevent it. #}
|
||||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||||
</details>
|
||||
</figure>
|
||||
{% else %}
|
||||
|
||||
@@ -33,8 +33,18 @@
|
||||
white-space:pre-wrap}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
/* Escape leaves the page, so it must not fire from inside a field someone
|
||||
is typing in — the same guard the image viewer carries, stated in both
|
||||
places because the handler is on `document` in both. */
|
||||
function isEditable(el) {
|
||||
return !!(el && (el.isContentEditable ||
|
||||
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (isEditable(e.target)) return;
|
||||
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -99,7 +99,17 @@
|
||||
img.addEventListener('load', evaluate);
|
||||
window.addEventListener('resize', evaluate);
|
||||
if (img.complete) evaluate();
|
||||
|
||||
/* An arrow key inside the note field is a CARET move, not a navigation.
|
||||
The handler is on `document` and the note textarea shipped into this same
|
||||
page, so typing a note and reaching for ← threw the draft away; Escape
|
||||
did it in one keystroke. Anything editable keeps its own keys. */
|
||||
function isEditable(el) {
|
||||
return !!(el && (el.isContentEditable ||
|
||||
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (isEditable(e.target)) return;
|
||||
if (e.key === 'Escape') window.location.href = BACK;
|
||||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||||
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
||||
|
||||
@@ -208,10 +208,13 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
`asks.html:11` — and three in Python — `app.py:271` (the index badge),
|
||||
`app.py:750` and `app.py:751` (the verbatim-booth chip).
|
||||
- **INV-3 — the judgment travels, like the caption.** U1's rule, extended:
|
||||
every surface that renders an item renders that item's marks. Gallery tile,
|
||||
zoom view, doc view. *Falsifiable:* fetch `/b/<n>/view?f=<img>` for a flagged
|
||||
item carrying a note and assert both the flag state and the note text are in
|
||||
the served HTML.
|
||||
every surface that renders an item renders that item's marks. *Falsifiable,
|
||||
once per surface* — the first draft named three surfaces and checked one, which
|
||||
all four panel arms flagged as the document's strongest ambiguity: (a) the
|
||||
**gallery tile** shows the flag control in its current state and the item's
|
||||
notes; (b) the **zoom view** `/b/<n>/view?f=<img>` carries the flag state and
|
||||
the note text; (c) the **doc view** `/b/<n>/view?f=<doc>` carries the note
|
||||
text. Three tests, not one.
|
||||
- **INV-4 — the pick semantics are byte-identical.** `build_answer` produces,
|
||||
for every input, the document `write_answer` produced. *Falsifiable:* the
|
||||
existing `test_asks.py` answer assertions pass against `build_answer` with
|
||||
@@ -275,6 +278,66 @@ out here because it is a visible change to what the index shows, it is the kind
|
||||
of thing that looks like a bug when it lands, and the operator should get to
|
||||
veto it rather than discover it.
|
||||
|
||||
## Cross-frontier contract panel — 2026-09-22, four arms, artifact-only
|
||||
|
||||
`/heid-contract-review` panel (Gróa / Hulda / Regin / Kimi), thread
|
||||
`01M33VSNFER4N1554G0Y0VC9C8`, dispatched before implementation and triaged after
|
||||
it. Every quoted passage was verified verbatim by Heid; no arm fabricated an
|
||||
identifier. Triaged per the five-category rule — what follows is the disposition,
|
||||
not the reply.
|
||||
|
||||
**Three of these were defects in shipped code, not ambiguities in prose.** v0.2.0
|
||||
was already tagged and announced to 15 handles when they landed.
|
||||
|
||||
| finding | arms | category | disposition |
|
||||
|---|---|---|---|
|
||||
| **A write over a corrupt `.marks.json` silently replaced every mark in the booth.** The read path is deliberately lenient (unparseable → `[]` so the page loads); the write path inherited that through the same reader, so one flag click appended to an empty list and atomically replaced the file. | Kimi F2, Hulda F3 | **1 — genuine add** | **FIXED.** `MarksCorrupt`, raised by a strict `_read_raw_strict` used only by the write path. Read stays lenient, write goes strict; the damaged bytes are left on disk. Routes return 409, not 500. Reproduced first, then fixed. |
|
||||
| **`declare_pick` had no `target`**, so a pick could not be attached to an item — though `Mark.target` carried one, `marks_for_target` retrieved it, and `_marks.html` already rendered "on \<item\>". | Hulda F1, Regin | **1 — genuine add** | **FIXED.** `declare_pick(..., target=None)`, validated like every other target. A re-declaration may move it. |
|
||||
| **The importer stranded a legacy answer.** A stem already present as a mark was skipped wholesale, so a re-declared-but-unanswered pick with the operator's choice sitting in `<stem>.answer.json` lost that choice permanently — reads are forbidden from looking at sidecars. | Gróa F10 | **1 — genuine add** | **FIXED.** The declaration is still skipped (idempotence), but a legacy answer is ADOPTED when the existing mark is an unanswered pick. An answer made through marks is never overwritten. |
|
||||
| **INV-3 names "doc view" as a protected surface; nothing tested it.** Shipping the doc view unmarked would have passed. | 4/4 — the panel's strongest convergence | **1 — genuine add** | **TEST ADDED.** The behaviour was already implemented; the gate caught that nothing held it. INV-3's falsifiable below now covers all three surfaces. |
|
||||
| **The broken-declaration path is three different doors and none is written:** validate-before-write, stored-raw-with-read-time-error, and unparseable-file-yields-`[]`. | 4/4 | **1 — genuine add, prose only** | **PINNED below.** All three are real and distinct cases; the code always handled them separately. The contract conflated them. |
|
||||
| **"What counts as open" is defined three ways** across assumptions, the signature comment and a test row. | Gróa F1, Regin F5, Hulda F4 | **1 — genuine add, prose only** | **PINNED below.** Code and tests were already correct (partial = open). |
|
||||
| **INV-2 and INV-5's checks comply in letter:** openness can be re-derived as `(answer or {}).get("complete")` with the grep still green; `importlib` inside a function defeats the AST walk. | Gróa F5/F7, Kimi F4/F5 | **4 — out of place** | Accepted as true and NOT closed. Both describe a future careless change, and the honest statement is that these checks raise the cost of drifting rather than making it impossible. Recorded rather than papered over. |
|
||||
| **INV-6 named `_with_marks(booth)`; the code has `_Locked`.** And its falsifiable makes the mandated helper unimplementable, since the helper must itself call `os.replace`. | Gróa F8, Kimi F8 | **2 — sharpening** | **FIXED below** — the name and the exemption. |
|
||||
| `set_flag`'s annotation forbids a booth-level flag; never stated as a decision. | Regin F6 | **2 — sharpening** | It IS a decision: a flag means *this one*, so it needs an item. Stated in the signature. |
|
||||
| "Cleaning" note text is defined by example only. | Kimi F6, Hulda F6 | **2 — sharpening** | `_clean_text` is CRLF-normalize, strip, truncate at `TEXT_MAX`. Documented at the function. |
|
||||
| INV-1 self-conflict: the rule allows one function, the check and assumptions exempt `booth_items`' name check. | Gróa F6 | **3 — settled prior** | Already resolved by the seam review (SR-9): the exemption is a NAME check, never a content read. |
|
||||
|
||||
**The methodology note the arms volunteered, which is worth more than any single
|
||||
flag:** this contract's own frontmatter carries a plain-language narrative, so the
|
||||
paraphrase half was partly re-reading the author's framing back to him. Regin and
|
||||
Kimi both said the stronger shape for a narrative-heavy contract is the ambiguity
|
||||
pass with the paraphrase cut to a drift-check. That is a finding about the
|
||||
*mechanism*, not this document, and it belongs in the skill rather than here.
|
||||
|
||||
### The three pinnings
|
||||
|
||||
**Openness, normatively, once.** A mark is open when `shape == "pick"` **and** it
|
||||
has no `error` **and** (`answer is None` **or** `answer["complete"]` is false). A
|
||||
**partially answered pick is OPEN.** Every other sentence in this document about
|
||||
openness is descriptive; this one governs, and `open_marks` is its only
|
||||
implementation.
|
||||
|
||||
**A broken declaration, normatively — three distinct cases, not one.**
|
||||
|
||||
1. `declare_pick` validates through `normalize_ask` and **raises `AskError`
|
||||
before writing anything.** A session cannot land a refused question. The
|
||||
function never returns an invalid mark.
|
||||
2. A declaration that is invalid **in the stored file** — reachable via the
|
||||
importer, or a hand-edit — is hydrated with `error` set and is rendered, so a
|
||||
question the session believes it posted is never silently hidden. It is not
|
||||
open (it can never be answered), and it cannot be answered: `answer_pick`
|
||||
re-validates and raises.
|
||||
3. A **whole file** that cannot be parsed is not a broken declaration. `marks_for`
|
||||
returns `[]` so the page loads; every WRITE refuses with `MarksCorrupt`.
|
||||
|
||||
**INV-6, corrected.** Every writer goes through the one `_Locked(booth)` context
|
||||
manager, which holds an exclusive flock on `<booth>/.marks.lock` across read,
|
||||
mutate and atomic replace. *Falsifiable:* no function outside `_Locked` calls
|
||||
`_write_raw` or `os.replace` on the mark file. (The first draft named a
|
||||
`_with_marks` helper that does not exist, and forbade the very calls the helper
|
||||
must make.)
|
||||
|
||||
## Slices
|
||||
|
||||
Vertical, each one shippable and green before the next starts.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# The U2 bug-hunt panel — full triage
|
||||
|
||||
**Date:** 2026-09-22 · **Thread:** `01M33XEC1H0298C0D968FWBN7A` ·
|
||||
**Reply:** `01M33YZZ1VYGZ04JGNXNTBXDKS` · **Shipped as:** `v0.2.2`
|
||||
|
||||
`/heid-bug-hunt` on U2's diff (+2251/−632, 20 sections, 18 post-change
|
||||
snapshots). Four arms — Gróa (Grok), Hulda (Codex), Regin (GLM-5.2), Kimi
|
||||
(kimi-k3) — artifact-only, 4/4 clean transport. Heid adjudicated **9 findings
|
||||
(6 bug / 3 robustness)**. Staleness was disclosed at build: `app.py` was edited
|
||||
after the 06:38:52Z capture.
|
||||
|
||||
## Triage, five-category
|
||||
|
||||
### Category 1 — genuine add (8 taken, all shipped)
|
||||
|
||||
| # | finding | where | why it was real |
|
||||
|---|---|---|---|
|
||||
| 1 | Lock-inode split on the no-op unlink (**4/4 convergent**) | `marks._Locked` | `flock` binds to an inode; unlinking under a waiter destroys mutual exclusion silently |
|
||||
| 2 | No-op lock churn resets the TTL via **directory** mtime | `marks._Locked` + `app._newest_mtime` | the guard's own comment reasons about the lock FILE's mtime; the directory is what the sweeper reads |
|
||||
| 3 | Non-string `text` / `created` raise out of the read path | `marks._clean_text`, `marks_for` sort | `list_booths` reads every booth per page load → one bad file 500s `/` and `/healthz` |
|
||||
| 4 | Legacy import stamped `created` at whole-second resolution | `marks.import_legacy_asks` | same-second sidecars re-sorted alphabetically, reversing the order the importer had just set — violates the stated `(mtime, name)` rule |
|
||||
| 5 | `/answer` 500s on a non-string `notes` form value | `app.booth_answer` | the sibling `/note` guards it; same parser, same class of value, two answers |
|
||||
| 6 | All five mark-write routes hold a blocking `flock` on the event loop | `app.py` | a contended lock freezes every route, not just the one request |
|
||||
| 7 | CLI conflates a reader crash with "open" / "unanswered" | `scripts/booth` | `marks` printed a traceback and exited 0; `answer --wait` spun the full hour on a damaged file |
|
||||
| 8 | The inline-doc tile had `markcontrols` and not `marknotes` | `booth.html` | flag a report, cannot say why — on the one item kind that is prose |
|
||||
|
||||
Two more taken on the same sweep, found while fixing the above rather than by
|
||||
the panel: a broken mark of any shape now renders **⚠ broken** instead of as an
|
||||
empty note (the rule `_hydrate` states for picks, applied to all three shapes),
|
||||
and the marks panel is no longer suppressed on a booth that carries a
|
||||
`links.md` *and* has marks.
|
||||
|
||||
### Category 3 — restatement of a settled prior (1, no change)
|
||||
|
||||
**Corrupt read → filtered writeback → silent deletion** (hulda F2, kimi F3,
|
||||
gróa F4; Heid ranked it #3). **Already fixed in `v0.2.1`** by
|
||||
`_read_raw_strict` + `MarksCorrupt` — reads lenient, writes strict. The panel
|
||||
reviewed the pre-fix capture and the staleness was disclosed up front. Verified
|
||||
against the current source before declining, not assumed.
|
||||
|
||||
This is the exact case the cross-frontier triage discipline warns about: a
|
||||
confident, well-argued, four-arm-corroborated finding against code that no
|
||||
longer exists. **Check what the peer actually read before treating an omission
|
||||
or a defect claim as new.**
|
||||
|
||||
### Category 4 — out of place, parked (2)
|
||||
|
||||
- **Note-id recycling** (`note-1` reused after a withdrawal) lets a stale tab
|
||||
delete a newer note. Real mechanism; needs two tabs and an interleaving, and
|
||||
the Booth has one viewer. Non-reused ids are a schema change, not a patch.
|
||||
- **Unvalidated flag / note targets** accumulate orphan marks. Targets come
|
||||
from rendered items; the operator is the only writer through the browser.
|
||||
|
||||
### Category 5 — wrong-grounding (1)
|
||||
|
||||
**`delete_mark` can remove a pick, not only a note.** Framed as an
|
||||
access-control divergence. There is no auth by design, and restricting it would
|
||||
remove the only way to withdraw a pick that hydrates broken. Declined; the
|
||||
docstring is the thing that was imprecise, not the behaviour.
|
||||
|
||||
## What the round is worth remembering for
|
||||
|
||||
1. **The two review gates stayed complementary a second time.** The contract
|
||||
panel (2026-09-21) found three defects; this bug-hunt found eight more, with
|
||||
**no overlap**. Both ran on the same unit. Neither substitutes.
|
||||
2. **The panel beat the code's own comments three times.** The bundle's comments
|
||||
are unusually honest and still wrong about what protected the TTL, and
|
||||
"written atomically" sat next to a filter-then-replace. **A comment is a
|
||||
claim, and a claim can be tested.**
|
||||
3. **The headline bug class shipped with zero guard coverage, and both mutation
|
||||
tables said so.** `test_a_no_op_write_does_not_touch_the_booth` asserted only
|
||||
that `.marks.json` was absent — so removing the lock unlink, removing the
|
||||
whole lock lifecycle, or bumping the directory clock all **SURVIVED** it. The
|
||||
test asserted an artifact of the property instead of the property. The
|
||||
replacement asserts `booth_age_seconds` directly, with a positive control (a
|
||||
real mark still resets the clock) so the fix cannot overshoot into "marking
|
||||
is never activity".
|
||||
4. **`scripts/booth` had no tests at all** and two findings lived there. It has
|
||||
five now, running the real script under the system `python3`.
|
||||
+122
-31
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — booth
|
||||
|
||||
_Last updated: 2026-09-21_
|
||||
_Last updated: 2026-09-22_
|
||||
|
||||
> **Always check for `/tmp/booth-dev-handoff.md`** — if it exists and its
|
||||
> `Written:` stamp is under 8 hours old, read it (it carries the in-flight
|
||||
@@ -17,40 +17,118 @@ loop it turned out to actually be.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-09-21:_
|
||||
_As of 2026-09-22:_
|
||||
|
||||
- **v1 is gated on seven units** in `ROADMAP.md`, ordered by dependency:
|
||||
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent** of all of them.
|
||||
- **U1 (one item record) has landed** at `ce598b3` and is verified against its
|
||||
own invariants, not just its commit message: INV-1 holds (no `classify` /
|
||||
`doc_kind` / `read_blurred` / `render_doc` call survives in a route body),
|
||||
the zoom and doc templates render the caption they now receive, the
|
||||
re-exports are asserted by a test. 192 tests green, `0.1.15`.
|
||||
- **U2 (marks) has landed** — `booth/marks.py`, contract at
|
||||
`docs/contracts/u2_marks.contract.md`, 242 tests green. Not yet deployed.
|
||||
- **U2 is DEPLOYED and the migration is done.** The service was restarted
|
||||
2026-09-21 23:41 and again after the `auto_reload` fix; all four legacy
|
||||
sidecars imported (`dfa-concepts/dfa`, `run07-decisions/decisions`,
|
||||
`sc-iso-spread/spread`, `sindra-voice-1/anchor`, all still open) with the
|
||||
sidecars left on disk. Verified live: index + 25 booths x {booth page, marks
|
||||
page, marks.json} all 200, plus zoom views on five booths.
|
||||
- **Still needs the operator: the release tier.** U2 changes the CLI surface for
|
||||
17 consuming handles (`booth asks` -> `booth marks`, new `marks-import`) and is
|
||||
a v1 unit, so it reads minor-worthy — which needs explicit approval per the
|
||||
SemVer rule. Nothing is bumped or tagged; the work is committed as SHAs.
|
||||
- **`/heid-contract-review` on the U2 contract is still in flight** (panel mode,
|
||||
posted 2026-09-21, redacted copy at
|
||||
`/tmp/heid-contract-review/booth-20260922-061015/`). Triage it when it lands —
|
||||
the code is written, so findings land as follow-up fixes rather than contract
|
||||
edits. The seam review ran in-session and its nine findings are already folded
|
||||
into the contract and the code.
|
||||
- **Open, operator's call:** whether U6 (benches) runs in parallel with U2 or
|
||||
strictly after it. Nothing blocks on the answer; U6 touches different storage
|
||||
and a different surface, so it cannot be broken by U2.
|
||||
- Live service is `active` on `:8090` (systemd `--user`), 25 booths.
|
||||
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
|
||||
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
||||
- **U1 and U2 are landed and released.** Current version `0.2.2`, deployed to the
|
||||
live service, 275 tests green, tree clean, 25/25 booth pages verified 200 after
|
||||
the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as
|
||||
`v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the
|
||||
**bug-hunt panel's** nine (below).
|
||||
- **U5 is the next unit** (operator, 2026-09-21): **self-announcing booths.**
|
||||
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from
|
||||
`$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and
|
||||
the index becomes the "what landed" feed the link board was being used as. It
|
||||
closes job 5 of the five jobs — the one nobody named, and the reason 145 dead
|
||||
link rows existed. Nothing started: no contract, no blast-radius pass.
|
||||
- **Two things about U5 are already settled and should not be re-derived.**
|
||||
(1) `.booth.json` is a DOTFILE, so `booth_items`' existing `startswith(".")` skip
|
||||
already keeps it out of tiles, counts and zips — the same reason `.marks.json`
|
||||
needed no new exclusion rule. (2) The deterministic-order invariant applies to
|
||||
whatever U5 adds to the index; the index is ordered newest-first by mtime today
|
||||
and that rule must stay stated. Also worth knowing before scoping: enforcing the
|
||||
link rule without giving job 5 a home first just makes it homeless — that is the
|
||||
lesson from the 69% rot, and U5 is the home.
|
||||
- **No heid dispatch is outstanding.** The `/heid-bug-hunt` on U2's diff landed
|
||||
2026-09-22 and shipped as `v0.2.2`; see the dated entry below.
|
||||
- Live service `active` on `:8090`, 25 booths, verified 25 × 3 page types after the
|
||||
last deploy. The booth set churns: `sindra20-engines` and `sindra-finalists` were
|
||||
swept during the session, `cr123a-to-d-sleeve` and `sindra` appeared.
|
||||
|
||||
## Recent decisions
|
||||
|
||||
- `[2026-09-22]` **The U2 bug-hunt panel landed and it was not ceremony —
|
||||
`v0.2.2`.** Nine adopted findings across four arms; eight were real against
|
||||
live code and one was already fixed. The headline was **4/4 convergent from
|
||||
four different angles**: `_Locked.__exit__` unlinked `.marks.lock` on the no-op
|
||||
path, and `flock` binds to an INODE — so a writer blocked on the old inode
|
||||
proceeds while the next writer creates a fresh lock file and takes it at once.
|
||||
Two processes then run the read-modify-write concurrently and the later
|
||||
`os.replace` drops a mark, with both of them obeying the protocol. **The
|
||||
cleanup existed to protect the booth's TTL and it was failing at that too**:
|
||||
creating and removing a directory entry bumps the DIRECTORY's mtime, which is
|
||||
what `_newest_mtime` actually seeds from, so a no-op reset the clock it was
|
||||
written to leave alone. Same code region, two defects, one fix — never unlink
|
||||
the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and put the
|
||||
directory's mtime back after creating one. Full triage in
|
||||
`persistent-memory.d/2026-09-22-bug-hunt-panel.md`.
|
||||
- `[2026-09-22]` **The lenient reader's blast radius was the whole service, not
|
||||
one booth.** `_clean_text` did `(text or "").replace(...)` and `marks_for`
|
||||
sorts on `(created, id)`, so a stored `text` that was a dict or a `created`
|
||||
that was a number raised out of the READ path — and `list_booths` reads every
|
||||
booth's marks on every index load. One hand-edited file 500'd `/` and
|
||||
`/healthz` for all 25 booths. Fixed in two layers, matching the house posture:
|
||||
a named type check (`_entry_type_error`) plus a `_hydrate_safe` backstop that
|
||||
cannot raise, and the panel now RENDERS an unreadable mark as ⚠ broken instead
|
||||
of as an empty note. **The general shape: a lenient reader is only lenient if
|
||||
the leniency is bounded by where it runs.** `marks_for` was written for one
|
||||
booth's page and is called in a loop over every booth.
|
||||
- `[2026-09-22]` **`booth marks` / `booth answer` got real exit codes**, because
|
||||
a read that CRASHED was indistinguishable from a read that said no. `marks`
|
||||
printed a traceback and exited 0 (a caller's `jq` saw success and got
|
||||
nothing); `answer --wait` read a damaged file as "not yet" and spun for the
|
||||
full hour before blaming the operator. Now `0 ok · 1 unanswered/timed-out ·
|
||||
2 no such pick · 3 unreadable`, and `read_error()` was added to `marks.py` so
|
||||
the CLI can ask the question the browser must not: the page stays lenient, the
|
||||
machine consumer gets the truth. Also `--wait` now prints ONCE — it was
|
||||
emitting a whole JSON document per poll, so a captured `--wait` held several
|
||||
concatenated values and parsed as none of them.
|
||||
- `[2026-09-22]` **`scripts/booth` had zero tests and now has five**
|
||||
(`tests/test_cli.py`). The panel's guard-strength tables returned UNVERIFIED
|
||||
for every CLI claim because nothing in the suite executed the script — two of
|
||||
the round's findings lived in exactly that gap. The new tests run the real
|
||||
script under the system `python3`, which makes them a live check on INV-1
|
||||
(stdlib-only) as a side effect: a third-party import in `marks.py` now fails
|
||||
in the suite the same way it would fail on a fleet host.
|
||||
|
||||
- `[2026-09-21]` **v0.2.0 cut and announced; v0.2.1 fixed what the announcement
|
||||
was already wrong about.** Operator approved the minor (a v1 unit closed plus a
|
||||
CLI surface change for 17 consuming handles clears the release-note bar). The
|
||||
note went to 15 handles — the 17 link-board posters minus `nh3-dev`, a host
|
||||
label, and `heid`, an oracle that does not script these verbs. Then the
|
||||
cross-frontier contract panel landed and found **three defects in the code I had
|
||||
just released**, so `v0.2.1` shipped within the hour. Sequence worth remembering:
|
||||
the release was correct by the tier bar and still premature by the discipline —
|
||||
the panel had been dispatched BEFORE implementation and its reply arrived AFTER
|
||||
the tag. **If a gate is in flight, the tag can wait for it.**
|
||||
- `[2026-09-21]` **A write over a damaged `.marks.json` was wiping every mark in
|
||||
the booth.** Shipped in `v0.2.0`, found by the panel (Kimi, converged with
|
||||
Hulda), fixed in `v0.2.1`. `marks_for` is deliberately lenient — unparseable
|
||||
reads as `[]` so a review page still loads — and the write path inherited that
|
||||
leniency through the same reader, so one flag click appended to an empty list and
|
||||
atomically replaced the file. The fix is an **asymmetry**, which is the reusable
|
||||
part: reads stay lenient, writes go strict (`MarksCorrupt`), damaged bytes stay
|
||||
on disk, routes answer 409 not 500. A page that renders without an annotation is
|
||||
recoverable; a file that overwrote the operator's judgment is not. Kimi also
|
||||
named the class correctly — "an author steeped in the design conversation would
|
||||
likely read past" it — and that was accurate.
|
||||
- `[2026-09-21]` **The two review gates are complementary, measured on one unit.**
|
||||
The caller-side **seam review** (nine findings, against the real sibling module
|
||||
surfaces) and the cold **`/heid-contract-review` panel** (four arms,
|
||||
artifact-only) had **zero overlap in both directions** on U2. The seam review
|
||||
found a scope miss the panel structurally could not see: the contract omitted
|
||||
`inline.py`, whose `place()` indexes by subscript, which a frozen dataclass
|
||||
refuses. The panel found three code defects and a missing test the seam review
|
||||
had no lens for. Matches heid's kvasir zero-overlap result on the
|
||||
conformance-versus-hunt axis. **Run both; neither substitutes.**
|
||||
- `[2026-09-21]` **Every one of the panel's code-changing findings came from the
|
||||
AMBIGUITY pass, none from a paraphrase divergence** — and two arms independently
|
||||
proposed cutting the paraphrase to a drift-check for narrative-heavy contracts,
|
||||
because this contract's own frontmatter carries a plain-language narrative and the
|
||||
paraphrase was partly reading my framing back to me. That is a finding about the
|
||||
`/heid-contract-review` **skill**, not about this repo, and it was reported back
|
||||
to heid. Recorded here only so a future session does not rediscover it.
|
||||
- `[2026-09-21]` **Deterministic order is a cross-cutting v1 invariant** —
|
||||
operator directive, mid-implementation. Every ordered collection the Booth
|
||||
renders must have a *stated* rule producing the same sequence on every render
|
||||
@@ -149,6 +227,19 @@ _As of 2026-09-21:_
|
||||
|
||||
## Tried and abandoned
|
||||
|
||||
- `[2026-09-21]` **Tagging a release while a review gate was still in flight.**
|
||||
`v0.2.0` was cut and announced to 15 consuming handles; the
|
||||
`/heid-contract-review` panel — dispatched BEFORE implementation, as the
|
||||
discipline says — replied afterwards with three defects in the code that had just
|
||||
shipped, one of them silent data loss. Nothing about the tier decision was wrong;
|
||||
the *timing* was. **If a gate is outstanding on the work being released, the tag
|
||||
waits for it.** The cost was a same-hour `v0.2.1` and a correction note to peers
|
||||
who had already verified against the broken version.
|
||||
- `[2026-09-21]` **Letting the write path share the read path's leniency.** See the
|
||||
`MarksCorrupt` decision above. The general shape, worth carrying beyond marks:
|
||||
a tolerant reader and a tolerant writer over the same state are not the same
|
||||
decision, and pointing both at one function silently makes them one. Tolerate on
|
||||
read so the surface still renders; refuse on write so nothing is destroyed.
|
||||
- `[2026-09-21]` **Letting Jinja hot-reload templates while the repo is the
|
||||
deployment root** — the cause of a live outage the same day U2 landed, and the
|
||||
sharpest foot-gun in the repo. `booth.service` sets `WorkingDirectory` to this
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+76
-17
@@ -22,6 +22,13 @@
|
||||
# booth answer <name> <id> [--wait [SECS]]
|
||||
# print ONE pick's answer (exit 1 if unanswered);
|
||||
# --wait polls until it lands (default 3600 s)
|
||||
#
|
||||
# EXIT CODES for the two reading verbs. A read that FAILED gets its own code so
|
||||
# a caller can tell "not yet" from "the file is damaged" — conflating them is
|
||||
# how a broken `.marks.json` used to look like an unanswered question and wait
|
||||
# out the full hour.
|
||||
# marks 0 read ok · 1 --wait timed out with picks open · 3 unreadable
|
||||
# answer 0 answered · 1 unanswered · 2 no such pick · 3 unreadable
|
||||
# booth marks-import <name> import legacy *.ask.json into .marks.json
|
||||
# booth asks <name> alias for `marks` (deprecated)
|
||||
#
|
||||
@@ -79,7 +86,7 @@ BLUR=".blurred" # one booth-relative item path per line; see `blur` below
|
||||
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
||||
|
||||
usage() {
|
||||
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
||||
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -255,7 +262,7 @@ print("removed: %s %s" % (removed["desc"], removed["url"]))
|
||||
import os, pathlib, sys
|
||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||
from booth.asks import AskError
|
||||
from booth.marks import declare_pick
|
||||
from booth.marks import MarksCorrupt, declare_pick
|
||||
booth, mid, prompt, *opts = sys.argv[1:]
|
||||
try:
|
||||
declare_pick(pathlib.Path(booth), mid,
|
||||
@@ -263,11 +270,22 @@ try:
|
||||
"notes": os.environ["ASK_NOTES"] == "1"})
|
||||
except AskError as exc:
|
||||
sys.exit("bad pick: %s" % exc)
|
||||
except MarksCorrupt as exc:
|
||||
sys.exit("this booth'"'"'s .marks.json is damaged, so nothing was written: %s" % exc)
|
||||
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
|
||||
echo "$URL/b/$name/#mark-$mid"
|
||||
;;
|
||||
marks|asks)
|
||||
# booth marks <name> [--wait [SECS]] (`asks` is the deprecated alias)
|
||||
#
|
||||
# EXIT CODES. 0 = the read succeeded and the document is on stdout; 1 =
|
||||
# --wait gave up with picks still open (the document is still printed); 3 =
|
||||
# the marks could not be read at all. A reader that CRASHED must never look
|
||||
# like an answer — the old shape printed a traceback and exited 0, so a
|
||||
# caller piping to `jq` saw success and got nothing.
|
||||
#
|
||||
# Whether anything is still open is in the payload's `open` list. The read
|
||||
# verb does not encode it in its status: a successful read is a success.
|
||||
[ $# -ge 1 ] || usage
|
||||
name="$1"; shift
|
||||
wait_s=0
|
||||
@@ -276,19 +294,41 @@ except AskError as exc:
|
||||
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
|
||||
deadline=$(( $(date +%s) + wait_s ))
|
||||
while :; do
|
||||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||
# CAPTURED, not streamed. Printing inside the loop wrote one whole JSON
|
||||
# document per poll, so `booth marks b --wait | jq` got several values
|
||||
# concatenated and could parse none of them. The wait is a wait; the
|
||||
# print is the result, and it happens once.
|
||||
rc=0
|
||||
out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||
import json, os, pathlib, sys
|
||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||
from booth.marks import as_dict, marks_for, open_marks
|
||||
marks = marks_for(pathlib.Path(sys.argv[1]))
|
||||
print(json.dumps({"marks": [as_dict(m) for m in marks],
|
||||
try:
|
||||
from booth.marks import as_dict, marks_for, open_marks, read_error
|
||||
booth = pathlib.Path(sys.argv[1])
|
||||
# Ask FIRST whether the file is readable. `marks_for` answers "no marks"
|
||||
# for a damaged file, which is the right answer for a page and the wrong
|
||||
# one for a session that wants to know whether its question survived.
|
||||
broken = read_error(booth)
|
||||
if broken:
|
||||
print(f"booth: {broken}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
marks = marks_for(booth)
|
||||
doc = json.dumps({"marks": [as_dict(m) for m in marks],
|
||||
"open": [m.id for m in open_marks(marks)]},
|
||||
ensure_ascii=False, indent=2))
|
||||
sys.exit(1 if open_marks(marks) else 0)
|
||||
' "$DATA/$name" && exit 0
|
||||
# exit 1 from the reader means at least one pick is still open
|
||||
if [ "$wait_s" -eq 0 ]; then exit 0; fi
|
||||
ensure_ascii=False, indent=2)
|
||||
except Exception as exc:
|
||||
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
print(doc)
|
||||
sys.exit(2 if open_marks(marks) else 0)
|
||||
' "$DATA/$name")" || rc=$?
|
||||
case "$rc" in
|
||||
0) printf '%s\n' "$out"; exit 0 ;; # read ok, nothing open
|
||||
2) if [ "$wait_s" -eq 0 ]; then printf '%s\n' "$out"; exit 0; fi ;;
|
||||
*) echo "cannot read marks in $name" >&2; exit 3 ;;
|
||||
esac
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
printf '%s\n' "$out"
|
||||
echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
|
||||
fi
|
||||
sleep 2
|
||||
@@ -302,20 +342,39 @@ sys.exit(1 if open_marks(marks) else 0)
|
||||
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
||||
deadline=$(( $(date +%s) + wait_s ))
|
||||
while :; do
|
||||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||
rc=0
|
||||
out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||
import json, os, pathlib, sys
|
||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||
from booth.marks import marks_for
|
||||
try:
|
||||
from booth.marks import marks_for, read_error
|
||||
booth, mid = sys.argv[1:3]
|
||||
m = next((x for x in marks_for(pathlib.Path(booth)) if x.id == mid), None)
|
||||
broken = read_error(pathlib.Path(booth))
|
||||
if broken:
|
||||
print(f"booth: {broken}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
# id AND shape, matching the web route. Matching on id alone reported a
|
||||
# note id as "unanswered" and then polled it for an hour — a question that
|
||||
# could never be answered because it was never a question.
|
||||
m = next((x for x in marks_for(pathlib.Path(booth))
|
||||
if x.id == mid and x.shape == "pick"), None)
|
||||
except Exception as exc:
|
||||
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
if m is None:
|
||||
sys.exit(2)
|
||||
if m.answer is None:
|
||||
sys.exit(1)
|
||||
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
|
||||
' "$DATA/$name" "$mid" && exit 0
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ]; then echo "no such pick: $name/$mid" >&2; exit 1; fi
|
||||
' "$DATA/$name" "$mid")" || rc=$?
|
||||
case "$rc" in
|
||||
0) printf '%s\n' "$out"; exit 0 ;;
|
||||
2) echo "no such pick: $name/$mid" >&2; exit 2 ;;
|
||||
# A read that FAILED is not "not yet". Conflating them sent --wait
|
||||
# spinning for the full hour on a broken file and then blamed the
|
||||
# operator for not answering.
|
||||
3) echo "cannot read marks in $name" >&2; exit 3 ;;
|
||||
esac
|
||||
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""`scripts/booth` — the surface every fleet session actually calls.
|
||||
|
||||
It had no tests at all, which the 2026-09-22 bug-hunt panel found the hard way:
|
||||
its guard-strength table returned UNVERIFIED for every CLI claim because nothing
|
||||
in the suite executes the script. Two of that round's findings live in here.
|
||||
|
||||
These run the real script under the real system `python3` with no venv, which
|
||||
also makes them a live check on INV-1 (stdlib-only): a third-party import in
|
||||
`marks.py` fails here the same way it fails on a fleet host.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).parent.parent / "scripts" / "booth"
|
||||
|
||||
# Exit codes the verbs promise. 0 is a successful read; a reader that CRASHED
|
||||
# must never be one of the meaningful codes, or a caller cannot tell "no" from
|
||||
# "broken" — which is the whole finding.
|
||||
OK, UNANSWERED, NO_SUCH_PICK, READER_FAILED = 0, 1, 2, 3
|
||||
|
||||
|
||||
def run(data, *args, **kw):
|
||||
env = {**os.environ, "BOOTH_DATA_DIR": str(data), "BOOTH_URL": "http://booth.invalid"}
|
||||
return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True,
|
||||
env=env, timeout=30, **kw)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booth(tmp_path):
|
||||
b = tmp_path / "b"
|
||||
b.mkdir()
|
||||
return tmp_path, b
|
||||
|
||||
|
||||
def _declare(booth_dir, mark_id="winner"):
|
||||
import sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
from booth.marks import declare_pick
|
||||
declare_pick(booth_dir, mark_id,
|
||||
{"prompt": "Which one?", "options": ["A", "B"]})
|
||||
|
||||
|
||||
def test_marks_prints_one_json_document(booth):
|
||||
"""`booth marks <name>` is a read. Its stdout is parsed by the session that
|
||||
called it, so it has to be ONE document — and exit 0, because the read
|
||||
succeeded. Whether a pick is open is in the payload's `open` list, which is
|
||||
where a caller should read it from."""
|
||||
data, b = booth
|
||||
_declare(b)
|
||||
r = run(data, "marks", "b")
|
||||
assert r.returncode == OK, r.stderr
|
||||
doc = json.loads(r.stdout)
|
||||
assert doc["open"] == ["winner"]
|
||||
|
||||
|
||||
def test_marks_wait_prints_once_not_once_per_poll(booth):
|
||||
"""`--wait` polls every 2 s and printed the whole document on every pass, so
|
||||
a capture held several concatenated JSON values and `jq` could not read any
|
||||
of them. The wait is a wait; the print is the result."""
|
||||
data, b = booth
|
||||
_declare(b)
|
||||
import sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
from booth.marks import answer_pick
|
||||
|
||||
# Answer it after the first poll so --wait genuinely loops at least once.
|
||||
r = subprocess.Popen([str(SCRIPT), "marks", "b", "--wait", "20"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
env={**os.environ, "BOOTH_DATA_DIR": str(data),
|
||||
"BOOTH_URL": "http://booth.invalid"})
|
||||
import time
|
||||
time.sleep(3)
|
||||
answer_pick(b, "winner", "A")
|
||||
out, err = r.communicate(timeout=30)
|
||||
assert r.returncode == OK, err
|
||||
json.loads(out) # ONE document, or this raises
|
||||
|
||||
|
||||
def test_marks_reports_a_reader_failure_instead_of_printing_garbage(booth):
|
||||
"""A traceback on stdout with exit 0 is the worst of both: the caller's `jq`
|
||||
sees success and gets nothing. A read that could not happen is its own
|
||||
answer and gets its own code."""
|
||||
data, b = booth
|
||||
(b / ".marks.json").write_bytes(b"\xff\xfe not utf-8 at all")
|
||||
r = run(data, "marks", "b")
|
||||
assert r.returncode == READER_FAILED, f"rc={r.returncode} out={r.stdout!r}"
|
||||
|
||||
|
||||
def test_answer_distinguishes_a_crash_from_an_unanswered_pick(booth):
|
||||
"""`answer` funnelled a reader crash and "not yet answered" through the same
|
||||
exit 1, so `--wait` spun for the full hour on a broken file and then blamed
|
||||
the operator for not answering."""
|
||||
data, b = booth
|
||||
_declare(b)
|
||||
r = run(data, "answer", "b", "winner")
|
||||
assert r.returncode == UNANSWERED
|
||||
|
||||
(b / ".marks.json").write_bytes(b"\xff\xfe not utf-8 at all")
|
||||
r = run(data, "answer", "b", "winner", "--wait", "6")
|
||||
assert r.returncode == READER_FAILED, (
|
||||
"a crash was read as 'unanswered' and waited out the timeout"
|
||||
)
|
||||
|
||||
|
||||
def test_answer_on_a_note_id_says_no_such_pick(booth):
|
||||
"""`answer` matched on id alone while the web route filters on shape, so a
|
||||
note id was reported 'unanswered' and polled forever — a question that could
|
||||
never be answered because it was never a question."""
|
||||
data, b = booth
|
||||
import sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
from booth.marks import write_note
|
||||
write_note(b, "a.png", "just a note")
|
||||
|
||||
r = run(data, "answer", "b", "note-1")
|
||||
assert r.returncode == NO_SUCH_PICK
|
||||
assert "no such pick" in r.stderr
|
||||
@@ -713,3 +713,481 @@ def test_a_real_write_then_a_no_op_leaves_the_file_alone(tmp_path):
|
||||
|
||||
set_flag(booth, "a.png", True) # idempotent: already flagged
|
||||
assert path.stat().st_mtime == before, "an idempotent flag rewrote the file"
|
||||
|
||||
|
||||
# ---- findings from the cross-frontier contract panel, 2026-09-22 -------------
|
||||
#
|
||||
# Heid panel (thread 01M33VSNFER4N1554G0Y0VC9C8). Four arms, artifact-only.
|
||||
|
||||
|
||||
def test_a_write_over_a_corrupt_marks_file_refuses_instead_of_replacing(tmp_path):
|
||||
"""DATA LOSS, shipped in v0.2.0. Found by Kimi (flag 2), converged with Hulda.
|
||||
|
||||
`marks_for` is deliberately lenient — an unparseable file reads as "no marks"
|
||||
so a review page still loads. The write path inherited that leniency through
|
||||
the same reader, so the next flag toggle appended one entry to an empty list
|
||||
and atomically replaced the file: every judgment in that booth gone, from one
|
||||
click, silently.
|
||||
|
||||
The read stays lenient and the WRITE goes strict. That asymmetry is the fix —
|
||||
a page that renders without an annotation is recoverable, a file that
|
||||
overwrote the operator's judgment is not, and this repo's standing rule is
|
||||
that nothing deletes his data.
|
||||
"""
|
||||
from booth.marks import MarksCorrupt, set_flag, write_note
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
write_note(booth, "a.png", "judgment one")
|
||||
write_note(booth, "b.png", "judgment two")
|
||||
raw = (booth / MARKS_FILE).read_text()
|
||||
(booth / MARKS_FILE).write_text(raw[: len(raw) // 2]) # truncated mid-write
|
||||
|
||||
with pytest.raises(MarksCorrupt):
|
||||
set_flag(booth, "c.png", True)
|
||||
|
||||
# The damaged bytes are still on disk — untouched, recoverable by hand.
|
||||
assert (booth / MARKS_FILE).read_text() == raw[: len(raw) // 2]
|
||||
# And the read path is still lenient, so the page renders rather than 500s.
|
||||
assert marks_for(booth) == []
|
||||
|
||||
|
||||
def test_an_absent_or_empty_marks_file_is_not_corrupt(tmp_path):
|
||||
"""The strict write path must not mistake "nothing yet" for "damaged"."""
|
||||
from booth.marks import set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
assert set_flag(booth, "a.png", True) is not None # no file at all
|
||||
(booth / MARKS_FILE).write_text("")
|
||||
assert set_flag(booth, "b.png", True) is not None # zero bytes
|
||||
(booth / MARKS_FILE).write_text('{"version": 1, "marks": []}')
|
||||
assert set_flag(booth, "c.png", True) is not None # valid but empty
|
||||
|
||||
|
||||
def test_a_pick_can_target_one_item(tmp_path):
|
||||
"""Found by Hulda (flag 1), converged with Regin.
|
||||
|
||||
`Mark.target` carries an item rel, `marks_for_target` retrieves by it, and
|
||||
the panel template already renders "on <item>" for a pick — but
|
||||
`declare_pick` had no target parameter, so a session could not actually
|
||||
produce one. A question about ONE artifact is the 2026-09-09 ruling's whole
|
||||
point; the record supported it and the door was missing.
|
||||
"""
|
||||
from booth.marks import marks_for_target
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
declare_pick(booth, "which-crop", _single(), target="v3/DSC03389.jpg")
|
||||
m = marks_for(booth)[0]
|
||||
assert m.target == "v3/DSC03389.jpg"
|
||||
assert [x.id for x in marks_for_target(marks_for(booth), "v3/DSC03389.jpg")] == ["which-crop"]
|
||||
# and it still answers normally
|
||||
answer_pick(booth, "which-crop", "A — baseline")
|
||||
assert marks_for(booth)[0].answer["complete"] is True
|
||||
|
||||
|
||||
def test_a_pick_target_cannot_escape_the_booth(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
for bad in ("../outside.png", "/etc/passwd"):
|
||||
with pytest.raises(AskError):
|
||||
declare_pick(booth, "p", _single(), target=bad)
|
||||
|
||||
|
||||
def test_redeclaring_a_pick_may_move_its_target(tmp_path):
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
declare_pick(booth, "p", _single(), target="a.png")
|
||||
declare_pick(booth, "p", _single(), target="b.png")
|
||||
assert marks_for(booth)[0].target == "b.png"
|
||||
|
||||
|
||||
def test_import_adopts_a_legacy_answer_for_an_already_declared_pick(tmp_path):
|
||||
"""Found by Gróa (flag 10).
|
||||
|
||||
The idempotence rule skipped any stem already present as a mark. If a
|
||||
session had re-declared that stem through marks (so the mark exists, still
|
||||
unanswered) while the operator's answer sat in the legacy sidecar, the import
|
||||
skipped and that answer was stranded on disk forever — with the read path
|
||||
forbidden from looking at sidecars. Adopting the answer preserves both rules:
|
||||
idempotent, and never clobbers a NEWER judgment.
|
||||
"""
|
||||
from booth.asks import ANSWER_SUFFIX, build_answer, normalize_ask
|
||||
from booth.marks import import_legacy_asks
|
||||
|
||||
booth = tmp_path / "b"
|
||||
_sidecar(booth, "winner", _single())
|
||||
doc = build_answer(normalize_ask(_single(), "winner"), "B — async", notes="from the sidecar")
|
||||
(booth / f"winner{ANSWER_SUFFIX}").write_text(json.dumps(doc))
|
||||
declare_pick(booth, "winner", _single()) # re-declared, unanswered
|
||||
assert marks_for(booth)[0].answer is None
|
||||
|
||||
import_legacy_asks(booth)
|
||||
got = marks_for(booth)[0]
|
||||
assert got.answer is not None, "the legacy answer was stranded"
|
||||
assert got.answer["choice"] == "B — async"
|
||||
assert open_marks(marks_for(booth)) == []
|
||||
|
||||
|
||||
def test_import_never_overwrites_an_answer_made_through_marks(tmp_path):
|
||||
"""The other half of the same rule: a judgment recorded SINCE the sidecar
|
||||
outranks it, and adoption must not reach back over it."""
|
||||
from booth.asks import ANSWER_SUFFIX, build_answer, normalize_ask
|
||||
from booth.marks import import_legacy_asks
|
||||
|
||||
booth = tmp_path / "b"
|
||||
_sidecar(booth, "winner", _single())
|
||||
old = build_answer(normalize_ask(_single(), "winner"), "A — baseline")
|
||||
(booth / f"winner{ANSWER_SUFFIX}").write_text(json.dumps(old))
|
||||
declare_pick(booth, "winner", _single())
|
||||
answer_pick(booth, "winner", "B — async") # the operator changed his mind
|
||||
|
||||
import_legacy_asks(booth)
|
||||
assert marks_for(booth)[0].answer["choice"] == "B — async"
|
||||
|
||||
|
||||
def test_the_doc_view_carries_the_marks(client):
|
||||
"""INV-3's third surface — flagged 4/4 by the panel as named in the rule but
|
||||
covered by no test, so shipping it unmarked would have passed."""
|
||||
from booth.marks import write_note
|
||||
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
(b / "notes.md").write_text("# report\n\nprose here\n")
|
||||
write_note(b, "notes.md", "this section is wrong")
|
||||
|
||||
html = c.get("/b/b/view?f=notes.md").text
|
||||
assert "prose here" in html
|
||||
assert "this section is wrong" in html
|
||||
|
||||
|
||||
def test_a_corrupt_marks_file_gives_the_browser_a_409_not_a_500(client):
|
||||
"""The request was fine and the service is fine — the state on disk is not,
|
||||
and the refusal is deliberate. A 500 would read as "the Booth is broken" and
|
||||
send the operator looking for something to restart."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
from booth.marks import write_note
|
||||
write_note(b, "a.png", "keep me")
|
||||
(b / MARKS_FILE).write_text("{truncated")
|
||||
|
||||
r = c.post("/b/b/flag", data={"target": "a.png", "on": "1"}, follow_redirects=False)
|
||||
assert r.status_code == 409
|
||||
body = r.json()
|
||||
assert "cannot be read" in body["error"] and body["fix"]
|
||||
# the page still renders, so the operator can see the booth at all
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
assert c.get("/b/b/marks.json").status_code == 200
|
||||
|
||||
|
||||
# ---- findings from the cross-frontier BUG-HUNT panel, 2026-09-22 -------------
|
||||
#
|
||||
# Heid panel (thread 01M33XEC1H0298C0D968FWBN7A). Four arms, artifact-only,
|
||||
# diff-scoped. The headline was 4/4 convergent and none of it had a guard: the
|
||||
# panel's own mutation tables showed the lock lifecycle SURVIVED every existing
|
||||
# test, because `test_a_no_op_write_does_not_touch_the_booth` asserts only that
|
||||
# `.marks.json` is absent and never looks at the lock or at the clock the
|
||||
# sweeper actually reads.
|
||||
|
||||
|
||||
def test_the_lock_file_is_never_unlinked(tmp_path):
|
||||
"""The lock must outlive the operation that created it.
|
||||
|
||||
`flock` binds to an INODE, not to a path. Unlinking `.marks.lock` while a
|
||||
second writer is blocked on it leaves that writer holding an exclusive lock
|
||||
on a deleted inode — and the next writer along creates a FRESH lock file and
|
||||
takes it immediately. Two processes then run the read-modify-write
|
||||
concurrently and the later `os.replace` drops the earlier one's mark, with
|
||||
no error anywhere. Both of them obeyed the protocol.
|
||||
|
||||
The cleanup existed to keep a no-op from leaving a lock file as its only
|
||||
trace. That is a tidiness goal, and it bought a lost-update race.
|
||||
"""
|
||||
from booth.marks import MARKS_LOCK, set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
assert set_flag(booth, "ghost.png", False) is None # a no-op
|
||||
assert (booth / MARKS_LOCK).exists(), "the no-op path unlinked the lock file"
|
||||
|
||||
|
||||
def test_a_no_op_does_not_reset_the_ttl_clock(tmp_path):
|
||||
"""The property the no-op guard actually exists for, asserted against the
|
||||
clock the sweeper reads instead of against one file's absence.
|
||||
|
||||
Creating or removing a directory entry bumps the DIRECTORY's mtime, and
|
||||
`_newest_mtime` seeds from exactly that. So `touch` + `unlink` of the lock
|
||||
reset the booth's age to zero while leaving no trace behind — the comment on
|
||||
the create-only guard reasons about the lock FILE's mtime and misses that
|
||||
the directory moved underneath it. Repeated, it kept a dead booth alive
|
||||
forever, which is the precise outcome the guard was written to prevent.
|
||||
"""
|
||||
import os
|
||||
|
||||
from booth.app import booth_age_seconds
|
||||
from booth.marks import delete_mark, set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
old = 1_000_000_000
|
||||
os.utime(booth, (old, old))
|
||||
|
||||
set_flag(booth, "ghost.png", False) # no-op: never flagged
|
||||
delete_mark(booth, "nothing") # no-op: no such mark
|
||||
|
||||
age = booth_age_seconds(booth, now=old + 90_000)
|
||||
assert age > 86_400, f"a no-op reset the TTL clock (age fell to {age:.0f}s)"
|
||||
|
||||
|
||||
def test_a_real_mark_still_resets_the_ttl_clock(tmp_path):
|
||||
"""The other half of the same rule, so the fix cannot overshoot into
|
||||
'marking is never activity'. Marking IS activity and must reset the clock;
|
||||
only a write that changes nothing must not."""
|
||||
import os
|
||||
|
||||
from booth.app import booth_age_seconds
|
||||
from booth.marks import set_flag
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
old = 1_000_000_000
|
||||
os.utime(booth, (old, old))
|
||||
|
||||
set_flag(booth, "a.png", True) # a real mark
|
||||
|
||||
assert booth_age_seconds(booth, now=old + 90_000) < 86_400
|
||||
|
||||
|
||||
def test_a_non_string_note_text_does_not_crash_the_read(tmp_path):
|
||||
"""`_clean_text` did `(text or "").replace(...)`, so a stored `text` that is
|
||||
valid JSON but not a string raised AttributeError out of the READ path.
|
||||
|
||||
That is not a marks bug, it is an INDEX bug: `list_booths` reads every
|
||||
booth's marks on every page load, so one poisoned file took down `/` and
|
||||
`/healthz` for all 25 booths. The module's stated posture is that a mark it
|
||||
cannot read renders as broken, never as a 500.
|
||||
"""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
(booth / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "n1", "shape": "note", "text": 7,
|
||||
"created": "2026-09-21T00:00:00+00:00"}],
|
||||
}))
|
||||
|
||||
marks = marks_for(booth)
|
||||
assert len(marks) == 1
|
||||
assert marks[0].error, "a poisoned note read clean instead of reading broken"
|
||||
|
||||
|
||||
def test_a_non_string_created_does_not_crash_the_sort(tmp_path):
|
||||
"""`marks_for` sorts on `(created, id)`. A stored `created` of the wrong type
|
||||
made that comparison raise TypeError — same blast radius as the note above,
|
||||
reached through the sort rather than through hydration."""
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
(booth / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [
|
||||
{"id": "a", "shape": "note", "text": "fine",
|
||||
"created": "2026-09-21T00:00:00+00:00"},
|
||||
{"id": "b", "shape": "note", "text": "also fine", "created": 17},
|
||||
],
|
||||
}))
|
||||
|
||||
marks = marks_for(booth)
|
||||
assert len(marks) == 2
|
||||
# An unreadable mark loses its `created` and so sorts FIRST — the stated
|
||||
# rule is `("", id)` against `(created, id)`. A mark nobody can read is the
|
||||
# one that wants looking at, and the alternative is it landing at an
|
||||
# arbitrary position in the middle of the panel.
|
||||
assert [m.id for m in marks] == ["b", "a"]
|
||||
assert marks[0].error and not marks[1].error
|
||||
|
||||
|
||||
def test_legacy_import_order_survives_same_second_mtimes(tmp_path):
|
||||
"""ROADMAP states the legacy import's order is `(mtime, name)`. It was
|
||||
stamping `created` at whole-second resolution, so two sidecars written in
|
||||
the same second lost the fractional part that distinguished them and
|
||||
`marks_for`'s `(created, id)` tie-break silently re-sorted them into
|
||||
alphabetical order — reversing the pair the importer had just ordered.
|
||||
|
||||
Deterministic order is a v1 invariant precisely because the operator refers
|
||||
to things positionally. An order that is stated and not kept is worse than
|
||||
one that was never claimed.
|
||||
"""
|
||||
import os
|
||||
|
||||
from booth.marks import import_legacy_asks
|
||||
|
||||
booth = tmp_path / "b"
|
||||
booth.mkdir()
|
||||
for stem in ("zeta", "alpha"):
|
||||
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(_single()))
|
||||
# Same whole second, different fractions: `zeta` is OLDER and must come first.
|
||||
os.utime(booth / f"zeta{ASK_SUFFIX}", (1_700_000_000.10, 1_700_000_000.10))
|
||||
os.utime(booth / f"alpha{ASK_SUFFIX}", (1_700_000_000.90, 1_700_000_000.90))
|
||||
|
||||
imported = [m.id for m in import_legacy_asks(booth)]
|
||||
assert imported == ["zeta", "alpha"], "the importer's own order is wrong"
|
||||
assert [m.id for m in marks_for(booth)] == imported, (
|
||||
"the read path re-sorted what the importer ordered"
|
||||
)
|
||||
|
||||
|
||||
def test_the_index_survives_a_poisoned_marks_file(client):
|
||||
"""The blast radius, asserted where it actually hurts.
|
||||
|
||||
`list_booths` reads every booth's marks on every index load and `/healthz`
|
||||
does the same. One hand-edited or foreign-written `.marks.json` therefore
|
||||
took down the front page for all 25 booths — the single-booth failure the
|
||||
lenient reader exists to contain, escaping the booth it belongs to.
|
||||
"""
|
||||
c, data = client
|
||||
good = data / "good"
|
||||
good.mkdir()
|
||||
_png(good / "a.png")
|
||||
bad = data / "bad"
|
||||
bad.mkdir()
|
||||
(bad / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "n1", "shape": "note", "text": {"oops": True}, "created": 3}],
|
||||
}))
|
||||
|
||||
assert c.get("/").status_code == 200
|
||||
assert c.get("/healthz").status_code == 200
|
||||
assert c.get("/b/bad/").status_code == 200
|
||||
|
||||
|
||||
def test_answer_treats_a_non_string_notes_field_as_no_notes(client):
|
||||
"""`booth_note` guards `text` with `isinstance(..., str)`; `booth_answer`
|
||||
passed `notes` straight to `_clean_notes`, which calls `.replace` on it. A
|
||||
multipart FILE part named `notes` is a str to nobody, so the route 500'd on
|
||||
hostile-but-legal input where its sibling handled the same class of value.
|
||||
|
||||
Both routes now read the field the same way: a value that is not text is no
|
||||
value. The CHOICE is the judgment and it still lands — throwing the whole
|
||||
answer away over a junk optional field would be the wrong trade."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
declare_pick(b, "winner", _single())
|
||||
|
||||
r = c.post(
|
||||
"/b/b/answer",
|
||||
data={"ask": "winner", "choice": "A — baseline"},
|
||||
files={"notes": ("n.txt", b"surprise", "text/plain")},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
mark = next(m for m in marks_for(b) if m.id == "winner")
|
||||
assert mark.answer["choice"] == "A — baseline"
|
||||
assert not mark.answer.get("notes")
|
||||
|
||||
|
||||
def test_an_inline_doc_tile_offers_a_note_control(client):
|
||||
"""Three item branches, two of them call `marknotes`. The doc branch got the
|
||||
flag button and not the note field, so the operator could point at a report
|
||||
and not write down why — on the one item kind whose whole purpose is prose.
|
||||
|
||||
This is the exact failure the `blurtoggle` macro comment names ("patched two
|
||||
of three"), recurring on the macro that was written to prevent it.
|
||||
"""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
(b / "report.md").write_text("# report\n\nprose here\n")
|
||||
|
||||
html = c.get("/b/b/").text
|
||||
assert 'value="report.md"' in html, "the doc tile has no mark controls at all"
|
||||
# `marknotes`' add-field, which only that macro emits. The booth-level panel
|
||||
# has its own note form, so the presence of /note on the page proves nothing.
|
||||
assert 'placeholder="a note on this item"' in html, (
|
||||
"an inline doc tile has no way to add a note"
|
||||
)
|
||||
|
||||
|
||||
def test_the_marks_panel_survives_a_booth_that_also_has_a_link_board(client):
|
||||
"""The board booth renders as a board instead of a gallery, which is right —
|
||||
but the suppression was unconditional, so a pick declared on a booth that
|
||||
happens to carry a `links.md` had no form to answer it and no way to say so."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
(b / "links.md").write_text("- [a thing](http://example.invalid) <sub>· who · when</sub>\n")
|
||||
declare_pick(b, "winner", _single())
|
||||
|
||||
html = c.get("/b/b/").text
|
||||
assert "Which render wins?" in html, "a pick on a board booth was unanswerable"
|
||||
|
||||
|
||||
def test_the_zoom_view_does_not_navigate_away_from_a_note_being_typed(client):
|
||||
"""The viewer's arrow keys move between images and Escape goes back. The
|
||||
note textarea landed in the same page, and the handler is on `document`, so
|
||||
an arrow key meant for the caret threw away the draft instead of moving it.
|
||||
|
||||
Asserted structurally: the handler must bail on events from an editable
|
||||
target. There is no browser in this suite, and a guard nobody can test is
|
||||
exactly how this shipped."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
_png(b / "a.png")
|
||||
|
||||
js = c.get("/b/b/view?f=a.png").text
|
||||
assert "isEditable" in js, "the viewer's key handler has no editing guard"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["booth_answer", "booth_note", "booth_flag",
|
||||
"booth_unmark", "booth_import_asks"])
|
||||
def test_mark_writes_do_not_block_the_event_loop(route):
|
||||
"""Every mark write takes a blocking `flock` and does synchronous disk I/O.
|
||||
In an `async def` handler that runs ON the event loop, so a lock held by
|
||||
another process — the CLI mid-`marks-import`, a second browser tab — freezes
|
||||
every other request, including the index and `/healthz`.
|
||||
|
||||
Structural, like `test_stdlib_only`, and for the same reason: the failure is
|
||||
a property of where the call runs, which no single-process response
|
||||
assertion can see. The rule is that an async mark-write handler hands the
|
||||
locked section to a worker thread and never calls the writer inline.
|
||||
"""
|
||||
src = pathlib.Path(__file__).parent.parent / "booth" / "app.py"
|
||||
fn = next(
|
||||
n for n in ast.walk(ast.parse(src.read_text()))
|
||||
if isinstance(n, ast.AsyncFunctionDef) and n.name == route
|
||||
)
|
||||
writers = {"answer_pick", "write_note", "set_flag", "delete_mark",
|
||||
"import_legacy_asks"}
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
||||
if name in writers:
|
||||
pytest.fail(f"{route} calls {name}() on the event loop; "
|
||||
"dispatch it through run_in_threadpool")
|
||||
|
||||
|
||||
def test_an_unreadable_mark_is_visible_on_the_page(client):
|
||||
"""Surviving the poisoned file is half of it. A note whose stored `text` is
|
||||
unreadable hydrates with empty text, and the panel rendered that as an empty
|
||||
`<pre>` with a withdraw button beside it — which looks exactly like a note
|
||||
the operator wrote and then cleared.
|
||||
|
||||
`_hydrate`'s own docstring forbids this for picks ("a broken question the
|
||||
session believes it posted has to be visible — silently hiding it is the one
|
||||
outcome nobody can debug"). It is the same argument for every shape."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir()
|
||||
(b / MARKS_FILE).write_text(json.dumps({
|
||||
"version": 1,
|
||||
"marks": [{"id": "n1", "shape": "note", "text": {"oops": True},
|
||||
"created": "2026-09-21T00:00:00+00:00"}],
|
||||
}))
|
||||
|
||||
html = c.get("/b/b/").text
|
||||
assert "⚠ broken" in html, "an unreadable mark rendered as an empty note"
|
||||
assert "n1" in html
|
||||
|
||||
Reference in New Issue
Block a user