Implementation status (2026-04-20): Shipped. The scanner’s unified transition path at native_region_scanner/unified.rs:1879 handles (exit)? -> (=>)? (enter)? ($State | pop$), covering every decoration variant below. Codegen for the decorated pop branch lives in codegen/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:

  1. 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.

  2. 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.

  3. 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.

  4. 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:

  1. If exit_args supplied: write them into the current compartment’s exit_args field before transition.
  2. Pop the top of _state_stack into next_compartment.
  3. If enter_args supplied: replace next_compartment.enter_args with the supplied values (see §2.1 for rationale).
  4. If => supplied: write the current frame event into next_compartment.forward_event.
  5. Call __transition(next_compartment).
  6. 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) $State transition, 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

  1. No state_args. pop$ never accepts state args. The popped compartment carries its own state_args from the snapshot. Supplying -> pop$(x) is E607.

  2. No chaining of pop targets. -> pop$ -> pop$ is not valid — transitions don’t chain. This was never valid; documented here for completeness.

  3. 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.

@@[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

  1. 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?

  2. 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.

  3. Macro-style pop patterns. Would a pop$ $Specific form — “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.

  4. 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: bool to SegmentMetadata::Transition.
  • Unify FrameSegmentKind::Transition and FrameSegmentKind::TransitionForward so is_forward is populated consistently regardless of target.

Codegen — codegen/frame_expansion.rs

  • Replace the monolithic pop branch (starts at line 402) with a dispatch that:
    1. Emits exit_args writes on self.__compartment (reuse helper from the normal transition branch).
    2. Emits the pop from the stack.
    3. If enter_args supplied: emit .clear() + writes on the popped compartment’s enter_args.
    4. If is_forward: emit popped.forward_event = __e.
    5. Emit __transition(popped) and return.
  • 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, and enter_args uniformly. This is mostly a plumbing cleanup — the fields exist, but is_pop: true currently 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 reads exit_args, enter_args, forward_event uniformly; 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 for start() to begin a new operand.
  • $Reading — accumulates digits, returns the parsed integer to $Ready on done().

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 $Ready compartment re-enters with value=$.acc, replacing the empty enter_args captured when $Ready was 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 receives x; snapshot’s enter_args are gone.
  • Exit args on pop: (r) -> pop$. Verify leaving state’s <$ handler receives r.
  • Event forwarding on pop: -> => pop$. Verify popped state receives the forwarded event, not $>.
  • All three combined: (r) -> (x) => pop$. Verify exit fires with r, then forward fires with original event ($> suppressed because forward is non-enter event).
  • State vars preserved across fresh enter args: $.counter = 7 before push, pop with -> (x) pop$. Verify $.counter == 7 still after pop.
  • State params preserved across fresh enter args: pushed state has $State(p). Pop with fresh enter args. Verify p is still accessible via @@:params.p in 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 sees c=3 and no a or b.
  • 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; standalone pop$ (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 $A and 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 correct SegmentMetadata::Transition shape.
  • is_forward is populated consistently for both $State and pop$ 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 q event to $Normal after 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