Migrating from framec 4.1.x to 4.2.0
This release has three user-visible breaks. None are subtle — each surfaces as a clear compile error or wire-format mismatch on first attempt. Walk-throughs below.
| Break | Symptom | Fix scope |
|---|---|---|
@@import removed (RFC-0024) |
E823: @@import has been removed on framec compile |
Per-file mechanical edit |
| Enter/exit cascade removed (RFC-0019) | HSM ancestor $> / <$ handlers silently stop running |
Per-HSM design call |
| Persist wire-format changes | Pre-4.2 persisted blobs fail to load on Python / Lua / Erlang | One-time migration on each saved blob |
@@codegen removed (RFC-0032) |
E824: @@codegen { ... } is no longer accepted on framec compile |
One-line mechanical delete |
1. @@import removed (RFC-0024)
What changed
The @@import directive is gone. Cross-file dependencies are now
expressed entirely in the host language’s native import syntax,
written by you as Oceans Model pass-through. framec emits no
import-related lowering of its own. @@SystemName() references
lower using only the literal name; the host language’s import
system resolves it at host-compile time.
Symptom
E823: @@import has been removed. Replace with the target
language's native import syntax outside any @@system block.
See RFC-0024.
--import-mode strict is also gone (E821 / E822 retired).
Per-target fix
Replace each @@import "./other.f<ext>" with the target’s native
import line, written outside any @@system block (it flows
through Oceans Model verbatim):
| Target | @@import "./counter.f<ext>" becomes |
|---|---|
| Python | from .counter import Counter |
| Rust | use crate::counter::Counter; |
| GDScript | const Counter = preload("res://counter.gd") |
| JS / TS | import { Counter } from "./counter"; |
| Dart | import 'counter.dart'; |
| C / C++ | #include "counter.h" |
| PHP | require_once __DIR__ . '/counter.php'; |
| Ruby | require_relative 'counter' |
| Lua | local Counter = require 'counter' |
| Kotlin | import com.example.counter.Counter (per your package conv.) |
| Swift | (same module — no import needed) |
| Java | import com.example.counter.Counter; (per your package conv.) |
| C# | using Example.Counter; (per your namespace conv.) |
| Go | import "example.com/counter" |
| Erlang | (no replacement — single OTP application) |
If you were already on Java / C# / Go and using RFC-0022.1’s
“dependency-declaration-only @@import” pattern, your native
imports already exist — just delete the no-op @@import line.
Worked example — Python
Before (4.1.x):
@@[target("python_3")]
@@import "./counter.fpy"
@@system App {
domain:
c = @@Counter()
}
After (4.2.0):
@@[target("python_3")]
from .counter import Counter
@@system App {
domain:
c = @@Counter()
}
@@Counter() still lowers to Counter._create(). The
from .counter import Counter line lands verbatim in the
generated .py via Oceans Model.
2. Enter/exit cascade removed (RFC-0019)
What changed
$> (enter) and <$ (exit) used to run on every ancestor
of the entered/exited state. They now run only on the leaf.
The only way an ancestor’s $> / <$ runs is if the leaf
explicitly forwards via => $^ (forward).
Symptom
Subtle. No compile error. Generated code stops calling ancestor
lifecycle handlers. If you relied on a parent state’s $> to
do initialization work — counters incrementing, resources
acquired, logs emitted — that work silently stops happening
after the leaf transitions in.
Diagnosis
Look at each HSM in your system. For each substate, ask: was
the parent’s $> / <$ doing work I want to keep running on
every entry? If yes, add an explicit => $^ to the leaf’s
handler (creating one if it doesn’t exist).
Worked example
Before (4.1.x — implicit cascade):
$Active {
$>() {
log("entering Active")
}
}
$Heating {
-*Active
// No $> — but parent's $> ran anyway
}
After (4.2.0 — leaf’s $> must explicitly forward):
$Active {
$>() {
log("entering Active")
}
}
$Heating {
-*Active
$>() {
=> $^ // forward to Active's $>
}
}
Placement of => $^ controls order. => $^ at the start of the
handler runs the parent first; at the end runs the leaf first.
Mid-handler => $^ is supported on every backend (with one
documented Erlang residual: a transition inside an ancestor’s
$> reached via => $^ doesn’t fire — state_timeout only
works in the leaf’s own enter clause).
A related rule that didn’t change — but is easy to conflate
V4 has always required => $^ for regular events (anything
that isn’t $> / <$) to reach a parent — there was never an
implicit cascade for ordinary events. RFC-0019 didn’t change
that; it extended the same rule to lifecycle. If you find HSM
prose or older recipes that describe a parent’s panic() /
error_event() / similar “implicitly reaching” every child — or
each child “implicitly forwarding to its parent” — that prose
was wrong even before RFC-0019. The correct shape is, and has
been:
$Parent {
panic(msg: str) {
log_panic(msg)
-> $Halted
}
}
$Child {
-*Parent
// Option A: explicit handler that forwards
panic(msg: str) {
=> $^ // forward to Parent.panic
}
// Option B: trailing default catches anything unhandled
|panic|
}
If you’re migrating prose or examples, the lifecycle ($> /
<$) part is the RFC-0019 change; the regular-event part is
just a long-standing V4 rule that older documentation sometimes
states informally.
What this looks like in the matrix
The fixtures that had implicit cascade dependencies were mechanically migrated:
40_hsm_parent_state_vars42_hsm_three_levels46_hsm_enter_parent_only47_hsm_enter_both48_hsm_exit_handlers51_hsm_persist
Each gained explicit => $^ forwards in the leaf’s $>/<$.
Read these for worked examples.
3. Persist wire-format changes
Three backends changed their persist serialization format. Blobs saved with framec 4.1.x will not load with 4.2.0 on these backends. The fix is the same in each case: one-time load+save cycle on framec 4.1.x to read the old format, dump to a neutral intermediate (e.g., the system’s domain-as-JSON via your application’s own marshaller), then load+save under 4.2.0 to write the new format. Or: regenerate the persisted state from scratch under 4.2.0 if your application supports that.
Python: pickle → JSON
- Before:
pickle.dumps(system)— whole-object pickle. - After: field-by-field UTF-8 JSON, matching every other dynamic backend.
- Why: dropping a Python-specific format aligns Python with the rest of the matrix (RFC-0016.1).
Lua: cjson → serpent textual table-literal
- Before:
cjson.encode(saved_table)— JSON. - After:
serpent.dump(saved_table)— Lua table literal. - Why: cjson decodes every JSON number as
lua_Number(float), erasing the Lua 5.3+ integer subtype.math.type()queries and bitwise ops on persisted ints broke. Serpent preserves int vs float. - Dep change: add
serpentto your Lua project (single ~700-line pure-Lua file). cjson no longer required by framec output.
Erlang: map() → binary() (ETF)
- Before:
Saved = #{...}— Erlang map. - After:
Saved = term_to_binary(#{...}, [{minor_version, 2}])— ETF binary. - Why: ETF is OTP-standard, zero-dep, fully lossless for atoms, tagged tuples, char-list strings — all the Erlang types that JSON can’t represent. Aligns with mnesia / dets / ets / distributed Erlang.
- Test-driver impact: if your code introspected
Saveddirectly (rare — most user code is round-trip-only), wrap withbinary_to_term(Saved, [safe])first.
GDScript: brief JSON detour reverted; back on var_to_bytes
If you didn’t pull framec between morning-of-2026-05-13 and the same-day revert, no change applies. If you did and have JSON blobs from that window, regenerate.
Net wire-format inventory
14 backends share JSON (Python, JS, TS, Ruby, PHP, Dart, Java, Kotlin, Swift, C#, Rust, Go, C, C++). Three documented native-fidelity exceptions: Erlang (ETF), GDScript (Godot binary Variant), Lua (serpent textual table-literal). All three exceptions are driven by the same pattern: the language has real types JSON can’t represent.
4. @@codegen removed (RFC-0032)
What changed
The @@codegen { ... } module-level directive is gone. Its single
config knob (frame_event: on | off) was already auto-inferred by
the framepiler whenever a feature required it, declared at the
wrong granularity for multi-system files, and a no-op in practice
(the codegen path was unconditional on every backend except Rust).
Symptom
E824: @@codegen { ... } is no longer accepted (RFC-0032).
Delete the directive — the framepiler auto-enables frame_event
whenever a feature that requires it appears (enter/exit args,
event forwarding, @@:return, interface return values)...
Fix
Delete the @@codegen { ... } block from each source file.
Generated code is byte-identical without it.
Before:
@@[target("python_3")]
@@codegen { frame_event: on }
@@system Counter { ... }
After:
@@[target("python_3")]
@@system Counter { ... }
For external sources, a single-shot regex:
perl -i -0pe 's/\@\@codegen\s*\{[^}]*\}\s*//g' your_file.fpy
(Multi-line -0pe because the block can span lines.) Zero
fixtures in the matrix corpus used the directive (verified
2026-05-18), so no test-side migration was needed in framec
itself.
Verifying the migration
framec --versionreports4.2.0.framec <file>on any 4.1.x source using@@importerrors with E823.- After replacing
@@importwith native imports, the same file compiles clean. - After adding
=> $^to any leaf that depended on ancestor$>/<$, your existing test suite passes again. - Saved-state migration is one-time per blob; freshly generated blobs from 4.2.0 round-trip cleanly.
References
- RFC-0024 —
@@importremoval - RFC-0019 — uniform
$>/<$dispatch - RFC-0016.1 — persist contract and
@@[no_persist] - CHANGELOG
[Unreleased]— full release notes for 4.2.0 - Per-target guides — cross-file composition examples per backend