Grant-verified holds — shipped 2026-09-11
A design note from the engineering record: what a Python application needs to put its streams on a Mojo pool thread without a Mojo build of its own, and the format that carries its decision there.
Where this comes from. An SSE hold from a Mojo mount
proved the mechanism: a MojoPool thread takes an M0-Hold the way a
WSGI pool thread does, and the loop drains it. It also found that the
application this was for, textshelf, authorizes every stream before naming
its channel — a session and a membership query — and that a Mojo view has
neither. The recommendation was to move the holding, not the deciding: a
built-in mount that holds against a grant the application signed, so the
application keeps its views and its ORM and hands the browser a URL. This
is that mount, and the grant.
The grant
v1.<kid>.<exp>.<channel>.<sb>.<sig>
kid the first 8 hex characters of SHA-256(key)
exp unix seconds
channel [A-Za-z0-9_:-]{1,64}
sb base64url(SHA-256(session cookie value)[:16]) without padding, or -
sig base64url(HMAC-SHA256(key, "v1.<kid>.<exp>.<channel>.<sb>")) without padding
m0serve.grant issues it (issue, stream_url) and
m0_http.grant.verify_grant reads it; the wheel's copy and
apps/django_realtime/grant.py are byte-identical, and check-docs
insists, because each runs where the other is not installed. The key is
the bytes of M0_GRANT_KEY, read by the issuer and by the mount; they
share a process tree, so it is shared by construction. M0_GRANT_KEY_PREV
is accepted while set, and kid says which key signed — a fingerprint,
not a configured index, so neither side has to agree on an order.
Verification refuses in this order: malformed, unknown key, bad signature,
expired, no session cookie, session mismatch. The signature is checked
before the expiry, so an expired grant with a wrong tag is invalid and
not expired; the mount answers 401 with expired (fetch a fresh URL) or
invalid (stop) and the reason. The reason names nothing an attacker
could not learn by trying.
What the design is for
Performance. No Python on the connect path, and nothing allocated but
the small strings a verdict returns. A pool thread prepares its
HmacSha256 states when its handler is built, so a verification costs the
grant's own bytes plus one block, and one SHA-256 of the cookie; every
field is read as bytes and never through a codepoint-checked slice, since
the grant is request-derived (SPEC G14). The hold that follows is the one
the previous note measured: a head answered
in a twentieth of a second behind two busy Python threads.
Session binding is what makes a URL safe to hand a browser. An
EventSource cannot set a header, so the grant rides the URL, and URLs
leak — history, Referer, logs. Bound to the session cookie's value, a
copied URL is useless without the cookie, which the browser sends with the
stream request on its own. The issuer binds by default and takes
session=None only for a channel that is public anyway. m0serve's access
log records the path and never the query, so the grant does not land in
the log; that is now load-bearing.
Revocation is bounded by the TTL, one hour by default. A member
removed after a grant was issued keeps reconnecting until it expires, and
a stream already held is not closed — nothing on the server closes a hold
from the application side yet, which is D23's retiring condition. A
client that gets expired asks the application for a fresh URL through
the same view that issued the first, which is the same authorization
again.
Rotation is the key list: set M0_GRANT_KEY_PREV to the old key, the
new one in M0_GRANT_KEY, restart, and grants signed under either verify
until the previous is unset.
Fail closed. A hold mount refuses to start without --realtime (what
wires a pool thread's hold to the loop) or without M0_GRANT_KEY, exit 2,
and --doctor reports the same two checks. The issuer raises without a
key rather than signing with an empty one, and refuses a channel the
format cannot carry before the server has to.
Rejected
- Verifying Django's own signer.
django.core.signingwould spare the application a helper, at the cost of reimplementing its salted key derivation and base62 timestamps in Mojo and coupling the binary to Django'sSECRET_KEYhandling. A dedicated key and a five-field format is less to get wrong, and works for Flask. - The session cookie alone. The mount has no session store; a cookie proves nothing to it without the binding a grant carries.
- Opaque server-side grants. A store the loop consults, or bus frames announcing issuance per worker, is state with consistency problems the signed form does not have.
- The grant in a header.
EventSourcecannot send one.
The gate
smoke-hold-mount, every pull request: the two startup refusals and the
doctor agreeing; a bound grant admitting its own session and a publish
from Django reaching the stream with its id; each refusal by name; a grant
under the previous key admitted while it is listed; an unbound grant with
no cookie; the issuer refusing a bad channel; the slot released. The
verifier's unit form is test_grant.mojo, eight cases against grants the
issuer signed with a fixed key and clock, so what Django signs and what
the mount admits are pinned to each other across the language boundary.
The primitive underneath is m0-core's SHA-256 and HMAC (SPEC G15),
gated by the FIPS and RFC 4231 vectors.
What is not claimed
No timing measurement of the verifier; it is a few SHA-256 blocks, and
the number that matters is the hold's, already recorded. The mount is not
proven with an ASGI application beside it — --realtime still needs a
WSGI mount, as the previous note records. And the application layer's
soak is still NOT MET: this is the server holding for textshelf, not
textshelf written on Views and Fragment; that application is still to
be chosen.