RFC-0025.1: Typed lifecycle args — closing the stringify gap in the typed-payload contract

  • Status: Accepted (2026-05-21). Corrects a requirements gap in RFC-0025 Track B; implementation lands in 4.2.1.
  • Author: Mark Truluck mark.truluck@cogiton.com
  • Created: 2026-05-21
  • Amends: RFC-0025 (Track B — typed compartment payload)
  • Resolves: _scratch/FRAMEC_BUGS.md Issue #34

Summary

RFC-0025 Track B set the requirement that the Rust target’s emitted dispatch infrastructure carry user values in their declared types, not in a stringly-typed or blindly type-erased form — “don’t throw away Rust’s type system.” It delivered that for the typed StateContext (state args + state vars), the FrameValue enum (the @@:data map), and the FrameReturn enum (@@:return).

It missed one channel: the $> / <$ lifecycle (enter/exit) args passed by -> (args) $State / (args) -> $State. On the Rust target those still ride a Vec<String> — stringified on the write side and parse::<T>()-ed back in the handler. This RFC states explicitly that the typed-payload requirement covers lifecycle args too, and specifies the Rust fix.

This is a requirements correction, not a new feature: the intent was always “no stringly-typed payloads.” Lifecycle args were an unintentional omission.

Motivation — the gap (FRAMEC_BUGS #34)

Frame’s -> (args) $State passes enter args to the destination’s $>(params) handler. On the Rust target the handler binds each param:

let n: i64 = args.get(0).and_then(|s| s.parse::<i64>().ok()).unwrap_or_default();

with args: Vec<String>. Consequences:

  • Compound / custom enter-arg types are a hard compile break. $>(items: Vec<i64>) emits args.get(0)...parse::<Vec<i64>>()error[E0277]: the trait bound Vec<i64>: FromStr is not satisfied.
  • Primitives are fragile. .unwrap_or_default() silently yields 0/"" on a parse miss (a wrong value, not an error), and f64 → String → f64 can change representation.

Rust is the lone outlier. Every other backend already stores lifecycle args type-faithfully — verified across all 17:

Backend(s) lifecycle-arg storage / bind
python, ts, js, ruby, php, lua, gdscript native value (enter_args[i])
c void*-backed, (T)(intptr_t) category cast
cpp std::vector<std::any> + std::any_cast<T>
java / kotlin / swift / csharp Object/Any-backed + cast
go interface{} + type assertion
erlang native term
rust Vec<String> + parse::<T>() ← the gap

The codegen comment justified the stringify as a “persist contract” requirement. That is incorrect: rust_system/persistence.rs serializes the typed StateContext, the state stack, and the domain — it does not serialize enter_args / exit_args at all. Lifecycle args are transition-scoped and transient; no persist constraint applies to them.

The corrected requirement (normative)

Every channel that carries a user-declared Frame value through a backend’s emitted runtime — state args, state vars, enter args, exit args, @@:data, @@:return, interface parameters — MUST preserve the value’s declared type with backend-native fidelity (typed field, Object/any/std::any, interface{}, Rc<dyn Any> + downcast, native term, …). No channel may stringify-and-reparse a value to move it across the dispatch boundary.

Stringification is permitted only where the target language has no alternative (none of the 17 do, for this) — it is never the default, and it is never used to dodge a type the backend could otherwise carry.

Rust implementation

Lifecycle args ride the typed per-state StateContext — the exact mechanism state args already use. There is no lifecycle-arg Vec (neither Vec<String> nor a type-erased Vec<Rc<dyn Any>>); the enter/exit params become genuinely-typed ctx fields, so the compiler checks them and compound/custom types just work.

An earlier draft of this RFC proposed a type-erased Vec<Rc<dyn Any>> + downcast_ref::<T>() channel. That was rejected: it removes the stringify but keeps the args outside Rust’s type system (downcast is a runtime check, and integer-literal exactness — i32 vs i64 — silently mis-downcasts). The shipped design makes lifecycle args first-class typed, identical to state args.

Mechanism (rust_system.rs, runtime.rs):

Site Before (#34) After (shipped)
per-state {State}Context struct state params + state vars only also gains a typed field per enter/exit param (rust_ctx_param_fields merges + dedups by name; mapped via frame_type_to_rust_type)
FrameEvent::FrameEnter / FrameExit { args: Vec<String> } {} (empty variants — no payload)
Compartment enter_args / exit_args: Vec<String> fields removed
write (transition -> (e) $S) vec![(e).to_string()] (unqualified e) ctx.{param} = {e} into the dest-chain ctx (exit args → source ctx); coerce_ctx_field_value handles str-literal → String
read (handler bind) args.get(i)…parse::<T>()…unwrap_or_default() walk to the state’s ctx, match … StateContext::{S}(ctx) => ctx.{param}.clone(); bound as a typed handler-method param
decorated pop$ enter_args/exit_args Vec push exit → source ctx; enter → match __popped.state_context over poppable variants (restored state is dynamic)

The start state’s $> enter handler keeps binding its params from the system header (self.__sys_<name>, written into the Start ctx by the start chain); the start state’s <$ exit handler binds from the typed ctx like any other state (this is FRAMEC_BUGS #35, fixed alongside).

Persist: rust_system/persistence.rs serializes only the durable ctx fields (state args + state vars); the transient enter/exit fields are not serialized and fill from ..Default::default() on restore. The wire format is unchanged. no_std: typed fields + empty event variants are alloc/core-clean. Other 16 backends: already faithful; no change.

Validation

  • Reproducers from #34: $>(n: int) (primitive, n+1==43), $>(g: str) ("hi!"), and $>(xs: Vec<i64>) (compound, head==10) all compile and run correctly on Rust.
  • Exit args (#35): (99) -> $B / <$(code)seen==99 (compile+run).
  • Decorated pop: (7) -> (42) pop$ → exit 7 reaches the source <$, enter 42 reaches the restored $> (compile+run).
  • Regression tests bug34 / bug35 / pop_decorated_args_typed (compile+run); cross-backend prevention fixture 13_lifecycle_args frozen across all 17 backends.
  • 714 framec tests + 17 RFC-0027 snapshots green; 57 dogfood .gen.rs recompile; 17-backend matrix green.

Non-goals

  • No change to Frame surface syntax or to any non-Rust backend.
  • No change to the persist wire format.
  • This RFC does not revisit @@:data / @@:return (RFC-0025 Track B already typed those); it closes only the lifecycle-args omission.