Evidence bundles#
An evidence bundle is a zip file containing a range of events from one stream together with everything needed to verify them offline: the events themselves, the signed checkpoints covering them, the public signing keys, and a copy of the standalone verifier. Bundles are created from the Evidence export page in the UI and are the artefact you hand to an auditor.
On the Team plan and above, the same page can run exports on a schedule: choose streams, a monthly or quarterly cadence anchored to a day of the month, and a trailing window, and each run produces ordinary bundles through the same path, then emails your recipients download links — bundles never travel as mail attachments. Links require a Sigilbase sign-in; auditors with granted access download bundles covering their window from the auditor portal instead. Every run lands in your system stream (export.schedule_run), and a failed generation alerts owners (export.failed).
This page is the normative specification of the format. The current format identifier is sigilbase-evidence/1.4. Each version is strictly additive: 1.1 added anchors.json and the optional consistency.json over sigilbase-evidence/1; 1.2 adds the per-event payload_state field and redactions.json, which together declare any payload the tenant has destroyed (hash-preserving redaction); 1.3 adds informational qualified-TSA metadata on anchor records (see Qualified anchoring) and lets Certificates of Evidence covering records in the range travel under certificates/, hash-listed in the manifest; 1.4 adds the optional SigilSign blocks — documents.json, signatures.json, and links.json — each informational-but-verifiable: the verifier cross-checks every stated hash and sequence against the events themselves and fails on contradiction, while unknown fields (including any claim about legal effect or validity) are ignored and can never influence the verdict. The verifier accepts all five. Anything not specified here is not part of the format; consumers must ignore unknown fields rather than reject them.
Range semantics#
Proofs exist at checkpoint granularity, so the requested range is snapped outward to whole checkpoints and clamped to the sealed portion of the stream. The manifest records the actual exported range. A range with no sealed checkpoints cannot be exported.
Bundle contents#
| File | Purpose |
|---|---|
manifest.json |
Format id, stream identity, exported range, signing keys |
events.ndjson |
The events, one canonical JSON object per line |
checkpoints.json |
The signed checkpoints covering the range |
anchors.json |
RFC 3161 timestamp tokens over the checkpoint hashes (may be empty) |
consistency.json |
Cumulative tree states per checkpoint; present only when the range starts at sequence 1 |
redactions.json |
Declares every event in the range whose payload was redacted (may be empty) |
certificates/*.pdf |
Certificates of Evidence covering records in the range (1.3; present only when any exist) |
documents.json |
Documents of the stream by hash and metadata (1.4; present only when the range covers publications) |
signatures.json |
Signature records with per-signer facts and the viewed/signed version hashes (1.4; optional) |
links.json |
Document ↔ event links with the sequences of their ledgered facts (1.4; optional) |
README.txt |
Plain-language instructions for the person holding the bundle |
verify.php |
The standalone verifier, copied into every bundle |
manifest.json#
A single JSON object:
{
"format": "sigilbase-evidence/1.3",
"generated_at": "2026-07-09T10:02:11.482910Z",
"stream": {
"id": "0197c8e2-4a51-7000-8000-2f9d3b6a1c44",
"slug": "admin-actions",
"name": "Admin actions"
},
"range": {"from": 1, "to": 4},
"event_count": 4,
"signing_keys": [
{
"public_key": "2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12",
"created_at": "2026-07-01T00:00:00.000000Z",
"retired_at": null
}
]
}
| Field | Meaning |
|---|---|
format |
sigilbase-evidence/1.3 for this version |
generated_at |
When the bundle was produced, RFC 3339 UTC with microseconds |
stream.id |
The stream's UUID; hash preimages commit to this, not the slug |
stream.slug, stream.name |
Human labels; not covered by any hash |
range.from, range.to |
First and last sequence in the bundle, inclusive |
event_count |
Must equal to - from + 1; the verifier checks the line count |
signing_keys |
Every key the instance has used, active and retired. Ed25519 public keys as 64 lowercase hex characters |
certificates |
1.3, present only when certificates travel with the bundle: for each, id, issued_at, scope, file (a path under certificates/), and sha256 of the file bytes |
signing_keys is the trust anchor of the bundle. An auditor who wants to rule out a fabricated bundle compares these keys with the ones published at GET /api/v1/keys, or with keys obtained from Sigilbase out of band.
events.ndjson#
Newline-delimited JSON: one event per line, in sequence order, with a trailing newline. Each line is encoded as canonical JSON (RFC 8785; see payload rules), which makes lines byte-reproducible.
{"action":"role.granted","actor":"user:42","entry_hash":"9b89974c651b484589b61d710f1bc0284e7788bf3ace3e833a46f5729d694c01","occurred_at":"2026-07-09T09:15:00.000000Z","payload":{"granted_by":"user:42","role":"admin"},"payload_hash":"11294bc41a363046cae292e51e14a892de8f459f498fd9dd4381f98a4170550b","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","received_at":"2026-07-09T09:15:03.412876Z","resource":"user:9f31","seq":1,"v":1}
| Field | Meaning |
|---|---|
v |
Line schema version, 1 |
seq |
Sequence number in the stream, dense from range.from to range.to |
occurred_at, received_at |
RFC 3339 UTC with microseconds |
actor, action, resource |
As written; resource may be null |
payload |
The full original payload object, or null when the payload was redacted |
payload_state |
"present" or "redacted" (1.2; pre-1.2 lines omit it, meaning present) |
payload_hash |
SHA-256 of the payload's canonical JSON, lowercase hex — preserved through redaction |
prev_hash |
The previous event's entry_hash; 64 zeros for sequence 1 |
entry_hash |
SHA-256 of the entry preimage (see below) |
Each line carries everything its entry preimage commits to, so the verifier recomputes both hashes from the line alone plus the stream id from the manifest. The entry preimage is the canonical JSON object with keys v, stream, seq, occurred_at, received_at, actor, action, resource, payload_hash, prev; Reading and proofs shows one worked byte for byte.
A redacted event is one whose stored payload the stream's owner destroyed after ingestion — the erasure path for personal data recorded by mistake. The preimage commits to payload_hash, not the payload bytes, so a redacted event's entry_hash, the chain through it, the Merkle roots above it, and every signature verify exactly as before the redaction; only the payload_hash recomputation is skipped, because the content it hashed no longer exists. Every redacted line must be matched by an entry in redactions.json — an absent payload that is not declared there fails verification.
checkpoints.json#
A single JSON object with a checkpoints array, ordered by range:
{
"checkpoints": [
{
"v": 1,
"stream": "0197c8e2-4a51-7000-8000-2f9d3b6a1c44",
"from": 1,
"to": 4,
"root": "4a157629882340d739370041b824b38334a98777cfb805b89de4a7551c27b0d2",
"prev_checkpoint": "0000000000000000000000000000000000000000000000000000000000000000",
"created_at": "2026-07-09T09:20:00.000000Z",
"checkpoint_hash": "1d78a3d9d7fbe21e18cdeaf6acec0eca75ad647a66b057e1e96873b37abbdec9",
"signature": "fbaeacd76624ef225988cb7b6c9112a25f57b8bab2e081659fa7ff17ca42a00828f5ebf77a613fbd31377e1fd10695e77cf7e63c3585bfd222c7eb1dd6770208",
"public_key": "2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12"
}
]
}
| Field | Meaning |
|---|---|
v |
Record version, 1 |
stream |
Stream UUID, must match the manifest |
from, to |
The sealed range, inclusive; consecutive checkpoints are contiguous |
root |
Merkle root (RFC 6962, SHA-256) over the entry hashes of events from..to |
prev_checkpoint |
The previous checkpoint's checkpoint_hash; 64 zeros for the first |
created_at |
Seal time, RFC 3339 UTC with microseconds |
checkpoint_hash |
SHA-256 of the checkpoint preimage: canonical JSON with keys v, stream, from, to, root, prev_checkpoint, created_at |
signature |
Ed25519 signature over the raw 32 bytes of checkpoint_hash, 128 hex characters |
public_key |
The signing key used; must appear in the manifest's signing_keys |
anchors.json#
A single JSON object with an anchors array — one entry per RFC 3161 timestamp token stored for a covered checkpoint. The array is empty when no anchors exist (anchoring is a Business/Enterprise capability and is forward-only, so older checkpoints may legitimately have none). An anchor proves, on a third party's authority, that the checkpoint hash existed at anchored_at — evidence not even the Sigilbase operator can forge retroactively.
{
"anchors": [
{
"checkpoint_hash": "1d78a3d9d7fbe21e18cdeaf6acec0eca75ad647a66b057e1e96873b37abbdec9",
"type": "tsa",
"provider": "freetsa",
"token": "MIIWjQYJKoZIhvcNAQcCoIIWfjCCFno...",
"token_hash": "58ff5f1c2a4a1c565bb2e51e37cc6d0208b1f96e0827b2ba822a9c98ea52f5e4",
"anchored_at": "2026-07-09T09:21:04.118240Z",
"ca_pem": "-----BEGIN CERTIFICATE-----\n...",
"provider_name": "FreeTSA",
"jurisdiction": null,
"qualified": false,
"signer_serial": "1B4F0E9851971998E732078544C96B36C3D01CEE"
}
]
}
| Field | Meaning |
|---|---|
checkpoint_hash |
The anchored checkpoint's hash; must appear in checkpoints.json |
type |
Always tsa in this version |
provider |
The configured TSA's key; informational |
token |
The DER TimeStampToken (an RFC 5652 CMS SignedData over a TSTInfo), base64 |
token_hash |
SHA-256 of the raw DER token, lowercase hex |
anchored_at |
When Sigilbase stored the token, RFC 3339 UTC |
ca_pem |
The TSA's certificate chain as configured server-side, or null. Convenience only: it is as trustworthy as whoever exported the bundle, so for full independence obtain the TSA's root yourself |
provider_name, jurisdiction, qualified, signer_serial |
1.3, informational: the provider's display name and jurisdiction, whether it was configured as a qualified trust service at anchor time, and the token signer certificate's serial. Statements by the exporting instance — the verifier reports them and never lets them influence a verdict |
The token's message imprint is SHA-256 over the raw 32 bytes of checkpoint_hash.
consistency.json#
Present only when the bundle starts at sequence 1 — the cumulative values below are recomputable (and therefore honest) only from the full log. The cumulative tree at size n is the RFC 6962 tree over entry hashes 1..n; checkpoint_states records it at every checkpoint boundary.
{
"v": 1,
"tree_size": 4,
"root": "d37ee418976dd95753c1c73862b9398fa2a2cf9b4ff0fdfe8b30cd95209614b7",
"checkpoint_states": [
{"tree_size": 2, "root": "fac54203e7cc696cf0dfcb42c92a1d9dbaf70ad9e621f4bd8d98662f00e3c125"},
{"tree_size": 4, "root": "d37ee418976dd95753c1c73862b9398fa2a2cf9b4ff0fdfe8b30cd95209614b7"}
],
"proof": {
"from_tree_size": 2,
"to_tree_size": 4,
"nodes": ["5f083f0a1a33ca076a95279832580db3e0ef4584bdff1f54c8a360f50de3031e"]
}
}
Record tree_size and root (the verifier also prints them after a passing run). They are what a future export proves itself against: php verify.php --consistency old.zip new.zip, or --consistency new.zip --root <hex> --size <n> with the recorded values. See Reading and proofs for a worked example.
redactions.json#
Always present in a 1.2 bundle, listing every event in the exported range whose payload was redacted — empty when none were. This is the declaration that makes an absent payload legitimate: the verifier fails any bundle where a payload is missing without an entry here.
{
"redactions": [
{
"sequence": 3,
"redacted_at": "2026-07-12T08:14:09.221004Z",
"declared_by": {
"stream": "admin-actions",
"sequence": 5,
"entry_hash": "b8c1f9d3d7fbe21e18cdeaf6acec0eca75ad647a66b057e1e96873b37abbde11"
}
}
]
}
| Field | Meaning |
|---|---|
sequence |
The redacted event's sequence in this stream |
redacted_at |
When the payload was destroyed, RFC 3339 UTC |
declared_by |
Reference to the payload.redacted ledger event that recorded the act: the stream slug it lives in, its sequence, and its entry_hash. It appends to the same stream normally, or to the tenant's sigilbase-system stream when the redacted stream was archived, and may fall outside the exported range — the reference is context for the auditor, not a value the verifier recomputes |
Redaction is deliberately narrow: it destroys payload content and nothing else. It cannot touch sequences, metadata, hashes, checkpoints, or anchors, and it always leaves a payload.redacted event in the ledger — so a bundle can prove content is absent and prove everything around the absence is intact.
How the verifier consumes a bundle#
The bundled verify.php is self-contained: no package manager, no network, no imports from Sigilbase. It is developed in the open at github.com/sigilbase/verifier — run the released copy from there, after comparing hashes, if you prefer not to trust the copy inside the bundle. It needs PHP 8.2 or newer and accepts either the zip or an extracted directory:
php verify.php admin-actions-evidence-20260709.zip
It performs, in order:
- Manifest checks: format id is
sigilbase-evidence/1,/1.1,/1.2,/1.3or/1.4,event_countmatches the range and the ndjson line count, sequences are dense and in order. - Event recomputation: for every line, recompute
payload_hashfrom the embedded payload andentry_hashfrom the preimage fields, and compare with the stated values. For a redacted line, thepayload_hashrecomputation is skipped — the content no longer exists — and the redaction must be declared:payload_statesaysredacted,payloadisnull, andredactions.jsonnames the sequence. Any absent payload that fails that triple agreement fails the bundle; each accepted redaction is reported plainly and counted in the summary. - Chain walk: each line's
prev_hashmust equal the previous line'sentry_hash. - Merkle roots: for each checkpoint, rebuild the RFC 6962 tree over the covered entry hashes and compare with
root. - Checkpoint chain and signatures: recompute each
checkpoint_hashfrom its preimage, walk theprev_checkpointchain, and verify eachsignatureagainst itspublic_key, which must be listed in the manifest. - Anchors (1.1, when present and non-empty): each token must match its
token_hash, parse as a CMS timestamp token whose imprint is the checkpoint hash, carry a verifying signature from its embedded timestamping certificate, and — whenca_pemis provided — chain to that root. This step needs PHP'sopensslextension; without it the verifier reports the anchors as present but unverified and does not fail for inability to check. Pass--skip-anchorsto skip the step silently. - Certificates (1.3, when the manifest lists any): each listed file must exist under
certificates/and match its manifestsha256— a listed-but-missing or altered certificate fails the bundle. Certificates are documents about the evidence; no cryptographic check depends on them. - SigilSign blocks (1.4, when present): every fact in
documents.json,signatures.json, andlinks.jsonis cross-checked against the events — a listed version'spublished_sequencemust be itsdocument.publishedevent carrying the samesha256; each signer'ssignature.viewedandsignature.signedevents must reference the samesha256, which is the document's; each link's sequences must resolve todocument.linked/document.unlinkedevents carrying the samesha256. A contradiction fails the bundle. Unknown fields — including any claim about legal effect or validity — are ignored and can never influence the verdict; signatures recorded here are simple electronic signatures and the format asserts nothing about their effect in any jurisdiction. - Consistency section (1.1, when present): every recorded cumulative root is recomputed from the events and the embedded proof re-verified.
Exit code 0 means every check passed. Any failure prints what broke and exits 1. Because every check is a recomputation from the bundle's own bytes, a bundle that passes cannot have been altered after export, whoever has held it in the meantime.
After a passing run over a bundle that starts at sequence 1, the verifier prints the cumulative consistency state (tree_size and root). Record those two values: they let a future export prove it extends this one, via php verify.php --consistency old.zip new.zip or --consistency new.zip --root <hex> --size <n>.