ADR 0127: Traits — Protocols with Provided Methods, Flattened into Users

Status

Accepted (2026-09-23)

Closes the question ADR 0005 left open as Q9 ("Mixins/traits: deferred to a future ADR") and BT-274 (cancelled 2026-04-01 as "not pressing"; reopened by BT-3526 with the corpus evidence below).

The decision in one paragraph: a Protocol define: body may now contain provided methods (a signature with => and a body) beside its required signatures (no =>). There is no separate keyword; in this ADR a trait is simply a protocol with at least one provided method. A class receives a protocol's provided methods by naming it in a uses: line at the top of its body. Composition is flattened at compile time: the using class is compiled exactly as if the provided methods were written in its own body, so ThreadedIr, the class-kind calling convention, super, sealed self-send optimisation, xref, and hot reload all see ordinary methods. A class's own method beats a trait's, and a trait's beats an inherited one only when the using class says so with an overriding: clause (§3a), so no provision silently replaces an inherited method in a closed-world build, from either side of a release. Two traits providing the same selector is a compile error unless the class defines it or excludes one. A protocol's type is its required ∪ provided selectors, so the structural conformance rule of ADR 0068 is unchanged for protocols without provisions, and uses: never becomes a nominal implements:.

Decisions taken after review (each records the alternative and where it is written up):

  1. Now, or inheritance first? Decided (2026-09-23): traits now. Magnitude and an abstract enumerable class would remove most of the measured duplication, but they model capabilities as kinds (§Context, "Kinds versus capabilities"). The stdlib is not refactored into an inheritance-based interim first; Option H is rejected.
  2. A protocol's type: required only, or required ∪ provided? Decided (2026-09-23): required ∪ provided (§8). The required-only alternative (Option B′) borrows Java 8's rule, which works only because Java is nominal: every implementer inherits the defaults. Under structural conformance a class can conform without uses: and so without the provisions, and a required-only type would let it through to code that then sends max: and fails with does_not_understand at run time. Required ∪ provided reports that case as a conformance warning instead. Pony is the precedent: its structural interface may carry default bodies, includes them in the type, and delivers them only to types that declare is I.
  3. One keyword or two. Decided (2026-09-23): one keyword. Protocol define: may carry method bodies; there is no Trait define:. With decision 2 settled, a second keyword would only signal whether a file has bodies, and adding a protocol's first body would force a keyword change for no semantic gain.
  4. v1 scope. Decided (2026-09-23) (§Implementation, "v1 scope"). v1 ships everything the semantics need plus live patching and the users index, because principle 11 ("Live patching is a message send") and principle 8 ("Reflection as Primitive") make them part of the feature, and the in-image reload fan-out needs the users index to find its targets. Deferred past v1: aliasing:, a protocol using another protocol (§Status 8), class-side provisions, browse grouping by trait, protocol-wide rename, and LSP hover provenance.

Decided after the second review (2026-09-23):

  1. overriding: exempts only printString and displayString (§3a). equals: and hash need an explicit overriding:, because silently replacing a value's equality changes how its instances behave in sets and dictionaries.
  2. uses: of a protocol with no provisions is a hint (§1), not a warning, so a protocol gaining or losing its last body never fails a --warnings-as-errors build.
  3. Comparable requires <, not a three-way compare: (§1, §9): it is Pharo's Magnitude contract, every intended user already has it, and a three-way protocol can be added separately later.
  4. A protocol uses:-ing another protocol is post-v1 (Phase 7). No stdlib protocol needs it, and cutting it removes transitive expansion, cycle detection, protocol-internal precedence and same-origin diamonds from v1. extending: still composes types.
  5. The stdlib-protocol patch refusal reads "Cannot recompile 'max:' on stdlib protocol 'Comparable': built-in protocols are read-only in the workspace", a literal mirror of the existing stdlib-class message (stdlib_method_read_only_error/2, beamtalk_repl_eval.erl; §11).

All decisions are resolved; accepted 2026-09-23.

Context

Problem statement

Beamtalk has three ways to share behaviour, and none of them can put an implemented method on two unrelated classes:

MechanismSharesCannot
Single inheritance (ADR 0005, 0006)Bodies, down one chainCross the class-kind wall (ADR 0067): an Actor can never inherit from Collection, which is a Value
Structural protocols (ADR 0068)A contract (selector set)Carry a body — "Protocol bodies use class-body style — method signatures without => implementations"
Extension methods (ADR 0066)A body, onto one named classBe reused: Comparable+DateTime.bt and Comparable+Duration.bt are two copies; extensions "cannot add instance variables" and cannot require anything of the target

The gap is the one Schärli et al. identified in Smalltalk-80 twenty years ago: with single inheritance, behaviour that cuts across the hierarchy is either duplicated or hoisted into an over-general superclass. Beamtalk's class kinds make the wall higher than Smalltalk's, because the natural home for shared collection behaviour (Collection) is a Value, and an Actor can never be one.

Evidence from the stdlib (stdlib/src/*.bt at the time of writing):

Ordering. Four classes outside the Number tower hand-write the four ordering operators — each operator backed by its own FFI call or @primitive, so replacing three of them with derived bodies would be a small performance trade, not free — and none of them gets between:and:, min: or max:, which exist only on Number and its subclasses. The missing derived methods, not the four operators, are the reuse gap:

Class< > <= >=between:and: / min: / max:
DateTime (date_time.bt:313-341)4 × (Erlang beamtalk_datetime) lt:/gt:/lte:/gte:none
Duration (duration.bt:225-253)4 × (Erlang beamtalk_duration) …none
Uuid (uuid.bt:140-149)4 × (Erlang beamtalk_uuid) …none
String (string.bt:96-123)4 × @primitivenone
Character (character.bt:61-85)4 × @primitiveinherited — Character is an Integer subclass (character.bt:16)
Integer / Float4 × @primitive eachmin:/max: duplicated verbatim in both (integer.bt:294-306, float.bt:177-188) — fixable today by moving them up to Number, no traits needed; between:and: on Number only

Enumeration. Two Value classes that are not collections re-implement the Collection "shared protocol" by hand because is-a Collection would be the wrong statement:

SelectorCollection (collection.bt)SupervisionTree (supervision_tree.bt)ChangeLog (change_log.bt)
sizeabstract:66:63
isEmpty:68 self size =:= 0:74:71 self size =:= 0
notEmpty / isNotEmpty:77—:81 self isEmpty not
do:abstract:84:93
select::269:94:105
detect: / detect:ifNone::304 / :327:104 / :119—
collect::162:128—

String (string.bt:414-423) also re-implements isEmpty / isNotEmpty in terms of length rather than inheriting them.

Most of this stdlib duplication is fixable by single inheritance, and the ADR should be judged knowing that. DateTime, Duration, Uuid and Number all subclass Value directly, so a Pharo-style abstract Magnitude Value class above them would remove the ordering copies for everything except String. SupervisionTree and ChangeLog also subclass Value directly, so an abstract enumerable Value class (with Collection under it) would remove the enumeration copies. §Alternatives treats this seriously as Option H, and rejects it on modelling grounds, not on counts.

Kinds versus capabilities. Even where inheritance could remove the duplication, it would be modelling the wrong thing. A superclass says what a class is: Integer is a Number, Array is a Collection. "Can be ordered" and "can be enumerated" are not kinds; they are capabilities, and their natural names are adjectives (Comparable, Enumerable), not nouns. Putting DateTime, Duration and Uuid under a Magnitude superclass would spend each class's only superclass slot on a capability, and would claim a kinship between a timestamp, a length of time and an identifier that exists only because they share <. Smalltalk-80's Magnitude is a class because the language had nowhere else to put shared behaviour, not because ordering is a kind. Traits give capabilities their own home, and leave the hierarchy to say what things are.

What inheritance cannot fix at all is the rest of the reason for this ADR:

  1. A class that needs two concerns. String is a Collection (via Binary) and is ordered. Under inheritance it can have Magnitude or Collection behaviour, not both; today it re-implements the ordering half and isEmpty/isNotEmpty by hand.
  2. The class-kind wall. Every shared-behaviour base in the stdlib is a Value. An Actor that wants do:/select:/detect: over what it holds (a registry, a pool, a subscription set) can never inherit them. The stdlib has no such actor today. The evidence for this case is structural, not a count, and it is weaker than it looks: block-taking methods that call each other through actor self-sends are broken today (BT-3580, §9), and blocks passed to an actor run in the actor's process (language guide § Checked boundaries; ADR 0103). Traits whose provisions do not pass blocks through self-sends (Comparable, Counting, snapshot-based Enumerable, §9) are unaffected.
  3. A superclass slot already taken. User classes subclass TestCase, Error, Supervisor or an application base actor. Their superclass is spent on framework integration, so shared behaviour has nowhere to go except copies or extension methods.

The ADR 0037 steelman for "Option C: Traits / Protocol" made the same argument — "Traits separate the what (Enumerable protocol) from the how (implementation per type)" — and rejected it only because protocols did not yet exist. They do now.

Current state

Class definition (parser doc, parser/declarations.rs:216-233; ADR 0067): [internal|abstract|sealed|typed]* Superclass subclass: Name, an optional header tail ((T, E), native: mod, handleScope:, shapeVersion:), then an indented body of state: / field: / classState: declarations and methods. The body loop ends at the first line that is none of those — an unknown keyword such as uses: Foo today silently ends the class body (declarations.rs:808-809), which this ADR must turn into a targeted diagnostic.

Protocols (ADR 0068): Protocol define: Name, optional (T) and extending:, body of signatures. The parser tells a signature from a method by the absence of =>. Conformance is structural, tiered (ClassHierarchy walk → REPL method table → DNU override), and cached in beamtalk_protocol_registry. ADR 0068 Alternative D rejected a nominal implements: clause; ADR 0025 Alternative C rejected the same thing. A class and a protocol share one namespace.

Extension methods (ADR 0066): Class >> sel => body. A same-file "self-extension" is folded into the host class's AST before codegen (gen_server/extensions.rs:8-11, compiler-port/handlers/compile.rs:109-142 merge_method) — that is flattening, and it is the mechanism this ADR generalises. A foreign extension is a fun in the ETS table beamtalk_extensions, consulted before the hierarchy walk at every level — that is "shared code + dispatch", the alternative this ADR rejects for traits (§Alternatives).

Method representation. One class per file, one BEAM module per class (ADR 0040, module_validator.rs:130-131); the module name derives from the sole top-level definition (ADR 0119). Inherited methods are never copied: actors walk the class registry at run time and value types call the superclass module statically. A method body is not class-neutral: it bakes in the lexical class name (super, error hints), the lexical module (actor super sends and sealed self-sends go to <module>; ordinary actor self-sends resolve the module from the instance's '__class_mod__' since BT-3666), the class kind ({reply, R, State} clause vs. plain function), and per-class facts (late slots, sealed selectors, class-var names). ClassInfo.methods is a Vec<MethodInfo> whose entries already carry defined_in (class_hierarchy/class_info.rs:50-80), and ClassHierarchy::register_extensions already appends foreign methods to it.

Reflection. beamtalk_xref_methods rows carry provenance := class_body | extension | class_builder | put_method (ADR 0087); browse-protocols maps extension to a synthetic "extensions" category. Behaviour methods returns local selectors only (ADR 0032). renameSelector:to: (ADR 0114) rewrites self/super sites within the class's subclass closure. removeSelector: (ADR 0112) removes a class's own method.

Constraints

Decision

1. Protocols may provide methods — Protocol define: with bodies

// stdlib/src/comparable.bt
// Copyright 2026 James Casey
// SPDX-License-Identifier: Apache-2.0

/// Comparable — a total order derived from one required `<`.
///
/// A using class implements `<`; everything else is provided. A class that
/// already implements every selector below conforms to `Comparable`
/// whether or not it `uses:` it (ADR 0068).
Protocol define: Comparable
  /// Required — strict ordering. The using class must implement this.
  < other :: Self -> Boolean

  // === Derived ordering ===

  > other :: Self -> Boolean => other < self
  <= other :: Self -> Boolean => (other < self) not
  >= other :: Self -> Boolean => (self < other) not

  /// True if the receiver lies in the closed interval `[min, max]`.
  between: min :: Self and: max :: Self -> Boolean =>
    (self >= min) and: [self <= max]

  min: other :: Self -> Self => (self < other) ifTrue: [self] ifFalse: [other]
  max: other :: Self -> Self => (self < other) ifTrue: [other] ifFalse: [self]

The body grammar is the protocol body grammar (ADR 0068) plus method bodies:

Line shapeMeaning
selector :: … -> T (no =>)Required method — the using class, its superclass chain, or another used trait must provide it
selector … => bodyProvided method — flattened into every user
class selector … without =>Class-side requirement, as protocols already allow (ADR 0068, BT-1611)
class selector … => bodyClass-side provision — post-v1 (Phase 7); in v1 an error, "class-side provided methods are not yet supported"
sealed / internal before the selectorRejected in v1 (§13). sealed cannot stop the using class from overriding (class wins, §3), and internal in a trait has no well-defined package once flattened into a user in another package (ADR 0071)
uses: OtherProtocol …Composition — a protocol using another protocol's provisions. Post-v1 (Phase 7, §Status 8); in v1 a uses: line in a protocol body is a "not yet supported" error
// === Name ===Category divider (method_category.rs), kept with the trait's source
/// docDoc comment, carried onto the flattened method and shown by browse

Generic protocols (Protocol define: Enumerable(E), ADR 0068) may provide methods too.

Self in a trait. Self as a parameter type is an error in class methods today ("Self cannot be used as a parameter type … only valid in return position", type_checker/validation.rs:1959) and legal in protocol signatures (beamtalk-language-features.md:1867). A trait may use Self in both positions, and flattening substitutes the using class's name for every Self in a provision's signature before the method reaches the type checker. Comparable>>max: other :: Self -> Self becomes DateTime>>max: other :: DateTime -> DateTime in DateTime: exactly the signature a hand-written method would declare. Substituting the return position too matters: left as Self, max: flattened into a class with subclasses would claim to return the receiver's subclass while returning other. The Eiffel unsoundness that motivates the class-method ban (a subclass narrowing a parameter type) does not arise, because the substituted type is the using class, fixed at flattening, and subclasses inherit it unchanged as they inherit any hand-written method. Required signatures keep Self unsubstituted; they are checked like protocol requirements (§5). For a generic user (Pair(A, B) uses: Comparable), Self becomes the user with its own type parameters, Pair(A, B).

No primitives in provisions. A provided method may not use @primitive or @intrinsic. Provisions are flattened into user classes compiled in project mode, which cannot reproduce stdlib-mode primitives (the reason the workspace refuses stdlib patches, beamtalk_repl_eval.erl:927-938).

Which mechanism to use. A superclass says what a class is and shares representation and state. A protocol with provisions is a capability derived from a few required methods and usable across kinds and hierarchies. An extension method adds one behaviour to one existing class, typically one you don't own. The language guide (Phase 6) states this rule.

A protocol with provisions still has no header modifiers, no superclass, no state: / field: / classState: (§7), and no native:. It stays one protocol per file (comparable.bt, as printable.bt is today) and compiles to one module by the ADR 0119 rule. There is no new top-level form.

Names are protocol names (Comparable, Enumerable), not Pharo's TComparable: there is one construct, and it is a type.

uses: of a protocol with no provisions is a hint, not an error or a warning: "Printable provides no methods, so uses: only checks its requirements here; conformance is structural (ADR 0068)." Its one effect is to check the requirements at the class definition, the "assert conformance here" idiom. That is none of the things ADR 0068 rejected implements: for: it is optional, adds no nominal typing, and never breaks when a protocol is added later. A hint rather than a warning also avoids churn, because a protocol gaining or losing its last provision would otherwise flip every user between clean and warning, which fails builds under --warnings-as-errors. When a protocol later gains its first provided method, the classes that should receive it add uses:; structural conformers are affected as §8 describes.

2. Composing traits — uses: lines in the class body

sealed typed Value subclass: DateTime native: beamtalk_datetime
  uses: Comparable
  field: …

  < other :: DateTime -> Boolean =>
    (Erlang beamtalk_datetime) lt: self with: other
  // `>`, `<=`, `>=` stay: each is its own FFI call, and the class body
  // wins over a provision (§3, §9).
  // Gained from Comparable: `between:and:`, `min:`, `max:`.

Grammar of a uses: line:

uses: [package@]ProtocolName[(TypeArgs)] [excluding: #(#sel, …)] [overriding: #(#sel, …)]

Post-v1 (Phase 7) adds one more optional clause, [aliasing: #{#newSel => #traitSel, …}]. In v1 the keyword is reserved and rejected with a "not yet supported" error.

package@ qualifies a protocol from another package, as json@Parser does for a superclass (ADR 0070). Inside a protocol body only uses: is reserved, and only at the start of a line that has no => (a method named uses: has a body, as ADR 0071 distinguishes internal). excluding: and the other clause keywords appear only after it on the same line, so they stay usable in ordinary selectors (system_navigation.bt:1890). Without the reservation today parse_protocol_method_signature_with_doc would read uses: Comparable as a required keyword signature with a parameter named Comparable, so the protocol-body parser must check for them first, as it already does for extending:. overriding: is reserved the same way.

The header form (Value subclass: DateTime uses: Comparable) was considered and rejected: it is the Pharo spelling, but Beamtalk's header tail is already order-sensitive, and exclusion/aliasing clauses do not fit on one line (§Alternatives).

3. Flattening — the defining semantics

A class that uses traits means exactly the class with the provided methods written into its body, after these steps, in order:

  1. Collect each used protocol's provisions. (Post-v1, when a protocol may uses: another, expansion is transitive; a protocol's own provisions beat those of protocols it uses, excluding: is allowed on its uses: lines, and overriding: there is an error because a protocol has no superclass.)
  2. Per uses: line, apply excluding:. (Post-v1, aliasing: runs first, reading from the trait's original provisions, so an alias survives the exclusion of the selector it copies: excluding: #(#printString) aliasing: #{#describeString => #printString}.)
  3. Merge the provisions of all used traits into one set, detecting conflicts (§4).
  4. Drop every trait provision whose selector (same side) the class body defines — the class wins.
  5. Check requirements (§5), and check that every provision replacing an inherited method is acknowledged (§3a).
  6. Compile the class with the merged methods as if they were its own.

Precedence, highest first: class body → trait provision → inherited. A trait provision therefore overrides an inherited method, precisely as a method written in the body would — but only an acknowledged one (§3a). This is Schärli's flattening property and Pharo's rule, with Pharo's silent override removed, and it is the property that makes every later section cheap: after step 6 there is nothing trait-shaped left for ThreadedIr, dispatch, super, sealed self-sends, xref or hot reload to know about.

Where it runs. Flattening is a new pass in beamtalk-core semantic analysis (semantic_analysis/trait_expansion.rs), with explicit inputs: the module's AST, the ASTs of every trait it uses (§10a), and ClassInfo for superclasses outside the module. It is not an extension of today's same-file self-extension fold: that fold runs in the compiler port after analysis (compiler-port/handlers/compile.rs:109-142) and its merge_method (compiler-port/decode.rs:596) is replace-wins, the opposite of class-wins. The pass runs in two halves:

defined_in stays the using class. Every existing consumer of MethodInfo.defined_in reads it as a class name: inherited type-parameter substitution (build_inherited_substitution_map, type_checker/inference/generics.rs:116-190), method-local type-parameter inference (generics.rs:286-302), internal-visibility package lookup (validation.rs:666-673), DNU-override detection (has_dnu_override_in, method_resolution.rs:275-287) and the LSP's find_defining_class. Setting it to a trait name would silently break all five. Instead a new field MethodInfo.origin: Option<ProtocolName> records provenance for reflection, diagnostics and tooling, and defined_in keeps meaning "the class whose module holds the code", which after flattening is the user.

Type-parameter substitution and hygiene. Expansion substitutes the uses: type arguments for the trait's type parameters (uses: Enumerable(Worker) replaces E with Worker) and Self as in §1. It must be hygienic: a trait provision's method-local type variables are alpha-renamed before substitution, so Pair(A, B) uses: Enumerable(A) does not capture the A in inject: initial :: A into: … as the class's A.

Name resolution. Free class and protocol names in a provision are resolved in the protocol's package at expansion time and emitted package-qualified, and internal visibility (ADR 0071) of anything a provision references is checked against the protocol's package. Without this, a provision from package json flattened into a user would resolve Parser in the user's package (ADR 0070 resolves names lazily at the use site): json's own internals would become visibility errors, and a same-named class in the user's package would capture the reference.

Source locations. A flattened method keeps the source identity of its protocol. Today Span carries only offsets (span.rs:35) and codegen maps spans to lines through one source text per module (options.rs:40), so a copied provision's spans must be tagged with their file. Diagnostics whose span lies in a protocol are reported in the protocol file, once, de-duplicated across users, with a note "while flattening into A, B, …"; BEAM line annotations and stack traces for a flattened method point at the protocol's lines.

Synthesised methods rank as class body. A Value class's auto-generated field accessors and with*: setters are suppressed today whenever class.methods already has the selector (value_accessors.rs:63-92). The expansion pass takes the synthesised-selector set from the existing shared module crates/beamtalk-core/src/synthetic_selectors.rs, not a copy. A trait provision must not suppress them: synthesised accessors count as class body for step 4, so field: size beats a trait's size.

3a. Trait provisions never silently override inherited methods

The problem. Under plain Pharo precedence a trait's method beats an inherited one with no signal. That makes traits a worse fragile base class than inheritance, from two directions, and in neither does the using class's own source change:

The rule. After class-wins (§3 step 4), every remaining provision whose selector the using class inherits from its superclass chain, on the same side, must be acknowledged on its uses: line:

Record subclass: AuditRecord
  uses: Describable overriding: #(#printString)

Otherwise it is a compile error in the using class, naming both sides and both fixes:

error: Describable provides `printString`, which AuditRecord would otherwise
       inherit from Record.
  hint: to use Describable's version, write
          uses: Describable overriding: #(#printString)
        to keep Record's version, write
          uses: Describable excluding: #(#printString)

The error is the point: it turns a silent behaviour change on upgrade into a build break at the one place that can decide, which is the versioning argument behind C#'s explicit override/new.

Details.

4. Conflicts and their resolution

Two used traits providing the same selector on the same side is a compile error in the using class, unless one of:

An aliased copy is a provision like any other and can conflict.

Value subclass: Report
  uses: Labelled
  uses: Describable
  // error: `printString` is provided by both Labelled and Describable in Report.
  //   Define `printString` in Report, or exclude one:
  //     uses: Describable excluding: #(#printString)

There is no "call both" or Trait.super (Java 8's A.super.m()). In v1 a class that wants behaviour from both writes the method itself; its own definition wins over both provisions, so no exclusion is needed:

Value subclass: Report
  uses: Labelled
  uses: Describable

  printString -> String => self title ++ " " ++ self summary

Post-v1, aliasing: lets the class keep one provision under a new name and call it, instead of rewriting its body:

Value subclass: Report
  uses: Labelled
  uses: Describable excluding: #(#printString) aliasing: #{#describeString => #printString}

  printString -> String => self describeString ++ " " ++ self summary

5. Required methods

Every required selector of every used trait (after exclusion) must resolve on the flattened class: from the class body, from another used trait's provisions, or from the superclass chain. The check runs in the second half of the expansion pass, after ClassHierarchy has been built from the flattened classes (§3), so it can use the existing ClassHierarchy::resolves_selector, and ADR 0100's open-world policy applies: an unresolvable requirement is an error when the chain is closed-world and a hint when the superclass lives in an unindexed package or the class overrides doesNotUnderstand:.

Value subclass: Version
  uses: Comparable
  field: major = 0
// error: Version uses Comparable but does not implement required `<`
//   hint: Comparable requires `< other :: Self -> Boolean`

Provisions are checked once, in the protocol. Each provision is type-checked in the protocol file with self bounded by the protocol's required ∪ provided selectors plus Object's. A self-send outside that set is an error: "foo is sent by max: but is neither required nor provided by Comparable; declare it as required." This is Schärli's rule that the required set covers every self-send, and it means a protocol with no users is still checked. Per-user checking after flattening then reports only diagnostics that depend on the user, once each.

Excluding can break conformance. uses: T excluding: #(#x), where the class neither defines nor inherits x, leaves the class not conforming to T, the protocol it names. That is a warning: "C uses T but does not conform to T: it excludes x without defining or inheriting it."

extending: a protocol with provisions. When Q extending: P and P has provisions, P's provided selectors are requirements for classes that uses: Q, because Q's type includes them (§8) and extending: brings no bodies.

A required signature is checked like a protocol requirement: selector and arity are mandatory; declared types are compared by the type checker and mismatches are warnings, as everywhere else (ADR 0025).

6. self, super and ^ inside trait methods

7. Traits are stateless — required accessors, not slots

A trait body may not contain state:, field:, classState:, or any self.slot read or write. State a trait needs is expressed as a required accessor (and, for mutation, a required mutator), which the using class supplies as a method:

Protocol define: Counting
  /// Required.
  count -> Integer
  /// Required. Returns the receiver (Actor) or the updated value (Value).
  setCount: n :: Integer -> Self

  increment -> Self => self setCount: self count + 1
  reset -> Self => self setCount: 0

Rationale, in order of weight:

  1. Kind-neutral state access. self count compiles correctly in an actor (State map), a value (Self map) or a class-side method (ClassVars map) because it is a message send, and message sends are already threaded by ThreadedIr in every context. A self.count := … in a trait body would need the trait to know which storage family it will be flattened into — that is a fourth threading input, and it is exactly the "per-kind semantics" ADR 0067 spent a whole ADR separating. This covers state, not every body shape: a provision that passes a block through a self-send to another provision is broken on actors today (BT-3580, §9). Kind-neutrality for block-taking provisions depends on that fix, or on the trait being written so no block crosses a self-send.
  2. No shape changes, ever. Since a trait adds no slots, using, editing or reloading a trait never changes a class's state shape, so it never triggers ADR 0123 migration or hot_reload_descendants. Trait reload is a pure method-table event.
  3. It is the original model. Schärli's traits are stateless with required accessors; Pharo shipped that for a decade before adding stateful traits, and its TSlot/TraitedClass machinery is the part that keeps breaking.
  4. Object subclasses cannot hold state at all (ADR 0067), yet should be able to use class-side traits (§9, post-v1).

Selectors a trait may not provide. Some selectors change what the compiler or runtime does with a class, not just what it answers, so a trait providing them would change a class's shape or dispatch behind a uses: line. A provision (not a requirement) of any of these is an error (§13): initialize, terminate: (the actor lifecycle hook), migrateFromV<N>: (ADR 0123's migration chain, ast/class.rs:350), doesNotUnderstand:args: (open-world conformance, ADR 0068/0100), and supervisionPolicy / supervisionSpec (actor_codegen.rs:337-349). The statelessness check is syntactic (self.slot, slot declarations); reflective writes such as fieldAt:put: are not detected, exactly as they are not in any method today.

Stateful traits are deferred, not rejected. If evidence appears (a trait whose users all declare the same slot with the same default), the extension is a per-kind state:/field: declaration inside the trait, flattened into the user's slot list, and it must then specify its own ThreadedIr implications. Nothing decided here forecloses that.

8. The type of a protocol with provisions

A protocol's type — what conformsTo:, :: T annotations, intersections and bounds check — is its required ∪ provided selectors, instance and class side. For a protocol with no provisions that is exactly ADR 0068's rule, unchanged. Protocol requiredMethods: keeps answering the requirements; conformance checks requiredMethods: ∪ providedMethods:. Consequences:

extending: and (post-v1) uses: can both appear in a protocol body and stay distinct. extending: P adds P's selectors to this protocol's type and nothing else (ADR 0068). uses: P flattens P's provisions into this protocol, which also brings them into its type. A protocol that wants P's bodies uses it; one that only wants to be a subtype of P extends it.

9. Traits across the class kinds

Value — DateTime, above (§2). The same change applies to Duration, Uuid and String. Each keeps its primitive < and should keep its primitive >/<=/>= too, since a body method beats a provision (§3) and each is its own FFI call; what they gain is between:and:, min: and max:. Number does not use Comparable. It already defines all four operators and between:and: (number.bt:38-47, 101), so the trait would add only min:/max:, and Comparable's min: other :: Number -> Number would change tie semantics (3 max: 3.0 answers 3.0 today, and would answer 3). The Integer/Float min:/max: duplicate is fixed by moving it up to Number, which is plain inheritance.

Actor — an actor that wants enumeration over what it holds. The trait requires a snapshot rather than an internal iterator, so every provision forwards to a primitive List operation and no block ever crosses a self-send:

// stdlib/src/enumerable.bt
Protocol define: Enumerable(E)
  /// Required — the elements, in the receiver's order, as a List snapshot.
  elements -> List(E)

  size -> Integer => self elements size
  isEmpty -> Boolean => self elements isEmpty
  isNotEmpty -> Boolean => self elements isNotEmpty

  do: block :: Block(E, Object) -> Nil => self elements do: block
  inject: initial :: A into: block :: Block(A, E, A) -> A =>
    self elements inject: initial into: block
  select: block :: Block(E, Boolean) -> List(E) => self elements select: block
  collect: block :: Block(E, R) -> List(R) => self elements collect: block
  detect: block :: Block(E, Boolean) ifNone: noneBlock :: Block(T) -> E | T =>
    self elements detect: block ifNone: noneBlock
  anySatisfy: block :: Block(E, Boolean) -> Boolean => self elements anySatisfy: block
  count: block :: Block(E, Boolean) -> Integer => self elements count: block
typed Actor subclass: WorkerPool
  uses: Enumerable(Worker)
  state: workers :: List(Worker) = #()

  elements -> List(Worker) => self.workers

  add: w :: Worker -> Nil => self.workers := self.workers add: w
  idle -> List(Worker) => self elements select: [:w | w isIdle]

The snapshot design is deliberate, and it replaces an earlier draft whose provisions were written over a required do: (size as self inject: 0 into: …, detect:ifNone: as self do: [… ^each …]). That shape is correct in a Value but broken in an Actor today: a block passed through an actor self-send loses both ^ (the NLR throw escapes as a raw $bt_nlr error) and captured-local writes (size answered 0 for four elements). The probe and the fix are tracked as BT-3580. The snapshot design also answers two semantic points that a do:-based trait gets wrong on actors:

Adoption is per class, not mechanical. SupervisionTree and ChangeLog are one-line forwarders to an inner list already, so they become elements => self flatNodes (not nodes, which is an FFI enrich pass, supervision_tree.bt:40-41) and elements => self activeEntries. But ChangeLog select: deliberately ranges over all entries, including orphans (change_log.bt:36-44, 96-106), so it keeps its own select: (class wins), and SupervisionTree do: answers self, not nil, so it keeps its own do:. Its return type (SupervisionTree, not Nil) must be reconciled in Phase 6 itself, because the §8 override warning would fail the stdlib build under --warnings-as-errors: either change it to Nil after auditing callers, or uses: Enumerable(SupervisionNode) excluding: #(#do:). Collection does not use Enumerable in v1: its select:/collect: answer Self via species, and every enumeration method it has is already inherited by its subclasses. But Phase 6 adds elements -> List(E) => self asList to Collection, so every list, set and array conforms to Enumerable structurally, and x :: Enumerable(E) accepts them. Without it the name would promise the Ruby/Elixir Enumerable while no collection satisfied it.

Object (class-side provisions are post-v1, §Implementation; in v1 an Object subclass can use only instance-side provisions, which is rarely useful) — instance methods on an uninstantiable class are pointless, so Object subclass: users compose class-side traits:

Protocol define: Versioned
  /// Required.
  class version -> String

  class banner -> String => self name asString ++ " " ++ self version
  class isVersion: v :: String -> Boolean => self version =:= v
Object subclass: ReportTool
  uses: Versioned

  class version -> String => "1.4.0"

ReportTool banner // => "ReportTool 1.4.0". A trait with instance-side provisions used by an Object subclass is not an error — the methods flatten onto a class that will never be instantiated, exactly as a hand-written instance method on an Object subclass is not an error today.

Native classes (native: mod, ADR 0101) can use traits; a provided body is an ordinary method next to the self delegate ones.

10. Codegen — flatten into the user; the protocol module carries the source

Using class. Nothing new. The flattened ClassDefinition (with the trait MethodDefinitions merged in, provenance-marked) goes through the existing actor / value / class-side generators. The only codegen-visible additions are (a) methodXref rows for flattened methods carry provenance := protocol, origin := ProtocolName; (b) methodSource for a flattened method is the trait file's source slice for that method, taken from the trait's span and not unparsed after substitution, so browse and CompiledMethod source show the real code and the ChangeLog does not see a spurious difference; and (c) the user's __beamtalk_meta records uses => [{ProtocolName, ContentHash}], the hash of each used protocol's source, so the build, ADR 0105's re-check, and ADR 0125's release preflight can detect a user compiled against a stale trait.

Protocol module. A protocol file already compiles to a module that registers the protocol on load (generate_protocol_registrations, class_registry.rs:699). With provisions it still contains no dispatch and no callable methods. Its registration additionally records the provided signatures, per-method source, docs and categories, and the runtime beamtalk_protocol_registry gains a provided-selector set and a users index. There is no second registry.

Carrying trait ASTs to users. Flattening needs the trait's method ASTs at the user's compile time, and __beamtalk_meta does not carry method source today (class_meta.rs; per-method source lives only in the live class process via register_class/0's BuilderState, class_registry.rs:555). The carrier is therefore the trait's .bt source, which every compile path already has access to:

A trait is therefore a compile-time dependency of its users; §10a says how each build path tracks it.

Why not a shared trait module + dispatch step (foreign-extension style): an actor method's super and sealed self-sends are compiled against the lexical module, and its ordinary self-sends dispatch through the instance's own class module (BT-3666); compiled once in a trait module they would target a module with no gen_server and no dispatch/4. The calling convention differs by kind, so a shared module would need one body per kind per method. Every send on every class would pay an extra ETS lookup in the chain walk that ADR 0032 just shortened. And super, sealed self-sends, late slot guards and error hints all bake in the lexical class. Flattening makes every one of these a non-issue; the costs it carries instead (N copies of each method's bytecode, N recompiles on trait edit) are priced in §Consequences.

10a. Build graph and invalidation

Traits are the first cross-file dependency that embeds code in its dependents. Today every compile path treats .bt files as independent: the CLI rebuilds a file only when its own content hash changes (commands/build/changes.rs:120-150), and build_stdlib says "files are independent, no ordering required" (build_stdlib.rs:132, 196-198). Each entry point changes as follows:

Entry pointGets trait ASTs fromInvalidates users by
CLI beamtalk buildSame-package parse; dependency source (§10)A user's cache key becomes its own hash plus the hashes of its transitive traits
build_stdlibThe existing protocol pre-pass (build_stdlib.rs:968), extended to keep full ASTs of protocols with provisions and hand them to every fileStdlib is rebuilt whole; generated_builtins.rs is regenerated from the flattened classes
Compiler port (REPL, workspace)Workspace source, or '__beamtalk_protocol_source'/0§11 fan-out, using the users index in the protocol registry
LSP / language serviceProjectIndex, which gains trait → users edgesRe-analyses open users when a trait file changes
MCP lint / beamtalk checkSame as the CLI buildSame as the CLI build

Load order is unchanged: protocol modules already load before classes (protocol_modules). A class module never calls a protocol module, so a missing protocol module at run time affects reflection only, never dispatch.

Binary-only dependencies. Outside the REPL, building a user of a dependency's protocol needs that protocol's source: from the dependency's source checkout, or from '__beamtalk_protocol_source'/0 on its compiled module. A dependency that ships neither cannot have its provisions flattened, and uses: of such a protocol is an error naming the missing source.

Packages and releases. A dependency's trait bodies become part of each downstream user's compiled code. A patch release of a dependency that changes a provision has no effect downstream until the downstream package is rebuilt. The uses => [{Name, Hash}] meta entry (§10) lets beamtalk build warn when a dependency's trait hash differs from the one a user was compiled against, and lets ADR 0125's appup generation see that a user module changed.

11. Hot reload and live patching

12. Reflection and tooling

SurfaceAddition
Protocol (protocol.bt)Protocol providedMethods: #Comparable and Protocol usersOf: #Comparable (answering List(Behaviour), order unspecified; SystemNavigation usersOf: shares its implementation) beside the existing requiredMethods: and conformingClasses:. usersOf: answers the classes that uses: it; conformingClasses: still answers every structural conformer
BehaviourusedProtocols -> List(Symbol) (directly used, in uses: order), allUsedProtocols (transitive, including superclasses'), usesProtocol: #Comparable — distinct from the existing protocols, which answers structural conformance; methods still answers local selectors and includes flattened ones, because they are local
CompiledMethod`origin -> Symbol
SystemNavigationusersOf: #Comparable; implementorsOf: #between:and: lists every user (true — each does implement it) and each row's origin says which trait
Browse / categories (grouping post-v1; in v1 flattened methods are listed under their protocol's own dividers, unprefixed)Flattened methods appear in the class under their trait's own // === === dividers, prefixed with the trait name (Comparable › Derived ordering), via a new provenance := protocol branch beside the existing extension → "extensions" one (protocol_from_source/4, beamtalk_repl_ops_browse.erl:2284-2287)
Xref (ADR 0087, 0115)beamtalk_xref_methods rows: provenance := protocol, origin := ProtocolName. Sender rows inside a flattened body are indexed per user, recv_type per ADR 0115, so sendersOf: on < finds DateTime's copy of between:and: — correct, because that copy really sends < to a DateTime
Rename (ADR 0114; protocol-wide rename is post-v1; in v1 a class rename also rewrites type arguments in uses: lines, e.g. uses: Enumerable(Worker), via referencesTo:)renameSelector:to: on a trait-provided selector of a class is refused with a hint to rename on the trait; on the trait the site closure is the trait body ∪ the union of every user's ADR 0114 closure, one ChangeLog entry. renameTo: on a trait adds uses: lines to referencesTo: (a new uses_protocol reference row) and moves the file like a class rename does
LSPGo-to-definition on a flattened method jumps to the trait; completion on a receiver of a using class lists trait methods (they are in ClassInfo.methods); hover shows from Comparable (hover: post-v1)
Extension conflict (ADR 0066)An extension DateTime >> max: on a trait-provided selector is the existing "cannot override a method defined in the class body" error, because after flattening it is in the body

13. Diagnostics

All at the uses: line or the offending trait line; codes assigned at implementation in the existing E/W series.

SituationSeverityMessage shape
Two traits provide the same selectorError"sel is provided by both A and B in C. Define sel in C, or exclude one: uses: B excluding: #(#sel)"
Required selector unresolvable (closed world)Error"C uses T but does not implement required sel" + the required signature as hint
Required selector unresolvable (open world)Hintas ADR 0100
uses: names a protocol with no provided methodsHint"Printable provides no methods, so uses: only checks its requirements here; conformance is structural (ADR 0068)" (§1, §Status 6)
uses: names an unknown nameError"unknown protocol T" + nearest-name hint
uses: after a slot or methodError"uses: lines must come before state and method declarations"
Unknown keyword line in a class bodyError"unexpected foo: in class body" (replaces the silent end-of-body)
excluding: names a selector T does not provideError"T does not provide sel"
aliasing: in v1Error"aliasing: is not yet supported" (the check itself, "T does not provide sel", arrives with Phase 7)
excluding: a required selectorError"sel is required by T, not provided; requirements cannot be excluded"
state:/field:/classState: or self.slot in a protocolError"protocols are stateless — declare slot -> Type as a required method"
A uses: line in a protocol body (v1)Error"a protocol using another protocol is not yet supported" (post-v1 it becomes composition, and a cycle is an error: "protocol A uses itself through B")
Protocol modifier (sealed Protocol define:)Error"protocols take no modifiers"
sealed or internal on a provided methodError"sealed/internal are not supported on provided methods in v1"
A protocol provides initialize, migrateFromV<N>:, doesNotUnderstand:args:, supervisionPolicy or supervisionSpecError"a protocol cannot provide sel; it changes how the class is built or dispatched. Declare it as a required method instead"
A provision would override an inherited sealed methodErroras for a class-body method overriding a sealed method
A provision would override an inherited method (outside the kind roots) without overriding:Error§3a — names the trait, the superclass, and both overriding: and excluding: fixes
The same, where the superclass chain is open-world (ADR 0100)Hint§3a
An overriding: entry that replaces nothingWarning"sel in overriding: does not override an inherited method; remove it"
overriding: names a selector T does not provideError"T does not provide sel"
A class uses: a trait its superclass already uses, and the superclass has not customised any of its selectorsHint"C's superclass already uses T; this uses: is redundant" (same origin, §3a)
The same, where the superclass customised a selector T providesError§3a — the provision would replace the superclass's customisation; acknowledge with overriding: or excluding:
A provision sends a selector that is neither required nor provided (nor Object's)Error"foo is sent by max: but is neither required nor provided by T; declare it as required" (§5)
A provision references a name not resolvable from the protocol's packageErrorreported once, in the protocol file (§3, "Name resolution")
uses: T excluding: #(#x) leaves the class not conforming to TWarning"C uses T but does not conform to T: it excludes x without defining or inheriting it" (§5)
A protocol provides exactly one of equals: and hashWarning§3a
overriding: on a protocol's own uses: line (post-v1)Error"a protocol has no superclass; overriding: has no meaning here" (§3 step 1)
A provision uses @primitive or @intrinsicError"provided methods cannot use primitives" (§1)
A class-side provision (class sel … =>) in v1Error"class-side provided methods are not yet supported" (§1)
A class-body override is not override-compatible with the provision it replacesWarning§8
Renaming or removing a required selector on a userRuntime error§11
A trait edit that makes any source-backed user fail to compileRuntime error (edit rejected, nothing loaded)§11 — lists every failing user with its diagnostic
Self as a parameter type in a class body methodErrorunchanged (check_no_self_in_params); the hint mentions traits when the method's class uses one
Type mismatch between a required signature and the class's methodWarningas ADR 0025 protocol/type warnings
removeSelector: on a trait-provided method of a classRuntime errorsee §11

REPL session

With this version.bt loaded (Comparable is a stdlib protocol):

typed Value subclass: Version
  uses: Comparable
  field: major :: Integer = 0
  field: minor :: Integer = 0

  < other :: Version -> Boolean =>
    (self.major < other major) or: [(self.major =:= other major) and: [self.minor < other minor]]
v := Version major: 1 minor: 4
v between: (Version major: 1 minor: 0) and: (Version major: 2 minor: 0)   // => true
v max: (Version major: 1 minor: 9)                       // => Version(major: 1, minor: 9)
Version usedProtocols                                     // => [#Comparable]
Version conformsTo: #Comparable                           // => true
// Integer conforms structurally, without uses:
Integer conformsTo: #Comparable                           // => true
(Version >> #max:) origin                                 // => #Comparable
(Version >> #<) origin                                    // => nil
(Protocol usersOf: #Comparable) size                      // => 5
Comparable >> max: other :: Self -> Self => (self < other) ifTrue: [other] ifFalse: [self]
// => error: Cannot recompile 'max:' on stdlib protocol 'Comparable': built-in protocols are read-only in the workspace
Version removeSelector: #max:
// => error: `max:` is provided by trait Comparable; exclude it with
//    `uses: Comparable excluding: #(#max:)` or remove it from the trait

Single source of truth

Prior Art

LanguageMechanismCompositionConflictsStateTaken / rejected
Smalltalk-80 / Squeak / Pharo ≤ 6Traits (Schärli, Ducasse, Nierstrasz, Black, ECOOP 2003): Object subclass: #C uses: TA + TB - {#x} @ {#y -> #x}Flattened; class wins; +/-/@Explicit exclusion or alias; unresolved conflict is a method that errors when calledStateless, required accessorsAdopted wholesale: flattening, precedence, exclusion, required/provided, same-origin-is-not-a-conflict (alias post-v1). Adapted: keyword clauses instead of + - @ operators; conflict is a compile error, not a runtime traitConflict; no T prefix
Pharo 7+Stateful traits (TraitedClass, TSlot)as above + slotsas aboveSlotsDeferred (§7) — kind-specific slots need a threading story
NewspeakMixins: every class declaration denotes a mixin that can be applied to a superclass to create a classLinearised application creates a class per useLatest application wins (order)YesRejected (§Alternatives) — order-dependent, one registry class + module per application, extra chain steps per send
Scalatrait with linearisation; with A with BLinearised; super chains through traitsTwo unrelated traits with the same concrete member are a compile error unless the class overrides; override is required on every concrete override, including toStringYesRejected as mixins. Adopted: "trait is also a type" (§8), conflicts as compile errors (§4), and explicit override with no root-class exemption beyond §3a's short allowlist
TypeScriptNo traits; a class used as a type includes all its members; override keyword and noImplicitOverride (4.3)Structuraln/an/aAdopted: a type made of every member, implemented or not, is our required ∪ provided rule (§8), and adding a member is a reported break for structural implementers. noImplicitOverride is the same concern as overriding: (§3a)
Rusttrait with default methods, impl T for SStatic, type-directed; orphan rulesDisambiguation by qualified pathNone (associated functions, consts and types only)Adopted: required vs. default methods as the two body shapes. Rejected: nominal impl — ADR 0068 chose structural
SwiftProtocol extensions with default implementationsStatic dispatch for non-requirement extension methodsAmbiguity errorNoneRejected: the static/dynamic dispatch split is a well-known footgun; Beamtalk has one dispatch (ADR 0006)
C# 8Default interface methods; override / new on class membersClass wins over interface defaults; a class member hiding a base member without new or override warnsMust overrideNoneAdopted: the versioning stance. C# makes replacing inherited behaviour an explicit act so a base-type release cannot silently change a subclass; overriding: (§3a) is that rule applied to trait provisions
Java 8Interface default methodsClass wins, including inherited superclass methods, which beat interface defaults; A.super.m() to pick a defaultTwo defaults with the same signature must be overriddenNoneAdopted: the class body wins. Diverged: here a provision beats an inherited method, but only with overriding: (§3a). Rejected: the qualified-super call; the class writes the method (v1) or aliases (post-v1) (§4)
KotlinInterface default methods; by delegationA superclass method and an interface default with the same signature is a compile error ("must override … inherits many implementations"); super<A>.m()Must overrideNoneDirect precedent for §3a: an overlap between an inherited method and a default is an error until the class says what it wants. by delegation is the snapshot Enumerable design (§9) in language form
Ponytrait (nominal, default methods) + interface (structural, may also carry default bodies)Only types that declare is I receive the bodies; structural conformers do notMust overrideNoneThe strongest precedent for §8 and decision 2: a structural type that includes defaulted methods, with bodies delivered only by an explicit declaration. Pony has no exclusion/alias; we keep Pharo's
Dartmixin with on constraints, withLinearisedOrder decidesYesRejected as mixins. A mixin's abstract members are its required methods; on constrains the superclass type so super calls type-check
Rubymodule + include / prependLinearised into the ancestor chainOrder decidesInstance vars by conventionRejected as mixins; Comparable/Enumerable are the canonical examples and the names we use
Perl Moose / RakuRoles: with 'R' => { -excludes => …, -alias => … }Flattened (roles are traits)Compile-time conflict unless resolvedAttributes allowedConfirms exclusion/alias as keyword clauses read fine outside Smalltalk
Elixiruse M → __using__ macro injects code; defprotocol for dispatchTextual injectionInjected and local clauses of the same name/arity merge (with a "clause cannot match" warning) unless the injector marks them defoverridablen/ause is unchecked injection; our flattening is checked injection, and defoverridable is our class-wins rule made opt-in. Protocols ≈ our ADR 0068. Enumerable is the reference API
Erlang / OTP-behaviour(M) callbacks; the experimental, since-removed -extends module attributeContract onlyn/an/aBehaviours ≈ protocols; no body sharing — the gap this ADR fills
LFE FlavorsMixins ((defflavor … (:mixins …)))LinearisedOrderYesRejected as mixins
GleamNone; modules and functionsn/an/an/aNot applicable
PHPtrait + use, abstract members as requirementsFlattened; class > trait > inheritedinsteadof / as resolve conflictsProperties allowedThe closest mainstream match to this ADR's model, and it shows the silent override of inherited methods that §3a exists to prevent
GoStructural interfaces, deliberately without default methods; embedding for reusen/an/an/aSupports decision 2's reasoning: in a structural system, defaults that structural conformers never receive are a trap, so Go has none

A note on the word "protocol". In Pharo a protocol is a method category — accessing, printing — with no required-selector set and no conformance check. Beamtalk already uses the word both ways: the REPL's browse-protocols op groups methods by category (the Pharo sense), while Behaviour protocols and Protocol define: are ADR 0068's structural types. In this ADR "protocol" always means the ADR 0068 type, and the rule that a protocol's provided methods are part of its type (§8) comes from Scala and Pony, not Pharo.

User Impact

Newcomer (Python/JS/Ruby). uses: Comparable next to state: reads like Ruby's include Comparable and Python's class V(Comparable), and the required/provided split is the Java-interface-with-defaults they know. The errors name the fix (define it in C, or exclude one: …). They discover it by Version usedProtocols, CompiledMethod origin, and (post-v1) browse grouping Comparable › Derived ordering. The one surprise is that Integer conformsTo: #Comparable is true without uses: — the same structural surprise ADR 0068 already documents.

Smalltalk developer. This is Pharo traits with the operators spelled as keywords and the conflict moved to compile time. Flattening, class-wins, super = superclass, stateless + required accessors, same-origin harmless: all Schärli. What differs: no T prefix, no runtime traitConflict method, and a trait is written as a protocol with bodies. They would look for Trait named: and find Protocol define:; for TFoo and find Foo.

Erlang/Elixir developer. A flattened method is an ordinary function in the class's module — observer, recon, dbg and Module:module_info() show it with no trait indirection, and Erlang callers dispatch to it like any other. The protocol module is metadata only. use-style injection is what they expect; the difference is that missing requirements are compile errors and conflicts do not silently merge clauses. Behaviour callbacks map to required methods. The concern they should raise: a dependency's trait bodies are compiled into their modules, so a dependency upgrade that changes a trait needs a rebuild to take effect (§10a).

Production operator. No new process, no new ETS lookup in the dispatch path, no state-shape change on trait reload. The cost is N recompiles when a trait used by N classes is edited — visible as N ClassLoaded announcements, exactly like N method edits. Bytecode grows by one copy per user; §Consequences quantifies. In a release, a trait edit changes every user's module, so the appup lists each of them, and the user meta's trait hash (§10) is what lets ADR 0125's tooling see why.

Tooling developer. ProtocolDefinition gains provided methods and uses:, and ClassDefinition gains uses: Vec<ProtocolUse>; everything downstream sees a flattened class with origin marks. Completion, hover, go-to-definition and rename each need one branch on origin. Static analysis gets easier: a trait method is analysed in each concrete context with concrete types. The unparser (needed for flush and rename) must round-trip provided methods in protocol bodies and uses: lines.

Steelman Analysis

Option A: Stateless flattened traits — Protocol define: with bodies + uses: lines (chosen)

Option B: Retroactive default bodies (every structural conformer receives them)

Provisions apply to every class that structurally conforms, with no uses: line, as Swift protocol extensions do.

Option B″: Extension methods on a protocol (C#-style)

Allow Comparable >> max: … => … as an extension whose receiver is any class conforming to Comparable, as C# extension methods on an interface (the design behind LINQ) do.

Option B′: One keyword, opt-in, required-only type (type rule rejected; single keyword adopted)

Protocol define: may carry bodies; a class opts in with uses:; the protocol's type is its requirements only, so provisions can be added without breaking structural conformers.

Option C: Newspeak-style mixins (class-in-the-chain)

Option H: Inheritance only (Magnitude, abstract enumerable Value class)

Option D: Extension methods only (Comparable+DateTime.bt per class)

Option E: Shared code + dispatch step (implementation variant of A)

Keep uses: opt-in, but compile each provision once, into the protocol's module, and reach it through a dispatch step, as foreign extensions (ADR 0066) are reached today.

Option F: Stateful traits (Pharo 7 style)

Option G: Multiple inheritance

Tension points

Alternatives Considered

Inheritance only — Magnitude and an abstract enumerable Value class

Add abstract typed Value subclass: Magnitude (Pharo's own home for </between:and:/min:/max:) above Number, DateTime, Duration and Uuid, and an abstract enumerable Value class above Collection, SupervisionTree and ChangeLog. No new language surface; it removes most of the §Context duplication today. Rejected as the mechanism because it cannot serve String (ordered and a collection), any Actor, or any class whose superclass is already spent (§Context, "What inheritance cannot fix"). It is also rejected as an interim stdlib refactor (decided 2026-09-23): it would model capabilities as kinds (§Context, "Kinds versus capabilities") and then have to be unwound when traits land.

Extension methods on a protocol (C#-style)

Comparable >> max: … => … applying to every conformer, as C# extension methods on an interface do. Rejected because C# resolves them statically from the receiver's declared type, and Beamtalk's dynamic sends would need a run-time conformance check on every lookup miss and would turn cross-protocol ambiguity into a run-time question (§Steelman B″).

Delegation (Kotlin by)

Forward a set of selectors to a member object: Enumerable by self.workers. The snapshot Enumerable (§9) is this pattern expressed as a protocol with provisions. A general delegation form would need a per-kind way to name the delegate (a slot on actors and values, nothing on Object subclasses) and adds no capability that elements-style required methods lack. Not pursued.

Protocols with default method bodies

Protocol define: Comparable gaining => bodies. The retroactive form, where bodies apply to every structural conformer, is rejected: it injects methods into classes that never opted in (§Steelman B). The opt-in form, B′ (uses: required, type = requirements only), is rejected for its type rule (§Steelman B′, §Status 2). Its single-keyword syntax is adopted (§Status 3).

A separate Trait define: keyword

The earlier draft of this ADR: traits as their own top-level form beside Protocol define:, with identical body grammar. Rejected (2026-09-23, §Status 3): with the type rule settled it differs from the chosen design only in readability, and it makes a protocol file change keyword when it gains its first body.

Newspeak / Ruby / Dart / Scala mixins

Linearised application inserting a class per use. Rejected: order-dependent conflict resolution; a registry gen_server and module per application; one more step in every chain walk; cannot cross class kinds if it carries state (§Steelman C).

Extension methods only

Status quo, one Comparable+DateTime.bt per user. Rejected: it is what produced the duplication in §Context (§Steelman D).

Shared trait module + dispatch step

Foreign-extension-style ETS lookup of trait funs. Rejected for the lexical self-send, dual calling convention and per-send cost (§10, §Steelman E).

Stateful traits

Deferred (§7, §Steelman F).

Multiple inheritance

Rejected by ADR 0005; see §Steelman G.

uses: as a header clause

Value subclass: DateTime native: beamtalk_datetime uses: Comparable. This is Pharo's spelling and matches native:. Rejected because the header tail is already order-sensitive (the docs warn that reversing native: and handleScope: silently drops one), several traits with excluding: and aliasing: do not fit on a line, and the rejected-but-precedent ADR 0042 sketch already used the body-line form.

Pharo's + - @ operators

uses: TA + TB - {#x} @ {#y -> #x}. Rejected: the class body is declaration context, not expression context, and @ is the package separator in json@Parser. Keyword clauses with literal lists/dictionaries need no new tokens.

Runtime conflict methods (Pharo's traitConflict)

Pharo installs a method that raises on call when a conflict is unresolved. Rejected: Beamtalk compiles classes; a conflict is known at compile time and should be an error there, per the diagnostic policy of ADR 0100.

Trait.super / A.super.m() for calling an overridden provision

Java 8's escape hatch. Rejected in favour of writing the method in the class (v1) and aliasing: (post-v1) (§4): both keep the flattening property (nothing trait-shaped survives compilation) and need no new send form.

Do nothing

The BT-274 cancellation position ("the class hierarchy handles behavior sharing adequately for current needs"). The stdlib count alone does not refute it: v1 removes about eight enumeration forwarders from SupervisionTree and ChangeLog, and adds between:and:, min: and max: to four classes that lack them today. The ordering operators stay, and the Integer/Float min:/max: copy is fixed by inheritance. Rejected because the value is the mechanism, not the line count: capabilities should not be modelled as kinds, and the cases inheritance cannot reach at all — two concerns on one class, the actor/value wall, and a spent superclass slot — are permanent properties of the language.

Consequences

Positive

Negative

Neutral

Implementation

Sized for /plan-adr. Phases 1–3 are the compiler; 4–5 the runtime and live system; 6 adoption. Phase 0 is a half-day spike that de-risks the one assumption this ADR could not verify from source.

PhaseScopeComponentsSizeDepends on
0Spike: (a) confirm inherited actor method self-send binding (lexical <module>:safe_dispatch vs. __class_mod__), since §6 rests on self-sends resolving in the using class's module; (b) re-run the BT-3580 probe and record whether block-taking actor provisions are safe. Record both in docs/development/debugging.mdruntime, codegen (read-only)S—
1Syntax: ProtocolDefinition gains provided_methods (ast/class.rs; a uses: line in a protocol body is a "not yet supported" error in v1); ClassDefinition.uses: Vec<ProtocolUse { protocol, type_args, excluding, overriding, span }>; parse_protocol_body accepts provided methods (a signature followed by =>) through the class method parser, and reserves uses:/excluding:/overriding:/aliasing: (aliasing: parses to a "not yet supported" error until Phase 7); package@Protocol; uses: in the class-body loop with ordering and unknown-keyword errors; unparse round-trip; no new top-level form, so one-definition-per-file and ADR 0119 naming are unchanged; lexer nothing (contextual keywords)beamtalk-core source_analysis, ast, unparseM—
2Semantics: protocol_registry.rs extended with provided signatures; trait_expansion.rs implementing §3–§5 as a new pass with explicit inputs (expansion before ClassHierarchy, requirement check after; exclusion, conflict, class-wins, same-origin rule, synthesised accessors ranked as class body); MethodInfo.origin with defined_in left as the using class; ProtocolInfo conformance over required ∪ provided; hygienic type-param and Self substitution; reserved-selector and override-compatibility checks; the §3a unacknowledged-override check with kind-root exemption and stale-entry warning; statelessness validator (§7); all §13 diagnostics; typed check on the flattened class; name resolution in the protocol's package (§3); provisions checked once in the protocol with the self-send rule (§5); excluding: conformance warning; extending: of a protocol with provisions (§5); same-origin exemption and kind-root allowlist (§3a); diagnostics tagged with the protocol's source identity and de-duplicated across users (§3)beamtalk-core semantic_analysis, type_checkerL1
3Codegen & build graph: feed the flattened ClassDefinition to the existing generators (no change to merge_method); methodXref provenance/origin; methodSource as the trait's source slice; uses => [{Name, Hash}] in user meta; protocol module emission extended with provisions (generate_protocol_registrations, '__beamtalk_protocol_source'/0); every §10a entry point (CLI cache key, build_stdlib protocol pre-pass keeping full ASTs and generated_builtins.rs, compiler port, dependency_classes.rs, ProjectIndex edges); conformance fixture for the extended protocol registration shape; codegen maps lines per method source so BEAM line annotations for flattened methods point at the protocol file (§3)beamtalk-codegen, beamtalk-compiler-port, beamtalk-cli, beamtalk-language-service, build_stdlibL2
4Runtime & reflection: beamtalk_protocol_registry.erl gains provided selectors and a users index; Protocol providedMethods:/usersOf:; Behaviour usedProtocols/allUsedProtocols/usesProtocol:; CompiledMethod origin; SystemNavigation usersOf:; xref provenance := protocol; removeSelector: guard (§11); beamtalk_xref_methods schema bump; surface-parity table rowsruntime, stdlib, docs/development/surface-parity.mdM3
5Live system: trait file reload → two-stage user recompile fan-out in the workspace loader (compile all, load only if all succeed, roll back loaded modules on a load failure; source-backed, non-stdlib users only; stdlib traits read-only); Describable >> sel => … and removeSelector: live patching on protocols; required-selector rename/remove refusal and save-section routing (§11); provided methods in Protocol define: at the REPL; ADR 0105 re-check hookup; flush of protocol files (ADR 0113); LSP go-to-definition and completion on origin; REPL-protocol testsworkspace, REPL, LSPL4
6Stdlib adoption (one issue per trait): Comparable on DateTime, Duration, Uuid, String (each keeps its primitive operators); Enumerable(E) on SupervisionTree and ChangeLog per §9 (ChangeLog keeps its all-entries select:, and keeps its public notEmpty as the one-line method notEmpty -> Boolean => self isNotEmpty; SupervisionTree keeps its do:); separately, move Integer/Float min:/max: up to Number (inheritance, not traits); BUnit tests in stdlib/test/; docs/beamtalk-language-features.md § Traits; close ADR 0005 Q9; add elements -> List(E) => self asList to Collection so every collection conforms to Enumerable; reconcile SupervisionTree do:'s return type before adoption (the stdlib builds with --warnings-as-errors); list the selectors added per class in the release notes (§Migration Path)stdlib, docsM5
7Post-v1 (one issue each, any order): a protocol uses:-ing another (transitive expansion, cycle check, protocol-internal precedence, same-origin diamonds, §3 step 1, §4, §3a); aliasing: (parser clause, alias-before-exclude semantics, conflict rules, §2/§4); class-side provisions (§9); browse grouping by trait (§12); protocol-wide rename (ADR 0114 renameSelector:to: redirection and protocol renameTo: with uses_protocol reference rows); LSP hover provenancecore, workspace, LSPM6

Tests per phase.

PhaseSuites
0A BUnit fixture in stdlib/test/ pinning the observed self-send binding: a subclass override called from an inherited actor method
1Parser unit tests and snapshots in beamtalk-core, unparse round-trip, and diagnostics for misplaced uses: and unknown keyword lines
2Semantic-analysis unit tests for each §13 row, both §3a directions (trait grows, superclass grows), class-wins, Self substitution, and generic E substitution, a type error in a provision shared by two users reported once at the protocol's line, a self-send outside the declared set, and a same-named class in the user's package not capturing a provision's reference
3test-package-compiler codegen snapshots for one user of each class kind, just verify-threaded-ir over the flattened stdlib, and the trait-meta conformance fixture
4Runtime EUnit for the extended beamtalk_protocol_registry, BUnit reflection tests (usedProtocols, origin, usersOf:), and xref tests for provenance := protocol
5tests/repl-protocol/cases/ for trait reload fan-out, a rejected edit that loads nothing and names the failing user, Describable >> sel live patching, removeSelector: refusal, and rename; plus LSP tests
6Existing just test-stdlib and just test-bunit stay green with the duplicates deleted, plus new BUnit tests for between:and:/min:/max: on each new user
7Per feature: protocol-uses-protocol expansion, cycles and same-origin diamonds; parser and semantic tests for aliasing:, BUnit for class-side provisions, REPL-protocol tests for rename and browse grouping

Phase 1 alone is mergeable (a parsed but unexpanded uses: is a "not yet supported" error); phases 2–3 together give a working compiler; phase 4 is required before the feature is documented as available, because reflection without origin misleads.

v1 scope (decided 2026-09-23, §Status 4). Phases 0–6 as listed above make up v1. Phase 7 is the post-v1 work.

In v1Post-v1 (Phase 7)
Protocol define: with provided methods; uses: with excluding: and overriding: (§3a is not optional: without it v1 would ship the silent-override problem it exists to prevent)aliasing: (§2); ChangeLog keeps notEmpty as a one-line method instead
Flattening, conflicts, requirements, reserved selectors, statelessness, Self and type-parameter substitutionClass-side provisions (§9, Versioned)
Required ∪ provided type (§8)Browse grouping by trait (§12)
All-or-nothing reload of every user (§11)Protocol-wide rename across all users (ADR 0114 redirection, protocol renameTo:)
Live patching: P >> sel => … and P removeSelector: (principle 11)LSP hover "from Comparable" polish beyond go-to-definition
Users index; Protocol usersOf:/providedMethods:; Behaviour usedProtocols (principle 8, and the reload fan-out's source of targets)
origin on CompiledMethod and xref rows; refusal to rename or remove a trait-provided method on one class
Stdlib: Comparable on DateTime, Duration, Uuid, String; Enumerable on SupervisionTree, ChangeLog

aliasing: returns when a second use appears beyond ChangeLog notEmpty. Every post-v1 item is additive: none changes the meaning of v1 code.

Deferred to follow-up ADRs, explicitly: stateful traits (§7); trait-level modifiers; sealed traits for whole-program optimisation (BT-274's last criterion — moot while flattening already gives per-class sealing).

Implementation Tracking

Epic: BT-3586 Status: Implemented (v1); post-v1 items tracked in BT-3595

ADR phaseIssueTitleSizeBlocked by
1BT-3587Parse provided methods in Protocol define: and uses: lines in class bodiesM—
2BT-3588Flattening pass; protocol type is required ∪ providedMBT-3587
2BT-3589Semantic checks: requirements, overriding:, provision self-sends, protocol-side rulesMBT-3588
0, 3BT-3590Codegen with protocol source identity and package-scoped name resolutionMBT-3588
3BT-3591Build graph: protocol → user edges in every compile pathMBT-3590
4BT-3592Runtime users index and reflection APIMBT-3590
5BT-3593All-or-nothing reload and live patchingMBT-3591, BT-3592
6BT-3594Stdlib adoption, Traits docs, end-to-end testMBT-3589, BT-3593
7BT-3595Post-v1 (needs-spec)Lv1

Merged phase PRs: BT-3587 (#4034), BT-3588 (#4037), BT-3589 (#4044), BT-3590 (#4049), BT-3591 (#4051), BT-3592 (#4052), BT-3593 (#4054), BT-3594 (#4060).

Related: BT-3580 (actor self-send block bug; not blocking).

Migration Path

Additive for language semantics; three notes for adopters:

References