Evidence graph
Audit events answer “what happened?” Evidence edges answer “how are two stable things related?” Keeping those records separate prevents event metadata from becoming an unstructured graph database, and it lets a lineage graph be verified with the same rigour as an event chain rather than being trusted because it renders nicely.
An edge is a first-class evidence record. It has its own schema version, its own hash, its own tenant-local sequence, and its own verifier.
One relationship
Section titled “One relationship”createEvidenceEdge turns an input into a normalized edge payload. This call is taken verbatim from the checked fixture further down the page:
const edge = createEvidenceEdge({ id: 'edge_change_reviewed_by_owner', occurredAt: '2026-08-09T10:01:00.000Z', scope: { tenantId: 'org_acme', environment: 'production' }, from: { type: 'change', id: 'chg_subscription_sub_9f31' }, relation: 'reviewed_by', to: { type: 'principal', id: 'usr_owner', actorType: 'user' }, metadata: { decision: 'approved' },})The direction is explicit: change chg_subscription_sub_9f31 was reviewed by principal usr_owner. Reversing from and to would make a different claim, and nothing in the protocol would flag it — direction is the author’s responsibility.
Edge anatomy
Section titled “Edge anatomy”| Field | Meaning |
|---|---|
id |
Stable edge identifier. Omitting it mints edge_<uuid>, which is not reproducible. |
schemaVersion |
Always 2026-06-13, stamped by the SDK. Callers cannot set it. |
occurredAt |
Normalized UTC instant. Omitting it stamps the current clock. |
from |
The entity the directed relationship starts at |
relation |
One value from the closed relation vocabulary |
to |
The entity the relationship ends at |
scope |
Optional tenant, workspace, and environment context |
metadata |
Minimized JSON details, redacted deterministically; always present, {} when omitted |
Both endpoints are { type, id } pairs with optional qualifiers: actorType, resourceType, version, and pathHash. Those qualifiers refine the reference; they do not embed the referenced object’s content. When a filesystem path matters, the path enters the graph as a pathHash digest and the id stays an opaque host identifier.
createEvidenceEdge validates from and to against the entity vocabulary before it validates relation, so an edge that is wrong in both places reports the entity error first. That order is pinned by the fixture below, not incidental.
The vocabulary is closed
Section titled “The vocabulary is closed”Thirty-two entity types and thirty-seven relations are the only values the protocol recognises. A host application cannot invent a private relation such as promoted_by, or a private entity type such as invoice, and still get an edge back: the guard throws a TypeError before any edge object exists. A reader of the graph therefore never has to interpret a vocabulary term it does not hold.
The fixture below prints both lists in full, in declaration order, builds one accepted edge, then attempts three edges using terms the protocol does not define. Its output is byte-compared in CI.
{ "entityTypes": { "count": 32, "values": [ "tenant", "principal", "actor", "activity", "change", "revision", "assertion", "record", "evidence_commit", "data_subject", "resource", "data_category", "purpose", "policy", "consent", "processor", "system", "repository", "branch", "commit", "pull_request", "file", "diff_hunk", "agent_session", "activity_episode", "tool_call", "ci_run", "artifact", "deployment", "runtime_event", "subject_request", "export_bundle" ] }, "edgeRelations": { "count": 37, "values": [ "caused_by", "part_of", "read", "modified", "created", "deleted", "derived_from", "reviewed_by", "approved_by", "waived_by", "built_by", "deployed_as", "observed_in", "attests_to", "exports", "satisfies_policy", "violates_policy", "subject_of", "processed_for", "retained_under", "sent_to", "has_activity", "has_input", "has_output", "has_assertion", "resulted_in", "performed_by", "used", "generated", "based_on", "asserts_about", "retracts", "corrects", "supersedes", "disputes", "confirms", "compensates" ] }, "acceptedEdge": { "id": "edge_pr_reviewed_01", "schemaVersion": "2026-06-13", "occurredAt": "2026-08-09T10:00:00.000Z", "from": { "type": "pull_request", "id": "pr_4821" }, "relation": "reviewed_by", "to": { "type": "principal", "id": "usr_reviewer", "actorType": "user" }, "metadata": { "reviewDecision": "approved" }, "scope": { "tenantId": "org_acme", "environment": "production" } }, "closedVocabularyGuards": [ { "case": "relation outside EVIDENCE_EDGE_RELATIONS", "outcome": "rejected", "errorName": "TypeError", "errorMessage": "relation must be a supported evidence graph relation" }, { "case": "entity type outside EVIDENCE_ENTITY_TYPES", "outcome": "rejected", "errorName": "TypeError", "errorMessage": "from.type must be a supported evidence graph entity type" }, { "case": "unknown entity type AND unknown relation", "outcome": "rejected", "errorName": "TypeError", "errorMessage": "from.type must be a supported evidence graph entity type" } ]}Declaration order is protocol documentation rather than presentation, so a future addition or removal shows up as a byte diff instead of a silently stale page. The per-term meaning of every entity type and relation — including which direction each relation points — lives on the entity and relation vocabulary reference.
A provenance path
Section titled “A provenance path”A development workflow becomes several independently readable edges over one governed change:
change chg_subscription_sub_9f31 ──part_of─────▶ activity act_subscription_sub_9f31change chg_subscription_sub_9f31 ──modified────▶ file file_billing_plan_ts (pathHash sha256:6f4b…)change chg_subscription_sub_9f31 ──reviewed_by─▶ principal usr_owner (actorType user)A traversal can then answer which reviewed change touched which file without copying prompts, diffs, or file contents into each event. What travels is stable ids, content digests, and bounded non-PII metadata.
Two hashes, one of which is verified
Section titled “Two hashes, one of which is verified”Edges have two distinct digests, and conflating them is the most common misreading of this layer.
edge payload { id, schemaVersion, occurredAt, from, relation, to, metadata, scope } │ ├─ hashEvidenceEdge(edge, previousHash) │ SHA-256(canonical { edge, previousHash }) │ the cross-language link pinned by conformance fixtures │record envelope { edge, sequence, previousHash, hashAlgorithm, canonicalization, appendedAt, idempotencyKeyHash } │ └─ hashEvidenceEdgeRecord(record) SHA-256(canonical envelope, stored `hash` excluded) what a store persists and what the verifier checksThe envelope hash commits to the edge and to sequence, previousHash, appendedAt, and idempotencyKeyHash. Reordering or re-stamping a stored edge is therefore detectable, not just editing its payload. verifyEvidenceEdgeRecords checks the envelope hash; it never recomputes hashEvidenceEdge.
Audit-record and edge-record chains stay separate. An edge does not alter audit-event semantics, and a framework adapter cannot add private relation values to the protocol.
The chain rule
Section titled “The chain rule”A tenant’s edge chain starts at sequence 1 with previousHash: null. Each later record’s previousHash is the previous record’s envelope hash:
seq 1 previousHash null hash 30d530bb…seq 2 previousHash 30d530bb… hash 1a02ea70…seq 3 previousHash 1a02ea70… hash 022988fd…@veritio/core ships no edge store, and @veritio/storage implements AuditStore only. The chain rule, not a storage engine, is the protocol: the host supplies the recordEdge sink. The fixture below therefore chains the records the way MemoryAuditStore chains audit records, with the envelope hash as the tip.
A verified chain, then a broken one
Section titled “A verified chain, then a broken one”The fixture builds the three lineage edges, chains them, verifies the chain, then changes one metadata value in the second edge — changedPathCount from 2 to 1 — while leaving the stored envelope hash exactly as the chain wrote it.
{ "edges": [ { "id": "edge_change_part_of_activity", "schemaVersion": "2026-06-13", "occurredAt": "2026-08-09T10:00:00.000Z", "from": { "type": "change", "id": "chg_subscription_sub_9f31" }, "relation": "part_of", "to": { "type": "activity", "id": "act_subscription_sub_9f31" }, "metadata": { "activityType": "billing.plan_change" }, "scope": { "tenantId": "org_acme", "environment": "production" } }, { "id": "edge_change_modified_file", "schemaVersion": "2026-06-13", "occurredAt": "2026-08-09T10:00:30.000Z", "from": { "type": "change", "id": "chg_subscription_sub_9f31" }, "relation": "modified", "to": { "type": "file", "id": "file_billing_plan_ts", "pathHash": "sha256:6f4b1c0d2a7e5839c1b0f2d3e4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d" }, "metadata": { "changedPathCount": 2 }, "scope": { "tenantId": "org_acme", "environment": "production" } }, { "id": "edge_change_reviewed_by_owner", "schemaVersion": "2026-06-13", "occurredAt": "2026-08-09T10:01:00.000Z", "from": { "type": "change", "id": "chg_subscription_sub_9f31" }, "relation": "reviewed_by", "to": { "type": "principal", "id": "usr_owner", "actorType": "user" }, "metadata": { "decision": "approved" }, "scope": { "tenantId": "org_acme", "environment": "production" } } ], "chain": [ { "edgeId": "edge_change_part_of_activity", "sequence": 1, "previousHash": null, "edgeHash": "b558af701a039503c92f4c1b2c53e950e760d21a9ba0c5459f0884462c1c3566", "recordHash": "30d530bbf0705bde64017c0532d31e7a6c5a6618d69bcea3a5b555bbf0960f62", "recordHashRecomputes": true, "edgeHashDiffersFromRecordHash": true, "linksToPreviousRecordHash": true }, { "edgeId": "edge_change_modified_file", "sequence": 2, "previousHash": "30d530bbf0705bde64017c0532d31e7a6c5a6618d69bcea3a5b555bbf0960f62", "edgeHash": "0459a135e087a603946492369b35afabce15108d9a4660c4f28624a36feedbaa", "recordHash": "1a02ea70a099ad15764fca3b6565ef32ad9df8223fc3832c6a5450383c16c209", "recordHashRecomputes": true, "edgeHashDiffersFromRecordHash": true, "linksToPreviousRecordHash": true }, { "edgeId": "edge_change_reviewed_by_owner", "sequence": 3, "previousHash": "1a02ea70a099ad15764fca3b6565ef32ad9df8223fc3832c6a5450383c16c209", "edgeHash": "5539c3820457ae80cbdcc51887667760977bc4ae1c74b0573ef9bd44057875b7", "recordHash": "022988fdc76e2f98f5c6fbaa882a16b43a98b8d859df4b4a3f89f21a2b87c6ca", "recordHashRecomputes": true, "edgeHashDiffersFromRecordHash": true, "linksToPreviousRecordHash": true } ], "verification": { "ok": true }, "tamper": { "what": "edge[1].metadata.changedPathCount 2 -> 1, stored envelope hash left untouched", "verification": { "ok": false, "index": 1, "reason": "hash_mismatch" }, "failedRecord": { "edgeId": "edge_change_modified_file", "storedHash": "1a02ea70a099ad15764fca3b6565ef32ad9df8223fc3832c6a5450383c16c209", "recomputedHash": "59f3a2a960957fe9523cee7863e5a6f61bc6927e3ee1fb718bd53b8ca045bbf7" } }}Three things in that output are worth reading closely. edgeHashDiffersFromRecordHash is true on every record, which is the two-digest distinction made concrete. linksToPreviousRecordHash is true, confirming the chain links envelope hash to envelope hash rather than edge hash to edge hash. And the tamper result is not a vague failure: it is { ok: false, index: 1, reason: "hash_mismatch" }, with the stored hash 1a02ea70… and the recomputed hash 59f3a2a9… printed side by side.
One metadata integer changed. The digest is unrelated to the original. That is the property the whole layer rests on.
How verification walks an edge chain
Section titled “How verification walks an edge chain”For each record in supplied order, verifyEvidenceEdgeRecords checks:
edge.scope.tenantIdexists — otherwisemissing_tenant_scope.hashAlgorithmissha256— otherwiseunsupported_hash_algorithm.canonicalizationisveritio-json-v1— otherwiseunsupported_canonicalization.sequenceis exactly one greater than the last record for that tenant — otherwisesequence_mismatch.previousHashmatches the last verified hash for that tenant — otherwiseprevious_hash_mismatch.- The recomputed envelope hash matches the stored
hash— otherwisehash_mismatch.
It stops at the first failure and reports the array index plus a stable reason. Verifier state is per tenant: two tenants may both hold sequence 1, and their records must never be joined into one chain. The verifier reference covers every reason code.
Where edges come from in practice
Section titled “Where edges come from in practice”Few applications call createEvidenceEdge in a loop by hand. Two producers emit edges as part of a larger flow:
createGovernedActionDraftreturns anedgesarray ofEvidenceEdgeInputalongside its events and outbox entry, so a mutation boundary emits the change lineage in the same unit of work as the change itself.- The provenance recorder’s session methods emit connecting edges per recorded step, and
session.link(from, relation, to, metadata, occurredAt)adds an explicit one. The recorder performs no cross-sink transaction: a host that needs an event and its edges to commit together must wrap both sinks in one transaction, or an edge-sink failure leaves a committed event with missing edges.
What a relationship does not prove
Section titled “What a relationship does not prove”An edge is still a recorded claim, and verification proves that the claim has not changed since it was written — not that it was true when it was written.
reviewed_by does not prove the reviewer examined every byte. deployed_as does not prove the deployment was healthy. satisfies_policy records an evaluation result; it is not a legal conclusion. A verified edge chain supports compliance evidence about what your systems recorded; it does not establish legal compliance on its own.
A chain that verifies also says nothing about completeness. Edges that were never written leave no gap to detect, because the sequence counts what was appended, not what should have been. Capture the producing system and the supporting events when a stronger interpretation is needed, and bind the persisted records into an evidence commit when whole-history replacement is part of your threat model.
Read Agent events for a concrete capture model that emits these edges, or Export format for the layer that carries them off the host.