# 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//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]].