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.
121 lines
5.4 KiB
Markdown
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.
|