Vertep

Cluster and federation

Two ways to run actors on more than one machine. A cluster is a set of trusted nodes sharing one population of actors, placed by shard. Federation connects actors of different owners through relays nobody needs to trust. A chart cannot tell which case it is in: <send target="…"> looks the same for all of them.

Two separate problems

Both reuse the runtime's three layers: an address resolves through a directory to a mailbox, and transports deliver into mailboxes as SCXML Event I/O Processors (see the actor runtime). What differs is trust.

Cluster (vertep-cluster)Federation (vertep-federation)
Trustall nodes trusted, mutual TLSeach party trusts only itself
Addressesvt:-style IRIsDIDs, opt-in per actor
Directoryshard table: hash(IRI) mod N to the lease ownerDID resolver: the DID document's services
Mailboxthe shard owner's mailboxa DIDComm mediator or TSP endpoint, which may be offline
TransportQUIC, one stream per sender and receiver pairDIDComm v2 forward, TSP direct or routed
Sender identitycluster certificateverified DID, journaled with the event
Authorizationmembership onlyDID-scoped grants, inbound and outbound
Left, a trusted cluster of three nodes that share shards under leases from a lease authority and talk over QUIC. Right, another organisation's node. Between them, an untrusted mediator that holds only ciphertext. cluster: trusted, mutual TLS Lease authority static · gossip · etcd · k8s node a shards 0–3 node b shards 4–7 node c DID actor Mediator ciphertext only Org 2 node pickup, verify, grant, journal leases, epochs QUIC QUIC authcrypt + forward pickup
Plate IV. Inside and across the boundary. Leases inside the cluster; encryption and grants across it.

The cluster

The facade's cluster feature brings in vertep-cluster with its gossip, quic and runtime features. The etcd and Kubernetes authorities are separate features (etcd, kubernetes) that you enable on vertep-cluster directly.

Cluster metadata and shards

A cluster is fixed at creation by its ClusterMeta: an identifier, a shard count, a hash version and a replication factor, kept in cluster.meta. A node built with different values is refused when it joins.

Membership

One Membership trait reports the current view, notifies subscribers, and grants, renews, releases and revokes shard leases. The runtime never depends on which implementation is configured. Views carry strictly increasing version numbers, and a node ignores a view not newer than the one it holds.

ImplementationMembers fromLease authorityWhere
Staticconfiguration (StaticConfig)a MemoryAuthority hosted by one process, deterministic assignmentfixed fleets, tests
GossipSWIM with indirect probes (GossipMembership)a deterministic leader among active membersself-forming clusters without infrastructure
etcdetcd v3 over its JSON gateway (EtcdAuthority)etcd leases; a permanent key holds each shard's epochoperated clusters, feature etcd
Kubernetescoordination.k8s.io/v1 Lease objects (KubeAuthority)the API server; grants expire by observationclusters on Kubernetes, feature kubernetes

Joining checks the cluster id, shard count and node id. Leaving drains first: the node refuses new activations, finishes running macrosteps, flushes outboxes, ships each shard and hands it over. Failure detection marks a silent member suspect after 3 s and failed after 10 s by default (DetectorConfig), and a failed member's leases become free only after they expire.

Gossip lease granting is weaker than consensus under partitions. Fencing keeps it safe, at the cost of availability. A gossip leader's epochs carry a tag from its identifier and start, so a leader that restarts never grants an epoch twice; the fault-injection matrix found that bug and the fix has a test.

Leases and fencing epochs

A node owns a shard through a time-bounded lease. Every new grant carries a strictly greater fencing epoch; a renewal keeps the epoch. By default a lease lasts 10 s, and the owner stops starting macrosteps 2 s before it ends, by its own monotonic clock (LeaseConfig), to absorb clock drift and pauses.

At most one activation of a virtual actor can commit macrosteps under any epoch. Three checks give that, in order of cost:

  1. Routing. A node activates an actor only while it holds an unexpired lease on its shard. Other nodes forward the message to the owner, with a hop limit.
  2. Lease timing. The owner stops before expiry, so in the normal case two owners never overlap.
  3. Fenced append. Under clock skew or a long pause, a stale owner's append carries an epoch lower than the one the journal store has accepted, and is rejected as Fenced. Its outbound frames carry the old (shard, epoch) too, and receivers drop them into the dead-letter graph. The append is also a compare-and-set on the actor's journal position.

Lean proves the invariant behind this: for every shard and epoch at most one writer's appends are accepted, and accepted journal positions are totally ordered across epochs (see Verification).

The cluster inside a node

A ClusterNode joins one runtime Node to the cluster. It is the node's placement, its storage and its cluster Event I/O Processor. Before the runtime delivers a message or activates an actor, it asks the placement, which answers:

Every append goes through ClusterStorage, which routes it to the shard's store and stamps the node's lease epoch as the fencing token. The wiring, from crates/vertep-cluster/tests/rt/mod.rs:

let cluster = ClusterNode::new(membership.clone() as Arc<dyn Membership>, config);
wire.connect(&cluster);                       // MemoryWire in tests; QuicWire in production

let node = Node::builder(storage as Arc<dyn Storage>, Shell::tokio())   // a ClusterStorage
    .datamodels(Arc::new(vertep_dm_ecma::EcmaRegistry))
    .placement(cluster.placement())
    .transport(cluster.processor())            // <send type="cluster">
    .build();
cluster.attach(node.handle());

ClusterConfig holds the transport settings:

FieldDefaultMeaning
hop_limit4forwards between nodes before a message fails
mailbox_bound1024an inbound frame waits while the target's mailbox holds this many messages
dedup_capacity65 536accepted message ids the receiver remembers
delivery_deadline30 show long an outbound message is retried
retry_backoff50 msthe pause between retries

The QUIC transport

Nodes talk over QUIC with mutual TLS. Every node has a certificate from the cluster's CA for <node>.<cluster>.vertep.internal, and a server accepts a client only if the certificate chains to the cluster CA and names a current member, so a foreign certificate or a removed member is refused during the handshake.

Worker pools

Stateless workers are pooled, never sharded, and never take a lease. Any node that declares a worker type may run activations of it. A request is routed by the sending node: it picks an activation by policy, creates one on a declaring member when none has capacity, queues within a bound, or refuses at once with an error event.

PolicyPicks
RoundRobinthe activations with free capacity, in turn
LeastOutstandingthe activation with the fewest requests in progress
PreferLocalan activation on the sender's own node when one has capacity; otherwise the least loaded

Results are journaled by the caller, so replay never calls the worker again. A worker node that fails fails its requests to their callers as error events or request timeouts. A request is retried once on another activation only if the worker type is declared idempotent, because a retried LLM or tool call can have outside effects.

Replication and moving shards

With a replication factor above one, every shard has rf - 1 standbys: the members after the owner in the sorted list of active members. The owner ships to its standbys what they lack and acknowledges an append only after the configured number of them stored it. If the quorum is not reached, the append fails, and the actor's journal is neither read nor extended until a later shipment catches the standbys up. So an entry that missed the quorum never produces effects, and a standby that takes over holds every entry whose effects anyone saw. A standby refuses a stream from an epoch lower than one it has accepted.

With replication factor 1 there is no standby. A failed owner's shards stay unavailable until its disk returns, and no other node may take such a shard with an empty journal. Messages wait in mailboxes rather than being lost.

To move a shard, the rebalancer drains the old owner, ships the journal suffix the new owner lacks, verifies the end position, and transfers the lease at a higher epoch. Late messages at the old owner are forwarded with a redirect. With a shared journal store a move is a lease transfer only.

Resharding is offline. vt cluster reshard <dir> <out> doubles the shard count of a stopped cluster: it reads the data directory (cluster.meta, the shard files and the leases dump), refuses if the cluster is running, re-hashes every actor IRI into shard s or s + N, and writes the new files and metadata to <out>.

Known gaps in the cluster, as of this release:

  • Journal shipping between nodes has no wire protocol yet. The shipping hook accepts any copy of a journal, and the tests ship between stores in one process. Moving a shard between machines needs a way to copy its file that you supply.
  • A cluster no larger than its replication factor stops writing while a node is down. With fewer live members than the replication factor, appends wait rather than acknowledge with fewer copies than configured. A three-node cluster with factor 3 stops writing a shard while one of its nodes is down.
  • Hot shards are not split. Placement is per shard; individual actors are never moved off a busy shard.

Fault injection

Two harnesses check the cluster. tests/faults.rs runs in process over static, gossip and a stand-in for the external authorities on simulated time, and over etcd and Kubernetes in containers, each with a shared journal and with replication factors 1 and 3. tests/containers.rs runs every node as its own process (the vertep-fault-node binary) and injects network disconnects, pauses, kills and restarts, a lease clock at half speed, and lost frames. A checker requires every actor journal to be linear, at most one writer per shard and epoch, every acknowledged message processed exactly once, and no fenced side effect accepted. Every history is replayed through the Lean lease model, which must reach the same accept and reject decisions.

The nightly CI job runs:

VERTEP_CONTAINER_TESTS=require \
  cargo test -p vertep-cluster --release --features differential,etcd,kubernetes \
  --test faults --test simulation --test runtime --test external --test containers -- --test-threads 2

Without VERTEP_CONTAINER_TESTS the container tests skip; =1 runs them when Docker is available and =require fails without it. Set VERTEP_HISTORY_DIR to keep the histories as JSON lines.

Federation

The facade's federation feature brings in vertep-federation with DIDComm v2 and TSP. Actors on ordinary IRIs never touch any of it: their sends are delivered without DID resolution, keys or cryptography.

DID identities

A DID is optional and per actor. An actor that crosses a trust boundary uses its DID as its address, so the DID is also the subject of its journal and current-state graph. A DID resolver acts as the directory: the DID document's services become routes, and its verification methods become keys.

MethodResolutionUse
did:keyofflinesimple, keys only
did:peerofflinepairwise relationships
did:webover HTTP, through a fetcher you supplyactors with a web domain
did:webvhover HTTP with log verification, feature webvhlong-lived public actors with verifiable key history

Resolution is cached with a bounded size and time to live, and re-resolved on expiry or endpoint failure. Applications can register or override method resolvers in the ResolverRegistry.

Private keys stay behind the KeyManager trait (InMemoryKeyManager, FileKeyManager) and never enter journals, snapshots, the graph or datamodels: a never-forget store could only erase them by destroying the key that encrypts them. Rotating a key keeps the DID, with a grace period for retired keys. Federation::provision creates an actor's keys and DID and binds the DID to a chart type and key on the node.

Authenticated senders and grants

An inbound message from across a trust boundary passes these checks before any journal append, in order:

  1. decrypt it with the KeyManager;
  2. verify the sender against its resolved DID document (an anonymous message is refused unless the receiver's AnonymousPolicy is Allow);
  3. find a grant for this sender, target and event;
  4. check the payload against the receiver's declared event type;
  5. drop it if its message id was already seen.

An admitted event carries the sender's DID in _event.origin and _event.originverified set to true; an anonymous one has neither. The verification outcome is journaled with the event, so replay never resolves a DID or checks a signature again, and the pure core sees only the origin and one boolean. A refused message is recorded in the graph <actor>/inbound, acknowledged to the relay, and answered with error.communication when the sender is verified and had contacted the actor first. An ungranted message never costs the receiver a journal append.

Grants are checked at both ends. A grant names a grantee, a target and SCXML event descriptors, with an optional expiry:

// from crates/vertep-federation/examples/two_orgs.rs
a.fed.grant_outbound(Grant::new(buyer.did.as_str(), shop.did.as_str(), ["order.*"]));
b.fed.grant_inbound(Grant::new(buyer.did.as_str(), shop.did.as_str(), ["order.*"]));

A grant can also arrive as a verifiable credential from a trusted issuer (accept_credential). Grants are published into the actor's identity graph, <did>/identity, so "who may talk to this agent" is a query:

PREFIX vt-id: <https://w3id.org/vertep/vt-id#>
SELECT ?grantee ?event ?until WHERE {
  GRAPH ?g {
    ?grant a vt-id:Grant ;
           vt-id:target <did:webvh:example.org:agents:billing> ;
           vt-id:grantee ?grantee ;
           vt-id:event ?event .
    OPTIONAL { ?grant vt-id:expires ?until }
  }
}

Chart guards can still decide more finely on top of grants, for example accepting approve only from one DID.

Relay mailboxes: DIDComm and TSP

DIDComm v2 mediators and TSP endpoints serve as mailboxes and transports, so an actor can receive while deactivated or offline. Payloads are encrypted end to end: a relay sees routing data only, never the event name, payload, send id or origin.

Wiring a node, as the module documentation of crates/vertep-federation/src/federation.rs sketches it:

let fed = Federation::builder(resolver, network).protocol(didcomm).pickup(binding).build();
let node = Node::builder(storage, shell)
    .mailboxes_for_scheme("did", fed.mailboxes())     // local DID actors' relay mailboxes
    .transport(fed.transport(TransportKind::DidComm)) // <send type="didcomm">
    .build();
fed.attach(&node.handle());                         // did: resolver, wake-ups, error path
let did = fed.provision(&node.handle(), chart_type, key, &config, km)?;  // DID as address
loop { node.run_until_idle(); fed.pump(); }

The example crates/vertep-federation/examples/two_orgs.rs runs two organisations' nodes and an in-process test mediator. The buyer's <send> to the shop's did:peer DID is resolved to the mediator, authcrypted and forwarded; the shop's node picks it up, verifies the sender, checks the grant and journals the event before acknowledging it:

cargo run -p vertep-federation --example two_orgs --features didcomm,testkit
shop journaled order.placed from did:peer:2.Vz6MksFJPi77yVK2k5KeaVZQNqqFneW7JKC3r3S8pYjwyUHmx.Ez6LSiZjqpMXyzKGQPeDC4pCw8n3iovG3ibdtvHFABppNvQhW.SeyJpZCI6IiNkaWRjb21tIiwicyI6eyJhIjpbImRpZGNvbW0vdjIiXSwiciI6WyJkaWQ6cGVlcjoyLlZ6Nk1rcEJXYVdhcTR3REZCb29WeHFIY0xyaUxxcUM1SkVtR04zdVdWTWJ5V0hzMVkuRXo2TFNyUFpVREhoWnhOa0VRZjljdWtpcnJZVmUzeFJraU13eHhEeDVoRHFwcHd2ei5TZXlKcFpDSTZJaU5rYVdSamIyMXRJaXdpY3lJNmV5SjFjbWtpT2lKb2RIUndjem92TDIxbFpHbGhkRzl5TG1WNFlXMXdiR1V2Wkdsa1kyOXRiU0o5TENKMElqb2laRzBpZlEja2V5LTIiXSwidXJpIjoiaHR0cHM6Ly9tZWRpYXRvci5leGFtcGxlL2RpZGNvbW0ifSwidCI6ImRtIn0 via https://w3id.org/vertep/vt-id#DIDCommEventProcessor (send id Some("o-1"))
mediator still holds 0 message(s)

The DID differs on every run, because the example generates fresh keys. The event's origin is the buyer's verified DID, its origin type is the DIDComm Event I/O Processor, and the mediator holds nothing once the shop's turn is durable.

Known gaps in federation:

  • Live delivery over WebSocket from a mediator is not bound. Pickup from a remote mediator works in batches: Federation::go_live opens live sessions only on the in-process relays (the node's own inbound queue and the test mediator and intermediary), not through pickup::MediatorClient. Interop with a third-party mediator (Hyperledger Identus) is tested over HTTP, in Docker, when VERTEP_MEDIATOR_INTEROP=1 is set, and skipped otherwise.
  • The facade's federation feature does not enable did:webvh. Enable the webvh feature on vertep-federation for it.
  • No Lean proofs cover federation. It is checked by interop and integration tests only.

What each failure costs

Safety never depends on clocks or relays behaving well; only availability does.

FailureWhat happens
A partition isolates a shard ownera new owner gets a higher epoch; the old owner's appends and frames are fenced
Clock skew beyond the safety marginfencing on appends and frames keeps safety
Owner crash, replication factor 1the shard is unavailable until the disk returns; messages wait
Lost acknowledgementretry; the receiver deduplicates by message id
Stale routing tableredirect and forward, within the hop limit
Worker node dies mid-requesterror event or request timeout to the caller; no silent retry unless idempotent
Mediator crashes after a pickupno acknowledgement before the append, so the message is delivered again and deduplicated
Key rotated while messages are in flightretired keys work for a grace period; undecryptable messages are recorded
Recipient unreachable for gooderror.communication after the retry policy; the chart is not blocked

The same core also runs in the browser, linked to a server node over a WebSocket transport. That is the subject of the next page, Browser and JavaScript. The design notes are in tg.md/views/distribution.md; the requirements in openspec/specs/cluster-membership, shard-placement, cluster-transport, worker-pools, did-identity and relay-mailboxes.