A vocabulary an application defines — 2026-09-18
Fragment[V: Vocabulary] takes the frontend library as a type parameter.
DECISIONS D7 kept its two conformances, Htmx and Datastar, inside
html.mojo, because on Mojo 1.0 an application's conformance to a trait
behind a .mojoc was accepted and its witness table never emitted
(a-trait-and-a-directory-name). Mojo
1.1.0 fixed that and the pin moved the same day
(the-pin-moves-to-1-1-0). D7's retiring
condition read "opening Vocabulary to an application-defined conformance,
with a gate that an app outside this repo can emit a third library's
attributes". This is the record of that round, the last of the three the
pin unblocked (D28, D12, D7).
It was not D12 again. Vocabulary was already exported; what was closed
was everything a conformance needed. Datastar.swap read h._open_kind
and compared it with four module-private constants, and both conformances
called a module-private _check_verb. A stranger's vocabulary written
against that trait could emit attributes and nothing else: it could not
tell a form from a button, and it skipped the verb check the layer
promises. So the round's question was what the trait's contract is once
someone else implements it.
The probes
Each is an application compiled from a directory outside the repository
against the built m0_http.mojoc, at 46da0e8.
| probe | answer |
|---|---|
struct Htmx4(Vocabulary) with a @staticmethod swap, used as Fragment[Htmx4] — builder tier, Fragment.el, and Html.swap[Htmx4] |
compiles; emits its own attributes in all three |
a trait @staticmethod with a DEFAULT body, behind a .mojoc built from a renamed directory; one conformance takes the default, one overrides |
1five 2six — both work |
print(h._open_kind) from the app |
compiles, prints 1 |
from m0_http.html import _check_verb and a call |
compiles, raises the verb error |
from m0_http.html import _KIND_FORM, _kind_of |
compiles, prints 1 1 |
The static witness crosses like the instance one did. And nothing is
private: the underscore field, the module-private functions and the
comptime constants are all reachable through the .mojoc. So the design
question was never "what must be exposed for this to be possible" — it is
"what is blessed", and the answer has to be enforced by something other
than the compiler.
What is blessed (D34)
A conformance is written without an underscore. That is the contract,
and it is mechanical: poe check-app-vocabulary refuses its own app
source if an underscore-led name appears in it. Three things make the rule
sufficient.
The element kind is an accessor, not an argument. Html.open_kind()
answers an ElementKind with three questions — is_form, is_field,
is_link — "none of the three" being the fourth answer. Passing the kind
into swap was the alternative, on the argument that the signature would
then carry everything a conformance may read and Html could stay opaque.
It was rejected because Html is not opaque to a vocabulary and cannot
be: swap receives it mut and writes with attr and flag, so the
builder's public surface is already the contract, and a fifth positional
argument would be one more thing every conformance names and only one
reads. The accessor changed no signature and no call site.
The verb check is the layer's, and the verb list is the vocabulary's.
Left in each conformance, a stranger's vocabulary skips it and the promise
becomes "both built-in vocabularies". Hoisted with the five verbs
hard-coded, Fragment[Htmx4].swap("query", ...) is impossible from
outside — the layer refusing a verb the library defines, since htmx 4 has
six. So the trait grew a second static member with a default:
@staticmethod
def verbs() -> String:
return STANDARD_VERBS # "get post put patch delete"
Every swap now goes through one function, _swap[V], which refuses a verb
outside V.verbs() — naming that vocabulary's own list in the error — and
then calls V.swap. A string rather than a list because it is also the
error message, and because a conformance that overrides it writes one
line. _check_action_url stayed in Datastar: it is about a JavaScript
string literal, which is Datastar's context and nobody else's.
The built-in conformances use only the blessed surface. Datastar now
reads h.open_kind() and neither conformance checks a verb, so the
surface cannot be narrower than what a real library needed. That is the
honest proof of open_kind(); see below for what the outside gate adds.
The third library is htmx 4
Not fixi, which was the first pick: the layer's own Htmx is gated against
2.0.4 (D6), any new htmx use here is 4.x, and an application-defined
Htmx4 is how the layer meets htmx 4 without moving its own pin. Facts
were read out of the vendored 4.0.0 bundle in ~/projects/hx-flask and
that repository's HTMX4.md, whose rows are tests.
Two problems with htmx 4 as the gate, and what was done about each.
With outerHTML, Htmx4 emits what Htmx emits, byte for byte, and a
green gate would prove only that a conformance compiles. The gate's
Htmx4 spells hx-swap="outerMorph" — htmx 4's built-in morph by id,
which is what Datastar does with the same fragment, and arguably the right
htmx 4 spelling for a layer whose claim is one renderer for both — and
allows hx-query. Both are asserted by exact string, the gate fails if
outerHTML appears, and the five-verb Htmx is shown still refusing
query in the same run.
htmx picks its own event, so Htmx4.swap never asks the element kind.
A real use was looked for. The candidate is htmx 4's DELETE rule — a
delete on a non-form element inside a form sends none of the form's
values (hxlint reports it as delete-without-include) — but the
vocabulary sees the open element, not its ancestors, and the fix
(hx-include="closest form") moves fields into the query string, which is
the application's decision and not a spelling. So the gate carries a
second, plainly synthetic vocabulary, KindEcho, whose only job is to
read open_kind() from outside the package in both tiers for all four
answers. Htmx4 is not evidence for that surface and the gate's docstring
says so; Datastar is its real consumer.
The gate
scripts/app_vocabulary_check.py (poe check-app-vocabulary, inside
test-all; SPEC N21). It writes the app into a temporary directory,
refuses to continue if that directory is under the repository root,
compiles it with -I to the two .mojocs and nothing else, runs it, and
asserts: the whole rendered document by exact string; the two attributes
that differ from Htmx's; KindEcho's four answers; a typo refused BY
THE LAYER with Htmx4's six-verb list in the message, Htmx4.swap having
checked nothing; query allowed for Htmx4 and refused for Htmx; and no
warning from the app's own build.
Then it hands the document to hxlint, vendored from hx-flask with
hx_vocab, which is generated from htmx 4's source tree — a second
implementation, so the vocabulary is never checked against itself
(scripts/notes_session.py is the precedent). A linter that says nothing
has said nothing until it has failed, so the gate first breaks the
document three ways — a target naming no id (missing-target, which is an
outside check of the claim Fragment exists to make), a miscased swap
style, a misspelt verb attribute — and insists each is reported.
Sabotaged by hand, each restored from a copy:
| reverted | caught by |
|---|---|
the conformance's swap renamed |
the compile |
outerMorph → outerHTML in the app |
the exact-string assertion |
ElementKind(h._open_kind) in the app |
the underscore rule |
verbs() answering five |
the app raising on query |
| a null case that changes no id | "hxlint did not report…" |
_swap's check removed in html.mojo |
three tests in test_html.mojo, and the gate |
The vendored files are another project's (MIT, the owner's own). NOTICE
names them, licenses/LICENSE.hx-flask.txt is the licence, and
check_vendored_files in scripts/check_docs.py holds each file's sha256
with the upstream commit beside it, its three rules reverted in
--selftest. CI cannot see the upstream checkout, and dj-hx's copy of the
same two files was eighty lines stale within two days of its upstream,
which is the argument for the guard made by the owner's own repositories.
mapcore.py stayed behind: it maps template controls to Python handlers
by AST, and this layer has neither.
What did not change
poe smoke-fragment-notes and poe smoke-todo are the wire gates for the
two built-in vocabularies. Both apps were built at 46da0e8 and again
after the change, driven through every swapping call site — four in
fragment_notes (the create form, the title link and the delete form in
the list, the back link in the detail; three hx-target in the list and
one in the detail, counted in the capture) and three in datastar_todo
(toggle, the rename form, delete; the submit__prevent arm present) — and
nine captured bodies compared with cmp: identical. The session cookie
was signed by notes_session.py with a fixed expiry so the CSRF token in
the page is the same in both runs.
Not built, and flagged rather than added
HX-Request-Type. EverythingFragmentemits targets#id, so its htmx 4 requests arepartialand the four-header rule answers them correctly. A hand-writtenhx-target="body"orhx-selecton a page this layer serves isfullto htmx 4 and a fragment towants_fragment. Closing that is a fifth header, aVarychange on every answer, a D9 amendment and SPEC N8 — its own round. one-renderer-two-transports's "Not built" table already names it.- An htmx 4 application.
Htmx4here is a vocabulary in a gate. An app on it that deletes must answer whathxlintcannot see: htmx 4 has nomethodsThatUseUrlParams, a DELETE's fields always ride the query string, andfragment_notes' CSRF token would be in the URL, the access log and the Referer. A POST to a delete route, or the token inhx-headers. A clean lint is not a clean htmx 4 app. - Control → handler method agreement.
f.swap("delete", NOTE)against a table that registeredNOTEfor GET only is a 405 at click time.ViewsanswersOPTIONSwith a mergedAllow, so a gate could read every control out of a rendered page and ask the server. A follow-up, not this round's. - fixi sends
FX-Request: true, whichwants_fragmentdoes not read. Same D9 territory.