diff --git a/booth/app.py b/booth/app.py index 6ae0623..3c9d8b7 100644 --- a/booth/app.py +++ b/booth/app.py @@ -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): diff --git a/booth/marks.py b/booth/marks.py index c9b0807..04b4bf2 100644 --- a/booth/marks.py +++ b/booth/marks.py @@ -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, diff --git a/docs/contracts/u3_declared_embed_seam.contract.md b/docs/contracts/u3_declared_embed_seam.contract.md index 013e8f8..44f49cb 100644 --- a/docs/contracts/u3_declared_embed_seam.contract.md +++ b/docs/contracts/u3_declared_embed_seam.contract.md @@ -436,7 +436,7 @@ arms flagged the staleness themselves. | **BH-2** | **A submit anchor inside the author's own `