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:

  1. Allocate the instance (no user code runs in this step).
  2. 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.
  3. Run the start state’s $Start(...) body, then its $> enter handler.
  4. 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 @@system domain 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