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

75 lines
3.1 KiB
Markdown

# 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:
```json
{"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]].