The four planes & the firewall
A Flux program spans four planes — ANALYSIS, CANVAS, TRANSITION, APP — each
with its own clock and its own guarantees, and a one-way firewall between them. This page
holds the whole model: what each plane is, what may cross the boundary, the exact values the
firewall forbids in analysis, the live() escape hatch, and the rule that decides which plane
a line lands on. The precise per-plane semantics live in the Spec, linked from each section;
the concept and [ErrFirewall] are owned here.
New here? Start with Guide §8 — The four planes →
Figure — the four planes and the one-way firewall between them.
The property the split buys is worth stating exactly, because it is structural rather than disciplinary: a value can be animated, random, and frame-dependent, and still be provably incapable of changing a computed number. Not by convention — by construction. You never declare a plane; it is inferred from what you write.
A note on the samples. Flux has no expression-statements, so a bare expression is not a program. The lines marked
✗on this page are therefore expression fragments: they exist to show what the kind rules refuse, not what the parser accepts. Every unmarked line is a legal statement.
ANALYSIS — the plane that computes
| Clock | the bar — it advances only on closed data |
| Guarantees | total, causal, deterministic, sandboxed, no-repaint |
| What lives here | indicators, signals, representation transforms, the numbers a decision rests on |
Analysis is the most constrained plane, and therefore the most trustworthy. Everything in it is a pure function of the past: delays reach backwards only, resampling reads only closed units, feedback must cross a unit delay. What follows from those rules is not a promise but a theorem — a value, once produced for a bar, can never change.
plot rsi(close, 14)
plot ema(close, 20) @ tf("1h") // a coarser clock — still causal
mark close cross_up ema(close, 50)Analysis reads nothing from the planes above it. There is no symbol for the mouse, for the wall clock, for the current frame, or for whether 3-D mode is on — not as bad practice, but because those names are not in the analysis namespace. Causality, clocks and the no-repaint theorem are specified in Time & state.
CANVAS — the plane that shows
| Clock | the frame |
| Allowed | screen space, wall-clock time, randomness — explicitly outside the guarantees |
| What lives here | scenes, animated drawings, decoration, effects |
The canvas plane may read analysis. It may not write it.
circle {
at: (bar.i, spring(close)), // reads analysis; the easing is cosmetic
glow: 16 + 8 * throb(0.4), // per-frame, time-only — the compositor owns it
trail: 24
}
on close cross_up highest(close, 250)[1] -> burst(40) ring { at: (bar.i, close), life: 2s }Every property is a signal — a constant, a data value and an animation are the same kind of
thing here, so there is no animation API to learn. The on … -> line is the event side of the
same plane: an analysis signal firing a bounded, short-lived effect at the bar that produced it.
Because the compiler knows which signals are time-driven, it routes them to the host compositor: scene graphics and text both render on
WebGPU — text as SDF glyphs — so the parts that move the most run with zero JavaScript per frame.
Signals, spaces, primitives and the performance model are specified in
The Canvas plane; where presentation determinism ends is
catalogued in display.
TRANSITION — the plane that interpolates
| Clock | the frame |
| Rule | it interpolates the rendering between two already-computed states |
| Consequence | it is cosmetic by definition — it cannot change a value, so it cannot repaint |
on switch(asset) -> morph chart over 500ms { ease: inOutCubic ; stagger: 0.3 }
on click -> focus(view, at: (bar.i, close), zoom: 2.0, over: 600ms)A transition’s settle value — where it lands — is analysis data and lives in the oracle. Its
trajectory — how it gets there — is cosmetic and does not. That is why
prefers-reduced-motion can jump straight to the end state and change nothing that any verdict
depends on. morph, focus, replay and the transition descriptor are specified in
The Transition plane.
APP — the plane that remembers
| Clock | events |
| Shape | a bounded Model, a pure update, a pure view, declarative subs |
| Effects | inert command data the host executes, under default-deny capabilities |
The APP plane is sealed in design and additive to the core, and its rollout follows the v1 language.
The other three planes cannot hold state that persists between events and decides what is
displayed. The APP plane adds exactly that, and pays for it with a strict recipe: everything
ambient — time, input, randomness, the network, analysis values — arrives as a message, and
the message journal is the single source of truth. That is what makes an application replayable,
testable without a mock, and re-executable by a server bit-for-bit. The full contract — Model,
Msg, commands as inert data, subscriptions, capabilities — is specified in
The App plane.
The firewall
One rule holds the whole design together:
Dependency arrows never point toward a weaker guarantee.
Presentation may read analysis. Analysis may never read presentation. The APP plane may read analysis (read-only, through a typed subscription) and may orchestrate presentation (through commands) — but it may never write analysis either.
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 ✘What the firewall forbids is precise. These are the values that may never flow into analysis:
| Forbidden in analysis | Why |
|---|---|
screen.*, hover, the pointer |
screen space is not data; it varies per device |
now(), the wall clock |
it is not replayable, and it would make a past value depend on when you looked |
rand, noise |
per-frame presentation generators, non-replayable ⇒ two engines would disagree |
live(e) |
it reads the forming bar — the one thing that can still change |
All four raise [ErrFirewall], at compile time, with an explanation rather than a scolding.
The reason this is a plane split rather than a lint: a discipline you have to remember is a discipline you will forget. A firewall enforced by the kind system cannot be forgotten — the name is not in scope, and the compiler will not let the value cross. That is what makes it safe to run an untrusted author’s animated, random, interactive scene right next to the number a decision rests on.
live() — the one exception, and why it is safe
A reading within the forming bar is genuinely useful and genuinely non-causal, so Flux gives it a name, a plane, and a wall:
plot live(ema(close, 20)) // ✓ display — the forming bar included, per frame
alert live(ema(close, 20)) > 100 // ✗ [ErrFirewall] — a decision may not read a forming value
rsi(live(close), 14) > 70 // ✗ [ErrFirewall] — analysis may not consume one eitherlive(e) re-evaluates the analysis sub-graph of e including the bar in formation, per frame.
Its result may flow only into display sinks (plot, mark, fill, color bars, a scene).
Any confirmed sink — an alert, an assertion, a value a calculation consumes — is [ErrFirewall].
Placement is load-bearing, and the two positions are not variations on a theme. live wraps
the expression whose sub-graph is to be re-evaluated: live(ema(close, 20)) asks for the average
including the forming bar, and lands in a display sink. Pushed inward, onto a kernel’s argument
— ema(live(close), 20) — it stops being a display request and becomes a forming value handed to
a calculation, which is the breach itself. The firewall does not care that a plot is waiting at
the far end: the analysis kernel already consumed the forming value.
Three consequences follow:
- The confirmed series of
estays byte-identical.live()adds a provisional view; it does not modify what was computed. live()is excluded from the byte-identity oracle, exactly as the wall clock is — so the guarantee that the two engines agree is untouched.- A script that uses it is flagged non-replayable in the guarantees panel. You see the trade-off you made.
That is the general shape of every escape hatch in Flux: name it, bound it, wall it, and show the
user what it cost. live() in depth is specified in
Time & state.
Which plane am I on?
You never declare one. The plane is inferred from what you write — that is what “write the maths, the machinery follows” means:
| You write | The plane |
|---|---|
plot, mark, fill, alert, assert, an indicator expression |
ANALYSIS |
a primitive with props, on … -> …, scene{…}, group, repeat |
CANVAS |
morph, focus, replay |
TRANSITION |
an app block |
APP |
A single file mixes them freely — an indicator and its presentation are one program — because the plane comes from the constructs, not from a mode switch at the top of the file. If you mix them in a way the firewall forbids, the compiler tells you which value crossed which line, and what to do instead. The inference rule and the one grammar that carries all four planes are specified in Grammar › One grammar, all planes.
See also
- The App plane — the full application contract.
- The Canvas plane — signals, spaces, events, primitives, the performance model.
- The Transition plane — morph, focus, replay, and the transition descriptor.
- Time & state — causality, clocks,
live()in depth. - Guarantees — what each plane promises, and how it is verified.
- display — the render strata, and where presentation determinism ends.