scrml.dev v0.7.1
Reference › Errors

E-ENGINE-INVALID-TRANSITION

Direct write to an engine variable (or .advance(...)) violates the from-state's rule= contract.

Error Compile-time when from-state is statically known Runtime otherwise SPEC §51.0.F (normative)

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 = .NotInRuleSet from 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

  1. 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>
    </>
  2. 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 = .Loading first, then move to .Done from Loading's body.
  3. 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).
  4. Check the .advance call 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 .