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.mdIssue #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>)emitsargs.get(0)...parse::<Vec<i64>>()→error[E0277]: the trait bound Vec<i64>: FromStr is not satisfied. - Primitives are fragile.
.unwrap_or_default()silently yields0/""on a parse miss (a wrong value, not an error), andf64 → String → f64can 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 —i32vsi64— 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$→ exit7reaches the source<$, enter42reaches the restored$>(compile+run). - Regression tests
bug34/bug35/pop_decorated_args_typed(compile+run); cross-backend prevention fixture13_lifecycle_argsfrozen across all 17 backends. - 714 framec tests + 17 RFC-0027 snapshots green; 57 dogfood
.gen.rsrecompile; 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.