Skip to content
Sigilbase

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:

  1. Manifest checks: format id is sigilbase-evidence/1, /1.1, /1.2, /1.3 or /1.4, event_count matches the range and the ndjson line count, sequences are dense and in order.
  2. Event recomputation: for every line, recompute payload_hash from the embedded payload and entry_hash from the preimage fields, and compare with the stated values. For a redacted line, the payload_hash recomputation is skipped — the content no longer exists — and the redaction must be declared: payload_state says redacted, payload is null, and redactions.json names the sequence. Any absent payload that fails that triple agreement fails the bundle; each accepted redaction is reported plainly and counted in the summary.
  3. Chain walk: each line's prev_hash must equal the previous line's entry_hash.
  4. Merkle roots: for each checkpoint, rebuild the RFC 6962 tree over the covered entry hashes and compare with root.
  5. Checkpoint chain and signatures: recompute each checkpoint_hash from its preimage, walk the prev_checkpoint chain, and verify each signature against its public_key, which must be listed in the manifest.
  6. 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 — when ca_pem is provided — chain to that root. This step needs PHP's openssl extension; without it the verifier reports the anchors as present but unverified and does not fail for inability to check. Pass --skip-anchors to skip the step silently.
  7. Certificates (1.3, when the manifest lists any): each listed file must exist under certificates/ and match its manifest sha256 — a listed-but-missing or altered certificate fails the bundle. Certificates are documents about the evidence; no cryptographic check depends on them.
  8. SigilSign blocks (1.4, when present): every fact in documents.json, signatures.json, and links.json is cross-checked against the events — a listed version's published_sequence must be its document.published event carrying the same sha256; each signer's signature.viewed and signature.signed events must reference the same sha256, which is the document's; each link's sequences must resolve to document.linked/document.unlinked events carrying the same sha256. 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.
  9. 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>.