Files
booth/persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
T
vh e702be4e1a 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.
2026-09-22 14:34:28 -07:00

121 lines
5.4 KiB
Markdown

# 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/<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**:
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.