Proposal: Sendable Trait and Interior Mutability Definition

Status: Approved Approved: 2026-01-30 Author: Eric (with AI assistance) Created: 2026-01-29 Affects: Compiler, type system, concurrency


Summary

This proposal clarifies the Sendable trait by defining what “interior mutability” means in Ori’s context, specifying exactly which types are Sendable, and documenting verification rules for closures.


Problem Statement

The approved Sendable proposal states that types are Sendable when they have “no interior mutability,” but:

  1. Ori has no Mutex, RefCell, or similar types — what IS interior mutability?
  2. Closure Sendability depends on captured values — how is this verified?
  3. The rule “all fields are Sendable” is recursive — what are the base cases?
  4. Custom types need clear guidelines for Sendable auto-implementation

Interior Mutability Defined

In Other Languages

In Rust, “interior mutability” means mutating data through a shared reference via:

  • RefCell<T> — runtime borrow checking
  • Mutex<T> — mutual exclusion
  • Cell<T> — single-threaded mutation
  • Atomic types — lock-free mutation

In Ori

Ori’s memory model prohibits shared mutable references entirely:

“No shared mutable references — single ownership of mutable data”

Therefore, interior mutability does not exist in user-defined Ori types by language design.

Where “Interior Mutability” Matters

The only types with interior mutability are runtime-provided resources. These types wrap OS or runtime state that can change independently of Ori’s normal ownership rules:

  • The kernel can modify file descriptor state
  • Network connections have internal buffers
  • Database connections maintain session state
TypeDescriptionSendable?
FileHandleOS file descriptor wrapperNo
SocketNetwork connection wrapperNo
DatabaseConnectionDB session state wrapperNo
ThreadLocalStorageThread-specific dataNo

These types represent external resources with identity semantics — sending them to another task would violate their invariants.


Sendable Base Cases

Primitive Types (Always Sendable)

TypeSendableReason
intYesPure value, no references
floatYesPure value
boolYesPure value
strYesImmutable, reference-counted
charYesPure value
byteYesPure value
DurationYesPure value
SizeYesPure value
voidYesNo data
NeverYesNever instantiated

Built-in Collections (Conditionally Sendable)

TypeSendable When
[T]T: Sendable
{K: V}K: Sendable and V: Sendable
Set<T>T: Sendable
Option<T>T: Sendable
Result<T, E>T: Sendable and E: Sendable
(T1, T2, ...)All Ti: Sendable

Function Types (Conditionally Sendable)

TypeSendable When
(T) -> U (no captures)Always (pure function pointer)
ClosureAll captured values are Sendable

Channel Types (Conditionally Sendable)

TypeSendable When
Producer<T>T: Sendable
Consumer<T>T: Sendable
CloneableProducer<T>T: Sendable
CloneableConsumer<T>T: Sendable

Channel endpoints are Sendable because they are designed to be passed to tasks for inter-task communication.

Non-Sendable Types

TypeReason
FileHandleOS resource with thread affinity
SocketOS resource, not safely movable
DatabaseConnectionSession state, not safely movable
NurseryScoped to specific execution context

User-Defined Type Sendability

Automatic Implementation

Sendable is automatically implemented for user-defined types when all fields are Sendable:

// Automatically Sendable (all fields are Sendable)
type Point = { x: int, y: int }
type User = { name: str, age: int, active: bool }
type Tree<T: Sendable> = { value: T, children: [Tree<T>] }

// NOT Sendable (contains non-Sendable field)
type Connection = { handle: Socket, timeout: Duration }
// Socket is not Sendable, so Connection is not Sendable

No Manual Implementation

Users CANNOT manually implement Sendable:

// ERROR: cannot implement Sendable manually
impl MyType: Sendable { }
// Sendable is automatically derived or not available

Rationale: Sendable is a safety property verified by the compiler. Manual implementation could break thread safety.


Closure Sendability Verification

Capture Analysis

The compiler analyzes closure captures to determine Sendability:

let x: int = 10              // int: Sendable
let y: str = "hello"         // str: Sendable
let z: FileHandle = open()   // FileHandle: NOT Sendable

let f = () -> x + 1          // f is Sendable (captures only x: int)
let g = () -> y.len()        // g is Sendable (captures only y: str)
let h = () -> z.read()       // h is NOT Sendable (captures z: FileHandle)

Transitive Capture

Closures capturing other closures inherit their Sendability:

let x: int = 10
let inner = () -> x * 2      // inner is Sendable
let outer = () -> inner()    // outer captures inner, which is Sendable
                             // outer is Sendable

Task Boundary Verification

When closures cross task boundaries, the compiler verifies Sendability:

@spawn_tasks () -> void uses Suspend = {
    let data = create_data(),     // data: Sendable
    let handle = open_file(),     // handle: NOT Sendable

    parallel(
        tasks: [
            () -> process(data),   // OK: captures Sendable
            () -> read(handle),    // ERROR: captures non-Sendable
        ]
    )
}

Error message:

error[E0900]: closure is not `Sendable`
  --> src/main.ori:8:13
   |
8  |             () -> read(handle),
   |             ^^^^^^^^^^^^^^^^^^ closure captures non-Sendable value
   |
   = note: captured variable `handle` of type `FileHandle` is not Sendable
   = note: closures passed to `parallel` must be Sendable

Channel Type Requirements

Why Channels Require Sendable

Channel types (Producer<T>, Consumer<T>) require T: Sendable:

let (producer, consumer) = channel<int>(buffer: 10)      // OK
let (producer, consumer) = channel<FileHandle>(buffer: 10)  // ERROR

Rationale: Values sent through channels cross task boundaries. Non-Sendable values would violate their invariants.

Ownership Transfer

Sending a value through a channel transfers ownership:

let data = create_data()
producer.send(value: data)  // data moved into channel
// data is no longer accessible here

This ensures no shared mutable access even without explicit Sendable checks.


Reference Counting and Sendability

ARC is Thread-Safe

Ori’s reference counting is atomic (thread-safe) for all types:

  • Incrementing refcount uses atomic operations
  • Decrementing refcount uses atomic operations
  • This is an implementation requirement, not user-visible

Why This Matters

Because refcounts are atomic, sharing immutable data across tasks is safe:

let big_data = load_data()  // Reference-counted
parallel(
    tasks: [
        () -> read(big_data),   // Shares reference
        () -> analyze(big_data), // Shares reference
    ],
)
// Both tasks share the same data (reference counted atomically)

Generic Sendable Bounds

Constraining Generics

Generic types can require Sendable bounds:

@spawn_with<T: Sendable> (value: T, action: (T) -> void) -> void uses Suspend =
    parallel(tasks: [() -> action(value)])

// OK: int is Sendable
spawn_with(value: 42, action: x -> print(msg: str(x)))

// ERROR: FileHandle is not Sendable
spawn_with(value: file_handle, action: h -> h.read())

Conditional Sendable

Container types often have conditional Sendability. The compiler generates conditional implementations:

// Compiler-generated: Box is Sendable when T is Sendable
impl<T: Sendable> Box<T>: Sendable { }  // auto-generated, not user code

// This enables:
let boxed: Box<int> = Box(42)  // Sendable
let boxed_handle: Box<FileHandle> = Box(h)  // NOT Sendable

Examples

Sendable Data Structure

type Message = {
    id: int,
    content: str,
    timestamp: Duration,
    metadata: {str: str},
}
// All fields are Sendable, so Message is Sendable

@send_messages (messages: [Message]) -> void uses Suspend =
    parallel(
        tasks: messages.map(m -> () -> deliver(m)),  // OK
    )

Non-Sendable Resource Wrapper

type DatabasePool = {
    connections: [DatabaseConnection],  // DatabaseConnection: NOT Sendable
}
// DatabasePool is NOT Sendable

// Must use within single task:
@query_all (pool: DatabasePool, queries: [str]) -> [Result] =
    queries.map(q -> pool.query(q)).collect()  // Sequential, same task

Sendable Check Error

type CacheEntry = {
    key: str,
    value: str,
    file_cache: FileHandle,  // Oops!
}
// CacheEntry is NOT Sendable due to file_cache

@parallel_cache_lookup (entries: [CacheEntry]) -> void uses Suspend =
    parallel(
        tasks: entries.map(e -> () -> lookup(e)),  // ERROR
    )
// Error: CacheEntry is not Sendable

Spec Changes Required

Update 06-types.md

Clarify Sendable:

  1. Define interior mutability in Ori context
  2. List base Sendable types
  3. Auto-implementation rules
  4. Add channel types to Sendable list

Update 14-capabilities.md

Add:

  1. Channel Sendable requirements
  2. Task closure verification rules

Add to Type Reference

Document Sendable status for all standard types.


Summary

AspectSpecification
Interior mutabilityDoes not exist in user code; only runtime resources
Base SendableAll primitives (int, float, bool, str, etc.)
CollectionsSendable when elements are Sendable
Channel typesSendable when element type is Sendable
User typesAuto-Sendable when all fields are Sendable
Manual implNot allowed
ClosuresSendable when all captures are Sendable
VerificationCompiler checks at task boundaries
Non-SendableRuntime resources (files, sockets, connections), Nursery
ARCThread-safe (atomic refcounts)