◆ Flux

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.

The application loop 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 app block — an update, a view, a subs — outside its block. A member is only legal inside one, so read those samples as if the member sat within app name { … }; the type and def declarations 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.

FLUX
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:

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

FLUX
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].

FLUX
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.

FLUX
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:

The slotmap pattern Figure — removal writes a tombstone; nothing ever moves, so every handle stays valid and the memory plan stays flat.

FLUX
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:

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 gen on creation makes a stale handle read na instead. And on load, the persisted nextId is 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:

FLUX
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 chain A ⤳ B(A) ⤳ C and 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 fixed do … 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:

FLUX
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.

FLUX
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.

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 grant

Three 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

The message journal 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:

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:

  1. 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.
  2. 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 total migrate(old) -> Model the 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):

FLUX
// 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:

  1. 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.
  2. The APP plane reads analysis read-only, through Sub OnSeries.
  3. CANVAS reaches the APP plane through events; the APP plane reaches CANVAS through commands — it orchestrates presentation, it does not rewrite it.
  4. 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