Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RFD-0041 — Traits and behavioral polymorphism

Discussion Opened 2026-05-17

Question

How does Argon express behavioral polymorphism — generic algorithms that work across multiple concept hierarchies — without introducing OO-style inheritance or runtime dispatch? This RFD specifies the trait system named as atom #4 in RFD-0040.

Context

Argon’s current generic surface lifts <T: Bound> from pub constraint to pub derive / pub mutation / pub compute (RFD-0036, discussion). The bound today is a concept subtype — T must be a subtype of some concept. This is sufficient for monomorphizing over a domain hierarchy but does not express the common case of “T must provide a compare method” or “T must be hashable.”

The sharpe-ontology/ workflow branch reaches for behavioral contracts repeatedly. From workflow.ar:

// TODO: Implement the trait feature for this.
// Every instance must define its own function to define its successor
hasAlternativeSuccessors: [WorkflowActionType]

The team contributor who raised this in standup framed it as needing the “trait/macro/fn stuff” — three substrate primitives missing from the current language. This RFD addresses the trait component; RFD-0042 the macro component; RFD-0040 the rule/fn unification.

The OO trap is real. Multiple inheritance via concept hierarchies — pub kind Manager <: Person, Employee, Authority — invites diamond-inheritance pathologies the metatype calculus does not handle. Rust’s trait model handles this by separating what a thing IS (its type / concept) from what it can DO (its traits), with static dispatch resolving method conflicts at compose time.

Decision

D1 — Trait declaration

A trait declares required behavior. Syntax:

pub trait Identifiable {
    fn id(self) -> Text
}

A trait has zero or more required items. Items can be:

  • fn — pure functions.
  • derive — derivation rules (head bound by the trait, body provided by impls).
  • query — read-only observations.
  • mutation — state-changing operations.
  • Associated types: type Item;
  • Associated constants: const N: Nat;

Items declared in a trait body have signatures only; impls provide bodies.

pub trait Comparable {
    fn compare(a: Self, b: Self) -> Ordering
    type Key                                       // associated type
    const Identity: Self                           // associated constant
}

Self is the implementing type. It is in scope inside the trait declaration as a placeholder.

D2 — Default implementations

Required items can carry default bodies:

pub trait Comparable {
    fn compare(a: Self, b: Self) -> Ordering
    fn equal(a: Self, b: Self) -> Bool = compare(a, b) == Ordering::Equal
    fn less(a: Self, b: Self) -> Bool = compare(a, b) == Ordering::Less
}

An impl can override any default. Items without defaults must be implemented.

D3 — Trait inheritance via <:

A trait can extend other traits. The supertype operator <: is reused; inheritance means “implementing the subtrait requires also implementing the supertrait.”

pub trait Ord <: Eq {
    fn compare(a: Self, b: Self) -> Ordering
}

impl Ord for Person requires impl Eq for Person to exist. Multiple supertraits are line-broken per RFD-0040 D9:

pub trait Comparable
    <: Eq, Hashable
{
    fn compare(a: Self, b: Self) -> Ordering
}

Trait inheritance is a contract relationship, not a method-override relationship. Subtrait default methods cannot override supertrait default methods; the implementer chooses.

D4 — impl blocks

A concept implements a trait via a separate impl declaration:

pub kind Person {
    name: Text,
    email: Text,
}

impl Identifiable for Person {
    fn id(self) -> Text = self.email
}

impl Comparable for Person {
    fn compare(a: Self, b: Self) -> Ordering = Ordering::cmp(a.email, b.email)
}

impl is not pub — visibility follows the more restrictive of the trait’s and the concept’s visibility. Cross-package impls require both the trait and the concept to be pub in their respective packages, and they must satisfy the orphan rule (D8): at most one of the trait or concept may be external to the impl’s home package.

D5 — Generic bounds

Generic parameters can be bounded by traits:

pub fn sort<T: Comparable>(xs: List[T]) -> List[T] {
    // body uses T::compare
}

Multiple bounds compose with +:

pub fn dedup<T: Eq + Hashable>(xs: List[T]) -> List[T] {
    // body uses T::equal and T::hash
}

Bounds at declaration time make T’s trait methods callable inside the body. Outside the bounded scope, T is untyped.

A bound can require multiple traits via the + separator. It cannot require a concept-subtype bound and a trait bound mixed: <T: Animal + Comparable> is a syntax error. Use where-clauses for mixed bounds:

pub fn race<T>(animals: List[T]) -> T
where
    T <: Animal,
    T: Comparable,
{
    // T must be a subtype of Animal AND implement Comparable
}

D6 — Static dispatch via monomorphization

Trait bounds resolve at ox compose time using RFD-0034’s pipeline and RFD-0036’s bounded-generic machinery. Every site where sort<T: Comparable> is called with a concrete T produces a specialized rule set with the appropriate T::compare body inlined.

No runtime trait objects in v1. There is no dyn Comparable or analog. A function bounded by a trait sees the trait methods as named items; the compiler monomorphizes one specialization per concrete type used at a call site.

Cross-package trait method invocation goes through the same compose-time machinery: a downstream package using sort<T: Comparable> with its own T triggers monomorphization at the consuming ox compose. Stable IDs include the resolved T (per RFD-0036).

D7 — Concept / trait orthogonality

Concepts and traits live on orthogonal axes. The type system distinguishes them by declaration form:

Declaration formAtomPurpose
pub kind X { … }concept“X is an entity of metatype kind” (ontological)
pub trait X { … }trait“X is a behavioral contract” (no ontological commitment)
pub kind X <: Yconcept-to-concept specialization
pub trait X <: Ytrait-to-trait inheritance
pub kind X : Yconcept-to-concept instantiation (MLT — RFD-0044)
impl T for X { … }concept X implements trait T

A concept implements zero or more traits. A trait is implemented by zero or more concepts. Neither carries the other.

The Rust analogy holds with one adjustment: Rust’s struct is closest to Argon’s kind. Argon does not have struct separately — structures-without-ontological-commitment (like Result[T, E] or Pagination) are substrate primitives (the collections layer, RFD-0039) or library-defined kinds with a neutral metatype.

D8 — Coherence

The standard trait-system coherence problem: if both crate A and crate B can write impl Trait for Concept, which wins? Argon’s rule is the orphan rule: at least one of Trait or Concept must be in the impl’s own package. Equivalently, at most one of the two may be external — impls where both are external are rejected with OE0860 OrphanImpl.

// In package my_lib: implementing my own trait on someone else's concept — OK.
impl my_lib::Sortable for std::collection::List { … }

// In package my_lib: implementing someone else's trait on my own concept — OK.
impl std::traits::Eq for my_lib::Lease { … }

// In package my_lib: implementing someone else's trait on someone else's concept — ERROR.
impl std::traits::Eq for std::collection::List { … }   // OE0860 OrphanImpl

Diamond inheritance through traits is resolved by explicit disambiguation at the impl site. If Person implements Ord (which extends Eq) and PartialEq (which also extends Eq), the Person impl provides one equal method; the compiler errors with OE0862 ConflictingDefaultImplementation if the trait defaults disagree and the impl doesn’t pick one.

D9 — Trait methods inherit purity

A trait method declared as fn is fn-pure for callers; declared as mutation, the bounded site is impure.

pub trait Sink {
    mutation send(self, msg: Message)         // mutation, not fn
}

pub fn broadcast<T: Sink>(sinks: List[T], msg: Message) {
    // ERROR: fn cannot call mutation (RFD-0040 D3 purity ladder)
    for s in sinks { s.send(msg) }
}

pub mutation broadcast<T: Sink>(sinks: List[T], msg: Message) {
    // OK: mutation can call mutation
    for s in sinks { s.send(msg) }
}

The purity ladder applies through trait bounds transparently. There is no “pure trait” / “impure trait” distinction at the trait level; the purity is per-method.

D10 — Self-bounded recursion

A trait method can take Self as a parameter or return Self:

pub trait Monoid {
    fn empty() -> Self
    fn combine(a: Self, b: Self) -> Self
}

Recursive bounds — a trait that requires its associated type to also implement the trait — are admitted with a depth limit at monomorphization (RFD-0036 limits apply).

D11 — Diagnostic codes

OE0860 OrphanImplimpl violates the orphan rule. OE0861 MissingTraitItemimpl does not provide a required item without a default. OE0862 ConflictingDefaultImplementation — diamond inheritance leaves an ambiguous default. OE0863 TraitBoundUnsatisfied — call site uses a type that does not implement the required trait. OE0864 MixedBoundKinds<T: Concept + Trait> mixes concept-subtype and trait bounds (use where-clause). OE0865 SelfOutsideTraitSelf appears outside a trait declaration or impl block. OE0866 SupertraitNotImplementedimpl Subtrait for X without impl Supertrait for X.

D12 — Trait-bound derive and mutation

The same generic-bounds machinery applies to derive and mutation, not just fn. RFD-0036 already lifts <T: Bound> to all four operator surfaces; this RFD adds trait bounds as a permitted kind of bound.

pub derive ordered_pair<T: Comparable>(a: T, b: T) :- {
    less(a, b)
}

pub mutation persist_sorted<T: Comparable>(xs: List[T]) {
    with sorted := sort(xs);
    for x in sorted { insert StoredItem { value: x }; };
}

Rationale

Traits exist as a distinct atom because none of the other five expresses behavioral contracts. A concept is ontology; a trait is capability. Folding them produces either OO inheritance (which Argon explicitly rejects) or a metatype-as-trait conflation (which breaks foundation-neutrality).

Static dispatch only in v1. Runtime trait objects (dyn Trait) require a vtable and runtime polymorphism, both at odds with the monomorphization-at-compose model RFD-0034 established. The need for dynamic dispatch in Argon is hypothetical until a real workload demands it. Defer.

<: for trait inheritance reuses the specialization token because the relation is structurally the same — a subtrait is a refinement of a supertrait. The same token in concept declarations means concept specialization; in trait declarations means trait inheritance. Position disambiguates.

The orphan rule is the standard solution to coherence. Without it, two unrelated packages can write conflicting impls, and the compiler cannot decide which wins. With it, the impl lives with one of the two parties — predictable and reviewable.

Mixed concept-subtype and trait bounds via where-clauses. The <T: ...> form should mean one kind of bound. Mixing them inline (<T: Animal + Comparable>) ambiguates whether Animal is a concept or a trait. where-clauses make the kinds explicit.

Trait methods inherit purity from their declared keyword. This is the cleanest interaction with RFD-0040’s purity ladder. A Sink trait with a mutation send method makes any bounded site impure; a Comparable trait with fn compare keeps callers pure. No new purity machinery needed for traits.

Consequences

RFD-0036 is extended, not superseded. RFD-0036’s bounded-generic machinery handles trait bounds; the changes are limited to admitting trait names as bound targets and emitting the appropriate trait-method-resolution calls during monomorphization.

The stdlib ships a small trait vocabulary. Core traits: Eq, Ord, Hashable, Json, Display, Default, Sortable, possibly Iter. RFD-0042 (macros) makes @[derive(Eq, Hashable)] ergonomic; without macros, every impl is hand-written.

Coherence checks land in ox compose. The orphan-rule check runs at the workspace level (the only level at which all impls are visible). Per-package elaboration enforces the local invariants (impl provides all required items, doesn’t conflict with itself); cross-package conflicts surface at compose.

Wire types. oxc-protocol gains:

  • A Trait declaration kind alongside Concept, Relation.
  • An Impl declaration kind binding a (Trait, Concept) pair with a method body table.
  • Trait-bound generic-parameter metadata on fn / derive / query / mutation declarations.

Wire compatibility. oxbin artifacts gain a trait section. Older runtimes that don’t recognize trait sections must fail-fast (RFD-0038 capability advertisement covers this).

Book chapter. ch02-09-traits.md covers trait declarations, impl blocks, generic bounds, the orphan rule, and the concept/trait orthogonality principle.

Migration. Existing patterns that simulate traits (decorator-driven shape recognition for Sortable-like classes, hand-written gen_X_invalid_Y rules in the ArgUFO branch) can migrate to real trait declarations + impl blocks. The migration is not mechanical; trait extraction requires human design judgment.

Historical lineage

  • RFD-0036 (generic declarations) — discussion. Extended here to admit trait bounds.
  • RFD-0034 (composition pipeline) — committed. Provides the monomorphization-at-compose machinery traits depend on.
  • RFD-0019 (patterns are first-class) — committed. Patterns are reframed as macros (RFD-0042); some pattern use cases become trait + impl combinations.
  • RFD-0040 (substrate atoms and the explicit-writes principle) — discussion. Names traits as atom #4; this RFD specifies the grammar and semantics.

This RFD does not supersede any committed RFD.