fix: a wrong-shaped answer no longer 500s the gallery and the marks page

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.
This commit is contained in:
vh
2026-09-22 14:34:28 -07:00
parent c5ac49356f
commit e702be4e1a
7 changed files with 254 additions and 17 deletions
+13 -3
View File
@@ -1204,10 +1204,20 @@ def create_app(
return _pick_fragments(name, mark)
except Exception as exc: # noqa: BLE001 - deliberate
broken = replace(mark, error=f"this question could not be rendered: {exc}")
try:
whole = str(_frag.whole(broken, ask_form_id(mark.id),
quote(name, safe="")))
except Exception: # noqa: BLE001 - deliberate
# THE HANDLER MUST SURVIVE THE FAILURE IT IS HANDLING. The
# fallback re-rendered through the SAME macro module that had
# just raised, so when `whole` itself was the broken thing this
# guard re-raised and took the report anyway — a guard that
# only works when the failure is somewhere else. Found while
# building a falsifier for the guard: the falsifier tripped it.
# Plain text, escaped by the caller, no macro involved.
whole = ""
return {"id": mark.id, "error": broken.error,
"whole": str(_frag.whole(broken, ask_form_id(mark.id),
quote(name, safe=""))),
"submit": "", "questions": []}
"whole": whole, "submit": "", "questions": []}
@app.get("/b/{name}/embed.json")
def booth_embed_json(name: str):
+34
View File
@@ -488,6 +488,40 @@ def _hydrate(entry: dict) -> Mark:
norm = normalize_ask(decl, mid)
except AskError as exc:
return Mark(**base, declaration=decl, answer=answer, error=str(exc))
# THE ANSWER'S SHAPE IS VALIDATED HERE, at the ONE boundary every
# surface crosses — not at the three render sites that happen to draw
# it today, and not defensively in the template, which would hide that
# anything is wrong.
#
# `{"answer": {"answers": [], "notes": ""}}` is well-formed JSON with a
# wrong-shaped value. It passed `_entry_type_error`, passed the
# `isinstance(answer, dict)` check above, and `marks_for` and
# `hold_read` both reported the mark HEALTHY with no read error — and
# then `_ask_inline.html` did `a.answer.answers.get(q.key)`, Jinja asked
# a LIST for `.get`, and the gallery page and the marks page returned
# 500. Measured at 42ea67f, so it predates U3; U3 guarded only its own
# surface with `_safe_fragments` and left these two by scope.
#
# This is the v0.2.2 lesson finished rather than half-done. That outage
# was a file that could not be PARSED and the reader was made lenient;
# this one parses perfectly and breaks one layer further in, at render,
# where no leniency exists. `read_error` was answering a narrower
# question than every caller assumed.
#
# 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, which is the direction a too-eager guard fails in.
if norm["multi"] and isinstance(answer, dict) and \
not isinstance(answer.get("answers"), dict):
return Mark(**base, declaration=decl, answer=None,
prompt=norm["prompt"], title=norm["title"],
multi=norm["multi"], questions=norm["questions"],
options=norm.get("options", []),
notes_enabled=norm["notes"], notes_label=norm["notes_label"],
error="this pick's answer is stored in a shape the page "
"cannot render; the answer was dropped and the "
"question is unanswered")
return Mark(
**base,
declaration=decl,