# The layer moves to htmx 4 2026-09-19. `Fragment[Htmx]`, `page_or_fragment` and `apps/fragment_notes` were gated against htmx 2.0.4, provisionally (DECISIONS D6: "moving the pin" was the retiring condition, and what would weigh was written down with it). This is the move: the built-in vocabulary is htmx 4, the notes app loads 4.0.0, and its gates — wire, browser and sabotage — were re-run against it. SPEC N22 is the new row; N8, N13 and N21 were re-worded; D6 is retired and D38 is new. Every fact below was read out of the 4.0.0 bundle (`dist/htmx.js`, from npm's `next` tag; `latest` is still 2.0.10) and then watched in Chromium. None is from memory or from the migration guide. ## What htmx 4 changed that the layer can see | | htmx 2.0.4 | htmx 4.0.0 | |---|---|---| | asks for a fragment with | `HX-Request: true`, minus the restore and boost markers | `HX-Request-Type: partial` or `full`, on every request | | a history restore sends | `HX-Request` and `HX-History-Restore-Request` | `HX-History-Restore-Request` and `HX-Request-Type: full` — and no `HX-Request` | | a DELETE's form fields | in the query string, unless `methodsThatUseUrlParams` is narrowed | in the query string, hard-coded (`/GET\|DELETE/.test(method)`) | | a GET or DELETE from inside a form | includes the enclosing form | includes a form only when the element IS the form | | a 4xx answer | not swapped | swapped (`noSwap` is 204 and 304) | | attribute inheritance | implicit | explicit (`implicitInheritance: false`) | | default swap | `outerHTML` was never the default either | `innerHTML` | | verbs | five | six: `query` | ## The decision: the new header decides, the old rule stays The brief for this round named one condition under which to stop: a page-versus-fragment signal that cannot be made right for htmx 2 and htmx 4 at once. It did not arise, and the reason is worth a sentence, because it is the whole design: **the two majors are told apart by a header only one of them sends.** `wants_fragment` now reads, in order: 1. `Datastar-Request: true` — a fragment. 2. `HX-Request-Type: partial` — a fragment. `HX-Request-Type: full` — a page. Nothing else htmx sent is consulted. 3. Otherwise the htmx 2 rule, unchanged: `HX-Request: true` with neither `HX-History-Restore-Request: true` nor `HX-Boosted: true` beside it. A value of `HX-Request-Type` that is neither word falls through to rule 3 rather than being guessed at. Rule 2 *decides* rather than advises because there is one place where the two majors send the same older headers and need different answers. A boosted element that names its own `hx-target` sends, under htmx 4, `HX-Boosted: true` beside `HX-Request-Type: partial` — and means it. Given a whole document, htmx 4's `makeFragment` appends the parsed `` ELEMENT to the fragment it swaps, which is not what a section's `outerHTML` swap wants. Under htmx 2 the same `HX-Boosted: true` is answered a page, which htmx 2 unwraps. Reading the boost marker first would be wrong for htmx 4; ignoring it would be wrong for htmx 2; keying on the header's presence is right for both. The v4 history restore is the other case the old rule got right by accident. The restore's `request` option replaces htmx's header object whole, so the request carries no `HX-Request` at all and the old rule answered a page because *nothing asked for a fragment*. It now answers a page because the request said `full`. `Vary` gains `HX-Request-Type` as a fifth name, last, so the four an older cache already keyed on keep their order. This is the one change visible on the wire to an application that did nothing: every `page_or_fragment` answer's `Vary` is one name longer. ## The vocabulary: three attributes, unchanged, and a sixth verb `Htmx.swap` writes `hx-VERB`, `hx-target` and `hx-swap="outerHTML"` on the element, exactly as before — byte for byte, which `test_html.mojo` and the notes gate pin. That was the bet D6 recorded ("the generator is the only thing that knows the spelling") and it paid in full: all three attributes sit on the element itself, so explicit inheritance takes nothing away, and the new `innerHTML` default is never consulted because the swap style is always spelled. `Htmx.verbs()` now answers htmx 4's six, so `f.el("button", "query", url, ...)` renders `hx-query`. `Datastar` stays on the default five. `outerMorph` was considered for the built-in spelling and left alone. It is htmx 4's morph-by-id and arguably what a fragment that names itself wants, but it changes what every existing app's swap does to focus and form state, and nothing in the tree asked. `poe check-app-vocabulary` keeps building an `outerMorph` vocabulary from outside the repository, so the door D34 opened stays gated; that vocabulary now differs from the built-in one in the swap style alone. ## The CSRF token on a DELETE is a header Under 2.0.4 the notes app narrowed `methodsThatUseUrlParams` to `get` in a ``, so its delete form's hidden `csrf` field rode the body. htmx 4 has no such setting. A DELETE's fields go in the URL — where a token is an access-log entry, a `Referer` and a history entry — and nothing on the page can change that. So the delete form holds **no field**, and carries `hx-headers='{"X-CSRF-Token":"…"}'` instead, on the form itself because inheritance is explicit. The server accepts the token from the `X-CSRF-Token` header or from the body's `csrf` field, and from nowhere else; a header that is present decides, so a wrong header is not rescued by a right field beside it. A POST still carries the field — its fields go in the body under both majors, and a plain `
` (the sign-out) has no other way. The meta is gone, and the smoke asserts its absence: left behind, it would read as a protection and be none. A custom header is also the stronger carrier. A cross-origin page cannot set one without a CORS preflight, which this server never answers. **`hx-headers` is the one `hx-` attribute the app writes by hand** (`_csrf_header`), and that is DECISIONS D38. The layer spells swaps. A request header is a different thing in each library — an attribute beside the swap in htmx, an option *inside* the action expression in Datastar (`@delete('/x', {headers: {...}})`), which `Vocabulary.swap` has no argument for — and one application is not evidence of what the shared shape should be. The retiring condition is the usual one: a second app hand-rolling it. ## What only the browser could say `poe browser-notes-login` (pre-release; Chromium through Playwright) was rewritten around what fails silently under htmx 4, and each arm was made to fail before it was believed: - the create form's POST says `HX-Request-Type: partial` — the header the layer now decides by has to actually be sent; - the DELETE carries `X-CSRF-Token`, has no `csrf` in its query, and says `partial`. Reverting `hx-headers` on the form: 403, caught. Putting the field back in the form: `DELETE /notes/1?csrf=…`, caught; - a session that ends mid-interaction: the cookie is dropped, a note is added, and the 401's login fragment appears inside `#notes` with the address bar unmoved. Under the 2.0.4 bundle nothing appears, caught — along with both request-type arms, since 2.0.4 sends no such header. One finding for applications, recorded here because the notes app shows it: **htmx 4 swaps every 4xx.** The delete arm used to wait for the note to leave the list; with the token reverted the note "left" anyway, because the 403's `application/problem+json` body had replaced the whole fragment. The check now reads the DELETE's status. An application moving to htmx 4 should expect its error answers to be swapped in, and answer errors a person might see as fragments — as the notes app's 401 already did, which is why that one became correct without an edit. ## The gates - `test_fragment.mojo`: four new tests — the header is taken at its word in both directions and either case, the v4 restore shape with no `HX-Request`, the boosted `partial` where the two rules differ, and an unknown value falling through. Every `Vary` assertion names five. - `smoke-fragment-notes` (every PR): the same four shapes on the wire; the shell loads 4.0.0 by exact URL and carries no `htmx-config`; the delete form carries this session's token as `hx-headers` and the list holds exactly two `csrf` fields (create and sign-out); a DELETE with the token in its query, with none, with another session's as a header, and with a wrong header beside a right field are each 403 and leave the note. - `poe sabotage-notes-login` (pre-release): fourteen rules, ten of them new or re-pointed for this move, including both `HX-Request-Type` branches and the fifth `Vary` name, which rebuild `m0-http` on the way in and on the way out. A sabotage that does not build used to be skipped; it is now a failure, since a rule nobody reverted is a rule nobody showed to be guarded. ## Not done - **`hx-boost` and history in an app of ours.** The notes app uses neither, so the v4 restore and boost shapes are gated as request shapes (curl and unit tests, from headers measured in Chromium on 2026-09-15) rather than by clicking Back in a browser. The first app that pushes URLs should extend the browser check. - **SRI on the CDN tag.** Not there under 2.0.4 either; the scaffold's template is where it belongs (Phase 6, R3). - **`fx-*`.** D6 deferred it beside htmx 4 and its retirement does not revive it: `Vocabulary` is open (D34), so a house library is an application's own conformance, not a third built-in.