Evidence format
The .seal bundle in detail — records, the BLAKE3 hash chain, signed Merkle checkpoints, countersignatures, identity, time anchors and payload commitments.
The evidence format is the part of Sealr that has to outlive Sealr. An auditor should be able to verify a bundle in ten years with a tool nobody at this company wrote. So the format is specified, the reference implementation is Apache-2.0, and everything below is what the code actually does.
Reference implementation: crates/evidence, crates/bundle, crates/verify. Normative
specification: docs/03-EVIDENCE-FORMAT-SPEC.md.
Encoding
| Aspect | Choice |
|---|---|
| Serialization | JSON, canonicalized per RFC 8785 (JCS) for all hashing and signing |
| Hashes | BLAKE3-256, lowercase hex |
| Signatures | Ed25519 over the BLAKE3-256 digest of the JCS bytes of the signed structure |
| Timestamps | ts_wall RFC 3339 UTC with milliseconds; ts_mono_ns monotonic nanoseconds since segment start |
| Identifiers | ULID, 26-character Crockford base32 |
JSON over a compact binary encoding is a deliberate trade: an auditor can open a record in a
text editor. Every record carries schema_version so a CBOR/COSE migration remains possible.
Records
A record is one signed-into-chain unit describing an event. Types: operation, outcome,
approval, policy_event, lifecycle, coverage_gap, guard_error, spool_shed,
clock_anomaly, generic_exec.
The Console column marks fields included in the metadata projection. Everything else stays inside your boundary.
| Field | Console | Notes |
|---|---|---|
schema_version | yes | "1" |
record_id, stream_id, segment_id | yes | ULIDs |
seq | yes | Strictly monotonic per stream, no gaps within a segment |
ts_wall, ts_mono_ns | yes | Dual clocks |
record_type | yes | See the list above |
recorder | yes | recorder_id, version, host fingerprint, platform |
subject | yes | Agent kind, session, optional human principal, attribution quality, source |
action | yes | Integration, operation, verb class, target resource, labels — structural, no literals |
action_detail | no | Statement skeleton, resource addresses — may be sensitive |
payload_commitment | yes | alg and the salted hash |
payload_ref | no | Vault URL, aes-256-gcm, wrapping key reference |
verdict | yes | Decision, mode, risk class, policy version, rule ids, reason codes, guard, eval_ms |
approval_ref, links | yes | Approval that released the operation; outcome-to-request link |
prev_hash, record_hash | yes | Chain fields |
Committed versus stored
The distinction runs through the whole format. A record commits to the payload and stores structure:
- Committed, not stored: the operation payload — the SQL text, the plan JSON, the diff. What
the record holds is
BLAKE3(salt || payload_bytes). - Stored, locally:
action_detail, the guard's structural output — statement kinds, schema-qualified relations,WHEREskeletons with typed placeholders likeemail = <string>. Never projected to the Console. - Stored and projected: the classification and the verdict.
Writers reject unknown fields. Verifiers of a higher minor version preserve them and flag them, and the hash is computed schema-agnostically over the raw JSON so a record from a future minor version still chain-verifies.
The hash chain
record_hash_i = BLAKE3(JCS(record_i without record_hash))
record_i.prev_hash = record_hash_{i-1}The first record of a stream uses a genesis sentinel, BLAKE3("SEALR-GENESIS" || stream_id), so
a stream cannot silently start mid-air.
Verification asserts, per stream: hash correctness, prev_hash linkage, seq strictly +1
within a segment, ts_mono_ns non-decreasing, and ts_wall non-decreasing within a two-second
tolerance — a larger regression requires an adjacent clock_anomaly record or it is reported.
Findings accumulate rather than aborting, so one report tells you everything that is wrong.
Segments
One recorder boot is one segment. A segment opens with a lifecycle:segment_start record
carrying the recorder certificate chain, host fingerprint, recorder version, config digest,
policy bundle version and digest, the previous segment's final hash when locally known, and a
boot nonce. Clean shutdown appends lifecycle:segment_end and a final checkpoint. Its absence
is itself evidence of a crash, and the verifier reports it.
If local state is lost entirely, a segment opens with a distinct sentinel,
BLAKE3("SEALR-SEGMENT-BREAK" || stream_id || segment_id). Using it is a reported finding, not
a way to paper over a gap.
Checkpoints
Records are not individually signed. Integrity flows up to checkpoints, and a record counts as attested once a valid checkpoint covers it.
A CheckpointNote carries:
{
"checkpoint_id": "01J8Z...",
"stream_id": "01J8Y...",
"segment_id": "01J8Y...",
"seq_from": 0,
"seq_to": 999,
"chain_head_hash": "…",
"merkle_root": "…",
"tree_size": 1000,
"ts_wall": "2026-08-12T14:02:31.114Z",
"recorder_cert_fingerprint": "…",
"prev_checkpoint_hash": "…"
}The Merkle tree follows RFC 6962 conventions over BLAKE3: leaf hash is H(0x00 || leaf), node
hash is H(0x01 || left || right), and the tree splits at the largest power of two strictly
less than the leaf count. Leaves are the raw 32-byte record hashes over [seq_from, seq_to].
prev_checkpoint_hash links each checkpoint to its predecessor. Without it, removing an
interior checkpoint together with its records would leave a self-consistent set; with it, only
a suffix can be dropped — which is why the verifier's remaining worry is the tail and not the
middle.
The note is signed Ed25519 with the recorder's identity key.
Emission thresholds. The recorder emits a checkpoint every 1,000 records and at clean shutdown. The specification also calls for a 10-second activity timer; that timer is not implemented in V1. Short-lived surfaces — a hook invocation, a wrapped command — check point on close, so their records are attested; a long-running proxy accumulates up to 1,000 records between checkpoints.
Console countersignature
When the Console receives a checkpoint it verifies the recorder signature, checks that
seq_from equals its stored head plus one, and only then signs:
{
"checkpoint_id": "01J8Z...",
"received_at": "2026-08-12T14:02:33.900Z",
"console_key_id": "console-key-1",
"stream_high_water_seq": 41822
}stream_high_water_seq is the anti-rollback mechanism. It is the Console's signed assertion of
how far the stream had advanced, and a recorder-side attacker cannot forge a lower value. A
bundle whose records stop below a countersigned high-water mark is demonstrably incomplete.
A seq_from that does not match the stored head is a fork: the Console records a
chain_conflict and refuses to countersign the divergent branch.
Identity
Sealr Root (offline)
└── Tenant CA (per tenant, Console-held)
└── Recorder identity certificate (public key from an on-device keypair)All three are Ed25519. Validity: 3650 days for the root, 730 for a tenant CA, 90 for a recorder certificate. The recorder generates its keypair at enrollment and sends a CSR; the private key never leaves the device.
Validation is fully offline: the verifier walks the leaf up through the bundled intermediates to a trusted root, checking each signature, each validity window against the evaluation time, and CA basic constraints at every hop.
Revocation is a Console-signed list of certificate fingerprints with effective times. A checkpoint signed after the effective time is invalid and the verifier reports the boundary rather than silently dropping the records.
V1 stores the recorder private key in a file, obfuscated with a host-derived keystream and
written with restrictive permissions. That is obfuscation, not encryption: anyone who can read
the file on that host can sign as that recorder. TPM 2.0, Secure Enclave and OS-keychain
backends are enumerated in the code and not implemented. sealr doctor reports the backend and
flags the file fallback as a downgrade.
Time anchors
Anchoring binds checkpoints to a time nobody in the system controls.
The Console batches checkpoint note digests into a Merkle tree. Each batch record carries the
root twice — root_blake3, and root_sha256 as SHA-256 over the raw 32-byte BLAKE3 root, which
is the RFC 3161 message imprint, since TSAs do not speak BLAKE3. The verifier checks that
binding before it trusts anything else about the batch. Per-checkpoint inclusion proofs are
stored alongside, and the leaf must equal the digest of the checkpoint note actually present in
the bundle.
Token verification is offline: parse TimeStampResp or a bare ContentInfo, confirm the
message imprint, verify the CMS SignedData signature (RSA PKCS#1 v1.5 with SHA-256/384/512)
against the signer certificate embedded in the token, and read genTime. Qualification status
is reported as recorded by the exporter, not independently validated against an EU trust list.
More anchors never invalidate a bundle — tokens accumulate.
Implementation status. The format, the bundle structures, the dev TSA used for test vectors
and the offline verifier are implemented. The hosted Console does not yet submit checkpoints to
a timestamp authority — TSA_URL is present in its configuration and nothing consumes it. The
daily consolidated root, its eIDAS qualified timestamp and public publication are specified and
not implemented. Bundles produced today therefore carry no anchors, and the verifier reports no
earliest anchor.
Payload commitments and selective disclosure
commitment = BLAKE3(salt || payload_bytes) salt: 32 random bytesThe salt is never in the record. It travels with the payload in the vault, or is discarded in commitment-only mode. Three consequences follow, and they are the point of the design:
- A metadata-level bundle verifies fully — chain, checkpoints, signatures, anchors — with no payloads present at all.
- Whoever holds the payload and its salt can prove to anyone that this payload matches this record, without revealing any other payload. That is the selective-disclosure path: hand a court one statement, not a database.
- Without the salt the commitment reveals nothing practical about a low-entropy payload. Two commitments over the same bytes differ. Nobody can confirm a guess by hashing a dictionary — which is exactly why the salt must not travel with the record.
The V1 recorder runs in commitment-only mode: it generates a salt per record, computes the
commitment and discards the salt immediately, and never populates payload_ref. Selective
disclosure of recorder-produced records is therefore not available in V1 — the format and
the Ledger support vaulted payloads (AES-256-GCM per-payload data key, wrapped by a customer KMS
key, key_ref naming the wrapping key and never key material), but the recorder does not use
that path yet.
The .seal bundle
A deterministic zip container. Media type application/vnd.sealr.bundle+zip as declared by the
specification; IANA registration is not complete.
manifest.json # bundle id, created_at, tool, scope, content hashes
records/<stream>.part-00001.ndjson.zst # canonical JCS record lines, zstd, 512 MB shards
records/index.json # per-stream shard list and seq range
checkpoints/<stream>.json # signed checkpoints + countersignatures
anchors/rfc3161/<batch_id>.tsr # RFC 3161 tokens
anchors/qts/<day>.der # qualified timestamp tokens
anchors/proofs.json # batches, inclusion proofs, daily roots
identity/certs.pem # certificate chain
identity/revocations.json # signed revocation list
identity/roots.json # bundle-pinned roots
vault/ # optional, customer-side exports only
VERIFY.md # how to verify offline, verifier version pinsThe record lines in the bundle are byte-for-byte the lines from the recorder's spool: the spool bytes are the bundle bytes, so export cannot reshape evidence.
manifest.json lists every other entry with its BLAKE3 hash and byte length, and is itself
JCS-hashed. The exporter prints that hash:
sealr export -o evidence.seal --level fullUse --level metadata for a bundle with no vault entries. Quote the manifest hash when you hand
a bundle to someone: it is the one value that identifies this exact export.
Verification procedure
The verifier must, in order: parse the manifest and check schema versions; validate certificate
chains to a trusted root; apply the revocation list and compute validity windows; verify every
record hash, link, sequence and clock rule per stream; verify every checkpoint signature,
recompute every Merkle root, verify countersignatures; verify RFC 3161 and qualified tokens and
bind each covered checkpoint to its earliest proven time; then cross-check coverage — unanchored
tails, segments with no clean end, coverage_gap records, revocation boundaries.
Output is a human report plus machine JSON: result of pass, pass_with_findings or fail,
the findings, what was proven (record count, span, earliest anchor, attested and anchored
counts), and the standing limits. Exit codes: 0, 10, 11, and 30 for a usage error.
Findings carry stable machine classes — record_hash_mismatch, link_broken,
anchor_inclusion_invalid, anchor_digest_binding_broken, tail_completeness_unproven — so
they can be asserted on in CI.
Conformance and licensing
sealr-evidence, sealr-bundle and sealr-verify are Apache-2.0. A third party can implement
a writer or a verifier without Sealr's involvement, which is the only version of "verify it
yourself" that means anything.
The repository publishes golden bundles: one valid, and one per single-fault class — bad hash, reordered records, sequence gap, forged checkpoint, revoked key, bad TSA token, fork. CI runs the verifier against all of them, and a property test asserts that any single-bit mutation of any record produces a finding.
Next
- What Sealr proves — the claims this format supports, and the ones it does not.
- Verify a bundle — running the verifier and reading its output.
What Sealr proves
The exact claims a verified Sealr bundle supports, the threat each mechanism addresses, and the claims it deliberately does not make.
Writing policies
Author Sealr policy YAML — bindings, rules, the four decisions, per-team and per-agent rules, and how a signed bundle reaches your recorders.