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:
- Receive the oldest message that no active state defers.
- 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.
- Stamp the time and run one macrostep (the very first entry also runs the start macrostep).
- 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.
- 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.
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 actor | Invoked actor | Stateless worker | |
|---|---|---|---|
| Address | <chart type>/<key> | <parent>/invoke/<id> | the pool's address |
| Exists | always, logically | while the invoking state is active | while the pool is registered |
| State | its own journal and snapshots | through its parent | none; the caller journals results |
| Reached by | <send>, vt:request | the parent; #_parent back | <send>, vt:request |
| Activations | at most one per node | one | up to the pool size |
| Created with | register_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:
| Attribute | Values | Default |
|---|---|---|
vt:restart | never, on-failure, always | never |
vt:restart-limit | restarts allowed within the window | 3 |
vt:restart-window | a duration | 60s |
vt:restart-backoff | the 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>
- Correlation ids are computed from the actor's IRI, the journal position of the macrostep that issued the request, and the request's index in it. Replay rebuilds the same id without storing it separately.
- The timeout is a durable timer recorded in the same journal entry, so it survives deactivation and restarts.
- Duplicate replies are dropped: at most one reply per correlation id is delivered.
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:
| Value | Effect | Default for |
|---|---|---|
discard | drop it, as SCXML does | plain sessions |
dead-letter | record a vt:DeadLetter in the receiver's graph | virtual actors |
error | raise 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;
}
| Piece | Implementations | Notes |
|---|---|---|
| Mailboxes | MemoryMailboxes, StoreMailboxes | Store-backed mailboxes live in the actor's storage and outlive the process. Set with NodeBuilder::mailboxes, or per IRI scheme with mailboxes_for_scheme. |
| Transports | LocalTransport (the SCXML Event I/O Processor, alias scxml); the cluster, DIDComm, TSP and WebSocket processors | Each is an SCXML Event I/O Processor that <send type="…"> selects; _event.origintype names the one that delivered. |
| Directory | a Resolver per IRI scheme | Resolution depends only on the address, so reactivation or relocation keeps it. handle.register_resolver(scheme, resolver). |
| Placement | none on a single node; ClusterNode in a cluster | Answers 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:
- Append before processing is visible. The append is the commit point. Before it nothing is visible to anyone; after it every recorded send is dispatched at least once. A turn that fails never reaches the append, so it leaves the journal unchanged.
- Compare-and-set. An append succeeds only if the journal is still at the expected position. Two activations of one actor can never both extend its history: the second gets a conflict, discards its state and sends nothing.
- Deduplication before step. A message whose id is already journaled is acknowledged and dropped. A send's message id is built from the sender's address and send id, so a resend after a crash is the same message to the receiver. Delivery is at least once; processing is effectively once.
| Crash or fault | State after recovery |
|---|---|
During step, before the append | as if the message never arrived; it is delivered again |
| After the append, before the ack | the entry exists; the redelivered message is dropped as a duplicate |
| After the ack, before dispatch | pending sends are found in the entry and dispatched on activation |
| Transport refuses a send | the send stays pending and is retried; the receiver deduplicates |
| Two activations of one actor | one append wins; the other gets a conflict and deactivates |
| Replay produces a different digest | activation fails with ReplayDivergence instead of running on wrong state |
| Timer due while the node was down | fired 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.
| Backend | Crate | Implements | Use |
|---|---|---|---|
TiramemsuBackend, TiramemsuShard | vertep-tiramemsu (default) | both traits | the default: journal and current state in one file per shard |
MemoryStore | vertep-store | both traits | tests and the reference implementation |
OxigraphStore, SparqlEndpoint | vertep-oxigraph | graph store only | a queryable projection in Oxigraph or any SPARQL 1.1 endpoint |
SplitStorage | vertep-runtime | pairs a journal store with a separate graph store | current 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.
- By replay.
node.state_at(&address, position)rebuilds the state after any journal position without touching the live actor or the journal. It works on every backend. - In the store. On tiramemsu,
shard.view_as_of(t)reads the graphs as they were right after transactiont, andshard.view_as_of_time(ms)as of a wall-clock instant. Both answer SPARQL from the store's own history, without replay.shard.state_history(actor, triple)lists when a state statement was added and retracted.
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.
- Pin until final.
register_typewith a new chart makes it the latest version of the type. New actors start on it; existing actors stay on their version until they reach a top-level final state. - Replay uses the recorded version. Each run of entries with one version is replayed with that version, never a newer one. A version that cannot be loaded fails activation with
MissingVersion. - The registry keeps what journals need.
node.remove_version(iri)is refused withRemoveError::InUsewhile a non-final actor runs that version.
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:
| Field | Default | Meaning |
|---|---|---|
idle_timeout_ms | 60 000 | an idle virtual actor with nothing deliverable is deactivated |
snapshot_interval | 100 | a snapshot every N entries; 0 disables snapshots |
fencing | none | the fencing token every append carries; a cluster sets it per shard |
failure_backoff_ms | 1 000 | how long an actor whose turn failed waits before it is activated again |
crash_at | none | a 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.