RFC-0020 — Runtime Reference Architecture

  • Status: Authoritative. This RFC is the normative specification for the framec runtime kernel and surrounding primitives. Python is the reference implementation; all other 16 backends must produce code with identical observable behavior. Per-backend divergences are logged in “Exceptions” below; per-backend progress is tracked in “Implementation Status.”
  • Author: Mark Truluck
  • Reference implementation: Python
  • Companion: RFC-0017 (init decouple — justifies the bare-ctor + factory split), RFC-0018 (context push for start $>), RFC-0015 (factory-only construction), RFC-0019 (leaf-dispatch for $>/<$), RFC-0021 (runtime perf optimizations, out of scope)
  • Implementation plan: _scratch/rfc0020_impl_plan.md (disposable; not part of the durable RFC)

Aligned with RFC-0019 (2026-05-12). RFC-0019 removed the enter / exit cascade after RFC-0020’s first cut. The Compartment and FrameContext shapes documented below are unchanged; what changed is the kernel’s behavior on $> / <$ — both now leaf-dispatch only. Per-state ancestor lifecycle runs only via explicit => $^ in the leaf’s handler body, not via a runtime cascade. Anywhere this RFC discusses “the kernel walks the ancestor chain and fires $> / <$” should be read as describing the pre-RFC-0019 contract; the current contract is “leaf-only, forwarded via => $^ when the user opts in.” See RFC-0019 for the full new contract.

Architecture overview

A compiled Frame system has the following shape.

Per-system runtime types

Three classes are emitted per compiled system, prefixed with the system name so multiple systems can coexist in one module:

  • <Sys>FrameEvent_message + _parameters.
  • <Sys>FrameContext — the currently-processing event plus _return, _data, and _transitioned. One context is pushed per event-handler entry.
  • <Sys>Compartment — the active state’s runtime data. Fields:
    • state — state identifier
    • state_args — state arguments
    • state_vars — state-local variables
    • enter_args — args supplied by the transition that arrived here
    • exit_args — args to be passed to the destination’s <$
    • forward_event — set when the transition is -> => $State
    • parent_compartment — parent state’s compartment (HSM ancestry)

Construction — two artifacts

  • Bare constructor. Allocates the system; sets up framework state (start compartment, empty stacks, transition slot, domain defaults). Does not fire the start state’s $>. Target of @@!Foo().
  • Factory (_create in Python; per-backend name otherwise). Calls the bare ctor, then fires the start state’s $> via the kernel. Target of @@Foo(args).

The split exists because of @@!Foo() — the no-initialization Frame construction form used by load/restore. See “Construction” below.

Dispatch — two named primitives

  • __router(event) — single dispatch primitive. Reads self.__compartment.state and routes the event to the matching state dispatcher, passing self.__compartment along. Same primitive used both for wrapper-method events and for synthesized <$ / $> events from inside the kernel drain loop.
  • __kernel(event) — fires an event then drains any transitions queued by the handler. Drain loop is inline in __kernel. The drain handles forward events via a three-branch protocol — see “Forward-event protocol” below.

Compartment helpers

  • __prepareEnter(leaf, state_args, enter_args) — constructs the destination compartment chain by walking _HSM_CHAIN[leaf]. For flat (non-HSM) systems the chain has one entry. For HSM systems the walk builds a linked chain from root to leaf, with each compartment’s parent_compartment pointing one level up.
  • __prepareExit(exit_args) — walks up self.__compartment’s parent chain and copies exit_args onto every level. Called by the transition lowering at the source state before queueing.
  • __transition(next_compartment) — caches a pre-built compartment as the next transition target. The transition is drained by the kernel after the current handler returns.

Per-state structure

  • State dispatchers — one per state, _state_<State>(event, compartment). Matches event._message and routes to the matching handler method. State dispatchers contain only routing; handler bodies live in named methods.
  • Named handler methods — one per (state, event) pair, _s_<State>_hdl_<kind>_<event>(event, compartment). Holds the user’s Frame source. Naming: <kind> is frame for lifecycle handlers ($>frame_enter, <$frame_exit) and user for interface methods.

Interface method wrappers

One per declared interface method. Wrapper body: construct a FrameEvent, push a FrameContext, call __kernel(event), pop the context, return _return.

Per-instance runtime state

Fields on self:

  • __compartment — current state’s compartment
  • __next_compartment — next compartment when a transition is queued, else None
  • _context_stack — stack of FrameContext. Top is the currently-executing event. Read by @@:return, @@:data, @@:self.method()
  • _state_stack — modal-push stack for $$+ / $$-
  • Domain fields — user-declared

Per-class static state

  • _HSM_CHAIN — class-level static table; maps each leaf state name to its root-to-leaf ancestor chain. Drives __prepareEnter’s compartment chain construction.

A worked example used throughout this RFC

@@system Counter {
    interface:
        bump()
        get(): int

    machine:
        $Running {
            $>() {
                self.count = 1
            }
            bump() {
                self.count = self.count + 1
            }
            get(): int {
                @@:(self.count) return
            }
        }
    domain:
        count: int = 0
}

A Counter system, one state called $Running. On entry ($>) it initializes count to 1. The interface exposes bump() to increment and get() to read.

Vocabulary

  • Compartment — an object holding the state machine’s current state identifier (e.g. "Running") plus the arguments and state-variables of that state. Every system has exactly one current compartment at a time (self.__compartment).
  • Context stack — a stack of FrameContext objects, one pushed per event-handler entry. Each FrameContext holds the event being processed. The top of the stack is the currently-executing event. Used by @@:return, @@:data, and @@:self.method() to find the surrounding event from inside a handler body.
  • $> and <$ — the enter handler and exit handler of a state. Run automatically on entry/exit, dispatched as ordinary events through the same router as interface methods.
  • @@Foo(args) — the Frame source form that constructs a system and runs its start state’s $>. The everyday construction call.
  • @@!Foo() — the Frame source form that allocates a system without running its start state’s $>. Used by load/restore: a saved system is re-allocated empty, then its previously-saved state is poured back in.

The reference architecture in code

The full Python that framec emits for the Counter system, with every helper present. State dispatcher, handler methods, and interface wrappers shown in compact form; the full architecture is described in subsequent sections.

# ===================== Compartment ===================== #

class CounterCompartment:
    def __init__(self, state, parent_compartment=None):
        self.state = state
        self.state_args = {}
        self.state_vars = {}
        self.enter_args = {}
        self.exit_args = {}
        self.forward_event = None
        self.parent_compartment = parent_compartment


# ===================== FrameEvent ====================== #

class CounterFrameEvent:
    def __init__(self, message, parameters):
        self._message = message
        self._parameters = parameters


# ===================== FrameContext ==================== #

class CounterFrameContext:
    def __init__(self, event, default_return):
        self.event = event
        self._return = default_return
        self._data = {}
        self._transitioned = False


# ====================== System ========================= #

class Counter:

    # ---- Per-class static topology ------------------------

    _HSM_CHAIN = {
        "Running": ["Running"],   # flat system — one entry per state
    }

    # ---- Construction (two artifacts) ---------------------

    def __init__(self):
        # Bare framework setup. IS @@!Counter() — no $> fires.
        self._state_stack = []
        self._context_stack = []
        self.__compartment = self.__prepareEnter("Running", [], [])
        self.__next_compartment = None
        # Domain defaults
        self.count = 0

    @classmethod
    def _create(cls):
        # Factory. IS @@Counter() — bare ctor + fire start $>.
        c = cls()
        __e = CounterFrameEvent("$>", c.__compartment.enter_args)
        __ctx = CounterFrameContext(__e, None)
        c._context_stack.append(__ctx)
        c.__kernel(__e)
        c._context_stack.pop()
        return c

    # ---- Compartment helpers ------------------------------

    def __prepareEnter(self, leaf, state_args, enter_args):
        # Build the destination compartment chain (root → leaf).
        # For a flat system the chain has one entry and parent is None.
        comp = None
        for name in self._HSM_CHAIN[leaf]:
            new_comp = CounterCompartment(name)
            new_comp.state_args = list(state_args)
            new_comp.enter_args = list(enter_args)
            new_comp.parent_compartment = comp
            comp = new_comp
        return comp

    def __prepareExit(self, exit_args):
        # Copy exit_args onto every level of the current chain.
        comp = self.__compartment
        while comp is not None:
            comp.exit_args = list(exit_args)
            comp = comp.parent_compartment

    def __transition(self, next_compartment):
        self.__next_compartment = next_compartment

    # ---- Runtime primitives -------------------------------

    def __kernel(self, __e):
        # Route the event to the current state.
        self.__router(__e)

        # Drain any queued transitions. Inline loop with
        # three-branch forward-event handling.
        while self.__next_compartment is not None:
            next_compartment = self.__next_compartment
            self.__next_compartment = None

            # Exit current state.
            self.__router(CounterFrameEvent("<$", self.__compartment.exit_args))

            # Switch to the new compartment.
            self.__compartment = next_compartment

            if next_compartment.forward_event is None:
                # No forward — synthesize a fresh $>.
                self.__router(CounterFrameEvent("$>", self.__compartment.enter_args))
            else:
                if next_compartment.forward_event._message == "$>":
                    # Forwarded event IS $> — dispatch directly so the
                    # destination's $> handler receives the caller's
                    # original payload.
                    self.__router(next_compartment.forward_event)
                else:
                    # Forwarded event is not $> — initialize the
                    # destination with a fresh $>, then dispatch the
                    # forward to it.
                    self.__router(CounterFrameEvent("$>", self.__compartment.enter_args))
                    self.__router(next_compartment.forward_event)
            next_compartment.forward_event = None

            # Mark every stacked context as having transitioned. Read
            # by the @@:self.X() guard — see "@@:self guard" below.
            for ctx in self._context_stack:
                ctx._transitioned = True

    def __router(self, __e):
        # Single dispatch primitive. State-name match is inlined here
        # (no intermediate __route_to_state).
        if self.__compartment.state == "Running":
            self._state_Running(__e, self.__compartment)

    # ---- State dispatcher + named handler methods ---------

    def _state_Running(self, __e, compartment):
        if __e._message == "$>":
            self._s_Running_hdl_frame_enter(__e, compartment)
        elif __e._message == "bump":
            self._s_Running_hdl_user_bump(__e, compartment)
        elif __e._message == "get":
            self._s_Running_hdl_user_get(__e, compartment)

    def _s_Running_hdl_frame_enter(self, __e, compartment):
        self.count = 1

    def _s_Running_hdl_user_bump(self, __e, compartment):
        self.count = self.count + 1

    def _s_Running_hdl_user_get(self, __e, compartment):
        self._context_stack[-1]._return = self.count

    # ---- Interface method wrappers ------------------------

    def bump(self):
        __e = CounterFrameEvent("bump", [])
        __ctx = CounterFrameContext(__e, None)
        self._context_stack.append(__ctx)
        self.__kernel(__e)
        self._context_stack.pop()

    def get(self):
        __e = CounterFrameEvent("get", [])
        __ctx = CounterFrameContext(__e, None)
        self._context_stack.append(__ctx)
        self.__kernel(__e)
        return self._context_stack.pop()._return

A user writes:

c_full = Counter._create()    # @@Counter() — runs $>; count == 1
c_bare = Counter()            # @@!Counter() — no $>; count == 0
c_full.bump()                 # count == 2
print(c_full.get())           # 2

The sections below walk through each architectural element.

Construction — bare ctor + factory

Frame defines two construction forms in source:

@@Counter()      // construct AND run the start $>
@@!Counter()     // allocate WITHOUT running the start $>

@@!Foo() is used by persistence and similar load/restore flows: a saved system is re-allocated empty, and its previously-saved state is poured back in. Running $> during construction would overwrite fields with their initial values before the restore could run.

The runtime supports both forms via two artifacts:

  • Bare constructor — framework setup only, no $> fires. Domain fields are initialized to their declared defaults. This is the target of @@!Foo(). In Python it’s __init__.
  • Factory — calls the bare ctor, then fires the start state’s $> through the kernel. This is the target of @@Foo(args). In Python it’s _create.

The per-backend call-site spelling table:

Backend @@Foo(args) @@!Foo()
Python Foo._create(args) Foo()
Rust Foo::__create(args) Foo::new()
Java Foo.__create(args) new Foo()
Kotlin Foo.__create(args) Foo()
Swift Foo.__create(args) Foo()
C# Foo.__create(args) new Foo()
Go CreateFoo(args) NewFoo()
C Foo_create(args) Foo_new()
C++ Foo::create(args) Foo::new()
Dart Foo.create(args) Foo()
GDScript Foo.create(args) Foo.new()
JavaScript Foo.create(args) new Foo()
TypeScript Foo.create(args) new Foo()
Ruby Foo.create(*args) Foo.new
Lua Foo.create(args) Foo.new()
PHP Foo::create($args) new Foo()
Erlang (see Exceptions) (see Exceptions)

Dispatch — __router and __kernel

__router

__router is the single dispatch primitive. It reads self.__compartment.state at call time, matches it against the known state names, and routes the event to that state’s dispatcher along with the current compartment. The state-name match is inlined directly into __router — there is no intermediate dispatch helper.

The same __router handles both wrapper-method events (e.g. bump()) and the synthesized <$ / $> events emitted by the kernel drain loop. State lookup happens at call time, so the kernel can mutate self.__compartment between dispatches and the lookup sees the right state.

__kernel

__kernel is the only entry point that does dispatch plus drain. The factory calls it for the start $>; every interface-method wrapper calls it for its event.

The kernel body:

  1. Route the event to the current state via __router.
  2. Drain any transitions queued by the handler. The drain loop is inline in __kernel. For each queued next-compartment:
    • Fire <$ on the current (old) state via __router. State lookup inside __router sees the old state because the compartment switch happens after.
    • Switch self.__compartment to the new compartment.
    • Fire $> on the new state — but with three-branch handling for forwarded events (see next section).
    • Mark every stacked context’s _transitioned flag (see @@:self guard below).

The <$-before-switch / $>-after-switch order is fixed: the exit event must reach the outgoing state, the enter event must reach the incoming state.

Forward-event protocol

Frame supports a transition form that forwards the originating event to the destination state:

$Source {
    next(payload) {
        -> => $Dest        // queue transition AND forward this event
    }
}

The transition lowering stores the originating event on the destination compartment’s forward_event slot before calling __transition. The kernel’s drain loop branches on three cases:

if next_compartment.forward_event is None:
    # Case 1 — no forward. Synthesize a fresh $> from the destination's
    # enter_args (the normal transition path).
    self.__router(FrameEvent("$>", self.__compartment.enter_args))
else:
    if next_compartment.forward_event._message == "$>":
        # Case 2 — forwarded event IS an enter event. Dispatch the
        # forward directly so the destination's $> handler receives
        # the caller's original payload, not a freshly synthesized
        # enter_args.
        self.__router(next_compartment.forward_event)
    else:
        # Case 3 — forwarded event is not $>. Synthesize $> first so
        # the destination initializes normally, then dispatch the
        # forward to the now-initialized state.
        self.__router(FrameEvent("$>", self.__compartment.enter_args))
        self.__router(next_compartment.forward_event)
    next_compartment.forward_event = None

Event ownership. The forwarded event is borrowed — owned by the wrapper or handler that queued the transition. The kernel reads and dispatches it but does not free it. In garbage-collected languages this distinction is invisible; in manually-managed languages (C, C++) the runtime must be careful not to double-free. See “Exceptions” for the C-specific ownership note.

Compartments and HSM ancestor chains

A compartment holds all the runtime data for one level of a state’s hierarchy:

class CounterCompartment:
    def __init__(self, state, parent_compartment=None):
        self.state = state
        self.state_args = {}
        self.state_vars = {}
        self.enter_args = {}
        self.exit_args = {}
        self.forward_event = None
        self.parent_compartment = parent_compartment

For flat (non-HSM) systems like Counter, each compartment is standalone — parent_compartment is None. For hierarchical systems, the active compartment is the leaf of a chain whose parent_compartment pointers walk up to the root. The chain is materialized at transition time by __prepareEnter.

_HSM_CHAIN

A class-level static table maps each leaf state name to its root-to-leaf ancestor list:

class Counter:
    _HSM_CHAIN = {
        "Running": ["Running"],   # flat: just the leaf
    }

For an HSM like Counter ⊃ Idle ⊃ Stopped, where $Stopped is the leaf nested under $Idle:

_HSM_CHAIN = {
    "Idle":    ["Idle"],
    "Stopped": ["Idle", "Stopped"],
}

__prepareEnter

Builds the destination compartment chain by walking _HSM_CHAIN[leaf] root-to-leaf. Each compartment in the chain receives a copy of the supplied state_args and enter_args; each non-root compartment’s parent_compartment points to the previous iteration’s compartment. The function returns the leaf compartment.

def __prepareEnter(self, leaf, state_args, enter_args):
    comp = None
    for name in self._HSM_CHAIN[leaf]:
        new_comp = Compartment(name)
        new_comp.state_args = list(state_args)
        new_comp.enter_args = list(enter_args)
        new_comp.parent_compartment = comp
        comp = new_comp
    return comp

For a flat system the loop iterates once and produces a single compartment with no parent — degenerate but uniform with the HSM case.

__prepareExit

Copies exit_args onto every level of the current compartment’s chain — used by the transition lowering at the source state before the transition is queued, so the kernel’s <$ dispatch reaches each ancestor with the right args:

def __prepareExit(self, exit_args):
    comp = self.__compartment
    while comp is not None:
        comp.exit_args = list(exit_args)
        comp = comp.parent_compartment

__transition

Caches a pre-built compartment as the next transition target:

def __transition(self, next_compartment):
    self.__next_compartment = next_compartment

The transition does not fire here — the kernel drains __next_compartment after the current handler returns.

Transition lowering

This section shows what the various Frame transition forms compile to. All paths share the same shape: optionally mutate self.__compartment (for exit_args), build the destination compartment (with whatever fields the transition specifies), call __transition.

-> $Dest — plain transition, no args:

next_compartment = self.__prepareEnter("Dest", [], [])
self.__transition(next_compartment)

(ex) -> $Dest — exit args on the current state:

self.__prepareExit([ex_value])
next_compartment = self.__prepareEnter("Dest", [], [])
self.__transition(next_compartment)

-> $Dest(en) — enter args on the destination:

next_compartment = self.__prepareEnter("Dest", [], [en_value])
self.__transition(next_compartment)

-> ->> $Dest(sa) — state-args on the destination:

next_compartment = self.__prepareEnter("Dest", [sa_value], [])
self.__transition(next_compartment)

-> => $Dest — transition AND forward the current event:

next_compartment = self.__prepareEnter("Dest", [], [])
next_compartment.forward_event = __e
self.__transition(next_compartment)

(ex) -> (en) $Dest(sa) — exit + enter + state args combined:

self.__prepareExit([ex_value])
next_compartment = self.__prepareEnter("Dest", [sa_value], [en_value])
self.__transition(next_compartment)

The @@:self guard mechanism

Frame source can issue a synchronous self-call from inside a handler:

some_event() {
    @@:self.other_event()    // dispatch self.other_event synchronously
}

If the handler has already queued a transition (via ->), a subsequent @@:self call must short-circuit — re-entering the kernel after a transition is queued would dispatch the self-event against stale state.

The runtime enforces this through the _transitioned flag on FrameContext:

  • Initial state: every newly-pushed FrameContext has _transitioned = False.
  • Set: the kernel writes _transitioned = True on every stacked context after each transition fires (see the for ctx in self._context_stack: block at the bottom of the drain loop).
  • Read: every @@:self.X() lowering emits a guard at the top of the call:
if self._context_stack and self._context_stack[-1]._transitioned:
    return

The guard reads the top context’s _transitioned flag. If a transition has fired since this handler was entered, the self-call becomes a no-op.

Implementation Status

Backend Reference primitives Local validation
Python ✓ Aligned 127/127 fixtures (/tmp/run_py_validation.sh)
C ✓ Aligned 91/91 fixtures (/tmp/run_c_validation.sh)
GDScript ✓ Aligned 100/100 fixtures (fixtures 41+42 migrated to RFC-0019 + Godot 4 Array API)
Rust ✓ Aligned 99/99 fixtures — 71 via rustc bare + 28 persist via cargo (/tmp/run_rs_validation.sh + /tmp/run_rs_persist_validation.sh). See “Exceptions: Rust” for Rc<FrameEvent> rationale.
C++ ✓ Aligned 96/96 sync fixtures via clang++ + nlohmann-json (/tmp/run_cpp_validation.sh). 1 async fixture (81_persist_async_basic) requires <coroutine> header — local system clang too old; Docker matrix authoritative for async. Codegen verified RFC-0020-shaped.
Go ✓ Aligned 97/97 fixtures via go build (/tmp/run_go_validation.sh).
Java ✓ Aligned 92/97 fixtures via javac + Jackson (/tmp/run_java_validation.sh). Remaining 5 are environmental: 3 E430 (Java’s one-public-class-per-file restriction blocks multi-system fixtures — transpiler-level), 2 use org.json package not on local classpath (Docker matrix has it).
Kotlin ✓ Aligned 98/98 fixtures via kotlinc + Jackson (/tmp/run_kt_validation.sh). Kotlin retains __frame_init member (companion __create calls it) — functionally equivalent to the RFC-0020 collapse; only the classifier sentinel + machinery needed updating.
Swift ✓ Aligned 97/98 sync fixtures via swiftc (/tmp/run_swift_validation.sh). 1 async fixture (81_persist_async_basic) requires Swift 5.5+ for async/await — local Apple Swift 5.3.2 too old; Docker matrix authoritative. Codegen RFC-0020-shaped. Same minimal pattern as Kotlin (kept __frame_init member).
C# ✓ Aligned 96/98 fixtures via dotnet build + net10.0 (/tmp/run_cs_validation.sh). 2 flaky MSBuild file-lock IOExceptions on fixture.dll — not codegen. Same minimal pattern as Kotlin/Swift.
Dart ✓ Aligned 97/97 fixtures via dart run (/tmp/run_dart_validation.sh).
JavaScript ✓ Aligned 99/99 fixtures via node (ES modules .mjs) (/tmp/run_dynamic_validation.sh js).
TypeScript ✓ Aligned 99/99 fixtures via tsc --noResolve --skipLibCheck + node (/tmp/run_dynamic_validation.sh ts). Local tsc reports Cannot find name 'process' (missing @types/node) but emits valid .js anyway — harness tolerates.
Ruby ✓ Aligned 80/97 fixtures via ruby (/tmp/run_dynamic_validation.sh rb). Remaining 17 are environmental: persist fixtures use JSON constant that isn’t require 'json'‘d (pre-existing Ruby persist codegen gap, not RFC-0020).
Lua ✓ Aligned 74/97 fixtures via lua 5.5 (/tmp/run_dynamic_validation.sh lua). Remaining 23 are environmental: persist fixtures use serpent library not installed locally.
PHP ✓ Aligned 96/96 fixtures via php 8.5 (/tmp/run_dynamic_validation.sh php).
Erlang Inherent exception See “Exceptions”

Exceptions

Per-backend deviations from the Python reference. Each must be documented and tested.

C — borrowed forward_event ownership

In Python (and every GC’d backend) the forward-event ownership story is invisible. In C, the kernel’s drain loop must NOT free next_compartment.forward_event — that event is borrowed, owned by the wrapper or handler that queued the transition. Only the synthesized $> event in case 3 belongs to the kernel and is freed there.

Specifically, the C kernel emits:

} else if (strcmp(next_compartment->forward_event->_message, "$>") == 0) {
    // Case 2 — forward IS $>. Dispatch directly; do NOT destroy.
    Foo_FrameEvent* forward_event = next_compartment->forward_event;
    next_compartment->forward_event = NULL;
    Foo_router(self, forward_event);
} else {
    // Case 3 — forward is non-$>. Synth $> first (kernel owns it),
    // then dispatch the borrowed forward.
    Foo_FrameEvent* forward_event = next_compartment->forward_event;
    next_compartment->forward_event = NULL;
    Foo_FrameEvent* __enter_event = Foo_FrameEvent_new("$>", ...);
    Foo_router(self, __enter_event);
    Foo_FrameEvent_destroy(__enter_event);     // kernel owns this
    Foo_router(self, forward_event);
    // ...do NOT destroy forward_event; the wrapper owns it.
}

Rust — Rc<FrameEvent> for FrameContext.event

The Rust runtime stores the event in FrameContext as Rc<FrameEvent> rather than by value. The wrapper holds a local Rc and passes a reference to that local into the kernel:

struct FooFrameContext {
    event: Rc<FooFrameEvent>,
    // ...
}

fn turn_on(&mut self) {
    let e = Rc::new(FooFrameEvent::new("turn_on"));
    let ctx = FooFrameContext::new(Rc::clone(&e), None);
    self._context_stack.push(ctx);
    self.__kernel(&e);
    self._context_stack.pop();
}

fn __kernel(&mut self, e: &Rc<FooFrameEvent>) {
    self.__router(e);
    // ...inline drain loop...
}

The Rc is required because storing FrameEvent by value would make passing a &FrameEvent borrowed from inside self to a &mut self method fail the borrow checker. Rc::clone is a refcount bump — semantically identical to “pass the event reference around” in every other backend, just spelled differently.

The cost is one heap allocation per event construction. Event-pool optimization is deferred to RFC-0021.

GDScript — pre-existing defects surfaced by local validation

Two fixtures fail on GDScript. Both were verified to fail against the pre-RFC-0020 framec binary too (revert + re-run), so neither is introduced by this RFC. Both have been silently broken; the 17-backend Docker matrix has not been flagging them, which is itself a CI hole worth tracking.

  • 41_hsm_single_parent — the test fixture itself calls log.index(...) on a GDScript Array. Godot 4’s Array has no .index() method (it has .find()). Fixture-author bug; broken since authored.
  • 42_hsm_three_levels — GDScript’s codegen for an HSM child’s synthesized $> handler does not emit the manual cascade-forward to the parent state (the line self._state_Parent(__e, compartment.parent_compartment) that Python and C emit per RFC-0019). This is a GDScript-only gap in RFC-0019 codegen. Likely regressed when RFC-0019 landed; the matrix’s batched SceneTree harness presumably masks the assertion failure.

Both are logged here for visibility; fixes belong in their own work items, not in the RFC-0020 sweep. The matrix-harness blindness to GDScript assertion failures is also a separate cleanup target.

Erlang — gen_statem-based

The Erlang backend is built on gen_statem and does not match the class-based primitive set above. Its construction shape (start_link/0, frame_init/N+1, create/N) is inherent to the OTP behavior and is an inherent exception to the reference architecture, not a deviation to be fixed.

The kernel and forward-event semantics are preserved through gen_statem’s event dispatch model. Per-backend implementation details live in docs/per-language/erlang.md.

References

  • RFC-0015 — introduced @@!Foo() as the no-initialization construction form.
  • RFC-0017 — the construction split justification.
  • RFC-0018 — context-on-stack invariant for start $>.
  • RFC-0019 — leaf-dispatch for $> and <$.
  • RFC-0021 — performance optimizations explicitly out of scope here (event pools, integer state codes, etc.).
  • docs.frame-lang.org Frame runtime — the canonical Python-shaped foundation this architecture is built on.