E-ENGINE-INVALID-TRANSITION
Direct write to an engine variable (or
.advance(...))
violates the from-state's
rule=
contract.
What it means
An engine state-child declares which transitions are legal from
that state via its
rule=
attribute. Three target-only forms:
rule=.OneVariant
(single-target),
rule=(.A | .B | .C)
(multi-target), or
rule=*
(wildcard escape hatch). When code writes the engine variable
to a variant NOT in the current state's rule= set,
E-ENGINE-INVALID-TRANSITION fires.
The check is statically rejected when the from-state is known at compile time — e.g., writes inside a state-child body, where the from-state IS the surrounding state-child. Otherwise (writes outside any state-child body or via dynamic dispatch), the check is deferred to runtime.
What triggers it
-
Direct write to a forbidden variant.
@phase = .NotInRuleSetfrom a state-child whose rule= doesn't allow that target. -
.advance(.X)with a forbidden target. Same check; loud-failure variant (throws at runtime; the quiet-validation path is direct write). -
<onTimeout> with
to=.NotInRuleSet. The timer's target is checked against the surrounding state-child's rule= at compile time. -
<onIdle> watchdog fire from a state whose rule= doesn't allow the target.
Runtime fire (the current state at fire time is dynamic).
onIdle to= is validated against the engine's
for=enum at compile time, but rule= compatibility is runtime.
Minimal reproducer
type Phase:enum = { Idle, Loading, Done, Error }
<engine for=Phase initial=.Idle>
<Idle rule=.Loading>
// rule= only permits .Loading from here
<button onclick=${@phase = .Done}>Skip to Done</button>
// ^^^^^^
// E-ENGINE-INVALID-TRANSITION: .Done not in rule= set
</>
<Loading rule=(.Done | .Error)/>
<Done/>
<Error/>
</>
The write site (inside
.Idle)
has statically-known from-state, so the check fires at compile
time.
How to fix
-
Add the variant to the rule= set if the
transition is legitimate. The compiler accepts:
<Idle rule=(.Loading | .Done)> <button onclick=${@phase = .Done}>Skip</button> </> -
Route through an intermediate state if the
rule= currently expresses the correct contract. Often the
violation reveals that
two transitions are needed, not one direct write.
E.g., set
@phase = .Loadingfirst, then move to.Donefrom Loading's body. - Use wildcard rule=* sparingly if the transition surface is genuinely open. Wildcard is an escape hatch — the compiler loses static guarantees on writes from that state. Reserve for top-level UI states where the user can navigate anywhere (e.g., a global router enum).
-
Check the
.advancecall site..advance(.X)throws at runtime if .X isn't legal. For quiet no-op-on-violation semantics, use direct write@var = .X(a write that the runtime rejects becomes a no-op; the .advance form is loud).
Carve-out: idempotent self-writes
Per SPEC §51.0.F.1 (v0.3 Option-d, 2026-05-12), self-writes
are runtime no-ops, NOT violations. A self-write is
@var = .CurrentVariant
(or
@var.advance(.CurrentVariant))
where the target matches the engine's currently-active
variant.
Self-writes do NOT fire E-ENGINE-INVALID-TRANSITION. They do
fire
W-ENGINE-SELF-WRITE-DETECTED
(info-level lint) at compile time to surface the no-op
intention. They do NOT cause
<onTransition>
to fire, do NOT capture history, do NOT rearm timers, do NOT
reset the idle watchdog, do NOT notify subscribers.
If the no-op-when-already-in-state behavior is INTENTIONAL
(e.g., a defensive
set(.Current)
reachable from multiple variants), no action is required. If a
state change was expected unconditionally, the W- lint is the
signal to verify the write target or guard the call site.
Related
- <engine> — the container. The rule= contract is declared on state-children inside.
- <onTimeout> — per-state timer; to= validated against the surrounding state-child's rule=.
- <onIdle> — engine watchdog; runtime fire subject to rule= check.
- W-ENGINE-SELF-WRITE-DETECTED — info-level companion lint for the self-write carve-out.
Specification
Normative text:
SPEC §51.0.F (rule= contract),
§51.0.G
(.advance(.X)),
§51.0.F.1 (v0.3 self-write carve-out),
§34 catalog row. Spec lives at
compiler/SPEC.md
.