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).

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_vars
  • 42_hsm_three_levels
  • 46_hsm_enter_parent_only
  • 47_hsm_enter_both
  • 48_hsm_exit_handlers
  • 51_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 serpent to 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 Saved directly (rare — most user code is round-trip-only), wrap with binary_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

  1. framec --version reports 4.2.0.
  2. framec <file> on any 4.1.x source using @@import errors with E823.
  3. After replacing @@import with native imports, the same file compiles clean.
  4. After adding => $^ to any leaf that depended on ancestor $> / <$, your existing test suite passes again.
  5. Saved-state migration is one-time per blob; freshly generated blobs from 4.2.0 round-trip cleanly.

References