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, each with its created_at and retired_at (null while active). The verifier reads these to know which key signed which checkpoint; where it holds a trusted entry for a key, that entry's window is the one that decides |
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 |
A bundle cannot establish whose keys it carries: anyone can produce a hash chain, sign it with a key of their own, and list that key here. That is what the verifier's trusted key set is for. The released verifier carries Sigilbase's signing keys compiled into it, matches them on the public key bytes, and reports a bundle signed by any other key as UNCONFIRMED rather than as verified. You can replace that set with --keys, using keys you fetched yourself from GET /api/v1/keys or obtained out of band, and check against those instead.
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, and created_at must fall inside that key's created_at..retired_at window - a retired key cannot vouch for a later checkpoint |
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/1through/1.5,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 absence must be declared:payload_statesaysredactedandpayloadisnull. 2a. Declarations (1.5): the absence is then accepted only against a declaration the bundle carries indeclarations.ndjsonwhose entry hash recomputes, whose payload matches its payload hash, whose action matches the kind of absence, whose targets name that exact stream and sequence, and which was sealed in a checkpoint whose signature verifies inside its key's trusted window. A declaration outside the exported range travels with an audit path and its sealing checkpoint indeclaration_proofs.json.redactions.jsonremains as a convenience index and decides nothing. A bundle below format 1.5 that carries an absent payload does not verify, and says to export the range again. - 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 (valid at the token's time), and - whenca_pemis provided - chain to that root through issuers that are each a certificate authority (basicConstraintsCA, permitted to sign certificates, valid at the token's time, within any path-length limit); a chain through an ordinary end-entity certificate fails. 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.
What the verifier reports#
Five results, because what was proved and who proved it are different questions:
| Result | What it covers |
|---|---|
| Content integrity | The hash chain, payload hashes, Merkle roots and checkpoint signatures |
| Signing identity | Whether those signatures are by a key the verifier trusts, sealed inside that key's window |
| Timestamps | The RFC 3161 anchors, against trust roots the verifier carries |
| Scope | The range and completeness the manifest claims |
| Redactions | Whether every absent payload is named by an authenticated declaration |
And four exit codes:
| Exit | Word | Meaning |
|---|---|---|
| 0 | PASS | Every result that was checked holds |
| 1 | FAIL | Content integrity, redactions or a key window failed |
| 2 | ERROR | Usage, an unreadable bundle, or an unknown format |
| 3 | UNCONFIRMED | The maths holds, but the signing identity or the timestamps could not be confirmed |
Exit 3 is worth understanding before you meet it. It means the bundle is internally sound - nothing in it has been altered - and its origin is not established: it was signed by a key the verifier does not carry, or its timestamps chain only to a root the bundle itself supplied. That is not evidence of tampering and it is not a pass. You will see it on a bundle from a private deployment, on a test bundle, and on a bundle somebody rebuilt and re-signed.
Because every integrity check is a recomputation from the bundle's own bytes, a bundle whose content integrity passes cannot have been altered after export, whoever has held it in the meantime.
Every run prints the verifier's own version and sha256. Compare them with the release notes before relying on what it tells you: a verifier is only as trustworthy as the file you actually ran.
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>. --print-hashes adds the sha256 of the bundle file itself, so the record says exactly which bundle produced the root you kept.