PlatformDurable State
Node
Revocation Store
The revoked-capability set one process holds on its own disk, who consults it, and why an ephemeral one denies dispatch.
One node’s disk, not the cluster’s
One question, asked per subject
A revocation store answers one question for one process: is this capability id in the revoked set? Revocation is not Kernel content, and the kernel says so itself. The portable core's evaluate module lists what it deliberately does not do, and revocation membership lookup is the first item:
//! What it does NOT do (fenced into `chio-kernel` proper):
//!
//! - Revocation membership lookup (stateful `RevocationStore`).
//! - Budget mutation (stateful `BudgetStore`).Six more items follow in the same comment; Core and Shell reads the list out whole and explains why the split is drawn there. A revocation lookup needs mutable state that outlives the call, so it lives in the hosting kernel, not the verified core.
Model
| Term | Defined in | Role |
|---|---|---|
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 when nothing else is wired. |
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 | Same trait over HTTP to a trust control plane. Overrides is_ephemeral() to false but not observe_revocation, so it inherits 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 ever sees. |
The trait is four methods, and two of them carry defaults that decide 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
}
}An implementor who forgets to override is_ephemeral gets denied dispatch, not silent data loss. InMemoryRevocationStore never overrides it, so the default store reports ephemeral by construction (in_memory_revocation_store_is_ephemeral_by_default in chio-kernel/src/kernel/tests/revocation_durability.rs).
The durability gate
Before any capability is verified, the pre-dispatch path runs four readiness checks in order: federated receipt-persistence readiness, TCB lock health, local receipt-persistence readiness, then revocation durability. Revocation durability is the last of the four, and the one that 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(),
))
}Three ways to satisfy it: an installed view, a store that reports durable, or the explicit allow_ephemeral_revocation_store opt-in. The flag defaults to false in both chio-config/src/schema.rs and the control-plane policy types, so an unconfigured long-running node denies rather than accepting a token it had already revoked before a restart. Failing the gate produces a signed deny receipt, not a bare error: it routes through build_receipt_persistence_failclosed_deny_response_with_metadata, so the refusal is recorded with the reason text above. Note the attribution: that builder stamps the receipt's guard field with kernel.receipt_persistence regardless of which of the four readiness checks failed, so filter on the reason string when you are looking for revocation-durability denies specifically.
| Field | Value |
|---|---|
| Status | Shipped and default-on. |
| Claim | A kernel whose only revocation state is ephemeral denies every mediated call unless the operator opts in. |
| Subject | One node, one kernel, at pre-dispatch. |
| Evidence | ensure_revocation_durability_ready plus the three gate tests in kernel/tests/revocation_durability.rs; call sites in evaluation/async_evaluation_core.rs and evaluation/nested_flow_evaluation.rs. |
| Limit | The gate checks that durable revocation state exists. It does not check that the state is current, and an installed view satisfies it unconditionally, including a view that has never received a snapshot. The gate is also the fourth readiness check, so a poisoned TCB lock or an unhealthy receipt writer denies first and produces the same guard attribution. |
The CLI applies the distinction by runtime shape. chio run and chio check issue capabilities, evaluate, and exit inside one process lifetime, so opt_in_ephemeral_revocation_for_local_session calls the opt-in for them, and only when no durable backend is configured. An in-memory --revocation-db path counts as no backend for that test, so it takes the opt-in rather than the deny. The long-running edge built by build_mcp_edge_kernel never auto-opts in.
The store on disk
SqliteRevocationStore::open creates the parent directory, refuses a database already provisioned with a joint serving owner, refuses one whose application_id belongs to another product or whose stamped revision is newer than the supported one, initializes the schema, stamps revision 2 under the revocation key, and runs a foreign_key_check. The base table and its index:
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);That excerpt is not the whole schema. initialize_revocation_schema also creates the singleton counter table revocation_replication_meta, or, when the store is opened alongside a joint serving owner, admission_authority_meta plus an admission_authority_commits log and a migration table. Which of the two index columns a row gets depends on that same mode.
chio trust status and chio trust revoke are the two reads and the one write, against whichever backend the process is pointed at. Here the backend is a chio trust serve process holding a SQLite revocation database, so backend echoes the control URL rather than a file path. The fourth command repeats the third:
$ chio --control-url http://127.0.0.1:8940 --control-token "$TOKEN" \
trust status --capability-id cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
capability_id: cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
revoked: false
backend: http://127.0.0.1:8940
$ chio --control-url http://127.0.0.1:8940 --control-token "$TOKEN" \
trust revoke --capability-id cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
capability_id: cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
revoked: true
newly_revoked: true
backend: http://127.0.0.1:8940
$ chio --control-url http://127.0.0.1:8940 --control-token "$TOKEN" \
trust status --capability-id cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
capability_id: cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
revoked: true
backend: http://127.0.0.1:8940
$ chio --control-url http://127.0.0.1:8940 --control-token "$TOKEN" \
trust revoke --capability-id cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
capability_id: cap-019fbe33-b0ac-7712-a128-fc72a40b3c75
revoked: true
newly_revoked: false
backend: http://127.0.0.1:8940newly_revoked is the return value of the store call, and it is the whole of what idempotence means here. revoke is idempotent: an id already present rolls the transaction back and returns false. upsert_revocation only ever moves a stored revoked_at forward; an incoming record at or before the stored timestamp is a no-op. Every mutation allocates the next value from the singleton counter row via allocate_revocation_index and stamps it into the row. Reads run in a deferred transaction that is rolled back rather than committed. Writes take TransactionBehavior::Immediate and verify a serving-owner fence first, so a store whose lease has been taken over returns RevocationStoreError::Fenced instead of writing behind the current owner. That fence only bites for the co-located store built through SqliteServingOwner: a standalone --revocation-db store carries no owner, and open has already refused any database that carries one, so on that path the fence check is a no-op against an unprovisioned file.
The stamped index is not the cluster cursor
revocation_index is a per-mutation counter written into the row. It is not what the cluster pages on: list_revocations_after orders by revoked_at ASC, capability_id ASC and its cursor is that composite tuple, and RevocationRecord carries no index field at all. The counter earns its place elsewhere: it is the commit_index observe_revocation returns, and in joint mode the same allocation anchors the admission_authority_commits log that verify_admission_authority_invariants checks for density. The public reader latest_revocation_index has no in-tree caller outside the store's own tests.A failed commit does not become a plain error. It becomes RevocationStoreError::OutcomeUnknown: the revocation may or may not be durable, and the caller is told exactly that rather than a false negative.
An in-memory SQLite path is not a durable path
is_ephemeral is computed from the open path, not assumed. rusqlite enables URI filenames, so :memory:, file::memory:, and any file:...?mode=memory URI all open a database that loses every revocation on restart, and all three report ephemeral (in_memory_revocation_store_reports_ephemeral). Pointing --revocation-db at an in-memory path does not satisfy the durability gate. The one path that skips this computation is open_alongside, which hardcodes ephemeral: false for the store it shares with a provisioned serving owner.Who consults it, and when
check_revocation tests the leaf capability id first, then every delegation_chain[i].capability_id, one store call per subject. A revoked leaf raises KernelError::CapabilityRevoked; a revoked ancestor raises KernelError::DelegationChainRevoked. The tool-call path calls it at four points:
| Point | Call site | Effect of a hit |
|---|---|---|
| Pre-dispatch admission | check_tool_call_revocation_admission, after signature and time bounds, before delegation and guards | Deny response; the tool never runs. |
| Dispatch revalidation | kernel/dispatch.rs, after capability revalidation | Error propagates out of dispatch. |
| Allow finalization | kernel/responses/allow_responses.rs | The allow response is rewritten into a deny reading capability authorization changed before allow finalization: {error}. |
| Receipt persistence | record_chio_receipt_with_federation, guarded by receipt.is_allowed() | Persistence returns the error rather than recording an allow for a revoked capability. |
Four is the count on the tool-call path, not for every capability-backed operation. 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 as well. None of those three take the trace-emitting admission wrapper.
with_revocation_store takes no kernel-level lock, unlike the budget store; concurrency control belongs to the store implementation. Both revoke_capability and the admission check take the runtime-trace transition lock so trace events (RevocationCommitted, RevocationAdmission) are ordered, and both recover from a poisoned lock rather than failing: poisoned_runtime_trace_lock_does_not_disable_revocation_or_admission pins that a poisoned trace lock never disables the revocation check itself.
observe_revocation is the stricter read. The SQLite implementation answers membership and reads the commit index in one transaction, attaching RevocationCommitMetadata only when a serving owner is attached. The durable-admission coordinator uses it for supplemental quota claims and refuses the call when the observation carries no commit metadata (supplemental authorization requires atomic revocation observation) or when the guarantee level is not SingleNodeAtomic or HaLinearizable. A plain membership boolean is not enough to bind a revocation into a budget admission record. That cuts both ways: the SQLite store is the only in-tree implementation that overrides observe_revocation, and it attaches commit metadata only in joint serving-owner mode. InMemoryRevocationStore and RemoteRevocationStore both inherit the trait default and return commit: None, so neither can back a supplemental quota claim.
The read-only view
RevocationView is the only revocation state the portable core defines, and it is read-only by construction: an 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. Serde is deny_unknown_fields.
Writers go through install_if_newer, a compare-and-swap loop that rejects any candidate whose epoch does not strictly advance the installed one with RevocationViewError::NonMonotoneEpoch and leaves the active snapshot untouched. Unit tests cover equal-epoch replay, stale-epoch rewind, and eight concurrent writers racing to install epochs 1 through 8, after which the view reads 8 and a subsequent epoch-7 write still fails. The core does not re-verify root_hash; its own doc comment says so, and signature verification belongs to the layer that produced the snapshot. That layer has a page of its own: Revocation Oracle documents the signed epoch root a snapshot is built from, the freshness window applied before it is merged, and what the signature does not cover.
When a view is installed, the kernel consults it in validate_delegation_admission through consult_revocation_view, which loads the snapshot once and then tests the leaf and every chain link against it. Before any membership test it applies a freshness gate: DEFAULT_REVOCATION_VIEW_MAX_STALENESS_MS is 500, and a snapshot older than that, or issued in the future, denies with KernelError::DelegationInvalid. With no view installed the helper returns Ok(()) and the per-row store lookup is the whole check.
Two boundaries on that sentence. First, the call site is #[cfg(feature = "delegation")]. That feature is in chio-kernel's default set, so a stock build consults the view, but a --no-default-features build drops the consultation entirely and leaves only the per-row lookup. The comment on the feature itself still reads “Default OFF for the trust boundary,” which contradicts the default = ["delegation"] line above it; trust the default list. Second, the 500 ms window is compared against a whole-second clock: consult_revocation_view computes now as current_unix_timestamp().saturating_mul(1000), so the effective resolution of the freshness test is one second, not one millisecond.
An installed view that is never updated denies every call
RevocationView holds the empty sentinel: epoch 0, empty set, issued_at_unix_ms = 0. That satisfies the durability gate, because the gate only asks whether a view is present. It does not satisfy the freshness gate, and validate_delegation_admission runs before the empty-chain early return, so every mediated call is denied as stale until a real snapshot lands. empty_view_denies_as_stale in kernel/delegation.rs pins the helper's half of that directly. Install a view only alongside something that keeps it current.Guarantees and limits
| Claim | Status | Evidence and boundary |
|---|---|---|
| A revoked leaf or ancestor denies the call. | Shipped | check_revocation in kernel/validation.rs, re-run at dispatch revalidation and allow finalization. |
| Revocations survive process restart. | Shipped, store-dependent | sqlite_revocation_store_persists_across_reopen. True only for a filesystem-backed SQLite store or a remote one; the default in-memory store loses the set. |
| Deny-if-leaf-or-ancestor-revoked is a proved predicate. | Proved, narrow | The Kani harness revocation_snapshot_denies_presented_token_or_ancestor in chio-kernel-core/src/kani_harnesses.rs asserts the scalar projection 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 are the P2 row's named discharge. Read the width of that: 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 puts concrete SQLite implementations outside the boundary except as audited assumptions. |
| The view never rewinds. | Tested and modeled, not proved | The install_if_newer CAS loop plus the concurrency tests in revocation_view.rs and crates/kernel/chio-kernel-core/tests/revocation_view_concurrency.rs. The symbol is a covered entry in formal/proof-manifest.toml, and the model it answers to is formal/tla/RevocationPropagation.tla, where Propagate absorbs an older or duplicate epoch without moving the receiver. The harness named verify_revocation_view_freshness checks the boolean deny predicate, not the CAS loop and not the wall-clock gate. |
| Gossip keeps a node's view current. | Not wired in-tree | chio-federation/src/revocation_gossip.rs owns the wire envelope, push queue, and catch-up path, and the federation and oracle integration tests drive install_if_newer directly. No in-tree runtime task calls set_revocation_view on a hosted kernel outside tests; treat the view as an embedder-supplied handle today. |
| Lookup cost is constant in chain depth. | Unsupported | The per-row path issues one store call per subject, so a depth-n chain costs n+1 lookups, each a SQLite transaction against a durable store. The view path is the constant one: a single load_full followed by BTreeSet::contains per subject. |
Two gaps remain. The store answers membership, not why: there is no reason field, no revoker identity, and no un-revoke, and no code path deletes from revoked_capabilities. And a node with a durable store has no mechanism of its own for learning about a revocation another node recorded. It exposes the read the cluster layer needs, list_revocations_after over the (revoked_at, capability_id) cursor, and nothing more. Something above it has to do the pulling: today that is the control-plane cluster snapshot and delta handlers, plus the denylist warm-up in chio-api-protect.
Next Steps
- Replication & Convergence · the delta stream that carries a revocation to nodes that did not record it
- Core and Shell · the exclusion list this page is the first entry of
- Capabilities · what a capability id and a delegation chain are
- Fail-Closed Semantics · the wider invariant the durability gate belongs to
- Backup & Restore · operating the SQLite files a node depends on