fix(asks): one submit saves every ask on the page

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.
This commit is contained in:
vh
2026-09-27 17:05:11 -07:00
parent 34ac1683ee
commit 50bfc7b4ec
12 changed files with 1496 additions and 35 deletions
+68 -2
View File
@@ -215,11 +215,77 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
by the pick or form it holds. A flag that adds a tray row above a draft
must not move the draft into the wrong box. The form just sent is the
exception: its fields come back as the server rendered them, and its
disclosure comes back folded.
disclosure comes back folded — UNLESS it changed after the press, when it
carries like any unsent form (amended 2026-09-27; a pick changed
mid-flight came back as the saved copy of the earlier one). "Just sent"
is the form's IDENTITY plus its fields as they stood at the press, never
the DOM node: a queued save whose node an earlier swap replaced is still
recognised, where a node test missed it and carried a saved note's text
back as a draft.
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
before the next begins, so an older snapshot never lands after a newer one
(three quick flags show three flags). A form already queued or in flight
ignores another submit: a double-click writes one note, not two.
ignores another submit: a double-click writes one note, not two. "In flight"
is keyed by the form's IDENTITY (the same action + hidden-field key the
carry uses), never marked on the DOM node, because a queued save's swap
replaces the node with a fresh copy (amended 2026-09-27).
3a. **Several picks at once (amended 2026-09-27).** A pick form is a
`data-inplace` form carrying a hidden `ask` field. It is **dirty** when any
control in it differs from its server-rendered default (`checked` vs
`defaultChecked`, `value` vs `defaultValue`). A submit on a pick form while
ANOTHER pick form of the same action is dirty sends every dirty pick form
NOT already in flight — the pressed one only if it is dirty.
- A form in flight still counts as "another dirty form", so a press on a
clean pick during a batch is an empty batch: a no-op, never the blank
one-form POST whose 400 would take step 4 mid-save.
- One POST per form, to its own action, with its own fields read at the
moment of the press, one after another in DOCUMENT ORDER of the forms. A
refused POST does not stop the ones after it.
- None refused: ONE GET and ONE swap, in which every form sent counts as
"the form just sent": its fields come back as the server rendered them,
its disclosure folded.
- Any refused: ONE GET and ONE swap in which only the forms the server
TOOK count as sent, so everything else carries by identity — the refused
pick's own input and any draft on the page included — then the status
line says how many saved and names each pick that did not, with the
reason. (Nothing saved at all: no GET, just the words.) A pick withdrawn
under the page has no form in the fresh page to carry into; the reason
says so. This is the same rule as the verbatim half: a refusal never
clears what the operator entered.
- **A batch never reloads.** Where step 2 would reload — a failed GET, or a
fresh page whose structure changed — a batch says so in the status line
("reload to see it") and keeps the page, because a reload would take
every unsent draft with it. Step 4's say-and-reload stays the one-form
path's alone.
- The status line is cleared when the next save starts, so a "not saved"
never outlives the save that fixes it.
- With no other dirty pick form, the submit takes steps 1–3 exactly as
before.
Why: C3 already CARRIED an unsent pick across another save, so it survived
— but it was never SAVED, and pressing the submit of a BLANK pick got a
400, whose failure reload wiped every one. The operator's report
(2026-09-27, relayed by infra-ops): one submit on a page must save every
answer he filled in. The verbatim half of the same fix is U3's "Submitting
several asks at once"; the two surfaces share the dirty rule, the order and
the refusal rule, and differ only where their machinery does (this one
swaps in place; the verbatim page reloads when nothing is left unsaved).
*Falsifiable* (`tests/mutations/r2_submit_all.toml`): ignore the other pick
forms and `test_one_submit_on_the_marks_page_saves_every_changed_pick`
fails; send the pressed form even when blank and
`test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it` fails;
stop at the first refusal, reload on one, or count a refused pick as sent,
and `test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing`
fails; stop counting a form in flight as dirty and
`test_pressing_a_clean_pick_during_a_batch_sends_nothing` fails; key "in
flight" on the DOM node and
`test_a_pick_in_flight_stays_in_flight_across_another_saves_swap` fails;
leave the status line up and `test_a_later_save_clears_a_stale_not_saved_line`
fails; reload when the refresh fails and
`test_a_batch_whose_refresh_fails_keeps_the_page` fails; count a sent form
as sent after it changed and
`test_a_change_to_a_sent_pick_during_the_flight_is_kept` fails; count no
form as sent and `test_a_saved_notes_box_comes_back_empty` fails.
4. **The script never re-POSTs.** A retry after a lost response would re-apply
the judgment: a duplicate note, or a re-dated answer.
- On a non-204 HTTP response, or a network failure, it writes a fixed