ADR 0056: Native Erlang-Backed Actors — native: and self delegate
Status
Accepted (2026-03-07) — Revised from initial @native annotation design
Context
The Problem
Beamtalk Actor classes today are compiled to gen_server modules by the Rust compiler. The gen_server uses beamtalk_actor:dispatch/4 to route messages to method functions stored in the __methods__ map at init time. This pipeline works well for Actors whose logic is entirely in Beamtalk.
However, some Actors require hand-written Erlang gen_server implementations:
Subprocess— manages an OS port, deferred gen_server replies, and line buffering across two I/O channels. This logic cannot be expressed in Beamtalk.TranscriptStream— maintains a ring buffer with pub/sub subscriber management and dead-process monitoring viahandle_info/2. These OTP patterns require direct gen_server control.
Currently, these classes use (Erlang module) FFI to call wrapper functions on the backing Erlang module, passing self explicitly. The wrapper functions vary by class:
Subprocess— instance method shims extract the pid fromselfand callbeamtalk_actor:sync_send/3. Class-side shims (open:args:) use adispatch/3function. This approach already gets dead actor detection and timeout handling.TranscriptStream— shims delegate to adispatch/3function that operates on process-dictionary state within the actor's own process. This avoids gen_server re-entry deadlocks but uses a different dispatch path from standard Actor messaging.
Both approaches share these problems:
- Requires repetitive boilerplate — every method repeats
(Erlang beamtalk_module) selector: self - Requires the backing Erlang module to export public wrapper/shim functions in addition to its gen_server callbacks
- Provides no declared relationship between the
.btfile and its backing module — the compiler has no knowledge of which Erlang module backs the Actor - Previously used
@primitivestubs, requiring hardcoded entries ingenerated_builtins.rs— some classes have been migrated to FFI but thegenerated_builtins.rsentries remain - Each backing module implements its own dispatch pattern (Subprocess uses
sync_send, TranscriptStream uses process-dictionarydispatch/3) — there is no uniform protocol for native-backed Actors - TranscriptStream's process-dictionary
dispatch/3exists because its FFI shims are called from inside the compiled actor'shandle_call(during method dispatch) — usinggen_server:callback to the same process would deadlock. This complexity is an artifact of the FFI-inside-compiled-actor architecture, not an inherent requirement of TranscriptStream's logic
Current State
// TranscriptStream.bt today — FFI wrapper calls
Actor subclass: TranscriptStream
classState: current = nil
class current -> TranscriptStream => self.current
show: value :: Object -> Nil =>
(Erlang beamtalk_transcript_stream) show: self value: value
cr -> Nil => (Erlang beamtalk_transcript_stream) cr: self
subscribe -> Nil => (Erlang beamtalk_transcript_stream) subscribe: self
unsubscribe -> Nil => (Erlang beamtalk_transcript_stream) unsubscribe: self
recent -> List => (Erlang beamtalk_transcript_stream) recent: self
clear -> Nil => (Erlang beamtalk_transcript_stream) clear: self
The backing beamtalk_transcript_stream.erl implements the full gen_server behaviour with handle_call/3 using the {Selector, [Args]} wire protocol. It also exports public shim functions (e.g. show/2, cr/1) that adapt FFI-facing calls into an internal dispatch/3 function operating on process-dictionary state. These shims exist to centralise selector-to-function mapping and avoid gen_server re-entry deadlocks when the transcript is called from within its own callbacks.
Similarly for Subprocess:
// Subprocess.bt today — FFI wrapper calls
Actor subclass: Subprocess
writeLine: data -> Nil =>
(Erlang beamtalk_subprocess) 'writeLine:': self data: data
readLine -> Object =>
(Erlang beamtalk_subprocess) readLine: self
...
Constraints
- Hand-written logic must stay in Erlang — line buffering, deferred replies, port management, ring buffers, and pub/sub require direct OTP gen_server control
- No
state:declarations — instance state lives entirely in the gen_server; the.btfile declares the Beamtalk API, not the internal state. (classState:for class-level state is fine — it's independent of the gen_server) - Wire protocol compatibility — sync requests use
{Selector, [Args]}matchingbeamtalk_actor:sync_send/3viagen:call/4. Forgen_serverbacking modules this meanshandle_call/3; forgen_statembacking modules this means{call, From}events in state callbacks - Works with gen_server and gen_statem — both route through
gen:call/4, so a single dispatch path covers both OTP behaviour types - Explicit module naming — the
.btfile must declare which Erlang module it is backed by; implicit discovery is not acceptable - Open to library authors — any library author must be able to back an Actor with a hand-written gen_server without modifying the Rust compiler
- ClassBuilder integration — the mechanism must fit the ClassBuilder protocol (ADR 0038), not introduce a parallel annotation system
- Static compiler visibility — the backing module name must be visible to the Rust compiler at parse time (no runtime-only class methods like Pharo's
ffiLibraryName, since the compiler is not running inside the image)
Wire Protocol
beamtalk_actor:sync_send/3 — used for all Beamtalk Actor message sends — dispatches via:
gen_server:call(ActorPid, {Selector, Args})
where Selector is an atom and Args is a list. Generated actors wrap replies as {ok, Result} or {error, Error} per BT-918. However, sync_send/3 also has a backward-compatibility DirectValue fallback (beamtalk_actor.erl:435) that passes through unwrapped values — this is how beamtalk_subprocess.erl and beamtalk_transcript_stream.erl work today.
For async methods, beamtalk_actor:cast_send/3 sends gen_server:cast(Pid, {cast, Selector, Args}). Some backing gen_servers (e.g. TranscriptStream's show:, cr) use casts for non-blocking semantics.
The current FFI approach has no uniform dispatch protocol — Subprocess's instance shims already use sync_send/3 (getting dead actor detection), while TranscriptStream uses a process-dictionary dispatch/3 path that bypasses standard Actor messaging. This ADR establishes a single, consistent dispatch protocol for all native-backed Actors via the generated facade.
Decision
native: Keyword on subclass:
An Actor subclass: may include a native: keyword to declare that its gen_server implementation is provided by the named Erlang module rather than generated by the compiler. This is a keyword argument on the subclass: message, not a class-level annotation — it integrates with the ClassBuilder protocol (ADR 0038).
Actor subclass: Subprocess native: beamtalk_subprocess
/// Convenience factory — open a subprocess with command and args.
class open: command args: args =>
self spawnWith: #{"command" => command, "args" => args}
/// Convenience factory — open a subprocess with environment and working directory.
class open: command args: args env: env dir: dir =>
self spawnWith: #{
"command" => command,
"args" => args,
"env" => env,
"dir" => dir
}
/// Write a line to the subprocess's stdin.
writeLine: data -> Nil => self delegate
/// Read one line from stdout. Blocks until a line is available.
readLine -> Object => self delegate
/// Read one line from stdout with a timeout in milliseconds.
readLine: timeout -> Object => self delegate
/// Read one line from stderr.
readStderrLine -> Object => self delegate
/// Read one line from stderr with a timeout.
readStderrLine: timeout -> Object => self delegate
/// Return a Stream of stdout lines.
lines -> Stream => self delegate
/// Return a Stream of stderr lines.
stderrLines -> Stream => self delegate
/// Get the exit code. Returns nil if still running.
exitCode -> Object => self delegate
/// Force-close the subprocess.
close -> Nil => self delegate
self delegate — Pharo ffiCall: Pattern
Method bodies that are => self delegate are delegation declarations — the compiler generates a facade that forwards the message to the backing gen_server via beamtalk_actor:sync_send/3. Methods with full Beamtalk bodies (like open:args:) compile normally.
delegate is a real sealed method defined on Actor, following Pharo's ffiCall: pattern:
// Actor.bt
/// Delegate to the native backing module.
/// The compiler transforms this call on native: classes.
/// Calling on a non-native Actor raises an error.
/// Sealed to prevent subclasses from shadowing the name.
sealed delegate =>
Error signal: "delegate called on a non-native Actor"
The method is sealed to prevent user-defined Actor subclasses from accidentally shadowing it with a business-logic method (e.g. a DelegationManager actor). Since delegate is a compiler-recognized pattern, shadowing it would silently break the native: mechanism.
Compiler recognition: Only the literal unary send self delegate as the entire method body is recognized and transformed. Indirect forms (x := self. x delegate, self perform: #delegate) are not recognized — they compile normally and hit the sealed delegate fallback, which raises an error. The compiler or LSP should warn if a native: class has a method body that is neither self delegate nor a full Beamtalk expression.
Type annotations: Full type annotations are recommended on all native: class methods, especially self delegate methods. Since the method body is opaque to the type checker and LSP, the type annotation is the only source of type information — it serves as the API contract between the .bt declaration and the Erlang implementation. The stub generator also uses return types to generate correct {ok, Result} wrapping. The compiler emits a warning if a self delegate method has no return type annotation:
warning: native delegate method 'readLine:' has no return type annotation
--> Subprocess.bt:8
|
8 | readLine: timeout => self delegate
| ^^^^^^^^^^^^^^^^^^
|
= help: add a return type: readLine: timeout -> Object => self delegate
= note: native delegate methods are opaque — the return type annotation is the only type information available
Reflection: delegate appears in Actor localMethods and respondsTo: #delegate returns true for all Actors. This is a visible implementation detail but consistent with Pharo's ffiCall: appearing in Object's method dictionary. The LSP should exclude delegate from autocompletion suggestions for non-native: actors.
The compiler recognizes self delegate in the AST of a native: class and transforms it into a sync_send facade call. This is the same pattern as Pharo's ffiCall: — a real method exists as a safety net, but the compiler intercepts and replaces it:
| Pharo | Beamtalk |
|---|---|
ffiLibraryName on class | native: module on subclass: |
self ffiCall: #(...) in method | self delegate in method |
| Compiler generates C trampoline | Compiler generates sync_send facade |
primitiveFailed fallback | Error signal: fallback |
Unlike Pharo's ffiLibraryName (a class-side method), Beamtalk uses native: as a keyword on subclass: because the Rust compiler needs this information at parse time — there is no live image to query during compilation.
ClassBuilder Integration
native: is a keyword argument on subclass: that flows through the ClassBuilder protocol (ADR 0038). ClassBuilder gains a native: method:
// ClassBuilder.bt — added
/// Set the backing Erlang module for native delegation.
native: anErlangModule =>
backingModule := anErlangModule
The codegen cascade for a native: class:
%% Generated module init
CB = send('bt@object':'module_class'(), 'classBuilder', []),
_ = send(CB, 'name:', ['Subprocess']),
_ = send(CB, 'native:', [beamtalk_subprocess]),
_ = send(CB, 'methods:', [#{'writeLine:' => fun ?MODULE:'dispatch_writeLine:'/2,
readLine => fun ?MODULE:'dispatch_readLine'/1,
...}]),
send(CB, 'register', []).
This is the same ClassBuilder cascade pattern used for all class creation — native: is just another setter, like name:, fields:, and methods:.
What the Compiler Generates
For a native: class, the compiler generates a facade module (bt@stdlib@subprocess) instead of a full gen_server module. The facade:
spawn/1— callsBackingModule:start_link(Config)and wraps the result:
%% Generated facade — bt@stdlib@subprocess (Erlang source notation for clarity)
'spawn'/1 = fun(Config) ->
case beamtalk_subprocess:start_link(Config) of
{ok, Pid} ->
{'beamtalk_object', 'Subprocess', 'bt@stdlib@subprocess', Pid};
{error, Reason} ->
%% raise beamtalk instantiation_error
...
end
-
spawnWith:(class method) — passes the config dictionary tospawn/1 -
has_method/1— returnstruefor all selectors declared with=> self delegateor any Beamtalk body -
Dispatch functions (for
self delegatemethods) — delegate viabeamtalk_actor:sync_send/3:
%% Generated: writeLine: data dispatch
'dispatch_writeLine:'/2 = fun(Data, Self) ->
Pid = element(4, Self),
beamtalk_actor:sync_send(Pid, 'writeLine:', [Data])
- Class-side method bodies (like
open:args:) compile to normal Beamtalk codegen — they callself spawnWith:which invokes the generatedspawnWith:class method.
Backing Gen_Server Protocol
The hand-written gen_server module must implement:
%% start_link/1 — called by the generated facade's spawn/1
-spec start_link(map()) -> {ok, pid()} | {error, term()}.
start_link(Config) -> gen_server:start_link(?MODULE, Config, []).
%% handle_call/3 — uses {Selector, [Args]} wire format
handle_call({'writeLine:', [Data]}, _From, State) ->
NewState = do_writeLine(Data, State),
{reply, nil, NewState};
handle_call({readLine, []}, From, State) ->
%% Deferred reply: gen_server:reply(From, Line) called later
{noreply, register_waiter(stdout, From, State)};
handle_call({exitCode, []}, _From, State) ->
{reply, maps:get(exit_code, State, nil), State};
Reply format: sync_send/3 prefers {ok, Result} / {error, Error} wrapped replies (per BT-918) but also supports a DirectValue fallback for backward compatibility. Existing hand-written gen_servers return raw values today and work correctly via the fallback path.
New native: gen_servers MUST use {ok, Result} wrapping. The DirectValue fallback cannot distinguish a legitimate return value of {error, Reason} from an actual error — if a backing gen_server returns {error, <<"not found">>} as a value (e.g. a database query result), sync_send/3 will misinterpret it as an error and raise a #beamtalk_error{}. Existing stdlib gen_servers (beamtalk_subprocess.erl, beamtalk_transcript_stream.erl) are grandfathered via the DirectValue fallback because they do not return {error, _} tuples as values; they should be migrated to {ok, Result} wrapping as part of Phase 2. The stub generator produces {ok, Result} wrapping by default.
Async (cast) methods: When called with !, cast_send/3 sends {cast, Selector, Args} to the backing gen_server's handle_cast/2. Backing gen_servers that support fire-and-forget semantics (e.g. TranscriptStream's show:) implement handle_cast({cast, 'show:', [Value]}, State) alongside the standard handle_call clause. Both call and cast paths are generated by the facade — no method-level annotation is required. Library authors who want a selector callable via both . (sync) and ! (async) should implement both handle_call and handle_cast clauses for that selector. The stub generator generates handle_call clauses only — add handle_cast manually for selectors that support fire-and-forget.
Other gen_server callbacks: The facade does NOT intercept handle_info/2, terminate/2, or code_change/3. These callbacks are implemented directly by the backing gen_server. The facade only generates spawn/1, spawnWith:, has_method/1, and dispatch functions.
start_link arity: The facade always calls BackingModule:start_link(Config) where Config is the spawnWith: dictionary. When spawn is called without arguments, Config is #{} (empty map). Backing modules MUST export start_link/1 accepting a map. There is no fallback to start_link/0 — a single arity simplifies the contract.
start_link failure: If start_link/1 returns {error, Reason}, the facade raises a #beamtalk_error{kind = instantiation_error, details = #{reason => Reason}} with the original Reason preserved under details.reason. If start_link/1 crashes (throws an exception), the exception propagates to the caller as a #beamtalk_error{kind = instantiation_error, details = #{reason => CrashReason}}.
Stub Generation
Because the .bt file fully describes the Actor's API — selectors, arities, and return types — tooling can auto-generate a skeleton gen_server:
$ beamtalk gen-native MyActor
%% Generated from MyActor.bt — fill in implementations
-module(my_library_actor).
-behaviour(gen_server).
-export([start_link/1, init/1, handle_call/3]).
start_link(Config) -> gen_server:start_link(?MODULE, Config, []).
init(Config) -> {ok, #{}}. %% TODO: initialise state from Config
handle_call({'doWork:', [Task]}, _From, State) ->
{reply, {ok, todo}, State}; %% TODO: implement doWork:
handle_call({status, []}, _From, State) ->
{reply, {ok, todo}, State}. %% TODO: implement status
Generated once, developer fills in the bodies. No regeneration, no mixed generated/hand-written code. The LSP could also flag mismatches: "MyActor declares doWork: but my_library_actor.erl has no matching handle_call clause."
State Exclusivity
native: Actors MUST NOT declare state: fields. The gen_server owns all instance state — it is opaque to the Beamtalk compiler. If a state: declaration is found on a native: Actor, the compiler raises an error:
error: native actor 'Subprocess' cannot declare state fields — state is owned by the backing gen_server 'beamtalk_subprocess'
classState: is permitted — class-level state (e.g. TranscriptStream's singleton current) is independent of the gen_server instance state.
gen_server vs gen_statem
Both gen_server and gen_statem expose the same gen:call/4 API internally. beamtalk_actor:sync_send/3 uses gen_server:call/2 which routes through gen:call/4 for both behaviour types, so a gen_statem-backed module receives the call correctly.
However, gen_statem does not have a handle_call/3 callback — synchronous calls arrive as {call, From} events in state callbacks:
%% gen_statem handle_event_function mode
handle_event({call, From}, {'readLine', []}, State, Data) ->
{keep_state, Data, [{reply, From, read_line(Data)}]};
handle_event({call, From}, {'exitCode', []}, State, Data) ->
{keep_state, Data, [{reply, From, maps:get(exit_code, Data, nil)}]}.
Sync and Async Dispatch
Per ADR 0043, the call site determines dispatch mode — the .bt file does not need to declare it:
agent selector.→sync_send/3→gen_server:call(Pid, {Selector, [Args]})agent selector!→cast_send/3→gen_server:cast(Pid, {cast, Selector, Args})
The backing gen_server implements handle_call/3 for selectors that must return values, handle_cast/2 for fire-and-forget selectors, or both. No method-level sync/async annotation is required.
Self-sends: native: actors with Beamtalk method bodies should not call their own self delegate methods via self synchronously — this causes a gen_server deadlock, the same constraint as any gen_server. This is existing OTP behaviour, not a new constraint.
Ports, NIFs, and Raw Processes
native: is scoped to gen_server-compatible OTP processes. For actors backed by ports, NIFs, or raw processes, use per-method (Erlang module) FFI (ADR 0028) instead:
// NIF-backed class — use FFI per method, not native:
sealed Object subclass: NativeAccelerator
class compute: data -> Object =>
(Erlang nif_accelerator) compute: data
Complete Example — TranscriptStream
/// TranscriptStream — Per-workspace shared log with pub/sub semantics.
Actor subclass: TranscriptStream native: beamtalk_transcript_stream
classState: current = nil
/// Return the current singleton instance.
class current -> TranscriptStream => self.current
/// Set the current singleton instance.
class current: instance :: TranscriptStream -> Nil => self.current := instance
/// Clear the current singleton instance.
class resetCurrent -> Nil => self.current := nil
/// Write a value to the transcript.
show: value :: Object -> Nil => self delegate
/// Write a newline to the transcript.
cr -> Nil => self delegate
/// Subscribe the calling process to receive transcript output.
subscribe -> Nil => self delegate
/// Unsubscribe the calling process from transcript output.
unsubscribe -> Nil => self delegate
/// Return recent transcript entries as a list.
recent -> List => self delegate
/// Clear the transcript buffer.
clear -> Nil => self delegate
REPL Example
agent := Subprocess open: "echo" args: #("hello")
line := agent readLine. // => "hello"
agent exitCode. // => 0
agent close.
// Streaming lines
(Subprocess open: "ls" args: #("-la")) lines do: [:line | Transcript show: line]
Error Examples
// native: with state: raises a compile error
Actor subclass: Broken native: some_module
state: count = 0 // => compile error
error: native actor 'Broken' cannot declare state fields — state is owned by the backing gen_server 'some_module'
--> Broken.bt:2
|
2 | state: count = 0
| ^^^^^^^^^^^^^^^^
|
= help: remove state declarations from native actors; state lives in the gen_server
// self delegate on a non-native Actor raises a runtime error
Actor subclass: NotNative
doStuff => self delegate
NotNative spawn doStuff
// => Error: delegate called on a non-native Actor
Library Author Example
Any library author can create a native-backed Actor without modifying the compiler:
// my_library/src/DatabasePool.bt
Actor subclass: DatabasePool native: my_db_pool
class connect: config => self spawnWith: config
query: sql -> List => self delegate
query: sql params: params -> List => self delegate
transaction: block -> Object => self delegate
close -> Nil => self delegate
%% my_db_pool.erl — standard gen_server
-module(my_db_pool).
-behaviour(gen_server).
-export([start_link/1, init/1, handle_call/3]).
start_link(Config) -> gen_server:start_link(?MODULE, Config, []).
init(Config) ->
{ok, Conn} = connect_db(Config),
{ok, #{conn => Conn}}.
handle_call({'query:', [SQL]}, _From, #{conn := Conn} = State) ->
{reply, {ok, execute(Conn, SQL, [])}, State};
handle_call({'query:params:', [SQL, Params]}, _From, #{conn := Conn} = State) ->
{reply, {ok, execute(Conn, SQL, Params)}, State};
handle_call({'transaction:', [Block]}, _From, #{conn := Conn} = State) ->
Result = run_transaction(Conn, Block),
{reply, {ok, Result}, State};
handle_call({close, []}, _From, #{conn := Conn} = State) ->
close_db(Conn),
{reply, {ok, nil}, State}.
ETS-Backed Data Structure Example
ETS tables are a natural fit for native: — table lifecycle (create, own, delete) requires Erlang, and the actor owns the table (ETS tables die with their owner process), so Actor supervision gives table durability for free:
/// A persistent key-value store backed by an ETS table.
Actor subclass: KeyValueStore native: beamtalk_kv_store
class create => self spawn
class create: name => self spawnWith: #{"name" => name}
get: key -> Object => self delegate
put: key value: value -> Nil => self delegate
delete: key -> Nil => self delegate
keys -> List => self delegate
size -> Integer => self delegate
Methods that need to bypass the gen_server (e.g. concurrent ETS reads) can use a full Beamtalk body with FFI instead of self delegate:
/// Fast read — bypasses gen_server, reads ETS directly.
get: key -> Object =>
(Erlang beamtalk_kv_store) directGet: self key: key
This mixes self delegate and FFI bodies on the same native: class — the same pattern as Subprocess's open:args: factory method.
Prior Art
Pharo — ffiCall: and <primitive: N>
Pharo's UFFI allows method bodies to declare ffiCall: — a message send that the compiler intercepts and transforms into a foreign function call. The method body looks like a normal message send, but the compiler generates native call trampolines. ffiCall: is defined as a real method on Object (returning self primitiveFailed), so calling it without compiler support produces a clear error. The library is named at the class level via ffiLibraryName.
What we adopted: The ffiCall: pattern directly — self delegate is a message send the compiler recognizes and transforms. The real method on Actor provides a runtime safety net, just as ffiCall: falls back to primitiveFailed. The class-level module declaration (native: on subclass:) mirrors ffiLibraryName.
What doesn't translate: Pharo's ffiLibraryName is a class-side method evaluated at compile time because Pharo's compiler runs inside the live image. Beamtalk's Rust compiler has no image to query, so the backing module must be declared syntactically via the native: keyword — visible to the parser at compile time.
Elixir — use GenServer and Bare Module Wrappers
Elixir GenServer behaviour expects handle_call/3 with {reply, Result, State}. Wrapping an existing Erlang gen_server in Elixir requires explicit delegation in handle_call/3 bodies — there is no annotation to say "this module backs a GenServer; generate the delegation for me." Library authors hand-write every delegation clause.
What we adopted: The per-selector {Selector, [Args]} wire protocol mirrors Elixir's handle_call/3 pattern matching style.
What we improved: self delegate generates the delegation facade automatically from the .bt declaration. Elixir has no equivalent of the compiler generating handle_call delegation clauses from a type declaration.
Gleam — @external per Function
Gleam binds each function individually to an Erlang MFA using @external(erlang, "module", "function"). There is no class-level or actor-level annotation. Gleam has no actor or gen_server concept in the language.
What we adopted: The concept of explicit module naming — native: beamtalk_subprocess names the exact Erlang module, analogous to @external(erlang, "beamtalk_subprocess", "function").
What we rejected: Per-function MFA binding for Actor methods. The native: keyword on subclass: names the module once; self delegate in each method is minimal and uniform. Per-method module/function binding is unnecessary since the {Selector, [Args]} wire protocol means the selector name IS the function routing key.
Erlang — gen_server Wrapper Pattern
Standard Erlang practice is to write thin wrapper modules with start_link/N, stop/1, and per-method delegation functions that call gen_server:call(Pid, {selector, args}). No annotation or code generation exists.
What we adopted: The {Selector, [Args]} tuple as the message format — this is idiomatic Erlang gen_server call protocol.
What we improved: The generated facade eliminates boilerplate delegation functions and ensures beamtalk_actor:sync_send/3 lifecycle semantics (dead actor detection, timeout handling) are applied consistently.
Smalltalk — <primitive: N> and subclassResponsibility
Smalltalk's <primitive: N> pragma is metadata inside the method body — the method always has a body, never bodyless. Abstract methods use self subclassResponsibility as the method body. Both patterns are explicit method bodies, not absent bodies.
What we adopted: The principle that every method has a body. self delegate is a method body, not an absent body. This follows Smalltalk convention where the body always expresses the method's intent — delegation, abstraction, or implementation.
User Impact
Newcomer (coming from Python/JS)
native: on a class declaration is a recognizable "this is backed by Erlang" signal. self delegate reads naturally as "delegate this to the native implementation." Class factory methods (open:args:) remain pure Beamtalk, so newcomers interact with a normal API. The (Erlang module) FFI they may have seen elsewhere is conceptually related — native: is the Actor-specific version that routes through the gen_server protocol.
Smalltalk Developer
self delegate follows the Smalltalk pattern that every method has a body expressing its intent — analogous to <primitive: N> in Pharo. The native: keyword on subclass: is a natural extension of the keyword message pattern they already know from Actor subclass: Name. From a usage perspective, agent writeLine: "hello" is still a message send — the delegation is invisible at the call site.
Erlang/BEAM Developer
native: maps directly to the standard Erlang gen_server wrapper pattern they already know. The {Selector, [Args]} wire format is the main constraint — documented and consistent across all Beamtalk Actor messaging. The stub generation tool (beamtalk gen-native) scaffolds the handle_call/3 clauses from the .bt declaration. gen_statem compatibility means existing state machines can be wrapped without behavioural changes.
Production Operator
native: actors behave identically to generated actors from an OTP perspective — they are gen_server processes under the standard supervision tree. observer:start(), :sys.get_state/1, and standard tracing tools work. The backing gen_server module can implement code_change/3 for hot reload state migration independently of the .bt facade. Standardising on sync_send ensures uniform dead actor detection and timeout handling across all native-backed Actors (Subprocess already used sync_send but TranscriptStream used a different dispatch path).
Tooling Developer (LSP/IDE)
native: BackingModule on the subclass: line gives the LSP an explicit module to link to for "go to implementation." self delegate methods in the IDE can offer "open backing Erlang file" navigation. Since the .bt file declares all selectors with types, completion and hover documentation work without understanding the gen_server internals. The LSP could flag mismatches between declared selectors and handle_call clauses in the backing module.
Steelman Analysis
Steelman for keeping (Erlang module) FFI for all Actor methods (current state)
- Newcomer: "I already know
(Erlang module)FFI from Value classes. One mechanism for all Erlang interop is simpler than learningnative:andself delegateas an Actor-specific variant." - BEAM veteran: "The FFI path is explicit — I can see exactly which Erlang function is being called in each method.
self delegatehides the dispatch path behind a compiler transformation." - Language designer: "
native:adds a new keyword onsubclass:, a new ClassBuilder method, and a compiler pattern-match onself delegate. The FFI approach requires no new concepts." - Tension: The implementation cost is real — a new codegen path for a small number of classes. However, the FFI approach bypasses
sync_send(missing dead actor detection and error wrapping), requires boilerplate wrapper functions in Erlang, and doesn't scale to library authors who want consistent Actor semantics.
Steelman for class-level @native annotation (previous draft of this ADR)
- Language designer: "
@native BackingModuleas a class annotation is familiar from Gleam's@external. It's explicit, grep-able, and doesn't require integrating with ClassBuilder." - BEAM veteran: "Annotations are a well-understood pattern. Adding a keyword to
subclass:changes the grammar of the most important declaration in the language." - Tension:
@nativeintroduces a class-level annotation syntax that sits outside Beamtalk's message-send model.native:as a keyword onsubclass:integrates with ClassBuilder — it's a message argument, not an annotation.native:must be a compile-time keyword because the compiler needs the backing module name during codegen to generate the facade.
Steelman for bodyless methods instead of self delegate
- Newcomer: "Why do I need to write
=> self delegatenine times? The class saysnative:— isn't that enough? Bodyless methods would be cleaner." - Language designer: "Bodyless methods are more concise. The
native:keyword on the class already provides all the context needed." - Smalltalk purist: "Smalltalk methods always have a body —
self subclassResponsibility,<primitive: N>, or actual code. A bodyless method is not Smalltalk. Butself delegateIS Smalltalk — it's a message send in the method body that expresses intent." - Tension: Conciseness vs explicitness.
self delegateis slightly more verbose but follows Smalltalk convention, provides a runtime safety net (the real method on Actor), and doesn't overload "no body" which could conflict with future abstract class support or simply look like an incomplete method.
Tension Points
- BEAM veterans prefer explicit FFI; Smalltalk developers prefer
self delegateas a message-send pattern - Newcomers want fewer concepts; library authors need the
native:mechanism to avoid compiler modifications - The stub generation tool and LSP mismatch detection reduce the cost of the
{Selector, [Args]}convention for Erlang authors
Alternatives Considered
Per-Method (Erlang module) FFI for All Actor Methods (Current State)
Keep using (Erlang beamtalk_subprocess) 'writeLine:': self data: data in each method body:
Actor subclass: Subprocess
writeLine: data -> Nil =>
(Erlang beamtalk_subprocess) 'writeLine:': self data: data
readLine -> Object =>
(Erlang beamtalk_subprocess) readLine: self
Rejected because: requires public wrapper/shim functions in the Erlang module with per-class dispatch patterns (Subprocess uses sync_send, TranscriptStream uses process-dictionary dispatch/3), is repetitive (module name repeated in every method), provides no declared relationship between class and backing module, and does not establish a uniform dispatch protocol. Library authors must manually implement whichever dispatch pattern suits their use case.
@native BackingModule Class Annotation (Previous Draft)
Annotate the class with @native above the subclass: declaration:
@native beamtalk_subprocess
Actor subclass: Subprocess
writeLine: data -> Nil => @native
Rejected because: introduces an annotation syntax (@) outside Beamtalk's message-send model, doesn't integrate with the ClassBuilder protocol, and is inconsistent with Beamtalk's keyword message style. The @native on both class and method was "two annotation types to learn."
Bodyless Methods on native: Classes
Methods without bodies on a native: class implicitly delegate:
Actor subclass: Subprocess native: beamtalk_subprocess
writeLine: data -> Nil
readLine -> Object
Rejected because: violates Smalltalk convention that every method has a body (self subclassResponsibility, <primitive: N>, or real code); overloads "no body" syntax which will conflict with future abstract class support (where bodyless methods mean "subclass must implement" — a fundamentally different intent from "delegate to backing gen_server"); provides no runtime safety net if called incorrectly; and makes methods look incomplete to readers unfamiliar with the native: class context. self delegate is explicit and follows the Pharo ffiCall: precedent.
(native) Shorthand FFI Syntax
Use the FFI syntax with native as a backreference to the class-level module:
writeLine: data -> Nil => (native) 'writeLine:': data
Rejected because: native in (native) is syntactically ambiguous — it's not a module name, not a variable, and introduces a new meaning for the (Erlang module) syntax. self delegate is a clean message send that the compiler transforms, with no new syntax forms.
Auto-Generate Backing Gen_Server Boilerplate
Generate the handle_call/3 clauses from the .bt declarations, with user-fillable implementation bodies:
Rejected as the primary mechanism because generated code mixed with hand-written code creates maintenance problems — regeneration overwrites customizations. However, a one-shot scaffold tool (beamtalk gen-native) is included as a convenience — generated once, developer owns the result, no regeneration.
Direct sync_send via Existing FFI (No Compiler Changes)
Route through sync_send directly in each method body using the existing (Erlang module) FFI:
Actor subclass: Subprocess
writeLine: data -> Nil =>
(Erlang beamtalk_actor) syncSend: self selector: #'writeLine:' args: #(data)
readLine -> Object =>
(Erlang beamtalk_actor) syncSend: self selector: #readLine args: #()
This achieves the primary goal (routing through sync_send for consistent error handling) with zero compiler changes. Rejected because: it is verbose (every method repeats the syncSend:selector:args: boilerplate with manual selector and argument packing), error-prone (selector names as symbols must match exactly), provides no declared relationship between the class and its backing module, offers no path for library authors to avoid the boilerplate, and cannot support stub generation or LSP mismatch detection. The verbosity is worse than the current FFI wrapper approach. However, this approach could serve as an interim fix for the error handling gap before Phase 1 lands.
NativeActor Superclass
Introduce a NativeActor subclass of Actor that owns delegate, keeping Actor's namespace clean:
NativeActor subclass: Subprocess backing: beamtalk_subprocess
writeLine: data -> Nil => self delegate
Rejected because: sentinel methods on the base class are the universal Smalltalk pattern — subclassResponsibility, primitiveFailed, ffiCall:, shouldNotImplement, and doesNotUnderstand: all live on Object, not on purpose-built subclasses. Creating a NativeActor class to own one sealed method fragments the hierarchy unnecessarily, adds a new class to the bootstrap sequence, and forces library authors to know about NativeActor as a distinct superclass. No Smalltalk derivative has introduced sentinel classes for this pattern — the convention is a method on the base class, available everywhere, used by intent.
@primitive with Module Declaration
Extend @primitive to accept a module name: @primitive beamtalk_subprocess "writeLine:":
Rejected because it conflates two different mechanisms (BIF-level primitives and gen_server delegation), complicates the @primitive narrowing from ADR 0055, and still requires per-method repetition of the module name.
Consequences
Positive
- ClassBuilder integration:
native:is a keyword argument onsubclass:, flowing through the same ClassBuilder cascade asname:,fields:, andmethods:— no separate annotation system - Uniform dispatch protocol:
self delegateroutes throughsync_send/3for all native-backed Actors. Currently, Subprocess shims already usesync_sendbut TranscriptStream uses a process-dictionarydispatch/3path —native:standardises both on the same protocol with consistent dead actor detection, timeout handling, and#beamtalk_error{}wrapping - Open to library authors: Any library author can back an Actor with a hand-written gen_server by adding
native: moduleto theirsubclass:declaration — no compiler modifications required - Stub generation: The
.btdeclaration contains enough information to auto-generate skeletonhandle_call/3clauses for the backing gen_server @primitivescope further narrowed: Subprocess and TranscriptStream no longer use@primitiveor FFI wrappers for module-backed delegation- Erlang module cleanup: Public wrapper functions and
has_method/1/dispatch/3exports can be removed from backing gen_server modules — only standard gen_server callbacks remain - LSP navigation:
native: BackingModulegives the LSP an explicit link to the Erlang implementation; mismatch detection between declared selectors andhandle_callclauses is possible - Smalltalk idiom:
self delegatefollows the PharoffiCall:pattern — every method has a body, the body expresses intent, and a runtime fallback exists gen_statemsupported: Bothgen_serverandgen_statemroute throughgen:call/4, sonative:works transparently with either- No deadlock on TranscriptStream migration: The process-dictionary
dispatch/3hack was needed because FFI shims ran inside the compiled actor'shandle_call. Withnative:, the backing gen_server IS the process —self delegatesends from the caller, andhandle_call/3runs self-contained logic with no re-entry. The existinghandle_call/3clauses inbeamtalk_transcript_stream.erlare already safe
Negative
- New codegen path: The compiler needs facade generation for
native:classes —spawn/1,has_method/1, and dispatch functions that route throughsync_send/3 - Compiler pattern recognition: The compiler must detect
self delegatein the AST and transform it — a new form of compiler-recognized message pattern generated_builtins.rsmigration: Subprocess and TranscriptStream must be moved fromgenerated_builtins.rsto be parsed from their.btfiles with thenative:keyword- Constraint on gen_server API: Backing gen_servers are constrained to the
{Selector, [Args]}wire protocol — Erlang authors accustomed to arbitrary message formats must adapt self delegaterepetition: Each delegating method says=> self delegate— more verbose than bodyless methods, but explicitdelegatenamespace occupation: Addingsealed delegateto Actor means every Actor subclass responds todelegate. The name is reserved — a library author cannot usedelegateas a business-logic selector on an Actor. Thesealedmodifier prevents accidental shadowing but the name is consumed- Gradual typing interaction: When the type checker (ADR 0025) validates return types,
self delegatemethods return whatever the gen_server returns — opaque to the type checker. The type checker will need a special case: onnative:classes,self delegateis assumed to return the method's declared return type. This is analogous to how@primitivereturn types are trusted today __beamtalk_meta/0schema (ADR 0050): Native facade modules must generate a__beamtalk_meta/0that differs from standard actors:native => true,backing_module => atom(), nofields, and delegate stubs rather than real method implementations in the method metadata. The incremental compiler'sClassInfoschema must account for native classes so that crash recovery and compiler server injection produce correct type information- Parser grammar extension: Adding
native:as an optional keyword argument after the class name insubclass:is a grammar change — the compiler needs the backing module name during codegen. This is compiler special sauce: it fundamentally changes the codegen path from "generate gen_server" to "generate facade"
Neutral
spawn/0behaviour: fornative:actors, the facade always callsstart_link(#{})(empty config map).spawn/0works if the backing module'sstart_link/1accepts an empty map; it raisesinstantiation_errorifstart_link/1rejects it. There is no fallback tostart_link/0code_change/3hot reload support lives entirely in the backing gen_server — no changes to the Beamtalk compiler's hot reload machineryclassState:is permitted onnative:actors — only instancestate:is prohibited- Hot code reload: Reloading a
native:class's facade module updates the dispatch functions but does not disrupt existing actor processes — the pid identity is stable, and callers continue reaching the same gen_server. The backing gen_server's owncode_change/3handles state migration independently inspectshows class and pid only: Sincenative:actors have nostate:declarations,inspectshows the class name and pid (e.g.a Subprocess (pid: <0.123.0>)) but not internal state. The gen_server state is an Erlang implementation detail — exposing it would break the abstraction that the.btfile deliberately does not declare. Operators can still use:sys.get_state/1from Erlang to inspect backing gen_server state for debugging- REPL limitation:
native:requires a pre-existing compiled Erlang gen_server module on the code path. It is not suitable for interactive class definition at the REPL — the backing module must exist before thenative:class can be loaded. Attempting to spawn anative:actor whose backing module doesn't exist producesinstantiation_error: module not found
Implementation
Phase 0 — Hand-Written Facade (80% Solution, No Compiler Changes)
Status: partially complete. beamtalk_subprocess.erl (hand-written gen_server), Subprocess.bt (FFI stubs), and the generated_builtins.rs entry are already in place and all tests pass.
The primary remaining Phase 0 deliverable is documentation: write and publish the facade shape (spawn/1, has_method/1, dispatch functions) as the library author protocol so external authors can hand-write their own facades today, before Phase 1 ships.
Note: Removing entries from generated_builtins.rs requires Phase 1's native: keyword support so the compiler parses class metadata from the .bt file.
Issues: BT-1204
Phase 1 — Compiler native: Support
- Parse
native:as a keyword argument onsubclass:in the class definition grammar - Store the backing module name on the
ClassDefinitionAST node - Add
native:method to ClassBuilder (both.btand Erlang backing) - Recognize
self delegatein the AST ofnative:classes → generatesync_sendfacade dispatch - Define
delegatemethod onActor.btwith error fallback - Validate:
state:declarations onnative:actors produce a compile error - Warn:
self delegatemethods without a return type annotation - Generate facade module:
spawn/1,spawnWith:,has_method/1, dispatch functions forself delegatemethods - Generate
__beamtalk_meta/0withnative => true,backing_module => atom(), and delegate method metadata (ADR 0050) - Full Beamtalk method bodies on
native:actors compile normally (e.g.open:args:) - Remove
SubprocessandTranscriptStreamfromgenerated_builtins.rs
Issues: BT-1205, BT-1206, BT-1207, BT-1208, BT-1209, BT-1210
Phase 2 — Stdlib Migration
- Migrate
Subprocess.btfrom FFI wrappers tonative: beamtalk_subprocesswithself delegate - Migrate
TranscriptStream.btfrom FFI wrappers tonative: beamtalk_transcript_streamwithself delegate - Remove public wrapper functions from
beamtalk_subprocess.erlandbeamtalk_transcript_stream.erl— retain only gen_server callbacks - Remove
has_method/1anddispatch/3exports from both modules - TranscriptStream deadlock safety: The process-dictionary
dispatch/3path inbeamtalk_transcript_stream.erlexists because the current FFI shims run inside the compiled actor'shandle_call— callinggen_server:callback to the same process would deadlock. Withnative:, this architecture changes: the backing gen_server IS the process, andself delegatesends messages from the caller's process viasync_send. The backinghandle_call/3clauses (which already exist and are self-contained — they call internal functions likebuffer_text/2directly, with no gen_server re-entry) handle requests without deadlock risk. The process-dictionarydispatch/3and FFI shims become dead code - Add integration tests covering
native:spawn, sync dispatch, async/cast dispatch, deferred replies, and error propagation
Issues: BT-1211, BT-1212
Phase 3 — Tooling
- Implement
beamtalk gen-nativestub generation from.btdeclarations - LSP: "go to Erlang implementation" for
self delegatemethods - LSP: mismatch detection between declared selectors and
handle_callclauses
Issues: BT-1214, BT-1215
Affected Components
crates/beamtalk-core/src/source_analysis/lexer.rs— no changes needed;nativeis not a keyword, it's a keyword-argument identifier in thesubclass:messagecrates/beamtalk-core/src/source_analysis/parser/— parsenative:as a keyword argument onsubclass:class definitions. This is a grammar extension: the current parser consumessubclass:then an identifier (class name) then enters the class body. Addingnative:requires parsing an additional optional keyword argument after the class name — a targeted parser changecrates/beamtalk-core/src/ast.rs— add optionalbacking_modulefield toClassDefinitioncrates/beamtalk-core/src/codegen/core_erlang/actor_codegen.rs— detectnative:on class; branch to facade codegen instead of full gen_server codegencrates/beamtalk-core/src/codegen/core_erlang/gen_server/— newnative_facade.rsgeneratingspawn/1,has_method/1, dispatch functionscrates/beamtalk-core/src/semantic_analysis/— recognizeself delegateinnative:class methods; validate nostate:onnative:classescrates/beamtalk-core/src/semantic_analysis/class_hierarchy/generated_builtins.rs— remove hardcodedSubprocess,TranscriptStreamstdlib/src/Actor.bt— adddelegatemethod with error fallbackstdlib/src/ClassBuilder.bt— addnative:setter method andstate: backingModulestdlib/src/Subprocess.bt— replace FFI withnative: beamtalk_subprocess+self delegatestdlib/src/TranscriptStream.bt— replace FFI withnative: beamtalk_transcript_stream+self delegateruntime/apps/beamtalk_stdlib/src/beamtalk_subprocess.erl— remove public wrapper functions; retain gen_server callbacksruntime/apps/beamtalk_stdlib/src/beamtalk_transcript_stream.erl— remove public wrapper functions; retain gen_server callbacks
Migration Path
Existing FFI Actor Classes
Replace (Erlang beamtalk_module) selector: self method bodies with self delegate and add native: beamtalk_module to the subclass: declaration. Remove public wrapper functions from the Erlang module — retain only the gen_server callbacks with {Selector, [Args]} pattern matching.
Existing @primitive Actor Classes
Replace @primitive "selector" method bodies with self delegate and add native: BackingModule to the subclass: declaration. The selector names in the .bt file must match the {Selector, [Args]} patterns in the gen_server handle_call/3.
Existing Hand-Written Gen_Server Modules
No changes required for handle_call/3 reply format — sync_send/3's DirectValue fallback handles raw values. Public wrapper functions can be removed once the .bt file uses self delegate. Optionally, wrap replies as {ok, Result} for future-proofing.
No Breaking Changes for Callers
The Beamtalk API (message selectors and return types) does not change. Callers of agent writeLine: data, agent readLine, etc. are unaffected — the generated facade produces identical behaviour to the current FFI dispatch, with improved error handling.
References
- Epic: BT-1203
- Related issues: BT-1204, BT-1205, BT-1206, BT-1207, BT-1208, BT-1209, BT-1210, BT-1211, BT-1212, BT-1214, BT-1215
- Related ADRs: ADR 0005 (BEAM Object Model — Actor vs Value distinction), ADR 0028 (BEAM Interop Strategy — FFI mechanism), ADR 0038 (ClassBuilder Protocol —
native:as ClassBuilder method), ADR 0042 (Immutable Value Objects and Actor-Only Mutable State), ADR 0043 (Sync-by-Default Actor Messaging —sync_send/3andcast_send/3protocols,!bang semantics), ADR 0048 (Class-Side Method Syntax Redesign), ADR 0050 (Incremental Compiler ClassHierarchy —__beamtalk_meta/0schema), ADR 0051 (Subprocess Execution — proof-of-concept), ADR 0055 (Erlang-Backed Class Authoring Protocol —state:and FFI for Value classes) - gen_server protocol: https://www.erlang.org/doc/man/gen_server.html
- gen_statem protocol: https://www.erlang.org/doc/man/gen_statem.html
- Pharo UFFI / ffiCall: https://files.pharo.org/books-pdfs/booklet-uFFI/UFFIDRAFT.pdf