# 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`.