Verified history — coming
The provenance family is in the contract and not switched on here yet — the endpoints answer not_enabled until it is. The contract they keep:
History, verified on every read
Every state the document has been through, in order — who acted (from your side's point of view), when, and a verified boolean the graph recomputes from its own hash chain on every read. Attribution rides the entry's note verbatim: a transition made through the API reads via API (rk_…), one Gio carried out for a user reads via Gio.
curl "https://api.rivet.network/v1/documents/{documentId}/history" \
-H "Authorization: Bearer rk_live_…"A document adopted mid-life carries a baseline marker: what happened before Rivet witnessed it is named as unwitnessed — never backfilled, never dressed up.
Lineage, with sealed steps
direction=up (the default) is the documents this one came from; down is what came of it. A step on the counterparty's own side appears as a sealed node — {sealed, transition_hash, occurred_at} and nothing else. Existence is proven; content is withheld. That boundary is the same one the app itself enforces, applied verbatim to the public surface.
curl "https://api.rivet.network/v1/documents/{documentId}/lineage?depth=2" \
-H "Authorization: Bearer rk_live_…"The export bundle
GET /v1/documents/{id}/export returns the document's ancestry as one self-describing JSON artifact: every included document's raw transition records — states, actors, timestamps, hashes, lineage links, metadata — plus the sealed hashes and the exact verification recipe. The body is canonical JSON: re-export the same unchanged document and you get the same bytes, and the Rivet-Bundle-Digest header is the sha256 of exactly those bytes.
curl -o bundle.json -D headers.txt \
"https://api.rivet.network/v1/documents/{documentId}/export" \
-H "Authorization: Bearer rk_live_…"
# headers.txt now holds Rivet-Bundle-Digest — keep it with the file- Ancestry only, by design. A bundle proves where a document came from as of issuance. What later came of it is a live query, never proof material — a new descendant changes nothing about an existing bundle, byte for byte.
- Complete or absent. An ancestry too large to bundle answers
conflict; a graph the service can't answer for is503. A truncated proof would be worse than none, so one is never produced. - Exports have their own small per-key daily budget —
rate_limitedwhen spent.
Verify without trusting Rivet
This is the point of the bundle: hand the file to a lender, an auditor, a court — not API access — and they verify it with the script below (or any SHA-256 implementation and the bundle's own verification block). It recomputes every hash from the bundle's contents, checks the chain, and resolves every cross-document link. Change one byte of any covered field and it fails, loudly, naming the transition.
#!/usr/bin/env node
// ── verify-bundle.mjs — verify a Rivet provenance bundle OFFLINE ─────────────
//
// You do not have to trust Rivet to trust the bundle. This script recomputes
// every hash from the bundle's own contents and checks the chain, using
// nothing but Node's standard library. Anyone can read it end to end.
//
// node verify-bundle.mjs <bundle.json> [--digest <sha256hex>]
//
// What it proves, in order:
// 1. INTEGRITY — the file's sha256 matches --digest (the Rivet-Bundle-Digest
// header from the export, if you kept it).
// 2. HASHES — every transition's hash recomputes from its own fields.
// Change one byte of any covered field and this fails, loudly.
// 3. CHAIN — every transition links to real prior transitions of the
// same document, and depths are consistent (a DAG, no cycles).
// 4. LINEAGE — every cross-document edge points at a hash that exists in
// the bundled parent document, or is declared sealed (a
// counterparty-side step: existence proven, content withheld).
//
// What the hash does NOT cover (contextual records, listed so nothing is
// oversold): note, event_type, producer, acknowledgement. The states, actors,
// timestamps, ancestry links and metadata ARE covered.
//
// Exit codes: 0 verified · 1 verification FAILED · 2 usage/read error.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const GENESIS = "genesis";
const NULL_STATE = "null";
const SYSTEM_ACTOR = "system";
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
/** Canonical JSON: object keys sorted recursively; arrays keep their order. */
function canonicalJson(value) {
return JSON.stringify(sortKeys(value));
}
function sortKeys(value) {
if (Array.isArray(value)) return value.map(sortKeys);
if (value && typeof value === "object") {
return Object.keys(value).sort().reduce((acc, k) => { acc[k] = sortKeys(value[k]); return acc; }, {});
}
return value;
}
/** The transition hash — the exact recipe the bundle's `verification` block states. */
function computeTransitionHash({ specVersion, documentId, t }) {
const at = new Date(t.occurred_at);
if (Number.isNaN(at.getTime())) throw new Error(`invalid occurred_at: ${t.occurred_at}`);
const parents = [...(t.parent_transition_hashes ?? [])].sort().join(",") || GENESIS;
const serialized = [
`v=${specVersion}`,
`boid=${documentId}`,
`from=${t.from_state || NULL_STATE}`,
`to=${t.to_state}`,
`at=${at.toISOString()}`,
`user=${t.triggered_by_user_id || SYSTEM_ACTOR}`,
`org=${t.triggered_by_org_id}`,
`parents=${parents}`,
`depth=${t.graph_depth}`,
`meta=${sha256(canonicalJson(t.metadata ?? {}))}`,
].join("|");
return sha256(serialized);
}
// ── run ──────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith("--"));
const digestIdx = args.indexOf("--digest");
const expectedDigest = digestIdx >= 0 ? args[digestIdx + 1] : null;
if (!file) {
console.error("usage: node verify-bundle.mjs <bundle.json> [--digest <sha256hex>]");
process.exit(2);
}
let raw;
try {
raw = readFileSync(file);
} catch (err) {
console.error(`could not read ${file}: ${err.message}`);
process.exit(2);
}
let failed = 0;
const ok = (label, detail = "") => console.log(` ✓ ${label}${detail ? ` — ${detail}` : ""}`);
const bad = (label, detail = "") => { failed += 1; console.error(` ✗ ${label}${detail ? ` — ${detail}` : ""}`); };
// 1. INTEGRITY
const fileDigest = createHash("sha256").update(raw).digest("hex");
console.log(`bundle: ${file}`);
console.log(`sha256: ${fileDigest}`);
if (expectedDigest) {
if (fileDigest === expectedDigest.toLowerCase()) ok("digest matches the export header");
else bad("digest MISMATCH", `expected ${expectedDigest} — this is not the file Rivet exported`);
}
let bundle;
try {
bundle = JSON.parse(raw.toString("utf8"));
} catch (err) {
console.error(` ✗ not valid JSON: ${err.message}`);
process.exit(1);
}
if (bundle.bundle_format !== "rivet-provenance-bundle/1") {
bad("unknown bundle_format", String(bundle.bundle_format));
}
const specVersion = bundle.spec_version;
const documents = Array.isArray(bundle.documents) ? bundle.documents : [];
const sealed = new Set((bundle.sealed ?? []).map((s) => s.transition_hash));
console.log(`root: ${bundle.root} · spec v${specVersion} · ${documents.length} document(s) · ${sealed.size} sealed step(s)\n`);
// 2 + 3. HASHES and CHAIN, per document
const hashesByDoc = new Map(); // documentId → Map(hash → depth)
for (const doc of documents) {
const seen = new Map();
hashesByDoc.set(doc.id, seen);
let docOk = true;
for (const [i, t] of (doc.transitions ?? []).entries()) {
let recomputed;
try {
recomputed = computeTransitionHash({ specVersion, documentId: doc.id, t });
} catch (err) {
bad(`${doc.id} #${i}`, err.message);
docOk = false;
continue;
}
if (recomputed !== t.transition_hash) {
bad(`${doc.id} #${i} hash MISMATCH`, `${t.from_state ?? "genesis"}→${t.to_state}: a covered field was altered`);
docOk = false;
continue;
}
// chain: every parent must already exist for this document; depth must be consistent
const parentDepths = [];
for (const p of t.parent_transition_hashes ?? []) {
if (!seen.has(p)) { bad(`${doc.id} #${i} parent link`, `parent hash ${p.slice(0, 12)}… not among this document's prior transitions`); docOk = false; }
else parentDepths.push(seen.get(p));
}
const expectedDepth = parentDepths.length ? Math.max(...parentDepths) + 1 : 1;
if (t.graph_depth !== expectedDepth) {
bad(`${doc.id} #${i} depth`, `graph_depth ${t.graph_depth}, expected ${expectedDepth}`);
docOk = false;
}
seen.set(t.transition_hash, t.graph_depth);
}
if (docOk) ok(`${doc.document_type ?? "document"} ${doc.document_number ?? doc.id}`, `${doc.transitions?.length ?? 0} transition(s) verified`);
}
// 4. LINEAGE — cross-document edges resolve into the bundle or the sealed set
let crossChecked = 0, crossOk = true;
for (const doc of documents) {
for (const t of doc.transitions ?? []) {
for (const e of t.cross_edges ?? []) {
crossChecked += 1;
const target = hashesByDoc.get(e.parent_business_object_id);
if (target?.has(e.parent_transition_hash)) continue;
if (sealed.has(e.parent_transition_hash)) continue;
bad(`cross-edge from ${doc.id}`, `${e.relationship_type} → hash ${String(e.parent_transition_hash).slice(0, 12)}… is neither in the bundle nor declared sealed`);
crossOk = false;
}
}
}
if (crossChecked && crossOk) ok(`lineage`, `${crossChecked} cross-document link(s) resolve`);
console.log("");
if (failed === 0) {
console.log("VERIFIED — every hash recomputes, the chain is intact, all lineage resolves.");
process.exit(0);
}
console.error(`FAILED — ${failed} problem(s). This bundle does not prove what it claims.`);
process.exit(1);
Run it as node verify-bundle.mjs bundle.json --digest <the header>. It states plainly what the hash does not cover (the contextual note, event_type and producer fields) — nothing about the artifact is oversold.