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.rs is 1,892 LOC. That’s not a pipeline stage, that’s a swamp. Break it by error class.
  • frame_ast.rs at 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.rs files — 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:

  1. The Gates jab about frame_validator.rs being “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.
  2. The Gates dyn Any complaint was directionally right but misaimed at framec internals (a persistence.rs file that doesn’t exist). The real dyn Any use 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 30 panic!() call sites with one of three intent-bearing forms: .expect("invariant: …"), structured Result<_, CompileError> propagation under a new error-code block (E900–E999, “internal invariant surfaced as recoverable error”), or unreachable!() 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 @@:data map only — the one place runtime variance is genuinely needed (dynamic keys).

    Drop the downcast-rs dependency (already unused). Drop the manual Clone impl that silently dropped parameters. Drop the unreachable!() lies that today’s .downcast_ref::<T>() pattern encodes. Postmortem note: an earlier draft scoped this track to a refactor of framec’s internal dyn Any payloads. That scope did not exist on disk — see Postmortem § below.

  • 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:

  1. Provable invariant: the prior code path guarantees the Some / Ok case. Convert to .expect("invariant: <what> guaranteed by <where>"). The text names the upstream guarantor so a future reader can verify.
  2. 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.
  3. 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.rs files 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 test and 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:

  1. 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).
  2. framec uses Box<dyn Any> internallywhich it does not. No internal payloads are Box<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:

  1. Vec<Box<dyn Any>> — event parameters, heterogeneous-by-position, known at Frame-compile time from interface declarations.
  2. Option<Box<dyn Any>> — handler _return value.
  3. HashMap<String, Box<dyn Any>>@@:data[key] map, genuinely heterogeneous-by-key.
  4. 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. Eliminates dyn Any but the read sites still pattern-match on the value enum with unreachable!() 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 _return as Box<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)

  1. 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.
  2. Replace the FrameEvent.parameters: Vec<Box<dyn Any>> field with the event enum. The message: String field collapses — the enum variant is the message identifier.
  3. Rewrite per-state dispatch (_state_X functions) 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)
            }
            _ => {}
        }
    }
    
  4. The manual Clone impl 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)

  1. Inventory handlers-with-return via SystemAst::interface_methods() filtered by non-void return declaration.
  2. 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)).
  3. Change FrameContext._return field type from Option<Box<dyn Any>> to Option<<System>Return>.
  4. Handler bodies that today write self._return = Some(Box::new(value)) change to self._return = Some(<System>Return::HandlerName(value)). This happens in frame_expansion/context_return.rs’s Rust-target branch.
  5. @@:return read sites pattern-match on the variant. framec already tracks the producing handler at every @@:return site — emit the exact variant arm; the _ fallback is true compile-time-unreachable.

B.3 — Value enum for _data (~0.5 day)

  1. Emit per-system <System>Value enum with 6 variants: Int(i64), Float(f64), Bool(bool), Str(String), List(Vec<Self>), Dict(HashMap<String, Self>).
  2. Replace _data: HashMap<String, Box<dyn Any>> with _data: HashMap<String, <System>Value>. The @@:data[key] read sites (in frame_expansion/handler_body.rs’s Rust-target branch) pattern-match on <System>Value.

B.4 — Bootstrap regen + cleanup (~0.5 day)

  1. Regenerate the 5 .gen.rs metatranspiler files via framec compile -l rust. These bootstrap files used the old Box<dyn Any> shape because they were emitted by the OLD Rust backend; after B.1–B.3 they regenerate with the new shape.
  2. Drop downcast-rs = "1.2.0" from framec/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.rs kernel 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, by Rc, 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; verify cargo check passes 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> in runtime.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 Box to satisfy borrow checker on kernel X," the rewrite must replay that fix in typed form.

Verification

After each sub-phase:

  • cargo test 400/400 (framec’s own tests).
  • All .frs fixtures across framec-test-env matrix run clean. Per-phase: B.1 should not affect persist or @@:data fixtures (they touch different surfaces); B.2 affects @@:return fixtures; B.3 affects @@:data fixtures.

After full Track B:

  • rg "Box<dyn" framec/src/frame_c/compiler/codegen/runtime.rs returns nothing.
  • rg "Box<dyn" framec/src/frame_c/compiler/codegen/rust_system.rs returns nothing.
  • rg "Box<dyn" framec/src/frame_c/compiler/attribute_scanner/*.gen.rs framec/src/frame_c/compiler/gdscript_multisys/*.gen.rs returns nothing (bootstrap regen verified).
  • rg "downcast-rs" framec/Cargo.toml returns 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 @@:return access, inspect the generated Rust, confirm the read site is a typed variant match on <System>Return. Pick another with @@:data[key] access, confirm <System>Value pattern match.
  • Hand-spot-check: pick a fixture with a multi-parameter handler (connect(url: String, port: i32)), confirm dispatch is match FooEvent::Connect { url, port } => ... with no intermediate downcasts.
  • RFC-0027 snapshot tests, if landed by then: re-bless. Diff the before/after .snap files 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., BodyCloserPy vs BodyCloserRuby) and the .gen.rs filename they include!().
  • 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.rs files via framec 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:

  1. macro_rules! extraction. Define body_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.rs files themselves remain unchanged; only the glue collapses.
  2. Extend the metatranspiler. framec already generates parts of itself into .gen.rs files for FSM bodies. Extend the generator to emit the glue too, so each per-language file shrinks to mod 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 test with zero snapshot diffs (modulo intentional changes from the trigger work itself).
  • cargo test passes whatever its count is at trigger time.
  • framec-test-env matrix runs clean across all backends.
  • wc -l framec/src/frame_c/compiler/body_closer/*.rs shows the expected reduction (target: each per-language file drops from ~500 LOC to <50 LOC; mod.rs grows 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 constructing FooFrameEvent directly, or who added their own methods to the generated impl that touch parameters / _return / _data, hit the new enum shape on regen and update their code once. The added FooValue enum 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 _return typing 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 is Rc<FrameEvent>, cloning is an O(1) refcount bump). The validation sketch in /tmp/typed_event_sketch.rs confirms the typed-enum shape compiles cleanly under this topology. The risk is not borrow-checker breakage but bootstrap-regen surprises in the 5 .gen.rs files — validation step #2 (trace .gen.rs event 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-rs removal: if any other module uses it (besides the 7 sites under treatment), Track B doesn’t remove the dependency. Quick rg decides 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 commit 985113a on 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 fixture framec-test-env/tests/common/positive/primary/01_interface_return.frs, captured 2026-05-17, 564 LOC, 39 Box<dyn / downcast mentions, 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–1010FrameEvent struct: parameters: Vec<Box<dyn Any>>. Becomes: <System>Event enum, one variant per interface method, #[derive(Clone, Debug)].
  • runtime.rs:1014–1021 — manual Clone impl 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–1037new / new_with_params impls. Removes — typed enum variant construction replaces.
  • runtime.rs:1046–1051FrameContext struct: _return: Option<Box<dyn Any>>, _data: HashMap<String, Box<dyn Any>>. Becomes: Option<<System>Return> + `HashMap<String, Value>`.
  • runtime.rs:1054–1062FrameContext::new signature takes default_return: Option<Box<dyn Any>>. Becomes: Option<<System>Return>.
  • rust_system.rs:703–711 — interface method packs typed params via Box::new(p.clone()) as Box<dyn Any> then __e.parameters = vec![...]. Becomes: typed enum variant construction let __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 _return via Box::new(default) as Box<dyn Any>. Becomes: Some(<System>Return::HandlerName(default)) or None.
  • rust_system.rs:745–749 — interface reads __ctx._return via ret.downcast::<T>().unwrap(). Becomes: typed variant pattern match.
  • rust_system.rs:1062–1072 (rust_expand_box_return) — handler @@:return = expr writes Box. Becomes: typed variant construction.
  • rust_system.rs:1098–1100 (rust_expand_context_data_write) — @@:data[key] = expr writes 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>Value variant determined by emit-site declared type.
  • rust_system.rs:1175–1204 (rust_context_return_read_typed) — @@:return read with per-type downcast match. Becomes: typed variant pattern match on <System>Return.

Known complications (don’t be surprised by these)

  1. 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., separate LifecycleEvent enum). Decide before starting.
  2. rust_context_data_get always downcasts to String today. The @@:data[key] read site doesn’t carry type info; the current emission hardcodes String. With the value enum, the emission needs to either (a) hardcode <System>Value:: Str(s) and document that @@:data reads 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.
  3. Bootstrap .gen.rs files at framec/src/frame_c/compiler/attribute_scanner/attribute_scanner.gen.rs and framec/src/frame_c/compiler/gdscript_multisys/multisys_assembler.gen.rs use the current Box<dyn Any> shape. After the codegen rewrite, they MUST be regenerated via framec compile -l rust against whatever Frame source generated them. Find the source .frm files (they’re alongside the .gen.rs under the same directory) and re-run framec on them. cargo build will fail until they’re regenerated.
  4. 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

  1. Read _scratch/track_b_validation/canary_01_interface_return_BEFORE.rs end-to-end. This is what framec emits today; it’s the floor the rewrite must clear.
  2. Read _scratch/track_b_validation/sketch_v2_full_option_c.rs end-to-end. This is roughly the target shape (it’s hand-written but small enough to validate the full design).
  3. Make the lifecycle-event decision (point 1 above).
  4. Start B.1 with runtime.rs::generate_rust_runtime_types — emit the <System>Event enum from system.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.