# A Datastar form, end to end — shipped 2026-09-12 > A design note from the engineering record. The fourth phase of the > application layer's plan: the piece the plan named "the form arm is > where a wrong spelling fails silently", and what a browser said about it. **Where this comes from.** `Fragment[Datastar]` had spelled the form arm since the two-transports round — `swap` on a `
` emits `data-on:submit__prevent="@post('url', {contentType: 'form'})"` — and one unit test pinned that string. No browser had sent it. The todo demo used signals for everything (`data-bind:draft`, a `keydown` action posting the store), so the one attribute whose correctness only a browser can judge was the one attribute no browser had judged. SPEC N12 named the gap and decision D21 rested on it. **The piece.** The todo demo renames a todo in place. Each open todo's text is now the one field of a ``, and `swap("post", url_for(EDIT, id))` on that form spells the submit action; done todos stay `text`. The new route `POST /edit/:id` reads `form(req)`: `None` for any body that is not `application/x-www-form-urlencoded`, answered as a 400 problem rather than read as a field named after itself (D13's point, now on the wire); an empty `text` is a no-op like an empty draft; anything else updates the row and broadcasts, so the renamed todo morphs into every tab through the same `patch-elements` frame every other mutation uses. The fragment stays one line, and `render_todos` stays the only renderer. **The smoke** (`smoke-todo`, every pull request) posts the form the way a browser would, urlencoded with `text=walk+the+dog`, and greps the frame for the renamed value; greps the served page for the exact wire spelling, `data-on:submit__prevent="@post('/edit/2', {contentType: 'form'})"`, escaped as the builder escapes it; and posts a JSON body to the same route and insists on the 400 and on the value never reaching a frame. **The browser run** (`poe browser-datastar-form`, pre-release) starts the demo, opens two Chromium tabs, adds a todo from the draft field and renames it from the keyboard in one tab, records every request the bundle makes, and waits for the other tab to morph. What the pinned bundle (v1.0.3) sent, verbatim: ``` the field's action (D21): POST /add content-type 'application/json' body '{"draft":"buy milk"}' the form's submit (N12): POST /edit/1 content-type 'application/x-www-form-urlencoded' body 'text=buy+oat+milk' ``` Both requests carried `Datastar-Request: true`. The second tab showed the renamed text without a reload. **D21 confirmed.** The decision said a Datastar field's own action sends the signal store, never the field, and that a form arm sends the fields. The run shows exactly that split: the draft input's `keydown` action posted the store as JSON with no form encoding, and the form's submit posted its fields urlencoded with no signals. The ledger row now names this note and its retiring condition is a bundle whose field actions send the field alone, which is what the run would show first. **Pre-release, not CI.** The run needs Chromium, which the CI runners do not carry, and what it guards moves only when the bundle pin moves (D20). The smoke stays the every-pull-request gate for the server's half. The run costs ten seconds and prints the two bodies as its record, so a moved pin gets its evidence in the release that ships it; `docs/RELEASING.md` lists it beside the other pre-release steps. **Not claimed.** Whether a half-typed rename survives a broadcast from another tab: Datastar morphs `#todos` by id and this run typed and submitted before any other tab wrote, so the interaction of a morph with an input mid-edit was not exercised. Multipart stays D16. And one route, one field: the form arm is proven for the shape `form(req)` decodes, not for `enctype="multipart/form-data"`, which the bundle sends as multipart and `form(req)` refuses.