Vertep

Durable objects

A durable object is a virtual actor whose journal belongs to no single machine or organisation. Its address is a did:webvh DID, its journal is kept by several custodians that talk only over TSP, and exactly one host writes it at a time, without any shared coordinator.

When to use one

An ordinary virtual actor lives in one store; a cluster keeps it alive inside one trusted organisation. A durable object is for actors that must outlive any one operator: an agent working a case between a company and its suppliers, a contract between parties, a long-running workflow that should keep going when one organisation's data centre is gone. The chart, the runtime and the journal format do not change. What changes is where the journal lives and who may extend it.

Virtual actorDurable object (vertep-objects)
Addressan IRIa did:webvh DID that never changes
Journalone storereplicas at every custodian, hash-chained and signed
One writershard lease from the clustercustody epoch won by a majority of promises
Effectsafter the local appendafter a quorum of custodians accepted the turn
Survivesthe node's diskany minority of custodians, any one organisation

Custodians

The object's DID document names its custodians and the quorum in a VertepCustody service. A host may serve the object and write its journal. A keeper only stores entries; it sees a clear header (position, epoch, previous hash, signatures) and an encrypted body, so a storage provider can keep an object it cannot read. The quorum must be a majority.

let config = CustodyConfig::new(
    vec![
        Custodian::host("did:web:host.org1.example"),
        Custodian::host("did:web:host.org2.example"),
        Custodian::keeper("did:web:keeper.org3.example"),
    ],
    2, // a majority of three
)?;
doc.service.push(config.to_service(object.as_str())); // publish in the did:webvh log

One writer per epoch

A host becomes the writer by asking every custodian to promise a new custody epoch. A custodian promises an epoch only above every epoch it promised before, and then refuses anything older. With a majority of promises, the host adopts the chain accepted in the highest epoch (then the longest), stores it, and ships it to the custodians under its own epoch. An entry counts once a majority accepted it in one epoch; only then does the runtime perform the turn's sends and timers. Two hosts can never both win an epoch, a host that lost its epoch is refused on its next append, and no clock is ever consulted.

let host = Arc::new(CustodyHost::new(object, signer, keys, agreement_key, replica, directory)
    .with_transport(tsp_to_org2)          // direct TSP
    .with_transport(tsp_to_keeper));       // routed through the keeper's mediator
let storage = Arc::new(ObjectStorage::new(inner_storage));
storage.custody().add(host.clone());
let mut node = Node::builder(storage, shell).build();
node.handle().bind_address(&object_address, "https://example.org/charts/ticket", "8812");
serve_object(&mut node, &object_address)?; // win custody, replay, re-arm timers

Handoff and failover

hand_off moves an object on purpose: the old host brings the custodians level and stops writing, and the next host to activate wins a higher epoch. When a host disappears, any other host takes over the same way as long as a majority of custodians is reachable; with fewer, nothing is written and events wait in the object's mailbox. Timers are in the journal, so the new host re-arms them, and a timer that came due in between fires on activation. Sends of turns whose effects the old host may not have completed are dispatched again with their original message ids, which receivers deduplicate.

The controller changes the custodian set with a signed request. The writer replicates the new set as pending; meanwhile every commit and election needs a majority of both sets. It switches once both majorities hold the journal, then publishes the did:webvh update. Hosts that join receive the content keys; when a host leaves, a new key generation starts.

Hosting the object's did:webvh

vertep-webvh writes and hosts the object's DID log, with keys that stay in the key manager. The log names the custodians as did:webvh witnesses, each by the did:key of its signing key, with the custody quorum as the threshold. A web host that serves an unwitnessed entry is caught by every resolver.

A custodian witnesses an entry only if the entry is the valid next entry, signed by a current update key, and declares a custodian set the custodian accepted. A custody change therefore carries its log entry: the old custodians witness it after the switch, and the writer publishes it.

vt webvh object create --domain example.org --path objects:ticket-8812 --keys ./keys \
    --custodian host,did:web:a.example,z6Mk... --custodian host,did:web:b.example,z6Mk... \
    --custodian keeper,did:web:k.example,z6Mk... --quorum 2
vt webvh witness ./keys/dids/<did>/did.jsonl --keys ./custodian-keys --key <key>   # each custodian
vt webvh publish <did> --keys ./keys --git ./pages --subdir docs --branch main
vt webvh custody change <did> --keys ./keys --custodian ... --quorum 2 --request-out change.json

The publishers write the did:webvh 1.0 layout (did.jsonl, did-witness.json and the did:web form did.json), and each refuses a log that does not verify:

vt webvh serve serves such a directory behind a TLS proxy. vt-mediator with did = "webvh:mediator.example" creates its own DID and serves the log beside /tsp.

What is checked

Every entry carries its object, position, epoch, writer, the previous entry's hash, its own hash and the writer's signature, so any party verifies a replica from genesis without trusting whoever served it. Two validly signed entries of one epoch at one position are kept as equivocation evidence and reported to the controller. A randomised fault test drives partitions, colliding elections, crashes and late duplicate messages through many seeds, checks that one writer holds each epoch and that nothing committed is ever lost, and exports each history for the Lean custody model.

The example

cargo run -p vertep-objects --example three_orgs --features testkit,tsp runs a support agent kept by three organisations over TSP. Org 1 serves it until the agent waits for a person's approval; org 1 disappears; org 2 wins the next epoch from itself and org 3's keeper (reached through its mediator), replays the agent without calling the model again, and the person approves the very decision org 1 had opened.

org 1: agent in ["review"], custody epoch Some(1), 3 entries committed, model calls 1
       keeper K holds 3 encrypted entries
org 2: won custody epoch Some(2), replayed ["review"], model calls 0
       open approval did:webvh:QmScid:support.example:agents:ticket-8812/decision/3.0 (same as before the failover: true)
org 2: agent in ["approved"] after 4 journaled inputs

The design notes are in tg.md/views/durable-objects.md; the requirements in openspec/changes/m7-durable-objects (capabilities durable-objects and object-custody). The TSP mediator that objects and keepers use while offline is described in Cluster and federation.