Implementation status (2026-04-20): Shipped. The scanner’s unified transition path at
native_region_scanner/unified.rs:1879handles(exit)? -> (=>)? (enter)? ($State | pop$), covering every decoration variant below. Codegen for the decorated pop branch lives incodegen/frame_expansion.rs. The language reference (docs/frame_language.md, Transitions section) carries the normative syntax table.
Summary
Extend -> pop$ so it accepts the same transition decorations that a normal
-> $State transition does:
- Exit args on the left:
(exit_args) -> pop$ - Fresh enter args on the right:
-> (enter_args) pop$ - Event forwarding:
-> => pop$ - Combinations:
(exit_args) -> (enter_args) => pop$
Before this RFC, -> pop$ was a stripped-down transition: the scanner
recognised pop$ as the target and discarded any exit/enter/forward
decorations. The popped compartment was reinstated verbatim, and a $>
event fired with whatever enter_args were captured at push$ time.
Pop transitions are now first-class — the caller can influence the exit
event fired on the leaving state, override or refresh the enter args
delivered to the restored state, or forward the current event to the
restored state in place of $>.
The runtime model required no architectural change. The Compartment
already carried enter_args, exit_args, and forward_event fields, and
the kernel already honoured them for every transition. This was a syntax +
scanner + codegen gap, not a runtime gap.
Motivation / Problem
push$/pop$ support modal dialogs, subroutine states, and undo patterns
(see cookbook recipes 7 and 8). Today the patterns work only when the popped
state is happy to re-enter with exactly the arguments it was originally given.
In practice callers often want to:
-
Pass a result back. A subroutine state completes and wants to hand a value to the state that pushed it. Today the caller has to write the result into a state variable on the parent before
push$, or use a domain variable — neither of which composes well. -
Refresh stale enter args. A dialog state pushed an hour ago may have been entered with a timestamp, a nonce, or a reference to an object that has since changed. On resume, the caller wants to supply current values without losing state variables accumulated while pushed.
-
Forward the triggering event. A help-mode state wants to return control to the underlying workflow state and hand it the event that closed the help dialog (e.g., a
keypress(key)event the help state consumed should be replayed to the resumed state). Today=>forwarding is not available on pop transitions — the caller must re-dispatch the event manually via@@:self.<iface>()or a domain flag. -
Run exit handlers with context. A state leaving via pop should be able to pass exit args to its
<$handler exactly as it would on a normal transition. The runtime already supports this — but the scanner drops the syntax on pop transitions, so users can’t express it.
Every one of these is awkward without the extension, and several require
introducing domain-level coordination state purely to paper over the missing
syntax. The compartment model already contains the fields needed; the
transition syntax should expose them uniformly regardless of whether the
target is $State or pop$.
Design
1. Syntax
The full generalized transition syntax becomes:
(exit_args)? -> (=>)? (enter_args)? $State(state_args)?
(exit_args)? -> (=>)? (enter_args)? pop$
Every decoration that is legal on a normal transition is legal on a pop
transition. The only syntactic difference is the target: $State vs pop$.
state_args is not applicable to pop$ — the popped compartment brings
its state_args with it from the snapshot. Supplying state_args on a pop is a
compile error (see §4, E607).
1.1 Examples
-> pop$ # bare (today's behavior — unchanged)
-> (result) pop$ # fresh enter args for restored state
(reason) -> pop$ # exit args for the leaving state
-> => pop$ # forward current event to restored state
(reason) -> (result) pop$ # exit args + fresh enter args
(reason) -> (result) => pop$ # all three
2. Semantics
The kernel sequence is unchanged. A pop transition with decorations runs:
- If
exit_argssupplied: write them into the current compartment’sexit_argsfield before transition. - Pop the top of
_state_stackintonext_compartment. - If
enter_argssupplied: replacenext_compartment.enter_argswith the supplied values (see §2.1 for rationale). - If
=>supplied: write the current frame event intonext_compartment.forward_event. - Call
__transition(next_compartment). - The kernel then does the standard exit → swap → enter/forward sequence, exactly as it does today for any transition.
The state vars, state params, and parent_compartment on the popped
compartment are never touched by decorations — they remain exactly as
captured at push$ time. Only the three transient fields (enter_args,
exit_args, forward_event) participate.
2.1 Replace vs. Merge for enter_args
When the caller supplies fresh enter_args, the popped compartment’s
enter_args dict is replaced wholesale, not merged with the snapshot.
Rationale:
- Merge semantics invite silent bugs where stale keys from the snapshot leak
through. A caller writing
-> (result) pop$is stating “this is the complete payload for the enter handler.” - Replace matches the semantics of a regular
-> (args) $Statetransition, which also does not merge with anything. Consistency matters more than convenience here. - If merge is ever needed, it can be added later as a distinct operator; a replace-by-default baseline does not preclude it.
2.2 Absent Decorations
Omitting a decoration preserves today’s behavior:
| Syntax | exit_args source | enter_args source | forward_event |
|---|---|---|---|
-> pop$ |
current compartment’s existing value (usually empty) | snapshot | none |
(e) -> pop$ |
(e) from call site |
snapshot | none |
-> (a) pop$ |
current compartment’s existing value | replaced with (a) |
none |
-> => pop$ |
current compartment’s existing value | snapshot (unused — forward takes priority) | current frame event |
Note: when => is present, the kernel’s existing forward_event logic runs.
The popped state sees the forwarded event, not a $> event (unless the
forwarded event happens to be $>, in which case the kernel’s existing
enter-forwarding branch — system_codegen.rs:2900 — applies).
3. Codegen Expansion
All codegen changes live in frame_expansion.rs under the pop-transition
branch (currently frame_expansion.rs:402). Today that branch emits:
# Python example, current codegen
__saved = self._state_stack.pop()
self.__transition(__saved)
return
The extended branch emits writes to exit_args, enter_args, and
forward_event in the same order as a normal transition does — because
the existing normal-transition codegen (system_codegen.rs:1316-2500) has
already been working through the same fields across every backend. The pop
branch reuses that helper logic rather than duplicating it.
3.1 Python
(reason) -> (result) => pop$
# Generated
self.__compartment.exit_args.append(reason)
__saved = self._state_stack.pop()
__saved.enter_args.clear()
__saved.enter_args.append(result)
__saved.forward_event = __e
self.__transition(__saved)
return
3.2 TypeScript
// Generated
this.__compartment.exit_args.push(reason);
const __saved = this._state_stack.pop()!;
__saved.enter_args = [];
__saved.enter_args.push(result);
__saved.forward_event = __e;
this.__transition(__saved);
return;
3.3 Rust
Rust’s compartment model uses the enum-of-structs pattern; the popped
compartment’s typed enter_args struct is rebuilt from the supplied args
rather than dict-assigned. See §6.2 for details.
// Generated (pop with fresh enter args)
let mut __popped = self._state_stack.pop().unwrap();
// Rebuild typed enter_args struct
__popped.enter_args = FooEnterArgs::RestoredState { result };
__popped.forward_event = Some(__e);
self.__compartment.exit_args = FooExitArgs::LeavingState { reason };
self.__transition(__popped);
return;
3.4 Static-Typed Dict Backends (Java, Kotlin, Swift, C#)
Follow the same pattern as Python/TypeScript. Each backend already has a
clear()/reassign equivalent for its enter_args dict; the pop branch
reuses whatever that backend uses for normal enter-arg codegen.
3.5 C
C uses _FrameDict_* helpers. The pop branch:
_FrameDict_set(self->__compartment->exit_args, "reason", ...);
Foo_Compartment* __saved = (Foo_Compartment*)Foo_FrameVec_pop(self->_state_stack);
_FrameDict_clear(__saved->enter_args);
_FrameDict_set(__saved->enter_args, "result", ...);
__saved->forward_event = __e;
Foo_transition(self, __saved);
return;
4. Validation Rules
| Code | Check | Severity |
|---|---|---|
| E605 | enter_args supplied on pop references a parameter name not declared in the popped state’s $> handler signature |
Error |
| E606 | exit_args supplied on pop references a parameter name not declared in the current state’s <$ handler signature |
Error |
| E607 | state_args supplied on pop$ target (e.g., -> pop$(x)) |
Error |
| E608 | pop$ appears in a context with no reachable push$ (static analysis — reuses RFC-0002 stack-depth analyzer if present; otherwise warn only) |
Warning |
| W602 | Popped state cannot be known statically (multiple push sites with different targets), so enter_args parameter validation is skipped | Warning |
E605 has a wrinkle: the popped state is not always knowable at compile time
(one state may be pushed from several call sites, each pushing a different
state). When the set of possible pop targets can be narrowed, each target’s
$> signature must accept the supplied enter_args. When it cannot be
narrowed, the transpiler emits W602 and defers to runtime.
5. Scanner / Parser Impact
The scanner’s transition segment handler (unified.rs:1258-1388) currently
short-circuits on pop$:
if trimmed.contains("pop$") {
return SegmentMetadata::Transition {
target_state: "pop$".to_string(),
exit_args: None,
enter_args: None,
state_args: None,
label: None,
is_pop: true,
};
}
That early-return drops every decoration. The fix is to remove the
short-circuit and let the existing extraction logic for exit_args,
enter_args, and forwarding run — then set is_pop: true and
target_state: "pop$" at the end. The state-args extractor must be
short-circuited when the target is pop$ (to trigger E607 cleanly rather
than silently accept).
The forwarding flag (=>) needs a new field on SegmentMetadata::Transition:
SegmentMetadata::Transition {
target_state: String,
exit_args: Option<String>,
enter_args: Option<String>,
state_args: Option<String>,
label: Option<String>,
is_pop: bool,
is_forward: bool, // NEW
}
is_forward is set when => appears between -> and the target. Normal
transitions already support => via the FrameSegmentKind::TransitionForward
segment kind — the scanner should unify the two paths so is_forward is
populated consistently for both $State and pop$ targets.
6. Interaction with Other Features
6.1 HSM Parent Compartment
parent_compartment on the popped compartment is preserved from the
snapshot and is unaffected by any decoration. Pop does not re-resolve the
hierarchy — you get back exactly the HSM context you left.
6.2 Rust Typed Enter/Exit Args
Rust’s codegen generates a typed enum variant per state for enter_args and
exit_args (see rust_system.rs, enum-of-structs pattern). Fresh enter args
on pop require the codegen to emit the correct variant constructor for the
popped state. When the popped state is not statically knowable (W602),
Rust cannot emit typed enter_args on pop — this becomes E605-rust, a
Rust-specific error requiring the caller to use a form where the target is
inferable, or to use bare -> pop$.
This is an acceptable constraint: Rust pays for its type safety with a narrower set of legal pop-with-fresh-enter-args programs. All other backends are unaffected.
6.3 Event Forwarding Chain
-> => pop$ forwards the currently-processing event. Chaining
(popped state receives the event, then forwards it again) is allowed and
works like any other forward chain — each forward is independent.
6.4 @@:return
Pop-with-decorations does not interact with return-value handling. The
caller’s @@:return is set by the caller’s handler and read by the caller’s
interface wrapper; nothing on the pop path reads or writes it.
6.5 Standalone pop$
Bare pop$ (no transition arrow) remains unchanged — it discards the top of
the stack without dispatching any events. Decorations on standalone pop$
are a syntax error (E609).
7. Restrictions
-
No state_args.
pop$never accepts state args. The popped compartment carries its own state_args from the snapshot. Supplying-> pop$(x)is E607. -
No chaining of pop targets.
-> pop$ -> pop$is not valid — transitions don’t chain. This was never valid; documented here for completeness. -
No label on pop. Graphviz labels (
-> "note" $State) have no meaning on a pop transition because the target is dynamic. A label on-> pop$is ignored (not an error — permits pattern-level macros).
Examples
Subroutine State Returning a Result
@@[target("python_3")]
@@system Calculator {
interface:
input(digit: int)
equals()
machine:
$Entry {
input(digit: int) {
push$
-> (digit) $Accumulate
}
$>(result: int) {
print(f"Result: {result}")
}
}
$Accumulate {
$>(start: int) {
$.value = start
}
input(digit: int) {
$.value = $.value * 10 + digit
}
equals() {
-> ($.value) pop$ # fresh enter arg — returns result to $Entry
}
}
}
The popped $Entry compartment re-enters with result=<final value>,
replacing the (empty) enter_args captured when $Entry was originally
entered at system start.
Help Mode with Event Forwarding
@@[target("python_3")]
@@system Editor {
interface:
keypress(key: str)
help()
machine:
$Normal {
keypress(key: str) {
edit(key)
}
help() {
push$
-> $Help
}
}
$Help {
keypress(key: str) {
if key == "q":
-> => pop$ # close help, forward keypress to $Normal
}
}
actions:
edit(key) { ... }
}
When the user presses q in help mode, the keypress(q) event is forwarded
to the restored $Normal state. Without =>, the event would be consumed
by $Help and $Normal would only see the synthetic $> enter event.
Modal Dialog with Reason
@@[target("python_3")]
@@system Workflow {
interface:
confirm()
cancel()
machine:
$Editing {
confirm() {
push$
-> $ConfirmDialog
}
<$(status: str) {
log(f"Leaving Editing: {status}")
}
}
$ConfirmDialog {
confirm() {
("confirmed") -> pop$ # exit arg for $ConfirmDialog's <$
}
cancel() {
("cancelled") -> pop$
}
}
}
Open Issues
-
Static resolvability of pop target. Many validations require knowing which state is being popped. A flow analysis over
push$sites can sometimes determine this; when it cannot, we fall back to W602 (skip validation, trust runtime). Is a best-effort analysis worth building now, or should the initial implementation always emit W602 and defer the analyzer to a follow-up RFC? -
Rust + fresh enter args + ambiguous target. When Rust codegen cannot statically determine the popped state, it cannot construct the typed enter_args variant. Proposal: E605-rust. Alternative: Rust codegen falls back to a runtime dispatch table keyed on the popped state’s discriminant. The latter is more work but preserves expressiveness.
-
Macro-style pop patterns. Would a
pop$ $Specificform — “pop only if the top is$Specific, else error” — be useful? It would enable assertions on stack shape. Out of scope for this RFC; flagged for future. -
Multi-pop.
-> pop$(2)to pop two frames at once is not proposed here. If demand emerges, a follow-up RFC can address it; the syntax is orthogonal to this one.
Code Changes
Scanner — native_region_scanner/unified.rs
- Remove the short-circuit at line 1262 that strips decorations from
pop$transitions. - Extract exit_args, enter_args, and forwarding using the existing
transition extraction logic, then set
is_pop: true. - Short-circuit state_args extraction when target is
pop$(to fail cleanly at E607). - Add
is_forward: booltoSegmentMetadata::Transition. - Unify
FrameSegmentKind::TransitionandFrameSegmentKind::TransitionForwardsois_forwardis populated consistently regardless of target.
Codegen — codegen/frame_expansion.rs
- Replace the monolithic pop branch (starts at line 402) with a dispatch
that:
- Emits exit_args writes on
self.__compartment(reuse helper from the normal transition branch). - Emits the pop from the stack.
- If enter_args supplied: emit
.clear()+ writes on the popped compartment’senter_args. - If
is_forward: emitpopped.forward_event = __e. - Emit
__transition(popped)andreturn.
- Emits exit_args writes on
- Factor the exit_args/enter_args emission helpers out of the normal transition branch so both branches share them.
Rust Codegen — codegen/rust_system.rs
- Emit the correct typed
EnterArgs::<State>variant constructor when fresh enter_args are supplied and the popped state is statically known. - Emit E605-rust when popped state is not inferable and fresh enter_args are supplied.
Validator — validation/passes/semantic.rs
- Add E605, E606, E607, E608, E609, W602.
- E607/E609 are syntactic and can be emitted from the scanner or an early validation pass.
- E605/E606 require a light flow analysis of
push$targets. Start with single-push-site cases; emit W602 for multi-site.
AST — codegen/ast.rs / frame_ast.rs
- Ensure the AST node for transitions carries
is_pop,is_forward,exit_args, andenter_argsuniformly. This is mostly a plumbing cleanup — the fields exist, butis_pop: truecurrently suppresses the others.
Documentation Changes
docs/frame_language.md
-
State Stack and History section: expand to document the decorated pop forms. The current wording only mentions “saves the current state (including all state variables)” — understates what
push$actually captures. Rewrite to:push$saves the current compartment — state, state params, state variables, and enter/exit args — onto the stack.pop$restores it. Pop transitions accept the same decorations as regular transitions:(exit_args) -> (enter_args) => pop$. The enter_args, if supplied, replace the snapshot’s enter_args; state vars and state params are always restored verbatim. -
Add a dedicated Pop Transition Decorations subsection with the syntax table from §2.2 of this RFC.
-
Add the error codes E605–E609 and W602 to the error reference section.
docs/frame_cookbook.md
- Update recipes 7 (Undo) and 8 (Modal Dialogs) to note that pop transitions now support fresh enter args and forwarding.
- Add a new recipe — Recipe 9: Subroutine State Returning a Result —
demonstrating
-> (result) pop$. See Cookbook Demo below.
docs/frame_runtime.md
- Clarify that the kernel’s transition loop is flavor-agnostic: every
next_compartment— whether constructed fresh, copied for push, or popped from the stack — is processed identically. The kernel readsexit_args,enter_args,forward_eventuniformly; the transition syntax is just a way of populating those fields.
docs/rfcs/ index
- Add RFC-0008 entry.
Cookbook Demo — Calculator Subroutine
A concrete end-to-end demo to land in docs/frame_cookbook.md as
Recipe 9: Subroutine State Returning a Result. The demo exercises all
three extensions: exit_args on the leaving state, fresh enter_args on the
popped state, and (optionally) event forwarding.
Spec
A two-state calculator:
$Ready— displays a running total, waits forstart()to begin a new operand.$Reading— accumulates digits, returns the parsed integer to$Readyondone().
Without RFC-0008, the caller has to stash the accumulated value in a
domain variable and have $Ready’s enter handler read it. With
RFC-0008, the value is passed as an enter arg directly.
Frame Source
@@[target("python_3")]
@@system Calculator {
interface:
start()
digit(d: int)
done()
equals(): int = 0
machine:
$Ready {
$>(value: int = 0) {
$.total = $.total + value
print(f"Total: {$.total}")
}
start() {
push$
-> $Reading
}
equals(): int {
@@:($.total)
}
}
$Reading {
$>() {
$.acc = 0
}
digit(d: int) {
$.acc = $.acc * 10 + d
}
done() {
-> ($.acc) pop$ # return acc to $Ready's $> handler
}
}
domain:
total: int = 0
acc: int = 0
}
Driver Script
c = Calculator()
c.start(); c.digit(1); c.digit(2); c.done() # Total: 12
c.start(); c.digit(3); c.digit(4); c.done() # Total: 46
print(c.equals()) # 46
Prose (for the cookbook)
Subroutine states are a natural fit for
push$/pop$— you push into a state that collects input, then pop back when done. Before RFC-0008, returning a result meant writing it to a domain variable, which couples every subroutine call site to a shared field. With decorated pop, the subroutine hands its result directly to the caller’s enter handler:
-> ($.acc) pop$The popped
$Readycompartment re-enters withvalue=$.acc, replacing the empty enter_args captured when$Readywas first entered at system start. State variables ($.total) and state params are preserved from the snapshot; only the enter_args are refreshed.
Test Plan
Unit Fixtures
- Bare pop (regression):
-> pop$— no decorations. Verify existing behavior unchanged: state vars restored, snapshot enter_args replayed on$>. - Fresh enter args on pop:
-> (x) pop$. Verify$>handler receivesx; snapshot’s enter_args are gone. - Exit args on pop:
(r) -> pop$. Verify leaving state’s<$handler receivesr. - Event forwarding on pop:
-> => pop$. Verify popped state receives the forwarded event, not$>. - All three combined:
(r) -> (x) => pop$. Verify exit fires withr, then forward fires with original event ($>suppressed because forward is non-enter event). - State vars preserved across fresh enter args:
$.counter = 7before push, pop with-> (x) pop$. Verify$.counter == 7still after pop. - State params preserved across fresh enter args: pushed state has
$State(p). Pop with fresh enter args. Verifypis still accessible via@@:params.pin the restored handler. - parent_compartment preserved: HSM state pushed, popped with fresh
enter args. Verify
@@:system.state/ parent resolution is unchanged. - Enter args replace, do not merge: snapshot had
enter_args = {a:1, b:2}. Pop with-> (c: 3) pop$. Verify$>handler seesc=3and noaorb. - Multi-pop sequence: push, push, pop with fresh args, pop with fresh args. Verify both pops deliver the respective fresh args to their respective restored states.
- Pop with forwarding when forwarded event is
$>: edge case — the kernel’s existing “forward_event._message == "$>"” branch (system_codegen.rs:2900) should fire. Verify no double-enter. - Pop with forwarding when forwarded event is not
$>: verify both$>and the forward are dispatched in order. - Standalone
pop$rejects decorations:-> (x) pop$is legal; standalonepop$ (x)is E609. - state_args on pop:
-> pop$(x)is E607. - Arity mismatch: single-push-site case where popped state’s
$>takes 2 params but pop supplies 1. Verify E605. - Ambiguous pop target + fresh enter args: two push sites, one pushes
$Aand one pushes$B. Pop with fresh enter args. Verify W602 emitted on all backends except Rust, and E605-rust emitted for Rust. - Exit args arity mismatch: leaving state’s
<$takes 0 params but pop supplies 1. Verify E606.
Per-Backend Validation
All fixtures must pass on every target backend. The codegen for pop with
decorations is structurally parallel to normal-transition codegen, so
fixtures that pass on normal transitions should — modulo the typed-enum
caveat for Rust — pass verbatim on pop transitions. Integration tests in
framec-test-env must be run for each backend after the codegen
change.
Scanner Tests
pop$with each combination of decorations round-trips through the scanner into the correctSegmentMetadata::Transitionshape.is_forwardis populated consistently for both$Stateandpop$targets.pop$with state_args parses into an AST node that the validator can flag as E607 (rather than silently dropping the state_args).
Integration Tests
- Calculator subroutine (cookbook recipe 9): runs on all 17 backends, verifies the printed totals match the expected sequence.
- Help-mode editor: runs on all 17 backends, verifies that the
keypress forwarding delivers the
qevent to$Normalafter pop. - Modal dialog with exit args: runs on all 17 backends, verifies the
<$handler log output.
References
- RFC-0002 — State Stack (if present — the canonical spec for
push$/pop$; this RFC is an additive extension) - RFC-0006 — Self Interface Call (demonstrates the RFC template and interaction with the context stack)
- Frame V4 Language Reference —
frame_language.md, §State Stack and History - Frame Runtime Architecture —
frame_runtime.md, §Kernel Transition Loop - Framepiler Design —
framepiler_design.md - Cookbook Recipes 7 (Undo) and 8 (Modal Dialogs) — existing patterns that benefit from this extension