ADR 0101: Unified Erlang Interop — native: for Stateless Objects, Wrap-by-Default FFI, and a Clean @primitive/@intrinsic/native: Split

Status

Implemented (2026-07-01)

Implementation Tracking

Epic: BT-2719 Issues: BT-2720 (native: codegen) · BT-2721 (bulk migrate) · BT-2722 (unify FFI proxy) · BT-2723 (migrate exit/throw catches) · BT-2724 (optional @primitive string) · BT-2725 (substrate→@intrinsic + ReactiveSubprocess) · BT-2726 (internal seams) · BT-2727 (E2E + docs) · BT-2730 (FFI error-details harmonization) · BT-2731 (remaining FFI Object classes) Start: BT-2722 (foundation — adds the native_call helper + unified wrapping; no deps), then BT-2720 (consumes native_call) Status: Implemented (2026-07-01) — all phases landed. As shipped (see the End-state table for the estimate-vs-actual reconciliation): native: grew from 2 classes to 23 (170 self delegate methods); @primitive at 343; @intrinsic at 48 (substrate relabelled); ReactiveSubprocess migrated to native:; SystemNavigation embedded FFI extracted into 19 internal seams. Follow-ups BT-2730/BT-2731 harmonized FFI error details and finished the remaining Object-class migrations, deliberately leaving several classes on wrapped inline FFI (see Resolved Decision 5).

Context

Problem statement

Beamtalk's standard library binds to the BEAM through three overlapping mechanisms that have drifted into incoherence:

  1. @primitive "selector" — lowers to beamtalk_<class>:dispatch(Selector, Args, Self), a runtime dispatch that adds type guards, structured #beamtalk_error{} wrapping, and the open-world extension registry (ADR 0007).
  2. (Erlang module) call: arg — a cross-module Erlang call. Wrapping today is inconsistent across two runtime paths: beamtalk_erlang_proxy:direct_call/3 wraps only badarg/undef and lets every other error:*, exit, and throw propagate raw (beamtalk_erlang_proxy.erl:161-186); validate_and_apply/4 (the ErlangModule proxy-object path) wraps all error:* and exit:* (as erlang_exit) and throw:* (as erlang_throw) (beamtalk_erlang_proxy.erl:395-438). So the same surface syntax has two different error contracts depending on lowering, and embedded FFI in logic frequently hits the leakier direct_call path.
  3. native: + self delegate — a class-level declaration that an Actor's gen_server is provided by a named Erlang module; self delegate lowers to beamtalk_actor:sync_send/3, which auto-wraps errors at the actor boundary (ADR 0056).

Measuring the current stdlib (stdlib/src/*.bt):

Two smells fall out of this:

Smell 1 — @primitive is overloaded. Of the 362, ~306 are value-type primitives: self is a native BEAM value (Integer, Float, String, a #beamtalk_error{} record, a fun) needing guarded dispatch + an extension table. But ~56 others use @primitive for unrelated reasons — ~36 are reflection over class objects (Behaviour, Metaclass, Class, Protocol), ~15 are dispatch/actor substrate (ProtoObject, base Actor lifecycle), ~5 are an Erlang-backed actor (ReactiveSubprocess). The keyword conflates "native value type" with "anything bound to Erlang." (Part 3 shows the right split is subtler than "value type vs not" — some reflection correctly stays @primitive because it needs the dispatch+extension+wrapping layer, while substrate moves to @intrinsic and the actor to native:.)

Smell 2 — native: is the missing twin of FFI. Of the 350 FFI methods, 305 are pure pass-through — the entire body is one (Erlang module) selector: self with: arg call. These are exactly what self delegate expresses for actors, but because the class is a stateless Object (not an Actor), the author must hand-thread self into every call. The poster child is Stream, which sits next to its actor twin TranscriptStream in the tree:

// TranscriptStream (Actor) — clean
show: value :: Printable -> Nil => self delegate

// Stream (Object) — must hand-thread self
select: predicate :: Block -> Stream(E) => (Erlang beamtalk_stream) select: self with: predicate
take: count :: Integer -> List(E)       => (Erlang beamtalk_stream) take: self count: count

The asymmetry is purely an accident of class kind. 15 of Stream's 16 methods are pure delegation.

The remaining 45 FFI methods are "embedded" — FFI buried inside real Beamtalk logic (collect:/inject:into:/isNil ifTrue:). These are concentrated: SystemNavigation alone holds 29 (64%), the rest a thin tail. These genuinely leak raw Erlang errors today, mitigated only by each backing module hand-rolling try…catch → beamtalk_error:new.

Current state — the three mechanisms compared

@primitive "sel"native: + self delegate(Erlang module) FFI
Lowers tobeamtalk_X:dispatch(Sel, Args, Self)beamtalk_actor:sync_send(Pid, Sel, Args)module:fn(Args)
Restricted to"primitive type" classesActor subclasses onlyany class, any method body
Error wrappingautomatic (#beamtalk_error{})automatic (ensure_wrapped)inconsistentdirect_call leaks most; validate_and_apply wraps everything incl. exit/throw
Type guards / extension registryyesn/ano

Constraints

Decision

Adopt a single coherent model with four parts, delivered in phases.

Part 1 — native: extends to stateless Objects

native: becomes the universal "this class is backed by a named Erlang module" declaration. The class kind selects the lowering (exactly as the compiler already branches everywhere else):

Class kindself delegate lowers toReceiver conventionWrapping boundary
Actorbeamtalk_actor:sync_send(Pid, Sel, Args)self = pid; cross-process; can block/timeout/cross-nodesync_send (ensure_wrapped)
Objectbeamtalk_erlang_proxy:native_call(Mod, Fn, [Self|Args], {Class, Sel})self = first positional arg; in-process; synchronousshared native_call helper — same error:*#beamtalk_error{} wrapping and 0076 Result coercion as inline FFI

native: must NOT lower to a bare mod:Fn(Self, Args) call. A bare call bypasses the FFI proxy and would therefore get neither wrap-by-default (Part 2) nor the ok/error→Result coercion (ADR 0076) — re-introducing the exact "two FFI behaviors" inconsistency this ADR removes (e.g. File readAll: would return a raw {ok, Bin} instead of Result(String, Error)). Instead it lowers through a shared boundary that applies the same coercion + wrapping as inline (Erlang …) FFI, carrying the Beamtalk {Class, Sel} as context so native: errors read Stream>>take: (better than the bare-MFA context raw inline FFI gets — see Part 2 limitation).

Two implementation notes from review:

Self-threading is inferred from declaration side: instance methods prepend self; class methods omit it — the compiler already knows which is which. Stream becomes:

sealed typed Object subclass: Stream(E) native: beamtalk_stream
  class new: _ :: Object -> Nil => self error: "Use 'Stream on:' or 'Stream from:'"  // real body stays
  class from: start :: Integer -> Stream(Integer) => self delegate   // ⇒ beamtalk_stream:from(Start)
  select: predicate :: Block -> Stream(E)         => self delegate   // ⇒ beamtalk_stream:select(Self, Predicate)
  take: count :: Integer -> List(E)               => self delegate   // ⇒ beamtalk_stream:take(Self, Count)
  asList -> List(E)                               => self delegate   // ⇒ beamtalk_stream:asList(Self)

Delegated methods use an explicit => self delegate body — identical to the actor twin TranscriptStream (show: value => self delegate); there is no bare-signature method form in Beamtalk. This requires a delegate sentinel on the Object/Value base, mirroring exactly what ADR 0056 added to Actor (sealed delegate => @primitive "actorDelegate", raising "delegate called on a non-native Actor"): it makes self delegate typecheck on a stateless Object and is the runtime safety net for misuse on a non-native: class. And — because Stream's constructors (class from:, class on:) are class-side delegations where self is the class object — a matching sentinel is needed on Object class (the metaclass) too, so self delegate typechecks in a class-method body. (Actor needs only the instance-side sentinel because no stdlib Actor has class-side self delegate methods; native: Objects do.) Codegen rewrites self delegate on a native: class to the real call (direct for Objects, sync_send for Actors) before the sentinel is ever reached. A class may mix self delegate methods with real-body methods (TranscriptStream already does).

Part 2 — FFI wraps by default (no opt-out in v1)

(Erlang module) call: becomes safe by default and unified across both proxy paths: error-class exceptions are converted to #beamtalk_error{} (reusing beamtalk_exception_handler:ensure_wrapped, which is idempotent).

Primary motivation — a user must never see a raw Erlang error tuple. In an interactive, Smalltalk-style environment the REPL surfaces errors constantly; a raw ** exception error: badarg or {badarg, [...]} tuple leaks the host VM, breaks the illusion of being in Beamtalk, and stops a newcomer cold. Wrap-by-default guarantees every error a user sees is a #beamtalk_error{} with kind/hint/selector — the same shape whether it originated in a value-type @primitive, a native: delegate, or raw FFI. That uniformity matters most at the REPL, which is the surface where the abstraction is most exposed.

This is not purely additive — it changes both existing paths in opposite directions, which the migration path must account for:

Exception classdirect_call/3 todayvalidate_and_apply/4 todayProposed unified defaultNet change
error:*only badarg/undef wrapped; rest leakall wrappedwrap#beamtalk_error{}direct_call wraps more
exit:*propagate rawwrapped as erlang_exitpropagatevalidate_and_apply rolls back to propagate
throw:*propagate rawwrapped as erlang_throwpass throughvalidate_and_apply rolls back to pass through

The exit/throw rows are a deliberate choice: process exit and throws are semantically meaningful (intentional erlang:exit/1, {noproc,_} from a dead actor, control-flow throws) and should not be flattened into #beamtalk_error{}. Erlang erlang exit: 1 keeps working with no opt-out. Risk: any code currently relying on ErlangModule-dispatch wrapping exit/throw as a catchable #beamtalk_error{} changes behavior — Phase 2 must grep for and migrate such call sites (expected to be rare; exit/throw from FFI is unusual).

No opt-out receiver in v1 (decided). An earlier draft proposed a (RawErlang …) receiver as the opt-out. Review showed its use case collapsed once exit/throw propagate by default — the only thing it would still buy is returning a raw error:* tuple instead of #beamtalk_error{}, a rare low-level need. So v1 ships no opt-out:

(Erlang erlang) exit: 1                       // propagates unchanged (exit not wrapped)
(Erlang beamtalk_xref) senders_of_bt: sel     // error:* → #beamtalk_error{}
// raw error:* tuple → not supported in v1

A future raw-error:* escape (receiver or @raw pragma) is left as a documented option to add only if a concrete need appears; the existing beamtalk_erlang_proxy:dispatch/3 call:args: form is not it (it routes through validate_and_apply, which wraps). The existing per-module hand-wrapping (beamtalk_file:readAll:'s file_not_found + hint, etc.) is demoted to an enrichment layer — kept where it adds domain-specific hints, deleted where it only duplicated the generic try…catch → beamtalk_error:new boilerplate the default now provides.

Limitation (documented, accepted): for raw inline (Erlang …) FFI, the proxy knows the Erlang MFA, not the Beamtalk class/selector, so an auto-wrapped error carries beamtalk_xref:senders_of_bt context rather than SystemNavigation>>sendersOf:. Still structured, still no raw crash. native: methods do not have this limitation — their native_call lowering passes {Class, Sel}, so the error carries Stream>>take:.

Performance: wrapping adds a try/catch frame per FFI/native: call (ensure_wrapped runs only on the error path, never on success). For inline FFI there is no new frame — the proxy is already wrapped today (beamtalk_erlang_proxy.erl:190); Part 2 only broadens the existing catch from named clauses (badarg/undef) to a generic error:* catch-all, which the Erlang compiler may optimize slightly differently but does not add a frame. The frame is a constant amortized against coarse-grained FFI work (one call = an I/O op, ETS access, or an in-Erlang fold), and the hot fine-grained path (@primitive/@intrinsic) bypasses the proxy entirely. The only behavioral change is that try/catch defeats tail-call optimization on the wrapped call — a non-issue for native: methods, which bottom out in Erlang that loops internally. No fast-path carve-out in v1; the native_call helper is the seam to add one if profiling ever justifies it.

Interaction with Result (ADR 0076) and typing

Wrap-by-default touches the exception channel; ADR 0076's ok/error→Result coercion touches the return-value channel. They are orthogonal and mutually exclusive per call — a function either returns a value or raises — so they never collide:

Outcome of an FFI callMechanismBeamtalk resultType-visible?Handled with
returns {ok,V} / {error,R}ADR 0076Result(V, R) — a valueyes (-> Result(T,E) from the FFI spec, ADR 0075)andThen: / unwrap / isOk
raises error:ReasonADR 0101#beamtalk_error{}raisedno (out of band, like any exception)on:do: / ensure:, or bubbles to REPL
raises exit:ReasonADR 0101propagates past the proxy unwrapped; an enclosing on:do: still catches it and wraps it erlang_exit, else it reaches process deathnoon:do: (→ #beamtalk_error{}) or supervisor / let-it-crash if uncaught
raises throw:TermADR 0101passes through unchangednorequired — ^ and Beamtalk exceptions are throw/catch in codegen

The pipeline in direct_call/3 makes the exclusivity explicit: try V = apply(M,F,A), coerce_result(coerce_charlist(V)) catch error:E -> raise(ensure_wrapped(E)); exit/throw -> propagate end. The coercion runs only if the call returned; the wrapping only if it raised.

The disambiguator is returned-vs-raised, not tuple shape. A function that returns {error, enoent}Result error: …; a function that raises erlang:error({error, enoent}) → a raised #beamtalk_error{}. Same tuple, different channel.

Consequence to document (it surprises otherwise): the same function may use both channels. File readAll: path -> Result(String, Error) returns Result error: file_not_found for the modeled domain failure, but raises #beamtalk_error{} if called with a non-binary path (badarg). The split is principled — Result for expected/recoverable outcomes the API models, exceptions for misuse/faults — but the user guide must state it so "File returns Result, why did it throw?" doesn't bite.

Typing bottom line: wrap-by-default changes no return type. select: pred -> Stream(E) stays Stream(E); a raised #beamtalk_error{} is invisible to the type system. Result remains the only type-visible error channel, driven by the FFI spec, not by the wrapping.

Foreign throw — the real hazard is over-catching, not a raw leak (pre-existing). throw passes through the proxy unwrapped, but it does not surface raw to the user: on:do:'s generated catch preamble (control_flow/exception_handling.rs:84-149) catches every exception, re-raises only Beamtalk's own non-local-return throws ({'throw', {'$bt_nlr', …}}), and routes everything else — including a foreign Erlang throw(Term) — through ensure_wrapped, producing #beamtalk_error{kind = erlang_throw} (beamtalk_exception_handler.erl:500). So the genuine concern is the opposite of a raw leak: an Erlang library that uses throw as ordinary control flow (parsers, validators, parts of cowboy/ecto) can be silently intercepted by a user's broad [ (Erlang …) … ] on: Error do: […]. This is pre-existing on:do: behaviour, not introduced or worsened by wrap-by-default (the proxy change only touches error:*). Noted here because it's the real edge of the "never see raw Erlang" goal; a future refinement could have on:do: pass through non-$bt_nlr throws, but that's an exception_handling change out of scope for this ADR.

Part 3 — Reclassify the 362 @primitives

The deciding question is not "what is self" (an earlier draft used that and mis-routed the reflection classes). It is what mechanism does the method need:

This refinement is the key correction from review: Behaviour/Metaclass/Class/Protocol are reflection over class objects, but they stay @primitive, for three concrete reasons:

  1. Name mismatch — their methods (name, superclass, reload) bind to Erlang functions named className, classSuperclass, classReload (primitives/behaviour.rs, beamtalk_behaviour_intrinsics.erl). The native: self-threading rule infers mod:name(Self), which is the wrong function. native: would need a per-method name override (new surface we reject).
  2. Process-crossingclassReload/superclass call into class gen_servers and the workspace via gen_server:call (beamtalk_behaviour_intrinsics.erl:121,671). They are not the in-process synchronous calls native:-Object lowering assumes; their exit:* (dead class process, timeout) would propagate raw under Part 2, where @primitive dispatch wraps it.
  3. Extension registry@primitive dispatch consults the open-world extension table (ADR 0007/0066/0070) as a fallback; native: direct calls bypass it, silently killing cross-package extensions targeting these selectors.

So @primitive is not "value types only" — it is "anything needing the guarded-dispatch + extension + wrapping layer." Only one class genuinely migrates from @primitive to native:: ReactiveSubprocess (an Actor, like Subprocess).

Two ergonomic cleanups land with this:

Resulting @primitive-side split (362), revised after review:

DispositionCountClasses
Stay @primitive (value types + runtime-extensible reflection)342String 58, List 41, Float 37, Integer 34, Behaviour 24, Character 19, Set 15, Dictionary 13, Binary 11, Array 11, Exception 11, Tuple 8, StackFrame 8, Pid 8, Metaclass 7, Symbol 7, CompiledMethod 6, Reference 6, Collection 5, Port 5, Protocol 4, Block 2, Class 1, Value 1
@intrinsic (substrate)15ProtoObject 7 (==//=/class/doesNotUnderstand:/perform:*), Actor 7 (pid/monitor/stop/kill/delegate/isAlive/onExit:), Object 1 (class)
native: (clean Actor delegation)5ReactiveSubprocess 5

(The earlier draft moved 41 to native:; review showed 36 of those — the reflection facades — must stay @primitive. Only ReactiveSubprocess migrates.)

Part 4 — internal seams dissolve the embedded-FFI category

The 45 embedded-FFI methods are already safe under Part 2 (wrapped in place). As an optional clarity refactor, extract each buried (Erlang …) call into a thin internal helper (the internal modifier already exists — docs/beamtalk-language-features.md:3798, package-scoped, compile-time only, still reflection-visible). The public method becomes pure Beamtalk:

internal xrefImplementorsOf: sel :: Symbol -> List => (Erlang beamtalk_xref) implementors_of_bt: sel

implementorsOf: aSelector -> List(Behaviour) =>
  partition := self xrefImplementorsOf: aSelector   // pure BT — no FFI in the logic
  indexImpls := indexedRows collect: [:row | ... ]

This does not make SystemNavigation a native: class — it hits four backing modules (beamtalk_interface/beamtalk_xref/beamtalk_extensions/beamtalk_class_registry), and native: names one. But it drives the "embedded FFI in logic" category to 0: every Erlang call site in the stdlib becomes a one-line seam.

End state — the whole interop surface

The table below gives the design estimate next to the as-shipped counts (measured 2026-07-01 over stdlib/src/*.bt by method-body binding pattern, excluding doc-comment mentions):

MechanismDesign estimateAs shippedMeaning
@primitive342343native BEAM value types, perf folds, runtime-extensible reflection (keeps dispatch + extension + wrapping)
native:310 methods / ~45 classes170 methods / 23 classespure stateless delegation to one backing module (=> self delegate) — grew from 2 classes
internal FFI seam~4519 (SystemNavigation)one-line (Erlang …), wrapped-by-default
@intrinsic54 (15 + 39 existing)48dispatch / actor / block substrate

native: did not become the single largest mechanism (@primitive stays ahead at 343) — but it grew from 2 classes to 23, absorbing the pure-delegate FFI methods that satisfy the first-keyword + self-threading rule.

Why shipped native: came in below the estimate (23 classes / 170 methods, not ~45 / 310): BT-2731 (Resolved Decision 5) deliberately kept a set of classes on inline (Erlang …) FFI where a clean first-keyword rename is blocked or wrong — Ets (at:/at:put: first-keyword+arity collision), AtomicCounter (value intercepted by block-eval codegen), DateTime (operator selectors aren't valid Erlang function names), Timer (after: is reserved), Announcer (when: collision/reserved), TestCase (fail:/skip: don't thread self), and the BeamtalkInterface/WorkspaceInterface receiver-ignoring singletons. These stay inline but are wrapped-by-default since Part 2, so they still never leak raw Erlang errors. Consequently inline (Erlang …) call sites are not driven to zero — ~170 remain across those intentionally-inline classes plus SystemNavigation's orchestration — but each is a wrapped, single-purpose call, not buried logic. Every Erlang call is one of: a @primitive, a native: delegate, an @intrinsic, or a wrapped inline/internal FFI seam. Nothing leaks raw errors.

Why shipped internal seams came in at 19, not ~45: the ~45 estimate bundled SystemNavigation (Part 4 projected "~29") plus a "thin tail" of embedded FFI in other classes. As shipped, all 19 seams live in SystemNavigation — and the 19-vs-29 difference is consolidation, not omission: the 19 named internal helpers cover SystemNavigation's ~25 inline (Erlang …) sites across four backing modules (beamtalk_interface, beamtalk_xref, beamtalk_extensions, beamtalk_class_registry), since several call sites fold into one seam and a few FFI calls legitimately stay inline inside orchestration logic. The estimated "thin tail" in other classes was not extracted into separate seams: those are the Resolved-Decision-5 intentionally-inline classes above, already wrapped-by-default by Part 2, so extracting them would only inflate the xref index (see Consequences — "Part 4 inflates the xref index") for no safety gain.

Why shipped @intrinsic is 48, not 54: the 54 estimate was 39 existing + 15 reclassified, but that arithmetic does not reconcile line-by-line with the shipped count and should be read as a projection, not a ledger. The 39 existing baseline was a point-in-time snapshot that drifted as concurrent stdlib work landed between the ADR draft and implementation, and the 15 reclassifications shipped at different granularity than projected (e.g. Object carries 9 @intrinsic bindings as shipped vs the "1" the Part 3 table projected, while other predicted moves were absorbed into the @primitive dispatch layer rather than relabelled). The as-shipped 48 is the authoritative measured count (grep '@intrinsic' stdlib/src/*.bt, doc-comment lines excluded); the per-class distribution is Object 9, Block 9, Actor 9, Logger 8, ProtoObject 7, Value 2, and singletons on Erlang/ErlangModule/ClassBuilder. No attempt is made here to attribute the 6-method delta to specific methods, as that would require archaeology of the pre-ADR baseline that the reclassification did not record.

Prior Art

User Impact

Steelman Analysis

Alternative A — a distinct keyword backedBy: instead of reusing native:

Rejected, on merits: the process-boundary distinction is real, but it is already encoded by class kind — the reader sees Actor subclass: vs Object subclass: one line above. Duplicating it into the delegation keyword is redundant and re-creates the "two mechanisms for one concept" smell this ADR exists to delete. The leaky-abstraction cost is genuine and kept as an explicit Negative; the mitigation is docgen/hover/:help labelling which kind a native: class is — metadata, not a second keyword. The veteran and the purist pull in opposite directions; "class kind already says it" + symmetry + "parser already allows it" breaks the tie toward reuse.

Alternative B — raw FFI by default, opt-in SafeErlang wrapping

Rejected, on merits: the let-it-crash worry conflates two layers. Wrap-by-default converts error:* into a raised #beamtalk_error{} — still an exception that propagates and kills the process if uncaught, so supervision still fires; and exit/links are untouched, propagating raw. The only thing that changes is the shape a caller sees if it chooses to catch — structured instead of raw. Auditability is real but inverts the cost onto the ~99% of sites that want safety (precisely the ADR 0007 anti-pattern); a future @raw marker covers the rare audited-raw case. Performance: the try/catch is cheap next to the cross-module call itself, and the genuinely hot path (value-type ops) is @primitive/@intrinsic, not FFI. The decisive cohorts are the newcomer + purist at the REPL — never leaking the VM is the ADR's primary motivation.

Alternative C — status quo: keep three mechanisms, hand-fix only the 45 leak sites

Rejected, on merits: cheapest today, most expensive cumulatively — it entrenches the @primitive/FFI duplication and the 305-method self-threading ceremony, and leaves safety as per-author discipline forever (forget once → a leak). It is genuinely available as a fallback (Parts 1–3 stand without Part 4), but it fixes the smallest smell while leaving the structural one.

Tension points

Alternatives Considered

See Steelman Analysis for backedBy:-vs-native: and raw-vs-wrapped defaults. Additionally:

Status quo + targeted hand-wrapping

Leave the three mechanisms as-is; fix only the 45 embedded-FFI leak sites by hand-adding try…catch → beamtalk_error:new (matching existing curated wrappers in beamtalk_file/beamtalk_atomic_counter). Zero new surface, zero migration risk. Rejected because it entrenches the @primitive/FFI duplication and the 305-method self-threading ceremony, and leaves "safe FFI" as per-author discipline rather than a default. But note: since the ADR states the 45 are "already safe under Part 2," Parts 1–3 are viable without Part 4 — Part 4 is explicitly optional cleanup, a valid stopping point.

Codegen-only: optional @primitive selector, no native: for Objects

Deliver only the Part 3 de-ceremony (bare @primitive infers the selector) without introducing native: for non-Actor Objects. Smaller surface. Rejected because it leaves the 305 pure-delegate FFI methods stuck with hand-threaded self and no error-wrapping unification — it fixes the smaller smell and ignores the larger one.

Per-method @delegate mod annotation instead of class-level native:

Allow select: predicate => @delegate beamtalk_stream per method rather than a class-level declaration. Directly analogous to Elixir's defdelegate (cited in Prior Art) and handles the multi-module case (e.g. SystemNavigation's four backing modules) without forcing one module per class. Rejected for v1 — class-level native: is far less repetitive when a class delegates wholesale to one module (Stream, File), and first-keyword+self inference covers all 305 pure-delegate methods. Deferred (not dismissed): if selector/function-name mismatches prove common, @delegate … as: fn is the natural way to add per-method name-overrides without the reflection classes needing it.

Consolidate SystemNavigation to one backing module

Make SystemNavigation native: beamtalk_system_navigation by introducing a facade module that fans out to interface/xref/extensions. Rejected — pushes Beamtalk-level fold/fan-out logic into Erlang (wrong layer) or is pure indirection. The internal-seam approach (Part 4) keeps orchestration in Beamtalk.

Consequences

Positive

Negative

Neutral

Implementation

Phased, bottom-up; each phase leaves CI green.

Phase ordering note: Part 2 must land first (or together with Phase 1) — native: Object dispatch lowers through the native_call helper that Part 2 adds, so Phase 1's generated code does not run end-to-end until Part 2 is merged. This is a hard dependency (BT-2720 blocked by BT-2722), not just the soft "failure-prone delegations want wrapping" ordering.

Migration Path

Resolved Decisions

These forks were raised in review and resolved:

  1. No RawErlang opt-out in v1. exit/throw propagate by default, so the only remaining use was raw error:* tuples — too narrow to justify new receiver syntax now. A @raw/receiver escape is a documented future option. (See Part 2.)
  2. Class-level native: only; no per-method @delegate. First-keyword + self inference covers all 305 pure-delegate methods (written with that same convention); the few selector/function-name mismatches stay inline (Erlang …) (wrapped-by-default). @delegate is deferred — revisit only if mismatches prove common.
  3. ADR 0100: coordination, not conflict. native: Objects are legitimately ClosedComplete (no extension hook → a DNU hint on an unknown selector is correct). @primitive classes (incl. the reflection facades) keep their Open/extensible treatment. Requirement on ADR 0100's implementation: its method-surface scan must count self delegate native: methods as declared methods (so they don't read as "missing" and trigger false hints). Neither ADR blocks the other (see Migration Path).
  4. Canonical timeout idiom (BT-2723). The Migration Path flagged the gen_server:call timeout replacement as unspecified. Resolved: Beamtalk already has a structured-timeout path; no raw exit-catch idiom is introduced.
    • A bounded synchronous send uses anActor withTimeout: ms (→ TimeoutProxy). A timeout surfaces as a raised #beamtalk_error{kind = timeout} (catchable as #timeout, under Error/Exception) — not a propagating raw exit. This is produced by beamtalk_actor:sync_send/4raise_timeout/1, independent of the FFI proxy, so it is unaffected by Part 2.
    • Result-returning form (canonical): to convert a timeout specifically into a Result error: value (while letting every other failure propagate to a supervisor), guard on the #timeout kind and re-raise anything else. Wrap the success path in Result ok: so both branches share the Result(Rows, Error) type:
      result := [ Result ok: ((db withTimeout: 30000) query: sql) ]
        on: Error
        do: [:e |
          e kind = #timeout
            ifTrue: [ Result error: e ]   // a timeout becomes a Result error: value
            ifFalse: [ e signal ] ]       // re-raise non-timeout errors (let-it-crash)
      // result : Result(Rows, Error) — `Result ok:` on reply, `Result error:` on timeout
      
      The #timeout guard matters: a bare on: Error do: [Result error: …] would also swallow actor_dead, validation faults, and any other crash surfacing through sync_send, defeating supervision. There is no dedicated TimeoutError class today — kind = timeout falls through to the Error class — so the kind check is how the idiom stays timeout-specific; a future TimeoutError subclass could replace the guard with on: TimeoutError do:.
    • A dedicated callWithTimeout: convenience helper that returns Result directly is deferred (recorded as an optional future addition): the on:do: form already composes the two existing, type-visible channels, and no current call site needs the sugar. Adding it later is non-breaking.
    • Migration consequence: because the only structured-timeout source (the actor path) already raises rather than propagating a raw exit, the Phase 2 grep for FFI exit/throw catches finds nothing to migrate (see Migration Path bullet). The idiom above is the documented canonical form for any future code that needs a value-returning timeout.
  5. BT-2731 — remaining FFI Object classes: convert vs. stay-inline. BT-2721 migrated the pure-delegate methods whose backing function already satisfied the first-keyword + self-threading rule under its .bt-only scope. BT-2731 reconsidered the rest, now permitted to rename backing .erl exports.
    • Converted (clean first-keyword renames; blast radius = tests only): SupervisionNode (6 *Of accessors → native: beamtalk_process_navigation; status stays inline — it threads self pid, not self), Random (instance next/nextInteger: → backing next/1/nextInteger/2, coexisting with the class-side next/0/nextInteger/1 by arity), Session (7 *For/*Of instance methods → first-keyword names), BindingsView (7 view_* methods → native: beamtalk_session_primitives; at:/at:put: map to at/2/at/3 — distinct arities, no collision).
    • Deliberately stays inline FFI (already wrapped-by-default since BT-2722; a clean rename is blocked or semantically wrong):
      • Etsat:/at:put:/at:ifAbsent: share the first keyword at, and at:put:/at:ifAbsent: both want at/3 (irreducible first-keyword + arity collision, per Part 1 case (a)); size/delete would shadow the auto-imported erlang:size/1 / clash with ets:delete.
      • AtomicCountervalue: the unary value selector is intercepted by block-evaluation codegen (BT-1260), so the backing fn is deliberately readValue.
      • DateTime — the 7 comparison operators (< > <= >= =:= =/= /=): operator selectors are not valid Erlang function names, so self delegate cannot emit the call (shims lt/gt/… exist for the FFI path only).
      • Timerafter:do:: after is an Erlang reserved word (rejected by the native_validators reserved-word check).
      • Announcerwhen:do:/when:doOnce: both collapse to when/3 (collision; when is also reserved), and announce would collide with the module's Layer-1 global announce/2,3 bus API.
      • TestCasefail:/skip: do not thread self, and suiteFixture lacks a return type; not worth native: on the heavily-subclassed base test class for one method.
      • BeamtalkInterface, WorkspaceInterface — receiver-ignoring singleton facades: their backing functions are arity-0/N and deliberately ignore the receiver, so threading self would be wrong. They stay receiver-ignoring instance FFI.

References