# Evidence graph

Kind

concept

For

developer · governance

Verified against

@veritio/core@0.4.7 · edge schema 2026-06-13

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”](#one-relationship)

`createEvidenceEdge` turns an input into a normalized edge payload. This call is taken verbatim from the checked fixture further down the page:

```ts
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”](#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”](#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.

verified output

```json
{
  "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](/docs/reference/evidence-vocabulary/) reference.

## A provenance path

[Section titled “A provenance path”](#a-provenance-path)

A development workflow becomes several independently readable edges over one governed change:

```text
change chg_subscription_sub_9f31 ──part_of─────▶ activity  act_subscription_sub_9f31
change 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”](#two-hashes-one-of-which-is-verified)

Edges have two distinct digests, and conflating them is the most common misreading of this layer.

```text
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 checks
```

The 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”](#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:

```text
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”](#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.

verified output

```json
{
  "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”](#how-verification-walks-an-edge-chain)

For each record in supplied order, `verifyEvidenceEdgeRecords` checks:

1.  `edge.scope.tenantId` exists — otherwise `missing_tenant_scope`.
2.  `hashAlgorithm` is `sha256` — otherwise `unsupported_hash_algorithm`.
3.  `canonicalization` is `veritio-json-v1` — otherwise `unsupported_canonicalization`.
4.  `sequence` is exactly one greater than the last record for that tenant — otherwise `sequence_mismatch`.
5.  `previousHash` matches the last verified hash for that tenant — otherwise `previous_hash_mismatch`.
6.  The recomputed envelope hash matches the stored `hash` — otherwise `hash_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](/docs/reference/verifier/) covers every reason code.

## Where edges come from in practice

[Section titled “Where edges come from in practice”](#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:

-   `createGovernedActionDraft` returns an `edges` array of `EvidenceEdgeInput` alongside 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”](#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](/docs/concepts/evidence-commits/) when whole-history replacement is part of your threat model.

Read [Agent events](/docs/ai/agent-events/) for a concrete capture model that emits these edges, or [Export format](/docs/reference/export-format/) for the layer that carries them off the host.

[Edit page](https://github.com/getveritio/veritio-website/edit/main/src/content/docs/docs/concepts/evidence-graph.mdx)

Last updated: Aug 23, 2026

[Previous  
Hash chain](/docs/concepts/hash-chain/)[Next  
Evidence commits](/docs/concepts/evidence-commits/)

Veritio provides evidence support, not legal advice or automatic compliance.

This site uses cookieless, anonymous analytics (Umami) by default. With your consent, we also enable Google Analytics, which sets cookies and sends usage data to Google. [Privacy Policy](/legal/privacy/)
