Files
booth/persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
T
vh 5c20e2f4d5 fix(u3): seven defects two cold panels found in the declared seam
The /heid-code-review and /heid-bug-hunt panels, artifact-only over the U3
diff, between them found four real defects and three vacuous falsifiers. Both
snapshots predate the contract-review fixes, so two of their findings were
already closed; the rest are here.

Prototype pollution in the placement maps. A mark id and a question key are
both [A-Za-z0-9][A-Za-z0-9._-]*, so `toString` and `constructor` are legal in
each. Against a plain `{}` an anchor naming NO mark returned an inherited
function, passed the guard meant to reject it, and threw on .questions.length
-- aborting placement before the tail, so one typo in author markup cost the
page every ask. The `placed` set had the mirror bug: inherited
`got.constructor` read as already-placed and silently dropped a question.
Object.create(null), three times. Found independently by both panels.

A declaring page was not served as written. read_text() opens in
universal-newline mode, so a CRLF report came back LF, and errors="replace"
replaced every byte that was not valid UTF-8. That is this unit's headline
promise, broken by the read itself, and the test could not see it because its
fixture was LF-only ASCII. The verbatim branch reads and serves bytes now; the
decoded copy answers only "does it declare the seam?".

A submit anchor inside the author's own <form> lost ours -- the parser drops a
nested form element outright -- while the code still recorded the pick as
submitted, so no fallback was appended. Every control's form= pointed at
nothing and the button did nothing. It counts as submitted only if the form
survived.

A broken pick's diagnostic never rendered from a submit-only anchor: an errored
pick's submit block is empty, and mounting that then marking it placed made the
tail skip the "broken ask" box entirely. The anchor is left alone instead.

An author's own element could hijack the open-ask chip -- id="bk-ask-winner-
background" satisfies any prefix rule, hyphen boundary included. The chip now
searches only elements this script mounted, which is the identity the deleted
bk-ask-<id>-top anchor used to guarantee, and takes the earliest by
compareDocumentPosition.

No error boundary around fragment rendering. A .marks.json that is well-formed
JSON with a wrong-shaped answer hydrates with no error and then raises in the
macro; this endpoint renders every pick on every load of the report, so that
was the whole seam gone while hold_read called the file readable. Reproduced
before building for it. _safe_fragments gives it the per-mark leniency
_hydrate_safe already applies one layer down.

The gallery and marks pages still 500 on that same entry. Measured at 42ea67f
-- it predates this unit, they render the same macro with no guard, and the
gallery is named out of scope in the contract. Recorded, not quietly widened:
persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md

Also corrected: several comments claimed a multi-question pick POSTs a 400
unless every question is answered. It does not -- an empty submission is
refused, a partial one is recorded on purpose. The real reason an unplaced
question must still be appended is that a question which never reaches the page
cannot be answered at all.

Vacuity pass rebuilt around the rule this session learned: the mutation comes
from the invariant's claim, never from the falsifier's example. 21 mutations,
21 caught, unmutated control green. Getting there took three rounds -- it
passed INV-3 with the contract's own mutation, then found its own fix's hole,
then flagged seven stale mutations and one genuinely vacuous fixture whose
sibling-mark arrangement made the right answer also the first answer.

444 tests. Deployed and verified: 23/23 booths 200, and all four live verbatim
reports served at exactly +46 bytes -- len(EMBED_SCRIPT_TAG) -- with the
authors' own wrappers and headings intact and no console errors.
2026-09-22 11:22:37 -07:00

3.1 KiB

A wrong-shaped answer 500s the gallery and the marks page — PRE-EXISTING, NOT U3

2026-09-22 · booth

Found by the U3 bug-hunt panel, measured against 42ea67f — the commit BEFORE U3 — so it is not this unit's doing and was not fixed by it. U3's own surface is guarded; these two are not.

The defect

.marks.json that is well-formed JSON with a wrong-shaped value passes every reader and then raises in the renderer:

{"id": "batch", "shape": "pick", "answer": {"answers": [], "notes": ""}}

_hydrate only checks isinstance(entry.get("answer"), dict) — it never validates answer["answers"]. So marks_for and hold_read both return the mark with error = None and no read error at all, and then _ask_inline.html does a.answer.answers.get(q.key), Jinja asks a list for .get, and it raises UndefinedError.

Measured, not reasoned:

PRE-U3 (42ea67f) gallery page:  500
PRE-U3 (42ea67f) marks page:    500
PRE-U3 (42ea67f) index:         200

The index survives because it never renders a fragment.

Why it matters more than it looks

This is the v0.2.2 shape with a different trigger. That outage was a .marks.json that could not be PARSED; the reader was made lenient and the index stopped 500ing. This one parses perfectly and breaks one layer further in, at render time, where no leniency exists — so the lesson "one damaged file must cost its own tile, not the page" is only half-implemented. read_error is answering a narrower question than every caller assumes.

What U3 did and did not do

U3 added _safe_fragments around _pick_fragments, so /b/<name>/embed.json returns a per-mark error record instead of a 500 — the same posture _hydrate_safe takes one layer down. That protects the verbatim path only.

booth.html and marks.html call the same macros with no such guard. Left alone deliberately: the gallery is named out of scope in the U3 contract, and widening a unit mid-flight to cover a pre-existing defect in a surface it never touched is the scope drift the roadmap gate exists to stop.

The design question it deserves, when it is picked up

Not "wrap the other two call sites" — that is the third copy of one guard. The real question is where the boundary belongs:

  1. In _hydrate, validating the answer shape so a wrong-shaped answer becomes error at hydration and every surface inherits the fix. Cleanest, and consistent with declarations already being normalized on read — but it widens what error means.
  2. At each render site, per-mark, as U3 did. Honest and local; three copies.
  3. In the template, defensively. Cheapest and worst — it hides the fact that anything is wrong.

(1) is the shape the rest of this module already argues for: one predicate, one place. Worth an operator decision because it changes what a Mark can be.

⚠ Reproduce with the fixture in tests/test_embed.py::test_a_wrongly_shaped_answer_costs_its_pick_not_the_report, whose closing comment points back here.

Related: 2026-09-21-marks-write-wiped-judgment, 2026-09-22-lenient-reader-blast-radius, 2026-09-22-u3-declared-embed-seam-landed.