Chio/Docs

Revocation Store

Revoke a capability so its whole delegation branch stops, keep the revocation across restarts, and see which reads can bind a budget.

Concept chio@5f63e6e

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.

Revoking one grant in a delegation tree: its subtree is refused from then on; its parent, its siblings, and every earlier call are unchangedselect a grant to revoke itallowedrefusedcalls before revocation standt
trev\htmlClass{role-deny}{t_{\mathrm{rev}}}
ccoordinatorCapabilityRevokedaanalysisDelegationChainRevoked(a)rreaderstests
deny(x)  ⟺  revoked(x)⏟CapabilityRevoked  ∨  ∃ ℓ∈chain(x):revoked(ℓ)⏟DelegationChainRevoked(ℓ)\mathrm{deny}(\htmlClass{role-call}{x}) \iff \underbrace{\htmlClass{role-deny}{\mathrm{revoked}}(\htmlClass{role-call}{x})}_{\texttt{CapabilityRevoked}} \;\lor\; \underbrace{\exists\, \htmlClass{cor-parent}{\ell} \in \mathrm{chain}(\htmlClass{role-call}{x}) : \htmlClass{role-deny}{\mathrm{revoked}}(\htmlClass{cor-parent}{\ell})}_{\texttt{DelegationChainRevoked}(\htmlClass{cor-parent}{\ell})}
closed(a)={ x:x=a  ∨  a∈chain(x) }={a,r}\mathrm{closed}(\htmlClass{role-deny}{a}) = \{\, \htmlClass{role-call}{x} : \htmlClass{role-call}{x} = \htmlClass{role-deny}{a} \;\lor\; \htmlClass{role-deny}{a} \in \mathrm{chain}(\htmlClass{role-call}{x}) \,\} = \{ \htmlClass{role-deny}{a}, \htmlClass{role-deny}{r} \}
Revoking one grant in a delegation tree: its subtree is refused from then on; its parent, its siblings, and every earlier call are unchangedselect a grant to revoke itallowedrefusedearlier calls standt
trev\htmlClass{role-deny}{t_{\mathrm{rev}}}
ccoordinatorCapabilityRevokedaanalysisDelegationChainRevoked(a)rreaderstests
closed(a)={a,r}\mathrm{closed}(\htmlClass{role-deny}{a}) = \{ \htmlClass{role-deny}{a}, \htmlClass{role-deny}{r} \}

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.

deny(x)  ⟺  revoked(x)∨  ∃ ℓ∈chain(x):revoked(ℓ)closed(v)={ x:x=v  ∨  v∈chain(x) }\small \begin{aligned} & \mathrm{deny}(\htmlClass{role-call}{x}) \iff \\ & \quad \htmlClass{role-deny}{\mathrm{revoked}}(\htmlClass{role-call}{x}) \\ & \quad \lor\; \exists\, \htmlClass{cor-parent}{\ell} \in \mathrm{chain}(\htmlClass{role-call}{x}) : \htmlClass{role-deny}{\mathrm{revoked}}(\htmlClass{cor-parent}{\ell}) \\[8pt] & \mathrm{closed}(v) = \{\, \htmlClass{role-call}{x} : \\ & \quad \htmlClass{role-call}{x} = v \;\lor\; v \in \mathrm{chain}(\htmlClass{role-call}{x}) \,\} \end{aligned}
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(())
}
An example team with its calls in order: the kernel refuses a grant when its own id or any ancestor id in its delegation chain is revoked. Calls admitted before the revocation stand, and the refusal holds at each verifier that has observed it.Revocation cascade

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:

crates/kernel/chio-kernel/src/kernel/construction.rsrust
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:

Terminal · one capability, revoked oncebash
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 $CAP
Output
capability_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:

  1. Admission. After signature and time bounds, before delegation and guards. A hit returns a deny response and the tool never runs.
  2. Dispatch revalidation. After the capability is revalidated. A hit propagates as an error out of dispatch.
  3. Allow finalization. A hit rewrites the allow into a deny reading capability authorization changed before allow finalization: {error}.
  4. 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

Types and implementations

TermDefined inRole
RevocationStore
chio-kernel/src/revocation_runtime.rs
The trait: is_revoked, revoke, observe_revocation, is_ephemeral
InMemoryRevocationStore
chio-kernel/src/revocation_runtime.rs
A Mutex<HashSet<String>>; what ChioKernel::new installs by default
SqliteRevocationStore
chio-store-sqlite/src/revocation_store.rs
The durable node-local backing: one revoked_capabilities table plus a monotone index
RemoteRevocationStore
chio-control-plane/src/trust_control/service_runtime/remote_stores.rs
The trait over HTTP to a trust control plane; is_ephemeral() is false, observe_revocation is the trait default
RevocationRecord
chio-kernel/src/revocation_store.rs
One row: capability_id: String, revoked_at: i64
RevocationObservation
chio-kernel/src/revocation_runtime.rs
A membership answer plus optional RevocationCommitMetadata (authority, guarantee level, commit index)
RevocationView
chio-kernel-core/src/revocation_view.rs
Read-only arc-swap cache of one RevocationSnapshot; the only revocation state the core sees

The RevocationStore trait

Four methods, two with defaults that set the node's failure posture:

crates/kernel/chio-kernel/src/revocation_runtime.rsrust
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.

crates/platform/chio-store-sqlite/src/revocation_store.rssql
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

PointCall siteEffect of a hit
Admission
check_tool_call_revocation_admission
Deny response; the tool never runs
Dispatch revalidation
kernel/dispatch.rs
Error propagates out of dispatch
Allow finalization
kernel/responses/allow_responses.rs
Allow rewritten into a deny
Receipt persistence
record_chio_receipt_with_federation, under receipt.is_allowed()
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.

Next: carry the revocation to every node