# A wrong-shaped answer 500s the gallery and the marks page — CLOSED 2026-09-22 _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//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]]. --- ## CLOSED — 2026-09-22, after U6, at option (1) Fixed in `_hydrate`, the option this entry argued for: **one predicate, one place, every surface inherits it.** The operator was asked three times where the guard belonged and did not answer; the placement was taken under the stated assumption, and it is cheap to move if he disagrees — the whole fix is one condition in one function. **Only the MULTI case is checked**, because only the multi case indexes: a single-question pick's answer IS the record, with no `answers` key to get wrong. Requiring one unconditionally would break every single pick — the direction a too-eager guard fails in, and it has its own test. Measured before and after, on the gallery booth (no `index.html`): before /b/g/ 500 /b/g/marks 500 / 200 /healthz 200 after /b/g/ 200 /b/g/marks 200 / 200 /healthz 200 and the error is VISIBLE on the page, and the booth's OTHER, healthy pick still renders **Two things fell out of it that are worth more than the fix.** 1. **`_safe_fragments` lost its natural trigger.** Probed every wrong answer shape reachable from a `.marks.json`: `answers` as a list, a string or null all become hydration errors now, and a wrong-typed VALUE inside `answers` renders without raising because Jinja absorbs attribute access on a non-mapping. So U3's guard is now a pure backstop with **no reachable natural input**. Its test was rewritten to a synthetic trigger that says so — patching the shared macro module through `app.state.templates` — rather than left asserting a path nothing reaches. An untested guard and a guard tested by an unreachable input are the same thing. 2. **The guard's own handler could not survive the failure it was handling.** Building that falsifier tripped it: `_safe_fragments` caught a raising `_pick_fragments` and then rebuilt the broken-ask box **through the same macro module that had just raised**, so when `whole` itself was broken the handler re-raised and took the whole report. Fixed, with its own test. Found by accident, which is the usual way. Both new falsifiers were **verified RED against their defeating change** rather than assumed — the discipline from [[2026-09-22-vacuous-falsifiers]], applied to the fix for the entry that names it.