Pre-existing, measured at 42ea67f, so it predates U3. `_hydrate` checked only
that `answer` was a dict and never that `answer["answers"]` was one, so
`marks_for` and `hold_read` both reported the mark healthy with no read error
-- and `_ask_inline.html` then asked a list for `.get`. The v0.2.2 lesson was
half-implemented: that outage was a file that could not be PARSED and the
reader was made lenient, while this one parses perfectly and breaks one layer
further in, at render, where no leniency existed.
Closed at the hydration boundary rather than by a third copy of the guard --
one predicate, one place, every surface inherits it. Only the multi case is
checked, because only the multi case indexes; requiring `answers`
unconditionally would break every single-question pick, and that direction has
its own test. Measured before and after: gallery and marks pages 500 -> 200,
the error visible on the page, the booth's other healthy pick untouched.
The placement was the one open operator question of the session. It was
surfaced three times without a ruling, so it is taken under a stated assumption
and is cheap to move: the whole fix is one condition in one function.
Two things fell out of it worth more than the fix.
`_safe_fragments` no longer has a reachable natural trigger. Probed every wrong
answer shape a .marks.json can carry: `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. U3's
guard is a pure backstop, and its test now says so and trips it synthetically
through the shared macro module rather than asserting a path nothing reaches.
A guard tested by an unreachable input is an untested guard.
And that guard's handler could not survive the failure it was handling: it
caught a raising `_pick_fragments` and rebuilt the broken-ask box through the
SAME macro module that had just raised, so whenever `whole` was the broken
thing it re-raised and took the whole report. Found by accident while building
the falsifier. Fixed, with its own test.
Both new falsifiers were verified RED against their defeating change rather
than assumed.
607 -> 611 tests.
5.4 KiB
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:
{"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:
- In
_hydrate, validating the answer shape so a wrong-shaped answer becomeserrorat hydration and every surface inherits the fix. Cleanest, and consistent with declarations already being normalized on read — but it widens whaterrormeans. - At each render site, per-mark, as U3 did. Honest and local; three copies.
- 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.
-
_safe_fragmentslost its natural trigger. Probed every wrong answer shape reachable from a.marks.json:answersas a list, a string or null all become hydration errors now, and a wrong-typed VALUE insideanswersrenders 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 throughapp.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. -
The guard's own handler could not survive the failure it was handling. Building that falsifier tripped it:
_safe_fragmentscaught a raising_pick_fragmentsand then rebuilt the broken-ask box through the same macro module that had just raised, so whenwholeitself 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.