m0serve Realtime from a synchronous Python app

A swap that moves the address bar

2026-09-21. The first application outside apps/ on the layer — unotes, a reader over a private notes corpus, the application-layer soak — has a filter form and a list of links, all of them swaps. Its log's third finding: a filter that swaps without changing the URL cannot be reloaded, bookmarked or sent to anyone, and for a research tool that is the feature. The scaffold's AGENTS.md says never to type an hx- swap attribute by hand; that app typed one, hx-push-url="true", on every link and on the form. This is the lift. SPEC N37 is the row and D46 the decision.

What was built

push=True on the three places a swap is written — Fragment.swap, Fragment.el and Html.swap[V] — and one defaulted method on Vocabulary:

f.el("a", "get", url, attr("href", url), text(title), push=True)

_swap[V], the one function every swap goes through, writes the swap and then calls V.push_url(h) on the same open element. Htmx answers with hx-push-url="true". The trait's default REFUSES, so a conformance an application wrote before today still compiles and raises if it is asked for a push it has no spelling for.

Two refusals

Only a get is pushed, and the check is the layer's, beside the verb check, so no conformance can forget it. A pushed URL is one the browser GETs on reload, from a bookmark and on a history restore. Pushing a post's URL writes an address that answers 405, or one that answers. htmx 4's query is refused with the writes: its parameters travel in the body, so its URL alone does not name the view.

Datastar refuses a push. Datastar's free bundle was read for it and names neither pushState, replaceState nor popstate: it has no history handling of any kind. Re-read at v1.0.4 (2026-09-21) with the same result, and the reason is now visible: the two attributes that would spell one, data-replace-url and data-query-string, are Pro features. So the condition that would retire this is a pricing decision, not a release. The only spelling would be a history.pushState(...) appended to the action expression, and nothing would answer the back button — the address would change back and the page would not. The note on the one swap mode argues the same way from the other side: a parameter on swap is a promise both vocabularies keep or one of them breaks silently. Here the break would not be silent if it raises, so it raises, and the error names the way out (a plain link, for a view that needs an address). The refusal is a raise and not a no-op because the failure this piece exists to stop is exactly a filter that quietly stopped being linkable.

The other half was already there

A pushed address is only real if asking for it rebuilds the view. htmx 4 re-requests a pushed URL on back and forward with HX-Request-Type: full and no HX-Request, and page_or_fragment has answered that as a document since N22. So the push needed nothing from the server. The wire gate asserts both halves together anyway: smoke-fragment-notes reads hx-push-url off the list's link (expression tier) and the detail's link back (builder), checks nothing else in the list pushes, then requests the pushed URL as a restore and insists on the note inside a document. Each push reverted alone fails the gate at its own line.

In a browser: unotes carries the same bytes, hand-typed, against htmx 4.0.0 in Chrome — the URL moves, back restores the list AND the filter form's values.

Not built

  • hx-replace-url. No application has asked.
  • A tidy pushed URL for a form. A GET form sends every field, so the pushed URL is /notes?q=football&era=&type=. Query (N36) writes only what is set for a link the application renders; what the browser serializes from a form is the browser's.
  • Response-side pushes (HX-Push-Url). D17 stands: three applications writing the same header setter is its condition, and this is none.