Frame Language Reference
Prompt Engineer: Mark Truluck mark@frame-lang.org
Complete reference for the Frame language. For a tutorial introduction, see Getting Started.
Table of Contents
- Source File Structure
- System Declaration
- Interface Section
- Machine Section
- Actions Section
- Operations Section
- Domain Section
- Frame Statements
- Hierarchical State Machines
- System Context
- Self Reference
- System Runtime
- Compartment
- Persistence
- Async
- System Instantiation
- Token Summary
- Error Codes
- Complete Example
- Appendix: Frame Syntax Taxonomy
Source File Structure
<preamble> // native code (optional)
@@[target("<lang>")] // required, exactly once
<annotations>* // zero or more (@@[persist], etc.)
@@system <Name> (<params>)? {
<sections>
}
<postamble> // native code (optional)
Everything outside @@[target(...)], annotations, and @@system is native code and passes through unchanged.
Types and Expressions
Frame has no type system. Wherever a type or expression appears in Frame syntax — interface params, state variables, domain fields, return types, initializers — Frame treats them as opaque strings and passes them through to the generated code verbatim. Write your target language’s type names (int, String, Vec<i32>, std::string, etc.) and expressions. Frame does not parse, validate, or translate them.
@@[target(...)]
@@[target("<language_id>")]
Required. Must appear before @@system. Specifies the target language.
| ID | Language | ID | Language | |
|---|---|---|---|---|
python_3 |
Python 3 | go |
Go | |
typescript |
TypeScript | php |
PHP | |
javascript |
JavaScript | kotlin |
Kotlin | |
rust |
Rust | swift |
Swift | |
c |
C (C11) | ruby |
Ruby | |
cpp_23 |
C++ (≥ C++20 for async) | erlang |
Erlang | |
java |
Java | lua |
Lua | |
csharp |
C# | dart |
Dart | |
graphviz |
GraphViz DOT | gdscript |
GDScript |
The @@[target(...)] attribute is the authoritative declaration of the file’s target language. It can be overridden by a CLI flag (-l <language>). The bare @@target form is hard-cut (E804) per RFC-0013.
@@[persist]
@@[persist(<blob_type>)]
@@[save(<save_method_name>)]
@@[load(<load_method_name>)]
Marks a system as serializable. A persisted system declares three
system-level attributes: @@[persist(<blob_type>)] (the blob type),
@@[save(<name>)] (the save method name), and @@[load(<name>)]
(the load method name). Framec generates the save/load pair on the
system class — save returns the blob, load is an instance method
that mutates self.
Bare @@[persist] (no save/load names) is rejected with E814.
The legacy operation-attribute form (operations: @@[save] foo())
is rejected with E819 at framec 4.1.0+; the codemod at
scripts/migrate_rfc0015.py rewrites old fixtures.
Optional companion on domain fields:
| Attribute | Position | Purpose |
|---|---|---|
@@[no_persist] |
domain field | Excludes this field from the serialized blob |
See Persistence, RFC-0015, and RFC-0016 (deferred selective-domain-persist form).
System Declaration
@@system <Name> ( <system_params> )? ( : <Base1>, <Base2>, ... )? {
( operations: <operations_block> )?
( interface: <interface_block> )?
( machine: <machine_block> )?
( actions: <actions_block> )?
( domain: <domain_block> )?
}
Sections are optional but must appear in the order shown: operations → interface → machine → actions → domain.
Base Classes
A system can declare base classes or interfaces using : after the name (and optional parameters):
@@system Pong : RefCounted { ... }
@@system NetworkPlayer : Node, Serializable { ... }
@@[main]
@@system Robot($(x: int)) : Controller { ... }
Frame passes base class names through verbatim to the target language. It does not validate inheritance rules — the target compiler does. Each backend renders the base list per its language’s convention:
| Target | @@system Foo : A, B |
|---|---|
| Python | class Foo(A, B): |
| TypeScript | class Foo extends A implements B |
| JavaScript | export class Foo extends A implements B |
| Java | class Foo extends A implements B |
| Kotlin | class Foo : A(), B |
| Swift | class Foo: A, B |
| C# | class Foo : A, B |
| C++ | class Foo : public A, public B |
| PHP | class Foo extends A implements B |
| Ruby | class Foo < A (single inheritance; extra bases ignored with a warning) |
| Dart | class Foo extends A implements B |
| GDScript | extends A (module scope; only one base) |
| Rust | (not supported — structs have no inheritance; use traits via native code) |
| Go | (not supported — structs have no inheritance; use embedding via native code) |
| C | (not supported — no inheritance) |
| Lua | (not supported — use metatables via native code) |
| Erlang | (not supported — use behaviours via native code) |
Systems without : generate standalone classes with no base (the default). For targets that don’t support inheritance (Rust, Go, C, Lua, Erlang), declaring : on a system is currently ignored — a warning may be added in a future revision.
Visibility
System classes are public by default — they emit public class (Java/C#/Swift), export class (TypeScript/JavaScript), or pub struct (Rust). Languages where classes are public by default (Python, Kotlin, Dart, PHP, Ruby, Lua) emit a bare class declaration.
To make a system non-public, use the private keyword:
@@system private Helper { ... }
| Target | @@system Foo (default) |
@@system private Foo |
|---|---|---|
| Java | public class Foo |
class Foo (package-private) |
| C# | public class Foo |
class Foo (internal) |
| Swift | public class Foo |
class Foo (internal) |
| TypeScript | export class Foo |
class Foo (not exported) |
| JavaScript | export class Foo |
class Foo (not exported) |
| Kotlin | class Foo (public) |
private class Foo |
| Rust | pub struct Foo |
struct Foo (crate-private) |
Rules:
@@system public Foois an error — systems are public by default; the keyword is redundant.@@system private Footargeting Python, Ruby, Lua, C, GDScript, or Erlang is an error — these languages do not support class-level visibility modifiers.
Other elements follow fixed visibility and do not accept modifiers:
- Interface methods — always public (that is their purpose)
- Operations — always public
- Actions and handlers — always private (implementation details)
System Parameters
Three parameter groups configure a system at construction time. Each is optional, but when present they must appear in this order: state params ($()), then enter params ($>()), then domain params (bare).
@@system Name ( $(state_params), $>(enter_params), domain_params )
| Group | Sigil | Target |
|---|---|---|
| State arg | $(name: type) |
Start state’s compartment.state_args |
| Enter arg | $>(name: type) |
Start state’s compartment.enter_args |
| Domain arg | name: type (bare) |
Constructor argument, used in domain field initializers |
Each param body has the same shape (name: type or name: type = default) regardless of group; only the sigil differs. The framepiler validates that state and enter args have matching declarations on the start state’s $Start(name: type) and $>(name: type) handlers.
Param syntax
Each individual parameter follows the same shape as an interface method parameter:
name
name : type
name : type = default
- Untyped (
name): valid in dynamically-typed targets (Python, JavaScript, Ruby, Lua, GDScript, PHP, Erlang). Static-typed targets require an explicit type. - Typed (
name : type): the type string is passed through verbatim to the target language’s constructor signature. Use the target’s native type names (int,str,bool,float, etc.). - Defaulted (
name : type = default): the default expression is pasted verbatim into the constructor signature. Defaults must be valid in the target language at the parameter-default position. Integer and boolean literals are portable; string and collection defaults may not be.
State params
$(name: type) declares a parameter that lands in the start state’s compartment.state_args map under the declared name. The start state must have a matching $Start(name: type) declaration so the dispatch function can bind the param to a local at the top of the state body:
@@system Robot($(x: int), name: str) {
interface:
describe(): str
machine:
$Start(x: int) {
describe(): str { @@:(self.name + "@" + str(x)) }
}
domain:
name = name
}
r = @@Robot($(7), "R2D2") // x = 7 (state arg), name = "R2D2" (domain)
Note the call site: state args are tagged with $(...) so the assembler can route them into compartment.state_args. See System Instantiation for the full call site form.
State args are also written by transitions (-> $Start(42)). The codegen stores transition-passed args under the same declared param name, so the dispatch reads the param identically whether the state was entered via the system constructor or a transition.
Enter params
$>(name: type) declares a parameter that lands in the start state’s compartment.enter_args map under the declared name. The start state must have a matching $>(name: type) enter handler that reads the param:
@@system Worker($>(batch_size: int)) {
interface:
run()
machine:
$Start {
$>(batch_size: int) {
self.size = batch_size
}
run() {
// process self.size items
}
}
domain:
size = 0
}
w = @@Worker($>(50)) // start state's enter handler sees batch_size = 50
The call site tags enter args with $>(...), the same shape as the declaration. Enter args are also written by transitions that use the -> "args" $State form. As with state args, the codegen stores both transition-passed and constructor-passed enter args under the declared param name.
Domain params
Bare identifiers in the header become constructor arguments that are in scope when the domain field initializers run. A domain field’s right-hand side can reference any header param by name:
@@system Counter(initial: int = 0) {
interface:
get(): int
machine:
$Counting {
get(): int { @@:(self.value) }
}
domain:
value = initial // initial is a constructor arg in scope
}
c = @@Counter(10) // value is 10
The codegen prepends the language-appropriate self-reference (self., this., @) to the LHS of the domain field assignment, so value = value (param and field with the same name) is unambiguous: it compiles to self.value = value.
Interface Section
Declares the system’s public API.
interface:
<method_name> ( <params>? ) (: <return_type> (= <default_value>)? )?
Examples:
interface:
start()
stop()
process(data: str, priority: int)
getStatus(): str
getDecision(): str = "yes"
Rules:
- Method names must be unique within the interface
- Parameters:
name: typeor untypedname - Default return value is a native expression, used when no handler sets
@@:return - A return type with no default implies
None/nullas default
Cross-target return behavior: in strongly-typed targets
(TypeScript, Java, Kotlin, Swift, C#, Dart, C, C++, Go, Rust) the
declared : type annotation is required for the wrapper to expose a
return value — without it, the method is void. In dynamic targets
(Python, JavaScript, Ruby, Lua, PHP, GDScript, Erlang) the wrapper
always exposes the FrameContext’s return slot, so : type is
documentation only. See Frame Runtime — Step 8: Return value.
Machine Section
Contains state definitions.
State Declaration
$<StateName> ( => $<ParentState> )? {
<state_var_declarations>*
<handlers>*
( => $^ )?
}
- State names must be unique within the system
- The first state listed is the start state
=> $ParentStatedeclares an HSM parent (see HSM)
State Variables
Must appear at the top of the state block, before any handlers.
$.<varName> (: <type>)? = <initializer_expr>
| Part | Required | Description |
|---|---|---|
$. |
Yes | State variable prefix |
<varName> |
Yes | Identifier |
: <type> |
No | Type annotation |
= <initializer_expr> |
Yes | Native expression; evaluated on every state entry |
Scope rules:
$.xalways refers to the enclosing state’s variablex- No syntax exists to access another state’s variables
- No duplicates within a state
- State variable names may shadow domain variables (no ambiguity due to
$.prefix)
Portable init expressions: Use Frame-portable literals for state variable initializers: "" for strings, 0 for integers, false for booleans. The framepiler wraps these to match the target language’s type system (e.g., String::from("") for Rust, std::string("") for C++). Target-language-specific constructors like String::new() are NOT portable — the Frame parser may not handle them correctly. If you need a target-specific value, write it as native code and the framepiler will pass it through unchanged.
Event Handlers
<event_name> ( <params>? ) (: <return_type> (= <default_value>)? )? {
<body>
}
When a handler declares a return type with a default value (= <expr>), that expression initializes @@:return before the handler body executes.
The body is a mix of native code and Frame statements. Native code passes through unchanged.
Enter Handler
$> ( <params>? ) {
<body>
}
Called when the state is entered via a transition. Parameters come from the transition’s enter args.
Exit Handler
<$ ( <params>? ) {
<body>
}
Called when the state is exited via a transition. Parameters come from the transition’s exit args.
Enter/Exit Parameter Mapping
Enter and exit args are passed positionally:
$Idle {
start() {
-> ("from_idle", 42) $Active
}
}
$Active {
$>(source: str, value: int) {
print(f"Entered from {source} with {value}")
}
}
Argument-receiver contract
A transition that supplies args must have a receiver that can take them. The framepiler enforces this at compile time:
| Site | Receiver | Code |
|---|---|---|
(args) -> $T |
source state’s <$(...) |
E419 |
-> (args) $T |
target state’s $>(...) |
E417 |
-> $T(args) |
target state’s state params | E405 |
If the receiver is missing or its arity doesn’t fit, the compile
fails. EventParam-backed receivers (E417, E419) honor trailing
defaults — <$(a, b = "x") accepts 1 or 2 supplied args. State
params (E405) currently have no defaults, so the count must match
exactly.
The check applies only when the transition supplies args.
-> $T against a state with <$(reason) is allowed; <$ simply
runs with reason unbound (a runtime concern, not a structural
error).
Actions Section
Private helper methods on the system class.
actions:
validate(data): bool {
return data is not None
}
Can access: domain variables, @@:return, @@:params.x, @@:event, @@:data.key, @@:self.method(), @@:system.state
Cannot access (E401): -> $State, => $^, push$, pop$, $.varName
Actions have no state context. return in actions is the native language return.
Operations Section
Public methods that bypass the state machine entirely.
operations:
static version(): str {
return "1.0.0"
}
get_debug_info(): str {
return f"state={self.__compartment.state}"
}
- Static operations have no
self/thisaccess - Non-static operations can access domain variables and
@@:return - Same Frame statement restrictions as actions (E401)
returnis the native language return
Domain Section
Instance variables declared in canonical Frame syntax: name : type = init.
domain:
count : int = 0
label : str = "default"
items : list = [1, 2, 3]
- Type is an opaque string — write the target language’s type name (
int,String,Vec<i32>, etc.) - Init is an opaque native expression — Frame passes it through verbatim
- Type is optional for dynamic targets (Python, JS, Ruby, Lua, Erlang, PHP):
count = 0 - Init is optional for static targets that zero-initialize (C, C++, Go):
count : int - Multi-line init uses paren wrapper:
items : list = (\n [1, 2, 3]\n)
Domain variables persist across state transitions and are accessible via self.field / this.field / this->field (per target language) in handlers.
const Modifier
Prefix a domain field with const to mark it immutable after construction:
domain:
const max_retries : int = 3
const threshold : int = threshold // initialized from system param
counter : int = 0 // mutable
A const field may be assigned exactly once — either via its initializer or via a system param of the same name in the constructor. Assignment in any handler body is rejected (E615).
Per-target emission uses each language’s idiomatic immutability keyword:
| Target | Emitted as |
|---|---|
| C++ | const T name; (member init list when init refs a system param) |
| Java | final T name = init; |
| C# | readonly T name = init; |
| Dart | final T name = init; |
| Kotlin | val name: T = init (promoted to primary constructor on param collision) |
| Swift | let name: T = init |
| TypeScript | readonly name: T = init; |
| Rust | (fields are immutable by default) |
| Python / JS / PHP / Ruby / Lua / Erlang / GDScript / C / Go | comment-only marker; immutability not enforced at the target level |
Frame Statements
Frame recognizes exactly 7 constructs within handler bodies. Everything else is native code.
Transition — -> $State
( <exit_params> )? -> ( => )? ( <enter_params> )? <label>? $<TargetState> ( <state_params> )?
| Form | Meaning |
|---|---|
-> $State |
Simple transition |
-> $State(args) |
With state args |
-> (args) $State |
With enter args |
(args) -> $State |
With exit args |
(exit) -> (enter) $State(state) |
Full form |
-> "label" $State |
With label (for diagrams) |
-> => $State |
With event forwarding |
-> pop$ |
Transition to popped state |
-> (enter_args) pop$ |
Pop with fresh enter args |
(exit_args) -> pop$ |
Pop with exit args |
-> => pop$ |
Pop with event forwarding |
Event forwarding (-> =>): The current event is stashed on the target compartment. After the enter handler fires, the forwarded event is dispatched to the target state. Works on both $State and pop$ targets.
Transition to popped state (-> pop$): Pops a compartment from the state stack. Full lifecycle fires. State variables are preserved (not reinitialized).
Decorated pop transitions: Pop transitions accept the same decorations as normal transitions. -> (result) pop$ replaces the popped compartment’s enter_args with fresh values (the caller’s $> handler receives result instead of the original snapshot). (reason) -> pop$ writes exit_args on the current compartment before leaving. -> => pop$ forwards the current event to the restored state instead of sending $>. All decorations can be combined: (exit) -> (enter) => pop$. State args on pop$ are not allowed (E607) — the popped compartment carries its own.
Every transition is implicitly followed by a return — code after a transition is unreachable.
Forward to Parent — => $^
=> $^
Forwards the current event to the parent state’s dispatch function. The enclosing state must have a parent declared with => $ParentState.
Stack Push — push$
push$
Saves a reference to the current compartment (including all state variables) onto the state stack. The compartment itself is NOT copied — the stack entry and __compartment point to the same object.
push$ is almost always followed by a transition (push$ -> $State). The transition creates a new compartment for the target state; the old one is preserved on the stack. -> pop$ later restores the saved reference.
Bare push$ (no transition): the stack entry and current compartment are the same object. Any modifications to state variables after push$ are visible through both. pop$ restores the same modified object. For snapshot/undo semantics, use push$ -> $SameState(args) to create a new compartment on transition.
Stack Pop — pop$
pop$
Pops and discards the top compartment. To transition to the popped state, use -> pop$.
State Variable Access — $.varName
$.counter // read
$.counter = <expr> // write
$.varName works inside string interpolation expressions for languages that support them (Python f-strings, TypeScript template literals, Kotlin ${}, Ruby #{}, Swift \(), C# $"{}"). The expansion uses the opposite quote from the string delimiter to avoid collisions — e.g., inside f"text {$.count}", the generated code uses single quotes for the dict key: state_vars['count'].
System Context — @@
@@:params.x // interface parameter (by name)
@@:return = <expr> // set return value
@@:return // read return value
@@:event // interface method name
@@:data.key // call-scoped data (by key)
See System Context for full semantics.
Self & System Prefixes
@@:self.method(args) // call own interface method (reentrant)
@@:system.state // current state name (read-only)
@@:self and @@:system are syntactic prefixes — neither is a first-class value. Bare @@:self (E603) and bare @@:system (E604) are errors.
See Self Reference and System Runtime for full semantics.
return is always native. It exits the current function — it does NOT set @@:return. In event handlers, return expr silently loses the value (W415 warning). Use @@:(expr) or @@:return = expr to set return values.
| Syntax | Effect |
|---|---|
@@:(expr) |
Set return value only (concise) |
@@:return = expr |
Set return value only (explicit long form) |
@@:return(expr) |
Set return value AND exit handler (one statement) |
return |
Exit the handler (native — valid everywhere) |
return expr |
Native return — in handlers, value is lost (W415) |
return @@:(expr) |
Error E408 — cannot combine |
@@:return(expr) is the recommended form when you want to set the return value and immediately exit. It replaces the common two-statement pattern @@:(expr) + return. The expression inside the parens is evaluated, stored in the context return slot, and a native return is emitted — all in one Frame statement.
Hierarchical State Machines
Parent Declaration
$Child => $Parent {
...
}
Explicit Forwarding
V4 uses explicit-only forwarding. Unhandled events are ignored, not forwarded.
In-handler forward:
$Child => $Parent {
event_a() {
log("Child processing")
=> $^
}
}
State-level default forward (forwards ALL unhandled events):
$Child => $Parent {
specific_event() { ... }
=> $^
}
Key semantics:
=> $^is the only way to forward to parent=> $^can appear anywhere in a handler- Without
=> $^, unhandled events are ignored
Lifecycle handlers in HSM ($> and <$)
Since RFC-0019, $> (enter) and <$ (exit) are
ordinary leaf-dispatched events — the kernel does not walk the parent
chain firing ancestor $>/<$ handlers. Only the current state’s lifecycle
handler runs on entry / exit. If you want an ancestor’s lifecycle to run, the
leaf must explicitly forward via => $^ (placement controls order):
$Child => $Parent {
$>() {
=> $^ // run $Parent.$> first (parent-then-child)
self.log.append("Child:enter")
}
<$() {
self.log.append("Child:exit")
=> $^ // run $Parent.<$ last (child-then-parent)
}
}
$Parent {
$>() { self.log.append("Parent:enter") }
<$() { self.log.append("Parent:exit") }
}
A $Child with no $>/<$ handler and no state-level => $^
silently overrides its ancestor’s lifecycle — the parent’s $>/<$ does
not run. Tag the child with a state-level => $^ if you want the parent’s
lifecycle to fire when the child has nothing to add.
This is intentionally different from UML statecharts; the rationale is in RFC-0019 § Motivation.
System Context
The @@ prefix provides access to the current interface call’s context.
Architecture
Every interface call creates:
- FrameEvent —
{ _message: string, _parameters: dict } - FrameContext —
{ event: FrameEvent, _return: any, _data: dict }
The context is pushed onto _context_stack on call and popped on return. Lifecycle events ($>, <$) use the existing context.
Accessor Grammar
All @@ accessors follow a uniform grammar:
:(colon) — navigates Frame’s namespace hierarchy.(dot) — accesses a field on the resolved object
Colon drills through Frame namespaces. Dot accesses a property on whatever you’ve arrived at. If the target is a value (not a container), no dot is needed.
Context Accessors
@@: refers to the current execution context. It is transient — it exists for the duration of a dispatch chain and is then discarded. Multiple contexts stack on _context_stack during reentrant calls.
| Syntax | Meaning |
|---|---|
@@:params.x |
Interface parameter x |
@@:params |
Parameter bag (if needed as object) |
@@:return |
Get/set return value |
@@:(expr) |
Set return value (concise) |
@@:return(expr) |
Set return value and exit handler |
@@:event |
Interface method name |
@@:data.key |
Call-scoped data entry |
Reentrancy
Each interface call pushes its own context. Nested calls are isolated — inner @@:return does not affect outer @@:return.
Context Not Available
@@ context accessors are not available in static operations or the initial $> during construction.
Self Reference
@@:self is a syntactic prefix used to dispatch through the system’s own interface. It is not a first-class value — bare @@:self is a compile error (E603). The only valid form is @@:self.method(args).
Self Accessors
| Syntax | Meaning |
|---|---|
@@:self.method(args) |
Reentrant interface call |
@@:self (bare) |
Error — E603. Requires .method(args). |
Self Interface Call — @@:self.method(args)
A system can call its own interface methods using @@:self.<method>(args). This dispatches through the full kernel pipeline — FrameEvent construction, context push, router, state dispatch, handler execution, context pop — exactly as an external call would.
Why @@:self.method() and not native self.method()?
In OO target languages (Python, TypeScript, Rust, Java, Kotlin, Swift, C#, Ruby, PHP, Dart) a plain self.method() / this.method() inside a handler body also reaches the generated interface method and produces the same runtime behavior — the context-stack push/pop and deferred-transition semantics live in the generated interface wrapper, not in the @@:self. syntax.
@@:self.method(args) is preferred for two reasons:
- Static validation. The validator checks that
methodexists in theinterface:block with the right arity (E601/E602). Native calls bypass this. - Cross-backend portability. In C and Erlang the handler scope has no
self/thiskeyword; dispatch goes through a different mechanism.@@:self.abstracts that difference so the same Frame source compiles everywhere.
$Active {
calibrate() {
baseline = @@:self.reading() // reentrant self-call
self.offset = baseline * -1
}
reading(): float {
@@:(self.raw_sensor_value + self.offset)
}
}
Semantics
- Full dispatch. The call goes through the kernel. The handler that executes depends on the current state at the time of the call.
- Context isolation. A new context is pushed onto
_context_stack. Inside the called handler,@@:eventis the called method’s name,@@:paramsare the called method’s parameters, and@@:returnis the called method’s return slot. The calling handler’s context is untouched. - Return value. The return value is available to the caller as a native expression, just like any function call.
- State sensitivity. If a transition occurred before the self-call, the call dispatches to a handler in the new state.
Restrictions
- Only interface methods can be called via
@@:self.method(). Actions and operations are called directly using native syntax. @@:self.method()does not support calling constructors.
Self-Call Validation
| Code | Check | Severity |
|---|---|---|
| E601 | Method does not exist in interface: block |
Error |
| E602 | Argument count does not match interface declaration | Error |
| W601 | Return value not captured for method with return type | Warning |
Codegen Expansion
The transpiler expands @@:self.method(args) into the target language’s native self-call on the generated interface method:
| Target | Expansion |
|---|---|
| Python | self.method(args) |
| TypeScript | this.method(args) |
| Rust | self.method(args) |
| C | SystemName_method(self, args) |
| C++ | this->method(args) |
| Go | s.Method(args) |
| Java | this.method(args) |
The generated interface method handles FrameEvent construction, context push/pop, kernel dispatch, and return value extraction. The self-call enters the same code path as an external call.
System Runtime
@@:system provides read-only access to the system’s runtime state from within handlers, actions, and non-static operations.
| Syntax | Meaning |
|---|---|
@@:system.state |
Current state name (read-only string) |
Current State — @@:system.state
Returns the current state name as a string, without the $ prefix. Read-only — assignment is a parse error.
$Processing {
status(): str {
@@:(@@:system.state) // returns "Processing"
}
}
@@:system.state reads from the compartment’s state field. It reflects the current state at the time of access — if a transition has been deferred but not yet processed, @@:system.state still returns the pre-transition state.
Available in: event handlers, enter/exit handlers, actions, non-static operations.
Not available in: static operations (no system instance).
Compartment
The compartment is Frame’s central runtime data structure — a closure for states that preserves state identity and all scoped data.
| Field | Purpose |
|---|---|
state |
Current state identifier |
state_args |
Arguments via $State(args) |
state_vars |
State variables ($.varName) |
enter_args |
Arguments via -> (args) $State |
exit_args |
Arguments via (args) -> $State |
forward_event |
Stashed event for -> => forwarding |
State Stack = Compartment Stack
push$ saves the entire compartment (including state variables). -> pop$ restores it.
| Transition | State Variable Behavior |
|---|---|
-> $State (normal) |
Reset to initial values |
-> pop$ (history) |
Preserved from saved compartment |
Persistence
@@[persist] generates save/restore methods.
| Language | Save | Restore |
|---|---|---|
| Python | save_state() → bytes |
restore_state(data) [static] |
| TypeScript | saveState() → any |
restoreState(data) [static] |
| Rust | save_state(&mut self) → String |
restore_state(json) [static] |
| C | save_state(self) → char* |
restore_state(json) [static] |
Persisted: current state, state stack, state/enter/exit args, state vars, forward event, domain variables.
Reinitialized on restore: _context_stack (empty), __next_compartment (null).
Restore does NOT invoke the enter handler — the state is being restored, not entered.
Field Filtering
By default every domain variable round-trips through save/restore. To exclude
one — a cache, a resource handle — tag it @@[no_persist] in the domain:
block; after restore it holds its declared default value:
domain:
n: int = 0
@@[no_persist]
connection : Connection = null // not in the blob; null after restore
@@[no_persist] is specified in RFC-0016.1. A proposed
system-level inclusion list — @@[persist_fields([...])] — is tracked in
RFC-0016 (deferred; not yet shipped).
Async
Interface methods, actions, and operations can be declared async:
interface:
async connect(url: str)
async receive(): Message
actions:
async fetch_data() {
return await http.get("/data")
}
If ANY interface method is async, the entire dispatch chain becomes async (with a couple of per-language carve-outs noted below). Async systems use a two-phase init: s = @@System() (sync construct), then await s.init() (async — fires the $> enter event). Swift is the exception: init is a reserved keyword, so the async entry point is named initAsync().
| Target | Supported | Mechanism | Caller pattern |
|---|---|---|---|
| Python | Yes | async def + await |
asyncio.run(main()) |
| TypeScript | Yes | async + await, Promise<T> |
await worker.get_status() |
| JavaScript | Yes | async + await, Promise<T> |
await worker.get_status() |
| Rust | Yes | async fn + .await, boxed futures for recursion |
runtime-dependent (tokio / async-std) |
| Dart | Yes | Future<T> foo() async + await |
await worker.get_status() |
| GDScript | Yes | bare await on dispatch calls (no keyword) |
await worker.get_status() |
| Kotlin | Yes | suspend fun — suspend→suspend calls are bare, no await keyword |
runBlocking { worker.get_status() } |
| Swift | Yes | func foo() async -> T; async entry is initAsync() (not init()) |
Task { await worker.get_status() } |
| C# | Yes | async Task<T> |
await worker.get_status() (inside an async method) |
| Java | Yes | CompletableFuture<T> on the public interface only — internal dispatch (__kernel, __router, _state_X) stays synchronous. Bodies run sync and wrap the result via CompletableFuture.completedFuture(...). |
worker.get_status().get() |
| C++ | Yes (C++23) | FrameTask<T> coroutine promise emitted header-guarded at file scope. suspend_never initial + suspend_always final — bodies run sync until a real co_await; callers extract via .get(). |
worker.get_status().get() |
| C | No | No native async/await. async on an interface method is a framec error (the test environment marks these with @@skip). |
— |
| Go | No | No async/await keyword. Goroutines + channels model concurrency differently. |
— |
| PHP | No | No native async. Fibers (PHP 8.1+) exist but framec has no PHP fiber backend. | — |
| Ruby | No | No native async. Fibers/Async gem exist but framec has no Ruby fiber backend. | — |
| Lua | No | No native async. Coroutines exist but framec has no Lua coroutine backend. | — |
| Erlang | No | gen_statem is a one-color functional async model — async isn’t applicable. |
— |
Notes:
- Kotlin is the one supported language that does not take an
awaitkeyword on internal dispatch calls — asuspend funcalling anothersuspend funis bare syntax. This is handled by the framec backend. - Java (no native async/await) uses
CompletableFuture<T>for the public interface only; the dispatch chain stays sync so the call graph doesn’t explode through.thenCompose(...). Net cost: callers.get(). - C++ target must be
cpp_23(the defaultcpp/cpp_17aliases also work, but the compiler needs ≥ C++20 for coroutines — seeframepiler_design.mdfor theFrameTask<T>model).
System Instantiation
Use @@SystemName() in native code to instantiate a Frame system. The framepiler expands this to the appropriate native constructor and validates that the system name exists and arguments match.
calc = @@Calculator()
Passing system parameters
When the system header declares parameters (see System Parameters), the call site supplies them in one of two forms. Within a single call, all arguments must use the same form — mixing positional and named is rejected.
Sigil-tagged positional form
State and enter args at the call site are tagged with the same sigils used in the declaration. Domain args remain bare. Order at the call site must match declaration order.
// Pure domain params — no sigils needed
@@system Counter(initial: int = 0) { ... }.
c = @@Counter(10)
// Mixed: state param + domain
@@system Robot($(x: int), name: str) { ... }
r = @@Robot($(7), "R2D2")
// Pure enter param
@@system Worker($>(batch_size: int)) { ... }
w = @@Worker($>(50))
// All three groups in one header
@@[main]
@@system Service($(slot: int), $>(timeout: int), name: str) { ... }
s = @@Service($(0), $>(1000), "primary")
Named form
The named form omits ordering requirements and lets you supply args by declared name. Domain args use bare name=value; state and enter args wrap the assignment in their sigil.
@@system Robot($(x: int), name: str) { ... }
r = @@Robot($(x=7), name="R2D2")
@@[main]
@@system Service($(slot: int), $>(timeout: int), name: str) { ... }
s = @@Service($(slot=0), $>(timeout=1000), name="primary")
Named-form args may be supplied in any order. Defaults are filled in for any omitted params.
Defaults are substituted at the call site
Parameters with default values may be omitted from either form. The Frame assembler substitutes the declared default expression at the tagged-instantiation expansion site, so the target language never sees it as a constructor-default — it’s a literal arg in the generated call.
@@system Counter(initial: int = 0) { ... }
c1 = @@Counter() // expands to Counter(0) — Frame substitutes the default
c2 = @@Counter(42) // expands to Counter(42)
This means default values can use any expression valid in the target language at call scope, not just at parameter-default scope. It’s also why the call site for @@Counter() works in target languages that don’t natively support default arguments (Java, C, Go, etc.).
Instantiation Validation
The framepiler validates at the assembler stage:
- The system name exists in this file.
- Sigils on the call site match the declared groups (
$(...)for state args,$>(...)for enter args, bare for domain). - All required (no-default) params are supplied.
- Named args reference declared param names (no typos).
- No duplicate named args.
- No mixing positional and named within a single call.
- State and enter args have matching declarations on the start state’s
$Start(name: type)and$>(name: type)handlers.
Token Summary
Module-Level
| Token | Meaning |
|---|---|
@@[target("<lang>")] |
Declare target language (attribute form; required, exactly once) |
@@[persist] |
Enable serialization (attribute form) |
@@system |
Declare state machine |
State Machine
| Token | Meaning |
|---|---|
$<Name> |
State reference |
$> |
Enter handler |
<$ |
Exit handler |
$^ |
Parent state reference |
$. |
State variable prefix |
Statements
| Token | Meaning |
|---|---|
-> |
Transition |
-> "label" |
Labeled transition |
=> |
Forward |
-> => |
Transition with forwarding |
-> pop$ |
Transition to popped state |
push$ |
Push to state stack |
pop$ |
Pop from state stack |
return |
Native return (exits handler/action/operation) |
Context
| Token | Meaning |
|---|---|
@@:params.x |
Interface parameter x |
@@:return |
Return value |
@@:event |
Event name |
@@:data.key |
Call-scoped data |
Self & System
Both @@:self and @@:system are syntactic prefixes. Bare forms are errors (E603 / E604).
| Token | Meaning |
|---|---|
@@:self.method() |
Self interface call (reentrant) |
@@:system.state |
Current state name (read-only) |
Error Codes
Parse Errors (E0xx)
| Code | Name | Description |
|---|---|---|
| E001 | parse-error |
Malformed Frame syntax |
| E002 | unexpected-token |
Unexpected token in Frame construct |
| E003 | unclosed-block |
Missing closing brace or delimiter |
Structural Errors (E1xx)
| Code | Name | Description |
|---|---|---|
| E105 | missing-target |
@@[target(...)] directive missing or invalid |
| E111 | duplicate-system-param |
Duplicate parameter in system declaration |
| E113 | section-order |
System sections out of order |
| E114 | duplicate-section |
Section declared more than once |
| E116 | duplicate-state |
State name declared more than once |
| E117 | duplicate-handler |
Handler declared more than once in same state |
Semantic Errors (E4xx)
| Code | Name | Description |
|---|---|---|
| E400 | unreachable-code |
Code after terminal statement |
| E401 | frame-in-action |
Forbidden Frame statement in action or operation |
| E402 | unknown-state |
Transition targets undefined state |
| E403 | invalid-forward |
=> $^ in state without parent |
| E405 | param-arity-mismatch |
Wrong number of parameters |
| E406 | multi-system-erlang |
Multiple systems in single file (Erlang target) |
| E407 | frame-in-closure |
Frame statement inside nested function scope |
| E410 | duplicate-state-var |
State variable declared more than once |
| E413 | hsm-cycle |
Circular parent chain |
Self-Call Errors (E6xx)
| Code | Name | Description |
|---|---|---|
| E601 | unknown-iface-method |
@@:self.method() targets method not in interface: |
| E602 | self-call-arity |
Argument count does not match interface declaration |
| E603 | bare-self-reference |
Bare @@:self — must be @@:self.method(args) |
| E604 | bare-system-reference |
Bare @@:system — must be @@:system.state (or other member) |
Domain & Pop Errors (E6xx)
| Code | Name | Description |
|---|---|---|
| E605 | static-field-no-type |
Static target requires explicit type on domain field |
| E607 | state-args-on-pop |
State arguments on pop$ — popped compartment carries its own |
| E613 | field-shadows-param |
Domain field name shadows a system parameter |
| E614 | duplicate-field |
Duplicate domain field name |
| E615 | const-field-assign |
Assignment to const domain field in handler body |
Warnings (W4xx, W6xx)
| Code | Name | Description |
|---|---|---|
| W414 | unreachable-state |
State has no incoming transitions |
| W415 | handler-return-value-lost |
return expr in event handler; value not set on context stack |
| W601 | unused-self-call-return |
Return value not captured for method with return type |
Complete Example
import logging
@@[target("python_3")]
@@[persist(str)]
@@[save(save_state)]
@@[load(restore_state)]
@@system OrderProcessor (max_retries: int) {
operations:
static version(): str {
return "1.0.0"
}
interface:
submit(order)
cancel(reason)
getStatus(): str = "unknown"
machine:
$Idle {
submit(order) {
logging.info("Received order")
self.order_data = order
-> $Validating
}
}
$Validating {
$.attempts: int = 0
$>() {
$.attempts = $.attempts + 1
if self.validate(self.order_data):
-> $Processing
else:
if $.attempts >= self.max_retries:
-> $Failed
}
getStatus(): str {
@@:("validating")
}
}
$Processing {
$>() {
logging.info("Processing order")
}
cancel(reason) {
-> (reason) $Cancelled
}
getStatus(): str {
@@:("processing")
}
}
$Cancelled {
$>(reason) {
logging.info(f"Cancelled: {reason}")
}
}
$Failed {
$>() {
logging.error("Order failed")
}
}
actions:
validate(data) {
return data is not None
}
domain:
max_retries: int = 3
order_data = None
}
if __name__ == '__main__':
proc = @@OrderProcessor(5)
proc.submit({"item": "widget", "qty": 3})
Appendix: Frame Syntax Taxonomy
Frame’s surface syntax divides into a small, closed set of categories. This
appendix names each with standard compiler terminology and is the source of the
vocabulary used throughout this guide. Every classification here is verified
against emitted code by framec/tests/syntax_taxonomy.rs — it states what the
compiler does, not what the syntax looks like.
The central fact: Frame has almost no expression grammar of its own. There
are no operators, literals, precedence, or control-flow keywords (if/while/
for are not Frame tokens). The value-bearing parts of a handler line are
native code; Frame contributes only references (which splice a value into
that native expression) and calls (which produce one). So in
self.total = $.x + n * 2, the whole line is a native expression with a single
Frame reference ($.x → self.x) spliced in.
Categories
-
Sections — the five block headers that partition a system:
interface:,machine:,actions:,operations:,domain:. -
Declarations — introduce a named entity: the system; a state (
$State(params) => $Parent); a handler (event(params): ret, plus the$>enter and<$exit lifecycle handlers); interface / action / operation methods; a state variable ($.x: T = init); a domain field ([const] x: T = init). - Statements — executed for effect; yield no value:
- Control flow: transition
->, forward=>/=> $^, pushpush$, pop-> pop$. - Mutations (property setters):
$.x = e,@@:data.key = e,@@:return = e, and@@:(e)(sugar for@@:return = e). - Exit-return:
@@:return(e)— a setter plus an exit.
- Control flow: transition
- Expressions — yield a value. Frame has exactly two kinds:
- Property references (getters):
$.x,@@:return,@@:data.key,@@:event,@@:params.x,@@:system.state,@@:self. - Call expressions:
@@:self.method(args)(re-entrant self-dispatch) and@@Sys(args)/@@!Sys()(system instantiation). Both are usable in value position (assignment RHS) and, standalone, as expression-statements.
- Property references (getters):
-
Attributes / Pragmas — compile-time metadata, never runtime:
@@[target(...)],@@[persist],@@[main],@@[create/save/load/no_persist], and the bare directives@@import,@@codegen,@@run-expect,@@skip-if,@@timeout. - Native code — opaque target-language passthrough. The only place where whitespace belongs to the user rather than to Frame.
Properties and accessors
The organizing concept beneath categories 3–4 is the property: a named, Frame-managed place value. A property exposes up to two accessors:
- a getter (read) — a Reference; it is an expression (yields a value);
- a setter (write) — a Mutation; it is a statement (a store).
| Property | Getter (Reference) | Setter (Mutation) |
|---|---|---|
$.x |
yes | yes |
@@:data.key |
yes | yes |
@@:return |
yes | yes (= e, @@:(e); @@:return(e) also exits) |
@@:event |
yes | — (read-only) |
@@:params.x |
yes | — (read-only) |
@@:system.state |
yes | — (read-only) |
@@:self |
yes | — (read-only) |
“Two kinds of accessor” = getter and setter. A read-only property has only a getter.
The word “return” names two unrelated things
- Native
return e— the host language’s own keyword. Passthrough: emitted verbatim. It is not a Frame construct; the parser only recognizes it so it can reason about control flow (does this path return?). - Frame return — the
@@:family that writes the Frame-managed return slot on the event context:@@:return = e(setter),@@:(e)(sugar for it),@@:return(e)(setter + exit), and@@:return(getter). These are not passthrough — each lowers to a read/write of the runtime return slot.
Whitespace sensitivity
- Tier A — whitespace-invariant. All structural Frame tokens (every
statement, reference, call, and the
@@:/$.families). Whitespace between Frame tokens — including line breaks, tabs, and\r\n/\r— is insignificant: any permutation must produce byte-identical output. - Tier B — whitespace-significant. The
domain:section (indentation marks the section’s end) and section ordering. - Tier C — native passthrough. Whitespace is the user’s and is preserved verbatim.
Authority
The construct list is derived from the lexer’s Token set and the parser’s
Statement variants; the category each construct belongs to is pinned by
framec/tests/syntax_taxonomy.rs, which asserts the lowered form (statement vs
reference vs mutation vs passthrough) against emitted code. When this guide uses
a term — statement, expression, property, accessor, reference,
mutation — it means it in the sense defined here.