The APP plane — applications
The first three planes compute, present, and interpolate. None of them holds state that
persists between events and decides what is displayed — a score, a document, a selection,
a blotter of open positions. That single missing primitive is what the APP plane adds: a
reducible model, under a recipe that keeps every guarantee the other planes rely on. This
page is the normative specification of that plane: the app block and its five members, the
bounded Model, the command and subscription catalogues, and the capability sandbox. The view
primitives and the scene value are specified in display; the full
capability catalogue in host services; the server-side re-derivation
of a run in server.
An application is a model, a pure update, a pure view, and a set of declarative subscriptions. Effects are inert data the host executes; capabilities are default-deny; the message journal is the single source of truth, so an application can be replayed message by message, tested without a single mock, and re-executed by a server bit-for-bit.
The APP plane is fully designed and strictly additive to the frozen core, and its rollout follows the v1 language. Everything on this page is normative for what an application is.
New here? Start with Guide §10 — Building an application → — it teaches the loop from the intuition, one runnable program at a time; this page is the exhaustive reference for the same machinery.
Figure — everything ambient enters through the front door as a message; everything outgoing leaves as inert data the host interprets under a capability.
A note on the samples. Several samples below carry a member of an
appblock — anupdate, aview, asubs— outside its block. A member is only legal inside one, so read those samples as if the member sat withinapp name { … }; the type anddefdeclarations beside it are ordinary top-level statements. Every other sample on this page is a complete program.
Undo, redo and time travel come free
In most codebases, undo/redo is a feature you build again in every application: a stack of inverse operations, maintained by hand, and subtly wrong at the edges — the undo that forgets one field, the redo that brings back a selection you had already moved on from. In Flux you do not build it at all. It is a property of the architecture, because an application’s state never changes except through one deterministic, journaled reducer.
The state is a fold: the model is fold(init, [the journal of every message so far]). Undo
rewinds the journal one step and folds again; redo re-extends it. Because the fold is a pure
function of the journal, the state you land on is exactly the state you left — reproduced, not
reconstructed.
variant Msg { Bump | Undo | Redo }
app tally {
capabilities: [ journal ]
init(p) = { doc: { n: 0 }, ui: { pending: na } }
update(m, msg) = match msg {
Bump -> { model: m with { doc: m.doc with { n: m.doc.n + 1 } }, cmds: [] }
Undo -> { model: m, cmds: [ Journal(UndoToMark) ] }
Redo -> { model: m, cmds: [ Journal(RedoToMark) ] }
}
view(m) = row {
button("undo", Undo)
text("{m.doc.n}")
button("+1", Bump)
button("redo", Redo)
}
subs(m) = []
}The two arms that give this application its entire undo history are Journal(UndoToMark) and
Journal(RedoToMark). They return the model unchanged and hand the host an inert command; the
host — which is where the journal lives — truncates it to the target and re-folds (init, [msg]')
into the new model. Nothing in the application code knows how to reverse an edit. It only knows how
to move forward.
One mechanism, two faces. For the user, this is undo/redo inside the app — an undo that
cannot silently forget a field or resurrect a stale selection, in a level editor, a trading tool,
a game. For the developer, the same journal is a time-travel debugger: the bar-axis cursor of
the analysis debugger becomes an event cursor. Scrub to any past message, step backwards, set a
data breakpoint — stop at the first message where score crosses 100 — and put the run beside a
reference run to see where, if ever, they diverge.
It falls out the same way for very different applications:
- A level editor — every stroke is a message, so undo brings the drawing back exactly: the same result, because the same fold.
- A game — rewind to any move and replay from there; the whole match is the list of moves, and any prefix of it is a valid state.
- A blotter — scrub the position history like a video, because the history is the journal.
Why undo is correct, and not almost-correct. Here is the subtlety hand-rolled undo nearly
always gets wrong. An application’s model is split in two: a doc — the business state the
history owns — and a ui — the selection, the cursor, the half-drawn shape, the in-flight
request — which the history does not own. Undo rewinds doc and leaves ui untouched. So it
never revives a selection you made three edits ago, or a request that has since returned. The
boundary is part of the design; you cannot forget to draw it, because the two live in different
fields of the model.
Why redo is exact, and not approximate. Flux is deterministic to the byte — the interpreter
and the compiled module agree on every bit, and na has one canonical pattern — so re-folding the
journal reproduces the state byte-for-byte. That includes the answers that came back from the
network: an async result entered the model as a message, so it is already in the journal,
replayed verbatim rather than re-fetched. Rewinding never fires the request a second time; it reads
the reply that already happened.
One honest note, and one precise boundary. Undo is free of bespoke work, not free of cost: an undo rebuilds the model by re-folding from the nearest memoized checkpoint, so it costs work proportional to the distance back to that checkpoint, and the machinery — a message journal, a fold, an event timeline — is real and modest. It is reused by every application rather than rebuilt in each, which is the whole point. The formal contract, the cost bounds and the two named exceptions are in Totality, determinism, replay below. And note the exact claim: this is undo/redo over an application’s execution — the events it processed — never over its source code.
The shape of an application
app counter {
capabilities: [ clock, sfx ] // `clock` backs OnTick; `sfx` backs PlaySfx — both are needed
init(p) = { n: 0 }
update(m, msg) = match msg {
Tick -> { model: m with { n: m.n + 1 }, cmds: [] }
Reset -> { model: m with { n: 0 }, cmds: [ PlaySfx("reset") ] }
}
view(m) = row {
text("count: {m.n}")
button("reset", Reset)
}
subs(m) = [ OnTick(1000, Tick) ]
}Both entries in that capability list are load-bearing, and the second one is easy to forget. clock
is what backs OnTick; sfx is what backs PlaySfx. Drop sfx and the program stops compiling —
not at the moment the sound would have played, but at the Reset arm, because emitting a command
the manifest does not grant is [ErrCapDenied]. The list is not documentation of intent; it is the
grant the compiler checks every cmds: against.
The app block compiles to a descriptor — a sibling of the representation and tool
descriptors, with no base class:
| Member | Kind | Role |
|---|---|---|
capabilities: |
a list of capability references | what the application requests. Default-deny: anything not listed is not merely unavailable, it is a compile error to emit. |
init(params) |
→ Model |
the pure initial state |
update(model, msg) |
→ record{ model: Model, cmds: vec(Cmd, N) } |
the pure, total, deterministic reducer |
view(model) |
→ UiTree |
a pure tree of vetted primitives — never raw markup |
subs(model) |
→ [Sub] |
declarative inputs, recomputed from each model |
contributes? |
— | optional interface contributions (panes, panels, commands, tools) |
The five member names are fixed keywords, not free identifiers: the roles of the harness are part of the language, so the compiler can check them.
The Model — bounded state
Every field of a Model must have a bounded kind. Scalars qualify; so do string (immutable
UTF-8 with a declared cap) and decimal(scale) (a fixed-width value type); so do records and
vec(κ, N) with a const-folded N. An unbounded field — or a ⊤ — is [ErrState], the
Model’s exact analogue of [ErrPlot].
variant Verdict { Right | Wrong }
record Model { n: num ; label: string ; history: vec(Verdict, 64) } // bounded — ok
// a growing list with no declared cap is not a Model field: ✗ [ErrState]The cap is a const-folded length — a literal, as above, or an identifier that const-folds to
one (vec(Level, MAX_LEVELS)). Either way the compiler knows the number before the first
message arrives.
Why bound the Model. Because the memory footprint of an application then becomes computable at compile time — the plane inherits the same totality argument as the analysis plane, and a running application cannot leak, thrash, or be killed mid-frame for exceeding a budget it never declared. Boundedness is not a restriction imposed on the author; it is what lets the compiler promise the budget in the first place.
The doc / ui partition
Any application with an undo needs this, and needs it from the first line: split the Model into
a doc sub-record (the business state, versioned by history) and a ui sub-record
(selection, cursor, an in-flight request’s epoch, a drag draft) that does not enter history.
Without the boundary, undoing a change would resurrect a stale selection or a dead draft. With it, undo is exactly “truncate the journal to the previous bound and re-fold”.
Functional record update
m with { … } rewrites the listed fields and carries the rest forward. It is shape-preserving:
a field that does not exist is [ErrField], and a field you forget is kept, not
silently lost.
update(m, msg) = match msg {
Score(pts) -> { model: m with { doc: m.doc with { score: m.doc.score + pts } }, cmds: [] }
Select(h) -> { model: m with { ui: m.ui with { sel: h } }, cmds: [] }
}The Model’s kind is a fixpoint
A field initialized to na in init does not stay at ⊥: its kind is the join, field by
field, over init and every arm of update. picked: na in init, then picked: key
(a string) in one arm, gives Model.picked : string, with na remaining a legal runtime
value (absence). Declaring record Model { … } up front pins the same kinds explicitly — the
two routes coincide.
The slotmap — a bounded collection with stable removal
Editors keep drawings; a trading blotter keeps positions; a tool keeps anchors. All three need
a collection you can add to, remove from, and address stably — and the Model may not grow,
may not re-index, and has no filter.
The official pattern is a slotmap over a bounded vector:
Figure — removal writes a tombstone; nothing ever moves, so every handle stays valid and the memory plan stays flat.
record Level { id: num ; price: price ; kind: Tool ; label: string ; gen: num }
record Model {
slots: vec(Level, MAX_LEVELS) // a tombstone (`na`) marks a free slot
live: vec(signal, MAX_LEVELS) // 1 where a slot is occupied
count: num
nextId: num // the monotone domain-id counter (persisted)
}
def emptyDoc() = { slots: emptySlots(MAX_LEVELS), live: vec.fill(MAX_LEVELS, 0), count: 0, nextId: 1 }
// removal — functional, length-preserving, nothing shifts
def remove(m, h) =
m with { slots: vec.setAt(m.slots, h.slot, na),
live: vec.setAt(m.live, h.slot, 0),
count: m.count - 1 }
// iteration — `na`-aware: a tombstone produces no child at all
view(m) = col { for lvl in vec.mask(m.slots, m.live) -> levelRow(lvl) }Four rules make it work:
- Removal writes a tombstone, never compacts.
vec.setAtis total and length-preserving; out-of-bounds leaves the vector unchanged. There is no indexed assignment in Flux — purity is not negotiable. - Iteration is
na-aware.vec.mask(slots, live)keeps the length and blanks the dead slots; the comprehension skips them. (vec.where(slots, is_some)is the same idea with a predicate instead of a parallel signal.) - Identity is dual. The domain id — a monotone counter in the Model, or a UUID v7 for shared storage — is the script-facing identity: it is what messages, undo and serialization carry. The slot/generation handle is an internal execution index, rebuilt on load. A serialized slot index would be dead on arrival; a domain id survives.
- Overflow is an application state. Filling
Nis not a crash: the add arm returns a message, and the application decides what to say.
Why a generation counter. Re-using a slot after a removal would let a stale handle read a new item — the classic ABA bug. Bumping
genon creation makes a stale handle readnainstead. And on load, the persistednextIdis clamped upward (max(persisted, max(id)+1)), never rebased downward — otherwise deleting the highest ids and reloading would re-mint an id that history already used.
update — pure, total, deterministic
update may not read the clock, may not read randomness, may not touch the DOM, may not read
a live series inline. Everything ambient arrives as a message. It must handle every
message — match exhaustiveness is [ErrTotalMatch], checked, not hoped for.
It returns a named record, never an anonymous pair: { model: …, cmds: [ … ] }. There is
no tuple sort in the lattice, and adding one for this would have been a real cost for no
benefit.
cmds is an ordered list of inert descriptors. A command is data: a sound’s name, a key,
a score. It never carries a socket, a token, a URL, or a DOM handle — the host owns the
resource, the script holds only a request.
Asynchronous results come back as messages
A command with a result carries the constructor that its result should be wrapped in. The host applies that constructor to the outcome and delivers a message:
variant Msg { Save | Saved(epoch: num, ok: signal) | Cancel }
update(m, msg) = match msg {
Save -> { model: m with { ui: m.ui with { epoch: m.ui.epoch + 1 } },
cmds: [ SaveLevel(m.doc, m.ui.epoch + 1, Saved) ] } // ← carries `Saved`
Saved(e, ok) -> if e == m.ui.epoch // a stale result is ignored
then { model: m with { ui: m.ui with { saving: 0 } }, cmds: [] }
else { model: m, cmds: [] }
}Why not a task abstraction. A composable
Task/effect type would chainA ⤳ B(A) ⤳ Cand hide the intermediate results — they would never reach the journal. Re-folding, time-travel, and server-side replay would all lose the ability to reconstruct the model bit-for-bit. So every asynchronous result stays a message. The verbosity of a long chain is the acknowledged price of exact replay, and an optional desugaring of a fixeddo … then …sequence into the same message machine keeps the trace identical either way.
The epoch token above is the general answer to a stale result: the command carries an
app-supplied scalar; the host echoes it back verbatim; the arm compares it with the current
epoch and drops what no longer matters. It lives in ui, never in doc — it must not travel
into a shared journal.
view — a pure tree of vetted primitives
view returns a UiTree: containers (col, row, grid, stack, tabs, scroll,
panel, and the application rails), content (text, label, badge, chip, icon,
progress, sparkline, image), controls (button, toggle, slider, select,
radioGroup, textInput, metaForm), and windows onto the other planes — chartView,
paneView, sceneView.
The three windows are the only way a UiTree reaches another plane, and each takes a different
kind of target: chartView mounts a chart engine and accepts a CANVAS scene as its overlay:;
paneView projects a series; sceneView(target, tree, space) paints a free scene into a named
render target, in Data, Screen or World3D coordinates. (sparkline appears in the content
row above: it renders a series inline, and is not a window onto a target.) See
display.
There is no raw markup, no HTML string, no event handler. A click is a message:
view(m) = panel(slot: right.panel) {
row { text("levels: {m.doc.count}") ; button("add", AddLevel) }
chartView(chartId: "main",
onClick: ClickAt, // a constructor reference, not a closure
overlay: overlayOf(m.doc, m.ui.draft, m.ui.sel))
when m.ui.saving: progress("saving…")
}Two details in that snippet are load-bearing.
Callback slots take a constructor reference, never a function. onClick: ClickAt names the
constructor the host will apply to the real (bar, price) at the moment of the click. The
compiler checks the constructor’s payload kinds against the slot’s declared argument kinds.
No function value ever enters the lattice — there is no arrow sort, and this keeps it that way.
The chart is not re-rendered by the view diff. chartView mounts the real chart engine;
the reconciler only ever touches the chrome around it. The scene passed as overlay: is a
CANVAS value (scene{…}) — the sanctioned channel from the presentation plane into a pane.
The host diffs the small tree, sanitizes it (text goes in as text; an unknown node is rejected), and paints. A view can therefore never inject markup, and a hostile application cannot draw a fake system dialog: the primitive set is closed.
subs — the declarative front door
Subscriptions are recomputed from each model, and the catalogue is closed. Everything ambient — time, input, randomness, data, the network, the wallet, other users — enters here, as a message.
| Subscription | Delivers | Backed by |
|---|---|---|
OnTick(everyMs, C) |
a periodic tick | clock |
OnFrame(C) |
one message per animation frame | clock |
OnSeries(key, C) |
analysis values, read-only | chart:read |
OnChartClick(C) / OnHover(C) |
(bar, price) — hover is throttled to bar boundaries |
chart:read |
OnDrawingChange(C) |
drawings changed | chart:read |
OnRand(seed, C) |
seeded randomness | rand:seeded |
OnFeed(C) |
a schema-typed network payload | net:fetch / net:stream |
OnRoute(path, C) |
a deep link, parsed by a fixed host grammar | ui:navigate |
OnConnectivity(C) |
connectivity edges | net:offline |
OnTransfer(reqKey, C) |
transfer progress for a request | the request’s own net:* grant |
OnReveal(C), OnRevealProgress(C) |
a host-computed outcome, and the progress of a reveal — the outcome is the open anti-cheat vector | chart:read |
OnKey(C), OnPointer(C), OnWheel(C), OnGamepad(i, C) |
input edges — never a held sample | input:* |
OnFocus(C) |
focus gained or lost — journaled, so a key event is only delivered when the journal attests focus | — (mediated by slot ownership) |
OnVisible(itemKey, threshold, C) |
a visibility transition per key — the edge behind lazy loading and read receipts | — |
OnPeerMsg(C) |
a remote journal entry, in a collaborative session | net:rtc |
OnLocale(C) |
the active locale changed | i18n:catalogue |
OnSession(C) |
the session lifecycle — login, refresh, logout | auth:session / auth:passkey |
OnEntitlement(C) |
a server-verified entitlement or subscription | pay:checkout |
OnGeo(minInterval, C) |
a watched position, at a declared minimum interval | geo:read |
OnWallet(C), OnTx(C) |
wallet and transaction lifecycle events | wallet:* / chain:* |
OnPresence(C), OnContactUpdate(C), OnInvite(C) |
social events | social:* / present:* |
OnSharedChange(scope, C) |
a change in a hosted shared collection | storage:shared |
OnWebhook(path, C) |
an inbound HTTP call, decoded against the declared schema | the server plane |
OnJob(spec, C) |
a scheduled run — the server twin of schedule:wake |
the server plane |
OnQueue(name, C) |
a work item | the server plane |
NoSub |
“nothing this frame” — the nullary constructor that makes conditional subscription type | — |
The last four are the server plane’s subscriptions: they are delivered to a headless
application, and they are what a subs looks like when there is no viewport at all. They belong
in this catalogue rather than in a second one, because a headless application is not a different
kind of program — it is the same init/update/view/subs with a different set of inputs
reaching it. Canonical rows elsewhere. The row shapes of OnWebhook, OnJob and OnQueue
are owned by server; the entries here
summarize them for continuity. The grants that back them live in
host services.
Each subscription with a payload carries the constructor the host will apply to it, exactly
as commands do — OnTick(100, Tick), OnSeries("rsi", Got), OnChartClick(ClickAt). Without
that, an event would not know which arm of update it belongs to.
Subscriptions may be rate-shaped without leaving the model: OnSeries(k, Got).throttle(100)
delivers at most ten messages a second, and the delivered edge is still journaled, so replay
stays exact.
subs(m) = [ if m.ui.live then OnSeries("close", Got).throttle(100) else NoSub,
OnChartClick(ClickAt) ]Capabilities — the security model
The script never holds a capability object. It emits a request — emit Cap(args) — and the
host, the only holder, interprets it through a handler only the host has. A request for a
capability the manifest does not grant is rejected at compile time ([ErrCapDenied]), not
at runtime. There is no ambient authority: no global object is in scope at all.
A representative slice of the catalogue follows (every entry is default-deny, host-attenuated, and graded by trust). Canonical catalogue elsewhere. The complete, normative list of capabilities — every verb, its grant, and its host attenuation — is owned by host services; the rows here illustrate the shape and never extend it.
| Capability | Grants | Host attenuation |
|---|---|---|
storage:own |
Persist / LoadPersist |
a partitioned namespace per application, with a quota |
journal |
Journal(UndoToMark | RedoToMark | JumpToMark) |
host-mediated truncate + re-fold + re-install |
chart:read |
OnSeries, OnChartClick, OnHover, and the bounded pixel queries |
read-only, public series, causal bars |
chart:ctl |
SetChart, RevealForward |
delegated to the chart engine, rate-limited |
net:fetch(domain) |
Fetch, Sub OnFeed |
user consent per domain; the host holds the socket; the payload is decoded against the schema the app declared and kind-checked at the boundary |
net:stream(domain) |
a persistent bidirectional connection | consent per destination; the host holds the file descriptor; typed frames |
clock |
OnTick, OnFrame, After(ms, msg) |
bounded timers; the deferred message re-enters the journal |
rand:seeded |
OnRand(seed) |
a server-derived seed through a pinned integer generator |
wallet:* / chain:* |
a signing intent | the wallet signs, the user confirms in the wallet; a mandatory decoded simulation precedes any signature; per-argument scoping |
ui:contribute:<kind> |
interface contributions | gated at mount |
storage:shared |
hosted collections with tiered ACLs | tiers compile to row-level security — never a free-form rule language |
Some things are inexpressible for every tier, trusted or not: eval and code generation,
raw DOM, a raw socket, a raw database client, a token or a cookie, and any global store. They
are not “forbidden by policy” — they have no name in the language.
Two trust tiers — security is the grant, not the code
The first-party interface and a stranger’s application run the same language in the same sandbox. They differ only in what has been granted to them.
- Tier A — the first-party interface (including the level editor). Its Flux source stays private and is never shipped; it ships as WASM. It is trusted by perimeter, and its privileged grants sit behind a triple barrier: an authenticated, admin-gated grant; a host mediator that alone holds the token; and row-level security in the database.
- Tier B — user and marketplace applications. Untrusted from the point of view of whoever
loads them. Strict default-deny, sanitized view, isolated realm. The buyer receives the
WASM alone, never the source — so consent cannot rest on reading the code. It rests on
the sealed manifest, which travels with the binary, is derived by the compiler from the
emit Capsites (their only origin), and is inspectable before install.
Trust is decided by the host from a server-side provenance record keyed by the binary’s content hash. A module cannot declare itself trusted, and no embedded metadata is believed.
Neither tier ever relaxes a language invariant. A trusted tier grants effects; it does not loosen causality, no-repaint, totality or the firewall. Repaint is inexpressible for everyone.
Transitive manifests, zero escalation
An application built on packages aggregates their requests:
manifest(A) = ( ⋃ emit Cap over the transitive closure of A ) ⊓ the user's grantThree consequences, all normative. A dependency’s net:fetch surfaces in the buyer’s
manifest before install — no hidden capability. No dependency can exceed what the user granted
to the application — authority flows only along import edges, capped by the grant. And no
dependency holds a capability object, so it can neither re-delegate nor amplify one.
Revocation is an event, not a check
A grant can be dropped mid-session — by a user gesture, or by a supervisor. The host writes a
CapRevoked bound into the journal at that instant, exactly as it writes a pause bound, so
that a re-fold reproduces the revocation deterministically. (Without the bound, a re-fold would
rebuild the membrane at the un-revoked manifest and a post-revocation command would succeed —
a silent divergence, which the plane does not permit.)
Commands then fail closed: one that carries a completion constructor is answered with
[ErrCapRevoked] through that same constructor; a fire-and-forget command is dropped and
audited. An effect already in flight is cancelled, and the epoch token absorbs any result that
was already stale.
Totality, determinism, replay
| Property | Mechanism |
|---|---|
| Totality | update/view/subs are total; match is exhaustive; no free loops; the Model is bounded |
| Determinism | update is pure; everything ambient arrives as a message; randomness is OnRand(seed) through a pinned integer generator shared bit-for-bit by interpreter, WASM and server |
| Exact replay | re-folding the message trace reconstructs the Model bit-for-bit — which proves a journal coherent, not unforgeable (see below) |
| Bounded cost | cost per message is bounded; the view diff touches a small tree; heavy effects are commands, outside update |
Figure — the journal is the single source of truth: undo truncates to a bound and re-folds; a checkpoint keeps that cheap.
The journal is not a flat list of messages. Two refinements are forced by real editors:
- Undo bounds. A message may or may not set a bound. Undo truncates to the previous bound, never to the previous message — otherwise a drag would come apart into a hundred micro-steps. Coalescing a gesture means placing the bound at the pointer release.
- Checkpoints. Re-folding from
initon every undo is linear in history, so models are memoized at intervals and the re-fold starts from the nearest one.
What replay proves — and what it does not
Because a verdict is a pure function of (init, [msg]), a server can recompute it on the same
bytes, and a claimed score that was not earned is a journal that does not fold to the claimed
result. That property was not designed; it was inherited from determinism. It is also the point at
which this page owes the reader a precise limit rather than a slogan.
Replay proves the COHERENCE of a journal, not its NON-FALSIFIABILITY.
“Diverges ⇒ tampered” is complete for a score derived from the seed and from the elapsed
time, because the server owns both: the seed is derived server-side from (runId, level, qIndex)
and never accepted from the client, and the elapsed time of a ranked run is host-stamped and
substituted at re-fold, so a forged Tick count buys nothing. It is not complete for a third
class, and the gap is open by name.
A score fed by a host-pushed outcome re-folds without divergence. OnReveal delivers an
outcome the host computed — a kernel result, revealed as a message. That outcome enters the
journal as data, and a re-fold replays data verbatim: the seed re-derives the messages that
came from OnRand, and this one did not. So a forged outcome re-folds to the claimed result
without a whisper of divergence. The check passes; the claim is still a lie. The OnReveal row in
the subscription catalogue above is exactly this vector, and it is worth knowing which row it is.
The same class covers a pixel. A bounded pixel reading is derived from the client’s viewport — its pan and its zoom — and a server with no viewport cannot re-derive it. Hence the standing rule, which is a language-level discipline and not a server one: a pixel value never feeds a ranked verdict. It is a readout, or it is cosmetic.
An outcome-fed run therefore has exactly two honest destinations, and no third: the host re-derives the outcome server-side (re-running the kernel itself, which the server plane is what makes possible), or the run is local-score-only, excluded from the shared leaderboard. It is never accepted on the strength of the client’s journal alone. This section states what the journal alone can and cannot prove; the server-side re-derivation that closes the argument is owned by server, its canonical home.
The two named exceptions
update is the only producer of a Model in normal operation. Exactly two host-mediated
exceptions exist, and both are deterministic:
- Time travel.
Journal(UndoToMark)makes the host truncate the journal, re-fold(init, [msg]')and re-install the result. The journal remains the source of truth — it is rewound and re-derived, never fabricated. - Migration and hot reload. On a build-hash change, the host either re-folds the retained
journal with the new
update(state re-derived from the same inputs), or — when the journal is long — decodes the persisted snapshot and passes it through a totalmigrate(old) -> Modelthe application declares.
A host-initiated (re)launch — a notification tap, a scheduled wake, a deep link — is not a third exception: its payload is delivered as the first journaled messages of the new session, ordered before any other subscription delivery.
Schema evolution
While the Msg variant only grows (new constructors, never retyped or removed), an old
journal stays re-foldable under a new update — resuming state is free. A breaking change
crosses the monomorphic seam through an explicit total upcast (migrateMsg), or surfaces as
SchemaMismatch. Never a silent decode of old bytes into a new shape. Snapshot and journal
migrate as one unit: a v2 snapshot under a v1 journal would be a split brain. Checkpoints from
a previous build are invalidated, not reinterpreted.
Testing an application
Because update and view are pure, total and deterministic, and the whole fold is inside the
byte-identity oracle, an application test is a golden over pure functions — at four grains,
with no mocks anywhere.
The trace grain carries the weight: the replay harness is the test harness. A trace test
folds a literal message list — fold(init(p), [ Tick, Tick, Reset ]) — and goldens the Model
it lands on. Nothing drives a live subscription, and nothing needs to: the journal already reifies
every event as a message, so the message list is the mock — total, typed, and the very thing a
server re-executes. A trace golden of a ranked score pins the host-authoritative elapsed time and
seed as inputs.
The other three grains are ordinary assertions over the same pure functions (m0, m1 and
expectedTree stand for the application’s own model values and view, as in the note at the top):
// step — a deep, na-aware equality on two lattice values
assert update(m0, Tick) == { model: m0 with { n: 1 }, cmds: [] }
// view — a snapshot of the canonical view buffer
assert view(m1) == expectedTree // chartView nodes are goldened as nodes, never as pixels
// property — an invariant asserted at every intermediate model, over a seeded message list
assert m.count == popcount(m.live)The cmds field is inert data, so asserting which effects were emitted needs no fake clock
and no fake socket: the descriptor is the assertion. And a non-deterministic subscription is
never mocked — it is goldened on its description (subs(m)), while its delivery is supplied
as a literal list of messages. Where an invariant is already proven by the kind, no test is
owed at all.
Extending the interface
An application declares what it adds — panes, panels, controls, menus, tools,
statusItems, commands — and never fabricates it. Each contribution names a slot (a
region), a when: predicate, and a render producing a UiTree. Contributing requires
ui:contribute:<kind>.
The layout manager owns where; the application owns what. It arbitrates order, docking,
tabs and splits, it persists the arrangement, it reconciles and sanitizes the tree, and it
contains a failing contribution to its own pane. Each slot exposes a port —
rect(), onResize, onVisible, requestFocus(), dispose() — whose notifications arrive
as messages, and whose lifecycle owns the pane’s WASM instance: opening instantiates the
module, closing releases its linear memory, drops its subscriptions and revokes its
contributions.
An application never owns its display state either. It emits a request
(RequestForeground, RequestDetach, …) and the layout manager decides; the verdict returns
later as a message. The authority order is total and unforgeable: user > supervisor >
application. Forcing your own window to the front is not rate-limited — it is inexpressible.
The three official patterns
| Pattern | Shape | Where the bulk lives |
|---|---|---|
| Slotmap | vec(Item, N) + tombstones + live + count + domain ids |
in the Model, bounded |
| Document | a doc sub-record with declared caps; a Tree(Node, N) node pool; history with bounds and checkpoints. Beyond one editable window, the document persists in chunks and the Model holds the active window |
host storage; the Model holds the editing window |
| Feed | a bounded window: vec(Item, W) + cursor + total; nearing the edge emits LoadPage(cursor, C); rendering is a virtualList driven by OnVisible |
the host cache or shared storage |
A feed is therefore never an unbounded collection in the Model: it is a deterministic window onto a host-held stock. A large document is the same idea applied to editable content.
Composition and the firewall
APP (mutable state + effects) ← the most permissive plane
│ reads ANALYSIS (Sub OnSeries) ✔ read-only
│ orchestrates CANVAS / TRANSITION (Cmd) ✔
▼
CANVAS / TRANSITION (cosmetic, per frame) ← reads ANALYSIS ✔
▼
ANALYSIS (pure, causal, no-repaint) ← reads nothing above it ✘Four hard rules, checked statically:
- Analysis never sees the APP plane. A signal stays provably repaint-free even next to a game — a game that switches asset does not repaint an indicator; it recomputes it, through a capability.
- The APP plane reads analysis read-only, through
Sub OnSeries. - CANVAS reaches the APP plane through events; the APP plane reaches CANVAS through commands — it orchestrates presentation, it does not rewrite it.
- The APP plane never writes analysis.
Reading a live series from update obeys the same floor-containing rule as any resample: the
most recent closed bar, never a nearest match. Scoring a quiz cannot repaint the indicator
beside it.
See also
- The four planes & the firewall — where APP sits, and what the firewall admits.
- Kinds —
variant,record,ui, and the bounded kinds a Model may hold. - display — the view primitives, the scene value, panes and windows.
- net —
net:fetch/net:stream, typed payloads, offline queues. - server — headless applications, shared storage, and server-side replay.
- Guarantees — what replay, determinism and the sandbox actually promise.