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 (@@:system reservation)

Implementation status

Shipped on all 17 backends, matrix-green at each step:

  • @@:self.field scalar 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 Java self.this. rewrite is removed (passthrough restored; @@:self.field lowers to this.field via the expansion, not a rewrite). The Erlang self.#data mechanism is kept — it is Erlang’s fundamental record field-access, which @@:self.field relies on (a @@:self.<field> is normalized to native self.<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 bare self.ship.respawn() (no @@:) is native code: non-portable, never transformed. On targets without a self, 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/Rc in Rust; */&/ shared_ptr/unique_ptr in 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.field remains legal native code — non-portable, valid only where the host language defines self. 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 @@:self with 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): ship must be a domain field (else E609); respawn is 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.field lowering + 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 native self.field to @@:self.…; (d) delete the three bolted-on native-self.field rewrites (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:

  1. Field access → portable: self.field / this.field / this->field / $this->field in handler bodies → @@:self.field.
  2. Embedded-system calls → portable: native self.sub.method()@@:self.sub.method().
  3. Self-calls → blessed: native self.method() targeting the own interface → @@:self.method().
  4. 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 + 1 is 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

  1. Per-target self.field rewrites (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.
  2. 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.
  3. Auto-prefix bare field names (extend value = valueself.value). Rejected: requires parsing/rewriting native code to find bare field identifiers — the opposite of passthrough — and still leaves the self-call unguarded.
  4. 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.field rewrites are gone; grep finds no self.→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.field lowers to X” claim; frame_language.md:517/:790 corrected.
  • @@:self.… inside strings and comments is not lowered (boundary-safe via the FSM scanner, not a textual pass).