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 <form> 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-<id>-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.
352 lines
17 KiB
JavaScript
352 lines
17 KiB
JavaScript
/* The Booth - the declared embed seam (U3).
|
||
*
|
||
* A booth that ships its own index.html is served verbatim. This script is how
|
||
* the Booth's chrome gets onto that page WITHOUT the Booth reaching into it:
|
||
* the report carries one line,
|
||
*
|
||
* <script src="/_booth/embed.js" defer></script>
|
||
*
|
||
* and everything below mounts through real DOM APIs. It replaced ten regular
|
||
* expressions applied to author HTML - six hunting for a place to hang a
|
||
* favicon and a chip, four substituting rendered markup into the author's own
|
||
* tags. A page that declares this line is now served exactly as written.
|
||
*
|
||
* WHAT THIS SCRIPT DOES NOT DECIDE: what a mark says, whether it is still open,
|
||
* or what order marks come in. Every fragment below is rendered server-side by
|
||
* the same Jinja macros the gallery page uses, and `open` is computed by
|
||
* `open_marks`. Two renderers of one truth is the bug INV-1 exists to stop -
|
||
* the zoom view once re-derived an item and lost its captions doing it.
|
||
*
|
||
* Served from a read taken ONCE at app startup. Editing this file does nothing
|
||
* until `systemctl --user restart booth.service`, exactly like the templates,
|
||
* and for the same reason: on 2026-09-21 a hot-reloading template put 19 of 25
|
||
* booths at 500 against Python that had never heard of the context it wanted.
|
||
*/
|
||
(function () {
|
||
"use strict";
|
||
if (window.__boothEmbed) return; // declared AND appended: mount once
|
||
window.__boothEmbed = true;
|
||
|
||
var CSS = [
|
||
/* ---- the way home, and the open-asks jump ---- */
|
||
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
|
||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;",
|
||
"text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}",
|
||
/* top-right: a top-left chip clips the page title on left-aligned report
|
||
layouts, and this matches the zoom view's back affordance. */
|
||
".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||
"color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);",
|
||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}",
|
||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}",
|
||
".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||
"color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}",
|
||
".booth-nav-asks:hover{filter:brightness(1.08)}",
|
||
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
|
||
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
|
||
nothing here may inherit from it, so the palette adapts via
|
||
prefers-color-scheme rather than borrowing. ---- */
|
||
".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);",
|
||
"border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);",
|
||
"font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
|
||
".bk-ask.bk-done{border-top-color:#3fae6a}",
|
||
".bk-ask.bk-skip{border-top-color:#6f7c8c}",
|
||
".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}",
|
||
".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||
"letter-spacing:.12em;text-transform:uppercase;color:#c9a227}",
|
||
".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}",
|
||
".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}",
|
||
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
|
||
".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}",
|
||
".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;",
|
||
"border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}",
|
||
".bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}",
|
||
".bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}",
|
||
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}",
|
||
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
|
||
".bk-ask-det{font-size:.8rem;opacity:.68}",
|
||
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;",
|
||
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
|
||
"border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}",
|
||
".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||
"letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;",
|
||
"background:#2fa8a0;color:#08131a}",
|
||
".bk-ask-go:hover{filter:brightness(1.09)}",
|
||
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}",
|
||
".bk-ask-was b{opacity:1}",
|
||
".bk-ask-err{color:#d6452a;font-size:.86rem}",
|
||
"@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}",
|
||
"@media print{.bk-ask{break-inside:avoid}}"
|
||
].join("");
|
||
|
||
/* The anchor attributes. `data-booth-mark` is canonical - U2 made an ask one
|
||
shape of mark - and `data-booth-ask` is kept because two of the four live
|
||
verbatim booths spell it that way, in the operator's own reports. */
|
||
var MAIN_SEL = "[data-booth-mark],[data-booth-ask]";
|
||
var SUBMIT_SEL = "[data-booth-mark-submit],[data-booth-ask-submit]";
|
||
var WHOLE = " whole"; // the set member meaning "the whole ask landed here"
|
||
|
||
function attr(el, a, b) {
|
||
var v = el.getAttribute(a);
|
||
return v === null ? el.getAttribute(b) : v;
|
||
}
|
||
|
||
/* "batch:r1" -> ["batch", "r1"]; "batch" -> ["batch", null]. Split on the
|
||
FIRST colon: a question key cannot contain one (asks._KEY_RE) and neither
|
||
can a pick id (asks.valid_stem), so this is unambiguous for everything the
|
||
payload carries. A flag's id IS `flag:<target>`, which is why the payload
|
||
carries picks only. */
|
||
function splitSpec(spec) {
|
||
var s = (spec || "").trim();
|
||
var i = s.indexOf(":");
|
||
if (i < 0) return [s, null];
|
||
return [s.slice(0, i).trim(), s.slice(i + 1).trim() || null];
|
||
}
|
||
|
||
function boothName() {
|
||
var tag = document.querySelector("script[data-booth]");
|
||
if (tag) return tag.getAttribute("data-booth");
|
||
var parts = location.pathname.split("/"); // ["", "b", "<name>", ...]
|
||
if (parts.length < 3 || parts[1] !== "b" || !parts[2]) return null;
|
||
try { return decodeURIComponent(parts[2]); } catch (e) { return parts[2]; }
|
||
}
|
||
|
||
function mount(el, html) {
|
||
/* beforeend, NOT replaceWith: the author's element and its contents survive
|
||
and the fragment lands inside it. `<div class="ask" data-booth-ask="...">
|
||
<h3>heading</h3>` is live markup today, and the regex it replaced ate
|
||
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() {
|
||
if (document.getElementById("booth-embed-css")) return;
|
||
var st = document.createElement("style");
|
||
st.id = "booth-embed-css";
|
||
st.textContent = CSS;
|
||
(document.head || document.documentElement).appendChild(st);
|
||
}
|
||
|
||
function favicon(href) {
|
||
/* The question `_ICON_RE` and its three head-seam siblings were asking of
|
||
raw text. Same question, asked of a parsed document. */
|
||
if (!href || document.querySelector('link[rel~="icon"]')) return;
|
||
var link = document.createElement("link");
|
||
link.rel = "icon";
|
||
link.href = href;
|
||
(document.head || document.documentElement).appendChild(link);
|
||
}
|
||
|
||
function homeChip(href) {
|
||
var a = document.createElement("a");
|
||
a.className = "booth-nav-home";
|
||
a.href = href || "/";
|
||
a.setAttribute("aria-label", "back to all booths");
|
||
a.textContent = "‹ all booths";
|
||
document.body.appendChild(a);
|
||
}
|
||
|
||
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 `<section id="bk-ask-winner-background">` 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-<id>-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. */
|
||
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";
|
||
a.textContent = "? " + openIds.length + " open ask" + (openIds.length === 1 ? "" : "s");
|
||
document.body.appendChild(a);
|
||
}
|
||
|
||
function reassociate() {
|
||
/* 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 <form> 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 <form>.
|
||
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 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++) {
|
||
var v = bound[i].getAttribute("form");
|
||
bound[i].removeAttribute("form");
|
||
bound[i].setAttribute("form", v);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
/* 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 = 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] = 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++) {
|
||
var el = anchors[a];
|
||
var spec = splitSpec(attr(el, "data-booth-mark", "data-booth-ask"));
|
||
var mark = by[spec[0]];
|
||
if (!mark) continue; // a typo'd id is LEFT ALONE, not blanked
|
||
if (spec[1] === null) {
|
||
record(mark.id, mount(el, mark.whole));
|
||
note(mark.id, WHOLE);
|
||
if (hasForm(mark.id)) submitted[mark.id] = true;
|
||
continue;
|
||
}
|
||
var q = null;
|
||
for (var k = 0; k < mark.questions.length; k++) {
|
||
if (mark.questions[k].key === spec[1]) { q = mark.questions[k]; break; }
|
||
}
|
||
if (!q) continue; // names no question: also left alone
|
||
record(mark.id, mount(el, q.html));
|
||
note(mark.id, spec[1]);
|
||
}
|
||
|
||
// 2. explicit submit anchors.
|
||
var subs = document.querySelectorAll(SUBMIT_SEL);
|
||
for (var s = 0; s < subs.length; s++) {
|
||
var sel = subs[s];
|
||
var sid = splitSpec(attr(sel, "data-booth-mark-submit", "data-booth-ask-submit"))[0];
|
||
var sm = by[sid];
|
||
if (!sm) continue;
|
||
/* 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);
|
||
/* ...and only count it submitted if the <form> SURVIVED. An author who
|
||
puts this anchor inside their own <form> 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
|
||
// 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];
|
||
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++) {
|
||
var qq = mk.questions[q2];
|
||
if (!got[qq.key]) holder.insertAdjacentHTML("beforeend", qq.html);
|
||
}
|
||
}
|
||
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();
|
||
// 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);
|
||
var mounted = place(data.marks || []);
|
||
reassociate();
|
||
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
|
||
the chrome, never the page. */ });
|
||
}
|
||
|
||
/* The declared line carries `defer`, but an author may not copy it exactly. */
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", start);
|
||
} else {
|
||
start();
|
||
}
|
||
})();
|