PlatformFleet Evidence
Cluster
Evidence Export
Receipts, checkpoints, proofs, and lineage leave as one digest-bound directory that an outside party can verify offline, with no store and no network.
Which half of the boundary this is
A directory, not a stream
chio evidence export writes a directory. The output path must not exist or must be empty; a non-directory path and a directory with any entry in it are both refused before a byte is written. What lands there is twelve files, up to three more when a policy or a federation policy is attached, and manifest.json, which lists the SHA-256 and byte length of every file except itself. The whole feature is shipped: export, verify, import, and federation-policy create are subcommands in chio-cli, and the remote path is POST /v1/evidence/export and POST /v1/evidence/import on the trust control plane.
The verifier is the point. chio evidence verify --input <dir> dispatches to a function handed the input directory and an output flag and nothing else: no store handle, no control-plane client, no key material. It re-reads the directory, re-derives every count the manifest asserts, checks every signature the package carries, and replays every inclusion proof against the Merkle root in the checkpoint that proof names. An outside party runs it on a copy with the network unplugged.
Verified means internally consistent, not trusted
receipt.kernel_key and checkpoint signatures against checkpoint.body.kernel_key, both carried inside the package. Nothing in chio evidence verify compares those keys to a trust root, and the manifest itself is not signed. A pass means the bytes are unaltered and every derived quantity agrees with them. Deciding whose keys those are is the recipient’s job, and the code refuses to do it silently: the import round-trip test seeds the partner’s kernel key as a local authority seed on purpose, and the federation policy’s own signer is deliberately not auto-trusted.What is in the package
| File | Holds |
|---|---|
manifest.json | Schema chio.evidence_export_manifest.v1, export timestamp, the query, counts, proof coverage, the receipt semantic summary, the child-receipt scope, the transparency claim boundary, the file digest list, and optional policy, federation-policy, and disclosure-notice metadata. Unsigned, and not in its own list. |
query.json | The normalized query the bundle was built from. Must equal the manifest’s copy. |
receipts.ndjson | Signed tool receipts, each with its stable claim-log sequence. |
child-receipts.ndjson | Signed child request receipts, subject to the scope flag below. |
checkpoints.ndjson | Kernel-signed checkpoint bodies covering the exported sequence range. |
checkpoint-publications.ndjson, -witnesses, -consistency-proofs, -equivocations | The derived transparency summary, one publication per checkpoint. Verification reads all four with an optional reader, so a package missing them is treated as carrying empty lists and then fails on the count comparison unless it also has no checkpoints. The equivocation file is empty in any package that verifies: the derived summary only exists when no equivocation was found, and the supplied file must equal it exactly. |
capability-lineage.ndjson | The combined delegation chain of every capability the exported receipts cite, deduplicated by capability id, so ancestors land here too. Each snapshot carries either its signed token or an unsigned fed-del- federation anchor. |
inclusion-proofs.ndjson | One Merkle path per checkpointed receipt, naming its checkpoint sequence, leaf index, and root. |
retention.json | Live-database size and oldest live receipt timestamp, narrowed for a tenant-scoped export. |
README.txt | Plain-text restatement of the claim boundary, the counts, the child-receipt scope, and the disclosure notice when there is one. |
policy/source.<ext>, policy/metadata.json | Optional. The policy file passed to --policy-file, plus its format name and its source and runtime hashes. |
federation-policy.json | Optional. The signed bilateral policy the export was constrained by. |
The manifest is a commitment, not an authority. Six of its fields are re-derived at verification time from the data next to them: counts, proof_coverage, receipt_semantics, child_receipt_scope, disclosure_notice, and claim_boundary. That is why a tampered receipt is caught even when the attacker refreshes the file digest to match:
struct EvidenceExportManifest {
schema: String,
exported_at: u64,
query: EvidenceExportQuery,
counts: EvidenceExportCounts,
proof_coverage: EvidenceProofCoverage,
receipt_semantics: EvidenceReceiptSemanticSummary,
child_receipt_scope: EvidenceChildReceiptScope,
claim_boundary: Option<EvidenceTransparencyClaims>,
files: Vec<EvidenceExportFileHash>,
policy: Option<PolicyAttachmentMetadata>,
federation_policy: Option<FederationPolicyAttachmentMetadata>,
disclosure_notice: Option<EvidenceDisclosureNotice>,
}receipt_semantics counts receipt kinds, boundary classes, and an authorized total that increments only for a receipt whose id recomputes from its body, whose signature and action hash both verify, and which ChioReceipt::is_allowed accepts: trust level Mediated, kind MediatedDecision, boundary class Prevent, no observation outcome, decision Allow. Editing a receipt body moves that total, so the semantic-summary check trips before the raw signature pass. That is what evidence_verify_detects_tampered_receipt_even_if_manifest_hash_is_updated asserts, byte for byte, in the CLI integration suite.
How an export is assembled
The query is settled before the store is touched. prepare_evidence_export verifies an attached federation policy’s signature, merges its query with the requested one, and validates the result. The merge is intersection, not union: an exact scope field present in both must match or the export fails with falls outside the signed federation policy; since takes the maximum, until the minimum, and a window that does not overlap is an error. require_proofs is the logical OR of the flag and the policy. A read boundary is mandatory: a query with none is refused, and when a policy is attached the boundary must come from the policy, so a caller cannot pick its own.
Then the store builds the bundle. Tool receipts page through the ordinary receipt query with the boundary applied, and each receipt is paired with its claim_receipt_log_entries sequence; a receipt missing from the claim log fails the export rather than exporting unsequenced. Checkpoints are every checkpoint whose batch range overlaps the exported minimum and maximum sequence. Lineage is the combined delegation chain for each cited capability, and every snapshot must pass transport validation, which re-verifies the signed token against the projected fields and refuses a legacy projection outright. A snapshot whose provenance is a federation anchor has no token to verify: transport validation only checks that its id is fed-del- plus 64 hex characters and that it puts nothing in the signed lineage fields. Inclusion proofs are rebuilt, not stored: for each checkpoint the store re-reads the canonical bytes of the whole batch, rebuilds the Merkle tree, and emits a path for each exported receipt inside it. Receipts with no covering checkpoint go to uncheckpointedReceipts, and --require-proofs turns a non-empty list into a failed export naming the first five as receipt_id@seq. Only the count reaches the package; the ids live in the in-memory bundle and in that error string, not in a file.
Child receipts carry an honest scope flag
Child request receipts have no capability or agent attribution, so the export refuses to imply one. The scope is derived from the query alone and recorded in the manifest: full_query_window only for an admin-all export with no tenant, capability, or agent filter, since a time window on its own does not narrow it; time_window_context_only when a capability or agent filter is paired with a time window; and omitted_no_join_path for any tenant-scoped or tenant-filtered export and for a capability or agent filter with no time window. Verification recomputes the expected scope from the query and rejects both a mismatch and the presence of child receipts under an omitted scope.
The cross-tenant disclosure a signed checkpoint cannot avoid
A tenant-scoped export contains only that tenant’s receipts. The signed checkpoint bodies it inherits cover the full per-batch Merkle tree, which spans tenants by construction, so the package necessarily reveals seven checkpoint body fields (batch_start_seq, batch_end_seq, tree_size, merkle_root, checkpoint_seq, issued_at, previous_checkpoint_sha256) and three derived publication fields (entry_start_seq, entry_end_seq, log_tree_size). Rather than hide that, a tenant-scoped export attaches a fixed notice (chio.evidence_export_disclosure_notice.v1) listing those ten fields, the metadata narrowed relative to an admin-all export, and a protocol reference. The notice is canonical text: verification rebuilds it from the query and rejects a package that strips it, tampers with it, or attaches it to an admin-all export. Eliminating the disclosure needs per-tenant subtree proofs, which the protocol does not have.
Where it runs, and what the cluster does with it
Export reads one store. Against --receipt-db it opens the file directly; against --control-url it posts the query and receives the bundle. Passing both is refused, passing neither is refused. On the service side the handler resolves a read principal first: a tenant read token cannot request an admin-all export, cannot name another tenant, and has an omitted boundary filled in with its own tenant. That check runs twice, once on the request and again on the query that came out of the federation-policy merge, so a signed policy cannot widen a tenant token.
Import is the write, and it is the one clustered operation here. POST /v1/evidence/import authenticates against the full service token rather than a read principal, so a tenant read token can export and cannot import at all. It then goes through forward_post_to_leader like any other trust-control write, so it inherits the quorum check, the lease check, and the 503 vocabulary described in Leader & Failover. Export forwards nothing; it is a read, answered by whichever node received it, from that node’s own disk.
An imported share is leader-local and stays there
federated_evidence_shares, federated_share_tool_receipts, and federated_share_capability_lineage. It never appends to the local claim log, which is what keeps a partner from rewriting your history, and it is deliberately visible only as imported trust. It is also not replicated: the four delta pullers cover budgets, tool receipts, child receipts, and lineage, revocations pull on their own round, and no code outside chio-store-sqlite names those three tables at all. Import to a three-node cluster and the share lives on the node that handled the write.Every import needs an attached signed federation policy. build_federated_share_import refuses a package without one, with the reason stated in the code ("so remote receipt sharing stays bilateral and explicit"), and names the share share-<sha256> over a canonical descriptor holding the manifest and that policy. The mechanism is Cluster. The authorization it insists on is Federation.
What verification actually checks
load_verified_evidence_package is one pipeline used by all three verbs: verify runs it and prints, import runs it and then builds the share, and chio-mercury runs its summary form to obtain a manifest digest. Every step below is a hard failure, and the order is the order in the function: a package that fails an early structural check never reaches the proof replay.
| Pass | What it refuses |
|---|---|
| Manifest schema and file digests | An unsupported manifest schema, a duplicate file entry, a path that is absolute or escapes the package root, a digest mismatch, a byte-length mismatch. |
| Query agreement | query.json differing from the manifest query. |
| Counts, coverage, semantics | Any count that disagrees with the rows on disk, proof coverage that is not the arithmetic difference, and a receipt semantic summary that does not recompute. |
| Disclosure notice | A tenant-scoped package with the notice stripped or altered, and an admin-all package carrying one. |
| Policy attachments | Policy metadata that differs from the manifest, a missing policy source file, a federation policy whose metadata or signature does not check out, an export timestamp outside the policy validity window, a query exceeding the policy scope, and uncheckpointed receipts under a require_proofs policy. It does not re-derive the policy’s source_hash or runtime_hash; the source file is only checked to exist, and its bytes are bound by the manifest digest list. |
| Lineage | A duplicate capability snapshot, or one that fails transport validation (legacy provenance, a signed token that does not verify, fields that do not match the token). |
| Query scope, per record | A receipt outside the tenant scope, capability filter, or time window; an agent-filtered receipt whose lineage subject does not match; a child-receipt scope that disagrees with the query. |
| Signatures | A duplicate sequence, a receipt signature or action hash that fails, a child receipt signature that fails, an unsupported checkpoint schema, a checkpoint signature that fails. |
| Checkpoint continuity | Two checkpoints at one sequence with conflicting digests (reported as equivocation), a broken predecessor link, transparency records that do not match the signed checkpoint set. |
| Inclusion proofs | A proof naming a missing checkpoint or receipt, a root that differs from the checkpoint body, a leaf index at or beyond the tree size, a receipt sequence outside the checkpoint batch, a duplicate proof, a path that does not verify against the canonical receipt bytes, and a derived uncheckpointed count that differs from the manifest. |
| Claim boundary, last | A claim boundary that does not equal the one rebuilt from the data. A package claiming trust_anchored without publications carrying valid, agreeing trust-anchor bindings fails here. A manifest that omits claim_boundary skips this pass, because there is then no claim to check. |
Export, verify, then break it
Both verbs run against a local receipt database with no cluster and no network. The package below comes from a node that had answered exactly two governed calls, one allow and one deny.
$ chio --receipt-db .chio/receipts.db evidence export --admin-all --output ./evidence
$ ls evidenceREADME.txt capability-lineage.ndjson checkpoint-consistency-proofs.ndjson checkpoint-equivocations.ndjson checkpoint-publications.ndjson checkpoint-witnesses.ndjson checkpoints.ndjson child-receipts.ndjson inclusion-proofs.ndjson manifest.json query.json receipts.ndjson retention.json
$ chio evidence verify --input ./evidenceevidence package verified tool_receipts: 2 child_receipts: 0 checkpoints: 0 checkpoint_publications: 0 checkpoint_witnesses: 0 checkpoint_consistency_proofs: 0 checkpoint_equivocations: 0 capability_lineage: 2 inclusion_proofs: 0 uncheckpointed_receipts: 2 authorized_receipts: 1 trace_observations: 0 advisory_evaluations: 0 verified_files: 12 child_receipt_scope: FullQueryWindow transparency_preview_logs: 0 publication_state: transparency_preview
verified_files: 12 is the first pass in the table: every file the manifest lists was re-hashed and re-measured. authorized_receipts: 1 counts the allow, and the deny is the other row. Rewrite that deny in a copy of the exported receipts.ndjson so it reads as an allow, which is the edit an exporter would most want to get away with, and the manifest digest catches it before any signature is checked.
$ cp -R ./evidence ./evidence-flipped
$ python3 - <<'PY'
$ import json, pathlib
$ p = pathlib.Path('./evidence-flipped/receipts.ndjson')
$ lines = p.read_text().splitlines()
$ obj = json.loads(lines[1])
$ obj["receipt"]["decision"] = {"verdict": "allow"}
$ lines[1] = json.dumps(obj, separators=(",", ":"))
$ p.write_text("\n".join(lines) + "\n")
$ PY
$ chio evidence verify --input ./evidence-flippederror [urn:chio:error:attest:provenance-missing]: evidence package file hash mismatch for receipts.ndjson
context: {"domain":"attest","severity":"error","stability":"unstable","string_code":"CHIO-ATTEST-PROVENANCE-MISSING"}
suggested fix: Regenerate the evidence bundle and include provenance before submitting the operation.The refusal names the file, not the receipt, because the manifest digest pass runs before the signature pass and a package that fails an early structural check never reaches the later ones. Repairing the manifest to match would move the refusal down to the signature pass, where the receipt’s own kernel signature no longer covers the edited body.
The require_proofs row in the table is the same shape and refuses at export rather than at verify, since these two receipts predate any checkpoint:
$ chio --receipt-db ./.chio/receipts.db evidence export \
--output ./evidence-proofs --admin-all --require-proofserror [urn:chio:error:attest:provenance-missing]: evidence export requires checkpoint coverage, but 2 receipt(s) are uncheckpointed: f8128d2289c69d835348f229edfdd2714a8b3c6f064f4f7324b652a299e9a4b3@1, e7a4fc95979cc9b31d8b740c27cd58f1fde209aac323aaeac115544d82be2ee7@2
context: {"domain":"attest","severity":"error","stability":"unstable","string_code":"CHIO-ATTEST-PROVENANCE-MISSING"}
suggested fix: Regenerate the evidence bundle and include provenance before submitting the operation.Both receipt ids are printed with their entry sequence, so the operator knows which rows to checkpoint rather than which flag to drop.
One deliberate looseness is documented in place. Package verification has no policy input, so it cannot enforce an operator’s configured crypto floor and says so rather than guessing:
// Evidence package verification has no policy.crypto_floor input.
// Keep the compatibility floor explicit here: accept classical and hybrid receipts, while policy-bearing callers enforce
// their configured floor before export.
if !record
.receipt
.verify_signature_with_floor(ReceiptCryptoFloor::AllowHybrid)Scoped disclosure: a subgraph of a signed root
The enterprise path solves a different problem. A transaction passport signs one evidence graph, and a recipient should see only the nodes their verifier family needs. Rather than re-sign a smaller graph, chio proof verify slices it: it keeps nodes whose role belongs to the family, keeps only edges whose endpoints both survived, and hands the verifier the scoped bytes plus the untouched root bytes. The passport it passes carries the scoped digest.
let mut signed_passport = passport.clone();
signed_passport.evidence_graph_sha256 = super::sha256_hex(signed_evidence_graph_bytes);
verify_transaction_passport_signature(&signed_passport, trusted_root_signer_keys)?;
if signed_evidence_graph_bytes != scoped_evidence_graph_bytes {
validate_scoped_evidence_graph_subset(
signed_evidence_graph_bytes,
scoped_evidence_graph_bytes,
)?;
}The signature is checked against a clone carrying the root digest, so the issuer still signed the whole graph. The subset check then requires every scoped node and every scoped edge to appear in the signed root. Downstream, artifact binding runs against the scoped bytes and the passport’s own scoped digest. Omission is allowed, addition is not, and the recipient never receives the roles they were not given.
Entry identity in the subset test is serde_json::to_string of the parsed entry. The workspace does not enable preserve_order, so object keys are already sorted on both sides and neither key order nor whitespace matters; numeric re-formatting does, since 1.0 where the root wrote 1 is a different key. The signed root bytes themselves have to stay byte-exact regardless, because the passport signature is checked over their SHA-256.
The enterprise artifacts it validates
chio-enterprise-export verifies a scoped graph whose nodes are typed artifacts, each keyed to the passport and to one risk comptroller report that all five must cite identically. Every artifact struct in that crate is deny_unknown_fields, so an unexpected key is a parse failure rather than an ignored one.
| Schema | Proves | Representative refusal |
|---|---|---|
chio.enterprise.data-governance-report.v1 | claim.enterprise.data_governance_bound | Observed region outside the allowed list; any legal hold status other than not_held; a retention class that is not audit-<days>d with at least 365 days; a field classified pii whose export action is not redacted. |
chio.enterprise.evidence-export-bundle.v1 | claim.enterprise.evidence_export_digest_bound | A bundle digest that is not the SHA-256 of the canonical artifact list; an artifact whose bytes do not hash to its listed digest; a duplicate role; a missing one of the six required roles; a verifier report whose verdict is not verified; issuance outside the approval window. |
chio.enterprise.approval-case.v1 | claim.enterprise.export_approval_bound | A decision that is not approved for subject evidence-export; fewer unique approvers than the required quorum; expiry at or before issuance; a signature outside the sig-ed25519:<key>:<sig> form or from an untrusted signer. |
chio.enterprise.telemetry-projection.v1 | claim.enterprise.telemetry_projection_bound | An event whose artifact digest does not match the bundle bytes; a siem_export event with no receipt; a receipt signed by an untrusted kernel key; a receipt whose content hash, tool name, or action parameters do not bind the event; a projection missing allow, denied_guard, or risk_verifier. |
chio.enterprise.control-evidence-map.v1 | claim.enterprise.control_map_bound | A control citing a claim outside the five enterprise claims; a gate reference with no matching graph node; a node whose bytes or declared schema do not match; a gate whose schema does not prove the cited claim. |
The trust model here is the opposite of the offline verifier’s. Passport signers, approval signers, risk comptroller signers, and telemetry receipt kernel keys are all supplied by the operator, and the CLI reads them from pinned environment variables such as CHIO_ENTERPRISE_TRUSTED_APPROVAL_KEYS with no default: an unset variable fails the run with CHIO_ENTERPRISE_TRUSTED_APPROVAL_KEYS must pin trusted enterprise approval signer keys rather than accepting anyone.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | A package verifies with no store, no network, and no key material, and every count in the manifest is re-derived from the data rather than trusted. | cmd_evidence_verify; evidence_export/verification.rs |
| Proved by test | Editing a receipt body and refreshing its manifest file digest still fails, on the semantic summary before the signature pass. | evidence_verify_detects_tampered_receipt_even_if_manifest_hash_is_updated |
| Proved by test | A tenant-scoped export carries only that tenant’s receipts, omits the cross-tenant live database size, and documents the checkpoint fields it cannot avoid disclosing. | tenant_evidence_export_omits_cross_tenant_metadata, tenant_evidence_export_documents_metadata_disclosure, admin_all_evidence_export_omits_tenant_disclosure_notice |
| Proved by test | An export whose requested scope exceeds the signed federation policy fails before any file is written, and a compliant one round-trips through export, verify, and import against both a local store and a live control plane. The control-plane case skips itself where loopback binding is denied. | evidence_export_rejects_scope_outside_federation_policy, evidence_export_with_signed_federation_policy_roundtrips, evidence_export_supports_remote_trust_control_with_federation_policy |
| Proved by test | An imported share raises imported trust without touching local history: the importer’s own receipt count stays zero while the share reports its two receipts under the issuer and partner names. | evidence_import_roundtrip_surfaces_imported_trust_without_rewriting_local_history |
| Limit | Verification establishes internal consistency, not provenance. Signing keys are read out of the package, the manifest is unsigned, and no trust root is consulted. | verify_checkpoint_signature, ChioReceipt::verify_signature |
| Limit | A capability snapshot whose provenance is a federation anchor carries no signature. Transport validation checks its shape, not its authority, so lineage in an exported package is only as strong as its weakest snapshot. | CapabilitySnapshot::validate_synthetic_anchor |
| Limit | The crypto floor is fixed at AllowHybrid. A package cannot be checked against a stricter configured floor; policy-bearing callers must enforce that before export. | Comment and call in verify_tool_receipts |
| Limit | Only files listed in the manifest are checked, and nothing requires the manifest to list every file the verifier reads. An extra file dropped into the directory is neither verified nor rejected. retention.json and README.txt are digest-bound but never re-derived, so the tenant narrowing in retention metadata is taken on faith. | verify_manifest_file_hashes iterates manifest.files only; no retention check in verification.rs |
| Limit | Child receipts are time-window context or omitted whenever a capability, agent, or tenant filter applies. The scope flag is truthful, not a workaround. | EvidenceExportQuery::child_receipt_scope |
| Limit | An imported share is written on the node that handled the import and is not replicated to peers. Local readers exist (reputation scoring, the shared-evidence and operator reports), but no replication path does. | peer_pullers in cluster/deltas.rs; the three federated_share tables appear only inside chio-store-sqlite |
| Not claimed | Transparency-log semantics. Unless every publication carries a valid trust-anchor binding on one shared anchor, the package states transparency_preview and names log identity and append-only growth as preview claims, in the manifest and in the README. | build_evidence_transparency_claims, trusted_publication_anchor |
| Unsupported | Per-tenant subtree proofs. Removing the cross-tenant checkpoint disclosure is a protocol change, and the notice says so instead of implying the export is fully isolated. | tenant_scoped_disclosure_notice |
| Unsupported | Importing a package with no attached federation policy. There is no unilateral import path. | build_federated_share_import |
Next Steps
- Receipt Aggregation · the log this reads, the streams that replicate, and the checkpoint chain that does not
- Federation Overview · the counterpart half: the signed bilateral policy, its scope fields, and what a partner may ask for
- Leader & Failover · why import forwards and export does not, and what a forwarded write returns when it cannot proceed
- Node State on Disk · the single-writer store the bundle is read out of
- Proof Room · the verifier that recomputes a bundle report from source inputs, including the enterprise family above