RFC-0046 — @@:self as the portable, blessed self-reference
- Status: Implemented (branch
rfc-0046-self-field) - Author: Mark Truluck mark.truluck@cogiton.com
- Created: 2026-06-09
- Builds on: RFC-0006 (self interface call
@@:self.method()), RFC-0013 (@@/@@:syntax), RFC-0045 (@@:systemreservation)
Implementation status
Shipped on all 17 backends, matrix-green at each step:
@@:self.fieldscalar access (read + assignment LHS) + E609.@@:self.field.method(args)— embedded-system call (kind-15 segment) and scalar-field native method call.@@:self.method(args)— interface self-call with the caller-side transition guard (RFC-0006, unchanged).@@:self.action(args)— direct action call (no transition guard).- All four forms work in handler bodies, action bodies, and operation bodies
(action/operation bodies route through a scanner-based
expand_self_in_body; bodies were native passthrough before). - Native-
self.rewrites: the Javaself.→this.rewrite is removed (passthrough restored;@@:self.fieldlowers tothis.fieldvia the expansion, not a rewrite). The Erlangself.→#datamechanism is kept — it is Erlang’s fundamental record field-access, which@@:self.fieldrelies on (a@@:self.<field>is normalized to nativeself.<field>at the Erlang body-processing entry); it is not a bolted-on transform.
d-cross — done. The C and Erlang cross-system call rewriters (textual,
paren-balance, not string/comment safe) are removed. A cross-system call
@@:self.field.method(args) is now emitted directly from the kind-15 segment:
Sys_method(self->field, args) on C, module:method(self.field, args) on Erlang
(the existing self.→Data#data. pass supplies the Pid receiver; the field
write #data threading is a separate mechanism and was never part of the call
rewriter). framec’s own native cross-system fixtures (2 C, 14 Erlang — the
untyped field = @@System() form included) were migrated to
@@:self.field.method(). ~230 lines of textual rewriting deleted; full
17-language matrix green.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.
Summary
@@:self becomes the single, portable, framec-lowered way to reference a system
from inside its own handlers. One rule governs the whole feature:
Everything under
@@:self.is framec’s to lower — the entire member chain. framec emits the correct per-target punctuation throughout. Anything not under@@:is native code and passes through untouched.
Under that rule, @@:self. covers four member forms:
| You write | <member> resolves to |
framec emits (C++ / Python) |
|---|---|---|
@@:self.method(args) |
own interface method | reentrant self-call + transition guard (RFC-0006) |
@@:self.score |
scalar domain field | this->score / self.score |
@@:self.ship.respawn(args) |
embedded system’s interface method | this->ship->respawn(args) / self.ship.respawn(args) |
@@:self.name.upper() |
method on a scalar/native field | this->name.upper() / self.name.upper() |
Because the whole @@:self. chain is a Frame construct, framec owns both the
storage representation of a field and the punctuation of any call on it — so
they always agree, on every target, with no user hint. A bare native
self./this. (no @@:) stops being a blessed construct: framec transforms
@@:self.… and nothing else, restoring verbatim passthrough.
This closes a real expressiveness hole (no portable way to touch a field or call
an embedded system existed), removes a class of silent miscompiles, makes
@@:self.method() the only correct self-call (the transition guard lives
there and nowhere else), and dissolves the embedded-system .-vs--> problem
that motivated issue #69 without any per-target native-code rewriting.
This is a breaking change, made deliberately before Frame has public users.
Motivation
1. No portable way to reference a field
Frame’s contract is verbatim passthrough of native code, with a small @@:
namespace of portable constructs. Field access fell through the crack: the docs
told authors to write self.field / this.field / this->field per target
(frame_language.md:517) — i.e. per-target source. A system that touches a domain
field could not be written once and compiled to all 17 backends. A literal
self.score compiles only on the five targets whose host language has a self
(Python, Rust, Swift, Lua, GDScript) and is a hard error on the rest — issue
#69 is one instance.
2. The “fix” so far bolted transforms onto native code
Three backends grew ad-hoc rewrites of bare self.field into the native receiver
— C++ (self.→this->), Java (self.→this.), Erlang (record update). These
contradict the passthrough contract (framec reaching into native code), and they
are inconsistent: the same self.field is silently rewritten on 3 targets and
silently miscompiled on 6 others, with the matrix green throughout because every
fixture sidesteps the issue by writing the native receiver directly. No portable
construct exists, so nothing portable is tested, so the gap is invisible.
3. @@:self.method() is the only correct self-call
Per RFC-0006, @@:self.method(args) emits a caller-side transition guard at
the call site: after the self-call returns, if the callee transitioned, the
caller’s remaining handler body is short-circuited (if ctx._transitioned:
return; on Python/TS; the case Data#data.frame_current_state of … wrapper on
Erlang). A native self.method() reaches the same generated interface wrapper (so
the transition executes) but gets no caller-side guard — so the caller’s
statements after the call keep running against a system that has already left the
state. That is a silent semantic bug. frame_language.md:790 currently claims the
two are equivalent; this RFC corrects that.
4. Embedded-system calls are not portable as native code
Calling an embedded system’s interface method — self.ship.respawn() where
ship: Ship is a domain field — is not portable native code, and not because of
storage alone: method-call punctuation itself differs across targets. C++/PHP
on a pointer/handle use ->; the dot-languages use .; PHP uses -> for every
object method call; C has no method syntax at all (Ship_respawn(self->ship));
Erlang dispatches through a process (sys:respawn(...)). No single native spelling
works everywhere, so this must be a Frame construct framec lowers — there is no
“write it natively and pass it through” that is portable.
Design
The grammar
@@:self is a syntactic prefix, never a bare value (RFC-0006: bare @@:self is
E603). It is always chained with at least one member:
@@:self.<member> // field access OR method reference
@@:self.<member>(args) // self interface call
@@:self.<field>.<member2>(args) // call through a field (embed or native)
<member> and <field> are resolved against the system’s symbol table, which is
what makes the per-target punctuation decidable at compile time.
Member resolution
@@:self.X where X is… |
Form | Lowering |
|---|---|---|
an interface method, with (args) |
self-call | RFC-0006 (kernel dispatch + caller-side transition guard), unchanged |
| a scalar domain field | field access | native receiver + accessor (table below) |
a scalar field, then .m(args) |
native method call on the field’s value | receiver + field + native .m(args) |
an embedded-system field, then .m(args) |
embedded interface call | receiver + field + per-target call punctuation, routed through the embed’s dispatch |
| an embedded-system field, bare | field reference | native receiver + accessor (rare; reference to the sub-system object) |
| none of the above | error | E609 |
The scalar-field-vs-system-field distinction is read from the domain symbol’s
declared type: a field whose type is a defined system is an embed; anything
else is scalar. framec already tracks defined systems (defined_systems) and
domain symbol types (arcanum).
Scalar field lowering (@@:self.field)
@@:self.<field> (read, or assignment LHS) lowers to the target’s native
member access:
| Target | @@:self.score |
|---|---|
| C++ | this->score |
| PHP | $this->score |
| C | self->score |
| Python, Rust, Swift, Lua, GDScript | self.score |
| TypeScript, JavaScript, Java, Kotlin, C#, Dart | this.score |
| Go | s.score |
| Ruby | self.score (via attr_accessor) |
| Erlang | self.score → domain post-pass threads it: read Data#data.score, write DataN = Data#data{score = …} |
Pointer-receiver targets (C++, PHP, C) use ->; the rest use .. Reads are a
uniform receiver prefix. Writes (@@:self.field = expr) delegate to each
backend’s existing domain-field assignment codegen, which already exists for
native domain writes — most are an lvalue prefix; Ruby uses the accessor
(self.score = x, since fields are attr_accessor); Erlang threads a new
#data binding (no in-place mutation). No second, divergent write path is
introduced.
Embedded-system call lowering (@@:self.ship.respawn(args))
When <field> is an embedded system, framec emits the full call — field access +
method invocation — in the target’s idiom, routed through the embed’s generated
interface (so it runs the embed’s kernel dispatch):
| Target | @@:self.ship.respawn(a, b) |
|---|---|
| C++ | this->ship->respawn(a, b) |
| PHP | $this->ship->respawn(a, b) |
| Python, Rust, Swift, Lua, GDScript, Ruby | self.ship.respawn(a, b) |
| TS, JS, Java, Kotlin, C#, Dart, Go | this.ship.respawn(a, b) / s.ship.respawn(a, b) |
| C | Ship_respawn(self->ship, a, b) (existing cross-system call form) |
| Erlang | the existing cross-system dispatch (sys:respawn(Data#data.ship, …) / process call) |
No caller-side transition guard is emitted for an embed call: the call enters a
different system’s dispatch; any transition it triggers is internal to the embed
and does not affect the caller’s transition state. (Contrast the self-call in §3.)
framec reuses the C and Erlang cross-system machinery that already exists (the C
Sys_method(self->field, …) rewrite and the Erlang process dispatch); the dot/
arrow targets gain a straightforward emission.
Storage is framec’s internal detail — no hint
Because the whole @@:self.ship.respawn() chain is framec-emitted, framec may
store the embed however each backend prefers (C++ std::shared_ptr<Ship>, Rust an
owned field, …) and emit the matching punctuation. The two never disagree, so a
@@[shared] / @@[ref] storage hint is not needed and not introduced. The
only thing that ever cared about the storage representation — the call’s ./->
— is now generated by framec, not written by the user.
What stays native — the reference boundary
- No-
@@:access is native. A bareself.ship.respawn()(no@@:) is native code: non-portable, never transformed. On targets without aself, it is the author’s error, exactly like any other invalid native code. - Passing a system by reference — as an interface/method parameter, or sharing
one embed between two systems — is native idiom, outside the
@@:namespace. Reference and ownership semantics (&/&mut/Box/Rcin Rust;*/&/shared_ptr/unique_ptrin C++; …) are per-language memory-model concerns that no portable Frame attribute can capture without becoming a lossy lowest-common- denominator. The author writes the native type; framec passes it through. This RFC deliberately does not add a Frame reference/ownership construct.
Removal of native self. as a blessed construct
- framec MUST NOT transform bare
self./this.in handler bodies. The C++/Java/Erlang field rewrites are deleted; the reverted PR #70 stays reverted. - Bare
self.fieldremains legal native code — non-portable, valid only where the host language definesself. framec neither blesses nor rewrites it. - Native
self.method()self-calls are unsupported (they miss the transition guard, §3). framec does not police this — it does not inspect native code, so it neither warns nor errors.@@:self.method()is documented as the blessed form; a native self-call is the author’s code and forgoes the guard. (Detecting a self-call in native code is undecidable, so a detector would be both a passthrough violation and unsound.)
Validation
@@:self.<member> is parsed into structured AST and arcanum carries the symbols,
so resolution is a compile-time decision. Diagnostics:
- E603 — bare
@@:selfwith no member (unchanged). - E609 (new; next free after RFC-0045’s E608) —
@@:self.<member>references no known interface method or domain field. - E601/E602 —
@@:self.method()unknown method / wrong arg count (unchanged). - Embedded-system call
@@:self.ship.respawn(args):shipmust be a domain field (else E609);respawnis resolved against the embed’s interface where that information is available in-module (cross-file embeds defer to the host compiler, consistent with RFC-0024/bug #30 — framec does not invent cross-module name resolution). Arg-count checking for embed calls is best-effort in-module; out of scope where the embed is imported.
Decisions (resolved)
- D1 — Staged rollout. Matrix green at each step. (a) scalar
@@:self.fieldlowering + E609 on all 17 targets (implemented); (b) the embedded-system call form (@@:self.field.method()) + parser extension; (c) migrate framec’s own fixtures/demos/docs from nativeself.fieldto@@:self.…; (d) delete the three bolted-on native-self.fieldrewrites (PR #70 already reverted). - D2 — No policing of native self-calls. framec does not inspect native code.
- D3 — E609 strict in v1.
@@:self.<member>is structured AST; arcanum resolves it; unknown member → E609 with the initial lowering. - D4 — No storage hint. Embed storage is framec’s internal detail; the call
form makes a
@@[ref]/@@[shared]attribute unnecessary. - D5 — Reference-passing is native idiom. No Frame reference/ownership
construct; the
@@:namespace stops at member access into the system’s own interface, fields, and embedded interfaces.
Migration
Pre-public breaking change; no published program is affected. framec’s own demos, fixtures, and docs migrate mechanically:
- Field access → portable:
self.field/this.field/this->field/$this->fieldin handler bodies →@@:self.field. - Embedded-system calls → portable: native
self.sub.method()→@@:self.sub.method(). - Self-calls → blessed: native
self.method()targeting the own interface →@@:self.method(). - Self-native targets (Python/Rust/Swift/Lua/GDScript) keep compiling on the old spelling, but migrate for portability.
Per-language guides drop every “Frame’s self.field lowers to X” claim (they teach
the now-removed rewrite) and instead teach: write @@:self.field for portable
field access and @@:self.sub.method() for embedded calls; bare self. is native
and non-portable. frame_language.md:517 and :790 are rewritten per Motivation
§1 and §3.
Drawbacks
- Verbosity.
@@:self.score = @@:self.score + 1is heavier than the native form. The cost lands on cross-target authors, who currently cannot express this portably at all. - Parser surface. The chained
@@:self.field.method()form extends the context-parser FSM (a new segment shape), with the scalar-vs-embed branch decided at expansion from the field’s type. - Churn. Deleting three rewrites and migrating fixtures/docs is non-trivial; the matrix must be re-greened with the portable constructs actually exercised on all 17 targets — which is the point.
Alternatives considered
- Per-target
self.fieldrewrites (the #69/PR-70 trajectory). Rejected: the passthrough violation, inconsistent across targets, and never produces a portable, testable construct — so it cannot fix the self-call correctness bug or the embed-call punctuation problem. - A
@@[ref]/@@[shared]storage hint. Rejected: it makes framec own a memory-model abstraction that no single attribute captures across languages, and it is unnecessary — once the embed call is framec-emitted, storage is an internal detail and the hint controls nothing the user can observe. - Auto-prefix bare field names (extend
value = value→self.value). Rejected: requires parsing/rewriting native code to find bare field identifiers — the opposite of passthrough — and still leaves the self-call unguarded. - Make native self-calls emit the guard. Rejected: passthrough violation, and
undecidable (is
x.method()a self-call?).
Acceptance
- All four
@@:self.forms lower correctly on all 17 backends, proven by portable fixtures compiled and run on every target in the matrix (scalar read, scalar write, embedded call with 0/N args, self-call, scalar-field native method call). - The three native-
self.fieldrewrites are gone;grepfinds noself.→receiver rewrite in codegen. - E603/E609 fire correctly;
@@:self.method()keeps its transition guard; native self-calls produce no diagnostic (D2). - Docs carry no “
self.fieldlowers to X” claim;frame_language.md:517/:790corrected. @@:self.…inside strings and comments is not lowered (boundary-safe via the FSM scanner, not a textual pass).