Vertep

RDF and versions

Every chart has an exact RDF 1.2 form, checked by SHACL shapes. Its version is the hash of that form, and actor journals are RDF too, so you can ask SPARQL what an actor believed at any past moment.

The IR is the hub, RDF is a projection

SCXML, YAML and the statechart! macro all lower to one chart IR in vertep-core. The runtime executes the IR. RDF is a lossless projection of it: vertep-rdf writes the IR as a graph and can rebuild exactly the same IR from that graph. The runtime never executes RDF.

Two consequences follow. The graph must carry enough to rebuild the IR, down to the order of transitions. And because RDF is open-world (a graph that is missing a triple is not wrong by itself), SHACL shapes, not the ontology, say what a valid chart graph is. Reconstruction runs the shapes first and refuses any graph that does not conform.

The vt: ontology modules

The ontology is split into modules. Each one is its own Turtle file, and a chart that uses only plain SCXML needs only the chart module. All modules are plain OWL/RDFS files with no imports.

ModulePrefix and namespaceFileWhat it describes
Chartvt: https://w3id.org/vertep/vt#crates/vertep-rdf/ontology/vt-chart.ttlOne class per IR node kind and one property per IR attribute or containment
Typesvt: (same namespace)crates/vertep-rdf/ontology/vt-types.ttlNamed types, records, tagged unions, lists, optional values, primitives
Runtimevt: (same namespace)crates/vertep-rdf/ontology/vt-runtime.ttlActors, mailboxes, journal entries, snapshots, timers, migrations, dead letters
Agentvt-agent: https://w3id.org/vertep/agent#crates/vertep-rdf/ontology/vt-agent.ttlDeciders, decisions, LLM calls, tools, tool calls, grants, plans
Verificationvt: (same namespace)crates/vertep-rdf/ontology/vt-verification.ttlCertificates issued by vt certify
Identityvt-id: https://w3id.org/vertep/vt-id#crates/vertep-federation/ontology/vt-id.ttlDID actors, verification methods, services, DID-scoped grants, relay envelopes

Shapes live next to them: crates/vertep-rdf/shapes/vt-chart-shapes.ttl, crates/vertep-rdf/shapes/vt-agent-shapes.ttl, crates/vertep-runtime/shapes/vt-runtime-shapes.ttl and crates/vertep-federation/shapes/vt-id-shapes.ttl. Shapes for a chart's own types are generated per type (see typed charts below).

The runtime, agent and identity modules are aligned with PROV-O and DID Core by plain rdfs:subClassOf and rdfs:subPropertyOf triples. No Vertep query uses a PROV-O or DID Core term, and no reasoner runs. A store with RDFS inference can answer PROV-O queries over the same data; vertep-agent also writes the PROV-O view of its records directly, so PROV queries work there without inference.

The projection

vt convert --to turtle prints a chart's projection. Here it is for the typed order chart in crates/vertep-dm-rust/tests/charts/order.scxml. The full output is 262 lines; the excerpt below keeps the prefixes, the chart node, the state idle and its one transition, in the order vt wrote them.

vt convert --to turtle crates/vertep-dm-rust/tests/charts/order.scxml
@prefix c: <urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix vt: <https://w3id.org/vertep/vt#> .

c:chart a vt:Chart ;
	vt:binding "early" ;
	vt:bindingExplicit false ;
	vt:child c:s.cancelled , c:s.checkout , c:s.idle , c:s.paid ;
	vt:data c:chart:d0 , c:chart:d1 ;
	vt:datamodel "rust" ;
	vt:datamodelExplicit true ;
	vt:declaresType <urn:vertep:type:sha256:41b1c80782c0edf4c066dd3ac0c6235285690642f6ec20b1bb77e8e7e1bd632f> , <urn:vertep:type:sha256:78b8b5724ef01cf7b643dc932acb99c0504f0928315f6e79cc6c0e966ce37d24> ;
	vt:extension c:chart:x0 ;
	vt:initial c:chart:ia0 ;
	vt:name "order" .

c:s.idle:t0:ev0 a vt:Token ;
	vt:order 0 ;
	vt:value "start" .
c:s.idle:t0:sel:chart vt:choice c:s.checkout .
c:s.idle:t0:sel:s.checkout vt:choice c:s.idle:t0:tg0 .
c:s.idle:t0:tg0 a vt:TargetRef ;
	vt:order 0 ;
	vt:scope c:chart ;
	vt:state c:s.checkout .
c:s.idle:t0 a vt:Transition ;
	vt:event c:s.idle:t0:ev0 ;
	vt:order 0 ;
	vt:source c:s.idle ;
	vt:target c:s.idle:t0:tg0 ;
	vt:transitionType "external" ;
	vt:transitionTypeExplicit false .
c:s.idle a vt:State , vt:StateNode ;
	vt:ancestor c:chart ;
	vt:id "idle" ;
	vt:idGenerated false ;
	vt:order 0 ;
	vt:transition c:s.idle:t0 .

The rules behind this output (spec openspec/specs/chart-rdf/spec.md, code crates/vertep-rdf/src/project.rs):

Typed charts

A chart with a vt:types block (see Datamodels and types) also projects its named types. The chart node links each one with vt:declaresType. A type's IRI is urn:vertep:type:sha256:<hex>, the hash of its own canonical definition, so the same type has the same IRI in every chart. The node under that IRI is both the type's definition (vt:RecordType, vt:typeField, …) and the SHACL node shape a value of the type must satisfy. In the order chart, the shape for Pay is closed, requires exactly one amount, and accepts an xsd:long, an xsd:decimal or a decimal-looking string. Type nodes are minted at <type IRI>#<path>, so they carry no blank nodes either: vt convert --to nquads on this chart writes 281 quads and not one blank node. The shapes do say sh:nodeKind sh:BlankNode, but that constrains values: for SHACL validation a record value is encoded as a blank node with one triple per field, and the shape checks that encoding, not the chart graph.

SHACL shapes and reconstruction

The chart shapes are the normative definition of a valid chart graph. Here is one of them, from crates/vertep-rdf/shapes/vt-chart-shapes.ttl (the first two property constraints of vt:TransitionShape):

vt:TransitionShape
    a sh:NodeShape ;
    sh:targetClass vt:Transition ;
    sh:node vt:OrderedShape ;
    sh:property [
        sh:path vt:source ;
        sh:minCount 1 ;
        sh:maxCount 1 ;
        sh:class vt:StateNode ;
        sh:message "a transition has exactly one source state" ;
    ] ;
    sh:property [
        sh:path [ sh:alternativePath (
            [ sh:inversePath vt:transition ]
            ( [ sh:inversePath vt:initialTransition ] [ sh:inversePath vt:initialElement ] )
        ) ] ;
        sh:equals vt:source ;
    ] ;

To see a shape at work, take the Turtle above, delete the single line vt:source c:s.idle ; from the transition, and validate it. vt validate reads RDF as well as SCXML and YAML:

vt validate order-broken.ttl
order-broken.ttl: graph does not conform to the chart shapes: 2 violation(s):
  <urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f#s.idle:t0> (^vt:transition|(^vt:initialTransition/^vt:initialElement)) = <urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f#s.idle>: value not also a value of <https://w3id.org/vertep/vt#source> (EqualsConstraintComponent)
  <urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f#s.idle:t0> vt:source: a transition has exactly one source state (MinCountConstraintComponent)

The command exits with status 1. Each violation names the focus node, the path and the constraint component, so a report points at the triple to fix.

A few rules cannot be written in SHACL Core and are checked by reconstruction instead: the vt:order values of a list are exactly 0 to n−1, expression datatypes match the chart's datamodel, a graph holds one chart, and the derived triples agree with a fresh projection. Triples that are not part of the projection, such as your own comments on a state, are ignored by reconstruction and by hashing.

The same shapes are transcribed into the Lean model, and the theorem shapes_equivalence proves that a chart's projection conforms to them exactly when the chart is well-formed; see Verification.

Content-addressed versions

A chart's version IRI is urn:vertep:chart:sha256: followed by the lowercase hex SHA-256 of the RDFC-1.0 canonical N-Quads of its projection. The steps, from crates/vertep-rdf/src/hash.rs and iri.rs:

Three surfaces lower to one IR; the IR is projected under a placeholder base, canonicalized with RDFC-1.0, hashed with SHA-256, and the graph is rebased onto the resulting version IRI SCXML YAML statechart! IR project urn:vertep:chart:self#… RDFC-1.0 SHA-256 version IRI urn:vertep:chart:sha256:… published graph <version>#… rebase
Plate. Naming a chart by its content. Hash under a fixed base, then rebase onto the hash.
  1. Project the chart with every node IRI minted under the fixed placeholder base urn:vertep:chart:self.
  2. Write that graph as RDFC-1.0 canonical N-Quads. The projection has no blank nodes, so canonicalization only sorts.
  3. Take the SHA-256 of those bytes. The version IRI is the prefix plus the 64 hex digits.
  4. Publish the graph rebased from the placeholder onto the version IRI.

The placeholder breaks a circle: node IRIs live under the version IRI, and the version IRI is the hash of a graph containing them. To check a published graph, rebase it back onto the placeholder and hash again; graph_version does this after validating and reconstructing the graph.

Because the hash covers the projection and nothing else, formatting, comments and the surface a chart was written in do not change it. The order chart exists in the repository as SCXML and as YAML:

vt hash crates/vertep-dm-rust/tests/charts/order.scxml
vt hash crates/vertep-dm-rust/tests/charts/order.yaml
urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f
urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f

Annotations do not change it either. Append one triple about a state to the projected Turtle and hash the result:

printf '\nc:s.paid <http://www.w3.org/2000/01/rdf-schema#comment> "the order is paid" .\n' > note.ttl
cat order.ttl note.ttl > order-annotated.ttl
vt hash order-annotated.ttl
vt validate order-annotated.ttl
urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f
order-annotated.ttl: ok: urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f

So a store can attach comments, usage statistics or certificates to a chart without changing which chart it is. A certificate from vt certify hangs off the version IRI with vt:certifiedBy; because the IRI is a content hash, a certificate can never apply to a different chart.

The runtime records the version IRI in every journal entry. An actor stays on the version it started with until it reaches a top-level final state or is migrated explicitly, and replay always uses the version recorded for each stretch of the journal. See The actor runtime for chart versioning and migrations.

Serialization formats and vt convert

vertep-rdf reads and writes Turtle, N-Triples, N-Quads and TriG, all RDF 1.2, with triples in sorted order so the output is stable. In the dataset formats (N-Quads, TriG) the chart sits in the named graph whose name is its version IRI:

vt convert --to trig crates/vertep-dm-rust/tests/charts/order.scxml
@prefix c: <urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix vt: <https://w3id.org/vertep/vt#> .
<urn:vertep:chart:sha256:05bdae901f2ef328f81ad649674bac2beab559eac46bae2dbc34db1bdcd2e38f> {
	c:chart:d0:x0 a vt:ExtensionNode ;
		vt:attributeValue "decimal" ;
		vt:localName "type" ;
		vt:namespace "https://w3id.org/vertep/vt#" ;
--toWrites
scxmlSCXML 1.0 with the vt: extensions
yamlThe YAML chart format
turtleRDF 1.2 Turtle, one graph
trigRDF 1.2 TriG, chart in the graph named by its version
nquadsRDF 1.2 N-Quads, same layout

The input surface is detected from the file extension (.scxml, .xml, .yaml, .yml, .ttl, .trig, .nq, .nt) and otherwise from the content. RDF input goes through the shapes and reconstruction, so converting Turtle back to SCXML is the round trip:

vt convert --to turtle crates/vertep-dm-rust/tests/charts/order.scxml > order.ttl
vt convert --to scxml order.ttl
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:vt="https://w3id.org/vertep/vt#" version="1.0" name="order" datamodel="rust" initial="idle">
  <vt:types xmlns:vt="https://w3id.org/vertep/vt#">type Pay = { amount: decimal };
type Approval = { by: iri };

vt convert refuses a conversion that would lose information rather than drop it silently. All flags are listed on The vt command.

The journal as RDF

Each journal entry of an actor is a small RDF graph, stored as a named graph. The runtime module fixes stable IRIs for everything an actor writes, derived from the actor's address A, so replaying a journal or projecting it again gives the same IRIs (from the header of vt-runtime.ttl):

ThingIRI
Journal entry (also the name of its graph)A/entry/<position>
Input event of an entryA/entry/<position>/input
Outgoing sendA/entry/<position>/send/<k>
TimerA/entry/<position>/timer/<send id>
ReminderA/reminder/<name>
Snapshot (also the name of its graph)A/snapshot/<position>
Dead letterA/dead-letter/<hex digest of the message id>, in graph A/dead-letters
Mailbox rowA/mailbox/<sequence>, in graph A/mailbox
Current statethe graph named A

An input entry (vt:InputEntry, a vt:JournalEntry) records the accepted input with its name, type, payload, send id, origin, origin type and message id; the chart version; the timestamp stamped when it was dequeued; the configuration digest after the macrostep; and its effects: outgoing sends, timers scheduled and cancelled, invocations started, requests issued and answered, and transitions taken. Migration and fork entries carry snapshots instead. The store adds rdf:type vt:JournalEntry, vt:actor and vt:position (see crates/vertep-runtime/src/journal.rs). Every entry the runtime writes must conform to the runtime shapes; a test validates them on both the in-memory store and tiramemsu.

The current-state graph A holds the actor's type, its chart version, its status (vt:Running or vt:Terminated), one vt:inState triple per active state pointing at the state node IRI of that chart version, the datamodel as JSON text (vt:datamodelState) and its pending timers.

Statements about statements, such as "this transition was taken because of that entry" or a decider's confidence in a choice, are RDF 1.2 triple-term annotations. A graph store that only speaks RDF 1.1 gets classic rdf:Statement reification instead, and reads it back as the same annotation.

SPARQL, now and as of a time

Storage is two traits in vertep-store: a JournalStore with compare-and-set appends, and a GraphStore that takes quads and answers SPARQL. Tiramemsu, the default, implements both on one file per shard. On tiramemsu an append and the new state graph commit in one transaction, and the state statements are stored as derived facts whose support is the entry that caused them.

Tiramemsu never deletes rows, so history is kept by construction. A TiramemsuShard gives you read-only views that are themselves graph stores:

MethodWhat it reads
view_now()The graphs as they are now
view_as_of(t)The graphs right after transaction t committed, read from the store's history, not by replay
view_as_of_time(ms)The graphs after the last transaction committed at or before an instant (epoch milliseconds of the store's clock)
state_causes(actor, triple)The journal entries whose appends caused a live state statement, by following its support links
state_history(actor, triple)Every lifetime of a state statement, as (added at, retracted at) transaction pairs

The query is ordinary SPARQL over the named-graph layout above. This sketch follows the store tests in crates/vertep-tiramemsu/tests/backend.rs, using the vt:inState predicate the runtime writes:

use vertep_store::GraphStore;
use vertep_tiramemsu::TiramemsuShard;

let shard = TiramemsuShard::open("data/shard-0.db")?;
let t = shard.last_tx()?;            // remember a transaction ...
// ... later, after more appends:
let actor = "https://acme.example/actors/Order/8f3a";
let q = format!(
    "SELECT ?s WHERE {{ GRAPH <{actor}> {{ <{actor}> <https://w3id.org/vertep/vt#inState> ?s }} }}"
);
let then = shard.view_as_of(t).query(&q)?.into_solutions()?;   // states as of t
let now  = shard.view_now().query(&q)?.into_solutions()?;      // states now

Views are read-only: writing to one returns StoreError::Unsupported. For the state after a given journal position rather than a store transaction, the runtime's Node::state_at(address, position) rebuilds it from the journal instead.

vertep-oxigraph implements only GraphStore, on an embedded Oxigraph (OxigraphStore) or any SPARQL 1.1 endpoint (SparqlEndpoint). It may not hold a journal, because SPARQL Update has no compare-and-set. Instead a Projector copies journal entries into it in order, idempotently, and records per actor how far it got. Reads there are eventually consistent; consistent reads go to tiramemsu. When the target store lacks RDF 1.2 triple terms, annotations are written with RDF 1.1 reification.