From 5c20e2f4d5f78041323e56b16dfd12fd5a43b3da Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 22 Sep 2026 11:22:37 -0700 Subject: [PATCH] fix(u3): seven defects two cold panels found in the declared seam The /heid-code-review and /heid-bug-hunt panels, artifact-only over the U3 diff, between them found four real defects and three vacuous falsifiers. Both snapshots predate the contract-review fixes, so two of their findings were already closed; the rest are here. Prototype pollution in the placement maps. A mark id and a question key are both [A-Za-z0-9][A-Za-z0-9._-]*, so `toString` and `constructor` are legal in each. Against a plain `{}` an anchor naming NO mark returned an inherited function, passed the guard meant to reject it, and threw on .questions.length -- aborting placement before the tail, so one typo in author markup cost the page every ask. The `placed` set had the mirror bug: inherited `got.constructor` read as already-placed and silently dropped a question. Object.create(null), three times. Found independently by both panels. A declaring page was not served as written. read_text() opens in universal-newline mode, so a CRLF report came back LF, and errors="replace" replaced every byte that was not valid UTF-8. That is this unit's headline promise, broken by the read itself, and the test could not see it because its fixture was LF-only ASCII. The verbatim branch reads and serves bytes now; the decoded copy answers only "does it declare the seam?". A submit anchor inside the author's own
lost ours -- the parser drops a nested form element outright -- while the code still recorded the pick as submitted, so no fallback was appended. Every control's form= pointed at nothing and the button did nothing. It counts as submitted only if the form survived. A broken pick's diagnostic never rendered from a submit-only anchor: an errored pick's submit block is empty, and mounting that then marking it placed made the tail skip the "broken ask" box entirely. The anchor is left alone instead. An author's own element could hijack the open-ask chip -- id="bk-ask-winner- background" satisfies any prefix rule, hyphen boundary included. The chip now searches only elements this script mounted, which is the identity the deleted bk-ask--top anchor used to guarantee, and takes the earliest by compareDocumentPosition. No error boundary around fragment rendering. A .marks.json that is well-formed JSON with a wrong-shaped answer hydrates with no error and then raises in the macro; this endpoint renders every pick on every load of the report, so that was the whole seam gone while hold_read called the file readable. Reproduced before building for it. _safe_fragments gives it the per-mark leniency _hydrate_safe already applies one layer down. The gallery and marks pages still 500 on that same entry. Measured at 42ea67f -- it predates this unit, they render the same macro with no guard, and the gallery is named out of scope in the contract. Recorded, not quietly widened: persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md Also corrected: several comments claimed a multi-question pick POSTs a 400 unless every question is answered. It does not -- an empty submission is refused, a partial one is recorded on purpose. The real reason an unplaced question must still be appended is that a question which never reaches the page cannot be answered at all. Vacuity pass rebuilt around the rule this session learned: the mutation comes from the invariant's claim, never from the falsifier's example. 21 mutations, 21 caught, unmutated control green. Getting there took three rounds -- it passed INV-3 with the contract's own mutation, then found its own fix's hole, then flagged seven stale mutations and one genuinely vacuous fixture whose sibling-mark arrangement made the right answer also the first answer. 444 tests. Deployed and verified: 23/23 booths 200, and all four live verbatim reports served at exactly +46 bytes -- len(EMBED_SCRIPT_TAG) -- with the authors' own wrappers and headings intact and no console errors. --- booth/app.py | 59 ++++- booth/static/embed.js | 132 +++++++--- .../u3_declared_embed_seam.contract.md | 67 ++++- ...-a-wrong-shaped-answer-500s-the-gallery.md | 74 ++++++ persistent-memory.md | 17 +- tests/test_embed.py | 110 ++++++++- tests/test_embed_browser.py | 232 ++++++++++++++++-- 7 files changed, 609 insertions(+), 82 deletions(-) create mode 100644 persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md diff --git a/booth/app.py b/booth/app.py index a371a40..c2627f7 100644 --- a/booth/app.py +++ b/booth/app.py @@ -44,6 +44,7 @@ import shutil import time import zipfile from contextlib import asynccontextmanager +from dataclasses import replace from pathlib import Path from typing import Sequence from urllib.parse import quote, unquote @@ -589,8 +590,8 @@ def declares_embed(html: str) -> bool: return any(d in html for d in _EMBED_DECLARATIONS) -def embed_verbatim(html: str) -> str: - """The ONLY thing the Booth does to a verbatim report. +def embed_verbatim(raw: bytes) -> bytes: + """The ONLY thing the Booth does to a verbatim report. BYTES IN, BYTES OUT. Appended, never inserted, and never prepended. That is what retires both of the old wrapper's hard constraints rather than satisfying them more @@ -598,8 +599,22 @@ def embed_verbatim(html: str) -> str: nothing can push the charset out of its first-1024-byte detection window, because nothing in front of them moves. Content after `` is parsed into the body by every browser, so there is no seam to find. + + ⚠ IT TAKES BYTES BECAUSE TEXT WAS QUIETLY EDITING THE DOCUMENT. The first + version read the file with `read_text()` and returned a str. That opens in + UNIVERSAL-NEWLINE mode, so a report written with CRLF came back with LF — + and `errors="replace"` turned any byte that was not valid UTF-8 into U+FFFD. + A declaring page was therefore NOT served as its author wrote it, which is + this unit's headline promise, and the test could not see it because its + fixture was LF-only ASCII. Found by a cross-frontier bug-hunt panel. + + Decoding still happens — `declares_embed` needs a string to look in — but + the decoded copy is used ONLY to answer that question. What goes on the wire + is the original bytes, plus the tag's bytes when it is appended, so the + source is a byte-exact prefix of the response. """ - return html if declares_embed(html) else html + EMBED_SCRIPT_TAG + text = raw.decode("utf-8", errors="replace") + return raw if declares_embed(text) else raw + EMBED_SCRIPT_TAG.encode("utf-8") def ask_form_id(stem: str) -> str: @@ -860,8 +875,11 @@ def create_app( # serving raw, which costs it the chrome exactly as it did before. try: if own_index.stat().st_size <= WRAP_MAX_BYTES: - raw = own_index.read_text(encoding="utf-8", errors="replace") - return HTMLResponse(embed_verbatim(raw)) + # ONE read, and it is a byte read: see embed_verbatim. + return Response( + content=embed_verbatim(own_index.read_bytes()), + media_type="text/html; charset=utf-8", + ) except OSError: pass return FileResponse(str(own_index), media_type="text/html") @@ -1104,6 +1122,30 @@ def create_app( ], } + def _safe_fragments(name: str, mark) -> dict: + """`_pick_fragments`, with the promise that it cannot raise. + + `marks_for` hydrates an entry whose JSON is well-formed but whose SHAPE + is wrong — `{"answer": {"answers": []}}` survives `_hydrate` with no + error and then raises `UndefinedError` in the template, because the + macro asks a list for `.get`. Verified, not assumed. + + This endpoint renders every pick in the booth on every page load of the + operator's report, so one such entry would 500 the whole seam and the + report would show no chrome at all — while `hold_read` reported the file + as perfectly readable. Same leniency `_hydrate_safe` already applies one + layer down, at the layer that actually renders: one unreadable pick + costs that pick, never the page. + """ + try: + return _pick_fragments(name, mark) + except Exception as exc: # noqa: BLE001 - deliberate + broken = replace(mark, error=f"this question could not be rendered: {exc}") + return {"id": mark.id, "error": broken.error, + "whole": str(_frag.whole(broken, ask_form_id(mark.id), + quote(name, safe=""))), + "submit": "", "questions": []} + @app.get("/b/{name}/embed.json") def booth_embed_json(name: str): """Everything a verbatim report needs to mount the Booth's chrome. @@ -1133,12 +1175,15 @@ def create_app( picks = [m for m in marks if m.shape == "pick"] body = { "booth": name, - "home": "/", + # No `home`: the way-home chip mounts from a constant BEFORE this + # fetch, so that a failed one still leaves the operator a way out. + # Carrying the value anyway would put a second representation of it + # on the wire for nothing to read. "favicon": FAVICON_HREF, # Picks only. It is also what keeps a flag's `flag:` id — # the one mark id containing the separator an anchor spec splits # on — out of a payload whose specs split on the first colon. - "marks": [_pick_fragments(name, m) for m in picks], + "marks": [_safe_fragments(name, m) for m in picks], "open": [m.id for m in open_marks(picks)], } if read_err is not None: diff --git a/booth/static/embed.js b/booth/static/embed.js index 65e8cc3..b4a3ec4 100644 --- a/booth/static/embed.js +++ b/booth/static/embed.js @@ -114,8 +114,14 @@ /* beforeend, NOT replaceWith: the author's element and its contents survive and the fragment lands inside it. `

heading

` is live markup today, and the regex it replaced ate - both the wrapper class and the heading's framing. */ + both the wrapper class and the heading's framing. + + Returns the elements it actually inserted. The chip needs to jump to a + fragment WE mounted, not to whatever the document happens to have with a + matching id — see chipTarget. */ + var before = el.children.length; el.insertAdjacentHTML("beforeend", html); + return Array.prototype.slice.call(el.children, before); } function styles() { @@ -145,22 +151,32 @@ document.body.appendChild(a); } - function asksChip(openIds) { + function chipTarget(mounted, markId) { + /* The earliest IN DOCUMENT ORDER of the elements WE mounted for this mark. + Not an id-prefix search over the whole document: a panel pointed out that + an author's own `
` satisfies any + prefix rule — hyphen boundary included — and would hijack the jump. Only + elements this script inserted are candidates, which is the identity the + deleted `bk-ask--top` anchor used to guarantee. */ + var mine = mounted[markId] || []; + var first = null; + for (var i = 0; i < mine.length; i++) { + var el = mine[i]; + if (!el.id || !document.contains(el)) continue; + if (first === null || + (first.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) { + first = el; + } + } + return first; + } + + function asksChip(openIds, mounted) { if (!openIds.length) return; /* A JUMP LINK, not a way out to another page: on a long report the question can be well below the fold and "there is a question waiting" still has to - be visible at first paint. Target: the FIRST element in document order - whose id belongs to the first open mark - the fragments already carry - ids, so the separate `bk-ask--top` anchor is not needed. */ - var first = null; - var all = document.querySelectorAll('[id^="bk-ask-"]'); - for (var i = 0; i < all.length; i++) { - var id = all[i].id; - if (id === "bk-ask-" + openIds[0] || id.indexOf("bk-ask-" + openIds[0] + "-") === 0) { - first = all[i]; - break; - } - } + be visible at first paint. */ + var first = chipTarget(mounted, openIds[0]); var a = document.createElement("a"); a.className = "booth-nav-asks"; a.href = first ? "#" + first.id : "/b/" + encodeURIComponent(boothName() || "") + "/marks"; @@ -169,13 +185,16 @@ } function reassociate() { - /* A control bound to its by the HTML5 `form=` attribute resolves its + /* SCOPED TO OUR OWN FRAGMENTS (`.bk-ask [form]`), deliberately: the Booth + does not rewrite attributes on elements the author wrote, even to help. + A control bound to its by the HTML5 `form=` attribute resolves its form owner when it is inserted. The fragments go in in VISUAL order, so a question can land before the submit block that carries the . Chromium 151 re-resolves this correctly - measured 2026-09-22, N=3 per condition, with a form-first positive control and a points-at-nothing negative control. The sensitivity floor of that probe is ONE ENGINE, and - the failure it would hide is a form that looks filled in and POSTs a 400. + the failure it would hide is a form the operator fills in whose controls + reach no form at all, so the button does nothing and nothing is saved. Three lines, so the engine stops mattering. */ var bound = document.querySelectorAll(".bk-ask [form]"); for (var i = 0; i < bound.length; i++) { @@ -185,17 +204,41 @@ } } + function hasForm(markId) { + /* `form_id` in booth/app.py builds the same string. Kept in step by the + fragments themselves: the submit macro emits exactly this id. */ + return !!document.getElementById( + "bk-ask-form-" + markId.replace(/[^A-Za-z0-9_-]/g, "-")); + } + function place(marks) { - var by = {}; + /* Object.create(null), NOT {} — three times, and it is not style. + A mark id and a question key are both `[A-Za-z0-9][A-Za-z0-9._-]*` + (asks.valid_stem, asks._KEY_RE), so `toString` and `constructor` are + legal in both. Against a plain object, an author writing + `data-booth-mark="toString"` — an anchor naming NO mark — gets + Object.prototype.toString back, passes the `if (!mark)` guard it was + supposed to fail, and throws on `mark.questions.length`. That aborts + `place` before the tail, so the page loses EVERY ask, from one typo in + the author's own markup. The `placed` set has the mirror bug: inherited + `got.constructor` reads as "already placed" and silently drops a real + question. Found by a cross-frontier code-review panel. */ + var by = Object.create(null); for (var i = 0; i < marks.length; i++) by[marks[i].id] = marks[i]; - var placed = {}; // id -> {key or WHOLE: true} - var submitted = {}; + var placed = Object.create(null); // id -> {key or WHOLE: true} + var submitted = Object.create(null); + var mounted = Object.create(null); // id -> [elements this script inserted] function note(id, key) { - if (!placed[id]) placed[id] = {}; + if (!placed[id]) placed[id] = Object.create(null); if (key !== undefined) placed[id][key] = true; } + function record(id, els) { + if (!mounted[id]) mounted[id] = []; + for (var n = 0; n < els.length; n++) mounted[id].push(els[n]); + } + // 1. whole / per-question anchors, in DOCUMENT ORDER. var anchors = document.querySelectorAll(MAIN_SEL); for (var a = 0; a < anchors.length; a++) { @@ -204,9 +247,9 @@ var mark = by[spec[0]]; if (!mark) continue; // a typo'd id is LEFT ALONE, not blanked if (spec[1] === null) { - mount(el, mark.whole); + record(mark.id, mount(el, mark.whole)); note(mark.id, WHOLE); - submitted[mark.id] = true; + if (hasForm(mark.id)) submitted[mark.id] = true; continue; } var q = null; @@ -214,7 +257,7 @@ if (mark.questions[k].key === spec[1]) { q = mark.questions[k]; break; } } if (!q) continue; // names no question: also left alone - mount(el, q.html); + record(mark.id, mount(el, q.html)); note(mark.id, spec[1]); } @@ -225,21 +268,41 @@ var sid = splitSpec(attr(sel, "data-booth-mark-submit", "data-booth-ask-submit"))[0]; var sm = by[sid]; if (!sm) continue; - mount(sel, sm.submit); + /* A BROKEN pick has no submit block — its `submit` is the empty string and + its diagnostic lives in `whole`. Mounting nothing here and then marking + it placed made the tail skip it, so the "broken ask" box never rendered + at the one surface built to show it. Leave the anchor alone, exactly as + an anchor naming no mark is left alone, and let the tail mount the + diagnostic. */ + if (sm.error) continue; + record(sm.id, mount(sel, sm.submit)); note(sm.id); - submitted[sm.id] = true; + /* ...and only count it submitted if the SURVIVED. An author who + puts this anchor inside their own loses ours: the HTML parser + drops a nested form element outright. Every control's `form=` would + then point at nothing, the tail would not add a fallback because we + said it was handled, and the operator would fill the whole thing in and + click a button that does nothing. */ + if (hasForm(sm.id)) submitted[sm.id] = true; } // 3. the tail, in PAYLOAD order - `(created, id)`. An ask is never // invisible: an unmarked page gets the whole thing, and a partially // marked one gets every question the author did not place, because a - // multi-question pick needs ALL of them or the POST is a 400 the - // operator meets only after filling it in. + // question the operator cannot see is a question he cannot answer, and + // a submission with NOTHING picked is refused outright (400), so a page + // showing two of four questions can strand a pick that looks answerable. + // (A PARTIAL answer is accepted and recorded — that is deliberate.) var holder = document.createElement("div"); for (var m = 0; m < marks.length; m++) { var mk = marks[m]; var got = placed[mk.id]; - if (!got) { holder.insertAdjacentHTML("beforeend", mk.whole); continue; } + var was = holder.children.length; + if (!got) { + holder.insertAdjacentHTML("beforeend", mk.whole); + record(mk.id, Array.prototype.slice.call(holder.children, was)); + continue; + } if (mk.error) continue; if (!got[WHOLE]) { for (var q2 = 0; q2 < mk.questions.length; q2++) { @@ -248,26 +311,31 @@ } } if (!submitted[mk.id]) holder.insertAdjacentHTML("beforeend", mk.submit); + record(mk.id, Array.prototype.slice.call(holder.children, was)); } var tail = document.createDocumentFragment(); while (holder.firstChild) tail.appendChild(holder.firstChild); document.body.appendChild(tail); + return mounted; } function start() { var name = boothName(); if (!name || !document.body) return; styles(); - homeChip("/"); // needs no payload, so a failed fetch still - // leaves the operator a way out + // Mounted BEFORE the fetch and from a constant, so a failed or slow fetch + // still leaves the operator a way out. That is why the payload carries no + // `home` — a value on the wire that nothing reads is a second + // representation of one fact, waiting to disagree with the first. + homeChip("/"); fetch("/b/" + encodeURIComponent(name) + "/embed.json", { credentials: "same-origin" }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (!data) return; favicon(data.favicon); - place(data.marks || []); + var mounted = place(data.marks || []); reassociate(); - asksChip(data.open || []); + asksChip(data.open || [], mounted); document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } })); }) .catch(function () { /* the report is the operator's; a failed fetch costs diff --git a/docs/contracts/u3_declared_embed_seam.contract.md b/docs/contracts/u3_declared_embed_seam.contract.md index 15a3314..013e8f8 100644 --- a/docs/contracts/u3_declared_embed_seam.contract.md +++ b/docs/contracts/u3_declared_embed_seam.contract.md @@ -232,7 +232,8 @@ points-at-nothing negative control) returned `F, F, F` for control-first and `null, null, null` for the negative. So the pass is *not* needed in Chromium. It is three lines, it costs nothing, and the sensitivity floor of that probe is **one engine** — the operator's own browser was not measured. The failure it -guards against is a form that looks filled in and POSTs a 400. +guards against is a form the operator fills in whose controls reach no form, +so the button does nothing. **Step 5 deletes an element.** Today `inject_asks` injects `` before the first fragment of each pick so the chip has somewhere to jump. The @@ -246,18 +247,25 @@ Each is falsifiable by a change that a test must catch going red. The invariant. (Five of seven U4 falsifiers were vacuous; see `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.) -**INV-1 — A page that declares the seam is served with no Booth markup added.** +**INV-1 — A page that declares the seam is served BYTE FOR BYTE.** The response body for a verbatim booth whose `index.html` contains -`src="/_booth/embed.js"` (either quote style) is exactly the text read from that -file. A page that only mentions the path is NOT declaring it — see the +`src="/_booth/embed.js"` (either quote style) is exactly the bytes on disk. +⚠ **Bytes, not text, and that is a correction.** The first implementation read +with `read_text()`, which opens in universal-newline mode: a CRLF report came +back LF, and `errors="replace"` turned any non-UTF-8 byte into U+FFFD. A +declaring page was NOT served as its author wrote it — the headline promise — +and the test could not see it, because its fixture was LF-only ASCII. The file +is decoded only to ask whether it declares the seam; what goes on the wire is +the original bytes. A page that only mentions the path is NOT declaring it — see the conditional-append assumption for which way that has to fail. *Falsifiable:* append anything — a chip, a comment, a newline — to the declaring branch's response and `test_declaring_page_is_served_untouched` fails on a whole-body equality, not on a substring absence. **INV-2 — A page that does not declare the seam, AND IS UNDER `WRAP_MAX_BYTES`, -is mutated exactly once, at the end.** The response is the source text plus -`EMBED_SCRIPT_TAG` and nothing else, with the source text a prefix of it. +is mutated exactly once, at the end.** The response is the source BYTES plus +`EMBED_SCRIPT_TAG`'s bytes and nothing else, with the source a byte-exact +prefix of it. ⚠ **The size cap is an explicit exception, not an oversight** — two cold arms read the invariant's universal wording against the raw-`FileResponse` assumption and found them prescribing different responses for the same page. An @@ -414,3 +422,50 @@ across two unrelated callers the same evening independently proposed promoting the end-to-end seam-walk from a conditional deliverable to a mandatory one. CR-1 is a direct product of that exercise. Recorded here as evidence; the skill change is the operator's call, not this repo's. + +## Bug hunt — the cold panel + +`/heid-bug-hunt`, four arms, artifact-only over the merge-base diff, dispatched +`01M352TPCSN52G6NGJ07T5WSGY`. ⚠ **The snapshot predates the contract-review +fixes**, so two of its findings were already closed when the reply landed; the +arms flagged the staleness themselves. + +| # | finding | arms | disposition | +|---|---|---|---| +| **BH-1** | **A declaring page was NOT served as written.** `read_text()` opens in universal-newline mode, so a CRLF report came back LF, and `errors="replace"` replaced any non-UTF-8 byte. The headline promise, broken by the read itself — and invisible to a test whose fixture is LF-only ASCII. | 1 | **Genuine add, and the best finding of the round.** The verbatim branch reads and serves BYTES; the decoded copy answers only "does it declare?". INV-1 and INV-2 now state the byte-level promise, with a CRLF-plus-invalid-byte fixture. | +| **BH-2** | **A submit anchor inside the author's own `` loses ours** — the HTML parser drops a nested form outright. Every control's `form=` then points at nothing, and the code recorded the pick as submitted so the tail added no fallback. The operator fills it in and the button does nothing. | 1 | **Genuine add.** A submit anchor counts as submitted only if the form actually survived (`hasForm`); otherwise the tail supplies one at body level, where no form encloses it. | +| **BH-3** | **A broken pick's diagnostic never rendered from a submit-only anchor.** An errored pick's `submit` is empty; mounting that and marking it placed made the tail skip it, so the "broken ask" box vanished from the one surface built to show it. | 3 of 4 | **Genuine add.** A submit anchor for an errored pick is left alone, exactly as an anchor naming no mark is, and the tail mounts the diagnostic. | +| **BH-4** | **An author's own element can hijack the chip.** `
` satisfies any id-prefix rule — the hyphen boundary from CR-7 included. | 4 of 4 | **Genuine add, and it supersedes CR-7's fix.** The chip now searches only the elements THIS SCRIPT MOUNTED, which is the identity the deleted `bk-ask--top` anchor used to guarantee, and takes the earliest of those by `compareDocumentPosition`. | +| **BH-5** | **No error boundary around fragment rendering.** A `.marks.json` that is well-formed JSON with a wrong-shaped `answer` hydrates with no error and then raises in the macro. | 1, `needs-repro` | **Genuine add — reproduced before building for it.** `_safe_fragments` returns a per-mark error record, the same leniency `_hydrate_safe` applies one layer down. ⚠ **The gallery and marks pages still 500 on it, and that is PRE-EXISTING** — measured at `42ea67f`. Out of scope here and recorded rather than quietly widened: `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`. | +| **BH-6** | Prototype pollution in the placement maps (`toString` as a mark id, `constructor` as a question key). | 1 | **Already fixed this round** as CR-13, from the code-review panel. Two panels, two lenses, the same defect independently — the strongest signal of the evening that the lenses are not redundant. | +| **BH-7** | Bare-substring declaration suppresses the chrome. | 4 of 4 | **Already fixed** as CR-3, before the reply landed. | + +**One correction the panel made to this repo's own prose, adopted:** several +comments claimed a multi-question pick POSTs a 400 unless every question is +answered. It does not — `test_empty_submission_is_refused_with_400` refuses a +WHOLLY EMPTY submission, and a partial answer is accepted and recorded on +purpose. The real reason an unplaced question must still be appended is simpler +and was being obscured: **a question that never reaches the page cannot be +answered at all.** Fixed in `embed.js`, the browser tests and this contract. + +**Not adopted:** the bundle's framing called the service Flask. It is FastAPI; +the arm noticed and declined to reason from it, which is the right handling. + +## Vacuity pass — final + +21 mutations, each drawn from an invariant's CLAIM rather than its falsifier's +example, each run against its named test, plus an unmutated control run. +**21/21 caught, control green.** + +The pass earned its place three times over and none of them was the first run: + +1. It reported **7/7** before the contract panel, which then showed INV-3 was + vacuous — because the mutation applied was the one the contract named. +2. Re-run **against that fix**, it found the fix's own hole (an aliased + `import re as _r`). +3. Re-run after the bug-hunt fixes, it reported seven **MUTATION-MISS** rows — + its loud-failure mode, firing correctly because the fixes had moved the code + out from under stale mutations — and then one genuine **VACUOUS**: the + sibling-mark chip test had its fixture arranged so the right answer was also + the first answer. Rewritten so the sibling comes first, which is the only + arrangement that can tell the two implementations apart. diff --git a/persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md b/persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md new file mode 100644 index 0000000..66ccf13 --- /dev/null +++ b/persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md @@ -0,0 +1,74 @@ +# A wrong-shaped answer 500s the gallery and the marks page — PRE-EXISTING, NOT U3 + +_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//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]]. diff --git a/persistent-memory.md b/persistent-memory.md index 176e8b6..fd758be 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -49,13 +49,15 @@ _As of 2026-09-22:_ a JavaScript dependency it did not have, and an author-facing anchor syntax — and **minor needs his explicit approval**. `pyproject.toml` still says `0.4.0`; nothing is tagged. Do not bump it on your own. -- ⚠ **TWO U3 GATES ARE STILL IN FLIGHT** and must be drained, triaged and - answered: `/heid-code-review` (`01M352RXV1ZET566KV73C7TSB8`) and - `/heid-bug-hunt` (`01M352TPCSN52G6NGJ07T5WSGY`), both dispatched 17:32Z. - **The `/heid-contract-review` panel (`01M351WKV666D681SSRNY7D7X6`) is CLOSED** - — 12 findings, 10 adopted, 2 already settled, 1 declined, reply sent. Two of - its adopted findings were CODE fixes, not wording: the vacuous INV-3 falsifier - and the bare-substring seam detection. The **seam review ran in-session and is +- **ALL FOUR U3 GATES ARE CLOSED.** In-session seam review (5 findings, SR-2 a + real payload-shape bug); `/heid-contract-review` + (`01M351WKV666D681SSRNY7D7X6`, 12 findings, 10 adopted, 2 already settled by + the seam review while it was in flight, 1 declined); + `/heid-code-review` (`01M352RXV1ZET566KV73C7TSB8`, 3 more vacuous falsifiers + + the prototype-pollution bug); `/heid-bug-hunt` + (`01M352TPCSN52G6NGJ07T5WSGY`, 5 net-new, incl. the byte-exactness break). + **Seven of the adopted findings were CODE fixes, not wording** — the cold + gates were not ceremony on this unit. The **seam review ran in-session and is folded in** — five findings as a table at the end of the U3 contract, and SR-2 was a real payload-shape bug the cold panel structurally could not see. U4's three and U5's three are all closed (`01M34VX0SH23Y3VC92E7GM4S70`, `01M34WAFJC3RTERFYBBZJN1SVG`, @@ -86,6 +88,7 @@ _As of 2026-09-22:_ ## Recent decisions - `[2026-09-22]` **U3 landed — the page declares the seam, the Booth mounts into it** — ten regexes against author HTML replaced by a substring test and a `+` → `persistent-memory.d/2026-09-22-u3-declared-embed-seam-landed.md` +- `[2026-09-22]` **A wrong-shaped answer 500s the gallery and the marks page** — PRE-EXISTING (measured at `42ea67f`), NOT U3; the v0.2.2 lesson is only half-implemented → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md` - `[2026-09-22]` **The browser became a test surface** — READ BEFORE TOUCHING `playwright` IN pyproject; the pinned upper bound is the foot-gun, and these tests SKIP rather than fail → `persistent-memory.d/2026-09-22-the-browser-became-a-test-surface.md` - `[2026-09-22]` **A vacuity pass that tries the contract's own mutation agrees with itself** — U3 ran one, reported 7/7, and a cold panel then showed one of the seven was vacuous; READ BEFORE WRITING A *Falsifiable:* LINE → `persistent-memory.d/2026-09-22-seven-of-seven-falsifiers.md` - `[2026-09-22]` **U4 landed — lifetime is derived, not declared** — three states, viewing is activity, and no new arithmetic anywhere → `persistent-memory.d/2026-09-22-u4-derived-lifetime-landed.md` diff --git a/tests/test_embed.py b/tests/test_embed.py index f7a0f80..6cacdcc 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -58,7 +58,8 @@ def test_embed_payload_carries_a_fragment_for_every_shape(client): c, data = client _multi(data / "b") body = c.get("/b/b/embed.json").json() - assert body["booth"] == "b" and body["home"] == "/" + assert body["booth"] == "b" + assert "home" not in body, "a value nothing reads is a second copy waiting to drift" assert body["favicon"].startswith("data:image/svg+xml,") (m,) = body["marks"] assert m["id"] == "batch" and m["error"] is None @@ -80,11 +81,29 @@ def test_a_single_question_pick_has_one_question_with_a_null_key(client): assert "Which render wins?" in m["questions"][0]["html"] -def test_marks_are_ordered_created_then_id(client): +def test_marks_are_ordered_by_creation_before_id(client): + """`created` LEADS. The fixture makes creation order and alphabetical order + disagree, because a fixture where they agree cannot tell the stated rule + from a plain id sort — which is what the first version of this test did, and + what a panel caught by reading the fixture rather than the assertion.""" + c, data = client + b = _pick(data / "b", "zebra") + _pick(b, "alpha") + raw = json.loads((b / ".marks.json").read_text()) + stamps = {"zebra": "2026-09-22T10:00:00.000000-07:00", # first + "alpha": "2026-09-22T11:00:00.000000-07:00"} # second + for e in raw["marks"]: + e["created"] = stamps[e["id"]] + (b / ".marks.json").write_text(json.dumps(raw)) + assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["zebra", "alpha"] + + +def test_the_id_is_only_the_tie_break(client): + """And with `created` equal, the id decides — so two marks written in the + same second cannot swap between renders.""" c, data = client b = _pick(data / "b", "zebra") _pick(b, "alpha") - # force identical creation stamps: the id is the tie-break, not json order raw = json.loads((b / ".marks.json").read_text()) for e in raw["marks"]: e["created"] = "2026-09-22T10:00:00.000000-07:00" @@ -179,10 +198,16 @@ def test_embed_js_does_not_hot_reload_from_disk(tmp_path): original = src.read_text() app = create_app(tmp_path, ttl_hours=24, start_sweeper=False) c = TestClient(app) - served = c.get(EMBED_SRC).text try: + # Poisoned BEFORE the first request, not between two of them. The + # earlier shape passed for a route that read the file lazily and cached + # on first use — which is not "read once at startup", and is exactly the + # staleness this invariant exists to forbid. src.write_text("/* POISONED */\n") - assert c.get(EMBED_SRC).text == served, "embed.js is being re-read per request" + first = c.get(EMBED_SRC).text + assert "POISONED" not in first, "embed.js is read at request time, not at startup" + assert first == original + assert c.get(EMBED_SRC).text == first finally: src.write_text(original) @@ -208,8 +233,13 @@ def test_undeclared_page_gains_only_the_tag(client): src = "r

REPORT

" (b / "index.html").write_text(src) out = c.get("/b/b/").text + # The literal, not just the constant: `out == src + EMBED_SCRIPT_TAG` also + # holds when EMBED_SCRIPT_TAG is the empty string, which is a mutation this + # test exists to catch. A panel found it by reading the assertion, not the + # code. + assert out == src + '' assert out == src + EMBED_SCRIPT_TAG - assert out.startswith(src) + assert len(out) > len(src) assert out.lower().lstrip().startswith("bare fragment" + EMBED_SCRIPT_TAG +def test_a_declaring_page_is_served_BYTE_for_byte(client): + """INV-1, at the level the promise is actually made. + + The first version read the file with `read_text()`, which opens in + universal-newline mode: a report written with CRLF came back with LF, and + `errors="replace"` turned any non-UTF-8 byte into U+FFFD. A declaring page + was NOT served as its author wrote it — the headline promise — and the + original test could not see it, because its fixture was LF-only ASCII. + Found by a cross-frontier bug-hunt panel. + """ + c, data = client + b = _pick(data / "b") + src = (b'\r\nr\r\ncaf\xe9 \xff\r\n' + b'\r\n') + (b / "index.html").write_bytes(src) + r = c.get("/b/b/") + assert r.content == src, "the operator's document was edited on the way out" + assert b"\r\n" in r.content and b"\xff" in r.content + + +def test_an_undeclared_page_keeps_every_byte_and_gains_the_tag(client): + """INV-2, same level: the source is a BYTE-exact prefix of the response.""" + c, data = client + b = _pick(data / "b") + src = b'\r\nr\r\ncaf\xe9 \xff\r\n' + (b / "index.html").write_bytes(src) + r = c.get("/b/b/") + assert r.content == src + EMBED_SCRIPT_TAG.encode("utf-8") + assert r.content.startswith(src) + + +def test_a_wrongly_shaped_answer_costs_its_pick_not_the_report(client): + """`marks_for` hydrates `{"answer": {"answers": []}}` with no error — the + JSON is well formed, the SHAPE is not — and the template then asks a list + for `.get`. This endpoint renders every pick on every load of the operator's + report, so an unguarded raise here is the whole seam gone while `hold_read` + calls the file perfectly readable. Verified reachable, not assumed.""" + c, data = client + b = data / "b" + b.mkdir(parents=True, exist_ok=True) + declare_pick(b, "batch", {"title": "T", "questions": [ + {"key": "r1", "prompt": "A?", "options": ["x", "y"]}, + {"key": "r2", "prompt": "B?", "options": ["x", "y"]}]}) + _pick(b, "healthy") + raw = json.loads((b / ".marks.json").read_text()) + for e in raw["marks"]: + if e["id"] == "batch": + e["answer"] = {"answers": [], "notes": ""} + (b / ".marks.json").write_text(json.dumps(raw)) + + (b / "index.html").write_text(DECLARED) + r = c.get("/b/b/embed.json") + assert r.status_code == 200 + by = {m["id"]: m for m in r.json()["marks"]} + assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"] + assert "broken ask" in by["batch"]["whole"] + # and the booth's other pick is untouched — one bad entry costs one entry + assert by["healthy"]["error"] is None + assert "Which render wins?" in by["healthy"]["whole"] + assert c.get("/b/b/").status_code == 200 + + # ⚠ THE GALLERY AND MARKS PAGES STILL 500 ON THIS ENTRY, and that is NOT + # U3's doing — measured at 42ea67f, the commit before this unit. They render + # the same macro without this guard. Out of scope here (the gallery is named + # out of scope in the contract) and recorded rather than quietly widened: + # see persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md + + def test_no_regex_touches_author_html(): """INV-3, and the version that actually falsifies it. diff --git a/tests/test_embed_browser.py b/tests/test_embed_browser.py index 34e6a44..99977b8 100644 --- a/tests/test_embed_browser.py +++ b/tests/test_embed_browser.py @@ -194,7 +194,8 @@ def test_the_authors_wrapper_and_its_contents_survive_the_mount(browser, live): def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live): """INV-7. A multi-question pick needs EVERY question on submit or the POST is - a 400 the operator meets only after filling the form in.""" + unanswerable: a question that never reaches the page cannot be picked, and + a submission with nothing picked at all is refused outright.""" base, data = live b = _multi(data / "b") page = _open(browser, base, "b", @@ -202,7 +203,10 @@ def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live): # attached, not visible: the shared is deliberately empty and so has # no box — the controls that bind to it are what the operator sees. page.wait_for_selector("form#bk-ask-form-batch", state="attached") - assert page.locator('input[name="choice.r2"]').count() == 2 # never dropped + # BOTH halves. Asserting only the appended r2 let a mutation that silently + # swallowed the anchored r1 — while still recording it as placed — pass. + assert page.locator('input[name="choice.r1"]').count() == 2 # anchored, mounted + assert page.locator('input[name="choice.r2"]').count() == 2 # unplaced, appended assert page.locator("form#bk-ask-form-batch").count() == 1 # submittable page.close() @@ -231,14 +235,19 @@ def test_an_anchor_naming_no_mark_is_left_alone(browser, live): def test_the_tail_follows_payload_order(browser, live): - """INV-6. Two picks whose creation order and id order disagree: the page must - render them `(created, id)`, the order every other surface reads.""" + """INV-6. Creation order and alphabetical order DISAGREE here on purpose: + `winner` is created first, `batch` second, so payload order is + winner-then-batch while an id sort would give the reverse. The first version + of this fixture made the two identical, so sorting the tail alphabetically + in JavaScript passed it — caught by a panel reading the fixture.""" base, data = live b = _single(data / "b") _multi(b) raw = json.loads((b / ".marks.json").read_text()) + stamps = {"winner": "2026-09-22T10:00:00.000000-07:00", # first + "batch": "2026-09-22T11:00:00.000000-07:00"} # second for e in raw["marks"]: - e["created"] = "2026-09-22T10:00:00.000000-07:00" + e["created"] = stamps[e["id"]] (b / ".marks.json").write_text(json.dumps(raw)) page = _open(browser, base, "b", f"{SEAM}", b) page.wait_for_selector(".bk-ask") @@ -246,9 +255,9 @@ def test_the_tail_follows_payload_order(browser, live): batch = [i for i, v in enumerate(ids) if "batch" in v] winner = [i for i, v in enumerate(ids) if "winner" in v] assert batch and winner, ids - # identical `created`, so the id is the tie-break: batch before winner, - # every fragment of one ahead of every fragment of the other. - assert max(batch) < min(winner), ids + # winner (created first) ahead of batch (created second) — the OPPOSITE of + # alphabetical, so an id sort cannot pass this. + assert max(winner) < min(batch), ids page.close() @@ -262,7 +271,7 @@ def test_a_form_scattered_down_the_report_submits_every_question(browser, live): entirely, bound only by the HTML5 `form=` attribute — and now inserted into a live document in visual order, which means a control can land before the form it points at. If form-owner resolution does not survive that, the - operator fills the whole thing in and gets a 400. + operator fills the whole thing in and the button saves nothing. It was true by construction before U3 (static HTML, resolved at parse). It is true by measurement now. That is the trade this test pays for. @@ -292,31 +301,170 @@ def test_the_chip_jumps_to_the_first_fragment_of_the_open_ask(browser, live): chip = page.locator(".booth-nav-asks") assert chip.inner_text() == "? 1 open ask" target = chip.get_attribute("href") - assert target.startswith("#bk-ask-batch") - assert page.locator(target).count() == 1 + # the EARLIEST match in document order, not merely a match: the report + # anchors r1 above r2 above the submit block, so any later one is wrong. + ids = page.eval_on_selector_all("[id^='bk-ask-batch']", "els => els.map(e => e.id)") + assert ids, "no batch fragment mounted" + assert target == "#" + ids[0], (target, ids) + page.close() + + +def test_an_anchor_named_like_an_object_property_does_not_kill_the_page(browser, live): + """`toString` is a legal mark id (`asks.valid_stem`) and therefore a legal + thing for an author to typo into an anchor. Against a plain `{}` lookup it + came back as Object.prototype.toString — truthy, so it sailed past the + unknown-mark guard and threw on `.questions.length`, aborting placement + before the tail and costing the page EVERY ask. One typo, no chrome, no + error the operator would see. Found by a cross-frontier panel.""" + base, data = live + b = _multi(data / "b") + page = _open(browser, base, "b", ( + '
' + f'
{SEAM}'), b) + page.wait_for_selector(".bk-ask") + assert page.locator("#t").inner_html().strip() == "" # left alone + assert page.locator("#v").inner_html().strip() == "" # left alone + # and the real ask still mounted, which is what the bug destroyed + assert page.locator('input[name="choice.r1"]').count() == 2 + assert page.locator('input[name="choice.r2"]').count() == 2 + assert page.locator("form#bk-ask-form-batch").count() == 1 + page.close() + + +def test_a_question_keyed_like_an_object_property_is_not_swallowed(browser, live): + """The mirror of the same bug, on the `placed` set. `constructor` matches + `asks._KEY_RE`, and against a plain object an inherited `got.constructor` + read as ALREADY PLACED — so a question the author did not anchor was + silently dropped from the tail, which is INV-7's whole subject.""" + from booth.marks import declare_pick + + base, data = live + b = data / "b" + b.mkdir(parents=True, exist_ok=True) + declare_pick(b, "batch", {"title": "Round one", "questions": [ + {"key": "r1", "prompt": "First?", "options": ["keep", "cut"]}, + {"key": "constructor", "prompt": "Second?", "options": ["keep", "cut"]}, + ]}) + page = _open(browser, base, "b", + f'
{SEAM}', b) + page.wait_for_selector("form#bk-ask-form-batch", state="attached") + assert page.locator('input[name="choice.r1"]').count() == 2 + assert page.locator('input[name="choice.constructor"]').count() == 2, \ + "an unplaced question was swallowed by an inherited property" + page.close() + + +def test_a_submit_anchor_inside_the_authors_own_form_still_submits(browser, live): + """A submit anchor placed inside the author's own `` loses ours: the + HTML parser drops a nested form element outright. The controls' `form=` + then points at nothing, the button does nothing, and the operator finds out + by filling the whole thing in. Found by a cross-frontier bug-hunt panel; the + fix is to count the anchor submitted only if the form actually survived, so + the tail supplies one at body level where no form encloses it.""" + base, data = live + b = _multi(data / "b") + page = _open(browser, base, "b", ( + '' + '
' + '
' + '' + '
' + f"{SEAM}"), b) + page.wait_for_selector("form#bk-ask-form-batch", state="attached") + assert page.locator("form#bk-ask-form-batch").count() == 1 + page.check('input[name="choice.r1"][value="keep"]') + page.check('input[name="choice.r2"][value="cut"]') + with page.expect_navigation(): + page.click("button.bk-ask-go") + ans = _answer(b) + assert ans is not None, "the button reached no form" + assert ans["answers"]["r1"]["choice"] == "keep" + assert ans["answers"]["r2"]["choice"] == "cut" + page.close() + + +def test_a_broken_pick_shows_its_diagnostic_even_from_a_submit_anchor(browser, live): + """A broken pick has no submit block — its `submit` is empty and the + diagnostic lives in `whole`. Mounting that empty string and then recording + the pick as placed made the tail skip it, so the 'broken ask' box never + rendered at the one surface built to show it. A question the session + believes it posted has to be visible.""" + base, data = live + b = _single(data / "b") + raw = json.loads((b / ".marks.json").read_text()) + raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError + (b / ".marks.json").write_text(json.dumps(raw)) + page = _open(browser, base, "b", + f'
' + f"
{SEAM}", b) + page.wait_for_selector(".bk-ask") + assert page.locator("#s").inner_html().strip() == "" # anchor left alone + # inner_text() is the RENDERED text, and `.bk-ask-tag` is uppercased by CSS — + # so assert the diagnostic itself, which is the part that has to reach him. + shown = page.locator("body").inner_text() + assert "this question could not be read" in shown, shown + assert "options" in shown # the actual reason + page.close() + + +def test_an_author_element_cannot_hijack_the_chip(browser, live): + """`
` satisfies any id-prefix rule, + hyphen boundary included. The chip therefore searches only the elements THIS + SCRIPT MOUNTED — the identity the deleted `bk-ask--top` anchor used to + guarantee — and takes the earliest of those in document order.""" + base, data = live + b = _single(data / "b") + page = _open(browser, base, "b", ( + '
notes
' + f"

report

{SEAM}"), b) + page.wait_for_selector(".booth-nav-asks") + target = page.locator(".booth-nav-asks").get_attribute("href") + assert target != "#bk-ask-winner-background" + landed = page.locator(target) + assert landed.count() == 1 + assert landed.evaluate("e => e.classList.contains('bk-ask')"), \ + "the chip jumped to something the Booth did not mount" page.close() def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live): - """A cold panel read the chip rule as a bare prefix match and pointed out - that `bk-ask-batch2-...` starts with `bk-ask-batch`. It does not match: the - rule is the id EXACTLY, or the id followed by a hyphen. An author element - can collide too, so the fixture plants one.""" + """A SIBLING MARK's fragment must not take the chip, even when it is earlier + in the document. `batch2`'s id starts with `batch`, so the original + id-prefix rule could land on it; the mounted-elements rule cannot, because + the candidates are partitioned by mark. + + ⚠ The first version of this test put the sibling's fragment AFTER the open + mark's, so the right answer was also the first answer and pooling every + mark's elements passed it. The vacuity pass caught that; the fixture now + puts the sibling FIRST, which is the only arrangement that can tell the two + implementations apart. + """ from booth.marks import declare_pick base, data = live - b = _multi(data / "b") + b = _multi(data / "b") # `batch`, created first declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]}) - page = _open(browser, base, "b", - f'
{SEAM}', b) + + # batch2 is anchored at the very top; batch is unanchored and so lands in + # the tail, at the END of the body. Document order is therefore batch2's + # fragments, then batch's. + page = _open(browser, base, "b", ( + '
' + f"

report

{SEAM}"), b) page.wait_for_selector(".booth-nav-asks") + + ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)") + assert any("batch2" in i for i in ids) and any( + "batch2" not in i and "batch" in i for i in ids), ids + assert ids.index(next(i for i in ids if "batch2" in i)) < \ + ids.index(next(i for i in ids if "batch2" not in i and "batch" in i)), \ + f"fixture is wrong: the sibling must come FIRST, got {ids}" + + # `open` is (created, id) -> batch before batch2, so the chip targets batch target = page.locator(".booth-nav-asks").get_attribute("href") - assert target != "#bk-ask-batchX" - landed = page.locator(target) - assert landed.count() == 1 - # whatever it points at belongs to `batch` itself, not to `batch2` - assert "batch2" not in target - assert landed.locator('input[name^="choice."]').count() > 0 or "batch" in target + assert "batch2" not in target, f"the chip landed on the sibling mark: {target}" + assert target.startswith("#bk-ask-batch") + assert page.locator(target).count() == 1 page.close() @@ -353,3 +501,39 @@ def test_the_chip_count_comes_from_the_server(browser, live): page.wait_for_selector(".bk-ask") assert page.locator(".booth-nav-asks").count() == 0 page.close() + + +def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, live): + """INV-4, and the version that actually falsifies it. + + The test above uses honest fixtures, so a client that INFERRED openness from + the rendered fragments would pass it — the fragments and `open` always agree + when the server computes both. A panel pointed out that this never creates + the disagreement it claims to test. + + So: intercept the response and make `open` lie. The fragments say fully + answered; the payload says two are open. The chip must follow the payload, + because the payload is the only thing that decides. + """ + import json as _json + + from booth.marks import answer_pick + + base, data = live + b = _multi(data / "b") + answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) # nothing is open + (b / "index.html").write_text(f"{SEAM}", encoding="utf-8") + + def lie(route): + body = _json.loads(route.fetch().text()) + assert body["open"] == [], "fixture is not answered; the lie would be true" + body["open"] = ["batch", "batch"] + route.fulfill(status=200, content_type="application/json", + body=_json.dumps(body)) + + page = browser.new_page() + page.route("**/embed.json", lie) + page.goto(f"{base}/b/b/", wait_until="networkidle") + page.wait_for_selector(".booth-nav-asks") + assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks" + page.close()