Verifying evidence
Clausgap records every governed agent decision in a tamper-evident hash chain. This page covers checking that chain, and exporting an evidence package that someone outside your organisation — an auditor, a regulator, an insurer, a court — can verify offline, with no Clausgap account, no network access and no need to trust the Clausgap server.
1. Check the live chain
curl -H "Authorization: Bearer $CLAUSGAP_API_KEY" https://control.example.com/v1/forensics/verify
curl -H "Authorization: Bearer $CLAUSGAP_API_KEY" https://control.example.com/v1/audit/verify/v1/forensics/verify covers agent decisions; /v1/audit/verify covers administrative actions (policy changes, votes, exports). Each recomputes every hash in the tenant's chain from the first record.
| Field | Meaning |
|---|---|
| verified / valid | true: no record was altered, inserted or removed. |
| linear | true: one unbroken chain with no forks. |
| first_mismatch_id / break_at_event_id | The first record that doesn't recompute: where tampering starts. |
Run it on a schedule. A false means someone changed the database directly; ordinary use can't produce one.
2. Export an evidence package
In the dashboard, go to Forensics → Export evidence. Or call the API as a signed-in user:
// POST /v1/forensics/export
{ "purpose": "regulatory_inquiry", "from": "2026-09-01T00:00:00Z", "to": "2026-10-01T00:00:00Z" }
// → 201
{
"package_id": "epkg_…",
"record_count": 1520,
"chain_intact": true,
"package_hash": "sha256:2365cc1e…",
"public_key_fingerprint": "sha256:c3385619…"
}purpose is required and recorded. from and to default to the last month; add sequence_id to export one agent session. A package holds up to 10,000 records — export a larger window in parts.
Write down package_hash. It identifies this exact package. Later, --expect-hash proves the file someone hands you is that package.
chain_intact: false means the server found records whose hashes don't recompute. The package is still exported — the break is itself evidence — and the verifier reports it.
Download it with GET /v1/forensics/packages/{package_id}/download or the dashboard's Download link. A package is stored exactly as it was signed, so every download returns the same bytes — after restarts, upgrades and any later change to the database.
3. Get the control plane's public key
The verifier needs to know who should have signed the package. Fetch the key once, from a source you trust, and keep it with your records:
curl -o clausgap.pub https://control.example.com/pubkey
shasum -a 256 clausgap.pub # → the fingerprint, as in public_key_fingerprint/pubkey serves the raw 32-byte Ed25519 key. To hand someone a short identifier instead, give them the fingerprint; they pin it with --fingerprint.
4. Verify a package offline
clausgap-verify is a small standalone program, shipped in the control-plane image. Run it there with networking disabled:
docker run --rm --network none -v "$PWD:/w:ro" --entrypoint /clausgap-verify \
<your clausgap-control image> --pubkey /w/clausgap.pub --expect-hash sha256:2365cc1e… /w/epkg_….json PASS format evidence package version 2, 15 records
PASS signer signed by the pinned key sha256:c3385619…
PASS signature Ed25519 signature verifies over the whole package (sha256:2365cc1e…)
PASS expected-hash matches the package hash recorded at export
PASS record-hashes all 15 records recompute to their chain hash
PASS chain-links every record links to the one before it — none missing between the first and last
PASS merkle-root Merkle root cdae0c5c… recomputed from the records
info timestamp no external timestamp token attached
VERIFIED — the package is intact and was signed by the pinned key.| Exit | Verdict | Meaning |
|---|---|---|
| 0 | VERIFIED | Every check passed against the key you pinned. |
| 1 | FAILED | Don't rely on the package; the FAIL lines say why. |
| 2 | — | Usage error, or a file couldn't be read. |
| 3 | UNPINNED | Internally consistent, but you gave no --pubkey or --fingerprint, so nothing ties it to your Clausgap. |
Add --json for a machine-readable report.
What each check proves
| Check | Fails when |
|---|---|
| format | The file isn't a version 2 package, or contains fields the format doesn't define. |
| signer | The package was signed by a key other than the one you pinned. |
| signature | Anything in the package changed after signing: any record, field, the purpose, the scope or the custody entries. |
| expected-hash | The file isn't the package recorded at export. |
| record-hashes | A record's content doesn't match the hash it was chained with when recorded — this catches database edits made before export, even though the server then signed the package. |
| chain-links | For a time-window package: a record is missing between two others, or records are out of order. (A single-session package skips other sessions' records, so gaps there are expected and reported as info.) |
| merkle-root | The record count, Merkle root or first and last hashes don't match the records. |
| chain-at-export | The server's own check at export found a broken chain. |
What this does and doesn't prove
- Edits after export can't go unnoticed. Changing the package breaks the signature; re-signing it needs the control plane's private key, and a package signed with another key fails
signer. - Database edits before export are caught by
record-hashes, and packages exported before an edit are stored unchanged and still verify — the original evidence survives. - Not covered by a record's hash:
agent_role,session_id,tool_nameandseverity. The package signature covers them, but only from the moment of export. - Someone with database write access could rewrite the tail of the chain consistently — the chain isn't keyed. What anchors history against that is evidence kept outside the database: packages you've already exported and their
package_hash, and an external timestamp on each package. Export regularly (monthly, say) and store packages and hashes where the Clausgap operators can't write. - External timestamps (forensics setting
external_timestamp) attach your timestamp authority's token for the package hash. The verifier reports that one is present but doesn't check it — the request is a simplified HTTP one, not RFC 3161 — so check it with your authority. - Signing key rotation: keep every public key you've used, to verify packages signed with older ones.
Package format (version 2)
For anyone writing their own verifier:
Signature
Ed25519 over sha256(C), where C is the package JSON without its signature and timestamp_proof members, in canonical form: object keys sorted, no insignificant whitespace, strings JSON-escaped without HTML escaping, numbers in shortest round-trip form. signature.package_hash is sha256: plus the hex digest; signature.public_key is the base64 raw key and public_key_fingerprint is sha256: plus its hex.
Record chain hash
BLAKE3-256, hex-encoded, over these lines, each ending in a newline:
prev:<prev_chain_hash> (empty for the first record in the tenant's chain)
tenant:<tenant_id>
sequence:<sequence_id>
agent:<agent_id>
action:<action>
input_hash:<input_hash>
output_hash:<output_hash>
confidence:<confidence to 2 decimal places, or empty>
policy_snapshot:<policy_snapshot>
model_id:<model_id>
context:<context_summary as JSON with sorted keys>
created_at:<UTC, YYYY-MM-DDTHH:MM:SS.ffffffZ>Merkle root
Leaves are BLAKE3(0x00 ‖ chain_hash); each parent is BLAKE3(0x01 ‖ left "\n" right "\n"); an odd node is paired with itself.
To see all of this run end to end — export, offline verification, a restart, and tampering with the package and the database — use the evidence evaluation.