Revocation Store
Revoke a capability so its whole delegation branch stops, keep the revocation across restarts, and see which reads can bind a budget.
On this page
An operator can withdraw a capability that still verifies cryptographically, and the kernel refuses every later call through that grant or through any grant delegated from it.
On one node the withdrawal lives in SqliteRevocationStore, the durable backing of the kernel's RevocationStore trait; Revocation Propagation carries it to other nodes. Revocation acts at an authorization check, so an effect a tool already produced stands; read a late refusal against the receipt's execution boundary.
A revoked ancestor closes its whole branch
check_revocation tests the leaf capability id first, then each delegation_chain[i].capability_id, with one store call per subject.
Grant a (analysis) is revoked at t_rev. Its subtree, a, r, is closed: a's later calls are refused with CapabilityRevoked and its descendants' with DelegationChainRevoked(a). c and s keep their grants. Every call before t_rev stands.
In Rust · validation.rs:71813 lines
pub(crate) fn check_revocation(&self, cap: &CapabilityToken) -> Result<(), KernelError> {
if self.with_revocation_store(|store| Ok(store.is_revoked(&cap.id)?))? {
return Err(KernelError::CapabilityRevoked(cap.id.clone()));
}
for link in &cap.delegation_chain {
if self.with_revocation_store(|store| Ok(store.is_revoked(&link.capability_id)?))? {
return Err(KernelError::DelegationChainRevoked(
link.capability_id.clone(),
));
}
}
Ok(())
}
A revoked leaf raises KernelError::CapabilityRevoked; a revoked ancestor raises KernelError::DelegationChainRevoked. Revoking a coordinator's grant stops every helper it delegated to without waiting for their tokens to expire, while its parent and siblings keep working.
Read more in Capabilities, which defines the delegation chain.
A node whose revocations would vanish on restart refuses calls
Before any capability is verified, the pre-dispatch path runs four readiness checks in order: federated receipt persistence, TCB lock health, local receipt persistence, and revocation durability. The last one refuses an ephemeral store:
pub(crate) fn ensure_revocation_durability_ready(&self) -> Result<(), KernelError> {
// A remote revocation view (federation/oracle) is consulted on every
// delegated dispatch and is re-synced from its source after a restart, so
// an installed view is itself a durable revocation source: it satisfies
// the gate even when the local per-row store is the default in-memory one.
if self.revocation_view.is_some() {
return Ok(());
}
let ephemeral = self.with_revocation_store(|store| Ok(store.is_ephemeral()))?;
if !ephemeral || self.config.allow_ephemeral_revocation_store {
return Ok(());
}
Err(KernelError::Internal(
"durable revocation state unavailable: no revocation store configured".to_string(),
))
}An installed view, a store that reports durable, or the explicit allow_ephemeral_revocation_store opt-in satisfies it. The flag defaults to false in chio-config/src/schema.rs and in the control-plane policy types, so an unconfigured long-running node refuses instead of honoring a token it revoked before a restart. The refusal is a signed deny receipt with the reason text above; its guard field reads kernel.receipt_persistence for all four readiness checks, so filter on the reason to find revocation-durability refusals.
chio run and chio check issue, evaluate, and exit within one process, so they opt in through opt_in_ephemeral_revocation_for_local_session when no durable backend is configured; an in-memory --revocation-db path counts as none. The long-running edge built by build_mcp_edge_kernel never opts in on its own.
Revoking writes once and reports whether the write was new
chio trust status and chio trust revoke act on whichever backend the process points at. Here it is a chio trust serve process holding a SQLite revocation database, so backend echoes the control URL. TOKEN stands for the trust service's credential (startup is on Trust Control Plane), and the fourth command repeats the second:
CAP=cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
chio --control-url http://127.0.0.1:8940 --control-token "$TOKEN" trust status --capability-id $CAP
chio --control-url http://127.0.0.1:8940 --control-token "$TOKEN" trust revoke --capability-id $CAP
chio --control-url http://127.0.0.1:8940 --control-token "$TOKEN" trust status --capability-id $CAP
chio --control-url http://127.0.0.1:8940 --control-token "$TOKEN" trust revoke --capability-id $CAPcapability_id: cap-019fbe33-b0ac-7712-a128-fc72a40b3c75 revoked: false backend: http://127.0.0.1:8940 capability_id: cap-019fbe33-b0ac-7712-a128-fc72a40b3c75 revoked: true newly_revoked: true backend: http://127.0.0.1:8940 capability_id: cap-019fbe33-b0ac-7712-a128-fc72a40b3c75 revoked: true backend: http://127.0.0.1:8940 capability_id: cap-019fbe33-b0ac-7712-a128-fc72a40b3c75 revoked: true newly_revoked: false backend: http://127.0.0.1:8940
You should see newly_revoked: true the first time and false the second. That field is the store call's return value: revoke rolls back and returns false for an id already present, and upsert_revocation only moves a stored revoked_at forward.
A commit whose outcome cannot be established returns RevocationStoreError::OutcomeUnknown, so the caller learns the revocation may or may not be durable and never receives a false negative. A store co-located with a serving owner checks its fence before each write and returns RevocationStoreError::Fenced once another process has taken the lease.
Read more in Node State on Disk, which covers the serving owner.
The tool-call path checks revocation four times
A revocation that lands mid-call still stops it at the next check:
- Admission. After signature and time bounds, before delegation and guards. A hit returns a deny response and the tool never runs.
- Dispatch revalidation. After the capability is revalidated. A hit propagates as an error out of dispatch.
- Allow finalization. A hit rewrites the allow into a deny reading
capability authorization changed before allow finalization: {error}. - Receipt persistence. For allowed receipts, a hit returns the error instead of recording an allow for a revoked capability.
Resource and prompt admissions, plan-step evaluation, and the governed active-response path each check once. Only an atomic observation can bind a revocation into a budget admission: the durable-admission coordinator accepts a supplemental quota claim only when observe_revocation returns commit metadata at SingleNodeAtomic or HaLinearizable, which the SQLite store attaches in joint serving-owner mode.
An installed view tests the whole chain against one fresh snapshot
RevocationView is the only revocation state the portable core defines: a read-only ArcSwap<RevocationSnapshot> that readers load without blocking writers. A snapshot carries an epoch, a 32-byte root_hash, an issued_at_unix_ms, and a sorted BTreeSet of revoked subjects.
Writers go through install_if_newer, a compare-and-swap loop that installs only an epoch that strictly advances; any other returns RevocationViewError::NonMonotoneEpoch and leaves the active snapshot in place. The live revocation feed installs each newer snapshot this way; the Revocation Oracle signs the epoch root a snapshot is built from and checks freshness before the merge.
With a view installed, consult_revocation_view loads the snapshot once in validate_delegation_admission and tests the leaf and every chain link against it. A snapshot issued more than DEFAULT_REVOCATION_VIEW_MAX_STALENESS_MS (500) milliseconds ago, or in the future, is refused with KernelError::DelegationInvalid. Install a view only with a feed that keeps it current: a fresh view holds epoch 0, an empty set, and issued_at_unix_ms = 0, so it satisfies the durability gate while every mediated call is refused as stale until a snapshot lands.
Read more in Replication & Convergence, which carries a revocation to nodes that did not record it.
Revocation types, schema, observation, and evidence
Search for observe_revocation, revocation_index, or install_if_newer.
Types and implementations
| Term | Defined in | Role |
|---|---|---|
RevocationStore | chio- | The trait: is_revoked, revoke, observe_, is_ephemeral |
In | chio- | A Mutex<HashSet<String>>; what ChioKernel::new installs by default |
Sqlite | chio- | The durable node-local backing: one revoked_ table plus a monotone index |
Remote | chio- | The trait over HTTP to a trust control plane; is_ephemeral() is false, observe_ is the trait default |
Revocation | chio- | One row: capability_id: String, revoked_at: i64 |
Revocation | chio- | A membership answer plus optional Revocation (authority, guarantee level, commit index) |
RevocationView | chio- | Read-only arc-swap cache of one Revocation; the only revocation state the core sees |
The RevocationStore trait
Four methods, two with defaults that set the node's failure posture:
pub trait RevocationStore: Send + Sync {
fn is_revoked(&self, capability_id: &str) -> Result<bool, RevocationStoreError>;
/// Revoke a capability. Returns `true` if it was newly revoked.
fn revoke(&self, capability_id: &str) -> Result<bool, RevocationStoreError>;
fn observe_revocation(
&self,
capability_id: &str,
) -> Result<RevocationObservation, RevocationStoreError> {
Ok(RevocationObservation {
revoked: self.is_revoked(capability_id)?,
commit: None,
})
}
/// Whether this store loses its revocation set on process restart. The
/// default is the safe (loud) assumption so an unknown store is treated as
/// ephemeral; durable and remote stores override to `false`.
fn is_ephemeral(&self) -> bool {
true
}
}Without an installed view or the opt-in, an implementor who leaves is_ephemeral at its default is refused dispatch, so a store of unknown durability never loses revocations silently. InMemoryRevocationStore keeps the default, so it reports ephemeral by construction (in_memory_revocation_store_is_ephemeral_by_default in chio-kernel/src/kernel/tests/revocation_durability.rs).
Durability gate scope
The gate is default-on and covers one node and one kernel at pre-dispatch: ensure_revocation_durability_ready, the three gate tests in kernel/tests/revocation_durability.rs, and call sites in evaluation/async_evaluation_core.rs and evaluation/nested_flow_evaluation.rs. It routes through build_receipt_persistence_failclosed_deny_response_with_metadata. It checks that durable revocation state exists, and an installed view satisfies it whether or not a snapshot has arrived; currency is the freshness check's job. As the fourth readiness check, it runs after a poisoned TCB lock or an unhealthy receipt writer has already refused with the same guard attribution.
Schema and write path
SqliteRevocationStore::open creates the parent directory; refuses a database provisioned with a joint serving owner, one whose application_id belongs to another product, or one whose stamped revision is newer than supported; initializes the schema; stamps revision 2 under the revocation key; and runs a foreign_key_check.
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS revoked_capabilities (
capability_id TEXT PRIMARY KEY,
revoked_at INTEGER NOT NULL,
revocation_index INTEGER UNIQUE,
admission_authority_commit_index INTEGER UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_revoked_capabilities_revoked_at
ON revoked_capabilities(revoked_at);initialize_revocation_schema also creates the singleton counter table revocation_replication_meta, or, opened alongside a joint serving owner, admission_authority_meta plus an admission_authority_commits log and a migration table; the mode decides which index column a row gets. Every mutation takes the next value from the counter row through allocate_revocation_index. Reads run in a deferred transaction that is rolled back; writes take TransactionBehavior::Immediate and check the serving-owner fence first. A standalone --revocation-db store carries no owner, since open refuses any file that has one, so its fence check passes trivially.
revocation_index is a per-mutation counter and is the commit_index that observe_revocation returns; in joint mode the same allocation anchors the admission_authority_commits log, whose density verify_admission_authority_invariants checks. Cluster paging uses a different cursor: list_revocations_after orders by revoked_at ASC, capability_id ASC and pages on that tuple, and RevocationRecord carries no index field.
is_ephemeral is computed from the open path. rusqlite enables URI filenames, so :memory:, file::memory:, and any file:...?mode=memory URI open a database that loses every revocation on restart, and all three report ephemeral (in_memory_revocation_store_reports_ephemeral). open_alongside sets ephemeral: false for the store it shares with a provisioned serving owner.
Call sites, locking, and observation guarantees
| Point | Call site | Effect of a hit |
|---|---|---|
Admission | check_ | Deny response; the tool never runs |
Dispatch revalidation | kernel/ | Error propagates out of dispatch |
Allow finalization | kernel/ | Allow rewritten into a deny |
Receipt persistence | record_, under receipt. | Error returned; no allow recorded |
check_revocation lives in kernel/validation.rs. Resource and prompt admissions route through evaluation/evaluation_entry.rs, which calls check_revocation once before delegation admission; plan-step evaluation and the governed active-response path each call it once, and none of the three takes the trace-emitting admission wrapper. with_revocation_store takes no kernel-level lock, so concurrency control belongs to the store. revoke_capability and the admission check take the runtime-trace transition lock, so RevocationCommitted and RevocationAdmission events are ordered, and both recover from a poisoned lock: poisoned_runtime_trace_lock_does_not_disable_revocation_or_admission.
observe_revocation is the stricter read. The SQLite store answers membership and reads the commit index in one transaction, attaching RevocationCommitMetadata only with a serving owner. The durable-admission coordinator refuses a supplemental quota claim whose observation carries no commit metadata (supplemental authorization requires atomic revocation observation) or whose guarantee level is neither SingleNodeAtomic nor HaLinearizable. InMemoryRevocationStore and RemoteRevocationStore inherit the trait default and return commit: None, so neither backs a supplemental quota claim.
View snapshot and feature gate
RevocationSnapshot deserializes with deny_unknown_fields. Unit tests cover equal-epoch replay, stale-epoch rewind, and eight concurrent writers installing epochs 1 to 8, after which the view reads 8 and an epoch-7 write still fails. The core leaves root_hash to the layer that produced the snapshot, which verifies its signature. chio-federation/src/revocation_gossip.rs owns the feed's wire envelope, push queue, and catch-up path. validate_delegation_admission runs the freshness check before its empty-chain early return, so the stale refusal covers calls with no delegation chain; empty_view_denies_as_stale in kernel/delegation.rs tests the helper's half.
The consultation call site is #[cfg(feature = "delegation")], and default = ["delegation"] in chio-kernel, so a stock build consults the view. A --no-default-features build drops the consultation and keeps only the per-row lookup. With no view installed, the helper returns Ok(()) and the per-row lookup is the whole check.
Proofs, models, and tests
Deny-if-leaf-or-ancestor-revoked is proved over a projection. The Kani harness revocation_snapshot_denies_presented_token_or_ancestor in chio-kernel-core/src/kani_harnesses.rs asserts revocation_snapshot_denies(a, b) == a || b over both booleans, and the Lean theorems revocationSnapshot_revoked_token_denies and revocationSnapshot_revoked_ancestor_denies in formal/lean4/Chio/Chio/Proofs/Protocol.lean discharge row P2. The projection takes two flags, not a store: P2 in formal/proof-manifest.toml lists audited_storage_assumption and sqlite_projection among its evidence kinds, and the manifest's excluded_surfaces treats the SQLite implementations as audited assumptions.
The view never rewinding is tested and modeled. The install_if_newer loop has concurrency tests in revocation_view.rs and crates/kernel/chio-kernel-core/tests/revocation_view_concurrency.rs; the symbol is a covered entry in the proof manifest, and its model is formal/tla/RevocationPropagation.tla, where Propagate absorbs a stale or duplicate epoch without moving the receiver. The harness verify_revocation_view_freshness checks the boolean deny predicate, separate from the loop and the wall-clock check. Status for every theorem is in the theorem inventory.
Lookup cost, record contents, and propagation
The per-row path issues one store call per subject, so a chain of depth n costs n + 1 lookups, each a SQLite transaction against a durable store. The view path loads one snapshot and runs a BTreeSet::contains per subject: no per-subject transactions, with membership work still growing with depth. Durability across restart holds for a filesystem-backed SQLite store or a remote one (sqlite_revocation_store_persists_across_reopen).
A row records membership and time. Revocation is permanent: rows in revoked_capabilities are never deleted, and a row holds no reason or revoker. Other nodes learn a revocation through the cluster layer, which reads list_revocations_after over the (revoked_at, capability_id) cursor: the control-plane cluster snapshot and delta handlers, and the denylist warm-up in chio-api-protect.