Receipts & Audit
Every tool call that reaches the kernel produces a signed receipt, whether the call was allowed, denied, cancelled, or interrupted. Receipts are the immutable audit trail. They carry the policy hash applied, per-guard evidence, the kernel signing key, and an algorithm envelope. Periodic Merkle checkpoints commit batches of receipts to a root that can be anchored out of band so deletion or rewriting is detectable.
Source
crates/chio-core-types/src/receipt.rs, crates/chio-core-types/src/canonical.rs, crates/chio-kernel/src/checkpoint.rs, crates/chio-kernel/src/receipt_store.rs, and the SQLite bootstrap at crates/chio-store-sqlite/src/receipt_store/bootstrap.rs.ChioReceipt
The signed-on-the-wire shape:
pub struct ChioReceipt {
pub id: String, // UUIDv7 recommended
pub timestamp: u64, // Unix seconds
pub capability_id: String, // The capability that was exercised
pub tool_server: String,
pub tool_name: String,
pub action: ToolCallAction, // Parameters + parameter_hash
pub decision: Decision, // The kernel verdict
pub content_hash: String, // SHA-256 of evaluated content
pub policy_hash: String, // SHA-256 of the applied policy
pub evidence: Vec<GuardEvidence>, // Per-guard records
pub metadata: Option<serde_json::Value>, // Stream / accounting details
pub trust_level: TrustLevel, // Mediation strength
pub tenant_id: Option<String>, // Multi-tenant isolation
pub kernel_key: PublicKey, // For verification without lookup
pub algorithm: Option<SigningAlgorithm>, // Absent means Ed25519
pub signature: Signature, // Over canonical body
}Decision
The receipt's decision is a four-variant enum. Note this differs from the kernel's in-flight Verdict (Allow / Deny / PendingApproval): the receipt records the terminal outcome, which includes interruption and incomplete states.
pub enum Decision {
Allow,
Deny { reason: String, guard: String },
Cancelled { reason: String },
Incomplete { reason: String },
}On the wire the variant is tagged verdict with snake_case values: allow, deny, cancelled, incomplete.
TrustLevel
Records how the kernel mediated the call. Defaults to Mediated; older receipts without the field deserialize to this default.
pub enum TrustLevel {
Mediated, // Synchronous in-line mediation (strongest, default)
Verified, // Embedded kernel observed the call but did not mediate
// through a separate trust boundary
Advisory, // Shadow-mode / observability-only; caller may have
// proceeded regardless of the verdict
}GuardEvidence
Each guard that ran during evaluation contributes one record:
pub struct GuardEvidence {
pub guard_name: String, // e.g. "ForbiddenPathGuard"
pub verdict: bool, // true = passed, false = denied
pub details: Option<String>, // Optional diagnostic
}External-guard adapters serialize their structured evidence (Bedrock category breakdown, VirusTotal detection counts, etc.) into details as a JSON string. The shape of that JSON is owned by the provider crate and versioned there.
Canonical JSON
Receipts are signed over canonical JSON (RFC 8785 / JCS), not arbitrary serde_json::to_string output. The implementation lives in chio_core_types::canonical. The rules:
- Object keys are sorted by UTF-16 code unit comparison (not byte or ASCII order).
- Numbers use the shortest representation matching ECMAScript
JSON.stringify(). - Strings use minimal escaping. Only required characters are escaped.
- No whitespace between tokens. The output is a single line.
The signing input is ChioReceiptBody: every field of ChioReceipt except algorithm and signature. Single-tenant receipts (where tenant_id is None) skip that field from canonical JSON to remain byte-identical with pre-multitenant receipts.
Do not roll your own canonicalization
SigningBackend
The kernel signs receipts through a trait so FIPS algorithms slot in without changing call sites:
pub trait SigningBackend: Send + Sync {
fn algorithm(&self) -> SigningAlgorithm;
fn public_key(&self) -> PublicKey;
fn sign(&self, message: &[u8]) -> Result<Signature, Error>;
}Three implementations:
Ed25519Backend· default, no feature flag required.P256Backend· gated on thefipscrate feature.P384Backend· gated on thefipscrate feature.
Verification reads the algorithm off the self-describing prefix on the PublicKey and Signature; the envelope algorithm field is informational. Building without the fips feature causes P-256 / P-384 receipts to fail verification with Ok(false) rather than attempting an unsupported curve.
ReceiptStore Trait
Receipts land in any backend that satisfies ReceiptStore. The core append surface:
pub trait ReceiptStore: Send + Sync {
fn append_chio_receipt(&self, receipt: &ChioReceipt) -> Result<(), ReceiptStoreError>;
fn append_chio_receipt_returning_seq(
&self,
receipt: &ChioReceipt,
) -> Result<Option<u64>, ReceiptStoreError> {
self.append_chio_receipt(receipt)?;
Ok(None)
}
fn append_child_receipt(&self, receipt: &ChildRequestReceipt) -> Result<(), ReceiptStoreError>;
// Checkpointing
fn store_checkpoint(&self, _checkpoint: &KernelCheckpoint) -> Result<(), ReceiptStoreError>;
fn load_checkpoint_by_seq(&self, _seq: u64) -> Result<Option<KernelCheckpoint>, ReceiptStoreError>;
fn load_latest_checkpoint(&self) -> Result<Option<KernelCheckpoint>, ReceiptStoreError>;
// Capability lineage, federated evidence, and lineage statements omitted here.
}Most trait methods have default implementations so backends only override what they actually persist. The full kernel queries receipts via ReceiptQuery:
pub struct ReceiptQuery {
pub capability_id: Option<String>,
pub tool_server: Option<String>,
pub tool_name: Option<String>,
pub outcome: Option<String>, // "allow" | "deny" | "cancelled" | "incomplete"
pub since: Option<u64>, // Unix seconds, inclusive
pub until: Option<u64>, // Unix seconds, inclusive
pub min_cost: Option<u64>, // Minor units
pub max_cost: Option<u64>,
pub cursor: Option<u64>, // Forward pagination, exclusive
pub limit: usize, // Capped at MAX_QUERY_LIMIT (200)
pub agent_subject: Option<String>, // Hex-encoded subject key
pub tenant_filter: Option<String>,
}SqliteReceiptStore
The production reference implementation lives in chio-store-sqlite. It owns an r2d2 connection pool and a strict-tenant-isolation flag. The schema for tool receipts:
CREATE TABLE chio_tool_receipts (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
receipt_id TEXT NOT NULL UNIQUE,
timestamp INTEGER NOT NULL,
capability_id TEXT NOT NULL,
subject_key TEXT,
issuer_key TEXT,
grant_index INTEGER,
tool_server TEXT NOT NULL,
tool_name TEXT NOT NULL,
decision_kind TEXT NOT NULL,
policy_hash TEXT NOT NULL,
content_hash TEXT NOT NULL,
raw_json TEXT NOT NULL
);
CREATE INDEX idx_chio_tool_receipts_timestamp ON chio_tool_receipts(timestamp);
CREATE INDEX idx_chio_tool_receipts_capability ON chio_tool_receipts(capability_id);
CREATE INDEX idx_chio_tool_receipts_subject ON chio_tool_receipts(subject_key);
CREATE INDEX idx_chio_tool_receipts_grant ON chio_tool_receipts(capability_id, grant_index);
CREATE INDEX idx_chio_tool_receipts_tool ON chio_tool_receipts(tool_server, tool_name);
CREATE INDEX idx_chio_tool_receipts_decision ON chio_tool_receipts(decision_kind);The full canonical receipt JSON lives in raw_json; the columnar fields exist for query performance only. The store rebuilds receipts from raw_json on read and re-verifies signatures.
Retention
Retention is a runtime concern, not a database trigger. The kernel uses RetentionConfig to rotate receipts older than retention_days or larger than max_size_bytes:
pub struct RetentionConfig {
pub retention_days: u64, // Default: 90
pub max_size_bytes: u64, // Default: 10 GB (10_737_418_240)
pub archive_path: String, // Default: "receipts-archive.sqlite3"
pub tenant_id: Option<String>,
}Rotation moves aged-out receipts into a separate read-only SQLite archive while keeping their checkpoint relationships intact, so an archived receipt is still verifiable against the Merkle root that committed it. Per-tenant rotation is a separate concern: setting tenant_id on the config scopes the rotation to one tenant's evidence and leaves others untouched.
Merkle Checkpoints
Periodically the kernel batches receipts into a Merkle tree and signs the root. The schema is chio.checkpoint_statement.v1.
pub struct KernelCheckpointBody {
pub schema: String, // "chio.checkpoint_statement.v1"
pub checkpoint_seq: u64, // Monotonic counter
pub batch_start_seq: u64,
pub batch_end_seq: u64,
pub tree_size: usize,
pub merkle_root: Hash,
pub issued_at: u64,
pub kernel_key: PublicKey,
pub previous_checkpoint_sha256: Option<String>,
}Default batch size is DEFAULT_CHECKPOINT_BATCH_SIZE = 100; the kernel emits a checkpoint every 100 receipts unless the operator overrides checkpoint_batch_size on kernel config. A value of 0 disables checkpointing entirely (web3 deployments forbid this).
The chain links via previous_checkpoint_sha256: each checkpoint hashes its predecessor, so any deletion or rewriting upstream invalidates every checkpoint downstream of the gap. tree_size is monotonic; auditors can verify leaf-count continuity by walking the chain.
Inclusion proofs are ReceiptInclusionProof records that name a checkpoint, a leaf index, and a sibling-hash path. Anchoring the signed root to a transparency log, blockchain, or notary service is out of band: the checkpoint statement is portable evidence that the kernel committed to a specific receipt set at a specific time.
Multi-Tenant Isolation
tenant_id on ChioReceipt is the isolation key. Two rules govern its lifecycle:
- Derived, not declared. The kernel sets
tenant_idfrom the authenticated session's enterprise identity context. It is never taken from a caller-provided field on the request, because caller choice would defeat the isolation intent. - Visible in queries via tenant_filter. A query with
tenant_filter = Some(id)returns rows wheretenant_id = id OR tenant_id IS NULL, keeping legacy pre-multitenant receipts visible. Strict mode (SqliteReceiptStore::with_strict_tenant_isolation(true)) excludes the NULL fallback set.
HTTP edge derives tenant_id
tenant_filter from the authenticated session, not from a query parameter. A caller-supplied filter is a confused-deputy bug.Worked Example: Verify a Receipt
use chio_core_types::receipt::ChioReceipt;
let receipt: ChioReceipt = serde_json::from_str(json_blob)?;
// Step 1: signature.
assert!(receipt.verify_signature()?, "signature did not verify");
// Step 2: action hash matches parameters.
assert!(receipt.action.verify_hash()?, "parameter_hash drift");
// Step 3: expected kernel key.
assert_eq!(receipt.kernel_key, expected_kernel_pubkey);
// Step 4: optional - inclusion proof against a checkpoint root.
// Build a MerkleTree from the canonical bytes of the batch range,
// then verify ReceiptInclusionProof.path against batch_root.
println!("decision: {:?}", receipt.decision);
for ev in &receipt.evidence {
println!(" {} -> verdict={}", ev.guard_name, ev.verdict);
}Next Steps
- Receipts (Concept) · why chio receipts exist, what they prove, what they don't
- Verify Receipts Offline · validate signatures and inclusion proofs without kernel access
- Query Audit Receipts · ReceiptQuery filters, pagination, and tenant scoping
- Observability · how receipts compose with metrics and tracing