Unified Actor Architecture

A Unified Actor Architecture

Bridging Native and Cloud Edge Through Shared Actor Semantics

December 4, 2025·Houston Haynes·26 min read

The actor model presents a useful abstraction for concurrent and distributed systems. When building two frameworks that target different runtimes, a natural question arises: can we maintain a consistent developer experience across native compilation (Fidelity) and edge deployment (Conclave)? Our answer is a division the framework has since made formal in the scheduler contract: actors are defined by their semantics, and dispatch is a separate concern with a contract of its own.

This entry records the architectural decisions behind a unified actor abstraction, designed to compile to our Olivier actors in Fidelity and to edge actors in Conclave. By designing at the right level of abstraction, developers would write actor behaviors once and deploy them to either target without modification.

The Core Tension

The two frameworks target radically different runtimes:

ConcernFidelity/OlivierConclave/SPEC Stack
State PersistenceMemory arenas + RAIIDurable Object storage API
SupervisionProspero on elevated threadWorker Loader capabilities
Message PassingBAREWire IPCWebSocket connections
LifecycleProcess spawn/terminateDO instantiation/hibernation
ConcurrencyOS threads + continuationsV8 isolates + event loop
DispatchAriel under the scheduler contractPlatform scheduler, assumed

Despite these differences, the developer mental model and code syntax expression should remain consistent: actors receive messages, maintain state, spawn children, and participate in supervision hierarchies. The challenge is defining an abstraction that captures these semantics without sacrificing target-specific optimizations.

The dispatch row carries a name and a contract. In Fidelity, Ariel realizes dispatch under the scheduler contract: six clauses and a per-implementation assumption manifest recording which clauses the implementation discharges itself and which it assumes from its substrate. The edge column’s manifest is nearly all assumption. There is no Ariel on the edge, because the platform does the scheduling, and we have no interest in re-implementing it. The work is cooperating with it, and the manifest records the terms of that cooperation:

ClauseOn the edge
FairnessAssumed. The platform schedules isolates and routes requests, with no program-visible bound.
Turn disciplineAssumed. One request or WebSocket message at a time per object, under a CPU clock that resets to 30 seconds per message. Overrun risks eviction, a substrate-chosen remedy.
Control-plane immunityCarried by Conclave. Supervision traffic travels its own path, never queued behind data.
AdmissionCarried by Conclave, with the platform’s help: every send is answered, so the response is the admission verdict, and the bounded mailbox lives in the receiver’s own storage.
Determinism modeNot offered. Arrival order, alarm retries, and hibernation are platform-decided. Replay belongs to the simulated profile on targets we control.
Assumption manifestPublished as this table.

Placement policy gets exactly two controls on this platform, and they differ in kind. A location hint is a suggestion: subcontinental granularity, honored only at an actor’s first activation, with no confirmation of where the platform placed it. A jurisdiction is a guarantee: an actor created under eu or fedramp runs and persists only inside that boundary, and the jurisdiction is part of the actor’s identity, so one name denotes different actors in different jurisdictions. For a regulated deployment, and the FedRAMP case in particular, Prospero’s placement policy on this target reduces to choosing the jurisdiction at creation, and that choice is the strongest placement commitment the platform documents. Olivier’s semantics hold unchanged in both columns while dispatch varies beneath them.

Protocol and Transport Separation

A critical architectural decision is separating protocol from transport. BAREWire serves as the shared protocol across both frameworks: the same binary message encoding, the same framing, the same schema definitions. What differs is the transport layer:

ConcernFidelityConclave
ProtocolBAREWireBAREWire
TransportIPC channels, shared memoryWebSocket connections
ConnectionOS-level primitivesDurable Object holds socket
PersistenceProcess lifetimePlatform-managed

Both transports are persistent and bidirectional, which means the BAREWire protocol maps cleanly to either. HTTP’s request/response model would have introduced impedance. WebSockets preserve the channel semantics BAREWire expects.

This separation yields a concrete benefit: a Fidelity actor and a Conclave actor can exchange messages directly if bridged, because they speak the same protocol. Only the transport adapter differs.

The Shared Abstraction

The abstraction is intrinsic to the compiler. CCS defines the actor types and interfaces that actor code depends upon, the same way it owns the rest of the Native Type Universe, so there is no shared library for the targets to link against. The compiler carries no execution policy with those types; each target provides its own runtime that interprets them, the native Olivier runtime on one side and our Conclave edge platform, presently built on Cloudflare, on the other.

The core types form a vocabulary for describing actor behavior without committing to execution details. An ActorRef is an opaque handle for sending messages. How the message reaches its destination is the runtime’s concern. An ActorBehavior specifies how an actor responds to messages. How that specification executes depends on the target. This separation enables the “write once, deploy anywhere” property.

Readers familiar with Erlang/OTP, Akka, or Orleans will recognize these patterns. The discriminated union ActorEffect resembles the behavior return types in those systems. The SupervisionStrategy cases mirror OTP’s supervision tree options, explored in depth in Erlang Lessons in Fidelity. The novelty is the reach: the same patterns carried across compilation targets as different as native code and JavaScript on edge infrastructure.

// Olivier - actor types intrinsic to CCS, shared across targets
namespace Olivier

/// Marker for actor message types
type IActorMessage = interface end

/// Actor reference - opaque handle for message sending
[<Struct>]
type ActorRef<'Message when 'Message :> IActorMessage> = {
    Id: ActorId
    // Target-specific routing info is internal
}

/// Core actor behavior definition
type ActorBehavior<'State, 'Message when 'Message :> IActorMessage> = {
    /// Initial state factory
    Init: unit -> 'State
    
    /// Message handler - returns new state
    Receive: 'State -> 'Message -> ActorEffect<'State>
    
    /// Optional: handle actor lifecycle events
    Lifecycle: 'State -> LifecycleEvent -> ActorEffect<'State>
}

/// Effects an actor can produce (interpreted by runtime)
and ActorEffect<'State> =
    | Continue of 'State
    | ContinueWith of 'State * SideEffect list
    | Stop of reason: string
    | Restart of 'State
    | Escalate of exn

and SideEffect =
    | Tell of target: ActorId * message: obj
    | Spawn of spec: ActorSpec * replyTo: ActorId option
    | Schedule of delay: TimeSpan * message: obj
    | Log of level: LogLevel * message: string

/// Supervision strategy (same semantics, different implementations)
type SupervisionStrategy =
    | OneForOne of maxRestarts: int * window: TimeSpan
    | AllForOne of maxRestarts: int * window: TimeSpan
    | RestForOne of maxRestarts: int * window: TimeSpan

/// Actor specification for spawning
type ActorSpec = {
    Behavior: obj  // Type-erased ActorBehavior
    Supervision: SupervisionStrategy option
    Capabilities: Set<Capability>
    Name: string option
}

ActorBehavior is a pure function from state and message to effects. No I/O, no platform dependencies. The ActorEffect type describes what should happen, and the runtime interprets those effects according to target-specific semantics.

This effect-based design enables testability. An actor behavior can be tested by supplying state and messages, then inspecting the returned effects. No network, no storage, no platform: pure data in, pure data out. The runtime that eventually executes these effects is tested separately against the effect contract. The same purity makes actor behaviors candidates for formal verification, where SMT proofs can validate behavior properties at compile time.

Computation Expression Layer

While the types above fully specify actor behavior, writing handlers by manually constructing ActorEffect values is verbose. Clef’s computation expressions provide syntactic sugar that makes actor code read naturally. The actor { } builder transforms familiar constructs like return, let!, and custom operations into the underlying effect types.

Readers unfamiliar with Clef computation expressions can think of them as programmable syntax. The builder type defines how expressions within the { } block translate to method calls. This mechanism powers Clef’s async, seq, and query expressions; we apply the same technique to actor behaviors.

module Olivier.Builder

type ActorBuilder() =
    member _.Yield(_) = Continue Unchecked.defaultof<_>
    
    member _.Bind(effect: ActorEffect<'a>, f: 'a -> ActorEffect<'b>) =
        match effect with
        | Continue state -> f state
        | ContinueWith(state, effects) -> 
            match f state with
            | Continue s' -> ContinueWith(s', effects)
            | ContinueWith(s', effects') -> ContinueWith(s', effects @ effects')
            | other -> other
        | Stop reason -> Stop reason
        | Restart state -> Restart state
        | Escalate exn -> Escalate exn
    
    member _.Return(state) = Continue state
    
    member _.ReturnFrom(effect) = effect
    
    [<CustomOperation("tell")>]
    member _.Tell(state, target: ActorRef<'M>, message: 'M) =
        ContinueWith(state, [Tell(target.Id, box message)])
    
    [<CustomOperation("spawn")>]
    member _.Spawn(state, spec: ActorSpec) =
        ContinueWith(state, [Spawn(spec, None)])
    
    [<CustomOperation("scheduleOnce")>]
    member _.ScheduleOnce(state, delay: TimeSpan, message: 'M) =
        ContinueWith(state, [Schedule(delay, box message)])
    
    [<CustomOperation("stop")>]
    member _.Stop(_, reason: string) = Stop reason

let actor = ActorBuilder()

The custom operations (tell, spawn, scheduleOnce, stop) extend the base computation expression with actor-specific vocabulary. When a developer writes tell target message inside an actor { } block, the builder transforms this into a ContinueWith effect containing a Tell side effect. The syntactic lightness hides the underlying effect machinery.

Identical Usage Across Targets

With the abstraction and computation expression in place, the developer experience strives to be consistent regardless of compilation target. A counter actor looks the same whether it will run as native code in Fidelity or as JavaScript in a Cloudflare Durable Object. The types, the pattern matching, the effect operations should all be identical.

The counter below has three message types. Increment and Decrement modify state. GetCount queries state and sends a response to the provided reply address. The lifecycle handler acknowledges start, stop, and restart events without performing special actions. This behavior definition is complete and target-agnostic.

// Works identically in Fidelity (native) and Conclave (Cloudflare)
type CounterMessage =
    | Increment
    | Decrement
    | GetCount of replyTo: ActorRef<CountResponse>
    interface IActorMessage

type CountResponse =
    | Count of int
    interface IActorMessage

let counterBehavior = {
    Init = fun () -> 0
    
    Receive = fun count msg -> actor {
        match msg with
        | Increment -> 
            return count + 1
            
        | Decrement -> 
            return count - 1
            
        | GetCount replyTo ->
            tell replyTo (Count count)
            return count
    }
    
    Lifecycle = fun count event -> actor {
        match event with
        | PreStart -> 
            return count
        | PostStop ->
            return count
        | PreRestart reason ->
            return count
    }
}

This behavior definition is designed to compile to either target without modification. The business logic remains pure. Platform concerns belong to the runtime.

Fidelity/Olivier Runtime

In the Fidelity framework, actors execute as native code compiled through MLIR. The runtime builds on the MailboxProcessor model that Clef inherits from F#, a core-library actor type that serializes message delivery to a single logical thread. This serialization eliminates data races within an actor without requiring explicit locks. Dispatch of those serialized turns belongs to Ariel, cooperative below the turn, with Prospero’s remedies above it.

The implementation pairs each actor with a memory arena, a pre-allocated region from which the actor draws its allocations. When the actor terminates or restarts, the entire arena releases as a unit. This approach aligns with Fidelity’s RAII model, detailed in RAII in Olivier and Prospero, where resource lifetimes bind to continuation boundaries.

// Olivier.Runtime - Native compilation target
namespace Olivier.Runtime

open Olivier

/// Native actor instance with memory arena
type OlivierActor<'State, 'Message when 'Message :> IActorMessage>
    (behavior: ActorBehavior<'State, 'Message>, arena: MemoryArena) =
    
    let mutable state = behavior.Init()
    let mailbox = MailboxProcessor<'Message>.Start(fun inbox ->
        let rec loop () = async {
            let! msg = inbox.Receive()
            let effect = behavior.Receive state msg
            state <- interpretEffect effect
            return! loop()
        }
        loop()
    )
    
    interface IDisposable with
        member _.Dispose() =
            arena.Release()
    
    member _.Tell(msg: 'Message) = mailbox.Post(msg)
    member _.State = state

/// Prospero supervisor for native actors
type Prospero(strategy: SupervisionStrategy) =
    let children = ConcurrentDictionary<ActorId, IDisposable>()
    
    member _.Supervise(id: ActorId, actor: IDisposable) =
        children.TryAdd(id, actor) |> ignore
    
    member _.HandleFailure(id: ActorId, exn: exn) =
        match strategy with
        | OneForOne(max, window) ->
            restartActor id
        | AllForOne(max, window) ->
            children.Keys |> Seq.iter restartActor
        | RestForOne(max, window) ->
            restartFrom id

RAII cleanup happens at continuation boundaries. When an actor terminates or restarts, its memory arena releases deterministically. Our Prospero supervisor, named for the orchestrating figure in Shakespeare’s The Tempest, runs on an elevated thread and monitors child actors for failures. Its role is to apply supervision policies: restart the failed actor alone, restart all siblings, or restart actors spawned after the failed one. This actor-oriented architecture provides process-level protection without runtime overhead.

Conclave Runtime with WebSockets

Conclave targets edge infrastructure, with Cloudflare’s Durable Objects as the current substrate beneath our own actor layer. We surveyed the platform’s actors framework as a candidate foundation and found ergonomics where Olivier requires semantics: named instances, per-instance SQLite storage, multiple alarms, and hibernation-aware WebSocket handling, with no one-way send, no mailbox, and no supervision anywhere in it. That scoping is deliberate on their part, and their README states the mission as making Durable Objects easier. So the actor semantics remain ours to carry on this target, and we mine that repository for platform lessons instead of binding to it.

The overlap with our own design exposition is structural. Our three-layer contract works through a registry server: one stateful actor threading a name table across a pool of clients, with Register as a fire-and-forget Tell, adding no wait edge, and Lookup as the one synchronous round trip the liveness layer ranks. Cloudflare runs that example as infrastructure. Name-to-actor resolution is the platform’s addressing layer, and the actors framework carries a registry actor of its own, a tracking instance threading an instance table across every other actor’s registrations. A Durable Object runs the same shape the example types: one server, its state threading across a serialized pool of clients, the client race carried as the order the platform serves requests. What the platform’s registry lacks is the pair of contracts the worked example teaches. Its register leg is a dropped promise with the error swallowed, so admission has no verdict, and its lookup leg is a wait-for edge no rank ever checks. What Conclave adds is exactly those two contracts, on a shape the platform already runs. Where native actors run in OS processes, Conclave actors would exist as JavaScript objects within V8 isolates. The platform may evict these objects from memory during idle periods, a process called hibernation. When a message arrives for a hibernated actor, the platform instantiates a fresh object and rehydrates its state from durable storage. Hibernation is the transparent-activation model: a caller reads the same thing after a crash as after a sleep. We take up that distinction, and what it costs supervision, in Surfacing the Scheduler.

WebSocket connections serve as the transport layer. Each actor-to-actor relationship manifests as a persistent bidirectional channel. The Durable Object “accepts” the server side of a WebSocket pair, allowing it to receive messages and send responses without the overhead of HTTP request/response cycles.

The sketch below records the runtime’s job in that setting, interpreting ActorEffect values against platform facilities. Those facilities come from the Durable Object bindings our CloudEdge work already carries, and two disciplines govern them: messages cross as BAREWire frames on the socket layer, and platform RPC stays on the management plane for naming and instance lifecycle.

// Conclave.Runtime - WebSocket-based actor
namespace Conclave.Runtime

open Olivier
open Fable.Core

[<AttachMembers>]
type ConclaveActor<'State, 'Message when 'Message :> IActorMessage>
    (behavior: ActorBehavior<'State, 'Message>) =
    inherit DurableObject()
    
    let mutable state: 'State option = None
    let connections = Dictionary<ActorId, WebSocket>()
    
    /// Accept WebSocket upgrade for actor-to-actor communication
    member this.fetch(request: Request) = promise {
        let upgradeHeader = request.headers.get("Upgrade")
        
        if upgradeHeader = Some "websocket" then
            let pair = WebSocketPair.Create()
            let client = pair.Client
            let server = pair.Server
            
            this.ctx.acceptWebSocket(server)
            
            return Response.create(
                null,
                {| status = 101; webSocket = client |}
            )
        else
            return Response.json({| status = "healthy" |})
    }
    
    /// Handle incoming WebSocket messages
    member this.webSocketMessage(ws: WebSocket, message: string) = promise {
        // Hydrate state if needed (after hibernation)
        if state.IsNone then
            let! stored = this.ctx.storage.get<'State>("state")
            state <- stored |> Option.orElseWith (fun () -> Some (behavior.Init()))
        
        let actorMessage = deserialize<'Message> message
        
        let effect = behavior.Receive state.Value actorMessage
        let (newState, sideEffects) = interpretEffect effect
        
        do! this.ctx.storage.put("state", newState)
        state <- Some newState
        
        for effect in sideEffects do
            do! this.executeSideEffect(effect)
    }
    
    /// Handle WebSocket close
    member this.webSocketClose(ws: WebSocket, code: int, reason: string) =
        let actorId = findActorBySocket ws
        connections.Remove(actorId) |> ignore
    
    /// Execute side effects over WebSocket connections
    member private this.executeSideEffect(effect: SideEffect) = promise {
        match effect with
        | Tell(targetId, msg) ->
            match connections.TryGetValue(targetId) with
            | true, ws ->
                ws.send(serialize msg)
            | false, _ ->
                let! ws = this.connectToActor(targetId)
                connections.[targetId] <- ws
                ws.send(serialize msg)
                
        | Spawn(spec, replyTo) ->
            let! actorRef = this.spawnChild(spec)
            match replyTo with
            | Some ref -> 
                do! this.executeSideEffect(Tell(ref.Id, box (Spawned actorRef)))
            | None -> ()
            
        | Schedule(delay, msg) ->
            do! this.ctx.storage.setAlarm(DateTime.UtcNow + delay)
            do! this.ctx.storage.put("scheduled", msg)
            
        | Log(level, message) ->
            console.log($"[{level}] {message}")
    }
    
    /// Establish WebSocket connection to another actor
    member private this.connectToActor(targetId: ActorId) = promise {
        let targetStub = this.env.ACTORS.[targetId]
        let targetUrl = $"wss://{targetId}/ws"
        
        let! response = targetStub.fetch(
            Request.create(targetUrl, {| 
                headers = {| Upgrade = "websocket" |} 
            |})
        )
        
        return response.webSocket
    }
    
    /// Handle scheduled alarms
    member this.alarm() = promise {
        let! scheduled = this.ctx.storage.get<obj>("scheduled")
        match scheduled with
        | Some msg ->
            do! this.ctx.storage.delete("scheduled")
            do! this.webSocketMessage(null, serialize msg)
        | None -> ()
    }

Durable Object hibernation is handled transparently by the Cloudflare platform. When no messages arrive for a configured period, the platform evicts the object from memory but preserves WebSocket connections. Upon receiving a new message, the platform reinstantiates the object and delivers the message to the appropriate handler. State rehydrates lazily from storage when the handler first accesses it.

The alarm API enables scheduled message delivery. When an actor calls setAlarm, the platform invokes the alarm() method at approximately the specified time, at least once and with retries on failure, even if the object hibernated in the interim. Scheduled messages therefore need idempotent handling. This mechanism supports time-based behaviors such as heartbeat generation, retry scheduling, and session timeouts.

Channel Abstraction

Both runtimes need a way to send and receive byte streams. The channel abstraction captures this requirement without prescribing the underlying mechanism. A channel is bidirectional, persistent, and operates on raw bytes. The BAREWire encoding happens above this layer. The channel moves frames without interpreting their contents.

This separation allows Fidelity actors to communicate over IPC endpoints (OS-level primitives optimized for local communication) while Conclave actors use WebSockets (the only persistent connection mechanism available in the Cloudflare environment). Both present the same interface to actor code:

// Olivier.Channels - Transport abstraction
namespace Olivier.Channels

/// Bidirectional persistent channel between actors
/// Carries BAREWire-encoded frames over transport
type IActorChannel =
    abstract member Send: byte[] -> Async<unit>
    abstract member Receive: unit -> Async<byte[]>
    abstract member Close: unit -> Async<unit>
    abstract member IsOpen: bool

// Fidelity implementation: IPC transport
type IPCChannel(endpoint: IPC.Endpoint) =
    interface IActorChannel with
        member _.Send(data) = async {
            do! endpoint.WriteAsync(data)
        }
        member _.Receive() = async {
            return! endpoint.ReadAsync()
        }
        member _.Close() = async {
            endpoint.Dispose()
        }
        member _.IsOpen = endpoint.IsConnected

// Conclave implementation: WebSocket transport
type WebSocketChannel(ws: WebSocket) =
    let receiveQueue = Queue<byte[]>()
    let mutable pendingReceive: AsyncReplyChannel<byte[]> option = None
    
    do ws.addEventListener("message", fun e ->
        let data = e.data :?> ArrayBuffer |> toByteArray
        match pendingReceive with
        | Some reply ->
            reply.Reply(data)
            pendingReceive <- None
        | None ->
            receiveQueue.Enqueue(data)
    )
    
    interface IActorChannel with
        member _.Send(data) = async {
            ws.send(ofByteArray data)
        }
        member _.Receive() = async {
            if receiveQueue.Count > 0 then
                return receiveQueue.Dequeue()
            else
                return! Async.FromContinuations(fun (resolve, _, _) ->
                    pendingReceive <- Some { new AsyncReplyChannel<_> with
                        member _.Reply(v) = resolve v
                    }
                )
        }
        member _.Close() = async {
            ws.close()
        }
        member _.IsOpen = ws.readyState = WebSocketState.Open

The byte[] passed through these channels is always BAREWire-encoded. The channel implementation neither knows nor cares about message structure. Its responsibility is frame delivery under a stated contract. On the edge the platform offers two substrates with different contracts: RPC and socket frames arrive ordered per pair and at most once, with every send answered, while Cloudflare Queues delivers durably, at least once, and in no guaranteed order, so a queue-fed mailbox must deduplicate and resequence. A fabric that leaves the choice between those contracts implicit inherits one of them silently. We make the choice per edge and state it in the channel.

JavaScript WebSockets deliver messages through event callbacks, but Clef async workflows expect a pull-based model. The pendingReceive mechanism bridges that gap: if a receive request arrives before data, it stores a continuation. If data arrives before a receive request, it queues in the receive buffer. This pattern recurs in any system that bridges event-driven and continuation-based concurrency models.

Ask Pattern Over Persistent Connections

A fire-and-forget Tell suffices for many actor interactions, but some scenarios require request-response semantics. A counter actor that receives GetCount needs to send the current value back to the requester. Ask is the request-response counterpart: a send that carries a reply address and a deadline.

With bidirectional persistent connections already in place, implementing Ask is straightforward. The sender generates a correlation ID, includes it in the outbound message, and awaits a response with matching correlation. The channel’s persistent nature means no connection setup overhead per request. Messages flow over the existing pipe:

type ActorRef<'Message when 'Message :> IActorMessage> with
    
    member this.Ask<'Response>(message: 'Message, timeout: TimeSpan) : Async<'Response> =
        async {
            let correlationId = Guid.NewGuid()
            let! channel = ChannelFactory.Connect(this.Id)
            
            let envelope = {| 
                correlationId = correlationId
                payload = message 
                expectsReply = true
            |}
            do! channel.Send(serialize envelope)
            
            let! response = 
                channel.Receive()
                |> Async.withTimeout timeout
            
            let responseEnvelope = deserialize<ResponseEnvelope<'Response>> response
            
            if responseEnvelope.correlationId = correlationId then
                return responseEnvelope.payload
            else
                return! failwith "Correlation mismatch"
        }

The implementation differs between targets (BAREWire framing over IPC vs. WebSocket binary messages), but the developer API is identical. This uniformity means code that uses Ask works without modification across Fidelity and Conclave deployments.

In a distributed system, a response might never arrive: network partition, target actor failure, or processing delay all look the same from the caller’s seat. The withTimeout combinator transforms a potentially unbounded wait into a bounded operation that either succeeds with a value or fails with a timeout exception. Callers must decide how to handle timeout failures: retry, escalate, or proceed with a default value.

Supervision in Conclave

Supervision is the mechanism by which parent actors monitor and respond to child actor failures. In Erlang/OTP, this pattern forms the foundation of fault-tolerant systems: let components fail, then recover according to policy. The same concept applies to Conclave, with WebSocket connections serving as the monitoring channel.

This model has a second root, contemporary with Erlang and seldom named beside it. Barbara Liskov’s Argus, the distributed language she built after CLU, organized programs into guardians, and in her 2009 Turing Award lecture she described one as “fundamentally an object,” one guardian to a machine, encapsulating its state behind an interface that was the only way to reach it. A file server was a guardian, and all access to its files went through it. That is the actor’s encapsulation, named in a different decade. The recovery story is the part that most resembles OTP: guardians were restartable, and her account of the failure path is the supervision policy almost verbatim, “you fail the guardian, bring it back up in a clean state, and then you can go forward again.” Argus paired this with transactions and checkpoint rollback, machinery our actors do not carry. What our Olivier and Conclave supervision inherits, through Erlang and Syme’s MailboxProcessor, is the guardian’s other half, an encapsulated unit that fails and restarts clean.

When a supervisor spawns a child actor, it establishes a WebSocket connection to that child. The supervisor attaches event listeners for close and error events. If the child’s Durable Object crashes or becomes unreachable, the platform closes the WebSocket, triggering the supervisor’s failure handler. The handler then applies the configured strategy: restart only the failed child (one-for-one), restart all children (all-for-one), or restart the failed child and those spawned after it (rest-for-one).

type ConclaveSupervisor(strategy: SupervisionStrategy) =
    inherit DurableObject()
    
    let children = Dictionary<ActorId, WebSocket * ActorSpec>()
    
    member this.spawnChild(spec: ActorSpec) = promise {
        let! actor = this.env.loader.loadWorker {
            script = spec.implementation
            bindings = {|
                SUPERVISOR = this.env.SELF
            |}
            props = spec.Capabilities
        }
        
        let! ws = this.connectToActor(actor.id)
        children.[actor.id] <- (ws, spec)
        
        ws.addEventListener("close", fun _ ->
            this.handleChildDisconnect(actor.id)
        )
        ws.addEventListener("error", fun e ->
            this.handleChildError(actor.id, e)
        )
        
        return ActorRef(actor.id)
    }
    
    member private this.handleChildDisconnect(childId: ActorId) =
        match strategy with
        | OneForOne(maxRestarts, window) ->
            let (_, spec) = children.[childId]
            this.spawnChild(spec) |> ignore
            
        | AllForOne(maxRestarts, window) ->
            let specs = children.Values |> Seq.map snd |> List.ofSeq
            children.Clear()
            for spec in specs do
                this.spawnChild(spec) |> ignore
                
        | RestForOne(maxRestarts, window) ->
            ()

WebSocket close events trigger supervision logic. The platform handles the mechanics of connection monitoring. The supervisor handles policy decisions.

The restart limits (maxRestarts within a time window) prevent infinite restart loops. If a child fails repeatedly, the supervisor eventually gives up and escalates the failure to its own supervisor, or terminates if it has no parent. This backoff mechanism prevents a single buggy component from consuming unbounded resources through continuous respawning.

BAREWire as the Universal Protocol

Regardless of transport mechanism, all actor messages use BAREWire. BAREWire is our binary interchange contract, built on the external BARE encoding. Both endpoints derive their read and write code from the same message types at compile time, so a frame is compact and deterministic and neither side inspects a self-describing tag at runtime to discover what it received.

The message envelope wraps every actor-to-actor communication. It contains routing information (source and target actor IDs), optional correlation ID for request-response matching, message type discriminator, the payload itself, and a timestamp for debugging and ordering purposes.

// Olivier.Protocol - BAREWire message framing
namespace Olivier.Protocol

/// Actor message envelope (BAREWire-encoded on all targets)
type MessageEnvelope = {
    sourceId: ActorId
    targetId: ActorId
    correlationId: Guid option
    messageType: string
    payload: byte[]
    timestamp: int64
}

module ActorProtocol =
    let encode (envelope: MessageEnvelope) : byte[] =
        BAREWire.Codec.encode envelope
    
    let decode (data: byte[]) : MessageEnvelope =
        BAREWire.Codec.decode<MessageEnvelope> data

The transport is abstracted behind the channel interface. Fidelity writes BAREWire frames to IPC channels. Conclave writes the same BAREWire frames as WebSocket binary messages. The protocol is identical. Only the delivery mechanism changes.

A BAREWire message captured from a Fidelity IPC channel has the same structure as one captured from a Conclave WebSocket, so diagnostic tools work across both environments. If a future requirement involves bridging Fidelity and Conclave systems, the bridge only needs to handle transport translation.

Connection Topology

Actor systems naturally form graph structures as actors spawn children and establish peer relationships. A Tell-first architecture, combined with persistent WebSocket connections in Conclave, produces topologies where connections remain open for the lifetime of the relationship.

  graph TD
    subgraph "Conclave Actor System"
        SUP[Supervisor<br/>Prospero]
        
        SUP ---|WebSocket| A[Worker A<br/>Olivier]
        SUP ---|WebSocket| B[Worker B<br/>Olivier]
        SUP ---|WebSocket| D[Worker D<br/>Olivier]
        
        A ---|WebSocket| C[Worker C<br/>Olivier]
        B ---|WebSocket| C
    end
    

All connections are persistent WebSockets carrying BAREWire-encoded frames. The supervisor monitors all child connections through event listeners. Workers can establish direct connections for peer-to-peer communication when the interaction pattern benefits from bypassing the supervisor.

This topology emerges organically from actor relationships. When Actor A sends its first message to Actor B, a connection forms. Subsequent messages reuse that connection. The connection closes when either actor terminates or explicitly closes the channel. The platform (Cloudflare for Conclave, OS for Fidelity) handles the low-level details of connection maintenance, keepalives, and cleanup.

State Persistence Abstraction

State management represents the most significant divergence between targets. Fidelity actors hold state in memory, potentially backed by memory-mapped files for crash recovery. Conclave actors must persist state to Durable Object storage because the platform may evict them from memory at any time. Despite this fundamental difference, the abstraction presents a uniform interface.

The IActorState interface defines three operations: load the current state, save a new state, and execute a transactional update. The transactional operation is particularly important for Conclave, where state mutations must be atomic to maintain consistency across hibernation cycles.

// Olivier.State - Abstraction over state persistence
namespace Olivier.State

type IActorState<'State> =
    abstract member Load: unit -> Async<'State option>
    abstract member Save: 'State -> Async<unit>
    abstract member Transaction: ('State -> 'State * 'Result) -> Async<'Result>

// Fidelity implementation: synchronous, in-memory
type OlivierState<'State>(arena: MemoryArena) =
    let mutable state: 'State option = None
    
    interface IActorState<'State> with
        member _.Load() = async { return state }
        member _.Save(s) = async { state <- Some s }
        member _.Transaction(f) = async {
            let (newState, result) = f state.Value
            state <- Some newState
            return result
        }

// Conclave implementation: async, persistent
type DurableObjectState<'State>(storage: DurableObjectStorage) =
    interface IActorState<'State> with
        member _.Load() = async {
            let! stored = storage.get<'State>("state") |> Async.AwaitPromise
            return stored
        }
        member _.Save(s) = async {
            do! storage.put("state", s) |> Async.AwaitPromise
        }
        member _.Transaction(f) = async {
            return! storage.transaction(fun txn ->
                let current = txn.get<'State>("state")
                let (newState, result) = f current
                txn.put("state", newState)
                result
            ) |> Async.AwaitPromise
        }

Shared Edges Summary

The abstraction boundaries clarify what’s shared and what’s target-specific:

LayerSharedTarget-Specific
ActorBehaviorDefinitionInterpretation
ActorRefAPIRouting implementation
Tell/AskSemanticsChannel type
SupervisionStrategy typesPlatform integration
ProtocolBAREWire encoding-
Transport-IPC vs WebSocket
State PersistenceLoad/Save APIStorage backend
Channel AbstractionSend/ReceiveTransport binding

Addendum: Production-Ready .NET Actors

While the compiler-intrinsic actor types provide the unified abstraction for Fidelity and Conclave, developers working exclusively in .NET often ask: “How do we build production-ready actors with MailboxProcessor?” This addendum addresses that question with patterns that stand alone from the native compilation path.

The .NET Actor Landscape

ApproachRuntimeTransportUse Case
Fidelity/OlivierNative via MLIRIPC, shared memoryHigh-performance systems
Conclave/SPECFable → JS on CloudflareWebSocketEdge deployment
.NET MailboxProcessorStandard .NETConfigurableOn-premises, development, migration

A standalone .NET actor implementation serves multiple purposes:

  • On-premises deployment: Organizations that cannot use edge compute or native compilation
  • Development environment: Rapid prototyping before committing to a deployment target
  • Migration path: Existing .NET applications can adopt actor patterns incrementally
  • Reference patterns: Demonstrates production scaffolding without exotic compilation

The .NET Runtime

The .NET implementation demonstrates that the abstraction does not require exotic compilation targets. Standard F# on the .NET runtime provides everything needed: MailboxProcessor for message serialization, ConcurrentDictionary for thread-safe collections, and async workflows for non-blocking operations.

The DotNetActor class wraps a MailboxProcessor and adds the scaffolding that production systems require: error handling with configurable policies, resource tracking for automatic cleanup, metrics collection for observability, and supervisor integration for fault tolerance. The core message loop mirrors the Fidelity implementation but uses .NET’s managed memory model.

The code below addresses the complete lifecycle, from message receipt through effect interpretation to error handling, cleanup, and child supervision. Each concern is factored into a separate local function that the main loop coordinates:

// Standalone .NET actor implementation
namespace FSharp.Actors

open System.Collections.Concurrent

/// MailboxProcessor-backed actor with full scaffolding
type DotNetActor<'State, 'Message when 'Message :> IActorMessage>
    (behavior: ActorBehavior<'State, 'Message>,
     config: ActorConfig,
     metrics: ActorMetrics option,
     supervisor: ISupervisor option) =
    
    let mutable state = behavior.Init()
    let mutable restartCount = 0
    let resources = ConcurrentDictionary<ResourceId, IDisposable>()
    let children = ConcurrentDictionary<ActorId, DotNetActorRef>()
    
    let errorHandler = 
        config.ErrorPolicy 
        |> Option.defaultValue ErrorPolicy.defaultPolicy
    
    let mailbox = MailboxProcessor<ActorMessage<'Message>>.Start(fun inbox ->
        let rec loop () = async {
            let! envelope = inbox.Receive()
            
            match envelope with
            | UserMessage msg ->
                do! processMessage msg
                return! loop()
                
            | SystemMessage sysMsg ->
                match sysMsg with
                | Terminate reason ->
                    do! cleanup reason
                | Restart ->
                    do! restart()
                    return! loop()
                | ChildFailed(childId, exn) ->
                    do! handleChildFailure childId exn
                    return! loop()
        }
        and processMessage msg = async {
            let sw = Stopwatch.StartNew()
            try
                // Pre-receive hook for metrics
                metrics |> Option.iter (fun m -> m.MessagesReceived.Increment())
                
                // Apply behavior
                let effect = behavior.Receive state msg
                
                // Interpret effect
                let! newState = interpretEffect effect
                state <- newState
                
                // Post-receive metrics
                metrics |> Option.iter (fun m -> 
                    m.MessagesProcessed.Increment()
                    m.ProcessingTime.Record(sw.ElapsedMilliseconds))
                    
            with exn ->
                metrics |> Option.iter (fun m -> m.ErrorCount.Increment())
                do! handleError exn
        }
        and interpretEffect effect = async {
            match effect with
            | Continue newState -> 
                return newState
                
            | ContinueWith(newState, sideEffects) ->
                for effect in sideEffects do
                    do! executeSideEffect effect
                return newState
                
            | Stop reason ->
                do! cleanup reason
                return state
                
            | Restart newState ->
                restartCount <- restartCount + 1
                metrics |> Option.iter (fun m -> m.RestartCount.Increment())
                return newState
                
            | Escalate exn ->
                match supervisor with
                | Some sup -> sup.HandleFailure(selfId, exn)
                | None -> raise exn
                return state
        }
        and executeSideEffect effect = async {
            match effect with
            | Tell(targetId, msg) ->
                // Route through actor registry
                match ActorRegistry.tryFind targetId with
                | Some target -> target.Post(msg)
                | None -> 
                    // Remote actor - use transport
                    do! Transport.send targetId (BAREWire.encode msg)
                    
            | Spawn(spec, replyTo) ->
                let! childRef = spawnChild spec
                children.TryAdd(childRef.Id, childRef) |> ignore
                match replyTo with
                | Some ref -> 
                    do! executeSideEffect (Tell(ref.Id, box (Spawned childRef)))
                | None -> ()
                
            | Schedule(delay, msg) ->
                do! Async.Sleep(int delay.TotalMilliseconds)
                mailbox.Post(UserMessage (msg :?> 'Message))
                
            | Log(level, message) ->
                Logger.log level message
        }
        and handleError exn = async {
            match errorHandler exn state with
            | Resume -> ()
            | RestartWith newState ->
                state <- newState
                restartCount <- restartCount + 1
            | Escalate ->
                match supervisor with
                | Some sup -> sup.HandleFailure(selfId, exn)
                | None -> raise exn
            | ErrorPolicy.Stop reason ->
                do! cleanup reason
        }
        and cleanup reason = async {
            // Run cleanup hooks
            for KeyValue(_, resource) in resources do
                try resource.Dispose() with _ -> ()
            resources.Clear()
            
            // Terminate children
            for KeyValue(id, child) in children do
                child.Post(SystemMessage (Terminate reason))
            children.Clear()
        }
        and restart () = async {
            do! cleanup "restart"
            state <- behavior.Init()
        }
        and handleChildFailure childId exn = async {
            match config.Supervision with
            | Some (OneForOne(maxRestarts, window)) ->
                match children.TryGetValue(childId) with
                | true, child -> child.Post(SystemMessage Restart)
                | false, _ -> ()
                
            | Some (AllForOne(maxRestarts, window)) ->
                for KeyValue(_, child) in children do
                    child.Post(SystemMessage Restart)
                    
            | Some (RestForOne(maxRestarts, window)) ->
                // Restart from failed child onward
                let childList = children |> Seq.toList
                let idx = childList |> List.findIndex (fun kv -> kv.Key = childId)
                for kv in childList |> List.skip idx do
                    kv.Value.Post(SystemMessage Restart)
                    
            | None ->
                // No supervision strategy - escalate
                match supervisor with
                | Some sup -> sup.HandleFailure(selfId, exn)
                | None -> raise exn
        }
        
        loop()
    )
    
    let selfId = ActorId.generate()
    
    member _.Id = selfId
    member _.Tell(msg: 'Message) = mailbox.Post(UserMessage msg)
    member _.State = state
    
    /// Acquire a tracked resource
    member _.AcquireResource<'R when 'R :> IDisposable>(factory: unit -> 'R) =
        let resource = factory()
        let id = ResourceId.generate()
        resources.TryAdd(id, resource :> IDisposable) |> ignore
        resource
    
    interface IDisposable with
        member _.Dispose() =
            mailbox.Post(SystemMessage (Terminate "disposed"))

and ActorMessage<'T> =
    | UserMessage of 'T
    | SystemMessage of SystemMessage

and SystemMessage =
    | Terminate of reason: string
    | Restart
    | ChildFailed of ActorId * exn

The message types distinguish between user-originated messages and system-originated messages. User messages carry the application-defined payload type. System messages control the actor lifecycle: terminate, restart, or notify of child failure. This separation allows the message loop to handle lifecycle concerns without polluting the application message space.

Supervision Hierarchy

Supervision in the .NET implementation follows the same patterns as Fidelity and Conclave. A supervisor tracks its children, monitors for failures, and applies restart policies. For .NET we added explicit restart counting with time windows. If a child fails too frequently, the supervisor stops attempting recovery and either terminates the child permanently or escalates the failure.

The restart window prevents a perpetually failing child from consuming resources indefinitely. If five failures occur within one minute, and the policy allows only three restarts per minute, the supervisor stops the child after the third restart. The window then slides forward, eventually allowing new restart attempts if the underlying issue resolves.

/// .NET Supervisor implementation
type DotNetSupervisor(strategy: SupervisionStrategy) =
    let children = ConcurrentDictionary<ActorId, DotNetActorRef * ActorSpec>()
    let restartCounts = ConcurrentDictionary<ActorId, int * DateTime>()
    
    interface ISupervisor with
        member this.HandleFailure(childId: ActorId, exn: exn) =
            let (maxRestarts, window) = 
                match strategy with
                | OneForOne(m, w) -> (m, w)
                | AllForOne(m, w) -> (m, w)
                | RestForOne(m, w) -> (m, w)
            
            // Check restart limits
            let (count, windowStart) = 
                restartCounts.GetOrAdd(childId, fun _ -> (0, DateTime.UtcNow))
            
            if DateTime.UtcNow - windowStart > window then
                // Reset window
                restartCounts.[childId] <- (1, DateTime.UtcNow)
                this.ApplyStrategy(childId, exn)
            elif count < maxRestarts then
                restartCounts.[childId] <- (count + 1, windowStart)
                this.ApplyStrategy(childId, exn)
            else
                // Max restarts exceeded - terminate
                match children.TryGetValue(childId) with
                | true, (actor, _) -> 
                    actor.Post(SystemMessage (Terminate "max restarts exceeded"))
                | false, _ -> ()
        
        member _.Supervise(id: ActorId, actor: DotNetActorRef, spec: ActorSpec) =
            children.TryAdd(id, (actor, spec)) |> ignore
    
    member private this.ApplyStrategy(failedId: ActorId, exn: exn) =
        match strategy with
        | OneForOne _ ->
            match children.TryGetValue(failedId) with
            | true, (actor, _) -> actor.Post(SystemMessage Restart)
            | false, _ -> ()
            
        | AllForOne _ ->
            for KeyValue(_, (actor, _)) in children do
                actor.Post(SystemMessage Restart)
                
        | RestForOne _ ->
            let childList = children |> Seq.toList
            let idx = 
                childList 
                |> List.tryFindIndex (fun kv -> kv.Key = failedId)
                |> Option.defaultValue 0
            for kv in childList |> List.skip idx do
                let (actor, _) = kv.Value
                actor.Post(SystemMessage Restart)
    
    /// Spawn a child actor under this supervisor
    member this.Spawn<'State, 'Message when 'Message :> IActorMessage>
        (behavior: ActorBehavior<'State, 'Message>, ?config: ActorConfig) =
        
        let cfg = config |> Option.defaultValue ActorConfig.defaults
        let actor = new DotNetActor<'State, 'Message>(
            behavior, cfg, None, Some (this :> ISupervisor))
        
        children.TryAdd(actor.Id, (actor :> DotNetActorRef, { 
            Behavior = box behavior
            Supervision = cfg.Supervision
            Capabilities = Set.empty
            Name = None
        })) |> ignore
        
        actor

When spawning a child, the supervisor creates the actor with itself as the supervisor reference, adds the child to its tracking collection, and returns a reference that callers can use to send messages. The child actor, upon failure, will call back to this supervisor’s HandleFailure method.

Transport Options

The .NET implementation supports multiple transport mechanisms, selected based on deployment requirements. In-process transport uses thread-safe queues for actors within the same application domain. TCP transport enables distribution across machines. Named pipe transport offers a middle ground for inter-process communication on a single host.

Each transport implements the same ITransport interface, making the choice transparent to actor code. An actor system configured with TCP transport sends messages over the network. The same system configured with in-process transport keeps everything in memory. The actor behaviors remain unchanged.

/// Transport abstraction for .NET actors
type ITransport =
    abstract member Send: ActorId -> byte[] -> Async<unit>
    abstract member Receive: ActorId -> Async<byte[]>
    abstract member Connect: ActorId -> Async<IActorChannel>

/// In-process transport (same AppDomain)
type InProcessTransport() =
    let channels = ConcurrentDictionary<ActorId * ActorId, BlockingCollection<byte[]>>()
    
    interface ITransport with
        member _.Send targetId data = async {
            let selfId = ActorContext.currentId()
            let key = (selfId, targetId)
            let channel = channels.GetOrAdd(key, fun _ -> BlockingCollection<byte[]>())
            channel.Add(data)
        }
        member _.Receive sourceId = async {
            let selfId = ActorContext.currentId()
            let key = (sourceId, selfId)
            let channel = channels.GetOrAdd(key, fun _ -> BlockingCollection<byte[]>())
            return channel.Take()
        }
        member _.Connect targetId = async {
            return InProcessChannel(targetId) :> IActorChannel
        }

/// TCP transport for distributed .NET actors
type TcpTransport(bindAddress: IPEndPoint) =
    let listener = TcpListener(bindAddress)
    let connections = ConcurrentDictionary<ActorId, TcpClient>()
    
    interface ITransport with
        member _.Send targetId data = async {
            let! client = getOrConnect targetId
            let stream = client.GetStream()
            // BAREWire length-prefixed frame
            let length = BitConverter.GetBytes(data.Length)
            do! stream.AsyncWrite(length, 0, 4)
            do! stream.AsyncWrite(data, 0, data.Length)
        }
        member _.Receive sourceId = async {
            let! client = getOrConnect sourceId
            let stream = client.GetStream()
            let lengthBuf = Array.zeroCreate 4
            let! _ = stream.AsyncRead(lengthBuf, 0, 4)
            let length = BitConverter.ToInt32(lengthBuf, 0)
            let data = Array.zeroCreate length
            let! _ = stream.AsyncRead(data, 0, length)
            return data
        }
        member _.Connect targetId = async {
            let! client = getOrConnect targetId
            return TcpChannel(client) :> IActorChannel
        }

/// Named pipe transport for local IPC
type NamedPipeTransport(pipeName: string) =
    interface ITransport with
        // Similar pattern using NamedPipeServerStream/NamedPipeClientStream
        member _.Send targetId data = async { (* ... *) }
        member _.Receive sourceId = async { (* ... *) }
        member _.Connect targetId = async { (* ... *) }

The TCP transport uses length-prefixed framing for BAREWire messages. Each frame begins with a 4-byte integer indicating the payload length, followed by the payload itself. This simple framing protocol allows the receiver to read exactly one message at a time from the TCP stream, avoiding the complexity of delimiter-based parsing.

Named pipes serve scenarios where multiple processes on the same machine need to communicate. They offer better performance than TCP loopback (avoiding network stack overhead) while providing the same stream semantics. Windows and Unix implement named pipes differently, but the .NET abstraction presents a uniform interface.

System Builder

Constructing an actor system involves multiple configuration decisions: which transport to use, whether to collect metrics, what default supervision strategy to apply. A fluent builder API consolidates these decisions into a readable chain of method calls. The builder pattern also provides sensible defaults, reducing boilerplate for simple cases while allowing full customization when needed.

/// Build complete actor systems with scaffolding
type ActorSystemBuilder() =
    let mutable transport: ITransport = InProcessTransport() :> ITransport
    let mutable metrics: IMetricsProvider option = None
    let mutable defaultSupervision = OneForOne(3, TimeSpan.FromMinutes(1.0))
    
    member this.WithTransport(t: ITransport) =
        transport <- t
        this
    
    member this.WithMetrics(m: IMetricsProvider) =
        metrics <- Some m
        this
    
    member this.WithDefaultSupervision(s: SupervisionStrategy) =
        defaultSupervision <- s
        this
    
    member this.Build() =
        ActorSystem(transport, metrics, defaultSupervision)

and ActorSystem(transport, metrics, defaultSupervision) =
    let registry = ConcurrentDictionary<ActorId, obj>()
    let rootSupervisor = DotNetSupervisor(defaultSupervision)
    
    /// Spawn a top-level actor
    member this.Spawn<'State, 'Message when 'Message :> IActorMessage>
        (behavior: ActorBehavior<'State, 'Message>, ?config: ActorConfig) =
        
        let cfg = config |> Option.defaultValue ActorConfig.defaults
        let actorMetrics = 
            metrics |> Option.map (fun m -> m.CreateActorMetrics(typeof<'Message>.Name))
        
        let actor = new DotNetActor<'State, 'Message>(
            behavior, cfg, actorMetrics, Some (rootSupervisor :> ISupervisor))
        
        registry.TryAdd(actor.Id, actor) |> ignore
        rootSupervisor.Supervise(actor.Id, actor, cfg)
        
        ActorRef<'Message>(actor.Id)
    
    /// Create a supervisor for a subtree
    member this.CreateSupervisor(?strategy: SupervisionStrategy) =
        let strat = strategy |> Option.defaultValue defaultSupervision
        DotNetSupervisor(strat)

/// Convenience functions
module ActorSystem =
    let create() = ActorSystemBuilder()
    
    let spawn behavior (system: ActorSystem) =
        system.Spawn(behavior)
    
    let spawnWith behavior config (system: ActorSystem) =
        system.Spawn(behavior, config)

The companion module provides functional-style wrappers around the object-oriented builder. These wrappers enable pipeline composition with the |> operator, a common Clef idiom that reads left-to-right.

Usage Example

Developers ask for patterns that handle errors, manage resources, and provide observability without scattering boilerplate throughout handler code.

The answer combines a behavior definition with the ActorSystem builder. The behavior contains business logic. The system provides operational scaffolding. Error policies, backpressure limits, and supervision strategies are declarative configuration, not imperative code mixed into handlers.

open FSharp.Actors

// Define messages
type CounterMsg =
    | Increment
    | Decrement
    | GetCount of AsyncReplyChannel<int>

// Define behavior
let counterBehavior = {
    Init = fun () -> 0
    Receive = fun count msg -> actor {
        match msg with
        | Increment -> return count + 1
        | Decrement -> return count - 1
        | GetCount reply ->
            reply.Reply(count)
            return count
    }
    Lifecycle = fun count _ -> actor { return count }
}

// Build system with full scaffolding
let system = 
    ActorSystem.create()
        .WithTransport(InProcessTransport())
        .WithMetrics(PrometheusMetrics())
        .WithDefaultSupervision(OneForOne(5, TimeSpan.FromMinutes(1.0)))
        .Build()

// Spawn actors
let counter1 = system.Spawn(counterBehavior)
let counter2 = system.Spawn(counterBehavior, { 
    ActorConfig.defaults with
        ErrorPolicy = Some (RestartWith 0)
        BackpressurePolicy = Some (DropOldest 1000)
})

// Use actors
counter1.Tell(Increment)
counter1.Tell(Increment)

// Ask pattern
let! count = counter1.Ask(GetCount, TimeSpan.FromSeconds(5.0))
printfn "Count: %d" count

// Supervision example
let supervisor = system.CreateSupervisor(AllForOne(3, TimeSpan.FromMinutes(1.0)))

let worker1 = supervisor.Spawn(workerBehavior)
let worker2 = supervisor.Spawn(workerBehavior)
// If worker1 fails, both restart

Migration Path

For existing .NET applications using raw MailboxProcessor:

// Before: Raw MailboxProcessor with manual error handling
let oldActor = MailboxProcessor.Start(fun inbox ->
    let rec loop state = async {
        try
            let! msg = inbox.Receive()
            match msg with
            | Increment -> return! loop (state + 1)
            | Decrement -> return! loop (state - 1)
        with exn ->
            // Manual error handling scattered everywhere
            printfn "Error: %A" exn
            return! loop state
    }
    loop 0
)

// After: Scaffolded actor with declarative policies
let newActor = 
    system.Spawn(counterBehavior, {
        ActorConfig.defaults with
            ErrorPolicy = Some Resume
            BackpressurePolicy = Some (DropOldest 1000)
    })

Existing .NET code migrates to the scaffolded model, gaining supervision, error policies, and observability without rewriting business logic. While these patterns are inspired by the intrinsic actor abstraction, they stand alone for .NET-only deployments.

Transport Evolution: Media over QUIC

WebSockets provide the foundation for Conclave actor communication, but they have inherent limitations for high-throughput, cross-system messaging. Cloudflare’s Media over QUIC (MoQ) work is aimed at exactly those limitations, and we track it as a candidate transport for actor system interconnection.

Why MoQ Matters Beyond Media

Despite the name, MoQ is not limited to audio/video. The protocol provides capabilities that benefit any dense messaging stream:

CapabilityWebSocket LimitationMoQ Advantage
Head-of-line blockingSingle ordered streamMultiplexed independent streams
Connection migrationDrops on network changeQUIC handles seamlessly
Priority/orderingApplication must manageProtocol-level support
Pub/sub topologyPoint-to-pointNative track-based fanout
Congestion controlTCP-basedQUIC adaptive

For actor systems with dense message flows, these differences compound. A supervision hierarchy with dozens of actors exchanging frequent state updates suffers under WebSocket’s single-stream model. MoQ’s multiplexed streams eliminate the head-of-line blocking that creates latency spikes.

Cross-System Actor Topology

We imagine a MoQ relay as the backbone connecting actor systems across deployment boundaries:

  graph TB
    subgraph "Fidelity System"
        F_SUP[Prospero<br/>Supervisor]
        F_A[Olivier<br/>Actor A]
        F_B[Olivier<br/>Actor B]
        F_SUP --- F_A
        F_SUP --- F_B
    end
    
    subgraph "Cloudflare MoQ Relay"
        RELAY[MoQ Relay<br/>320+ Locations]
    end
    
    subgraph "Conclave System"
        C_SUP[Supervisor<br/>Durable Object]
        C_A[Worker<br/>Actor A]
        C_B[Worker<br/>Actor B]
        C_SUP --- C_A
        C_SUP --- C_B
    end
    
    subgraph ".NET System"
        N_SUP[DotNetSupervisor]
        N_A[DotNetActor A]
        N_B[DotNetActor B]
        N_SUP --- N_A
        N_SUP --- N_B
    end
    
    F_SUP ===|MoQ Track| RELAY
    RELAY ===|MoQ Track| C_SUP
    RELAY ===|MoQ Track| N_SUP
    

Each system maintains its internal transport (IPC for Fidelity, WebSocket for Conclave, TCP/pipes for .NET), while the MoQ relay would carry cross-system communication. BAREWire remains the protocol. MoQ would be one more transport option.

Track-Based Actor Addressing

MoQ’s track model maps naturally to actor addressing:

// Olivier.Transport.MoQ
namespace Olivier.Transport

/// MoQ track corresponds to an actor's message stream
type MoQActorTrack = {
    Namespace: string      // System identifier: "fidelity", "conclave", "dotnet"
    ActorId: ActorId       // Target actor
    TrackName: string      // Derived from actor address
}

/// MoQ transport implementation
type MoQTransport(relayUrl: string) =
    let session = MoQSession(relayUrl)
    let subscriptions = ConcurrentDictionary<ActorId, MoQSubscription>()
    
    interface ITransport with
        member _.Send(targetId: ActorId) (data: byte[]) = async {
            let track = deriveTrack targetId
            
            // Publish BAREWire frame to actor's track
            let! publisher = session.GetOrCreatePublisher(track)
            do! publisher.PublishObject {
                GroupId = nextGroupId()
                ObjectId = nextObjectId()
                Payload = data  // BAREWire-encoded message
                Priority = Priority.Normal
            }
        }
        
        member _.Subscribe(sourceId: ActorId) (handler: byte[] -> Async<unit>) = async {
            let track = deriveTrack sourceId
            
            let! subscription = session.Subscribe(track)
            subscriptions.TryAdd(sourceId, subscription) |> ignore
            
            // Receive loop
            subscription.OnObject.Add(fun obj ->
                handler obj.Payload |> Async.Start
            )
        }
        
        member _.Connect(targetId: ActorId) = async {
            let track = deriveTrack targetId
            return MoQChannel(session, track) :> IActorChannel
        }

/// Derive MoQ track from actor address
let deriveTrack (actorId: ActorId) : MoQActorTrack =
    let parts = actorId.ToString().Split('/')
    {
        Namespace = parts.[0]      // System: fidelity/conclave/dotnet
        ActorId = actorId
        TrackName = $"actor/{parts.[1]}/{parts.[2]}"  // actor/supervisor/instance
    }

The track derivation function maps actor addresses to MoQ track identifiers. The namespace component identifies which system originated the actor (Fidelity, Conclave, or .NET), enabling cross-system routing. The track name encodes the actor hierarchy, allowing MoQ relays to optimize routing decisions.

Multiplexed Actor Streams

Head-of-line blocking is a limitation of single-stream protocols. When multiple logical channels share one TCP connection (as with HTTP/2 or WebSocket), a delay in processing one message blocks all subsequent messages regardless of their destination. MoQ eliminates this problem by mapping each logical channel to an independent QUIC stream.

For actor systems, this translates directly: each actor-to-actor relationship gets its own stream. A supervisor monitoring 50 child actors receives heartbeats on 50 independent streams. A slow heartbeat from one child does not delay processing of heartbeats from other children. This independence is particularly valuable for supervision, where timely failure detection prevents cascading outages.

/// Supervisor using MoQ for high-throughput child monitoring
type MoQSupervisor(strategy: SupervisionStrategy, transport: MoQTransport) =
    let children = ConcurrentDictionary<ActorId, MoQSubscription>()
    
    member this.SuperviseChild(childId: ActorId) = async {
        // Each child gets its own MoQ track subscription
        // No head-of-line blocking between children
        do! transport.Subscribe childId (fun heartbeat ->
            async {
                let msg = BAREWire.decode<HeartbeatMessage> heartbeat
                match msg with
                | Alive timestamp -> 
                    this.RecordHeartbeat(childId, timestamp)
                | Failed exn ->
                    this.HandleFailure(childId, exn)
            }
        )
    }
    
    member this.BroadcastToChildren(msg: byte[]) = async {
        // Parallel publish to all child tracks
        // MoQ relay handles fanout efficiently
        let! _ = 
            children.Keys
            |> Seq.map (fun childId -> transport.Send childId msg)
            |> Async.Parallel
        ()
    }

Fanout is the other draw. When a supervisor sends a configuration update to all children, it publishes once to its track, and the MoQ relay would carry the distribution. Children subscribed to that track receive the update without the supervisor maintaining individual connections to each child. This pub/sub model reduces connection count and simplifies topology management.

Priority and Ordering

Traditional actor systems handle priority at the application level, typically through multiple mailboxes or priority queues within a single mailbox. MoQ provides priority at the transport level, allowing urgent messages to preempt less critical traffic during congestion.

Consider a system under load: normal business messages accumulate, but a supervisor needs to send a shutdown command immediately. With application-level priority, the command enters the same queue as business messages and waits its turn. With MoQ priority, the transport layer ensures the command transmits ahead of queued traffic. The difference matters in failure scenarios where timely intervention prevents data loss or cascade failures.

type ActorPriority =
    | Supervision    // Highest: supervisor commands, failure notifications
    | Control        // High: lifecycle events, configuration
    | Normal         // Standard: business messages
    | Bulk           // Low: batch data, logs
    | Background     // Lowest: metrics, telemetry

/// Priority-aware message sending
let sendWithPriority (transport: MoQTransport) priority targetId msg = async {
    let moqPriority = 
        match priority with
        | Supervision -> Priority.Urgent
        | Control -> Priority.High
        | Normal -> Priority.Normal
        | Bulk -> Priority.Low
        | Background -> Priority.Lowest
    
    do! transport.SendWithPriority targetId msg moqPriority
}

// Supervision messages preempt normal traffic
do! sendWithPriority transport Supervision childId (Terminate "shutdown")

The priority mapping is explicit and deterministic. Supervision messages always receive urgent priority. Control messages (lifecycle events, configuration changes) receive high priority. Normal business traffic flows at standard priority. Bulk operations (batch data transfers, log shipping) and background tasks (metrics collection, health checks) receive progressively lower priority. The hierarchy is designed so that operational concerns never starve behind application traffic.

Transport Selection Strategy

Not every message needs MoQ. Intra-system communication within a single Fidelity deployment uses IPC, which offers lower latency than any network protocol. Simple cross-system interactions with low message rates work fine over WebSocket. MoQ adds value for dense streams, real-time requirements, and scenarios where multiplexing benefits outweigh protocol overhead.

The transport selector encapsulates this decision logic. Given a message pattern (intra-system, cross-system light, cross-system heavy), it returns the appropriate transport. The selection happens once when establishing a communication path. Subsequent messages flow over the selected transport without repeated decision overhead.

type TransportSelector = {
    /// Within same system
    IntraSystem: ITransport
    /// Cross-system, low throughput
    InterSystemLight: ITransport
    /// Cross-system, high throughput
    InterSystemHeavy: ITransport
}

let defaultSelector = {
    IntraSystem = 
        match currentSystem with
        | Fidelity -> IPCTransport()
        | Conclave -> WebSocketTransport()
        | DotNet -> InProcessTransport()
    
    InterSystemLight = WebSocketTransport()  // Simple cases
    InterSystemHeavy = MoQTransport(moqRelayUrl)  // Dense streams
}

/// Route based on message pattern
let selectTransport (pattern: MessagePattern) (selector: TransportSelector) =
    match pattern with
    | IntraSystem -> selector.IntraSystem
    | CrossSystem Low -> selector.InterSystemLight
    | CrossSystem High -> selector.InterSystemHeavy
    | CrossSystem (Streaming _) -> selector.InterSystemHeavy

The default selector illustrates practical choices. Within a system, use the fastest available option. For lightweight cross-system traffic (occasional control messages, infrequent state synchronization), WebSocket suffices. For heavy cross-system traffic (continuous telemetry, real-time data feeds, dense actor interactions), engage MoQ.

Preparing for MoQ Availability

Cloudflare’s MoQ relay infrastructure is maturing. The transport abstraction allows systems to prepare now, using WebSocket as a fallback while MoQ capabilities emerge. The compatibility transport wraps both options and selects based on availability and message characteristics.

// Current: WebSocket fallback with MoQ-like interface
type MoQCompatTransport(wsUrl: string, moqRelayUrl: string option) =
    let ws = WebSocketTransport(wsUrl)
    let moq = moqRelayUrl |> Option.map MoQTransport
    
    interface ITransport with
        member _.Send targetId data = async {
            match moq with
            | Some m when isHighThroughput targetId ->
                // Use MoQ when available and beneficial
                do! m.Send targetId data
            | _ ->
                // Fall back to WebSocket
                do! ws.Send targetId data
        }
        // ...

When Cloudflare publishes MoQ relay APIs, the transport implementation updates without changing actor code. The abstraction boundary is clean: actors speak BAREWire, transports move bytes, and the selection logic determines which transport handles each communication path.

Real-Time AI Audio Pipeline

The motivation for tracking MoQ development extends beyond general messaging efficiency. Real-time AI audio services present particularly demanding requirements: continuous bidirectional streams, strict latency budgets, and multiple concurrent processing stages.

Consider a voice agent pipeline where audio flows from client through multiple processing stages and returns as synthesized speech. Each stage is an actor: ingestion normalizes incoming audio, ASR converts speech to text, the language model generates a response, and TTS synthesizes the reply. Audio frames arrive at approximately 50 packets per second (20ms frame intervals). Any delay compounds across stages, degrading perceived responsiveness.

WebSocket’s head-of-line blocking creates jitter in this scenario. If the ASR stage delays processing one frame, subsequent frames queue behind it even though they’re destined for independent processing paths. MoQ’s multiplexed streams eliminate this interference: each stage-to-stage connection operates independently, maintaining consistent latency under varying load.

type VoiceAgentPipeline() =
    let ingestActor = spawn ingestBehavior
    let asrActor = spawn asrBehavior  
    let llmActor = spawn llmBehavior
    let ttsActor = spawn ttsBehavior
    
    // Audio streams use MoQ for low-latency delivery
    let audioTransport = MoQTransport(moqRelayUrl)
    
    // Control messages can use WebSocket
    let controlTransport = WebSocketTransport(wsUrl)
    
    member _.ProcessAudioFrame(frame: AudioFrame) = async {
        // MoQ stream: low latency, independent of control traffic
        do! audioTransport.Send ingestActor.Id (BAREWire.encode frame)
    }
    
    member _.UpdateConfiguration(config: AgentConfig) = async {
        // WebSocket: lower throughput, shared with other control
        do! controlTransport.Send llmActor.Id (BAREWire.encode config)
    }

This architecture is designed to carry dense audio streams alongside sparse control messages, each on the appropriate transport, all encoded as BAREWire. The audio path would use MoQ for its multiplexing and priority capabilities. The control path uses WebSocket because control messages are infrequent and latency-tolerant. Both paths remain compatible because the protocol layer is uniform. Only transport selection differs.

The pipeline also illustrates actor model benefits for AI workloads, a theme we have explored elsewhere. Each stage scales independently. If ASR becomes a bottleneck, spawn additional ASR actors and distribute incoming frames. If the LLM stage requires GPU acceleration, deploy that actor to hardware-equipped nodes while keeping other stages on standard compute. The actor boundaries create natural scaling points without requiring monolithic system redesign.

Temporal Distinction

Considering protocol separately from transport enables a clear actor architecture across different runtimes. BAREWire provides the common language, and the transport adapts to each platform’s strengths. The scheduler contract is the same separation applied to time: dispatch discharged by Ariel where we own the loop, assumed from the platform where we do not. By defining actor behaviors as pure functions that produce effects, and letting target-specific runtimes interpret those effects, we achieve a consistent developer experience without sacrificing platform-appropriate optimizations.

Developers write actor logic once. The same actor { } computation expression is designed to compile to our Olivier actors communicating over IPC in Fidelity, to edge actors in Conclave, presently realized on Durable Objects over WebSocket, or to MailboxProcessor actors communicating over TCP in standard .NET. All speak BAREWire. Business logic remains portable. Transport concerns stay in the runtime layer.

We intend the payoff to be practical. Actor behaviors test once against the effect contract. Deployment targets admit gradual migration, and a team working across both frameworks carries one mental model. As MoQ matures, the transport selection layer can incorporate it without disturbing actor code or protocol definitions. The work continues toward that separation of protocol and transport, and I expect the actor abstraction to keep earning its place as more of the constellation comes online.