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.
103 lines
6.5 KiB
Markdown
103 lines
6.5 KiB
Markdown
# 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 rewires `formKey` identity on the Booth pages (one form
|
||
holding many `ask` fields). 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-status` line 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) and `tests/mutations/r2_submit_all.toml` (11 rows), all proved; the
|
||
r2_flow row anchored on `form.__busy` moved to `pending[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.
|