Operator report (via infra-ops): on a page with several asks, a submit
saved only the pressed one and the reload wiped the rest. Confirmed on
auk-audition: one POST at 15:02:23 saved the last ask on the page, then a
400 from the submit of an ask the reload had just blanked.
Client-side on both surfaces; /answer is unchanged. A submit on a pick
form, while another pick form on the page holds unsent input, sends every
changed ("dirty") pick form: one POST each, to its own action, with
Accept: application/json, in document order. A refusal stops nothing, and
untouched forms are never re-sent. With no other dirty form, a submit is
exactly what it was.
- embed.js (verbatim reports): reloads only when nothing was refused and
nothing of ours is dirty. Otherwise a server-rendered status line in the
submit block says what did not save, and input stays. A form the server
took gets a new baseline. A press during the flight is ignored.
- base.html (marks page, lightbox, review rail): one refresh in place. A
batch never reloads. Only forms the server took count as sent. In-flight
state and "just sent" are keyed by form identity (formKey) plus the fields
at the press, not the DOM node.
Two heid bug-hunt rounds: a four-arm panel on the first cut, then Hulda
alone on the fold. Ten findings reproduced red in a browser before their
fixes. Contracts: U3 "Submitting several asks at once" + INV-8, R2 C3
steps 2, 3 and 3a. Mutation tables u3_submit_all (15) and r2_submit_all
(11), all proved. Suite 928 -> 951.
6.5 KiB
2026-09-27 — One submit saves every ask on the page
The report. Prime to infra-ops, relayed to booth-dev (althing thread
01M3JED397G1SZH7580PCXNVVA): "submitting a question should go through and
submit ALL answers. As it is, I go through, submit a question and it only
submits the last one and clears out the top ones."
Confirmed against live data before any code, and it matched to the second.
auk-audition: three single-question picks, no anchors, so all three in the
embed tail in (created, id) order. The access log: one POST at 15:02:23 (303)
that saved auk-emotion, the LAST pick on the page; the reload; a POST at
15:02:27 that 400'd (the submit of a pick the reload had just blanked — the
browser showed a raw {"detail": ...} page); then auk-clone at 15:04:45 and
auk-events at 15:05:02, answered one at a time. infra-ops' reading of the
code was right. vastblue-site-visit-2026-09-29 carries 14 picks and would
have hit it next.
The cause is not the server. One pick = one <form> = one POST to
/answer, and every POST did what /answer promises. The page offered one
button per form and no way to send them together. Two surfaces, two machineries:
- Verbatim report (embed.js). Plain form POST + 303 reload. The reload wiped every unsent pick.
- Booth pages (base.html's in-place script: marks page, lightbox verdict aside, review rail). R2 C3 already CARRIED unsent picks across a swap, so they survived — but were never SAVED. And pressing a blank pick's submit was a 400, whose failure path reloads and wipes them all.
The shape (booth-dev's call; infra-ops said "the shape is your call").
Client-side, both surfaces, no server change: a submit on a pick form, while
ANOTHER pick form on the page is dirty (a control differs from its
server-rendered default), sends every dirty pick form — the pressed one only if
dirty — one POST each to the unchanged /answer with Accept: application/json (the 204), serially in document order, a refusal stopping
none of the rest. With no other dirty form, nothing changes: the browser's own
POST (verbatim) or C3's one-form path (Booth pages).
- Rejected: a server batch endpoint + one page-level form. It would also
fix no-JS, but needs ask-scoped field names, a single
<form>element placed by embed.js, and rewiresformKeyidentity on the Booth pages (one form holding manyaskfields). Large blast radius for a no-JS verbatim path that does not exist (U3's named cost). Atomic all-or-nothing was also the WRONG semantics: one stale pick would refuse the rest — the 2026-09-09 partial-answer ruling's reasoning, one level up. - Untouched picks are not re-sent, the pressed one included: re-sending re-dates an answer nobody gave.
- A refusal never clears what was entered, on either surface (the first
cut had the Booth batch take C3's say-and-reload path; heid's panel caught
it, see below). Verbatim: no reload while anything of ours is dirty; the
pressed form's server-rendered, empty, hidden
.bk-ask-statusline says what did not save. Booth pages: refresh in place with only the forms the server took counted as sent, so the refused pick and any draft carry by identity; the status region names what did not save. C3's ONE-form failure path (step 4, say and reload) is untouched — not this change's surface, and it still loses drafts on a refusal (named, not fixed).
The heid bug-hunt panel (4/4 arms, thread 01M3JFDM1AWT2TCKS6E9NJM9G4) was
the gate that paid. All four landed on the same blind spot: the batch reads
every form AT THE PRESS but the page stays live through the flight, and every
guard protecting that window (sending, __busy, held) SURVIVED mutation,
because no test pressed or edited inside a flight. Six of its rows reproduced
RED in a browser before any fix: a successful embed batch reloading away a pick
made mid-flight (S1); the Booth batch's refusal reload (S2); saved forms staying
dirty so a retry re-dated them (S3); in-flight marked on DOM nodes a queued
swap replaced (S5 — now keyed by formKey identity via flightKey); a press
in flight falling through to a native POST (embed) or a blank 400 (Booth) (S6).
The trick that made them testable: hold every POST's reply 700ms in the CLIENT
(_HOLD_POSTS, the _HOLD_FIRST_REFRESH pattern) so the window is wide enough
to act in on purpose. S7 (no ask dedupe) rejected as unreachable; S8–S11
accepted with reasons in the fold reply (01M3JHYKA0GSJG4F836J1Z6TJS).
Lesson: a feature that opens an async window needs a test that acts inside
it; a green suite of single presses says nothing about the window.
Round two: a single cold Hulda arm on the FOLDED tree found six more
(thread 01M3JHYKCPXFA34XMRCEW4P3DJ), all in how the fold's own rules
interacted — and Heid's primed second voice, reading only the fold's delta,
cleared two of them wrongly and retracted. Four reproduced RED: a sent pick
changed mid-flight came back as the saved copy (#1); a note queued behind a
flag carried back as a draft because "just sent" was a DOM-node test (#3);
a batch whose page GET failed reloaded drafts away (#4); the embed reloaded
over a refusal the operator had set back to its first value (#6). Fix:
"just sent" = flightKey identity + the form's serialization at the press
(sentSet), a batch never reloads, and a refusal blocks the embed reload on
its own. #2 was a message fix (a withdrawn pick's input has nowhere to go);
#5 accepted (needs a refused POST AND a failed GET; costs a re-date of
identical content). A cold read of the whole diff beat a primed read of the
delta — worth remembering before scoping a re-review to "just the fold".
Contracts amended in the same commit: U3 "Submitting several asks at once"
- INV-8; R2 C3 steps 3 and 3a. Mutation tables:
tests/mutations/u3_submit_all.toml(15 rows) andtests/mutations/r2_submit_all.toml(11 rows), all proved; the r2_flow row anchored onform.__busymoved topending[flightKey(form)]. Tests: 23 new browser tests plus two tightened (the author-form test now wears our id prefix; the one-form failure test now asserts the reload it names). Suite 928 → 951. The[a3]case reproduced the live log exactly (only a3 saved).
Not done, named: no no-JS batch on the Booth pages (each plain form still saves one pick with scripts off — the no-JS path is unchanged, as asked); a verbatim batch that keeps the page leaves the saved picks' tags stale until a reload, and the message says so; C3's one-form refusal still reloads drafts away; Hulda #5 (above) accepted.