Document Version: 1.1
Date: July 2026
Audience: Core implementers, runtime engineers, language designers
Companion Documents: design notes in docs/archive/ (AI SDK, workflow SDK, cloud, package manager)
Implementation status (v1.1): Sections 2 and 6 (Language and AI Runtime) have been re-verified against the current source tree and describe the system as implemented. Sections 3–5 and the derived material in Sections 7–10 still describe the target architecture; where the implementation already diverges in a verified way, §1 carries the corrections. Treat uncaveated numbers and diagrams in Sections 3–5 as design goals, not as-built fact.
- System Overview
- Layer 1: Language
- Layer 2: Actor Runtime
- Layer 3: Durable Execution
- Layer 4: Distributed Platform
- Layer 5: AI Runtime
- Data Flow Diagrams
- Component Interaction Map
- Architecture Decision Records
- Performance Targets
- References
Nulang is organized into five strictly layered subsystems. Each layer communicates only with adjacent layers. This constraint ensures independent testability, replaceability, and evolution.
+==========================================================================+
| LAYER 5: AI RUNTIME |
| LLM Providers | Agent Actors | Tool Schemas | Memory (3 kinds) | Cost |
| Pipelines | Supervisor Teams | Debates | Sync-over-Async Providers |
+==========================================================================+
|
+==========================================================================+
| LAYER 4: DISTRIBUTED PLATFORM |
| Gossip Membership | CRDT Engine | Message Router | Service Discovery |
| Consistent Hash Ring | Multi-Region Replicator | NUL0 Transport |
+==========================================================================+
|
+==========================================================================+
| LAYER 3: DURABLE EXECUTION |
| State Models (4) | Checkpoint Manager | Event Journal | Recovery |
| Snapshot Engine | Replay Orchestrator | Determinism Enforcer |
+==========================================================================+
|
+==========================================================================+
| LAYER 2: ACTOR RUNTIME |
| Virtual Actor Manager | Work-Stealing Scheduler | Bounded Mailboxes |
| Supervision Trees | WASM Sandbox Pool | Location Directory | ORCA GC |
+==========================================================================+
|
+==========================================================================+
| LAYER 1: LANGUAGE |
| Lexer | Parser | HM Type Checker | Effect & Capability Analysis | HIR |
| MIR | Register-Bytecode Compiler | NaN-Boxed VM | Cranelift JIT Tier |
+==========================================================================+
Layer boundary rules:
- Layer N may call only Layer N-1 and Layer N+1
- Cross-layer calls are forbidden (no Layer 5 → Layer 3 shortcuts)
- Data passes across boundaries as plain structs; no shared mutable state
- Each layer can be tested with mocked adjacent layers
Implementation note (updated July 2026): the compiler targets a
register-VM bytecode by default. A WASM backend (--backend wasm | wasm-run | wasm-aot)
exists behind the wasm-backend feature flag (src/mir_wasm.rs, src/wasm_runtime.rs).
A native/AOT backend (--backend native) compiles via Cranelift ahead-of-time.
Other verified divergences from the target design of Layers 2–4, to keep in
mind while reading §3–§5:
- Mailboxes are unbounded, backed by
crossbeam::queue::SegQueue(src/runtime/mailbox.rs); push always succeeds — there is no 10,000-slot ring buffer, no overflow policy, and no transport-level backpressure. Messages carry aMessagePriority(System/Normal/Bulk) field, but the queue itself is a single FIFO. - Actor identity is a bare
u64from a global atomic counter (fresh_actor_id,src/runtime/mod.rs);spawnis explicit — there is no Orleans-style string identity, no activation-on-first-message, and no consistent-hash placement yet. - The reduction budget is 1000 per scheduling round (
next_reductionsinsrc/runtime/mod.rs), enforced by a synchronous single-threadedstep_actorloop — not 2000 WASM-asyncify reductions. - Persistence ships three
PersistenceStorebackends —MemoryStore,JsonFileStore,LibsqlStore(src/runtime/persistence.rs) — not PostgreSQL or S3. - The NUL0 transport is a hand-rolled, length-prefixed TCP protocol whose
fixed header is 13 bytes (4-byte
NUL0magic, 1-byte packet type, 8-byte sequence); the version/flags/MAC fields, TLS, QUIC, and Poly1305 drawn in §5.5 do not exist insrc/runtime/network.rs.
The Language layer is a self-contained compiler: Nulang source in,
register-machine bytecode out, executed by an interpreter loop with a
Cranelift JIT tier. It has no hard dependency on the actor runtime — the VM
reaches actors only through two object-safe callback traits (§2.6), so the
language layer runs standalone (VM::new() + load_module + run), which
is how --eval and the REPL work without a cluster attached.
Nulang is an expression-oriented, ML-flavored language with C-style braces
for blocks and actor bodies. Indentation is not significant — the lexer
preserves newlines as tokens only so the parser can terminate expressions.
Every construct is an expression that produces a value; there is no return
keyword, and the last expression of a block is the block's value. Comments
are //. The examples in this section are taken from examples/ and run as
written.
// Recursive Fibonacci — closures and recursion (examples/fibonacci.nula)
let fib = fn(n) {
if n <= 1 then n else fib(n - 1) + fib(n - 2)
} in fib(10)
Core surface forms (src/ast.rs, src/parser.rs):
let x = e1 in e2andlet rec f = ... in .... Alet-bound lambda may reference itself; it is lowered likelet recso the self-reference resolves.fn(x) { body }lambdas with real closures (captured locals live in closure environments, §2.6).if cond then e1 else e2;match scrutinee { | Pat => e ... }; blocks{ e1; e2 }; tuples, records{ field: value }, arrays.- Actors:
actor Counter { state count = 0 behavior inc() { self.count + 1 } }, spawned explicitly withspawn Counter { count = 0 }(examples/counter_actor.nula). Messages are sent withsend target behavior(args); inside a behavior,receive { | Msg(x) => x }reads the next mailbox message (examples/receive.nula). - Effects:
perform Effect.op(args), intercepted by handlers written ashandle perform Math.getAnswer() { | Math.getAnswer() => 42 }(examples/effects.nula). - Module-level declarations:
fn(optionally@tool-annotated, §6.5),actor,typealiases, record and variant types,effectdeclarations,module/import,externblocks,workflow(v0.8), andagent(v0.9, §6.3).
There is no switch and no case keyword — match arms are introduced by
|. Pattern matching is typed but not exhaustiveness-checked today: a
non-exhaustive match compiles and may fail at runtime.
TypeChecker::check_module (src/typechecker.rs) implements Damas-Milner
Algorithm W in the classical substitution-based form:
Substitution = Vec<(TypeVar, Type)>; unification viamguwith an occurs check;apply_subst/compose_substpropagate results.generalizeat let-bindings buildsType::Scheme { vars, body };instantiatefreshens scheme variables at use sites.- The
Typeuniverse (src/types.rs):Var,Primitive(Int,Float,Bool,String,Nil,Unit,Never,Address),Tuple,Record,Variant,Array,Function { param, ret, effect, cap },Actor { state, behavior },App { constructor, args }(type constructors such asOption[T]),Reference { cap, inner },Scheme.
Parameterized types. Functions, actors, and type declarations accept
type_params (e.g. fn f[T](x: T)). Because the VM's value representation
is dynamically tagged (§2.6), generic types are erased, not
monomorphized — no per-instantiation code is generated, and there is no
special boxing of generic values because every value is already a tagged
u64.
Row-polymorphic effects. Every function type carries an EffectRow:
Closed(Vec<Effect>) or Open(Vec<Effect>, Region) (src/types.rs). See
§2.3 for checking and runtime dispatch.
Capability types. Function types also carry a Capability from the
Pony-style lattice (iso, lineariso, trn, ref, val, box, tag;
subtyping computed via join). LinearIso adds exactly-once linear
consumption tracking. Capabilities are compile-time only — see §2.4.
What does not exist: no type classes or constrained types
(fn f[T: Serializable] is not valid Nulang), no protocol construct, and
no exhaustiveness checking for match. Type inference deliberately does not
cross actor boundaries — behavior signatures are explicit annotations — so
actors remain separately checkable units.
Effects are row-polymorphic in the type system and handler-resolved at
runtime; the two halves meet in the Perform opcode.
Static side (src/effect_checker.rs, wired per declaration in
run_frontend, src/main.rs):
EffectChecker::infer_effectscomputes the row a body may perform;check_effectsenforces a declared row (fn f() ! E { ... }) against the inferred one viaeffect_row_subset. Rows areClosedorOpenwith aRegionvariable; an open row on the allowed side may cover extra effects.- Bodies with a declared row are enforced; un-annotated bodies are
inference-only so existing programs keep compiling until interprocedural
effect propagation lands. The checker accumulates
diagnosticsrather than aborting the compile.
Runtime side (opcode-level detail in §2.6):
handle perform Math.getAnswer() { | Math.getAnswer() => 42 }compiles to a handler table plus aHandleopcode;performcompiles toPerform; a handler that resumes the suspended computation usesResume; leaving the handled scope emitsUnwind.- If no installed handler binds the effect, the VM asks the
ActorVmCallbacks::perform_effecthook. This is how built-in effects are serviced — e.g.Timer.sleepin workflow steps andPython.call(...). Any waiting on I/O happens inside the hook implementation, never in the VM interpreter loop itself. perform LLM.ask(prompt)never reaches the generic path: MIR lowering dispatches it through the genericPerformAsync(0xC6) effect opcode with the"Inference.ask"effect-op string (§6.3).
Determinism. The static row tells the durable-execution layer (§4) which operations a body may perform; capturing and replaying effect results for deterministic replay is the persistence layer's job, not the effect checker's.
"Capabilities" in the implemented language are Pony-style reference
capabilities — qualifiers on references that govern aliasing and
sendability — not the authority tokens of the target design. The lattice
(src/types.rs):
iso ─ lineariso ─ trn ─ ref ─ val ─ box ─ tag
| Capability | Meaning | Sendable? |
|---|---|---|
iso |
Unique ownership | Yes |
lineariso |
Unique ownership + exactly-once linear consumption | Yes |
trn |
Unique writer (recoverable to iso) |
No |
ref |
Shared read/write | No |
val |
Immutable shared | Yes |
box |
Read-only view (any non-tag cap reads as box) |
No |
tag |
Opaque identity only, no dereference | Yes |
CapabilityAnalyzer::infer_cap (src/effect_checker.rs) runs in
run_frontend over the same bodies as the effect checker; subtyping is
computed via join on the lattice, and is_sendable is
LinearIso | Iso | Val | Tag.
Capabilities are erased at runtime. There are no capability opcodes at
all: mir::RValue::CapabilityCheck compiles to Const1 (true) in
src/mir_codegen.rs, so nothing capability-related reaches the VM. There is no
runtime revocation list, no capability tokens in message headers, and no
cross-node capability verification today. When §3–§5 below mention
capability managers, narrowing, or revocation, they are describing the
target design, not the current implementation.
The pipeline is a straight line, wired exactly in run_frontend /
run_source (src/main.rs) and reused by the REPL (src/repl.rs) and the
LSP (src/lsp/):
source &str
-> Lexer::lex() Vec<Token> src/lexer.rs
-> Parser::parse_module() AstModule src/parser.rs
-> TypeChecker::check_module() Type src/typechecker.rs (Algorithm W)
-> EffectChecker (per body) infer / check src/effect_checker.rs
-> CapabilityAnalyzer (per body) Capability src/effect_checker.rs
-> hir_lower::lower_module() hir::Module src/hir_lower.rs
-> mir_lower::lower_module() mir::Module src/mir_lower.rs
-> mir_codegen::compile_mir() CodeModule src/mir_codegen.rs
-> VM::load_module() + VM::run() Value src/vm.rs
Notes:
--checkrunscheck_source, which stops after capability analysis — no HIR/MIR/codegen and no execution.- The pipeline is MIR-exclusive: there is no AST-to-bytecode compiler
left in the tree. HIR mirrors the AST with resolved types and flattened
statement/terminator bodies, and desugars
workflowandagentdeclarations into persistent actors (§6.3). MIR is 3-address code with explicit basic blocks and terminators — the last IR before bytecode. - Constructs the MIR pipeline cannot yet lower return an honest
NotYetImplementederror rather than miscompiling (current examples: aworkfloworagentthat reaches MIR un-desugared, and anagentwhosetoolslist names an unknown function, §6.3). - Top-level expressions are wrapped in a synthetic
__mainfunction.
Instruction format (src/bytecode.rs): fixed 32-bit instructions
{ opcode: u8, op1: u8, op2: u8, op3: u8 } with encode/decode;
imm16() reads op1+op2, simm16() its signed form, and offset16() reads
op2+op3 (used by JmpT/JmpF, whose op1 holds the condition register).
Constants live in a per-module pool (Constant::{Int, Float, String, Bool, Nil, Unit, TypeDescriptor, FunctionRef, BehaviorRef}). A CodeModule holds
the instructions, the constant pool, function_table (code offsets),
exports, an optional entry_point, handler_tables (one per handle
block), actor_metadata (ActorMeta, incl. the agent flags in §6.3),
foreign_functions, and tools (@tool schemas, §6.5).
Opcode space: 133 opcodes (OpCode, src/bytecode.rs):
| Range | Category | Count | Examples |
|---|---|---|---|
| 0x00–0x08 | Special | 9 | Nop, Halt, Panic, Const0…ConstL |
| 0x10–0x15 | Stack & locals | 6 | Load, Store, Move, Dup, Swap |
| 0x20–0x2D | Integer arithmetic | 14 | IAdd…IPow, shifts, bitwise |
| 0x30–0x38 | Float arithmetic | 9 | FAdd…FMod, IToF, FToI, FToS |
| 0x40–0x4B | Comparison & logic | 12 | ICmp*, FCmp*, SCmpEq, Not/And/Or |
| 0x50–0x57 | Control flow | 8 | Jmp, JmpT/F, Switch, Call, TailCall, Ret* |
| 0x60–0x64 | Closures | 5 | Closure, CapLoad/Store, FreeVar, ClosureCall |
| 0x70–0x7F | Memory & objects | 16 | Alloc, Field*, Arr*, Tuple*, Rec*, IsTag, Unpack, Copy, Drop |
| 0x80–0x8F | Actor & concurrency | 16 | Spawn, Send, Ask, Receive, Monitor…Unlink, StateGet/Set, Emit, SignalWait, ReceiveMatch |
| 0x90–0x93 | Effects | 4 | Perform, Handle, Resume, Unwind |
| 0x94–0x9B | Python interop | 8 | PyImport…PyRelease |
| 0x9C | Direct effect dispatch | 1 | PerformDirect |
| 0xA0–0xA1 | Actor concurrency (cont.) | 2 | ReceiveWait, ReceiveCommit |
| 0xB0 | FFI | 1 | FFICall |
| 0xC6 | Async effect dispatch | 1 | PerformAsync |
| 0xD0–0xD5 | Distribution | 6 | NodeId, Migrate, RSend, RAsk, RSpawn, Gossip |
| 0xE0–0xE7 | String & IO | 8 | SConcat, SPrint/SRead, FOpen…FClose, Print |
| 0xF0–0xF4 | Debug & meta | 5 | DbgBreak/Print/Stack, MetaType/Cap |
| 0xF5–0xF6 | Register spill | 2 | SpillLoad, SpillStore |
Value representation: NaN-boxed u64 (Value { raw: u64 }, src/vm.rs).
The canonical layout lives in src/value_layout.rs — the single source
of truth imported by the VM, the JIT runtime helpers, the typed JIT compiler,
and the Python marshalling layer (the constants are not duplicated across
those files):
- Non-float values are quiet NaNs: upper 16 bits = tag
(
TAG_MASK = 0xFFFF_0000_0000_0000), low 48 bits = payload (PAYLOAD_MASK = 0x0000_FFFF_FFFF_FFFF). Any bit pattern that is not a quiet NaN is a real IEEE-754f64. - Tags:
TAG_NIL 0x7FF8,TAG_UNIT 0x7FF9,TAG_BOOL 0x7FFA,TAG_INT 0x7FFB(48-bit signed payload, sign-extended bysext48),TAG_PTR 0x7FFC(heap pointer),TAG_ACTOR 0x7FFD,TAG_STRING 0x7FFE(interned string id),TAG_CLOSURE 0x7FF7.TAG_PYTHON 0x7FF6lives insrc/python/bridge.rsbehind thepythonfeature.
Frames: a Frame holds [Value; 256] registers, pc, module_idx,
return_dst, caller_idx, and an optional closure_env. Frames live in a
flat Vec<Frame> on the VM (not a linked stack): a call pushes a frame
recording the caller's index, Ret/RetVal pop back. Closures with
captures are TAG_CLOSURE values whose payload indexes a VM-side
closure_envs table.
Effects at runtime (handler_stack: Vec<HandlerFrame>, src/vm.rs):
Handlepushes aHandlerFrame(handler-table index, module, resume pc/dst).Performfinds the innermost handler frame whose table binds the effect name, captures aContinuation(deep-cloned frames up to the current one, resume pc/dst, step count) into that frame, and jumps to the handler body. With no binding it falls back to the innermost table'sfallback_offset, then to theperform_effectcallback (built-ins), else raisesUnhandled effect.Resumerestores the captured continuation with the resume value in the destination register (an error if none was captured);Unwindpops the handler frame.
Runtime callbacks: the VM never touches the actor runtime directly. Two
object-safe traits mediate (src/vm.rs): ActorVmCallbacks (heap
alloc/drop, spawn/send/ask, try_receive, perform_effect, and the AI hooks
complete_llm, pipeline_*, supervisor_*, debate_*) and
DistributedVmCallbacks (node id, migrate, remote send/ask/spawn, gossip).
Standalone execution installs StandaloneVmCallbacks, which owns a private
ActorHeap; the actor runtime installs bridges back into Runtime.
MVP stubs worth knowing about: capability checks compile to Const1
(§2.4); the Py* opcodes error in the standalone VM ("Python opcodes require
native actor runtime — use perform Python.call(...)"); Receive stores the
next message's first payload value or nil and serves as the non-blocking
fallback when selective receive (ReceiveMatch 0x8F, shipped — see
lower_receive in src/mir_lower.rs) finds no matching arm;
FOpen/FRead/FWrite/FClose are stubs.
JIT tiering (src/jit/): the VM keeps an optional JitSession. Before
each instruction it snapshots the frame registers into a [u64; 256] and
calls jit::tiered_execute_step_typed:
- If a compiled function exists for
(module, pc), run it — its ABI isextern "C" fn(*mut u64 regs, *const u64 constants). - Otherwise bump the hot counter; at
HOT_THRESHOLD = 1000executions,find_compilable_regioncollects up to 500 instructions (stopping at the first non-compilable opcode, and beforeRetso the VM still pops the frame); regions of 3 or more instructions (region_len >= 3) are compiled. - SIMD first:
simd_analyzerrecognizes element-wise binop/unary/compare loops andsimd_compileremits vectorized Cranelift IR (I64x2/F64x2/I32x4/F32x4); otherwise the scalarcompiler::compile_regionpath is used. - The typed path is live: at tier-up,
typed_compiler::infer_reg_typesrecovers register types from the enclosing function's bytecode (a conservative forward must-analysis), and hot regions compile throughcompile_region_typedwith NaN-tag guards stripped when types are provable — falling back to the scalar path on absent/empty metadata or compile error. - Helpers callable from JIT code are
extern "C"functions insrc/jit/runtime.rs, NaN-tag-aware (e.g. division by zero yieldsnil).
Cold code always interprets; there is no whole-module AOT compilation.
Target design — read with care. Sections 3–5 describe the architecture Nulang is building toward; they have not been re-verified in this revision. The implementation has already diverged in the verified ways listed in §1 — most importantly, actors execute as register-VM bytecode (§2.6) and nothing runs inside a WASM sandbox, and mailboxes are unbounded
SegQueues whose push always succeeds. Until these sections are re-verified againstsrc/runtime/, treat their diagrams and numeric defaults as design intent.
The Actor Runtime is the in-memory execution engine. It schedules actors, routes messages, manages lifecycles, and enforces isolation. All code runs inside WASM sandboxes provided by Wasmtime.
Nulang implements the Orleans virtual actor model. An actor exists whenever it has an identity. There is no explicit creation or destruction.
Actor identity: Every actor has a unique identity string, typically <type>:<key>. Examples: OrderActor:order-42, UserSession:session-abc123, PaymentGateway:stripe. Identity is the only thing you need to send a message — no PID, no handle, no reference.
Addressing:
+------------------+ +------------------+ +------------------+
| Sender Actor | | Runtime | | Target Actor |
| | | | | |
| send(msg, to= | --> | 1. Parse target | --> | (may not exist |
| "Order:ord-42") | | identity | | in memory yet) |
+------------------+ | 2. Look up in | +------------------+
| directory | ^
| 3. If inactive: | |
| activate |--------------+
| 4. Deliver to |
| mailbox |
+------------------+
Activation lifecycle:
- Activation trigger: A message arrives for actor identity
I - Directory lookup: The location directory maps
Ito a node (via consistent hash) - Local check: Is
Ialready activated on this node?- Yes: route to mailbox
- No: proceed to activation
- Activation:
- Load actor code (WASM component)
- Restore state from latest checkpoint (if
durableorevent_sourced) - Initialize actor state (constructor runs)
- Register in local activation table
- Deliver message to mailbox
- Deactivation (triggered by inactivity timeout or memory pressure):
- Finish processing current message
- Checkpoint state (if persistent)
- Release WASM instance back to pool
- Remove from activation table
- Directory entry marked "inactive"
Activation table (per node):
+------------------+----------+----------+------------------+----------+
| Actor Identity | WASM | Mailbox | State | Last |
| | Instance | Ref | (running/ | Active |
| | Ref | | suspended) | (ms ago) |
+------------------+----------+----------+------------------+----------+
| Order:ord-42 | wasm-17 | mbox-33 | running | 12 |
| User:sess-abc | wasm-4 | mbox-12 | suspended | 4500 |
| Cart:cart-99 | wasm-17 | mbox-45 | running | 3 |
+------------------+----------+----------+------------------+----------+
WASM instances are shared across actors of the same type via copy-on-write. The instance pool keeps recently-used instances warm.
Placement strategy: Actors are placed via consistent hashing on their identity string. This means:
- The same actor identity always maps to the same node (within replication factor)
- Node additions cause only 1/N actors to migrate (where N = node count)
- No central coordinator needed for placement decisions
- Hot actors can be manually migrated via the management API
The scheduler maps M actors onto N OS threads. It is the execution heart of the runtime.
Architecture:
+------------------------------------------------------------------------+
| OS Thread Pool (N threads) |
| Typically N = number of CPU cores |
+------------------------------------------------------------------------+
| |
| +----------+ +----------+ +----------+ +----------+ |
| | Sched 0 | | Sched 1 | | Sched 2 | ... | Sched N-1| |
| | | | | | | | | |
| | Run Queue| | Run Queue| | Run Queue| | Run Queue| |
| | [A1, A3] | | [A7] | | [A2, A5] | | [A4, A6] | |
| | | | | | | | | |
| | LIFO for | | LIFO for | | LIFO for | | LIFO for | |
| | spawns | | spawns | | spawns | | spawns | |
| | FIFO for | | FIFO for | | FIFO for | | FIFO for | |
| | messages | | messages | | messages | | messages | |
| +----------+ +----------+ +----------+ +----------+ |
| | | | | |
| | steal <-----+----- steal +----- steal -----------+ |
| | (when empty, steal half from random neighbor) |
+------------------------------------------------------------------------+
Per-scheduler data structures:
| Structure | Purpose | Access Pattern |
|---|---|---|
| Local run queue | Ready actors | LIFO (spawns) + FIFO (messages), producer-consumer |
| Steal deque | Work available for other schedulers | Lock-free Chase-Lev deque |
| Current actor | Actor currently executing | Single owner |
| Reduction counter | Remaining reductions for current actor | Decremented per operation |
Reduction counting: Each actor is assigned a reduction budget (default: 2000 reductions) when scheduled. One reduction ≈ one WASM instruction or one host function call. When the counter reaches zero:
- The actor yields (WASM execution paused via asyncify)
- Actor returns to the run queue
- Scheduler picks the next actor
Reduction counting serves two purposes: fairness (no actor monopolizes a thread) and checkpointing (yield points are natural checkpoint boundaries).
Work-stealing algorithm (Chase-Lev, lock-free):
When scheduler S has no work:
1. Pick a random victim scheduler V
2. Attempt to steal half of V's deque (from the bottom)
3. If steal succeeds: process stolen work
4. If steal fails: try another victim (up to 3 attempts)
5. If all fail: park the OS thread (futex wait)
6. Wake when new work arrives (futex wake)
Message processing loop:
for each scheduled actor:
1. Dequeue next message from actor's mailbox
2. If mailbox empty: deactivate actor (if idle timeout exceeded)
3. Load reduction counter (default 2000)
4. Enter WASM sandbox, call behavior handler with message
5. Handler runs until completion OR reduction counter hits 0
6. If counter hit 0: save WASM stack (asyncify), requeue actor
7. If handler completed: check for outgoing messages, deliver them
8. If persistent actor: trigger checkpoint (async, non-blocking)
9. If more messages in mailbox: requeue actor
10. If mailbox empty: mark idle, start idle timer
Every actor has exactly one mailbox. The mailbox is a bounded multi-producer, single-consumer queue.
Mailbox structure:
+------------------------------------------------------------------+
| Actor Mailbox |
| |
| +----------------------------------------------------------+ |
| | Message Queue (ring buffer, bounded) | |
| | +------+------+------+------+------+------+------+ | |
| | | Msg1 | Msg2 | Msg3 | | | Msg7 | Msg8 | | |
| | +------+------+------+------+------+------+------+ | |
| | ^ ^ | |
| | | head (consumer) | tail (producers) | |
| +----------------------------------------------------------+ |
| |
| Capacity: 10,000 (default) |
| Backpressure strategy: drop oldest / block sender (configurable) |
| |
| Priority bands: |
| +----------------------------------------------------------+ |
| | [HIGH: system messages] [NORMAL: user messages] [LOW: | |
| | batch work] | |
| +----------------------------------------------------------+ |
+------------------------------------------------------------------+
Mailbox properties:
| Property | Value | Rationale |
|---|---|---|
| Default capacity | 10,000 messages | Prevents unbounded memory growth |
| Max message size | 1MB | Larger data uses blob store references |
| Queue type | Lock-free ring buffer | MPSC, cache-friendly |
| Priority levels | 3 (high, normal, low) | System vs user vs batch |
| Overflow policy | Configurable: block, drop_oldest, drop_newest |
block for correctness; drop for availability |
| Fairness | Round-robin across senders | Prevents single sender from starving others |
Backpressure: When a mailbox is full, the sender is blocked (for local sends) or receives a MailboxFull error (for remote sends). The sender can retry with exponential backoff or route to a dead-letter actor. This is the only backpressure mechanism in Nulang — there are no rate limiters, no flow control protocols, just bounded mailboxes.
Message format:
+--------+--------+--------+---------+---------+----------+
| Meta | Priority| Sender | Target | Payload | Cap |
| (16B) | (1B) | ID | ID | (var) | Tokens |
+--------+--------+--------+---------+---------+----------+
Meta: timestamp (8B), correlation ID (4B), flags (4B)
Payload: Cap'n Proto serialized message
Cap Tokens: list of capability tokens (signed JWTs)
Messages are serialized to Cap'n Proto for zero-copy deserialization within the WASM sandbox. Cap'n Proto was chosen over Protocol Buffers because it requires no parsing step — the serialized bytes are the in-memory layout.
Every actor has a supervisor. Supervisors are themselves actors. Supervision creates a tree of accountability.
Supervision strategies (from Erlang/OTP):
| Strategy | Behavior | Use Case |
|---|---|---|
OneForOne |
Restart only the failed child | Independent workers |
OneForAll |
Restart all children when one fails | Tightly coupled group |
RestForOne |
Restart failed child + all children started after it | Dependency chain |
Restart policies:
actor OrderSupervisor:
supervise:
strategy: OneForOne
max_restarts: 5 -- max 5 restarts in 10 seconds
max_seconds: 10
children:
- OrderProcessor { restart: permanent } -- always restart
- PaymentGateway { restart: transient } -- restart only on crash
- AuditLogger { restart: temporary } -- never restart
Escalation: If restart limits are exceeded, the supervisor itself fails, and its own supervisor handles it. This creates a fault propagation tree:
Root Supervisor (realm level)
|
+-- Service Supervisor (one per service type)
| |
| +-- OrderActor:order-42 (worker)
| +-- OrderActor:order-43 (worker)
| +-- OrderActor:order-44 (worker)
|
+-- System Supervisor (runtime services)
|
+-- Mailbox Manager
+-- Checkpoint Manager
+-- Location Directory
Restart sequence:
- Child actor crashes (WASM trap, panic, or explicit
fail) - Supervisor receives
EXITsignal with reason - Supervisor checks restart policy:
normalexit: no restart (unlesspermanent)crashexit: restart (if under limit)killedexit: restart (if under limit)
- If under restart limit: stop child, reload WASM, restore state, start child
- If over limit: supervisor exits with
shutdown, escalates to parent
Process isolation: Each actor runs in its own WASM sandbox instance. Memory is isolated — actor A cannot read actor B's linear memory. If actor A crashes (segmentation fault, infinite loop, stack overflow), only actor A is affected. The runtime detects the crash via Wasmtime's trap handling and restarts the actor via its supervisor.
Nulang uses ORCA (Ownership and Reference Counting based on Cycles in Actors) garbage collection. ORCA is a concurrent, per-actor garbage collector designed specifically for actor systems.
Why ORCA: Traditional GC (stop-the-world, generational) is a poor fit for actor systems because:
- Stop-the-world pauses break real-time message processing guarantees
- Cross-actor references are hard to trace (actors are distributed)
- Per-actor collection is naturally parallel (each actor collects independently)
ORCA overview: Each actor manages its own heap. Memory is reclaimed via:
- Reference counting: Most objects are freed immediately when their refcount hits zero
- Cycle detection: Reference cycles are detected via a lightweight local cycle collector
- Cross-actor references: When actor A holds a reference to an object in actor B, actor B tracks it as a "foreign reference." When the foreign reference is dropped, a decrement message is sent to actor B.
Heap layout per actor:
+------------------------------------------------------------------+
| Actor Linear Memory (WASM) |
| +----------------------+-------------------------------------+ |
| | Immutable Heap | Mutable Heap | |
| | (actor code, static | (state, message buffers, temp | |
| | data) | allocations) | |
| +----------------------+-------------------------------------+ |
| | GC-managed objects | ORCA allocator: bump + free list | |
| +----------------------+-------------------------------------+ |
| Max size: configurable (default 100MB, hard max 1GB) |
+------------------------------------------------------------------+
GC guarantees:
- Pause time: < 1ms per collection (per-actor, no global pause)
- Collection frequency: triggered when heap crosses threshold (default: 75% of max)
- Cross-actor decrements: batched and sent asynchronously (not immediate)
- Memory limit enforcement: hard limit enforced by WASM memory bounds
The location directory is a distributed hash table mapping actor identities to their hosting nodes.
+------------------------------------------------------------------+
| Location Directory (per node) |
| |
| Actor Identity -> Node ID | Status |
| ------------------------- -------------- | -------- |
| Order:ord-42 -> node-7 | active |
| User:sess-abc -> node-3 | active |
| Cart:cart-99 -> node-7 | active |
| Payment:pay-12 -> node-1 | deactivated |
+------------------------------------------------------------------+
The directory is sharded by actor identity across all nodes via consistent hashing. Each node is responsible for a portion of the directory. Directory entries are cached locally with a TTL (default: 30 seconds). Stale cache entries are refreshed on message send failure.
Durable execution is the persistence layer. It ensures that actor state survives crashes, restarts, and node failures. Without this layer, Nulang is an in-memory actor framework. With it, Nulang is a distributed durable execution platform.
Every actor has a state model. The state model determines persistence, recovery, and consistency semantics. It is part of the actor's type — it cannot be changed after deployment.
START
|
+-----------+-----------+
| |
Stateless? Stateful?
| |
v v
+---------+ +--------+--------+
| `local` | | Needs audit? |
+---------+ +--------+--------+
|
+----------+----------+
| |
No audit Full audit
| |
v v
+-----------+ +----------------+
| `durable` | | `event_sourced`|
+-----------+ +----------------+
|
Geo-replicated?
|
+---------+---------+
| |
Single Multi-region
region |
| v
v +------------+
(done) | `crdt` |
+------------+
State model comparison:
| Property | local |
durable |
event_sourced |
crdt |
|---|---|---|---|---|
| Persistence | None | Automatic checkpoint | Event journal | CRDT merge |
| Recovery | None | State restore | Event replay | State sync |
| Consistency | N/A | Strong (single node) | Strong (single node) | Eventual |
| Multi-region | N/A | Active-passive | Active-passive | Active-active |
| Audit trail | No | No (checkpoints only) | Yes (full history) | Yes (ops only) |
| Overhead | Zero | ~1ms/checkpoint | Journal append | Merge on sync |
| Determinism | Not required | Recommended | Required | Required |
Decision guide:
- Use
localfor stateless computation, caches, protocol adapters - Use
durableas default for business logic (simplest, safest) - Use
event_sourcedwhen you need audit history or deterministic replay - Use
crdtfor geo-replicated collaborative state
When to checkpoint:
| Trigger | Default | Configurable | Rationale |
|---|---|---|---|
| Every message | Default for durable |
Yes | Maximum safety |
| Every N messages | N=10 (configurable) | N | Amortize write cost |
| Every T seconds | Disabled by default | T | Time-based for batch |
| Memory threshold | 100MB | M | Prevent OOM |
| Before deactivation | Always | No | Ensure clean shutdown |
What to checkpoint:
Checkpoint payload:
+------------------+---------+-----------------------------------------+
| Field | Size | Description |
+------------------+---------+-----------------------------------------+
| Actor identity | var | Type + key |
| Sequence number | 8B | Monotonic message counter |
| State blob | var | Serialized linear memory (LZ4 compressed)|
| Effect results | var | Map of (effect_id -> result) since last |
| | | checkpoint |
| Mailbox snapshot | var | Unprocessed messages (for exactly-once) |
| Timestamp | 8B | Wall clock (for debugging) |
| Checksum | 8B | xxHash64 of entire payload |
+------------------+---------+-----------------------------------------+
How to checkpoint (async, non-blocking):
1. Actor finishes message M_n
2. Scheduler marks actor as "checkpointing" (still in activation table)
3. Fork-on-write: actor's memory pages marked copy-on-write
4. Actor continues processing next message M_{n+1}
5. Background thread serializes checkpoint from COW snapshot
6. Serialized checkpoint written to storage backend (append-only)
7. On success: checkpoint metadata updated, COW pages released
8. On failure: actor marked for restart, supervisor handles recovery
The copy-on-write technique ensures that checkpointing does not block message processing. The actor incurs at most one page fault per written page during checkpointing. This is the same technique used by Erlang's erlang:hibernate/3 and by Linux's fork() system call.
Storage backends:
| Backend | Use Case | Latency | Throughput |
|---|---|---|---|
| SQLite (local file) | Development | <1ms | 10K writes/sec |
| PostgreSQL | Single-node production | 1-5ms | 50K writes/sec |
| S3-compatible | Multi-node production | 10-50ms | Unlimited |
| Local NVMe (ephemeral) | High-throughput temp | <0.1ms | 100K writes/sec |
Event-sourced actors use an append-only event journal as their source of truth.
Journal entry format:
+--------+----------+----------+-------+----------+---------+----------+
| Header | Sequence | Timestamp| Type | Payload | Effect | Checksum |
| (16B) | (8B) | (8B) | (var) | (var) | Results | (8B) |
| | | | | | (var) | |
+--------+----------+----------+-------+----------+---------+----------+
Header: magic (4B), version (2B), flags (2B), payload_len (4B), type_len (4B)
Type: event type name (e.g., "OrderCreated", "PaymentProcessed")
Payload: Cap'n Proto serialized event data
Effect Results: optional map of effect_id -> captured result
Journal storage layout:
journals/
<actor_type>/
<shard>/
<actor_identity>/
journal-00000001.seg (10,000 events)
journal-00000002.seg (10,000 events)
journal-00000003.seg (5,000 events, current)
snapshot-000020000.bin (state at sequence 20,000)
snapshot-000030000.bin (state at sequence 30,000)
meta.json (journal metadata)
Segments are immutable once closed. Only the current (latest) segment is written to. Segments are written sequentially and never modified after close, enabling efficient replication (only the latest segment needs sync).
Segment file format:
+----------+----------+----------+----------+-----+----------+
| Index | Entry 1 | Entry 2 | Entry 3 | ... | Trailer |
| (offset | | | | | (max |
| table) | | | | | seq) |
+----------+----------+----------+----------+-----+----------+
Index: array of (sequence_number -> file_offset) for random access
Trailer: max sequence number, checksum, timestamp
Replay is the ability to reconstruct an actor's exact state by replaying its event journal. It is the foundation of crash recovery and debugging.
Replay algorithm:
function replay(actor_identity, from_seq=0, to_seq=INF):
1. Load latest snapshot at or before from_seq
2. state = snapshot.state
3. For each event in journal from snapshot.seq+1 to to_seq:
a. Load event + captured effect results
b. Apply projection function (pure, deterministic)
c. For effects: return captured results, do not re-invoke handlers
d. Update state
4. Return state
Determinism enforcement: Nulang enforces determinism at compile time and runtime:
| Source of non-determinism | Compile-time enforcement | Runtime capture |
|---|---|---|
Time (Now) |
Effect, not primitive | Captured timestamp |
Randomness (Random) |
Effect, not primitive | Captured value |
| External IO | Must use effects | Captured result |
| Message ordering | FIFO mailbox guarantee | Inherent |
| Internal iteration | Ordered data structures | Inherent |
What happens on non-determinism violation: If an event-sourced actor produces different output during replay (detected by hash mismatch of the state blob), the runtime:
- Logs the divergence with full context
- Halts replay for that actor
- Notifies the supervisor
- Supervisor restarts the actor from the last known-good snapshot
- Human operator is alerted
This is a safety mechanism. Divergence should never happen in correct code. If it does, it indicates a bug (e.g., using DateTime.now() instead of effect Now).
Snapshots are checkpoints of an event-sourced actor's projected state. They enable fast recovery without replaying from event 0.
Snapshot triggers:
| Trigger | Default | Description |
|---|---|---|
| Event count | Every 1,000 events | Regular snapshots for bounded replay |
| Time | Every 60 seconds | Time-based for low-volume actors |
| Size | When state > 10MB | Prevent oversized snapshots |
| Manual | On demand | Pre-deployment, debugging |
Snapshot format:
+----------+----------+----------+----------+----------+
| Sequence | Version | State | Index | Checksum |
| Number | (schema) | Blob | (for | |
| (8B) | (4B) | (var) | partial | (8B) |
| | | | load) | |
+----------+----------+----------+----------+----------+
Tiered storage:
+--------------------------------------------------------------+
| Hot snapshots (last 10) -> Local NVMe SSD |
| Warm snapshots (10-100) -> Network-attached SSD |
| Cold snapshots (archived) -> S3-compatible object store |
+--------------------------------------------------------------+
Partial loading: For large state snapshots ( > 100MB), Nulang supports partial loading. The snapshot index maps state keys to file offsets. Only accessed keys are loaded into memory. This is essential for actors with large state (e.g., a shopping cart actor holding 10,000 items).
The Distributed Platform enables multiple Nulang runtime nodes to form a cluster. It handles node discovery, actor placement, cross-node messaging, and state replication.
Nodes discover each other and maintain a consistent view of cluster membership using a gossip protocol.
SWIM protocol (Scalable Weakly-consistent Infection-style Process Group Membership):
+--------------------------------------------------------------+
| Node A Node B Node C |
| |
| 1. A picks random member (B) |
| sends PING to B |
| |
| 2. B receives PING, responds ACK |
| |
| 3. If A doesn't receive ACK within timeout: |
| A asks K random members (C, D) to PING B indirectly |
| |
| 4. If no indirect ACK: B is marked failed |
| Failure is gossiped to all members |
+--------------------------------------------------------------+
Gossip dissemination: In addition to failure detection, the gossip protocol disseminates:
- Node join/leave events
- Actor placement changes
- Configuration updates
- CRDT state deltas (piggybacked on gossip messages)
Gossip message format:
+----------+----------+----------+----------+----------+
| Sender | Sequence | Updates | Piggyback| Checksum |
| Node ID | Number | (array) | (CRDT | |
| | | | deltas) | |
+----------+----------+----------+----------+----------+
Updates: array of (type, payload) where type is:
- NodeJoined { node_id, address, capabilities }
- NodeFailed { node_id, timestamp }
- ActorPlaced { actor_id, node_id }
- ActorMigrated { actor_id, from_node, to_node }
- ConfigChanged { key, value, version }
Failure detection parameters:
| Parameter | Default | Description |
|---|---|---|
| Probe interval | 1 second | Time between direct probes |
| Probe timeout | 3 seconds | Time to wait for direct ACK |
| Indirect probes | 3 | Number of indirect probes on direct failure |
| Indirect timeout | 5 seconds | Time to wait for indirect ACK |
| Suspicion threshold | 3 | Number of missed probes before declaring failure |
| Sync interval | 200ms | Gossip sync frequency |
False positive handling: SWIM can falsely detect failure under high packet loss. Nulang mitigates this:
- A node marked failed enters "suspected" state first
- If suspected node responds to any probe, it is reinstated
- If no response after suspicion threshold, node is declared failed
- Actor placements on failed node are rebalanced to surviving nodes
- If the "failed" node was actually alive (network partition), it detects the partition and shuts down (split-brain prevention)
Messages are routed by actor identity. The routing process determines whether the target is local or remote and delivers accordingly.
Routing algorithm:
function route_message(msg, target_identity):
1. Hash target_identity -> consistent_hash_ring_position
2. Look up responsible node in consistent hash ring
3. If responsible node == this node:
a. Look up target_identity in local activation table
b. If active: push to mailbox
c. If inactive: activate, then push to mailbox
4. If responsible node == other node:
a. Check local cache for target location
b. If cache hit and fresh: forward to cached node
c. If cache miss or stale: forward to responsible node
5. If responsible node is failed:
a. Find next replica in hash ring
b. Forward to replica
c. Trigger rebalancing for failed node's actors
Consistent hash ring:
Consistent Hash Ring (0 to 2^160 - 1, SHA-1 space)
+------------------------------------------------------------------+
| |
| node-1 node-2 node-3 node-1(replica) |
| | | | | |
| v v v v |
| +------+ +------+ +------+ +------+ |
| |######| | | |######| | | |
| |######| | | |######| | | |
| +------+ +------+ +------+ +------+ |
| 0x10.. 0x50.. 0x90.. 0xD0.. |
| |
| Actor "Order:42" -> hash("Order:42") = 0x67 -> node-2 |
| Actor "Cart:99" -> hash("Cart:99") = 0xB3 -> node-3 |
+------------------------------------------------------------------+
Each node is placed at multiple points on the ring (virtual nodes, default: 150 per physical node). This ensures uniform distribution even with non-uniform actor identity hashing.
Location cache: Each node maintains an LRU cache of actor identity -> node mappings. Cache entries have a TTL (default: 30 seconds). A cache hit avoids a consistent hash computation and a directory lookup. On cache miss or stale entry, the router falls back to the directory.
CRDT actors replicate state across nodes without coordination. The replication uses an anti-entropy protocol.
Anti-entropy process:
Node A (CRDT replica) Node B (CRDT replica)
| |
| 1. A initiates sync |
| sends sync request with |
| vector clock + digest |
|----------------------------------->|
| |
| 2. B compares digest with |
| its own state |
| identifies missing deltas |
|<-----------------------------------|
| sends delta batch |
| |
| 3. A applies deltas, sends |
| ack + any deltas B is missing |
|----------------------------------->|
| |
| 4. B applies A's deltas |
|<-----------------------------------|
| final ack |
CRDT types supported:
| Type | Operations | Merge Semantics | Use Case |
|---|---|---|---|
| G-Counter | increment | max of replicas | Vote counting, page views |
| PN-Counter | increment, decrement | per-replica G-Counters | Like/dislike counts |
| G-Set | add | set union | Tag collections |
| 2P-Set | add, remove | add-wins over remove | Shopping cart items |
| OR-Set | add, remove | add-wins, unique tags | Collaborative lists |
| LWW-Register | set | last-writer-wins | User preferences |
| MV-Register | set | multi-value on conflict | Concurrent edits |
| OR-Map | put, remove, nested CRDTs | recursive merge | Document stores |
| Delta-State | all above | delta-based sync | Efficient replication |
Sync modes:
| Mode | Trigger | Latency | Bandwidth |
|---|---|---|---|
| Continuous | Every update | Low | High |
| Periodic | Every 5 seconds | Medium | Low |
| On-demand | Pull-based | High | Lowest |
| Gossip piggyback | On gossip messages | Medium | Very low |
The default is gossip piggyback for low-throughput CRDTs and periodic (1s) for high-throughput CRDTs.
Actor protocols are registered in a distributed service registry. Clients discover actors by protocol type, not by hardcoded identity.
Registry structure:
+------------------------------------------------------------------+
| Service Registry |
| |
| Protocol Type -> Actor Type(s) | Instances |
| ----------------- ----------------- | -------- |
| OrderService -> OrderActor | [ord-42, ord-43] |
| PaymentGateway -> StripeGateway | [stripe] |
| -> PayPalGateway | [paypal] |
| UserSession -> SessionActor | [sess-abc, ...] |
+------------------------------------------------------------------+
Discovery patterns:
| Pattern | API | Use Case |
|---|---|---|
| By identity | actor("Order:ord-42") |
Known entity (specific order) |
| By type | actor_of_type(OrderService) |
Any instance (round-robin) |
| By key range | actors_in_range("User:", start, end) |
Shard scanning |
| By capability | actors_with(LLM) |
Find all LLM-enabled actors |
The registry is itself a CRDT, replicated across all nodes via gossip. Registration is implicit: when an actor type is deployed, it is automatically registered. Deregistration happens on deployment removal.
NUL0 is Nulang's inter-node network protocol. It is a binary protocol over TCP (with optional QUIC for cross-region).
NUL0 frame format:
+--------+--------+--------+--------+--------+----------+----------+
| Magic | Version| Type | Flags | Length | Payload | MAC |
| (4B) | (1B) | (1B) | (2B) | (4B) | (var) | (16B) |
+--------+--------+--------+--------+--------+----------+----------+
Magic: 0x4E554C30 ("NUL0" in ASCII)
Version: 0x01 (current)
Type: Message, Ack, Nack, Heartbeat, Gossip, CRDTSync, Control
Flags: encrypted, compressed, priority
MAC: Poly1305 message authentication code
Connection management:
+------------------------------------------------------------------+
| Connection Pool (per node pair) |
| |
| Node A maintains persistent TCP connections to all other nodes |
| Connection count per pair: min(4, CPU cores) |
| |
| +----------+ +----------+ +----------+ +----------+ |
| | Conn 1 | | Conn 2 | | Conn 3 | | Conn 4 | |
| | (msgs) | | (gossip) | | (crdt) | | (ctrl) | |
| +----------+ +----------+ +----------+ +----------+ |
| |
| Separation: different traffic types use different connections |
| to prevent head-of-line blocking |
+------------------------------------------------------------------+
Security:
- All inter-node traffic is encrypted (TLS 1.3 over TCP, or native QUIC encryption)
- Messages are authenticated with Poly1305 MACs
- Capability tokens are embedded in message headers and verified at the receiving node
- Node identities are verified via Ed25519 signatures exchanged during handshake
The AI Runtime (v0.9) is split across two crates. Pure types live in the
standalone nulang-ai workspace crate (crates/nulang-ai/, published as
nulang_ai) with zero dependency on the core nulang crate. The core
crate imports them directly (use nulang_ai::…;, no façade module) behind
the ai-runtime cargo feature and adds the language-level wiring for
agent declarations and orchestration builtins plus the actor-runtime
integration that legitimately needs Runtime internals. It is not a
separate interpreter: agents compile to ordinary persistent actors, and LLM
calls flow through the same register VM and callback traits as every other
effect. There is no WIT layer, no capability-injected provider, and no
routing DSL in the current implementation — provider selection is a
Rust-level decision (Runtime::set_llm_client).
| File | Contents |
|---|---|
crates/nulang-ai/src/mod.rs |
Crate root; declares modules and re-exports the public surface |
crates/nulang-ai/src/client.rs |
LlmClient async trait + complete_sync bridge |
crates/nulang-ai/src/request.rs |
LlmRequest, LlmMessage, ToolSchema, ModelPricing |
crates/nulang-ai/src/response.rs |
LlmResponse, ToolCall, TokenUsage, LlmError |
crates/nulang-ai/src/providers/openai.rs |
OpenAiClient (OpenAI + compatible endpoints) |
crates/nulang-ai/src/providers/ollama.rs |
OllamaClient (local Ollama) |
crates/nulang-ai/src/mock.rs |
MockLlmClient (canned/sequenced responses, records requests) |
crates/nulang-ai/src/memory.rs |
EpisodicMemory — bounded conversation buffer |
crates/nulang-ai/src/semantic_memory.rs |
SemanticMemory — cosine-similarity fact store |
crates/nulang-ai/src/procedural_memory.rs |
ProceduralMemory — patterns + few-shot examples |
crates/nulang-ai/src/usage.rs |
estimated_cost, TokenBudget, UsageSummary accumulation |
crates/nulang-ai/src/pipeline.rs |
Pipeline / PipelineStage + PipelineRuntime trait |
crates/nulang-ai/src/supervisor.rs |
SupervisorTeam / Worker + SupervisorRuntime trait |
crates/nulang-ai/src/debate.rs |
Debate / Participant / Stance + DebateRuntime trait |
crates/nulang-ai/src/registry.rs |
AiRuntimeRegistry (pipelines + debates) and SupervisorTeamRegistry, both generic over the runtime traits |
src/runtime/ai_impls.rs |
Core-side impl PipelineRuntime for Runtime, impl DebateRuntime for Runtime, impl SupervisorRuntime for Runtime. Kept in core by the orphan rule |
src/runtime/agent.rs |
Agent LLM completion pipeline (build_agent_llm_request, finish_agent_llm, complete_agent_llm) — reads actor durable state, allocates strings into actor heaps |
src/runtime/llm.rs |
LlmState — persistent nulang-llm worker thread + completion channels polled by the scheduler |
src/tool_schema.rs |
Nulang Type → JSON Schema, function_to_tool_schema — kept in core (unconditional) because bytecode::ActorMeta.tools and HIR both embed it |
All providers implement one trait (crates/nulang-ai/src/client.rs):
#[async_trait]
pub trait LlmClient: Send + Sync {
async fn complete(&self, request: LlmRequest) -> Result<LlmResponse, String>;
}The runtime and VM are synchronous, so complete_sync(client, request)
bridges the gap: it reuses the current Tokio runtime handle when one exists
(Handle::try_current().block_on(...)), and otherwise builds a temporary
single-threaded Tokio runtime for the duration of the call. The only async
code in the project is this trait, main (#[tokio::main]), and the LSP
server.
LlmRequest carries model, messages, tools, memory (prepended to
messages by the provider before sending), and optional pricing
(ModelPricing, USD per 1k input/output tokens). LlmResponse carries
content: Option<String>, tool_calls, model, finish_reason, and
TokenUsage { prompt, completion, total }.
Providers (both reqwest-based, stream: false — no streaming today):
| Provider | Endpoint | Tool calling | Construction |
|---|---|---|---|
OpenAiClient |
POST {base_url}/chat/completions, Authorization: Bearer |
Native function-calling format, tool_choice: "auto" |
new(key, model); with_base_url(...) for OpenAI-compatible APIs; gpt4o() reads OPENAI_API_KEY |
OllamaClient |
POST {base_url}/api/chat |
Native | new(base_url, model); default() = http://localhost:11434, llama3.1 |
MockLlmClient returns a fixed response, a sequence of responses, or tool
calls, and records every request it receives — it is how agent, pipeline,
and debate tests run without a network.
There is no multi-provider routing and no budget auto-fallback yet; Anthropic, Azure, and vLLM backends from the target design are unimplemented.
agent is a real declaration form wired through the whole pipeline — lexer
("agent" keyword, src/lexer.rs) → parser (parse_agent,
src/parser.rs) → AST (Decl::Agent, src/ast.rs) → type checker (binds an
opaque synthetic Actor type) → HIR desugaring → MIR → bytecode metadata →
runtime support. It is not library-only sugar assembled in Rust code.
Syntax (examples/pipeline.nula):
agent Researcher = {
model: "llama3.1",
system_prompt: "You are a researcher. Provide factual information.",
pricing: { input: 0.0, output: 0.0 }
}
Recognized fields — anything else is a parse error: model (required
string), system_prompt (string), tools (list of function names),
memory: { max_turns } (default 50), semantic_memory: { dimensions },
procedural_memory: { namespace }, pricing: { input, output } (USD per 1k
tokens).
hir_lower::desugar_agent rewrites the declaration into a persistent
actor (is_agent: true) whose durable state holds the configuration and
whose generated behaviors provide the interface:
- State fields (all
StateModel::Durable):model,system_prompt,episodic_memory(anEpisodicMemoryserialized as JSON),usage_prompt,usage_completion,usage_cost,pricing_input,pricing_output, and — when configured —semantic_memoryandprocedural_memory(JSON snapshots of the corresponding stores). LLM.ask(prompt), which MIR lowering dispatches through the genericPerformAsync(0xC6) effect opcode with the"Inference.ask"effect-op string.
At the VM boundary (src/vm.rs), PerformAsync with "Inference.ask" reads
the model string from the constant pool and the prompt from a register, calls
ActorVmCallbacks::complete_llm(model, prompt), and writes the reply string
(or nil) back into the same register. The bytecode module records agent
metadata (ActorMeta { is_agent, tools, semantic_memory_dimensions, procedural_memory_namespace }) so the runtime knows which actors are
agents.
On the runtime side (src/runtime/mod.rs):
Runtime::set_llm_client(Box<dyn LlmClient>)installs the provider. With no client installed, LLM requests fail with an error string that becomesnilat the VM boundary — agents degrade gracefully instead of panicking.complete_agent_llm(actor_id, prompt)loads the agent's durable state, builds the request (system prompt + episodic memory + user prompt), callscomplete_llm_with_tools, then writes the updated episodic memory back and accumulates token usage and cost into durable state — so memory and spend survive actor restarts.complete_llm_with_toolspopulatesrequest.toolsfrommodule.tools; if the model returns tool calls, each named function is invoked with the JSON arguments (invoke_agent_tool_function) and the results are appended to the conversation for a final model round-trip.
The design's "3-tier memory" exists today as three Rust types, all serde-serializable so they can live in durable actor state:
| Kind | Type | Scope | Mechanism |
|---|---|---|---|
| Episodic | EpisodicMemory (crates/nulang-ai/src/memory.rs) |
Conversation | VecDeque<Turn> bounded by max_turns (default 50); oldest evicted first; to_messages() materializes provider-agnostic LlmMessages |
| Semantic | SemanticMemory (crates/nulang-ai/src/semantic_memory.rs) |
Long-term facts | store / search(query, top_k) / delete; cosine similarity over embeddings — a deterministic built-in FNV-based embedder by default, or a caller-supplied fn(&str) -> Vec<f32>; brute-force scan |
| Procedural | ProceduralMemory (crates/nulang-ai/src/procedural_memory.rs) |
Learned patterns | Namespaced Patterns (input_pattern → output_template) plus few-shot Examples per task; get_examples ranks by keyword overlap, falling back to most-recent |
Agent declarations opt into semantic/procedural memory via
semantic_memory: { dimensions } and procedural_memory: { namespace };
the generated actor keeps each store as a JSON blob in durable state and the
runtime services the memory behaviors by name (§6.3).
Not implemented yet: context-window auto-summarization, external vector backends (Qdrant/pgvector/HNSW), and the journal-backed "event memory" tier.
A tool is an ordinary function annotated @tool(description: "...")
(FunctionAnnotation::Tool, parsed in src/parser.rs). There is no
standalone tool-registry process and no separate definition format:
ai::schema::function_to_tool_schemabuilds aToolSchema({ name, description, parameters }) from the function signature;type_to_json_schemamaps Nulang types to the JSON-Schema subset — primitives, records → objects, arrays, tuples →prefixItems, variants →oneOf, references unwrap,Option[T]→anyOf [null, T].- Codegen collects the schemas of every agent actor into
CodeModule.tools(src/mir_codegen.rs). - At request time the runtime attaches them to the outgoing request; the
provider returns
tool_calls; the runtime invokes each named function with the JSON arguments and feeds the results back for a final model round-trip (§6.3).
Three multi-agent patterns ship both as Rust types and as surface-syntax
builtins. Each pattern defines a minimal runtime trait —
PipelineRuntime, SupervisorRuntime, DebateRuntime, each with a single
ask_agent(agent_id, prompt) method — so tests can drive them with mocks.
All three are implemented for Runtime by ask-ing the target agent actor
(the ask behavior is resolved by name, with a bytecode-module fallback for
source-compiled agents).
| Pattern | Type | Semantics |
|---|---|---|
| Pipeline | Pipeline { stages: Vec<PipelineStage> } |
Sequential chain of PipelineStage { name, agent_id, prompt_template }; {input} in each template is replaced by the previous stage's output (or the pipeline input for the first stage) |
| Supervisor team | SupervisorTeam { workers, max_iterations } |
Sequential refinement over Worker { name, agent_id, description }; each worker is prompted with its description plus the accumulated result |
| Debate | Debate { topic, rounds, consensus_threshold, participants } |
rounds rounds over Participant { name, stance: Pro/Con/Neutral, agent_id } with the running argument record in each prompt; the last participant acts as moderator and synthesizes the conclusion (consensus_threshold is clamped to [0,1] and currently advisory) |
Surface syntax such as
let pipeline = Pipeline.new()
|> Pipeline.stage("research", researcher, "Research: {input}")
|> Pipeline.stage("write", writer, "Write based on: {input}")
in
pipeline.run("CRDTs")
(examples/pipeline.nula) is recognized in HIR lowering
(is_ai_builtin_call / try_lower_run_call, src/hir_lower.rs) and
compiled to dedicated opcodes — PipelineNew/Stage/Run,
SupervisorNew/Worker/Run, DebateNew/Participant/Run (§2.6). The VM
forwards them to the runtime, which owns pipelines, supervisor_teams,
and debates maps keyed by monotonic ids.
The target design's LLM-driven planner (natural-language goal → workflow
graph) is not implemented; workflow declarations (§2.1) are the only
workflow mechanism today.
TokenUsage (per response) feeds estimated_cost(usage, pricing)
(crates/nulang-ai/src/usage.rs) with per-1k-token USD rates from ModelPricing (or the
agent's pricing block). UsageSummary accumulates prompt/completion/total
tokens (saturating adds) and cost (plain f64 sum).
For agent actors this accumulation is automatic: complete_agent_llm writes
the running totals into durable state after every call, and the generated
usage() behavior exposes [prompt, completion, cost].
Not implemented yet: OpenTelemetry span emission, PII redaction, latency tracing, per-deployment cost counters, and budget enforcement or alerts — the observability half of the target design remains open.
+--------+ +---------+ +----------+ +----------+ +-------+
| Sender | | Message | | Scheduler| | Behavior | |Mailbox|
| Actor | | Router | | (work | | Handler | | (next)|
| | | | | queue) | | (WASM) | | |
+---+----+ +----+----+ +----+-----+ +----+-----+ +---+---+"
| | | | |
| 1. send(msg) | | | |
|-------------->| | | |
| | 2. route to | | |
| | scheduler | | |
| |--------------->| | |
| | | 3. enqueue | |
| | | in run queue| |
| | | | |
| | | 4. schedule | |
| | | actor | |
| | |--------------->| |
| | | | 5. dequeue msg |
| | | | |
| | | | 6. run handler |
| | | | in WASM |
| | | | |
| | | | 7. handler |
| | | | completes |
| | | | |
| | | 8. deliver | |
| | | outgoing | |
| | | messages | |
| | | | |
| | | 9. if reply | |
|<------------------------------------expected: | |
| | | send reply | |
| | | | |
| | | 10. requeue | |
| | | if more msgs|---------------->|
+ + + + +
+---------+ +----------+ +-----------+ +----------+ +--------+
|Behavior | |Checkpoint| | Storage | | Recovery | |Replay |
|Handler | | Manager | | Backend | |Orchestr. | |Engine |
+----+----+ +----+-----+ +-----+-----+ +----+-----+ +---+----+
| | | | |
| 1. handler | | | |
| completes | | | |
|-------------->| | | |
| | 2. COW snapshot | | |
| | of state | | |
| (actor | | | |
| continues)| | | |
| | 3. serialize + | | |
| | compress | | |
| |----------------->| | |
| | | 4. append to | |
| | | journal | |
| |<-----------------| | |
| | 5. ack | | |
| | | | |
| | | | |
| | | | 6. node fails |
| | | | |
| | | | 7. detect |
| | | | failure |
| | |<---------------| |
| | | 8. load latest | |
| | | checkpoint | |
| | | | |
| | | | 9. replay |
| | | | events from |
| | | | checkpoint |
| |<-----------------------------------| |
| | 10. restore state | |
|<--------------| | |
| 11. resume | | |
| processing | | |
+ + + + +
For event_sourced actors, step 8 loads the snapshot and steps 9-10 replay
events after the snapshot. Effect results from the journal are used instead
of re-invoking handlers.
+--------+ +----------+ +----------+ +----------+ +--------+
|Local | |Location | | NUL0 | |Location | |Remote |
|Actor | |Directory | |Transport | |Directory | |Actor |
+---+----+ +----+-----+ +-----+----+ +-----+----+ +---+----+
| | | | |
| 1. send(msg, | | | |
| "Order:42")| | | |
|-------------->| | | |
| | 2. hash("Order:42")| | |
| | -> node-3 | | |
| | | | |
| | 3. if local: | | |
| | deliver | | |
| | if remote: | | |
| | forward | | |
| |----------------->| | |
| | | 4. serialize | |
| | | + encrypt | |
| | | | |
| | | 5. TCP send | |
| | |--------------->| |
| | | | 6. receive |
| | | | + decrypt |
| | | | |
| | | | 7. look up |
| | | | activation |
| | | | |
| | | | 8. deliver to |
| | | | mailbox |
| | | |-------------->|
| | | | 9. send ACK |
| | |<---------------| |
| | | 10. ACK | |
| |<-----------------| | |
| 11. delivery | | | |
| confirmed | | | |
+ + + + +
+--------+ +---------+ +----------+ +---------+ +--------+
|Local | |CRDT | | Gossip / | |CRDT | |Remote |
|Modify | |Engine | | NUL0 | |Engine | |Merge |
+---+----+ +----+----+ +-----+----+ +----+----+ +---+----+
| | | | |
| 1. apply(op) | | | |
|-------------->| | | |
| | 2. update local | | |
| | state | | |
| | | | |
| 3. return OK | | | |
|<--------------| | | |
| | | | |
| | 4. delta = | | |
| | state.delta() | | |
| | | | |
| | 5. encode delta | | |
| |----------------->| | |
| | | 6. send via | |
| | | gossip/NUL0 | |
| | |--------------->| |
| | | | 7. receive |
| | | | delta |
| | | | |
| | | | 8. merge(delta)|
| | | | (converged) |
| | | | |
| | | | 9. if local |
| | | | state |
| | | | changed: |
| | | | notify |
| | | | actor |
| | | +------->| |
+ + + + + +
No coordination, no locks, no consensus. Both replicas apply the operation
locally and converge via the CRDT merge function.
+--------+ +--------+ +----------+ +--------+ +--------+
| User | | Agent | | AI | | Tool | | LLM |
| Message| | Actor | | Runtime | | Exec | |Provider|
+---+----+ +---+----+ +-----+----+ +---+----+ +---+----+
| | | | |
| 1. message | | | |
|------------->| | | |
| | 2. build | | |
| | conversation| | |
| | (short-term | | |
| | memory) | | |
| | | | |
| | 3. recall | | |
| | long-term | | |
| | memory | | |
| | | | |
| | 4. effect | | |
| | LLMComplete | | |
| |--------------->| | |
| | | 5. send to | |
| | | LLM provider| |
| | |--------------->| |
| | | | 6. LLM decides |
| | | | tool call |
| | |<---------------| |
| | | 7. parse tool | |
| | | call | |
| | | | |
| | | 8. send to | |
| | | tool actor | |
| | |--------------->| |
| | | | 9. execute |
| | | | behavior |
| | | | |
| | | 10. tool result| |
| | |<---------------| |
| | | | |
| | | 11. re-send to | |
| | | LLM with | |
| | | tool result | |
| | |--------------->| |
| | | | 12. final |
| | | | response |
| | |<---------------| |
| | | | |
| | 13. emit trace | | |
| | (async) | | |
| | | | |
| 14. response | | | |
|<-------------| | | |
+ + + + +
| Component | Talks To | Protocol | Purpose |
|---|---|---|---|
| Scheduler | Actor (via mailbox) | Mailbox push (lock-free) | Work distribution |
| Scheduler | Location Directory | Read (local cache) | Determine if actor is local |
| Scheduler | Checkpoint Manager | Async callback | Trigger post-message checkpoint |
| Virtual Actor Manager | Location Directory | Read/Write | Track activations |
| Virtual Actor Manager | WASM Sandbox Pool | Acquire/Release | Get WASM instance for activation |
| Mailbox | Scheduler | Dequeue (consumer) | Deliver messages to scheduled actor |
| Mailbox | Message Router | Enqueue (producer) | Accept incoming messages |
| Message Router | Location Directory | Lookup | Resolve actor identity to node |
| Message Router | NUL0 Transport | Send frame | Forward remote messages |
| Message Router | Local Scheduler | Enqueue | Deliver local messages |
| Supervisor | Child actors | EXIT signals, restart commands | Fault recovery |
| Supervisor | Parent supervisor | Escalation | Propagate unrecoverable failures |
| WASM Sandbox Pool | Wasmtime | Instantiate/Call host | Run actor code |
| Checkpoint Manager | Storage Backend | Append | Write checkpoint blobs |
| Checkpoint Manager | Actor (COW) | Read memory | Capture actor state |
| Event Journal | Storage Backend | Append segment | Persist event log |
| Event Journal | Replay Engine | Read segment | Replay events for recovery |
| Replay Engine | Actor (WASM) | Inject effect results | Deterministic replay |
| Recovery Orchestrator | Event Journal | Read | Load actor state after crash |
| Recovery Orchestrator | Scheduler | Activate | Resume recovered actors |
| Gossip Protocol | All nodes | UDP multicast / TCP | Membership, failure detection |
| Gossip Protocol | Location Directory | Push updates | Propagate placement changes |
| Gossip Protocol | CRDT Engine | Piggyback deltas | Efficient CRDT sync |
| CRDT Engine | Actor (local) | Apply ops | Local CRDT updates |
| CRDT Engine | NUL0 Transport | Send deltas | Cross-node CRDT sync |
| CRDT Engine | Storage Backend | Read/Write | Persist CRDT state |
| Location Directory | Consistent Hash Ring | Compute | Map identity to node |
| Location Directory | Service Registry | Read/Write | Register actor types |
| Service Registry | Gossip Protocol | Replicate | Cross-node registry sync |
| NUL0 Transport | All nodes | TCP / QUIC | Encrypted inter-node messaging |
| AI Runtime | LLM Provider | HTTP/2 (OpenAI, Anthropic) | LLM completions |
| AI Runtime | Tool Registry | Read | Discover available tools |
| AI Runtime | Actor (via effect) | Effect return | Deliver LLM responses |
| AI Runtime | Vector Store | Query/Upsert | Long-term memory |
| AI Runtime | Event Journal | Read | Event memory queries |
| AI Runtime | Observability | Emit spans | LLM call traces |
| Tool Registry | Service Registry | Query | Map tool names to actors |
| Tool Registry | Scheduler | Send/Receive | Execute tool calls |
| Capability Manager | Actor (compile-time) | Type check | Verify capability usage |
| Capability Manager | Message Router | Verify tokens | Validate cross-node caps |
| Capability Manager | Revocation List | Append/Check | Lazy capability revocation |
| ORCA GC | Actor (per-actor) | Collect | Reclaim actor-local memory |
| ORCA GC | Cross-actor | Send decrements | Reclaim cross-actor references |
Status: Partially implemented — actors today have numeric u64 identities and explicit spawn; identity-string addressing, activation-on-first-message, and hash-ring placement remain target design (see §1 implementation note).
Context: Actor lifecycle management is complex and error-prone. Erlang requires explicit spawn/link/monitor. Akka requires actor-of-actor factories. Both force developers to manage lifetimes, introducing coupling between creator and created.
Decision: All actors in Nulang are virtual — addressed by identity, runtime manages activation/deactivation/ placement/migration. Creating an actor is as simple as sending it a message.
Consequences: (+) Simpler programming model, location transparency, automatic scaling. (-) Requires distributed directory service, potential "cold start" latency for first message.
References: Orleans (Microsoft Research), "Orleans: Distributed Virtual Actors for Programmability and Scalability" (Bernstein et al., 2014)
Status: Superseded (2026-07) — the implementation compiles to register-VM bytecode with a Cranelift JIT tier; no WASM/Wasmtime exists in the tree. See §2.5–§2.6. The sandboxing/portability goals remain open. Context: Nulang needs sandboxed execution, cross-language interop, and portable deployment. Options: native code (fast but unsafe), JVM (heavyweight, Oracle licensing), BEAM (Erlang-specific), WASM (sandboxed, portable, standard). Decision: Nulang compiles to WASM components (wasm32-wasip2). Every actor is a WASM component running in Wasmtime. Consequences: (+) Sandboxed execution, cross-language composition, portable deployment, capability-based security via WASI. (-) ~10-20% performance overhead vs native, dependency on Wasmtime project health. References: WebAssembly Component Model W3C specification, Wasmtime runtime
Status: Accepted
Context: Different use cases need different persistence/consistency tradeoffs. A single model (e.g., always event-sourced) is too expensive for simple stateless services. No persistence makes Nulang just another actor framework.
Decision: Nulang provides four state models: local (no persistence), durable (automatic checkpointing), event_sourced (full audit journal), crdt (geo-replicated). The state model is part of the actor's type.
Consequences: (+) Optimal cost/performance for each use case, type-safe persistence. (-) More complexity in the runtime, migration between state models requires code changes.
References: Orleans grain persistence, Durable Functions stateful entities, Akka Persistence, CRDT research (Shapiro et al.)
Status: Accepted, with a 2026-07 refinement: agent is now a first-class declaration form, but it desugars to an ordinary persistent actor (§6.3) — the "agents are actors, no separate interpreter" principle holds. Tools are @tool-annotated functions (§6.5); memory is durable actor state (§6.4).
Context: AI frameworks (LangChain, LangGraph) create parallel programming models for agents. This forces developers to learn two systems and prevents agents from using general distributed systems features.
Decision: There is no separate AI DSL in Nulang. An AI agent is an actor that holds the LLM capability. Tools are actor behaviors. Memory is actor state. Planning delegates to the workflow subsystem.
Consequences: (+) Unified programming model, agents get durability/distribution/supervision for free, no framework lock-in. (-) AI-specific ergonomics (prompt templates, conversation management) must be provided as libraries, not language features.
References: LangGraph, AutoGen, "Actors Are All You Need" (design principle)
Status: Accepted Context: Workflow engines (Temporal, Camunda) are separate systems that integrate poorly with actor models. Workflow steps are not actors — they cannot send/receive messages, hold state, or be supervised individually. Decision: Workflow definitions compile to actor graphs. Each step is a child actor. The workflow actor is a durable state machine tracking step completion. Control flow (parallel, conditional, compensation) compiles to message patterns. Consequences: (+) Workflows inherit all actor properties (durability, distribution, supervision), no separate workflow engine to operate, workflow steps can be any actor. (-) Workflow compilation is complex, workflow visualization requires mapping actor graphs back to workflow steps. References: Temporal (workflow as deterministic program), Saga pattern, Erlang gen_fsm
Status: Accepted Context: Stop-the-world GC is incompatible with actor systems where each actor must process messages with predictable latency. Per-actor heaps need a collection strategy that doesn't pause other actors. Decision: Nulang uses ORCA (Ownership and Reference Counting based on Cycles in Actors) for per-actor garbage collection. Each actor collects independently. Cross-actor references use asynchronous decrement messages. Consequences: (+) <1ms pause per actor, no global pause, naturally parallel. (-) Reference counting overhead (~5-10%), cycle detection requires periodic local tracing, cross-actor decrements are eventual (not immediate). References: "ORCA: A Causal Delta-Order for Concurrent Reclamation" (Clebsch et al., 2015)
Status: Accepted Context: Strongly consistent replicated state requires consensus (Paxos/Raft), which has high latency and is unavailable during partitions. Many distributed use cases (shopping carts, presence, counters) can tolerate eventual consistency. Decision: Nulang uses CRDTs (Conflict-Free Replicated Data Types) as the default for geo-replicated state. Strong consistency is available but opt-in and requires consensus. Consequences: (+) Low latency everywhere, available during partitions, no coordination overhead. (-) Eventual consistency (stale reads possible), limited CRDT types (not all data structures have CRDT equivalents), larger state (need to track tombstones). References: "Conflict-Free Replicated Data Types" (Shapiro et al., 2011), "A Comprehensive Study of Convergent and Commutative Replicated Data Types"
Status: Accepted Context: Side effects (IO, time, randomness) must be tracked for deterministic replay and capability-based security. Options: monadic IO (Haskell — too invasive), unchecked effects (most languages — unsafe), effect handlers (Koka, Eff — right abstraction). Decision: Nulang uses row-polymorphic algebraic effects. Effects are typed, resumable, and tracked in function signatures. Effect results are captured for deterministic replay. Consequences: (+) Clean separation of what from how, testable via handler swapping, automatic tracing, deterministic replay. (-) Effect row syntax adds noise to types, effect handlers are a new concept for many developers. References: Koka (Leijen), Eff (Bauer & Pretnar), "Algebraic Effects for Functional Programming" (Plotkin & Power, 2002)
Status: Superseded (2026-07) — capabilities are Pony-style reference capabilities enforced at compile time and erased at runtime (§2.4); no WASI mapping exists. The authority-token model (narrowing, revocation) remains target design.
Context: Nulang's capability security model needs an implementation mechanism. Inventing a custom capability system would require custom tooling and verification.
Decision: Nulang capabilities compile to WASI world imports. The WASM Component Model's import system IS the capability system. If an actor doesn't import wasi:http/outgoing-handler, it cannot make HTTP requests — enforced by the WASM sandbox.
Consequences: (+) Zero-overhead capability enforcement (sandbox-level, not runtime checks), standard tooling, composable via WIT. (-) Tied to WASI/WASM ecosystem evolution, some capabilities need custom WIT interfaces.
References: WASI (WebAssembly System Interface), WASM Component Model, "Capability-Based Security and the WASM Component Model"
Status: Accepted Context: Cluster membership and failure detection need to be decentralized, scalable, and tolerant of network partitions. Options: centralized coordinator (single point of failure), consensus-based (too slow), gossip-based (eventual, scalable). Decision: Nulang uses the SWIM gossip protocol for cluster membership and failure detection. Node state is disseminated via gossip. Actor placement changes propagate via gossip piggybacking. Consequences: (+) Decentralized, scalable to 1000+ nodes, sub-second failure detection, minimal network overhead. (-) Eventual consistency of membership view, false positives under high packet loss, no global consistency guarantee. References: SWIM (Scalable Weakly-consistent Infection-style Process Group Membership Protocol) (Das et al., 2002), HashiCorp Memberlist
Status: Superseded (2026-07) — the implemented syntax is brace-based and indentation-insensitive (§2.1); newlines are expression terminators, not block structure. Context: Syntax style debates waste time. Braces (C-style) are redundant with proper indentation. Python proved indentation-based syntax works at scale. Decision: Nulang uses mandatory 2-space indentation. No braces, no semicolons, no configuration. A deterministic formatter is required (like gofmt). Consequences: (+) Visual hierarchy matches semantic hierarchy, no style debates, smaller code, enforced consistency. (-) Whitespace sensitivity can surprise newcomers, copy-paste can break indentation, hard to generate programmatically. References: Python, Haskell, F#, gofmt
Status: Accepted
Context: Async/await creates "colored functions" — sync functions can't call async functions, leading to contagious async annotations throughout codebases. In an actor model, all concurrency is via message passing, not async tasks.
Decision: Nulang has no async/await syntax. Actors process messages sequentially within a behavior handler. Concurrency comes from having many actors. Effect handlers may be async internally, but this is invisible to actor code.
Consequences: (+) No colored functions, simpler mental model, behavior handlers are atomic from the actor's perspective. (-) Cannot express intra-actor concurrency, long operations must be split into multiple messages.
References: "What Color is Your Function?" (Nathaniel Smith, 2015), Erlang, original actor model (Hewitt et al., 1973)
These targets define the performance envelope for a production Nulang deployment on commodity hardware (AMD EPYC / Intel Xeon, NVMe SSD, 10Gbps network).
| Metric | Target | Measurement | Notes |
|---|---|---|---|
| Actor creation (activation) | < 1 microsecond | Time from first message to behavior handler start | Warm WASM instance from pool |
| Actor creation (cold start) | < 10 milliseconds | Time when WASM instance must be compiled | JIT compilation cost |
| Actor deactivation | < 1 millisecond | Time to checkpoint and release resources | Excluding storage write latency |
| Actor migration (same DC) | < 5 milliseconds | Time to stop on source, start on target | State transfer via shared storage |
| Metric | Target | Measurement | Notes |
|---|---|---|---|
| Message send (local) | < 100 nanoseconds | Time to enqueue in mailbox | Lock-free ring buffer |
| Message send (remote, same DC) | < 1 millisecond | End-to-end including serialization | TLS + TCP, under 1ms at p99 |
| Message send (cross-region) | < 50 milliseconds | Transatlantic / transpacific | Depends on geography |
| Message processing throughput | > 1M messages/second/node | Sustained, local actors |
64-byte messages, batching enabled |
| Mailbox capacity | 10,000 messages | Default, configurable | Bounded to prevent OOM |
| Metric | Target | Measurement | Notes |
|---|---|---|---|
| Checkpoint latency | < 10 milliseconds | COW snapshot + serialization | Excluding storage backend write |
| Storage write (SQLite) | < 1 millisecond | Append to local SQLite | NVMe SSD |
| Storage write (PostgreSQL) | < 5 milliseconds | Network roundtrip to DB | Same datacenter |
| Storage write (S3) | < 50 milliseconds | HTTP PUT to object store | Regional endpoint |
| Event journal append | < 1 millisecond | Local segment file append | Sequential write, fsync optional |
| Replay throughput | > 100K events/second | Events replayed per second | From SSD, single-threaded |
| Recovery time | < 5 seconds | Node failure to all actors resumed | 100K actors, NVMe storage |
| Metric | Target | Measurement | Notes |
|---|---|---|---|
| Scheduler latency | < 10 microseconds | Time from message arrival to actor scheduled | Work-stealing efficiency |
| Context switch (actor) | < 50 nanoseconds | WASM instance swap | Instance pool hot path |
| Fairness guarantee | Max 2x ideal share | No actor starves others | Reduction counting |
| Max actors per node | > 1,000,000 | Active + deactivated (memory-bound) | 1KB average per actor |
| Max active actors | > 100,000 | Actually scheduled | CPU-bound |
| Metric | Target | Measurement | Notes |
|---|---|---|---|
| GC pause (per-actor) | < 1 millisecond | Stop time for collection | ORCA cycle detection |
| GC overhead | < 5% | CPU time spent in GC | Reference counting dominant |
| Memory per actor | < 1KB baseline | Empty actor overhead | WASM instance shared |
| Heap limit per actor | 100MB default, 1GB max | Configurable | Enforced by WASM memory bounds |
| Metric | Target | Measurement | Notes |
|---|---|---|---|
| Cluster size | > 100 nodes | Tested, supported | Gossip protocol scales sub-linearly |
| Failure detection | < 3 seconds | Time to detect node failure | SWIM with suspicion mechanism |
| Gossip propagation | < 500ms | Time for update to reach all nodes | 100-node cluster |
| CRDT sync latency | < 100ms eventual | Time to converge after update | Same region, periodic sync |
| CRDT sync (cross-region) | < 5 seconds | Time to converge globally | Periodic sync mode |
| Consistent hash lookup | < 1 microsecond | Identity to node resolution | In-memory ring |
| Metric | Target | Measurement | Notes |
|---|---|---|---|
| LLM call latency (local) | < 100ms | End-to-end, cached tools | Excluding model inference time |
| Tool registration | < 1ms | Schema generation + registry | Per tool, at deploy time |
| Tool execution | Same as message send | Tool is just an actor message | + LLM roundtrip |
| Vector query | < 10ms | Semantic similarity search | HNSW index, local Qdrant |
| Vector upsert | < 5ms | Add to vector store | Batched async |
| Trace emission | < 1ms | Fire-and-forget to collector | Async, buffered |
| Metric | Target | Measurement | Notes |
|---|---|---|---|
| Workflow start | < 5ms | Time from request to first step | Includes compilation for dynamic workflows |
| Step throughput | > 10K steps/second/node | Completed workflow steps | durable actors, local storage |
| Compensation execution | < 50ms per step | Time to execute compensation | Sequential, reverse order |
| Human-in-the-loop resume | < 2 seconds | Time from human action to workflow resume | Includes activation + replay |
| Parallel step fan-out | 10 steps default, 100 max | Concurrent parallel steps | Configurable |
- Hewitt, C., Bishop, P., & Steiger, R. (1973). "A Universal Modular ACTOR Formalism for Artificial Intelligence." IJCAI.
- Agha, G. (1986). Actors: A Model of Concurrent Computation in Distributed Systems. MIT Press.
- Bernstein, P. A., et al. (2014). "Orleans: Distributed Virtual Actors for Programmability and Scalability." Microsoft Research Technical Report.
- Shapiro, M., Preguica, N., Baquero, C., & Zawirski, M. (2011). "Conflict-Free Replicated Data Types." SSS.
- Clebsch, S., Drossopoulou, S., Blessing, S., & McNeil, A. (2015). "Deny Capabilities for Safe, Fast Actors." AGERE!.
- Das, A., Gupta, I., & Motivala, A. (2002). "SWIM: Scalable Weakly-consistent Infection-style Process Group Membership Protocol." ICDCS.
- Leijen, D. (2017). "Type Directed Compilation of Row-Typed Algebraic Effects." POPL.
- Plotkin, G., & Power, J. (2002). "Notions of Computation Determine Monads." FOSSACS.
- Erlang/OTP — Ericsson. The original actor language and runtime.
- Orleans — Microsoft. Virtual actors for .NET.
- Akka — Lightbend. Actor toolkit for JVM (now Apache Pekko).
- Temporal — Temporal Technologies. Durable execution platform.
- Dapr — Microsoft. Distributed application runtime with building blocks.
- Wasmtime — Bytecode Alliance. WASM runtime for the Component Model.
- Cloudflare Workers — Edge WASM runtime (inspiration for deployment targets).
- WebAssembly Core Specification 2.0 — W3C.
- WebAssembly Component Model — W3C Community Group.
- WASI Preview 2 — WebAssembly System Interface.
- OpenTelemetry — CNCF observability standard.
- Cap'n Proto Serialization — Cap'n Proto project.
End of Architecture Reference