PlatformContracts
Receipts & Audit
The kernel signs receipts for allowed, denied, canceled, and interrupted tool calls, then checkpoints them in a Merkle tree.
Source
crates/core/chio-core-types/src/receipt/body.rs, crates/core/chio-core-types/src/canonical.rs, crates/kernel/chio-kernel/src/checkpoint.rs, crates/kernel/chio-kernel/src/receipt_query.rs, crates/kernel/chio-kernel/src/receipt_store.rs, and the SQLite bootstrap at crates/platform/chio-store-sqlite/src/receipt_store/bootstrap/open.rs.ChioReceipt
The signed wire format:
pub struct ChioReceipt {
pub id: String, // Content-addressed, from the canonical body
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: Option<Decision>, // Present only for mediated decisions
pub receipt_kind: ReceiptKind, // mediated_decision / trace_observation / advisory_evaluation
pub boundary_class: BoundaryClass, // prevent / detect_only / advisory_only / cannot_see
pub observation_outcome: Option<ObservationOutcome>, // observed / evaluated / dropped
pub tool_origin: ToolOrigin, // Where the effect executed relative to Chio
pub redaction_mode: RedactionMode, // none / summary / redacted
pub actor_chain: Vec<ActorRef>, // Signed actor attribution chain
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 bbs_projection_version: Option<String>, // Bound into id when BBS material is present
pub kernel_key: PublicKey, // For verification without lookup
pub bbs_signature: Option<BbsReceiptSignature>, // BBS selective-disclosure material
pub algorithm: Option<SigningAlgorithm>, // Absent means Ed25519
pub signature: Signature, // Over canonical body
}Note decision is Option<Decision>: not every receipt carries a mediated verdict. Trace and advisory receipts record an outcome through receipt_kind, boundary_class, and observation_outcome instead. All of the semantic fields above are part of the signed body.
Decision
The receipt's decision is a four-variant enum. It 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, // Guard::name(), e.g. "forbidden-path"
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, signature, and bbs_signature. The BBS material is excluded so the base signature commits to the receipt semantics while selective-disclosure material can be attached or stripped without breaking it. 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 uses a trait for receipt signing, allowing FIPS algorithms without changes at call sites:
pub trait SigningBackend: Send + Sync {
fn algorithm(&self) -> SigningAlgorithm;
fn public_key(&self) -> PublicKey;
fn sign_bytes(&self, message: &[u8]) -> Result<Signature>;
// Default impl delegates to sign_bytes over the canonical bytes.
fn sign_canonical_bytes(
&self,
canonical: &CanonicalBytes<CanonicalJsonWitness>,
) -> Result<Signature> {
self.sign_bytes(canonical.as_bytes())
}
}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
Any backend that implements ReceiptStore can persist receipts. The core append methods are:
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 override only the data they persist. The 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>, // Never authority on its own
pub read_context: Option<ReceiptReadContext>, // Resolved read boundary
}tenant_filter does not grant authority by itself. The explicit read_context — a boundary (AdminAll or TenantScoped), a source (LocalOperator, AdminService, or AuthenticatedTenant), and an include_null_tenant flag — is what authorizes and shapes the query. Remote and control-plane callers construct it from authenticated context before the query reaches the store. See Multi-Tenant Isolation below.
SqliteReceiptStore
The production reference implementation lives in chio-store-sqlite. It owns an r2d2 connection pool and a 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 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>,
pub check_interval_secs: u64, // Default: 3600 (rotation-evaluation cadence)
pub explicit_cutoff_unix_secs: Option<u64>, // Internal, no wire form
}A kernel maintenance worker evaluates rotation every check_interval_secs (default hourly). 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. - Scoped by an explicit read context. Whether a query sees untenanted (NULL
tenant_id) rows is governed byReceiptReadContext, whichReceiptQuerycarries asread_context. Itsinclude_null_tenantflag tracks the context's source, not its boundary. Both local-operator constructors set ittrue—local_operator_admin_all()(anAdminAllboundary) andlocal_operator_tenant(tenant)(aTenantScopedone) alike. The remote-sourced constructors keep itfalse:admin_service()andauthenticated_tenant(tenant). So an authenticated-tenant query does not see legacy NULL-tenant rows: NULL-row visibility follows the read context's source, an explicit decision, not a blankettenant_filterbehavior.
HTTP edge resolves the read context
ReceiptReadContext from the authenticated session, not from a query parameter, and treat tenant_filter as a narrowing filter that is never authority on its own. A caller-supplied boundary 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