Revocation
Withdraw a grant while agents still hold it, and know what the withdrawal stops at each boundary their work has reached.
On this page
Revoking a parent grant refuses the next call from every holder beneath it, even while agents are still using the grant.
Expiry bounds how long authority lives; revocation withdraws it earlier. The kernel checks current authority wherever work advances, so a past allow decision never stands in for a current dispatch or release check. The running example withdraws the parent grant of the Authority application while its two narrowed children still hold unused capacity.
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(())
}
Where a withdrawal lands decides what it can still prevent
A delegated grant can be withdrawn while its agent is preparing an external action. The withdrawal's effect depends on the boundary the operation has crossed when the enforcing owner sees it.
After capture, the connector handoff is fenced
Capture has crossed the durable accounting boundary, so the refusal fences the handoff and keeps the captured state attached to the original operation and its recovery identity. The pre-capture reversible state is never recreated.
Before admission, the kernel refuses the new use and keeps the rejection with the current authority context. Before capture, the operation acquires no captured allowance and performs no protected effect; a reversible reservation it already holds belongs to the original operation and is released through that operation's authority, leaving the reservation and its unwind as evidence.
After capture and before the connector handoff, the runtime fences the handoff and keeps the capture attached to the original operation and its recovery identity. Capture has crossed the durable accounting boundary, so a later refusal cannot restore the reversible state, and recovery preserves what committed. Revocation stops progress; it refunds nothing that already captured.
Once the provider may have accepted the request, revocation stops subsequent authorized work and rechecks release, so the raw result, its joined restrictions, and the release authority stay retained. The external effect needs its own authenticated outcome and reconciliation, with the captured allowance and attempt history preserved; a client's cancellation message is no proof that the provider did nothing.
Test the ordering you claim
A revocation test at admission covers admission only. Pause a test operation before capture, revoke it, and check for zero effects. Repeat after durable capture but before connector handoff: the handoff stops and the capture remains. Then attempt to recover and release under the revoked authority, and inspect the protected resource and retained ledger as well as the returned denial. Dispatch commitments explains capture ownership, and operation recovery names the races each boundary preserves.
Every enforcing component reads a durable, current view of withdrawals
A durable deployment configures a durable revocation authority, with its storage and trusted issuer relationship set up alongside the receipt and session stores. A restarted host reopens that authority and keeps every known revocation. For a brokered request, the broker's authority and revocation snapshots and the kernel's admission result each authorize the same original attempt at their own use boundary.
The revocation store is an authority dependency, and each enforcing component needs a current view under its selected contract. A revoked identifier in one service's database shows nothing about a remote executor or an independently configured host, so inventory every reader of revocation state and the freshness or liveness bound it enforces. When the authority is unavailable, the component reports the dependency failure instead of a live capability.
Across operators, freshness and anti-rollback are part of the contract. Issuer-signed lifecycle status is pulled with a time to live and fails closed when stale, and revoking a treaty ends every grant issued under it, delegated ones included. A signed but stale view could omit a recent withdrawal, a failure distinct from a caller resubmitting an old, still-valid request, which replay protection covers.
Read more in Revocation oracle and the quorum model, which define the independent evidence a federated deployment requires.
Containment expires and rolls back; revocation is a deliberate act
Active defense can suspend a session or a capability set under an authorized, bounded response plan, and those controls carry expiry and rollback. Permanent revocation remains an explicit manual action, so a temporary detector finding never silently becomes an irreversible change to authority.
The two compose. When a temporary response is lifted, the capability's independent revocation status is still enforced. A suspension that lands after admission refuses at dispatch, and a frozen lineage cannot mint delegates.
Read more in Quarantine and recovery, for containing a session while keeping an operator-controlled return path.
Withdrawing the parent refuses both children on their next read
The Authority application supplies the issuer, the parent, two narrowed child grants, persistent revocation authority, and the repository connector. Start from new private state, with the prepared example commands, so the family still has unused capacity at withdrawal:
authority init --repository repository --state .withdrawal --json > before.json
authority read src/discount/rules.py \
--as reviewer --operation before-withdrawal \
--state .withdrawal
authority inspect --state .withdrawal --json | jq '{parent_revoked, family, grant_usage}'The first source read succeeds and consumes one of three family uses. Both child signatures stay valid; the reviewer has one local use left and the tester two. The operator then selects the original parent from the host's process registry and commits its withdrawal, reading it back before continuing:
let selected = runtime.process(&args[3])?.capability;
kernel.revoke_capability(&selected.id)?;
ensure!(
kernel.is_capability_revoked(&selected.id)?,
"revocation readback missing"
);authority revoke parent --state .withdrawal
authority inspect --state .withdrawal --json | jq '{parent_revoked, family, grant_usage}'
authority read src/discount/rules.py \
--as reviewer --operation after-withdrawal \
--state .withdrawal
authority read src/refund/rules.py \
--as tester --operation after-withdrawal \
--state .withdrawal
wc -l .withdrawal/resource-reads.jsonlYou should see the parent read back as revoked, both new child reads denied, and the connector journal still at one read, with the unused allowance intact. Each command reopens the same stores in a new process, and the kernel reads the original SQLite authority's revocation store instead of provisioning an empty replacement. The refusal follows withdrawn ancestry, with no expired signature or exhausted counter involved.
| Observation | Before withdrawal | After reopen |
|---|---|---|
| Parent revocation | false | true |
| Family captured / limit | 1 / 3 | 1 / 3 |
| Reviewer / tester captured uses | 1 / 0 | 1 / 0 |
| New child request | Reviewer read succeeds | Both descendants refuse |
| Physical repository reads | 1 | 1 |
Withdraw a whole swarm family before its next call
The Security swarm example withdraws its root capability through the stopped process host, then presents all six descendant calls in its plan. The administrative command and the requests that follow are separate evidence:
def scenario_revocation(sock_root):
swarm = Swarm("revocation", sock_root, family_budget=PLANNED_CALLS)
revoked = run_chio("revoke-capability", "--state", str(swarm.state), "--process", "root")
if revoked.get("capability_revoked") is not True:
raise ScenarioFailure(f"root capability was not revoked: {revoked}")
swarm.host.start()
try:
results = {process: swarm.run_planned(process) for process in swarm.calls}
finally:
swarm.host.stop()
reason = f"capability has been revoked: {revoked['capability_id']}"
graphs = {call["governed_intent"]["context"]["chioSwarm"]["taskGraph"]["id"]
for process, call in swarm.calls.items() if refused(results[process], reason)}
if len(graphs) != 3 or not all(refused(result, reason) for result in results.values()):
raise ScenarioFailure(f"the family revocation did not refuse every graph: {results}")
retain_results(swarm, results)
print("DENY revocation: revoked capability refused for every graph")python3 run.py
python3 inspect.pyrevocation: 6 descendants denied; mailbox contains 0 messages
The command reports the root withdrawn, every descendant refuses, and the independently queried mailbox is empty. This is the before-admission case, and the boundaries above govern work that had already moved.
A revoked operation is reconciled under its own identity, never re-authorized
Recovery begins with the original operation. Determine whether it owns a reversible reservation, a capture with an unknown outcome, or a retained completion whose terminal state is immutable, and re-establish the required stores, signing history, and current serving fence before a recovery worker changes that state. A captured allowance stays with its request commitment, and the same payload is never routed through a new capability automatically.
Continuing the task under a replacement grant is a new authorization decision, recorded after the original attempt is reconciled, and it resolves nothing about the original effect. Reissuing a wider token to make a timeout disappear can leave two effectful operations in flight, and a replacement grant keeps the information restrictions on data the agent already read. Ownership and serving fences decide who may change the original state, so a rejected duplicate never compensates another worker's live reservation, and recovery reads history without turning it into dispatch authority.
In Rust: withdrawal, readback, and the store's error classes
An embedding host can keep three observations separate: the withdrawal call, an authoritative readback, and the next request's admission, attempted only after readback confirms the withdrawal. The evaluation API can dispatch a tool, so the function holds without calling it when readback is false or failed:
use chio_kernel::{
ChioKernel, KernelError, SecurityInvocationContext, ToolCallRequest, ToolCallResponse,
};
pub struct WithdrawalObservation {
pub mutation: Result<(), KernelError>,
pub currently_revoked: Result<bool, KernelError>,
pub next_admission: Option<Result<ToolCallResponse, KernelError>>,
}
/// The host keeps its configured revocation store and original request.
/// A mutation error alone does not establish whether the store committed.
pub async fn withdraw_and_observe(
kernel: &ChioKernel,
request: &ToolCallRequest,
trusted_context: &SecurityInvocationContext,
) -> WithdrawalObservation {
let mutation = kernel.revoke_capability(&request.capability.id);
let currently_revoked = kernel.is_capability_revoked(&request.capability.id);
// This call can invoke the tool. Never use it as an observation while
// withdrawal is unconfirmed: the permitted tool could otherwise execute.
let next_admission = if matches!(¤tly_revoked, Ok(true)) {
Some(
kernel
.evaluate_tool_call_with_security_context(request, trusted_context)
.await,
)
} else {
None
};
WithdrawalObservation { mutation, currently_revoked, next_admission }
}The kernel's withdrawal method commits through its revocation store and emits a runtime trace observation when one is configured; subsequent checks consult the store, and the trace records the path.
pub fn revoke_capability(&self, capability_id: &CapabilityId) -> Result<(), KernelError> {
info!(capability_id = %capability_id, "revoking capability");
let trace_transition = self.lock_runtime_trace_transition()?;
let newly_revoked = self.with_revocation_store(|store| Ok(store.revoke(capability_id)?))?;
let trace_event = if self.runtime_trace_observer.is_some() {
Some(RuntimeTraceEvent::RevocationCommitted {
source_sequence: self.allocate_runtime_trace_source_sequence()?,
capability_id: capability_id.clone(),
newly_revoked,
delegation_depth_limit: self.config.max_delegation_depth,
})
} else {
None
};
drop(trace_transition);
if let Some(event) = trace_event {
self.observe_runtime_trace(event);
}
Ok(())
}The store's error type separates an ordinary storage failure, an unknown durable mutation outcome, and a fenced serving owner. A mutation error can follow a committed change, so investigate an unknown outcome under the store's recovery contract before reporting the capability live or withdrawn, and repair serving ownership when the owner is fenced; neither error lets an older worker force a mutation through a replacement local store. A next-call refusal shows nothing about an effect captured before the withdrawal, which stays with its accounting for reconciliation under current serving ownership.
pub enum RevocationStoreError {
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("failed to prepare revocation store directory: {0}")]
Io(#[from] std::io::Error),
#[error("revocation store synchronization error: {0}")]
Sync(String),
#[error("revocation mutation durable outcome is unknown: {0}")]
OutcomeUnknown(String),
#[error(
"revocation store serving owner fenced (expected epoch {expected_epoch}, actual {actual_epoch:?})"
)]
Fenced {
expected_epoch: u64,
actual_epoch: Option<u64>,
},
}Retain the original capability ID, the store epoch, the returned error class, and the next authoritative observation, then test the relevant delegation subtree and dispatch boundary. A successful administrative call and a refused descendant presentation are related evidence, each answering its own question.