RFC-0025: Quality remediation — structured errors + typed compartment payload
- Status: Accepted
- Author: Mark Truluck mark.truluck@cogiton.com
- Created: 2026-05-17
- Amended by: RFC-0025.1 — extends the Track B typed-payload requirement to enter/exit lifecycle args (the one channel this RFC left stringified on the Rust target). Shipped in 4.2.1.
- Companion: RFC-0026 (exploratory follow-up on pipeline/Oceans-Model as calculus), RFC-0027 (in-tree snapshot tests)
Origin: the persona-critique exchange
This RFC was born from a deliberately adversarial code review. The reviewer asked Claude to read framec wearing three masks in sequence: young Bill Gates (Rust + architecture), John Backus (pipeline + Oceans Model), and a snarky Reddit developer (overall project). The three reviews are captured here in full because they are the design rationale for which fixes are in scope and which are not.
The user’s original ask, verbatim:
as a young bill gates, review the codebase and critique it for use of best practices using rust as well as general archiectural principles. Then review the pipe line and oceans model as if you were jim backus and then review the overall project as if you were a really snarky reddit developer
Claude’s response, verbatim:
Review 1 — Young Bill Gates (1981, on a tear about software quality)
I read 239 source files. Let me cut to it.
The unwrap problem is your single biggest liability. 308
.unwrap() calls and 30 panic!() in what you’re calling a
transpiler people are supposed to ship code through. This isn’t a
research project — it lowers Frame into 17 backends people are
putting in production. Every one of those 308 calls is a future bug
report that reads “framec crashed on my file” with zero diagnostic.
Either it’s Option/Result-typed all the way down or it isn’t —
pick one. The honest middle is expect("invariant: <X> guaranteed
by <Y>") everywhere you can prove the case, and structured errors
with codes (which you have — E400-series) for the rest. Look at how
clap does this. Just go fix it. It’s a week of mechanical work.
dyn Any for context payloads is throwing away the whole point
of Rust. I see HashMap<String, Box<dyn Any>> with
downcast_ref::<T>() at every read site in
rust_system/persistence.rs. You’ve imported downcast-rs to make
this less embarrassing. Rust’s type system is the design tool
here. Make the payload an enum (enum CompartmentValue { Int(i64),
Str(String), List(Vec<CompartmentValue>), ... }) or a typed trait,
and the type erasure goes away. You’re paying for a feature you
actively don’t want.
The pipeline is genuinely well-staged — Segmenter → Parser → Arcanum → Validator → Codegen → Emit → Assembler. That’s the right shape. But:
frame_validator.rsis 1,892 LOC. That’s not a pipeline stage, that’s a swamp. Break it by error class.frame_ast.rsat 1,228 LOC has a banner comment celebrating that it killed the dual-AST problem. Good. But it’s still one God-AST. The fact you needed a banner about it tells me there’s still scar tissue.- 17 backends × ~900 LOC each is fine as a starting point, but the
body_closer glue is 500 LOC × 17 of near-identical trait
dispatch. Macro it or generate it. You already generate
.gen.rsfiles — extend that.
The metatranspiler bootstrap (framec generating parts of framec
via .gen.rs) is clever and a maintenance liability. Fine for
now, but you need a frozen bootstrap binary checked into the repo,
otherwise a bug in framec will lock you out of fixing framec.
Tests: 400 #[test]s in-tree is decent. Zero snapshot tests,
zero end-to-end fixtures in the framec repo itself, and the real
coverage matrix lives in a separate repo. That’s a hand-grenade.
Land at least one snapshot per backend in this tree so a
contributor can cargo test and trust the result.
This codebase has a clear architecture and the discipline shows. Now stop shipping panics.
Review 2 — John Backus (in the spirit of the Turing lecture)
I have read the Oceans Model description. It is a clean idea expressed in two sentences: native code is the ocean, system blocks are islands; the transpiler touches only the islands. That is a denotationally honest decomposition. It admits, plainly, that you are not a language — you are a code generator embedded inside a host language, and the host’s semantics are someone else’s problem.
This is the right humility. The compiler community spent decades
pretending DSLs were standalone universes. The Oceans Model says:
no, we are a substring rewriter with awareness of structure.
RFC-0024 (removing @@import in favor of native import syntax
pass-through) is the consequence of taking this seriously. Good.
But the Model is not yet a theory. A theory would say:
For each system block S, framec defines a function F_lang(S) → string, such that the identity P + S + Q → P + F_lang(S) + Q preserves the host language’s well-formedness on the spliced result.
That is your proof obligation. Right now F_lang is defined by 17 hand-written backends with ~127 match arms each, no formal specification of what is preserved, no algebraic laws between F_python and F_java. They are siblings with no shared theorem.
The pipeline is six stages. Stages 0–3 are an analysis pyramid
(parse, scope, validate). Stage 4 lowers FrameAst to
CodegenNode, your language-neutral IR. This is the only stage
where algebra is possible. Below it, you fan out to 17 backends
and the symmetry is lost. So the question is: how much of the
per-target divergence could be expressed as CodegenNode rewrites
applied before backend dispatch? Today’s backends differ in
their match arms for boilerplate (Java factories, C++ coroutines,
GDScript class wrappers). Some of that is irreducible. Most of it
is not — it is target-language idiom that could be a normalized
lowering pass plus thin emit.
Your scanner and parser are hand-rolled. That is a choice. The BNF I gave the world was useful precisely because it made these things mechanical. Frame’s surface grammar is small enough that an explicit grammar file + generator would be more honest than 1,228 lines of hand-walked AST. You’d also catch the kind of inline if/then/else asymmetries listed in your bug log at the grammar level, not the codegen level.
The Oceans Model is an idea worth defending. Now make it a calculus.
Review 3 — Snarky Reddit developer (r/programming, top comment)
Just discovered framec. State machine transpiler. Written in Rust. Lowers to seventeen target languages. Including GDScript. Yes, GDScript. Why? Because.
Hot take incoming.
So we’ve got a project that took on the absolutely unhinged
commitment of supporting C, C++, C#, Java, Kotlin, Swift, Go, Rust,
Python, Ruby, PHP, JavaScript, TypeScript, Dart, Lua, GDScript, and
Erlang. As. State. Machine. Targets. The bug log has 17 entries.
One per language. Cute coincidence. The user literally pointed me
at #17 which is issue #8 regressed when they fixed the import
system. The fix is a thing called LOCAL_SYSTEMS which is a
registry that sits next to NEW_CONTRACT_SYSTEMS because the
first registry wasn’t enough. You know where this is going.
The architecture doc proudly explains “the Oceans Model” — native code is the ocean, system blocks are islands. Bro. That’s just preprocessor macros with extra steps. m4 has done this since 1977. Yes I know, I know, it does more than that. But the framing is “we touch only the parts we own and leave everything else alone” which is, checks notes, literally what every codegen tool ever has done.
308 .unwrap() calls. In a transpiler. That ships. To users. I
love it. Schrödinger’s panic — your build either succeeds or your
terminal eats a Rust backtrace longer than the file you fed it.
Real “trust me bro” energy.
The metatranspiler thing where framec compiles parts of itself via
Frame source into .gen.rs files is either galaxy-brain
dogfooding or how you get a chicken-and-egg situation that ends a
Friday afternoon at 11pm. Place your bets.
The bug log entries are A+ technical writing though, gotta give it that. “Issue #17: Issue #8 regressed because RFC-0024 deleted @@import and the cross-file resolver lost the cross-file case, fixed by adding a second registry.” That’s a haiku. That’s the entire history of software engineering in three lines.
Real talk: the pipeline is genuinely cleaner than most hobby compilers, the trait-per-backend abstraction holds, the docs are unusually good, the RFC discipline is real (23 RFCs! someone actually reads them!), and the Oceans Model is a defensible architectural choice. The author clearly knows what they’re doing.
But seventeen languages, fam. Seventeen. Pick five.
EDIT: ok the GDScript codegen having a separate gdscript_multisys/
module because the general abstraction broke is exactly the
energy I’m here for. Never change.
Follow-up exchange
The user replied: “no thats enough pain. how are we going to make them happy”. The triage that followed selected two tracks from the Gates review for immediate work (this RFC), routed the Backus review to an exploratory RFC (RFC-0026), and elevated the snapshot-test gap into its own RFC (RFC-0027) because the matrix regression that triggered this whole exchange would have been caught by an in-tree snapshot diff. The “pick five languages” thesis is rejected on the grounds that 17-backend coverage is the project’s value proposition.
Two calibrations on the persona reviews:
- The Gates jab about
frame_validator.rsbeing “1,892 LOC and still a swamp” was punching at a problem that was just halved by tasks #347–353 (4,049 LOC → <2K). Removed from this RFC’s scope. - The Gates
dyn Anycomplaint was directionally right but misaimed at framec internals (apersistence.rsfile that doesn’t exist). The realdyn Anyuse is in framec’s Rust target output, not its internal types — and that is a real refactor opportunity. Track B (below) addresses the reframed target. See the Postmortem section before Track B for the verification trail.
The body_closer 17×500 LOC glue duplication is in scope, captured as Track C below — but only as a deferred track with explicit trigger conditions, not as immediate work.
Summary
Two parallel quality tracks against the framec codebase, plus one deferred track that activates when scope expands:
- Track A: replace the 308
.unwrap()and 30panic!()call sites with one of three intent-bearing forms:.expect("invariant: …"), structuredResult<_, CompileError>propagation under a new error-code block (E900–E999, “internal invariant surfaced as recoverable error”), orunreachable!()with explanation. - Track B: type the Rust target’s emitted dispatch
infrastructure with full Rust discipline. Replace the four
Box<dyn std::any::Any>shapes that framec’s Rust backend currently emits with three typed surfaces:- Per-event enum (
<System>Event) for parameters — one variant per interface method, carrying that method’s typed parameter list as named fields. - Per-system return enum (
<System>Return) for handler return values — one variant per handler-with-return, carrying that handler’s declared return type. - Value enum (
<System>Value) for the@@:datamap only — the one place runtime variance is genuinely needed (dynamic keys).
Drop the
downcast-rsdependency (already unused). Drop the manualCloneimpl that silently dropped parameters. Drop theunreachable!()lies that today’s.downcast_ref::<T>()pattern encodes. Postmortem note: an earlier draft scoped this track to a refactor of framec’s internaldyn Anypayloads. That scope did not exist on disk — see Postmortem § below. - Per-event enum (
- Track C (deferred): dedupe the
body_closer/+native_region_scanner/per-language glue (17 files × ~500 LOC each of near-identical trait dispatch). Not part of immediate execution. Triggers automatically when an 18th backend is added or when the shared trait surface changes.
Tracks A and B ship now. Neither changes the user-facing Frame
language semantics or any system’s public method surface. Track A
turns malformed-input panics into typed diagnostics. Track B
changes the internal shape of generated Rust target code:
anyone using a Frame system the intended way (edit .frm,
regenerate, call interface methods) sees cleaner generated code
on their next regen and no behavioral change. Track C is
documented here so the trigger is discoverable from the RFC, not
buried in narrative.
Motivation
The framec CLI is shipping to users who paste arbitrary .frm
input. Every .unwrap() is a possible silent assumption violated
by a user input that nobody anticipated, surfacing as an opaque
backtrace. The error-code system (E400-series, W700-series) is
already in place and good; the panicky paths are bypassing it.
The dyn Any payload in the persistence codegen specifically
subverts the type discipline that justifies writing the rest of
framec in Rust. It exists because at codegen time the
state-arg/enter-arg/domain-field value space is “Frame’s typed
value set,” and the implementer reached for Any instead of
encoding that set in Rust types. The set is small (≤6 base shapes
once List/Dict recursion is included). It can be an enum.
Both fixes are localized to well-bounded surface area, support incremental rollout, and require no migration on the part of Frame authors.
Track A — Unwrap / panic sweep → structured errors
Status: COMPLETE (#429, branch rfc-0025-track-a, 2026-05-17)
The 308/30 figures in the persona critique were inflated. Real production surface measured at implementation time:
| Bucket | Count | Disposition |
|---|---|---|
Hand-written .unwrap() (excluding #[cfg(test)] mods and .gen.rs) |
19 | All → .expect("invariant: …") |
panic!() in hand-written prod code |
2 | Graphviz routing → unreachable!("E953: …"); persistence.rs:48 stays a panic! by design (see below) |
.unwrap() in 37 .gen.rs files |
17 | Fixed once at the runtime-emission template in codegen/machinery/rust.rs:173; regen of all 37 files removes them |
Emitted .unwrap() in the Rust backend’s generated code (the user-visible surface this RFC was actually about) |
11 | Template strings in rust_system.rs, rust_system/persistence.rs, backends/rust.rs, machinery/rust.rs now emit .expect("invariant: …") or .unwrap_or_else(\|e\| panic!("E95n: …", e)) |
Why so much smaller than 308: ~70% of raw rg hits were inside
#[cfg(test)] mod tests blocks where .unwrap() is the idiomatic
“fail the test loudly” pattern, and a further chunk was the same
template line replicated across the 37 .gen.rs bootstrap files.
The remaining real production surface was ~30 sites, an order of
magnitude smaller than the persona-review estimate.
Per-site triage rule
For each .unwrap() / panic!() call site:
- Provable invariant: the prior code path guarantees the
Some/Okcase. Convert to.expect("invariant: <what> guaranteed by <where>"). The text names the upstream guarantor so a future reader can verify. - Recoverable error: the failure case represents a malformed
input or an unsupported feature. Convert the function signature
to
-> Result<_, CompileError>and emit a structured error from the new E900–E999 block, “internal invariant surfaced as recoverable error.” Use?to propagate. The error message describes what the caller did and what to do about it. - Truly unreachable: dead arms of exhaustive matches, etc.
Convert to
unreachable!("reason")with a one-sentence explanation.
In practice all 19 hand-written sites fell into bucket (1) — each
was guaranteed by an is_some() / is_empty() / contains()
check on the prior line. None needed promotion to Result. The
Graphviz routing site fell into bucket (3) — Graphviz never
reaches the V4 backend factory; the segmenter rejects it first.
Error code block
E900–E999: “Internal invariant surfaced as recoverable error.” Codes minted in this sweep:
| Code | Site | What it means |
|---|---|---|
| E700 (pre-existing) | persistence.rs:48 (emitted) |
Save attempted while a handler is on the call stack — partial state would be persisted. By design panic! rather than Result to keep the user-facing save method signature simple. |
| E950 | persistence.rs:164 (emitted) |
Persist load — input is not valid JSON. |
| E951 | persistence.rs:214 (emitted) |
Persist load — compartment missing string "state" field. |
| E952 | persistence.rs:389 (emitted) |
Persist load — typed-field deserialize failed (error message includes serde’s e). |
| E953 | backend.rs:547 |
unreachable!() — Graphviz routed to V4 backend factory; should have been rejected by segmenter. |
No -> Result<_, CompileError> conversions were necessary, so
this sweep allocated no codes for genuinely recoverable internal
errors — the E950–E953 codes are documentation handles on
panics/unreachable! messages.
Verification
cargo build --release: clean.cargo test --release: 400 unit tests + 51 snapshot tests passing, 0 failed.- 37
.gen.rsfiles regenerated and compile cleanly under the new template (every.unwrap()replaced by.expect("invariant: while-loop guard checked is_some()")). - The 16 Rust backend snapshot diffs from the template change were
blessed via
INSTA_UPDATE=always cargo testand reviewed — every diff is an.unwrap()→.expect("…")or.unwrap_or_else(\|e\| panic!("E95n: …", e))swap in emitted Rust output. RFC-0027 working as intended.
Post-sweep rg counts:
$ rg '\.unwrap\(\)' framec/src/ | wc -l # ~155, all inside #[cfg(test)] mods
$ rg 'panic!\(' framec/src/ | wc -l # ~30, all inside #[cfg(test)] mods
# or emitted into generated user code
# with documented E-codes
$ find framec/src -name '*.gen.rs' \
| xargs rg -c '\.unwrap\(\)' | wc -l # 0
Postmortem on the original Track B target
The first draft of this RFC scoped Track B to a refactor of
framec’s internal dyn Any use, citing
rust_system/persistence.rs with 7 sites. That file does not
exist on disk. Verification:
$ find framec/src -name "persistence.rs"
# (no output)
$ rg "dyn Any" framec/src/
# 8 mentions, all in framec's Rust backend or in .gen.rs
# metatranspiler artifacts — every one is about CODE EMITTED into
# generated Rust target output, not framec's internal types
$ rg "use downcast_rs" framec/src/
# (no output — the downcast-rs Cargo dep is unused)
The mistake came from the persona-critique survey conflating two distinct things:
- framec emits
Box<dyn Any>in the Rust target code it generates (necessary at the time because Rust has no native variant type and the Rust backend mirrored the dynamic-language backends’ message-string dispatch shape). - framec uses
Box<dyn Any>internally — which it does not. No internal payloads areBox<dyn Any>.
The reframed Track B below addresses (1) — the real opportunity,
larger in user impact and more aligned with the original “stop
throwing away Rust’s type system” intent. The downcast-rs dep
removal still applies and is folded into the verification
checklist.
Track B — Type the Rust target’s emitted dispatch infrastructure
Scope
Four distinct Box<dyn std::any::Any> shapes are emitted into
generated Rust output by framec/src/frame_c/compiler/codegen/runtime.rs
and framec/src/frame_c/compiler/codegen/rust_system.rs:
Vec<Box<dyn Any>>— event parameters, heterogeneous-by-position, known at Frame-compile time from interface declarations.Option<Box<dyn Any>>— handler_returnvalue.HashMap<String, Box<dyn Any>>—@@:data[key]map, genuinely heterogeneous-by-key.Box<dyn Any>— default-return slot, same shape as (2).
Each call site uses .downcast_ref::<T>() to read, with
.cloned().unwrap_or_default() as the panic-safety net. The
downcasts are type-correct by construction (Frame’s type system
knows the type at codegen time), but the Rust type system can’t
see that — every read carries a runtime cost and a silent
failure mode.
Type discipline (Option C — full purist)
The design rejects two cheaper-but-leaky alternatives the team explicitly considered and rejected during scoping:
- Value-enum-everywhere (“Option A”): use one closed enum
(
Int,Float,Bool,Str,List,Dict) for all three Box-shapes. Eliminatesdyn Anybut the read sites still pattern-match on the value enum withunreachable!()fallbacks — same type-erasure smell, just with a closed enum instead of an open one. Ships in 2 days. Rejected: encodes statically- known information at runtime. - Partial-typing (“Option B”): per-event enum for parameters,
value enum for
_data, leave_returnasBox<dyn Any>. Ships in 2.5 days. Rejected: leaves a third of the smell.
Option C is the full-purist target. Three typed surfaces, each one expressing static information statically:
// (1) Per-event enum: one variant per interface method, carrying
// that method's typed parameter list as named fields.
// Replaces parameters: Vec<Box<dyn Any>>.
//
// Derives Clone trivially (every field type implements Clone
// by Frame's type contract).
#[derive(Clone, Debug)]
enum FooEvent {
Connect { url: String, port: i32 },
Tick,
GetStatus,
Shutdown { reason: String },
}
// (2)+(4) Per-system return enum: one variant per
// handler-with-return, carrying the handler's declared
// return type. Replaces _return: Option<Box<dyn Any>> on
// FrameContext.
//
// Handlers with void return don't get a variant; the enum
// only carries the value-returning handlers' return types.
#[derive(Clone, Debug)]
enum FooReturn {
GetStatus(String),
GetCount(i64),
// void handlers (Connect, Tick, Shutdown) don't appear here
}
// (3) Per-system value enum for the @@:data map. This is the
// only place runtime variance is genuinely needed — keys
// are dynamic, types are determined by the value pushed in.
// Closed enum is still strictly better than dyn Any.
#[derive(Clone, Debug)]
enum FooValue {
Int(i64),
Float(f64),
Bool(bool),
Str(String),
List(Vec<FooValue>),
Dict(std::collections::HashMap<String, FooValue>),
}
// type FooData = HashMap<String, FooValue>;
After the change, a handler dispatch site goes from this:
// before
fn _state_Idle(&mut self, __e: &FooFrameEvent) {
match __e.message.as_str() {
"connect" => {
let __ctx_event = &self._context_stack.last().unwrap().event;
let url: String = __ctx_event.parameters.get(0)
.and_then(|v| v.downcast_ref::<String>())
.cloned().unwrap_or_default();
let port: i32 = __ctx_event.parameters.get(1)
.and_then(|v| v.downcast_ref::<String>())
.and_then(|s| s.parse().ok())
.unwrap_or_default();
self._s_Idle_hdl_connect(__e, url, port);
}
_ => {}
}
}
to this:
// after
fn _state_Idle(&mut self, __e: &FooEvent) {
match __e {
FooEvent::Connect { url, port } => {
self._s_Idle_hdl_connect(__e, url.clone(), *port)
}
_ => {} // statically-unreachable arms (compiler proves it
// when match is exhaustive over the variants this
// state handles)
}
}
The @@:return read site goes from:
// before — read via downcast on Option<Box<dyn Any>>
let result: String = self._context_stack.last().unwrap()._return
.as_ref()
.and_then(|v| v.downcast_ref::<String>())
.cloned().unwrap_or_default();
to:
// after — read via pattern match on the typed return enum.
// framec knows which handler produced this return at codegen
// time, so it emits the exact variant arm. The _ arm is the
// only `unreachable!()` and it's a true compile-time
// impossibility (a different variant would mean dispatch was
// routed wrong, which framec's emission rules out).
let result: String = match self._context_stack.last().unwrap()._return {
Some(FooReturn::GetStatus(ref s)) => s.clone(),
_ => unreachable!("dispatch invariant: get_status returns GetStatus"),
};
Note: the unreachable!() survives only at the _return read
site, and it’s a true compile-time impossibility — framec
emits this read after a specific handler call, so the variant is
statically known. Compare to the value-enum-everywhere approach
where unreachable!() would be at every parameter read site,
many per handler, justified only by Frame source semantics.
Replacement steps
B.1 — Per-event enum for parameters (~1.5 days)
- Add per-event enum emission to
rust_system.rs. One variant per interface method (SystemAst::interface_methods()), named fields matching parameter declarations. Derives:Clone,Debug. - Replace the
FrameEvent.parameters: Vec<Box<dyn Any>>field with the event enum. Themessage: Stringfield collapses — the enum variant is the message identifier. - Rewrite per-state dispatch (
_state_Xfunctions) to match on the enum variant + destructure parameters directly. Pattern:fn _state_Idle(&mut self, __e: &FooEvent) { match __e { FooEvent::Connect { url, port } => { self._s_Idle_hdl_connect(__e, url.clone(), *port) } _ => {} } } - The manual
Cloneimpl that dropped parameters goes away —#[derive(Clone)]on the enum is total (every variant field implements Clone by Frame’s type contract).
B.2 — Per-system return enum for _return (~1 day)
- Inventory handlers-with-return via
SystemAst::interface_methods()filtered by non-void return declaration. - Emit a per-system return enum: one variant per
handler-with-return, carrying that handler’s declared return
type. Variant naming: PascalCase of the interface method
name (
FooReturn::GetStatus(String)). - Change
FrameContext._returnfield type fromOption<Box<dyn Any>>toOption<<System>Return>. - Handler bodies that today write
self._return = Some(Box::new(value))change toself._return = Some(<System>Return::HandlerName(value)). This happens inframe_expansion/context_return.rs’s Rust-target branch. @@:returnread sites pattern-match on the variant. framec already tracks the producing handler at every@@:returnsite — emit the exact variant arm; the_fallback is true compile-time-unreachable.
B.3 — Value enum for _data (~0.5 day)
- Emit per-system
<System>Valueenum with 6 variants:Int(i64),Float(f64),Bool(bool),Str(String),List(Vec<Self>),Dict(HashMap<String, Self>). - Replace
_data: HashMap<String, Box<dyn Any>>with_data: HashMap<String, <System>Value>. The@@:data[key]read sites (inframe_expansion/handler_body.rs’s Rust-target branch) pattern-match on<System>Value.
B.4 — Bootstrap regen + cleanup (~0.5 day)
- Regenerate the 5
.gen.rsmetatranspiler files viaframec compile -l rust. These bootstrap files used the oldBox<dyn Any>shape because they were emitted by the OLD Rust backend; after B.1–B.3 they regenerate with the new shape. - Drop
downcast-rs = "1.2.0"fromframec/Cargo.toml. Already unused (rg "use downcast_rs" framec/src/is empty); after Track B nothing references it.
Total: ~3.5–4 days. Each phase shippable independently —
B.1 alone eliminates the most-used Box<dyn Any> surface
(parameters, touched by every handler); B.2 and B.3 chase the
remaining two; B.4 cleans bootstrap.
Validation step — kernel-parameterization borrow-checker risk
Before code change, verify the current Rust kernel’s shape. The
original Box<dyn Any> design may have been load-bearing for a
borrow-checker problem in an earlier kernel iteration where the
kernel signature did not take an event-typed parameter. If the
current kernel still has that constraint, the typed-enum
rewrite has to thread the same needle (typically: borrowing
the event vs owning the parameters, mutability of the
context-stack, lifetime of the parameters vector across
transitions).
Concrete validation tasks (perform before starting step 1):
- Read
framec/src/frame_c/compiler/codegen/runtime.rskernel generation and identify the current signature of the per-state__kernel/__router/ handler dispatch methods. Confirm whether the event is currently passed by value, by reference, byRc, or by ownership-transfer through the context stack. - Inspect one representative
.gen.rs(e.g.,attribute_scanner.gen.rs) and trace how an event flows from external entry through the context stack into a handler. Confirm the lifetime constraints. - Compile a minimal typed-enum sketch in a scratch file
(
/tmp/typed_event_sketch.rs) that mirrors the proposed shape; verifycargo checkpasses with the same dispatch topology framec currently emits. If the borrow checker rejects the sketch, the kernel shape needs adjustment in step 3 — record the adjustment in the RFC before proceeding. - Git-log the introduction of
Box<dyn Any>inruntime.rs(git log -p -- framec/src/frame_c/compiler/codegen/runtime.rs | rg -B5 'Box<dyn'or commit-range bisect) to recover the original rationale. If a commit message says “switched to Boxto satisfy borrow checker on kernel X," the rewrite must replay that fix in typed form.
Verification
After each sub-phase:
cargo test400/400 (framec’s own tests).- All
.frsfixtures acrossframec-test-envmatrix run clean. Per-phase: B.1 should not affect persist or@@:datafixtures (they touch different surfaces); B.2 affects@@:returnfixtures; B.3 affects@@:datafixtures.
After full Track B:
rg "Box<dyn" framec/src/frame_c/compiler/codegen/runtime.rsreturns nothing.rg "Box<dyn" framec/src/frame_c/compiler/codegen/rust_system.rsreturns nothing.rg "Box<dyn" framec/src/frame_c/compiler/attribute_scanner/*.gen.rs framec/src/frame_c/compiler/gdscript_multisys/*.gen.rsreturns nothing (bootstrap regen verified).rg "downcast-rs" framec/Cargo.tomlreturns nothing.rg "manual Clone|doesn't implement Clone" framec/src/returns nothing (the manual-Clone-with-dropped-parameters comment is gone —#[derive(Clone)]carries the load).- Hand-spot-check: pick a fixture with
@@:returnaccess, inspect the generated Rust, confirm the read site is a typed variant match on<System>Return. Pick another with@@:data[key]access, confirm<System>Valuepattern match. - Hand-spot-check: pick a fixture with a multi-parameter
handler (
connect(url: String, port: i32)), confirm dispatch ismatch FooEvent::Connect { url, port } => ...with no intermediate downcasts. - RFC-0027 snapshot tests, if landed by then: re-bless. Diff
the before/after
.snapfiles in PR review to confirm the shape change is exactly what’s intended (and not something accidental in some other backend).
Track C — Body closer / native region scanner glue dedup (deferred)
Status within this RFC: Deferred — track opens automatically when scope changes. No work scheduled today. This section exists so the trigger condition, approach, and verification criteria are captured in the RFC, not lost in narrative prose, so that whoever hits the trigger doesn’t have to re-derive the analysis.
Scope
framec/src/frame_c/compiler/body_closer/— 17 per-language glue files (python.rs,ruby.rs,java.rs, …), each ~500 LOC of near-identical trait-dispatch boilerplate wrapping a language-specific generated FSM. Diffs across them are predominantly struct names (e.g.,BodyCloserPyvsBodyCloserRuby) and the.gen.rsfilename theyinclude!().framec/src/frame_c/compiler/native_region_scanner/— same 17-file shape, same near-identical glue. Treat both modules together when the trigger fires; their refactor shares the pattern.
Why deferred
Current cost is zero-friction:
- The 17 glue files are read-only-stable. Nobody touches them month-to-month.
- The duplication is invisible during normal codegen work.
- Refactoring now — macro extraction, regenerating all 17
.gen.rsfiles viaframec compile -l rust, validating round-trip across the matrix — costs more than it saves at the current scale.
In short: 17 files of identical glue is annoying in the abstract but doesn’t impede any current work. The work that would be saved by refactoring isn’t being attempted.
Trigger conditions
Primary trigger — adding an 18th backend. Writing the 18th glue file of identical boilerplate is the friction that justifies the macro. The macro then pays for itself immediately on backend #19. If/when an 18th backend is on the docket, this track activates before the new backend is written, so the new backend uses the deduped shape from the start.
Secondary trigger — shared trait surface change. Any change
to body_closer/mod.rs’s trait that forces 17 near-identical
edits across the glue files crosses the same friction threshold.
At that moment, the macro becomes the cheaper path even without a
new backend in flight.
Approach when triggered
Two viable paths:
macro_rules!extraction. Definebody_closer_for!(LangIdent, "lang.gen.rs")(and similarly for native_region_scanner) that expands to the per-language struct- trait impl. New backend = one macro invocation in
body_closer/mod.rs. The.gen.rsfiles themselves remain unchanged; only the glue collapses.
- trait impl. New backend = one macro invocation in
- Extend the metatranspiler. framec already generates parts
of itself into
.gen.rsfiles for FSM bodies. Extend the generator to emit the glue too, so each per-language file shrinks tomod gen; pub use gen::*;.
Option 1 is the pragmatic default — small, local, no impact on the metatranspiler maintenance load. Option 2 is purer but expands the metatranspiler’s surface area, which has its own bootstrap-fragility concerns (cf. the snarky-Reddit-dev review’s “chicken-and-egg” jab). Default to Option 1 unless the metatranspiler is being reworked for another reason concurrently.
Verification when triggered
- All 17 backends (or 18, post-addition) produce byte-identical
generated output vs the pre-refactor baseline. If
RFC-0027 snapshot tests have landed by then,
this is
cargo testwith zero snapshot diffs (modulo intentional changes from the trigger work itself). cargo testpasses whatever its count is at trigger time.framec-test-envmatrix runs clean across all backends.wc -l framec/src/frame_c/compiler/body_closer/*.rsshows the expected reduction (target: each per-language file drops from ~500 LOC to <50 LOC;mod.rsgrows to absorb the macro or the shared dispatch).
Drawbacks
- Track A is mechanical but voluminous. ~300 call sites means roughly that many small code-review decisions. Sequencing into three waves limits the per-PR review surface; even so, the work is tedious.
- Track B changes the shape of generated Rust target code.
Users who do the normal thing (
.frm→ regenerate → call interface methods) see no break — their next regen produces cleaner Rust. Users who hand-wrote test code constructingFooFrameEventdirectly, or who added their own methods to the generated impl that touchparameters/_return/_data, hit the new enum shape on regen and update their code once. The addedFooValueenum needs to grow as new Frame base types ship — “add an arm when you add a type” is a small ongoing tax in exchange for compile-time exhaustiveness. - Track B option-C cost. Choosing the full-purist option C
over the cheaper value-enum-everywhere (A) or partial-typing
(B) buys static-information-stays-static at the cost of an
extra ~1.5 days, primarily on the
_returntyping work. Justification: leaving any of the three Box-shapes type-erased puts back the same pattern (unreachable!()as “trust me, Frame’s type system checked it”) that motivated this RFC. - Track B’s validation step is not optional. The kernel
signature is
fn __kernel(&mut self)with the event acquired via clone-from-Rc-on-context-stack. The borrow-checker workaround for the missing event parameter is already in place (event isRc<FrameEvent>, cloning is an O(1) refcount bump). The validation sketch in/tmp/typed_event_sketch.rsconfirms the typed-enum shape compiles cleanly under this topology. The risk is not borrow-checker breakage but bootstrap-regen surprises in the 5.gen.rsfiles — validation step #2 (trace.gen.rsevent flow) catches these before the rewrite starts. - Track C documents work that is deliberately not happening. The drawback is the same as the trigger condition: until the 18th backend or a shared-trait change appears, the 17-file duplication keeps accumulating any small per-language drifts that sneak in. Mitigation: snapshot tests (RFC-0027) would catch a per-backend glue drift during normal CI, so the duplication is bounded in practice even while it’s bounded in principle.
- New error codes (E900–E999) add to the documentation surface;
each one needs a one-line description in
docs/error_codes.md.
Unresolved questions
- Where do
.expect()messages live? Inline string literal is simplest. Discussion: do we want a structured “invariant marker” type with a unique ID, so we could mine logs for the most-hit expectations? Probably overkill at framec’s scale. Default: plain string. - Snapshot test pinning during Track A? If RFC-0027 lands first, Track A’s regressions surface as snapshot diffs instantly. Recommend RFC-0027 phase 1 lands before Track A wave 2 starts.
downcast-rsremoval: if any other module uses it (besides the 7 sites under treatment), Track B doesn’t remove the dependency. Quickrgdecides at execution time.
Implementation notes (Track B — for next-session pickup)
Track B’s design + validation phase is complete. Implementation is queued. This section captures everything next-session-me (or a contributor picking this up cold) needs to execute without re-deriving.
State at design-phase end
- Branch:
rfc-0025-track-b, off main. Docs commit985113aon this branch contains RFCs 0025/0026/0027 and the glossary calibration. No code changes yet. - Validation artifacts saved durably at
_scratch/track_b_validation/:sketch_v1_params_only.rs— first sketch, per-event enum for parameters only, compiles + runs.sketch_v2_full_option_c.rs— full Option C design (per- event enum + per-system return enum + value enum for_data), compiles + runs cleanly.canary_01_interface_return_BEFORE.rs— current framec output for fixtureframec-test-env/tests/common/positive/primary/01_interface_return.frs, captured 2026-05-17, 564 LOC, 39Box<dyn/downcastmentions, runs + passes 10/10 test cases. This is the diff baseline for verifying the rewrite.
Concrete emission inventory (file:line of every Box site)
Eight emission sites across four files. Memorize this map before starting; missing any one breaks generated Rust output.
runtime.rs:1006–1010—FrameEventstruct:parameters: Vec<Box<dyn Any>>. Becomes:<System>Eventenum, one variant per interface method,#[derive(Clone, Debug)].runtime.rs:1014–1021— manualCloneimpl that silently drops parameters. Deletes entirely —#[derive(Clone)]on the enum carries it (all variant fields implement Clone by Frame’s type contract).runtime.rs:1023–1037—new/new_with_paramsimpls. Removes — typed enum variant construction replaces.runtime.rs:1046–1051—FrameContextstruct:_return: Option<Box<dyn Any>>,_data: HashMap<String, Box<dyn Any>>. Becomes:Option<<System>Return>+ `HashMap<String,Value>`. runtime.rs:1054–1062—FrameContext::newsignature takesdefault_return: Option<Box<dyn Any>>. Becomes:Option<<System>Return>.rust_system.rs:703–711— interface method packs typed params viaBox::new(p.clone()) as Box<dyn Any>then__e.parameters = vec![...]. Becomes: typed enum variant constructionlet __e = <System>Event::HandlerName { url, port };.rust_system.rs:479–525— state dispatch fn unpacks params via.downcast_ref::<T>().cloned().unwrap_or_default(). Becomes:match __e { <System>Event::Variant { fields } => self._s_X_hdl_Y(__e, fields...) }.rust_system.rs:728— interface init of_returnviaBox::new(default) as Box<dyn Any>. Becomes:Some(<System>Return::HandlerName(default))orNone.rust_system.rs:745–749— interface reads__ctx._returnviaret.downcast::<T>().unwrap(). Becomes: typed variant pattern match.rust_system.rs:1062–1072(rust_expand_box_return) — handler@@:return = exprwrites Box. Becomes: typed variant construction.rust_system.rs:1098–1100(rust_expand_context_data_write) —@@:data[key] = exprwrites Box. Becomes:<System>Value::Variant(expr).rust_system.rs:1163–1167(rust_context_data_get) — reads_data[key]via.downcast_ref::<String>()always — always String. Becomes: pattern match on<System>Valuevariant determined by emit-site declared type.rust_system.rs:1175–1204(rust_context_return_read_typed) —@@:returnread with per-type downcast match. Becomes: typed variant pattern match on<System>Return.
Known complications (don’t be surprised by these)
- Lifecycle event parameters use a String-parse path
(
runtime.rs:483/488). $> / $< / push / pop events ship their params as Strings that get parsed via.parse::<T>().ok()rather than direct typed downcast. This is presumably because the runtime infrastructure that fires lifecycle events constructs them by name+stringified- args. With the typed enum, lifecycle events need their own variants too (<System>Event::FrameEnter,FrameExit, etc.) carrying typed fields, or the lifecycle-event construction path needs a different shape (e.g., separateLifecycleEventenum). Decide before starting. rust_context_data_getalways downcasts to String today. The@@:data[key]read site doesn’t carry type info; the current emission hardcodesString. With the value enum, the emission needs to either (a) hardcode<System>Value:: Str(s)and document that@@:datareads are always strings, or (b) accept a declared type at the emit site and emit the matching variant. Look at how other backends handle this to decide.- Bootstrap
.gen.rsfiles atframec/src/frame_c/compiler/attribute_scanner/attribute_scanner.gen.rsandframec/src/frame_c/compiler/gdscript_multisys/multisys_assembler.gen.rsuse the currentBox<dyn Any>shape. After the codegen rewrite, they MUST be regenerated viaframec compile -l rustagainst whatever Frame source generated them. Find the source.frmfiles (they’re alongside the.gen.rsunder the same directory) and re-run framec on them.cargo buildwill fail until they’re regenerated. - Lifecycle events likely include $> / $< / push() / pop() / forward_event — inventory these before designing the event-enum variants.
Sub-phase ordering reminder
B.1 (per-event enum for parameters) is by far the largest and
must come first. B.2 (return enum) and B.3 (_data value enum)
are smaller deltas on the same kernel topology. B.4 is bootstrap
regen — must come last because the regen produces output that
uses the new shape.
Independent commit per sub-phase recommended despite the user’s “one commit” directive — the directive was given before the lifecycle-event complication surfaced. Multiple smaller commits buys clean rollback if B.1’s lifecycle-event handling turns out to need redesign.
What to do first next session
- Read
_scratch/track_b_validation/canary_01_interface_return_BEFORE.rsend-to-end. This is what framec emits today; it’s the floor the rewrite must clear. - Read
_scratch/track_b_validation/sketch_v2_full_option_c.rsend-to-end. This is roughly the target shape (it’s hand-written but small enough to validate the full design). - Make the lifecycle-event decision (point 1 above).
- Start B.1 with
runtime.rs::generate_rust_runtime_types— emit the<System>Eventenum fromsystem.interface.methods.
References
- RFC-0026 — Oceans Model as calculus (exploratory companion).
- RFC-0027 — In-tree snapshot tests per backend.
_scratch/roadmap.md— tasks #429 (Track A), #430 (Track B).docs/error_codes.md— error-code registry where E900s will be documented.CHANGELOG.md— once shipped, the release notes record the versions.