BuildIdentity
Rotate Keys & Revoke
Rotate signing keys and revoke Agent Passports, capability tokens, and affected delegated capabilities.
Revocation is visible history
Prerequisites
- A revocation store the kernel reads. Either a local sqlite file named by
--revocation-db, or a trust-control service reached through--control-url. Every command here targets one or the other. - An admin control token for the service path. Revoking and rotating go through
--control-token, which the CLI sends as anAuthorization: Bearerheader; reading a passport lifecycle record does not need one. - A passport statuses registry if you publish passports: a JSON file named by
--passport-statuses-file, or the same registry hosted by trust-control. A record has to be published before it can be revoked. - An authority backend on the service if you rotate operator keys. Start
chio trust servewith--authority-db: on that backend the status carries a generation counter and arotatedAtstamp. With--authority-seed-filethe rotation still happens but both come back null, and with neither the rotate call answers 409.
When to Rotate
Chio has three independent lifecycles, each with its own rotation cadence and emergency revocation path. Rotate on a schedule to limit the lifetime of a leaked key; revoke on demand after a compromise.
- Planned rotation. Operator authority keys should roll on a fixed cadence (typically every 90 days) to limit the lifetime of a leaked seed. The trust-control service exposes
rotated_aton its authority status so verifiers can confirm the latest rotation. - Reactive revocation. A compromised subject, a bad deployment, or a failed policy evaluation is reason to revoke immediately. For capability tokens this flips one row in the revocation store. For passports this transitions the lifecycle record to
Revoked. - Superseding publication. A new passport for the same subject replaces the old one by publishing a lifecycle record with
superseded_bypointing at the new passport ID. This is a lifecycle transition that relying parties honor, not a security revocation.
These actions leave persistent records. Rotation metadata lives on the authority status; revocation records live in the sqlite revocation store or the trust-control cluster; passport lifecycle records live in the passport statuses registry.
Key and credential lifecycles
Before you run any command, understand which lifecycle you are touching. Treat each lifecycle independently.
| Object | What it authorizes | Where state lives |
|---|---|---|
Operator signing key | Signs capability tokens, issues reputation credentials, authenticates trust-control writes | An authority seed file, or the service authority database. Only the database backend stamps TrustAuthorityStatus.rotated_at and a generation counter |
Agent Passport | Carries portable reputation credentials bound to a did:chio subject | PassportLifecycleRecord in the passport-statuses registry |
Capability token | Authorizes a specific tool invocation under scope, budget, and TTL | RevocationRecord in SqliteRevocationStore or trust-control |
A rotation in one lifecycle does not affect another. Rotating an operator key does not revoke outstanding capabilities signed by the previous key. Revoking a passport does not revoke the capability tokens the subject already holds. Revoke each affected credential type separately.
Rotating an Operator Signing Key
An operator signing key is an Ed25519 keypair held on disk as a seed file. Chio reads it with load_or_create_authority_keypair and rolls it with rotate_authority_keypair, both exported from chio-control-plane. The rotation is atomic: a new Ed25519 keypair is generated, written to the seed path, and the new public key is returned.
pub fn rotate_authority_keypair(path: &Path) -> Result<chio_core::PublicKey, CliError> {
let keypair = Keypair::generate();
write_authority_seed_file(path, &keypair)?;
Ok(keypair.public_key())
}In production you should rotate through the trust-control service so all replicas observe the rotation. The service exposes POST /v1/authority, which performs the same key rotation and returns a TrustAuthorityStatus carrying the new public key. GET /v1/authority reads the same record back. Rotation is a trust-plane operation on shared state, so it runs through the service or through the seed file the service reads; there is no per-process CLI verb that would let one replica rotate out from under the others.
Which backend the service runs on decides what the status can tell a verifier. On --authority-db the response carries a monotonic generation and a rotatedAt stamp. On --authority-seed-file both come back null, so a replica has no rotation marker to compare against and has to diff the key itself.
After rotation, confirm that the new public key is visible to each verifier. Verifiers should query the authority endpoint and pin the new key in their local policy before the old key actually stops signing new capabilities.
Rotation does not invalidate old signatures
Revoking an Agent Passport
An Agent Passport is a bundle of signed credentials. Revocation operates on the published PassportLifecycleRecord, not on the signed passport. The record moves from Active to Revoked, stamps revoked_at, and optionally carries a revoked_reason string returned by later resolutions.
pub struct PassportLifecycleRecord {
pub passport_id: String,
pub subject: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub issuers: Vec<String>,
pub issuer_count: usize,
pub published_at: u64,
#[serde(default)]
pub updated_at: u64,
pub status: PassportLifecycleState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_reason: Option<String>,
#[serde(default, skip_serializing_if = "PassportStatusDistribution::is_empty")]
pub distribution: PassportStatusDistribution,
pub valid_until: String,
}To revoke, call passport status revoke against the same registry file or trust-control service where the passport was originally published. The record must already be published: the registry refuses to transition an entry that does not exist.
# Revoke via trust-control (all replicas observe it)
chio \
--control-url https://trust.example.com \
--control-token operator-admin-token \
passport status revoke \
--passport-id <passport-artifact-id> \
--reason key-leakAgainst a local registry file the same verb prints the transition it wrote:
$ chio passport status revoke \
--passport-id "$(cat ./passport-id.txt)" \
--passport-statuses-file ./passport-statuses.json \
--reason compromisedpassport revoked passport_id: 57191b1ef425678d13e2080b8447bddba4ae8363585ff0d3a69efdac414cf4cc state: revoked revoked_at: 1788609711 revoked_reason:compromised
The trust-control variant takes no --passport-statuses-file: the service owns the registry, and the record it returns carries the same fields under camelCase keys.
Once committed, later resolutions return a PassportLifecycleResolution with state = Revoked. If a verifier policy has requireActiveLifecycle: true, the passport stops evaluating successfully the moment that resolution is available.
$ chio passport status resolve \
--passport-id "$(cat ./passport-id.txt)" \
--passport-statuses-file ./passport-statuses.jsonpassport_id: 57191b1ef425678d13e2080b8447bddba4ae8363585ff0d3a69efdac414cf4cc state: revoked subject: did:chio:d02f9b5f38ce7746e2ccbd8fc93f6d1d9dd4ddbe02a3e206830a6950b3752590 updated_at: 1788609711 source: registry:./passport-statuses.json revoked_at: 1788609711 revoked_reason:compromised
An empty reason poisons the registry file
PassportLifecycleRecord validation rejects an entry with an empty revoked_reason, but the CLI does not run that validation before it writes. Passing --reason '' succeeds, exits zero, and stores the empty string. Every later read of that registry then fails, including the revoke that would have repaired it, so the file can only be fixed by hand. Always supply a non-empty string, or omit the flag.$ chio passport status revoke \
--passport-id "$(cat ./passport-id.txt)" \
--passport-statuses-file ./poisoned-statuses.json \
--reason ''passport revoked passport_id: 57191b1ef425678d13e2080b8447bddba4ae8363585ff0d3a69efdac414cf4cc state: revoked revoked_at: 1788609711 revoked_reason:
$ chio passport status resolve \
--passport-id "$(cat ./passport-id.txt)" \
--passport-statuses-file ./poisoned-statuses.jsonerror [urn:chio:error:policy:decision-denied]: invalid passport lifecycle contract: passport lifecycle entry `57191b1ef425678d13e2080b8447bddba4ae8363585ff0d3a69efdac414cf4cc` cannot include an empty revoked_reason
context: {"domain":"policy","severity":"error","stability":"stable","string_code":"CHIO-CLI-POLICY"}
suggested fix: Inspect the policy rule path and update the request or policy inputs before retrying.Revoking a Capability Token
Capability tokens are revoked at admission. Each admission through ChioKernel consults a RevocationStore. Flipping one row stops that token from authorizing the next admission at each kernel that can read the store.
pub trait RevocationStore: Send + Sync {
/// Check if a capability ID has been revoked.
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
}
}Four methods, and every one takes &self, so a store is shared behind an Arc and mutates through interior mutability. observe_revocation is the richer read: it returns the boolean plus the commit metadata a distributed store attaches, and its default body answers from is_revoked with no commit. The row the sqlite store persists is two fields:
pub struct RevocationRecord {
pub capability_id: String,
pub revoked_at: i64,
}The CLI command is chio trust revoke. It targets either a local sqlite revocation store (via --revocation-db) or a remote trust-control service (via --control-url). Both call the same revocation operation.
# Local single-node kernel, persistent sqlite revocation store
chio \
--revocation-db revocations.sqlite3 \
trust revoke \
--capability-id cap-test-123
# Trust-control service, cluster-wide visibility
chio \
--control-url https://trust.example.com \
--control-token operator-admin-token \
trust revoke \
--capability-id cap-test-123$ chio --revocation-db ./revocations.sqlite3 \
trust revoke --capability-id cap-test-123capability_id: cap-test-123 revoked: true newly_revoked: true backend: ./revocations.sqlite3
The command prints whether the capability was newly revoked and which backend committed the write. In JSON mode you get a machine readable result suitable for automated runbooks.
$ chio --json --revocation-db ./revocations.sqlite3 \
trust revoke --capability-id cap-test-123{
"capability_id": "cap-test-123",
"newly_revoked": false,
"revocation_backend": "./revocations.sqlite3",
"revoked": true
}newly_revoked distinguishes the first write from a repeat, and the transcript above is the second run against the same store: revoked is still true and newly_revoked has gone false. That is what makes the step safe to re-run in a runbook. The alphabetical key order is the canonical JSON the command-result envelope serializes in, not a tidied listing.
Revocation Cascade
Revoking a capability also blocks its delegated descendants. When you revoke a root capability, any capability whose delegation_chain contains the revoked id is rejected on presentation.
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 write itself is one row. The rest of the function is the runtime trace: the revocation is committed inside a trace transition, and the commit metadata becomes a RevocationCommitted event only when the store actually changed something.
Admission is where the subtree is enforced. The kernel checks the presented capability first, then walks its delegation chain link by link, asking the revocation store about each id:
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(())
}The two failures are distinguishable. A revoked leaf raises capability has been revoked; a revoked ancestor raises delegation chain revoked at ancestor <id>. Inspect the capability lineage and reissue the chain from a non-revoked ancestor.
This has three practical implications:
- Revoke a delegated-capability root when its issuer is compromised. If an agent hands out ten attenuated children and one of those children misbehaves, revoke the child. If the agent itself is compromised, revoke the parent; its children will also be rejected.
- A chain whose ancestors the kernel cannot find also fails, for a different reason. Delegation admission requires a stored capability snapshot for every ancestor, and refuses the chain with
delegation admission failed: missing capability snapshot for delegation ancestorwhen one is absent, or when the stored ancestor's depth or parent linkage disagrees with the presented chain. That is a lineage failure, not a revocation one, and it fires whether or not anything was ever revoked. - Reissuance requires a live ancestor. Reissue from a non-revoked node to restore a subtree. The
RevocationStoretrait has no unrevocation operation.
A Revoked Capability, End to End
The transcript below is one capability across four steps: it authorizes a tool call, an operator revokes it, the same call is refused, and the refusal is itself a signed receipt. Every labelled block is output captured from that run.
Two processes. The trust-control service owns the revocation set, and the MCP edge reads it over --control-url, which is what makes a revoke on one host visible to the kernel on another. Start the service first:
chio trust serve \
--listen 127.0.0.1:8787 \
--service-token operator-admin-token \
--receipt-db ./receipts.sqlite3 \
--authority-db ./authority.sqlite3 \
--session-db ./trust-session.sqlite3Then the edge, pointed at that service. It wraps a tool server that exposes one tool, hello_world, under a policy that grants it. This is the project chio init scaffolds.
chio --session-db ./session.sqlite3 \
--control-url http://127.0.0.1:8787 \
--control-token operator-admin-token \
mcp serve --policy policy.yaml --server-id hello \
-- ./target/debug/hello_server1. The capability authorizes the call
An MCP client sends tools/call. The kernel admits it and the wrapped server runs.
{
"id": 3,
"jsonrpc": "2.0",
"result": {
"content": [
{
"text": "Hello, Ada! This call was mediated by Chio.",
"type": "text"
}
],
"isError": false,
"structuredContent": {
"greeting": "Hello, Ada! This call was mediated by Chio."
}
}
}The receipt that call wrote names the capability the operator is about to revoke. Read it back through the same control service and take the capability_id. Receipts come back in ascending sequence order, so take the last line rather than --limit 1, which would hand you the oldest receipt in the store:
chio --control-url http://127.0.0.1:8787 \
--control-token operator-admin-token \
receipt list --limit 200 \
| tail -n 1 | jq -r '.capability_id'cap-019fbe3d-bcc9-7263-9752-68702b83f3692. The operator revokes it
chio --control-url http://127.0.0.1:8787 \
--control-token operator-admin-token \
trust revoke \
--capability-id cap-019fbe3d-bcc9-7263-9752-68702b83f369capability_id: cap-019fbe3d-bcc9-7263-9752-68702b83f369
revoked: true
newly_revoked: true
backend: http://127.0.0.1:8787backend reports the service URL rather than a file path, which is how you confirm the write landed in shared state and not in a local sqlite file only this process reads.
3. The same call is refused
No restart, no new session. The client repeats the identical tools/call on the live edge.
{
"id": 4,
"jsonrpc": "2.0",
"result": {
"content": [
{
"text": "capability has been revoked: cap-019fbe3d-bcc9-7263-9752-68702b83f369",
"type": "text"
}
],
"isError": true
}
}The wrapped tool server was never reached. The kernel raised KernelError::CapabilityRevoked during admission, and the edge surfaced it as an MCP tool error with isError: true.
4. The refusal is on the record
A denial writes a signed receipt on the same schema as an allow. Query the deny lane:
chio --control-url http://127.0.0.1:8787 \
--control-token operator-admin-token \
receipt list --outcome deny --limit 5{
"action": {
"parameter_hash": "88bab6d8f6dc68a877064d584cbb5b6c50e74f617ea50d81d3a53c2ee6ffbc4f",
"parameters": {
"name": "Ada"
}
},
"boundary_class": "prevent",
"capability_id": "cap-019fbe3d-bcc9-7263-9752-68702b83f369",
"content_hash": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"decision": {
"guard": "kernel",
"reason": "capability has been revoked: cap-019fbe3d-bcc9-7263-9752-68702b83f369",
"verdict": "deny"
},
"id": "110b30ea4829d0cda2b23fa65c117906532bd0ae45a60297c4fd76c00f14382b",
"kernel_key": "c56228c41776e96d3b266053c0cfd3e2b8439cad5b83730b37a8d2d8ec4fa226",
"metadata": {
"attribution": {
"delegation_depth": 0,
"issuer_key": "617e2042e610c87a33ba9876e6cb6d750389db1dd74010b62372aa4e6e7eb73d",
"subject_key": "ead3b427b2954a3373dce7976618a5af04d7d4e60eccfdeb11ccc32bfd163172"
},
"chio_receipt_signing_nonce": "rcpt-019fbe3d-bf7e-7ab2-89b0-f5a89725b5dc",
"receipt_context": {
"request_id": "mcp-edge-req-a26edfd6b5b14d6aa4d8b81697e975dd"
}
},
"policy_hash": "69e943b96e9ce64d0264bb56bf8930e77bd4e68adf68fcf1395790dae03e6b55",
"receipt_kind": "mediated_decision",
"redaction_mode": "none",
"signature": "a1ea2190da78070fea48d91464b3756f2110b7bba5e3e3f282df3bc2df7abcbeaac386bfb8c9b534e8a365e5f147132884fc4bceb39fc3e082b687cba7687a0d",
"timestamp": 1785603145,
"tool_name": "hello_world",
"tool_origin": "caller_executed",
"tool_server": "hello",
"trust_level": "mediated"
}Four fields carry the incident. decision.guard is kernel, which is what every kernel-built deny receipt carries: the field names the layer that refused, not the check that fired. Read decision.reason and, for a guard denial, evidence[].guard_name to tell one refusal from another. decision.reason repeats the revoked id. capability_id ties the denial to the allow that preceded it. And action.parameter_hash is byte identical to the allowed call's, which is what lets an auditor prove the two receipts describe the same request either side of the revoke.
The allow stays in the log
kernel_key c56228c4.... That pair, not the deny alone, is what an incident review reads.Publishing & Checking Revocation Status
Verifiers must be able to read a revocation before it affects their decisions. Chio provides two publication paths for capability revocations and one for passport lifecycle.
- Local sqlite.
SqliteRevocationStorepersistsRevocationRecordrows on disk and exposeslist_revocations_afterfor cursor-based replication. Appropriate for single-node kernels and offline proofs. - Trust-control service. Run
chio trust servewith either a joint authority database (--session-db, which supplies the revocation store along with budget state) or a standalone--revocation-db. The two are mutually exclusive, and with neither a revoke answers 409 naming the flag it wants. Every node pointed at--control-urlthen sees the same revocation set. The service exposes a revocation query API with cursor pagination and a revoke admin endpoint. - Passport status distribution. Passport revocations are published into the passport-statuses registry and resolved through
/v1/public/passport/statuses/resolve/{passport_id}. Each resolution includes anupdatedAtso consumers can distinguish currentactivestate from fail-closedstalestate against the advertisedcacheTtlSecs.
To query capability revocation status from the CLI, use chio trust status. To query passport lifecycle, use chio passport status resolve.
# Capability status
chio --json \
--revocation-db revocations.sqlite3 \
trust status \
--capability-id cap-test-123
# Passport lifecycle
chio passport status resolve \
--passport-id <passport-artifact-id> \
--passport-statuses-file passport-statuses.json
# Passport lifecycle via the public holder route (read-only, no admin token)
chio passport status resolve \
--passport-id <passport-artifact-id> \
--control-url https://trust.example.com$ chio --json --revocation-db ./revocations.sqlite3 \
trust status --capability-id cap-test-123{
"capability_id": "cap-test-123",
"revocation_backend": "./revocations.sqlite3",
"revoked": true
}The same query against an id the store has never seen returns revoked: false rather than an error, so a status poll does not need to distinguish “never issued” from “issued and still live”.
$ chio --json --revocation-db ./revocations.sqlite3 \
trust status --capability-id cap-never-issued{
"capability_id": "cap-never-issued",
"revocation_backend": "./revocations.sqlite3",
"revoked": false
}If you start trust-control with --advertise-url, published passport records inherit https://.../v1/public/passport/statuses/resolve as the default holder resolution endpoint, together with a cacheTtlSecs of 300. The TTL is a mandatory companion rather than a precondition: PassportStatusDistribution validation rejects a distribution that publishes resolve URLs without one, and rejects a TTL of zero. You can also advertise the resolve URL through the subject DID document via chio did resolve --passport-status-url ..., which emits an ChioPassportStatusService DID service entry.
Emergency Response Runbook
When a seed file leaks or an agent is confirmed compromised, stop thinking about rotation cadence; run this procedure in order. Each step is idempotent; repeat one if a partial failure leaves you uncertain.
- 1. Revoke outstanding capabilities. Identify the root capability ids the compromised key or subject controls. For each one, run
chio trust revoke --capability-id <id>against the trust-control service. The cascade handles descendants automatically: chains containing a revoked ancestor are rejected at admission. - 2. Revoke the passport. If the compromised subject carried a passport, transition its lifecycle record with
chio passport status revoke --passport-id <id> --reason compromised. Any verifier policy withrequireActiveLifecycle: truenow fails closed for that subject. - 3. Rotate the operator key. If the leaked material was the authority seed, regenerate the keypair atomically by overwriting the
--authority-seed-filetarget and restarting the trust-control service, or by callingPOST /v1/authorityon the trust-control HTTP endpoint. The new public key appears onTrustAuthorityStatusso each replica can pin it, and the previous key stays intrustedPublicKeysso signatures it already made keep verifying. Run the service on--authority-dbif you want the rotation stamped: the seed-file backend returns a nullgenerationandrotatedAt. - 4. Republish downstream policy. Reissue signed verifier policies and reputation credentials under the new key so that
issuer_allowlistentries keep matching. Stale policies still verify against their signer, but they no longer name the current authority. - 5. Audit the receipt log. Pull receipts for the compromised subject window and reconcile what the revoked capabilities did before the revocation landed. Revocation leaves existing receipt entries unchanged and stops new admissions.
# Emergency: one agent compromised, one operator key leaked
export CHIO_CONTROL=https://trust.example.com
export CHIO_TOKEN=operator-admin-token
# 1. Kill the agent's capability subtree
chio --control-url $CHIO_CONTROL --control-token $CHIO_TOKEN \
trust revoke --capability-id cap-root-7b0f6f63
# 2. Revoke the passport
chio --control-url $CHIO_CONTROL --control-token $CHIO_TOKEN \
passport status revoke \
--passport-id passport-7b0f6f63-v3 \
--reason compromised
# 3. Rotate the authority key. Rotation is a trust-plane operation on
# shared state, so it runs through trust-control, not a per-process verb.
curl -X POST "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer $CHIO_TOKEN"
# 4. Confirm the new authority public key
curl "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer $CHIO_TOKEN"Steps 3 and 4, against a live service started with --authority-db. Read the status first so you know which key you are replacing:
$ curl -sS "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer $CHIO_TOKEN" | jq .{
"configured": true,
"backend": "sqlite",
"publicKey": "d991d21af0635a2eacdca9e85966fd8f08bdb3580d28c72dc0fee4f334febf23",
"generation": 1,
"rotatedAt": 1788609712,
"appliesToFutureSessionsOnly": true,
"trustedPublicKeys": [
"d991d21af0635a2eacdca9e85966fd8f08bdb3580d28c72dc0fee4f334febf23"
]
}$ curl -sS -X POST "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer $CHIO_TOKEN" | jq .{
"appliesToFutureSessionsOnly": true,
"backend": "sqlite",
"configured": true,
"generation": 2,
"publicKey": "4a3756f1650d76f57a8d7cc09a83aa95c4b14cfe339857e9db2fde282783a807",
"rotatedAt": 1788609712,
"trustedPublicKeys": [
"d991d21af0635a2eacdca9e85966fd8f08bdb3580d28c72dc0fee4f334febf23",
"4a3756f1650d76f57a8d7cc09a83aa95c4b14cfe339857e9db2fde282783a807"
]
}generation went from 1 to 2 and publicKey changed, which is the pair a replica compares. trustedPublicKeys now holds both keys: rotation adds, it does not retract, so a capability the old key signed still verifies until it expires or is revoked. appliesToFutureSessionsOnly says the same thing about sessions already open.
Do not wait for TTL
Failures and Recovery
| What you see | What it means | What to do |
|---|---|---|
missing or invalid control bearer token, HTTP 401 | The control token does not match the service token | Check --control-token against the service's --service-token. Reads of the public passport route need no token at all |
trust control service requires --authority-seed-file or --authority-db, HTTP 409 | The service was started with no authority backend, so there is nothing to rotate | Restart it with --authority-db and rotate again |
trust control service requires --revocation-db, HTTP 409 | The service holds no revocation store | Restart with a joint authority database (--session-db) or a standalone --revocation-db. Passing both is refused |
a joint authority database replaces separate budget and revocation databases | Both --session-db and --revocation-db were passed to the service | Drop one. The joint database already owns the revocation store |
was not found in the lifecycle registry | You revoked a passport the registry never published | Publish the record first with chio passport status publish, then revoke |
cannot include an empty revoked_reason | A revoke with --reason '' already landed and every read of that registry now fails | Edit the registry JSON by hand: the repair path runs the same failing load |
delegation chain revoked at ancestor | A capability presented under a revoked parent | Expected after a cascade. Reissue the subtree from a live ancestor; there is no unrevoke |
private directory ancestry must not be group or world writable unless sticky | The directory holding the sqlite files has permissive parents | chmod 700 the working directory before starting the service |
$ curl -sS -w '\nHTTP %{http_code}\n' "$CHIO_CONTROL/v1/authority" \
-H "Authorization: Bearer wrong-token"{"error":"missing or invalid control bearer token"}
HTTP 401Summary
| Action | Command | State it writes |
|---|---|---|
| Rotate authority key | POST /v1/authority on trust-control | TrustAuthorityStatus.rotated_at |
| Revoke a passport | chio passport status revoke | PassportLifecycleRecord.status = Revoked |
| Supersede a passport | chio passport status publish | superseded_by = <new-id> |
| Revoke a capability | chio trust revoke --capability-id ... | RevocationRecord in the revocation store |
| Check capability status | chio trust status --capability-id ... | Read-only query |
| Check passport lifecycle | chio passport status resolve | Returns PassportLifecycleResolution |
Next Steps
- Agent Passport · how passports are issued, verified, and what lifecycle states mean to a relying party
- Delegate Between Agents · how delegation chains are built, which informs how revocation cascades through them
- Capabilities · the capability-token authorization revoked by this procedure
- Trust Control Plane · how to run
chio trust serveso revocations propagate cluster-wide