The scaffold: two templates that compile where they lie — 2026-09-20
The second of three pull requests that let an application in Mojo be
written outside this repository. The first added the m0 wheel and the
commands that need no template
(the-m0-wheel). This one adds m0 new, the two
templates it writes, the AGENTS.md that goes with them, and the gate that
proves the path from uvx m0 new to an answer on the wire. It does not add
m0 dev, m0 image or the release workflow, and nothing is published.
SPEC N27–N29; decision D44.
What was built
m0 new NAME [--template views|live] writes a directory a person can read
in one sitting:
pyproject.toml AGENTS.md CLAUDE.md README.md smoke.sh
src/server.mojo src/views.mojo src/pages.mojo (live: board, wave)
test/test_*.mojo
deploy/Dockerfile deploy/fly.toml deploy/README.md
.github/workflows/test.yml .gitignore .dockerignore
viewsisapps/fragment_notes' shape without its login: aViewstable over a list held in the state, rendered by oneFragment[Htmx]that a form, a link and a delete button all swap.liveisapps/blobs' shape without its kernel: aProducerstepping a small wave twice a second, each step published as ONEdatastar-patch-elementsframe whoseelementsare, verbatim, the fragment the document painted first; aDatastarStream(send_latest=True)behind the four SSE hooks; a shared page carrying kicks and viewer counts between the workers and the producer, which pauses while nobody watches.
Both are sessionless (D44). A scaffold that refuses to start until a
password variable is set is a bad first minute, and with no ambient
authority there is no CSRF token to carry — so D38 stands, its scaffold
clause not met, and AGENTS.md says what changes the day a login arrives.
Templates are source, not text
The templates are real .mojo files under
packaging/m0/src/m0/templates/, and poe check-templates — inside
test-all — builds each server.mojo and runs each test file IN PLACE,
against the tree. A layer change that breaks a template therefore fails in
the pull request that made it.
That only works if a template compiles unsubstituted, which sets the rule:
the application's name appears only where a compiler does not look —
inside string literals, TOML and Markdown — as __M0_APP__. The two
version tokens appear in pyproject.toml alone. Substitution is
str.replace; there is no template engine, and a file that needed one
does not belong in a template. The layout is what makes the rule cheap to
keep: server.mojo imports views and pages as siblings under the
wheel's one include root, tests reach them with the second root m0 test
already passes, and there is no package named for the app — so every
scaffolded application has the same paths and a hyphenated name is fine.
What is written is a closed manifest in new.py, spelled a second time in
the gate. Hatch includes templates/ by walking the directory — the one
place this wheel is not git's manifest — so a stray file there could ship;
it could never be WRITTEN, and check-templates fails on a file no
manifest names. Dot-files are stored as dot-gitignore, dot-github/:
a real .gitignore inside the package would be read by the tools that
build it.
The reason phrase
The design pass found that page_or_fragment(..., status=422) answers
422 OK: text defaulted to the literal "OK". Rather than ship an
AGENTS.md rule that exists to paper over a default, text now defaults
to empty and an empty text takes reply.reason_phrase(status). An
explicit text still wins, so fragment_notes' 401 Unauthorized is
untouched. N29 is the row; the views template's bad form is the first
caller that leaves text alone.
The editor setting that was not written
The plan left one thing for this round to verify before writing: a
.vscode/settings.json giving the Mojo extension the include root. Read
from the installed extension (vscode-mojo 26.6.1): the setting is
mojo.lsp.includeDirs, its entries are passed to the language server as
-I VERBATIM — no variable is expanded — and the server is started with no
working directory of its own, so a relative entry resolves against
wherever the editor's extension host happens to be. The only entry that
works is an absolute one, and an absolute path is a fact about one
machine. So the scaffold writes no settings file; README.md gives the
two lines, .vscode/ is ignored, and m0 include prints the path. The
.m0/include symlink the plan held in reserve would not have helped: the
path to the symlink would be just as absolute.
The gate, and what breaking each rule did
smoke-scaffold runs on every pull request, both legs; its docstring lists
the phases. Three things about it are deliberate:
m0 newruns throughuvx --offlinewith no toolchain reachable —PATHis uv's directory and the system's. That is the claim "needs no toolchain and no network", asked as a question.- The build is the user's literal command,
uv run m0 build, against the tree's wheel throughUV_FIND_LINKS. The wheel is0.1.0+tree, a local version no index can serve, so the scaffold's exactm0==pin can never resolve to a published release. - Resolution is online. A scaffold's sync cannot resolve offline from the cache a locked sync leaves (measured in the design pass), so it asks the index with every wheel already cached — the dependency the job's own sync has.
poe sabotage-scaffold (pre-release) breaks each rule from the template
side, rebuilds the wheel, and requires the smoke to fail for that template
AND to say the expected thing. The rules the WIRE holds run with the
template's own m0 test switched off: those tests come first and catch
several of the same breaks, which is the template doing its job and not an
answer to "does the wire assertion fail?" One rule runs the other way
round, to show the template's test is what goes red. check-templates'
own five rules run first, against that gate.
Twenty-nine rules, all caught, at this pull request's head — after the first run of the smoke's twenty-four caught twenty-three, and the one it missed was the gate's own defect:
- The layer's reason-phrase fix, reverted, PASSED. The scaffold's own
files reach the project through
uvx --from <path>, which notices a rebuilt wheel. The framework underneath them is resolved by NAME —m0==0.1.0+treethroughUV_FIND_LINKS— and uv served the0.1.0+treeit had cached from the run before. So every template rule was caught and the one rule that editedm0-httpwas not: the gate was compiling yesterday's framework. Confirmed before fixing, by asserting first that every file the wheel carries underm0/is byte-equal in the venv — which failed namingm0/_mojo/m0_http/fragment.mojo. The gate now runsuv sync --refresh-package m0itself, keeps the byte-equal assertion, and then runs the user's literaluv run m0 build. The refresh is the gate's step and not the user's: only a gate rebuilds a wheel under one version, and a published version is immutable. A CI runner that persists uv's cache between runs would have had the same hole, silently.
Not covered — retired
This section said deploy/ was written and not gated. It is gated since
the next pull request: smoke-scaffold-image builds the scaffold's
Dockerfile as written, on x86-64 in CI, and
dev-image-and-a-release records how the
tree's wheel reaches uv sync --frozen inside docker build — the
question this section left open. Its hand probe's figures (2,953,336 bytes
of app, "python":false, Linux aarch64) were reproduced by the gate.
Timings
Measured by the gate on an M4, all-source from the installed wheel, the toolchain's wheels already in uv's cache:
| what | views |
live |
|---|---|---|
uv run m0 build, first build |
13.4 s | 13.5 s |
uv run m0 build after one string literal changed |
10.9 s | 11.3 s |
uv run m0 build, nothing changed |
3.3 s | 3.3 s |
uv run m0 test |
4.0 s | 2.4 s |
The second row is the one a developer lives on and is a changed LITERAL on
purpose; AGENTS.md quotes it as 10–13 s and names m0 test as the fast
loop. The first-build and test figures go to emit.py on every run,
recorded and never gated; the after-edit row was measured by hand, at a
load average near 5.