Vertep

The actor runtime

vertep-runtime turns a chart into a durable actor: addressed by an IRI, fed by a mailbox, run one macrostep per turn, and made durable by one journal entry per turn. This page covers the lifecycle classes, sending, mailboxes and transports, the journal and its storage, durable timers, time travel and chart migrations.

A shell around a pure step

The runtime is an imperative shell around the pure, no_std step of vertep-core. Everything that waits, retries, stores or talks to the network lives in the shell. Nothing inside step awaits, reads a clock or draws a random number. Time enters as the timestamp the shell stamps on each event when it is dequeued, and that timestamp is journaled. This one split is what makes replay deterministic and lets the Lean model check every macrostep.

One turn of a virtual actor, as crates/vertep-runtime/src/node.rs runs it:

  1. Receive the oldest message that no active state defers.
  2. Drop it if its message id is already in the journal, if it is a second reply to one request, or if it is a timer that was cancelled.
  3. Stamp the time and run one macrostep (the very first entry also runs the start macrostep).
  4. Append the entry with compare-and-set at the expected position, together with the current-state change and the fencing token. On a conflict, step down and send nothing.
  5. Acknowledge the message. Then dispatch the recorded sends (the outbox), arm timers, start and cancel invocations, record dead letters, and write a snapshot every N entries.
One turn: a message leaves the mailbox, is checked against the journal, runs one macrostep, is appended with compare-and-set, and only then are its sends dispatched through the directory and a transport into another mailbox Mailbox Seen message id? step one macrostep Journal append(n, n+1) Outbox Directory Transport Mailbox of the receiver yes: ack and drop committed: ack, then dispatch resolve IRI deliver, retry until accepted
Plate III. One turn. The append is the commit point; nothing leaves before it.

A first node

The facade crate vertep re-exports the runtime and the tiramemsu backend under its default features (runtime, tiramemsu, scxml). Its quickstart, crates/vertep/examples/quickstart.rs, opens a tiramemsu directory, registers an order chart as a chart type, and sends one event to a virtual actor. Abridged:

const ORDER: &str = r#"<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="open">
  <state id="open">
    <transition event="pay" target="paid"><send event="ship" delay="200ms"/></transition>
  </state>
  <state id="paid"><transition event="ship" target="shipped"/></state>
  <state id="shipped"/>
</scxml>"#;

const ORDER_TYPE: &str = "https://example.org/charts/order";

let backend = Arc::new(TiramemsuBackend::open(&dir, 1)?);
let mut node = Node::builder(backend.clone(), Shell::tokio()).build();
let chart = vertep::scxml::read_str(ORDER).map_err(|e| format!("{e:?}"))?.chart;
let version = node.register_type(ORDER_TYPE, chart)?;

// A virtual actor exists for every key: no create call, the first message activates it.
let order = Address::virtual_actor(ORDER_TYPE, "42")?;
node.send(&order, Event::named("pay", EventType::External))?;

// ... run the node for 400 ms on a LocalSet, then:
let now = node.state(&order)?;
let then = node.state_at(&order, 1)?;   // time travel by replay

Run it with cargo run -p vertep --example quickstart:

chart version urn:vertep:chart:sha256:6b74f6c5d780053619e9d360bbd73331f41fd52709d0bb7f5310d175b1272b08
now: ["shipped"] after 2 entries
  entry 1: pay (timers scheduled: 1)
  entry 2: ship (timers scheduled: 0)
as of entry 1: ["paid"]
  tiramemsu tx 2: in ["paid"]
  tiramemsu tx 3: in ["paid"]
  tiramemsu tx 4: in ["paid"]
  tiramemsu tx 5: in ["shipped"]
  tiramemsu tx 6: in ["shipped"]

The delayed ship was written into entry 1 as a durable timer and fired 200 ms later as an ordinary input, entry 2. The last lines read the current-state graph as it was after each tiramemsu transaction, without replay. The runnable examples on the examples page include a durable actor that survives a process restart.

A Node runs on one thread, because datamodels need not be Send. Run it inside a tokio::task::LocalSet and talk to it from elsewhere through node.handle(), a NodeHandle that is Send + Sync. For tests, Shell::virtual_time(start_ms) returns a shell and a VirtualClock you advance by hand, and node.run_until_idle() drives the node without async.

Lifecycle classes

Every actor has one absolute IRI address and belongs to one of three lifecycle classes. They differ in who owns the actor's lifetime and where its state lives.

Virtual actorInvoked actorStateless worker
Address<chart type>/<key><parent>/invoke/<id>the pool's address
Existsalways, logicallywhile the invoking state is activewhile the pool is registered
Stateits own journal and snapshotsthrough its parentnone; the caller journals results
Reached by<send>, vt:requestthe parent; #_parent back<send>, vt:request
Activationsat most one per nodeoneup to the pool size
Created withregister_type, then any message<invoke>register_worker

Virtual actors are activated by a message or a due timer: the runtime loads the latest snapshot, replays the entries after it, and checks each entry's configuration digest. An actor idle for idle_timeout_ms (60 s by default) with nothing deliverable is deactivated; its state does not depend on staying in memory. Address::virtual_actor(chart_type, key) builds the address, and the same pair always gives the same address.

A virtual actor cannot be the target of <invoke>, because no state owns its lifetime and cancelling a parent state must never stop a shared actor. register_type refuses a chart whose <invoke> names a virtual actor (RegisterError::InvokesVirtualActor).

Invoked actors start when the invoking state is entered and are cancelled when it exits, as SCXML defines. Besides charts, node.register_actor_kind(invoke_type, factory) lets <invoke type="…"> start a function actor (FnActor) or a stream actor (StreamActor). An <invoke> may carry a restart policy, read from crates/vertep-runtime/src/chart.rs:

AttributeValuesDefault
vt:restartnever, on-failure, alwaysnever
vt:restart-limitrestarts allowed within the window3
vt:restart-windowa duration60s
vt:restart-backoffthe first wait; the n-th restart waits base × 2^(n-1)100ms

When the limit is exceeded, the parent receives error.execution. Supervision follows the <invoke> tree, so there is no separate supervisor hierarchy. Virtual actors are not supervised this way: a failed turn leaves the journal unchanged, and the next message activates the actor again after failure_backoff_ms.

Stateless workers suit LLM calls, tool execution and heavy computation. node.register_worker(address, pool, handler) registers a pool of activations that run an async function. Nothing a worker does is journaled; its reply is journaled by the caller as an input, so replaying the caller never runs the work again.

Sending and requests

From outside the actor system, node.send(&to, event) or handle.send(&to, event) delivers an event and returns its message id. Inside a chart, <send> does the same, with _event.origin set to the sender's address so <send targetexpr="_event.origin"> always replies to the right actor.

A chart never blocks. To ask and wait for an answer, a state declares a <vt:request> element. This chart is from crates/vertep-runtime/tests/messaging.rs:

<state id="idle"><transition event="quote" target="quoting"/></state>
<state id="quoting">
  <vt:request target="https://example.org/charts/pricer/b" event="price.get"
              timeout="5s" reply="priced" ontimeout="failed"/>
  <transition event="ping"/>
</state>
<state id="priced"/>
<state id="failed"/>

Before compiling, the runtime lowers this into plain SCXML: a <send> on entry, a generated waiting substate, a transition on vt:reply to priced and one on error.timeout to failed. Other events are handled normally while the request is open. The replier answers with an ordinary send of the event vt:reply:

<state id="s">
  <transition event="price.get">
    <send event="vt:reply" targetexpr="_event.origin"><content expr="({price: 42})"/></send>
  </transition>
</state>

Code outside the actor system, such as an HTTP handler or a test, may block: handle.ask(&to, event, timeout).await sends the event and waits for its vt:reply, returning AskError::Timeout if none arrives in time.

Deferred and unhandled events

SCXML discards an external event that enables no transition. Actors must not lose messages that way, so a state can defer events by descriptor, and each chart chooses what happens to the rest. From tests/messaging.rs:

<state id="busy" vt:defer="order.*">
  <transition event="finish" target="idle"/>
</state>

While busy is active, an order.cancel message stays in the mailbox, in order, and is offered again once the actor leaves busy. Deferral lives in the mailbox, not in step, so the SCXML core and its conformance suite are unchanged.

The vt:unhandled attribute on <scxml> sets the policy for events that enable nothing and are not deferred:

ValueEffectDefault for
discarddrop it, as SCXML doesplain sessions
dead-letterrecord a vt:DeadLetter in the receiver's graphvirtual actors
errorraise error.unhandled in the chart with the event as data

Dead letters are graph data with sender, event, payload, reason and time. node.dead_letters(&address) lists them.

Mailboxes, transports and the directory

An address resolves through a directory to a mailbox, and transports deliver into mailboxes. Each of the three is a trait with several implementations, which is what lets the same <send> reach a local actor, an actor on another cluster node, or an actor behind a DID relay.

The mailbox contract, from crates/vertep-runtime/src/mailbox.rs (helper methods omitted):

pub trait Mailbox: Send + Sync {
    fn enqueue(&self, env: Envelope) -> Result<(), MailboxError>;
    /// The oldest message that is neither in flight nor deferred, now marked in flight.
    fn receive(&self, deferred: &[String]) -> Option<Envelope>;
    /// Removes the in-flight message (its turn is durable).
    fn ack(&self, message_id: &str) -> Result<(), MailboxError>;
    fn release(&self, message_id: &str);
    fn release_all(&self);
    fn pending(&self) -> Vec<Envelope>;
    fn pending_deliverable(&self, deferred: &[String]) -> usize;
}
PieceImplementationsNotes
MailboxesMemoryMailboxes, StoreMailboxesStore-backed mailboxes live in the actor's storage and outlive the process. Set with NodeBuilder::mailboxes, or per IRI scheme with mailboxes_for_scheme.
TransportsLocalTransport (the SCXML Event I/O Processor, alias scxml); the cluster, DIDComm, TSP and WebSocket processorsEach is an SCXML Event I/O Processor that <send type="…"> selects; _event.origintype names the one that delivered.
Directorya Resolver per IRI schemeResolution depends only on the address, so reactivation or relocation keeps it. handle.register_resolver(scheme, resolver).
Placementnone on a single node; ClusterNode in a clusterAnswers Local, Remote(mailbox) or Hold before a message is delivered or an actor activated.

Mailbox order is promised per sender and receiver only. Across senders there is no order, which is what makes relays and clusters possible at all.

The WebSocket Event I/O Processor (feature websocket) links a server node and browser nodes. Its frames and delivery rules are on Browser and JavaScript.

The journal

A virtual actor's state is a deterministic replay of the inputs it accepted. The journal is the only source of truth; the current-state graph, snapshots and any external graph store are derived from it.

Each entry holds the input (event name, payload, message id, origin and origin type, the journaled timestamp), the chart version it ran under, the sends with their send ids, the timers scheduled or cancelled, the resulting configuration digest and the position. Entries are numbered from 1 without gaps. Besides input entries there are migration entries and fork entries.

Three rules carry the guarantees:

Crash or faultState after recovery
During step, before the appendas if the message never arrived; it is delivered again
After the append, before the ackthe entry exists; the redelivered message is dropped as a duplicate
After the ack, before dispatchpending sends are found in the entry and dispatched on activation
Transport refuses a sendthe send stays pending and is retried; the receiver deduplicates
Two activations of one actorone append wins; the other gets a conflict and deactivates
Replay produces a different digestactivation fails with ReplayDivergence instead of running on wrong state
Timer due while the node was downfired after restart, in due order

Random values requested by executable content come from a generator seeded from the entry IRI, so replay reproduces them. node.journal(&address) returns the decoded entries; NodeConfig { crash_at: Some(CrashPoint::BeforeAppend), .. } simulates a crash in tests.

Storage

Storage is two RDF-agnostic traits in vertep-store, so no backend is asked for a guarantee it cannot give. A journal store must append linearizably per actor; a graph store only promises quads in and SPARQL out.

pub trait JournalStore: Send + Sync {
    fn capabilities(&self) -> JournalCapabilities;   // fencing, state_in_append
    /// Appends `entries` if, and only if, the last entry is at `expected`.
    fn append(&self, actor: &NamedNode, expected: Position, entries: &[Entry],
              meta: &AppendMeta) -> Result<Position, AppendError>;
    fn read(&self, actor: &NamedNode, from: Position) -> Result<Vec<JournalRecord>, StoreError>;
    fn head(&self, actor: &NamedNode) -> Result<Position, StoreError>;
    fn actors(&self) -> Result<Vec<NamedNode>, StoreError>;
}

pub trait GraphStore: Send + Sync {
    fn capabilities(&self) -> GraphCapabilities;     // RDF 1.2 or the RDF 1.1 fallback
    fn insert_quads(&self, quads: &[Quad]) -> Result<(), StoreError>;
    fn remove_quads(&self, quads: &[Quad]) -> Result<(), StoreError>;
    fn clear_graph(&self, graph: &NamedNode) -> Result<(), StoreError>;
    fn query(&self, sparql: &str) -> Result<QueryResults, StoreError>;
}

AppendError is Conflict, Fenced, Unsupported, Invalid or Storage. AppendMeta carries an optional fencing token (a scope IRI and an epoch) and an optional state delta. A store that does not enforce fencing refuses a token rather than ignoring it. A node's Storage pairs one journal store with the graph stores that hold each actor's current state, snapshots, outbox marks, dead letters and store-backed mailbox.

BackendCrateImplementsUse
TiramemsuBackend, TiramemsuShardvertep-tiramemsu (default)both traitsthe default: journal and current state in one file per shard
MemoryStorevertep-storeboth traitstests and the reference implementation
OxigraphStore, SparqlEndpointvertep-oxigraphgraph store onlya queryable projection in Oxigraph or any SPARQL 1.1 endpoint
SplitStoragevertep-runtimepairs a journal store with a separate graph storecurrent state written after each append

Why tiramemsu is the default. Tiramemsu is a bitemporal triple store that never deletes rows, so the journal is append-only by construction and history costs nothing extra. One append is one transaction that checks the head, writes the entry, derives the new current-state statements with the entry as their support, and acknowledges the message. So the actor's current state commits together with the input that caused it, and each state statement can be traced back to its entry (TiramemsuShard::state_causes). TiramemsuBackend::open(dir, shard_count) opens a directory of shard files, one writer each, with actors placed by an FNV-1a hash of their address IRI.

Why Oxigraph only holds a projection. SPARQL Update has no compare-and-set, so a SPARQL store cannot be a journal store. The Projector in vertep-store copies journal entries into a graph store in order, idempotently, and records how far it got per actor as vt:projectedPosition, so an interrupted run is completed by running it again. Consistent reads go to the journal store; the projection may lag.

Durable timers

A delayed <send> is recorded in the journal entry that scheduled it and fired later as an ordinary input, with a message id derived from its send id. A deactivated actor needs no running process to keep its timers. Due times are computed from the scheduling entry's journaled timestamp, so replay never reads a clock. A <cancel> is journaled too, and a cancelled timer never fires.

Recurring reminders use the <vt:reminder> element in executable content. From crates/vertep-runtime/tests/durable_timers.rs:

<state id="polling">
  <onentry><vt:reminder name="poll" period="10min" event="poll.tick"/></onentry>
  <transition event="poll.tick"/>
  <transition event="stop" target="done"/>
</state>
<final id="done"/>

A reminder fires every period until it is cancelled by name (<vt:reminder name="poll" cancel="true"/>) or the actor reaches a top-level final state. After downtime, every overdue timer fires in due order, and a reminder fires once for all the occurrences it missed. Durations accept ms, s, min, h and d, or a bare number of milliseconds. node.pending_timers() lists the armed timers in due order.

Time travel

There are two ways to look at an actor's past, and the quickstart output above shows both.

The named graphs follow a fixed layout: an actor's current state is in the graph named by its address, each journal entry in the graph named by its entry IRI. Both IRIs are reproduced by replay. The RDF and versions page shows SPARQL queries over them.

Chart versions and migrations

A chart version is the content address of the chart as written: urn:vertep:chart:sha256:<hex>, the hash of its canonical RDF form. The same chart loaded from SCXML or YAML gets the same version, and registering it again is a no-op. Every journal entry names the version it ran under.

Moving a running actor to a new version is an explicit migration, written as a journal entry that holds the snapshot before, the migration IRI and the snapshot after. From crates/vertep-runtime/tests/chart_versioning.rs:

let v1 = node.register_type(&ty("pay"), scxml("Idle", V1)).unwrap();
// ... the actor runs on v1 ...
let v2 = node.register_type(&ty("pay"), scxml("Idle", V2_RENAMED)).unwrap();
node.register_migration(
    Migration::identity("urn:vertep:migration:card2", v1.clone(), v2.clone())
        .rename_state("Paying.Card", "Paying.Card2"),
);
node.migrate(&a, "urn:vertep:migration:card2").unwrap();

A Migration maps configurations (explicitly with map_configuration, or by state renames) and may transform the datamodel with with_transform. Before applying it, the runtime enumerates every configuration reachable in the old chart, ignoring guards, and refuses the migration with MigrateError::Unmapped if any is not mapped. A failed transform appends nothing.

Rolling back is a fork, not a rewrite: node.fork(&source, position, &new_address) starts a new actor from the source's state at that position, under the version it had then. The source's journal is unchanged. A chart can also request its own migration with <vt:migrate to="…" migration="…"/> in executable content; the request is processed at the start of a later turn, never inside the requesting macrostep.

Node settings

NodeConfig, set with NodeBuilder::config:

FieldDefaultMeaning
idle_timeout_ms60 000an idle virtual actor with nothing deliverable is deactivated
snapshot_interval100a snapshot every N entries; 0 disables snapshots
fencingnonethe fencing token every append carries; a cluster sets it per shard
failure_backoff_ms1 000how long an actor whose turn failed waits before it is activated again
crash_atnonea simulated crash point, for tests of the journal protocol

The shell reaches its async runtime only through three small traits, Spawn, Clock and Sleep, bundled as a Shell. Shell::tokio() is the server shell; the browser build supplies its own (see Browser and JavaScript).

Single activation is guaranteed per node by the scheduler, and across processes by the journal's compare-and-set. Across a cluster it comes from shard leases with fencing epochs, described on Cluster and federation. The architecture view also states that an invoked chart child has no journal of its own and is reproduced by replaying its parent, so a child that must survive the loss of its node should be a virtual actor reached by <send>.

The design notes behind this page are in tg.md/views/runtime.md and tg.md/views/persistence.md; the normative requirements are in openspec/specs/actor-model, messaging, event-journal, storage, durable-timers and chart-versioning.