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 identifierstate_args— state argumentsstate_vars— state-local variablesenter_args— args supplied by the transition that arrived hereexit_args— args to be passed to the destination’s<$forward_event— set when the transition is-> => $Stateparent_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 (
_createin 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. Readsself.__compartment.stateand routes the event to the matching state dispatcher, passingself.__compartmentalong. 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’sparent_compartmentpointing one level up.__prepareExit(exit_args)— walks upself.__compartment’s parent chain and copiesexit_argsonto 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). Matchesevent._messageand 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>isframefor lifecycle handlers ($>→frame_enter,<$→frame_exit) anduserfor 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, elseNone_context_stack— stack ofFrameContext. 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
FrameContextobjects, one pushed per event-handler entry. EachFrameContextholds 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:
- Route the event to the current state via
__router. - 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__routersees the old state because the compartment switch happens after. - Switch
self.__compartmentto the new compartment. - Fire
$>on the new state — but with three-branch handling for forwarded events (see next section). - Mark every stacked context’s
_transitionedflag (see@@:selfguard below).
- Fire
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
FrameContexthas_transitioned = False. - Set: the kernel writes
_transitioned = Trueon every stacked context after each transition fires (see thefor 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 callslog.index(...)on a GDScriptArray. Godot 4’sArrayhas 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 lineself._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.