RFC-0019: Uniform enter/exit dispatch
- Status: Accepted — 2026-05-12
- Created: 2026-05-12
- Affects: how
$>(enter) and<$(exit) events reach a state’s ancestors in a hierarchical state machine - Supersedes in part: RFC-0018 — the
@@:self.method(...)-from-a-lifecycle-handler question is downstream of this one
Summary
Frame’s event model is explicit-only forwarding: an event is dispatched to
the current (leaf) state; if that state doesn’t handle it, it is ignored —
it does not bubble to the parent — and the only way a state sends an event to
its parent is => $^ (forward).
See frame_language.md § “Hierarchical State Machines”: “V4 uses explicit-only
forwarding. Unhandled events are ignored, not forwarded. => $^ is the only way
to forward to parent.”
$> (enter handler) and <$
(exit handler) events are the one exception. They are
not dispatched to the leaf and forwarded — the kernel runs an enter cascade /
exit cascade: it walks the whole ancestor chain and fires $> on every
compartment, parent first; on exit it fires <$ on every compartment, child
first. The handler never sees a => $^; the ancestors run whether or not the
leaf has an enter/exit handler at all.
This RFC proposes removing that exception: $> and <$ become ordinary
events — dispatched to the leaf, with ancestors reached only through => $^
(in a handler, or via a state-level default-forward). A leaf with no enter
handler and no forward simply has no enter behavior, including its ancestors’.
The result is one dispatch rule for every event, the developer gains control
over enter/exit ordering and over whether ancestor lifecycle code runs at all,
and Frame stops contradicting its own “explicit-only” promise.
This is a language-semantics change: programs that today rely on an
ancestor’s $>/<$ running automatically will behave differently. It needs a
migration (described below) and is not a patch — it’s a deliberate change to how
HSMs behave. The costs are weighed in “Drawbacks” and accepted; future relief is
sketched in “Future possibilities”.
Motivation
The cascade is the only implicit propagation in the language
Everywhere else, Frame is uniform: a handler runs only if it’s there, an event
reaches the parent only if you say => $^. The enter/exit cascade breaks both:
the parent’s $>/<$ runs even though nobody routed the event to it, and it
runs even when the child has nothing — no enter handler, no forward. A reader who
has internalized “explicit-only forwarding” is then surprised that $> doesn’t
follow it. (The current behavior is documented — frame_runtime.md § “Step 21”
spells out the cascade — but documented-and-inconsistent is still inconsistent.)
The developer can’t control enter/exit order, or opt out
Today the order is fixed by the kernel: on entry, outermost ancestor’s $>
first, then inward, leaf last; on exit, leaf’s <$ first, then outward. There is
no way to run the leaf’s enter logic before the parent’s, and no way to enter a
child without running the parent’s $> (e.g. a child that wants to fully
replace its parent’s setup, not append to it). The cascade is take-it-or-leave-it.
@@:self.method(...) from $>/<$ has been a problem precisely here
RFC-0018 documents a crash: a re-entrant interface dispatch from a
lifecycle handler read an empty per-call context stack. The immediate cause is
narrow (RFC-0017’s constructor split dropped the FrameContext the cascade is
supposed to run inside — fixed) but the shape of the bug — “a lifecycle handler
is special machinery, not a normal handler, so the normal invariants don’t hold
there” — is exactly the inconsistency this RFC removes. If $>/<$ are ordinary
events, a $> handler is an ordinary handler, with an ordinary context, and
@@:self.method(...) from it means exactly what it means anywhere.
Guide-level explanation
In a hierarchical state machine, when the system transitions into $Child
(where $Child => $Parent):
Today: the kernel fires $Parent.$>, then $Child.$>. Both run, in that
order, automatically. On the way out it fires $Child.<$, then $Parent.<$.
Under this proposal: the kernel dispatches a $> event to $Child only.
What happens next is whatever $Child’s dispatcher does with any event:
-
$Childhas a$>handler → it runs. If it wants$Parent.$>to run too, it does=> $^— and where it puts that forward decides the order:$Child => $Parent { $>() { => $^ // run $Parent's enter first set_up_child_stuff() // then mine } }$Child => $Parent { $>() { set_up_child_stuff() // mine first => $^ // then $Parent's } } -
$Childhas no$>handler but has a state-level default-forward (a bare=> $^in the state body —frame_language.md§ “Hierarchical State Machines” — which forwards all unhandled events) → the$>event falls through to it,$Parent.$>runs. This is how you keep the old “parent’s enter runs automatically” behavior for a child that has no enter logic of its own — note it also forwards every other unhandled event, which is the point (one forwarding knob, not two). -
$Childhas neither → the$>event is ignored.$Parent.$>does not run. The child has overridden its ancestors’ enter behavior by saying nothing.
Exit (<$) works the same way, symmetrically.
What does not change
- The kernel still builds the whole compartment chain on entry —
$Parent’s compartment exists underneath$Child’s, so$Childcan reach$Parent’s state variables, and the=> $^route has somewhere to go. That’s allocation, not handler dispatch; it’s unaffected. - Transitions still queue (
__next_compartment) and drain after the triggering event (or after construction), iteratively — the transition loop is unchanged. - Plain (non-HSM) state machines see no change at all — there’s no ancestor, so there’s nothing the cascade was doing for them.
=> $^keeps its current meaning and restrictions (e.g.=> $^in a state with no parent is still E403).
Reference-level explanation
The kernel’s transition processing today calls __fire_enter_cascade() /
__fire_exit_cascade(), each of which walks the chain and routes a synthesized
$> / <$ event to every compartment. Under this proposal those helpers go
away; the kernel routes a single $> / <$ event to the leaf compartment via
the same router every other event uses, and the leaf’s generated dispatcher
handles it — matching a $> / <$ case if the state declares one, otherwise
falling through to the state-level forward if one is present, otherwise doing
nothing. => $^ continues to re-route the in-flight event to the parent’s
dispatcher; for $> / <$ that means the parent’s $> / <$ handler runs at
the point the forward appears in the child’s handler body.
Every $> / <$ dispatch runs inside a FrameContext
— whether a fresh one per dispatch (matching an interface-method dispatch
exactly) or the surrounding one (today, per frame_language.md § “Hierarchical
State Machines”: “Lifecycle events ($>, <$) use the existing context”) is a
detail for the migration to settle. Either way there always is one: at
construction it’s the context the constructor pushes for the start state’s enter
(documented in frame_runtime.md § “Step 5”; restored independently of this RFC
— see RFC-0018); during a transition it’s the triggering event’s,
which is still on the stack. So @@:return, @@:data, the
post-@@:self.method(...) transition guard, and everything else that reads the
context stack work in a $>/<$ handler exactly as in any other handler.
This RFC does not change persistence: a saved system’s hsm_chain /
compartments array is unaffected, and restore (@@[load]) reconstructs the
chain the same way — it never ran the enter cascade in the first place.
Drawbacks
These costs were weighed and accepted (2026-05-12). They are real; they are not blockers.
- It changes the behavior of existing HSM programs, and the breakage is
silent. The common HSM idiom is “the parent state is shared setup/teardown
that wraps every substate” —
$Active.$> { motor_on() }, substates$Forward/$Reverse. After this change,-> $Forwardruns$Forward.$>and not$Active.$>unless$Forwardforwards (an in-handler=> $^, or a state-level default-forward). There is no error and no warning — the parent’s setup just quietly doesn’t run. The mitigations are cheap (a state-level=> $^is one line; a Frame developer already thinks “no forward → no propagation”), but note the asymmetry in consequence: a normal event not reaching the parent is usually harmless; an enter event not reaching the parent means the parent’s invariant setup didn’t run. Accepted; a future lint (“state has an ancestor with a$>/<$handler and doesn’t forward — intentional?”) and a prominent cookbook recipe for the parent-as-shared-setup pattern are the planned softeners — see “Future possibilities”. - A state-level
=> $^is a blunt instrument for this job. It forwards all unhandled events. A child that adds it purely to get the ancestor’s$>/<$to run has also made every other unhandled event bubble to the parent — which the cascade did not do (cascade gave “ancestor lifecycle runs, ancestor doesn’t see my events” — two independent behaviors, now one knob). To decouple, the child needs$>/<$handlers whose only body is=> $^. Accepted; a lifecycle-scoped forward is a “Future possibilities” item. - Ordering control via
=> $^placement is expressive enough to write confusing code.<$ { cleanup_a() => $^ cleanup_b() }tears down part of the child, runs the parent’s entire exit, then the rest of the child — the parent’s teardown sandwiched between halves of the child’s. This is a feature (you choose the order) and a footgun (you can choose a bewildering one); documentation, not a language restriction, is the answer. - It’s a large migration.
frame_runtime.md§ “Step 21” (the HSM walkthrough built around the cascade), the HSM cookbook recipes, the demos that use nested states, and the slice of the matrix corpus that asserts the cascade (multi-level HSM enter-order tests) all have to be reworked. - It diverges further from UML statecharts, which always run ancestor entry/
exit actions. Frame already diverges (it rebuilds the whole destination chain on
every transition rather than preserving composite-state identity within a
subtree — see
frame_runtime.md§ “Step 21”); this widens the gap. The trade is the same one Frame already made — and accepted again here: one uniform rule over UML fidelity. The UML machinery (LCA computation, intra-subtree-move suppression, composite-state history) is exactly the complexity Frame is choosing not to carry.
Rationale and alternatives
- Keep the cascade (status quo). Rejected: it’s the one place the language contradicts its own forwarding rule (“explicit-only; unhandled events ignored”), it gives the developer no control over enter/exit order or opt-out, and it’s the structural reason lifecycle handlers keep needing special-case handling in the runtime (the F1 crash being the latest instance).
- Cascade by default, with an explicit opt-out. Add a construct meaning
“don’t run my ancestors’
$>/<$”. Rejected: it’s a new keyword for a niche case, it still leaves the implicit-propagation inconsistency in place for the default, and it doesn’t give ordering control. The leaf-only model gets opt-out and ordering and consistency for free, by just being the normal event rule. - Make the cascade fire
<$/$>through the leaf’s dispatcher (so a leaf handler can intercept) but still auto-route to ancestors. Rejected: it’s a half-measure — the leaf can observe the cascade but not stop it or reorder it — and it still isn’t the normal event rule. - Auto-forward
$>/<$only when the state declares no handler for them (“Option C”): no$>handler → the ancestor’s$>runs (cascade-like default); a$>handler with no=> $^→ overrides; a$>handler with=> $^→ both, ordered by placement. Keeps the common case zero-ceremony and removes the silent-failure footgun (an empty substate is transparent; a substate with enter logic is opaque unless it says otherwise). Rejected because it reintroduces exactly one special-case rule — “$>/<$are auto-forwarded when unhandled; other events aren’t” — which is the carve-out this RFC exists to eliminate. It was considered and turned down deliberately; if the silent-failure cost proves too high in practice, this is the fallback to revisit.
Unresolved questions
- Migration mechanics. Is the corpus/demo/cookbook migration a mechanical
codemod (“every nested state gains a state-level
=> $^unless it already has one”), or does it need per-recipe judgement (some recipes may want the override behavior once it’s available)? A codemod gets to behavior-preserving; judgement gets to good. <$and the in-flight transition. A<$handler runs during the exit leg of a transition that’s already committed (__next_compartmentis set).=> $^from a<$re-routes the<$event to the parent — fine — but should anything else a<$handler does (a further transition, a@@:self.…) be constrained? This was an open question in RFC-0018 and stays open here.- Default-forward granularity. Is “a state-level
=> $^forwards everything, including$>/<$” the right semantics, or should$>/<$need an explicit forward even when a state-level one is present (so the state-level forward stays “events only”, matching the cascade’s old scope)? The former is simpler; the latter is closer to today’s split. - Deep chains. With ancestors reached by
=> $^chaining, a 4-deep nested state’s full enter is$Leaf.$>→=> $^→$P3.$>→=> $^→$P2.$>→=> $^→$P1.$>, i.e. each level must forward. Is requiring a forward at every level acceptable, or is there an ergonomic short form (“forward all the way up”)? - Interaction with
push$/pop$. A modal push enters a state; a pop re-enters the suspended one. Do those re-entries go through the same leaf-only$>dispatch (so a pushed/popped state’s ancestors also need=> $^), or is there a reason to treat modal re-entry differently?
Future possibilities
Not part of this RFC; recorded so the migration costs above have a known path to relief.
- A lint for the silent-failure case. A W-code on a state that has an
ancestor declaring a
$>/<$handler and itself neither forwards nor declares the corresponding handler — “this state’s ancestor has enter/exit logic that won’t run; add=> $^or confirm this is intentional”, with a way to silence it (an attribute on the state). Turns the footgun into a noticed one without reintroducing the cascade. - A canonical cookbook recipe for “parent state as shared setup/teardown”
showing the
$>{ => $^ } <${ => $^ }(or state-level=> $^) pattern, so the common case has an obvious blessed form. - Annotations that override the default enter/exit forwarding — e.g. an
attribute on a state (or on the system) that restores cascade-style auto-
forwarding of
$>/<$for that subtree, for codebases that want the UML-ish default locally. Lets a project opt back in without the language defaulting there. - A bubble-up syntax aligned with DOM event semantics — a more expressive
forwarding model (capture/bubble phases,
stopPropagation-style control) than the single=> $^. Would generalize beyond$>/<$to all events and could subsume the state-level-=> $^-is-too-coarse wart. Speculative; noted as a direction. - A lifecycle-scoped forward — a forward form that propagates only
$>/<$(not all unhandled events), so a child can run its ancestors’ lifecycle without also bubbling its other unhandled events. The narrow fix for the second Drawback if the DOM-style model above doesn’t land.
References
frame_language.md§ “Hierarchical State Machines” — explicit-only forwarding;=> $^is the only way to the parent; unhandled events are ignored.frame_runtime.md§ “Step 5” — the start state’s$>runs at construction inside a (discarded)FrameContext. § “Step 21” — the current enter/exit cascade, the whole-chain-rebuild rule, and the UML divergence.- RFC-0017 — init decoupling (
__frame_init/__create); where the construction-time enter cascade lives now. - RFC-0018 — re-entrant interface dispatch from lifecycle handlers; the crash whose shape this RFC’s inconsistency produced.
docs/glossary.md—enter handler($>),exit handler(<$),forward(=> $^),transition,frame context,compartment.