RFC-0015: Factory-Only System Construction
- Status: Shipped in framec 4.1.0 — see CHANGELOG
- Author: Mark Truluck mark@frame-lang.org
- Created: 2026-05-03
- Builds on: RFC-0012 (the persist contract), RFC-0013 (attribute syntax), RFC-0014 (
@@[main]) - Superseded in part by: RFC-0017 (the per-backend init mechanism)
This RFC describes the design: factory-only construction, the three lifecycle attributes, and the
@@!Foo()no-initialization sigil. The way each target language realizes a factory call and a no-initialization allocation was reworked by RFC-0017 — see there for the current per-backend call shapes.
Summary
A Frame system is a state machine; this RFC is about how you create a running instance of one — a system instance — and how to re-create one from a saved blob.
Before this RFC, creating a system instance ran the user’s initialization code
inside the host language’s constructor. That made restoring a persisted
system fragile — a persisted child system with required initialization
parameters had nothing to pass when restore tried to re-construct it — and on
some targets impossible (GDScript’s _init can’t be skipped).
This RFC fixes that. framec generates an explicit
factory (named by @@[create]) that allocates the
instance and runs the start state’s
initialization; the host-language constructor only allocates and never runs
user code; @@!Foo() allocates a system instance with no initialization
run, which is exactly what @@[load] /
restore needs; and @@[save] / @@[load] name the
serialize/deserialize pair.
Three system-level lifecycle attributes name the operations a user calls:
| Attribute | Names the… |
|---|---|
@@[create(<name>)] |
factory — construction |
@@[save(<name>)] |
save — serialize the instance to a blob |
@@[load(<name>)] |
load — deserialize a blob back into an instance |
@@!Foo() takes no name — it’s an expression you write in Frame source, not a
method you call on the instance. Paired with @@[load] it gives a clean
two-line restore:
fresh = @@!Foo() // allocate a system instance; run no init code
fresh.restore_state(data) // overwrite domain + compartment from the saved blob
This RFC also retired RFC-0012’s earlier operation-attribute persist form
(operations: @@[save] foo()): framec 4.1.0+ rejects it with E819 and
ships a codemod.
Motivation
The persist crash showed up like this: a system whose start state takes parameters — a database handle, a seed, a configuration object — held as a domain field of a larger persisted system. Restoring the larger system tried to re-construct that inner system and crashed: it had no parameters to pass to a constructor that demanded them.
The deeper cause was where Frame put user initialization code: inside each
target language’s natural constructor. That left every backend answering the
same question — “how do I allocate an instance without running the user’s init
code?” — with a different answer, ranging from awkward (reflection tricks,
marker constructors) to impossible (GDScript’s _init cannot be skipped at
all).
The fix removes the question. Frame stops emitting user code in host-language
constructors. A host constructor only allocates. User initialization — derived
fields, validation, resource acquisition — is a state-machine handler: the start
state’s $Start(...) body and its $> handler. Construction becomes “allocate,
then run the start state’s init”. Restore becomes “allocate with no
initialization, then deserialize the blob into the instance” — the same two
steps on every backend. The crash class disappears because there is no longer a
user-defined constructor for a zero-argument allocation to fail against.
The contract
Construction
A system’s header declares up to three groups of system parameters,
distinguished by sigil: state-params ($(...)),
enter-params ($>(...)), and
domain-params (bare). framec generates a
factory whose parameter list mirrors that header. The factory body is
mechanical:
- Allocate the instance (no user code runs in this step).
- Route each argument by its group — state-params → the start compartment’s state-args, enter-params → its enter-args, domain-params → the matching domain initializer.
- Run the start state’s
$Start(...)body, then its$>enter handler. - Return the initialized instance.
User code never lives in the factory body. Initialization logic belongs in
$Start(...) and $>. This is the architectural point of the RFC, and it is a
hard rule.
The factory is named @@[create(<name>)]; when the attribute is absent, framec
uses a per-backend default name (see RFC-0017). A system with no
header parameters gets a zero-argument factory.
Save and load
@@[persist(<type>)] marks a system serializable; <type> is the host-language
type of the serialized blob — the type save returns and
load takes (bytes in Python, String in Rust/Java/…,
std::string in C++, etc.; framec emits it verbatim and the per-backend
serializer does the encoding — see the glossary entry).
@@[save(<name>)] and @@[load(<name>)] name the two operations; framec
generates both bodies.
- Save is an instance method that returns a blob: it serializes the system’s
domain fields, its current compartment,
its state stack, and any nested
@@systemdomain fields. - Load is an instance method that takes a blob and overwrites the instance’s
domain and compartment without running any initialization code — no
$Start(...)body, no$>handler.
Bare @@[persist] with no save/load names is rejected with E814. The
RFC-0012 operation-attribute form (operations: @@[save] foo(): T) is rejected
with E819 (see Migration).
No-initialization allocation — @@!Foo()
@@!Foo() is the Frame-source expression that produces an instance of Foo
with no initialization run. It is always zero-argument — there is no factory
body to receive arguments — and it never fires $Start(...) or $>.
Its purpose is to pair with @@[load] for a clean restore. The canonical
pattern:
data = inst.save_state() // earlier — serialize
// ... later, possibly a different process ...
fresh = @@!Foo() // allocate, no init
fresh.restore_state(data) // overwrite from the saved blob
What @@!Foo() and the factory call (@@Foo(...)) become in the generated code
of each target language is specified by RFC-0017.
@@!Foo(anything) is rejected with E820. @@!Foo() (or @@Foo(...))
naming a system not declared in the module is rejected with E821.
Where initialization logic lives
Always in the start state’s $> handler (and, when state-args are involved, the
$Start(...) body):
@@system Inner($>(seed: int)) {
machine:
$Initializing {
$>(seed: int) {
self.derived = compute(seed)
self.connection = open_connection(seed)
}
ready() { -> $Active }
}
$Active {}
domain:
derived: int = 0
connection: Connection = null
}
@@Inner($>(7)) runs that $> handler. @@!Inner() does not. That distinction
is the whole point.
Composition
When a system holds another @@system as a domain field:
@@system Outer {
domain:
inner = @@Inner(42)
}
Outer’s factory constructs inner by calling Inner’s factory with 42.
Outer’s restore — and any nested-system restore inside it — uses @@!Inner()
followed by a recursive load. Because the no-initialization allocation is
zero-argument by definition, there is no constructor for it to fail against —
this is the fix for the crash described in Motivation.
Post-load resource reattach
A domain field tagged @@[no_persist] is excluded from the blob. After restore
it holds its domain: default — typically null for a resource handle.
Reattaching it is a user concern; Frame provides no implicit hook. The common
pattern is an explicit operation:
@@system Worker {
interface:
do_work()
operations:
reattach()
machine:
$Working {
do_work() { ... }
}
domain:
last_seq: int = 0
@@[no_persist]
connection: Connection = null
}
// user flow
fresh = @@!Worker() // allocate, no init
fresh.restore_state(data) // domain restored; connection still null
fresh.reattach() // user op opens the connection
fresh.do_work() // ready
Modeling reattach as an event that drives a transition through a “reconnecting” state works equally well — both are ordinary Frame, and framec needs no special support for either.
Examples
The examples above (the restore pattern; init logic in $>; composition;
resource reattach) are illustrative — they show shape, not a runnable program.
A runnable end-to-end persist round-trip lives in the matrix test suite.
Validator rules
| Code | Trigger |
|---|---|
| E814 | bare @@[persist] with no @@[save] / @@[load] names |
| E815 | @@[create], @@[save], or @@[load] attached to anything other than a system header |
| E817 | a lifecycle-attribute name argument that is not a valid identifier in the target language |
| E818 | a duplicate @@[create], @@[save], or @@[load] on the same system |
| E819 | the RFC-0012 operation-attribute persist form (operations: @@[save] foo(): T) — hard cut; the message points at the codemod |
| E820 | @@!Foo(...) with arguments — no-initialization allocation is zero-argument |
| E821 | @@Foo(...) or @@!Foo() naming a system not declared in the module |
Migration
The RFC-0012 operation-attribute persist form was hard-cut at framec 4.1.0:
framec rejects it with E819. The codemod at scripts/migrate_rfc0015.py
rewrites the old form — it lifts operations: @@[save] X(): T to a system-level
@@[save(X)], removes the lifted declarations, and drops the operations:
block if it becomes empty.
Adopting @@!Foo() is source-additive — existing code is unaffected. Code that
previously worked around the awkward restore by constructing with placeholder
arguments
// before — works, but the factory runs $> with throwaway args
fresh = @@Inner($>(0))
fresh.restore_state(data)
// after — clean; no init runs
fresh = @@!Inner()
fresh.restore_state(data)
can switch the construction line; nothing else changes.
Alternatives
@@[destroy] — a symmetric teardown attribute. Rejected: reliable
destructor hooks exist on only some target languages, and Frame’s value
proposition is determinism. A user who needs teardown writes it as an ordinary
event handler or operation and invokes it explicitly.
@@[blank(<name>)] — a fourth lifecycle attribute mirroring
create/save/load. Rejected: the other three name host-language methods a user
invokes (inst.save_state(), etc.). No-initialization allocation has no
host-language name to expose — @@!Foo() is an expression you write in Frame
source, not a method on the instance. An attribute would be a knob with nothing
to turn. The Frame-source sigil is the right shape.
A static load returning Self — fold the allocation into the load:
fresh = Foo.unpickle(data). Rejected: it hides the two-step nature in a way
that prevents the user from doing anything else with the freshly-allocated
instance before loading (e.g. wiring up a non-persisted dependency), it breaks
the instance-method restore_state(self, data) shape that already ships, and it
enlarges the codegen surface across every backend. @@!Foo() exposes the
allocated instance as a first-class value; restore_state is just one thing the
user can do with it.
References
- RFC-0012 — the persist contract
- RFC-0013 — attribute syntax (
@@[...]) - RFC-0014 —
@@[main]; the precedent for system-level attributes - RFC-0016 — selective-domain persist (deferred)
- RFC-0017 — the per-backend init mechanism: what the factory
call (
@@Foo(...)) and a no-initialization allocation (@@!Foo()) become in each target language - Frame language reference —
@@[persist], system parameters, system instantiation - Glossary — construction, factory, no-initialization, the persist contract, and the rest of the vocabulary used here
CHANGELOG.md