Chio/Docs
LOGIN · JOIN

PlatformFan-Out & Fan-In

Swarm

Revocation Epochs

Cluster revokes a capability. Swarm names a task id in a signed epoch, and every continuation token commits to that epoch's root.

The other half of this split

Revocation Propagation owns revocation under one authority: a capability id and a timestamp, replicated between nodes that share a keypair, with no signature on the record and no epoch at all. This page owns the artifact that works where that assumption does not hold, across parties whose nodes you do not run. The unit changes with it. There the subject of a revocation is a capability; here it is a delegation subject or a task id inside a signed plan, and the object carrying it is itself signed and time-bounded because the party reading it has no other reason to believe it.

Source

This page reflects spec/schemas/chio-swarm/v1/revocation-epoch.schema.json and spec/PROTOCOL.md section 6.4.2 in the chio repository. The Swarm Protocol reference carries the full set of artifacts and the verification order.


A signed block list with a clock

A revocation epoch is one artifact in a swarm delegation bundle. It carries two lists, a validity window, a digest over the lists, and a signature from a pinned witness key. The verifier reads it on every bundle it is handed, checks the window against the caller-supplied instant, recomputes the digest from the lists, and then tests the bundle’s own subjects and task ids against it. Nothing about it is stateful: it is data inside the bundle, not a service the verifier calls.

Three things are shipped and unconditional in crates/kernel/chio-swarm-authority: the type, the validation, and the binding into every continuation token.

The name is reused elsewhere in the tree and the mechanisms are not the same one. crates/trust/chio-revocation-oracle is a Merkle accumulator over (subject, epoch_nonce) keys that produces inclusion and non-inclusion proofs and ticks a signed root on a schedule; its one shipped implementation is sparse_merkle::InMemoryRevocationOracle, and it is documented in Revocation Oracle. The Kernel-rung RevocationView holds a snapshot of revoked capability ids and denies a delegated dispatch when any link in the chain appears in it. Read that one carefully: the delegation cargo feature it sits behind is in chio-kernel’s default set rather than an opt-in, and consult_revocation_view returns Ok(()) when no view is installed, falling back to the per-row revocation store. The swarm epoch borrows neither. chio-swarm-authority declares exactly four dependencies: chio-core-types, serde, serde_json, thiserror. The only others in its manifest are chio-test-support and proptest, both dev-dependencies.


Nine fields

crates/kernel/chio-swarm-authority/src/types.rs306-316rust
pub struct SwarmRevocationEpoch {
    pub schema: String,
    pub epoch_id: String,
    pub root_hash: String,
    pub issued_at_unix_ms: u64,
    pub valid_until_unix_ms: u64,
    pub revoked_subjects: Vec<String>,
    pub revoked_task_ids: Vec<String>,
    pub issuer: String,
    pub signature: String,
}
FieldWhat it isChecked against
schemaThe pinned tag chio.swarm.revocation-epoch.v1String equality. Anything else is unsupported swarm revocation epoch schema
epochIdAn opaque name for this epochNon-empty, and equal to the signed task graph’s revocationEpochRef. No ordering is implied or read; see Guarantees and limits
rootHashA digest over the two lists64 lowercase hex characters, then recomputed from the lists and compared
issuedAtUnixMs, validUntilUnixMsA half-open validity windowThe bundle’s now_unix_ms. Issue after that instant, an empty or inverted window, and expiry at or before that instant are three separate refusals
revokedSubjectsDelegation subjects that may no longer actEach entry non-empty, no uniqueness check. Membership is tested against the graph issuer, the planner subject, and every witness-hop issuer, by exact string equality
revokedTaskIdsTask ids that may no longer runNo emptiness or uniqueness check at all. Each entry is tested for presence in the graph’s task index; an id naming no node in this graph is a silent no-op
issuer, signatureA pinned witness key and its detached signatureThe issuer parses to a public key that must be a member of the caller-supplied trusted slice, then verifies the signature over the canonical epoch with signature removed

The wire form is pinned separately in spec/schemas/chio-swarm/v1/revocation-epoch.schema.json, which sets additionalProperties: false, requires all nine fields, holds rootHash to ^[0-9a-f]{64}$ and signature to ^[0-9a-f]{128}$, and gives both list item types minLength: 1. The Rust side carries deny_unknown_fields, so the closed-object rule holds in both. The two disagree in the same two directions they disagree everywhere else in this family, and Continuation Tokens works that asymmetry through once for the whole bundle. Two instances are local to this artifact. revokedTaskIds entries get no emptiness check from the verifier at all, so a blank entry is schema-invalid and verifier-acceptable, where a blank revokedSubjects entry is refused by both. And neither list is checked for uniqueness on either side: the schema sets no uniqueItems and the verifier calls require_unique_strings on neither list, so a repeated entry is accepted everywhere and still changes the root, because the digest sorts without deduplicating.

The root hash is a digest, not a tree

crates/kernel/chio-swarm-authority/src/verifier.rs1546-1549rust
struct RevocationEpochListRoot<'a> {
    revoked_subjects: Vec<&'a str>,
    revoked_task_ids: Vec<&'a str>,
}
crates/kernel/chio-swarm-authority/src/verifier.rs1551-1558rust
fn revocation_epoch_list_root_hash(
    epoch: &SwarmRevocationEpoch,
) -> Result<String, SwarmAuthorityError> {
    canonical_sha256(&RevocationEpochListRoot {
        revoked_subjects: sorted_strings(&epoch.revoked_subjects),
        revoked_task_ids: sorted_strings(&epoch.revoked_task_ids),
    })
}

The preimage type carries #[serde(rename_all = "camelCase")], so the hashed object holds revokedSubjects and revokedTaskIds and nothing else. That is a SHA-256 over the canonical JSON of two sorted string arrays. It is order-independent, because sorted_strings sorts before hashing, and it is not deduplicating, so ["a", "a"] and ["a"] are different epochs with different roots. What it does not carry is any proof structure. There is no inclusion proof, no non-inclusion proof, and no way to answer a membership question without the whole list. A holder of the root alone learns nothing except whether a list it already has matches. If you want proofs against a root, that is the oracle crate, and it is a different object.


What the verifier does with it

validate_revocation_epoch runs as one step inside the fixed pipeline Swarm Authority documents, after the task graph, route plans, joins, and the budget pool, and before terminal receipts and continuation tokens. Its internal order is what makes the refusals readable, because the first failing check returns and nothing after it runs.

#ConditionMessage
1Schema tag is not the pinned constantunsupported swarm revocation epoch schema: {schema}
2epochId is the empty stringswarm revocation epoch id must not be empty
3epochId differs from the graph’s revocationEpochRefswarm revocation epoch ref mismatch
4rootHash is not 64 lowercase hex charactersswarm revocation epoch root must be a lowercase sha256 digest
5issuedAtUnixMs is after now_unix_msswarm revocation epoch is from the future
6validUntilUnixMs is at or before issuedAtUnixMsswarm revocation epoch window is empty
7validUntilUnixMs is at or before now_unix_msswarm revocation epoch is stale
8Recomputed list root differs from rootHashswarm revocation epoch root mismatch
9issuer or signature is the empty stringswarm revocation epoch issuer must not be empty, swarm revocation epoch signature must not be empty
10issuer does not resolve to a public keyswarm witness issuer did:chio is not self-certifying, or swarm witness issuer public key invalid: {error}
11Issuer key is absent from the trusted sliceswarm revocation epoch issuer is not pinned: {epochId}
12signature is not parseable hex, or does not verify over the canonical epochswarm revocation epoch signature invalid: {epochId}, with : {error} appended on the parse branch
13Any revokedSubjects entry is emptyswarm revoked subject must not be empty
14Graph issuer, planner subject, or any witness-hop issuer is a revoked subject, tested in that orderswarm authority subject is revoked: {subject}
15A revoked task id names a node in this graphswarm task is revoked: {taskId}

Steps 8 and 12 are the pair that makes the list trustworthy rather than merely present. The root is recomputed from the lists the epoch actually carries, so an epoch cannot advertise a digest that does not describe it; the signature covers the whole epoch with the signature field stripped, so the lists cannot be edited without the pinned key. Editing one list and leaving the root alone is swarm revocation epoch root mismatch (swarm_authority_stage0_rejects_revocation_epoch_list_root_mismatch). Editing one list, recomputing the root, and failing to re-sign is swarm revocation epoch signature invalid (swarm_authority_stage0_rejects_unsigned_revocation_epoch_list_reseal). Only six of the fifteen refusals are asserted anywhere in the tree. Steps 1, 2, 3, 4, 6, 9, 10, 11, and 13 have no test and no fixture, so read them as code that has been read rather than code that has been exercised.

Step 14 reaches into the witness chains before those chains are structurally validated, which happens last in the pipeline. A revoked witness-hop issuer therefore rejects the bundle even when the chain it signed would have failed its own checks anyway. The reverse is also worth knowing, because it is a real gap rather than an ordering curiosity: the revoked-subject test names exactly three roles. It does not read the issuer of a continuation token, a route-plan receipt, a join receipt, or a terminal graph receipt. Those issuers are each pinned against the same trusted key slice by their own validator, so a stranger cannot sign them, but revoking one by subject does not stop a bundle it signed.

Step 15 is the one with a shipped bundle behind it. The fixture directory carries an epoch whose revokedTaskIds lists a task the graph still contains, and everything else about the bundle is signed and consistent.

swarm-authority · revoked-tasktranscript
$ source scripts/proof-room-quickstart-env.sh
$ chio proof verify \
  fixtures/proof-room/swarm-authority/revoked-task/transaction-passport.json
error [urn:chio:error:cli:other]: proof verify: swarm task is revoked: task-child-a
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.
exit 1

The message carries the task id, so an operator holding only the rejection knows which node to detach and re-plan. The other fourteen conditions in the table produce the same shape on stderr and the same exit status.

The root travels in every hop

rendering
What commits to what. The graph names the epoch, the epoch commits to its own lists, and each continuation token commits to both the epoch id and its root inside the token's own signature.
sourcecrates/kernel/chio-swarm-authority/src/verifier.rs:1455-1472crates/kernel/chio-swarm-authority/src/verifier.rs:1969-1984crates/kernel/chio-swarm-authority/src/verifier.rs:1618-1623at fe56570

A continuation token authorizes one child task, and two of its fields are the epoch it was minted under. validate_continuation_token requires revocationEpochRef to equal the bundle epoch’s epochId(swarm continuation revocation epoch mismatch), requires revocationEpochRootHash to be a lowercase digest, and requires it to equal the epoch’s rootHash field (swarm continuation revocation epoch root mismatch). Neither of those two refusals has a test. The second compares against the declared field rather than recomputing, which costs nothing here only because step 8 has already proved the two equal and returns before this code is reached. Both fields sit inside the token’s signature body, so a presenter can neither retarget a token at a different epoch nor patch its root to match one.

The binding is not vacuous on a token-free bundle, because there is no such thing: require_signed_swarm_delegation_evidence is checked fourth, after the key-slice precondition, the task graph, and the graph signature, and it rejects any bundle whose continuation tokens or witness chains are empty, with signed swarm delegation evidence missing: continuation tokens and witness chains are required. The epoch is validated after route plans, joins, and the budget pool, so every bundle that reaches the epoch check carries at least one token, and every token carries the root.

That is the whole mechanism for making a revocation land on a hop rather than on the plan. The graph is signed and fixed; the epoch is a separate signed artifact naming what the graph must be checked against; and every hop carries the digest of the view it was issued under. Change the lists and the root changes, and every token still carrying the old root fails at the equality check. The tokens are not amended in place by anything; they are re-minted or they stop verifying.

The terminal graph receipt binds the epoch too, but only by id: receipt.revocation_epoch_ref must equal the epoch id (swarm terminal revocation epoch mismatch), and the type carries no root-hash field. A terminal receipt commits to which epoch a graph closed under, not to what that epoch said.

The instant those checks read does not come from the bundle in production. The runtime overwrites it with the request clock before verifying, in the same function that resolves the stored bundle:

crates/kernel/chio-runtime-core/src/admission_hook/swarm_authority.rsrust
bundle.now_unix_ms = now_unix_ms;
verify_swarm_reference_hashes(&bundle, reference)?;
let route_plan = find_route_plan(&bundle, &reference.route_plan_receipt.evidence_id)?;
let route_metadata = verify_route_metadata_matches(route_metadata, route_plan)?;
verify_swarm_authority_bundle(&bundle, trusted_witness_keys).map_err(|error| {
    ChioRuntimeError::Rejected {
        code: "chio_swarm_authority_rejected",
        detail: error.runtime_detail(),
    }
})?;

A stored epoch that was inside its window when the bundle was written and outside it by dispatch is stale, and the whole bundle denies with chio_swarm_authority_rejected. That is the code path, not an observed one: no test in runtime_admission.rs advances the clock past a stored epoch’s window. The request must also name the epoch as one of seven id-plus-digest evidence pairs, and verify_swarm_reference_hashes compares the named id and canonical SHA-256 against the stored artifact before verification runs; a mismatch is chio_swarm_authority_ref_mismatch. The two proof paths that call the same verifier, the chio proof verify CLI and Proof Room, both read SystemTime::now for the same field.


What a new epoch costs

Publishing a revocation is not a one-artifact edit. Because the root is recomputed from the lists and every continuation token commits to that root, a change to either list invalidates every token in the bundle. The stage-0 suite has the operation written out, and it is the closest thing in the tree to a rotation procedure:

crates/kernel/chio-swarm-authority/tests/swarm_authority_stage0.rsrust
fn refresh_revocation_epoch_root(bundle: &mut SwarmAuthorityBundle) -> Result<(), Box<dyn Error>> {
    let root_hash = revocation_epoch_root_hash(
        &bundle.revocation_epoch.revoked_subjects,
        &bundle.revocation_epoch.revoked_task_ids,
    )?;
    bundle.revocation_epoch.root_hash = root_hash.clone();
    sign_revocation_epoch(&mut bundle.revocation_epoch)?;
    for token in &mut bundle.continuation_tokens {
        token.revocation_epoch_root_hash = root_hash.clone();
        sign_continuation_token(token)?;
    }
    Ok(())
}

Recompute, re-sign the epoch, then re-sign every continuation token against the new root. Every name in that excerpt is test-local, and one of them has to be. sign_revocation_epoch and sign_continuation_token are one-line wrappers over the exported sign_swarm_revocation_epoch and sign_swarm_continuation_token, but revocation_epoch_list_root_hash is private to verifier.rs and nothing equivalent is in lib.rs’s pub use list. There is no exported way to compute the root an epoch must carry, so anything that issues one reimplements the canonical-JSON digest by hand. Three places do: swarm_authority_stage0.rs, runtime_admission.rs, and the crate’s own examples/agent_os.rs. All three sort the two lists and hash a serde_json::json! literal keyed revokedSubjects and revokedTaskIds, which is the same object the verifier’s private struct serializes to only because its rename_all produces those two names. They agree today because the field names and the sort order happen to match, and nothing in the build would catch it if they stopped.

The negative fixture on disk is the result of exactly that procedure: fixtures/proof-room/swarm-authority/revoked-task/ keeps epochId at revocation-epoch-swarm-valid, adds task-child-a to revokedTaskIds, carries the new root 1d8a51dd..., and both continuation tokens in that directory carry the same new root under fresh signatures. Nothing about the fixture is malformed. It rejects on the revoked task id and on nothing else, which is what makes it a useful negative.


Guarantees and limits

StatusClaimEvidence
ShippedAn epoch’s lists cannot be edited without the pinned witness key: the root is recomputed from the lists, and the signature covers the epoch with signature stripped.revocation_epoch_list_root_hash, verify_revocation_epoch_signature, revocation_epoch_signature_body
ShippedEvery continuation token commits to the epoch id and the epoch root inside its own signature, so a token minted under one revocation view cannot be presented alongside another.validate_continuation_token; SwarmContinuationToken::revocation_epoch_root_hash in types.rs
Proved by testA revoked task id present in the graph, a revoked graph issuer, a future-dated epoch, a declared root that does not match its lists, an edited list that leaves the root stale, and a resealed but unsigned list all reject.Six tests in swarm_authority_stage0.rs: rejects_revoked_task, rejects_revoked_authority_subject, rejects_future_revocation_epoch, rejects_revocation_epoch_root_mismatch, rejects_revocation_epoch_list_root_mismatch, rejects_unsigned_revocation_epoch_list_reseal
Proved by testStaleness is exclusive at the boundary: an epoch whose validUntilUnixMs equals the evaluation instant is already stale.r_t03_recursive_swarm_conformance_rejects_generated_malformed_cases, case 2, in crates/tooling/chio-conformance. It is the only in-tree test that covers the stale branch
Proved by fixtureA fully re-signed bundle whose epoch revokes one of its own tasks is rejected on the public proof path.fixtures/proof-room/swarm-authority/negatives/revoked-task.json, claim claim.swarm.revocation_epoch_bound, failure code proof-room.negative.swarm-task-is-revoked, doctor spec swarm_revoked_task
LimitThe revoked-subject test covers three roles only: the graph issuer, the planner subject, and every witness-hop issuer. Continuation, route-plan, join, and terminal-receipt issuers are pinned but never tested for revocation.validate_revocation_epoch, the three revoked_subjects.contains sites
LimitOf those three roles, only the graph issuer is exercised. rejects_revoked_authority_subject revokes witness_issuer(), which the sample bundle also uses as task_graph.issuer, so the first branch returns and the planner-subject and witness-hop branches never run in any test.sample_swarm_bundle in swarm_authority_stage0.rs; the branch order in validate_revocation_epoch
LimitSubject matching is exact string equality, and issuer spelling is not pinned anywhere, so the two can be made to disagree. witness_issuer_public_key accepts did:chio: plus 64 lowercase hex, or any string PublicKey::from_hex takes, which includes bare hex in either case, a 0x prefix, and the p256:, p384:, and hybrid: wire forms. Re-issuing a graph with its issuer written in a second accepted spelling passes pinning and misses a revoked-subject entry naming the first. No JSON schema in spec/schemas/chio-swarm/v1/ constrains an issuer beyond minLength: 1.BTreeSet<&str> membership in validate_revocation_epoch, versus witness_issuer_public_key in verifier/witness.rs and PublicKey::from_hex in chio-core-types
LimitNo exported way to compute a root. revocation_epoch_list_root_hash is private to verifier.rs, so an issuer outside the crate must reimplement the canonical-JSON digest and keep it in step by hand.The pub use verifier::{...} list in lib.rs; the three hand reimplementations in swarm_authority_stage0.rs, runtime_admission.rs and examples/agent_os.rs
LimitThe shipped positive fixture has a wall-clock expiry. Every time-bounded artifact in valid-recursive-delegation closes at the same instant, 1800000061000, 2027-01-15T08:01:01Z: the epoch window, the task graph, all three continuation tokens, all three route plans, and the single hop in each of the three witness chains. The CLI and Proof Room paths supply SystemTime::now, so from that instant the fixture fails on swarm task graph is expired rather than on the epoch, because validate_task_graph runs first. The conformance suite avoids this by pinning now_unix_ms.swarm_authority_verification_time in chio-cli, proof_room_swarm_verification_time in chio-proof-room; the fixture timestamps; the call order in verify_swarm_authority_bundle
LimitWindow length is unbounded. validate_revocation_epoch refuses an empty or inverted window and an expired one, and imposes no maximum, so how fresh an epoch has to be is entirely the issuer’s choice. The shipped positive fixture carries a window of roughly 463 days.The three timestamp comparisons in validate_revocation_epoch; issuedAtUnixMs and validUntilUnixMs in valid-recursive-delegation/revocation-epoch.json
UnsupportedEpoch ordering. epochId is an opaque string, there is no sequence number, and nothing compares one epoch to another or to anything previously seen. An older epoch still inside its window verifies exactly as well as a newer one, so window length is the only bound on rollback.The full field set in types.rs; every comparison in validate_revocation_epoch is against the bundle or the clock
UnsupportedMembership proofs. The root is a flat digest over two sorted arrays, so answering whether a subject is revoked requires the entire lists. There is no inclusion or non-inclusion proof, and no way to check one entry against a published root.revocation_epoch_list_root_hash; contrast crates/trust/chio-revocation-oracle/src/sparse_merkle.rs, which is a separate crate this one does not depend on
UnsupportedIssuance and in-place rotation. The only callers of sign_swarm_revocation_epoch are tests and the crate’s examples/agent_os.rs, and no runtime store lets a stored bundle be replaced for the same task-graph id.Call sites of sign_swarm_revocation_epoch; insert_swarm_authority_bundle in the memory, JSON, and SQLite stores

Claim: revoking one task denies the whole graph

FieldValue
StatusShipped. Read this before designing around task-level revocation.
ClaimA revoked task id that names any node in the graph rejects the entire bundle, not the one hop. Because the verdict is whole-bundle and every hop is verified against the same bundle, revoking one task denies every task in that graph.
SubjectOne delegation bundle, on one call to verify_swarm_authority_bundle.
EvidenceThe loop tests task_by_id, the index over every graph node, and returns swarm task is revoked on the first hit. verify_swarm_authority_bundle returns either a report with verdict verified or an error, with no per-artifact result.
LimitThe precision the epoch buys is in the unit, not the blast radius. Revoking a task id leaves the signed plan intact and takes effect at the next presentation, without editing the graph, retiring a capability, or touching any other graph that shares the same subjects. What it does not do is let the surviving siblings keep running. If you need one branch stopped while the rest of a fan-out continues, that has to be modeled as separate graphs, and nothing in the verifier will do it for you.

Next Steps

  • Continuation Tokens · the artifact that carries the epoch root through every hop, and the single-use id burned before dispatch
  • Revocation Propagation · the same word under one authority: capability ids, a pull lane, and a propagation-lag histogram
  • Swarm Authority · the fixed pipeline this check runs inside, and why there is no partial verdict
  • Task Graphs · the signed plan whose revocationEpochRef names the epoch
  • Revocation Store · the node-local set, for contrast: no signature, no window, no root
Revocation Epochs · Chio Docs