Chio/Docs
LOGIN · JOIN

PlatformFederation

Federation & Compliance

Revocation Oracle

Revocation across authorities: signed epoch roots under a pinned key, a caller-set freshness window, and a read that fails closed on a stale epoch.

The counterpart, and the assumption it drops

Revocation Propagation documents the other revocation path in the tree, and it says outright that it excludes this one. There, a node believes a revocation because an authenticated peer inside the same administrative boundary handed it over: the record is a capability id and a whole-second timestamp, nothing is signed, and the two nodes already share one authority keypair. This page documents the path built for a reader who has none of that. It holds one thing about the producer, a pinned Ed25519 public key, and it will not treat anything that key signed as current for longer than a window it sets itself. The two mechanisms never meet inside one process’s data path.

A library, not a service

crates/trust/chio-revocation-oracle is a pure library. Its own architecture record states the exclusions: no async runtime, no network I/O, and deliberately no library dependency on chio-kernel-core (that crate and chio-federation appear only as dev-dependencies, backing the feature-gated acceptance tests). The crate root carries forbid(unsafe_code) and seven modules. What ships here is the accumulator, the signing and verifying split, the epoch broadcast contract, the freshness gate, a bridge from passport-lifecycle revocations onto oracle inserts, and a second, genuinely sparse authenticated map that serves a different key space entirely. Transport is somebody else’s: chio-federation owns the wire envelope and the catch-up protocol, chio-federation-transport-iroh owns the QUIC lane and the blob lane under it, and chio-custody-hw layers a transactional revocation cascade over the same oracle at its credential-mint gate.

crates/trust/chio-revocation-oracle/src/lib.rs5-11rust
pub mod api;
pub mod epoch;
pub mod finding_status_sparse;
pub mod freshness;
pub mod passport_bridge;
pub mod signer;
pub mod sparse_merkle;

One implementation of the RevocationOracle trait ships: InMemoryRevocationOracle. It is in-memory, it has no removal or expiry method, and the trait defines none, so a key inserted stays revoked for the life of the process that holds it. Persistence, if a deployment wants it, is the caller’s problem, and so is serving epoch history: the oracle does not implement RevocationCatchupHistory, and no in-tree type serves that trait off a live oracle.


The objects

TypeFieldsWhat it is
RevocationKeysubject_id: SubjectId, epoch_nonce: EpochNonceOne leaf. The unit of revocation. Equality covers both fields, so the same subject can be revoked again under a different nonce, and every lookup needs both: contains and inclusion_proof take a whole key, and there is no query by subject alone.
EpochRootepoch: u64, root_hash: [u8; 32], leaf_count: usize, issued_at_unix_ms: u64The whole commitment. Four scalars, and the only thing a signature ever covers.
RootSignaturesigner_id, algorithm, signature_bytesDetached. algorithm is the wire tag ed25519; anything else denies.
SignedEpochRootroot, signatureThe artifact that crosses the boundary. Everything downstream carries this verbatim.
InclusionProofkey, epoch_root, leaf_index, leaf_hash, proof_bytesA Merkle audit path, checked against the epoch_root the proof itself carries. That embedded root is unsigned, so a holder must pair the proof with a SignedEpochRoot for the same epoch or it proves only self-consistency.
NonInclusionProofkey, epoch_root, checked_at_unix_msNot a proof. It carries no cryptographic absence evidence, and checking it needs the live oracle back.
FreshnessConfigmax_staleness_ms, offline_grace_msThe reader’s policy, not the signer’s. Default is both zero.

A leaf is a domain-free SHA-256 over a length-prefixed subject and a big-endian nonce, and an insert is the only thing that moves the epoch:

crates/trust/chio-revocation-oracle/src/sparse_merkle.rsrust
fn leaf_hash(key: &RevocationKey) -> Result<[u8; 32]> {
    Self::validate_key(key)?;
    let subject = key.subject_id.as_str().as_bytes();
    let mut bytes = Vec::with_capacity(subject.len() + 16);
    bytes.extend_from_slice(&Self::subject_len_prefix(subject)?);
    bytes.extend_from_slice(subject);
    bytes.extend_from_slice(&key.epoch_nonce.get().to_be_bytes());
    Ok(Sha256::hash(&bytes))
}

validate_key refuses an empty or whitespace-padded subject_id with InvalidRevocationKey, so no leaf is ever minted for a noncanonical subject and " alice" can never be mistaken for "alice". A repeat of an existing key is AlreadyRevoked and the epoch does not move, which is what makes the passport bridge idempotent: apply_passport_revocation maps that error to AlreadyApplied, and with the epoch unmoved there is no new root for a push path to carry.

The bridge keys more narrowly than its event does. PassportRevocationEvent::to_revocation_key builds the key from the event’s subject and a nonce that is a fixed salt XORed with revoked_at_unix_ms. The passport_id is validated as non-empty and unpadded but never enters the key, so two distinct passports for the same subject stamped with the same millisecond collapse onto one leaf and the second reads as AlreadyApplied.

Read the word epoch narrowly. insert appends exactly one leaf and advances epoch by one, so for this implementation the epoch counter and leaf_count are the same number. An epoch is one revocation, not an interval of time. DEFAULT_EPOCH_TICK_MS is 250, and it is a broadcast cadence rather than a definition of the epoch; its own comment names the trade it makes, gossip-storm avoidance against the 500 ms median revoke-to-deny budget the acceptance test enforces. It is a documented default with no reader. The constant is re-exported, named in one chio-federation doc comment, and asserted equal to 250 by its own unit test; no tick driver in the tree consumes it.

The one field an insert does not take from the caller unchanged is time:

crates/trust/chio-revocation-oracle/src/sparse_merkle.rsrust
// Clamp issued_at to a monotone non-decreasing floor. Wall-clock
// skew (NTP correction, leap-second roll-back) MUST NOT make a
// newer epoch carry an earlier `issued_at_unix_ms` than a prior
// one: downstream freshness caches that track "latest seen
// issued_at" as a replay-protection floor would otherwise accept
// a root issued strictly before the previous epoch as still
// fresh. Fail-closed on the time field by taking the max.
self.issued_at_unix_ms = self.issued_at_unix_ms.max(now_unix_ms);

issued_at_is_monotone_under_clock_skew pins it by inserting at 1000 and then at 500 and asserting the second root’s stamp did not go backwards. That unit test is the only one that drives the skew: the property test epoch_monotone asserts the same pair of invariants over generated subjects but inserts with forward-moving stamps, so it covers the ordering rule and not the clamp.

The module name is legacy, this structure is not sparse

The module is sparse_merkle.rs and the crate manifest description says sparse-Merkle epoch roots. Both the README and the architecture record describe what the code actually is: an append-only Merkle accumulator over an ordered leaf vector. append_leaf pushes onto layer zero and rebuilds parents upward; proof_bytes_for_index collects siblings by position. That shape is why non-inclusion here is not a proof: an ordered vector has no addressable slot to show empty, so the only available absence check is a membership query against the live tree. Read the name as describing this module, not the crate. A full-depth sparse map does ship alongside it, over a different key space, and it does carry portable absence.

The second backend, and the absence proof that travels

finding_status_sparse.rs is the module that does it, and its own doc comment states why it is a separate thing rather than a fix to the accumulator: the oracle above is an append-only ordinary Merkle tree whose absence checks consult local state, finding status needs portable absence, so this module defines a full-depth sparse map with fixed hashing semantics. The two share the crate’s error type and nothing else. No key type, no root type, no code path between them: the accumulator keys on RevocationKey { subject_id, epoch_nonce } and this map keys on chio.finding.status.v1 finding ids, which are themselves required to be SHA-256 hex.

ConstantValueWhat it fixes
FINDING_STATUS_SPARSE_DEPTH256The map consumes every bit of the SHA-256 key digest, so every possible key has an addressable slot and an empty one is a fact the path can show.
FINDING_STATUS_KEY_DOMAIN_NONCE0x0b_c9f6_f005_59b6A wire constant, never derived at run time. It is mixed into every key hash, so a path in this map cannot be replayed as a path in a map built over a different key domain.
FINDING_STATUS_MAP_VERSIONsparse_map_v1The signed status-map version. A status epoch declaring any other value is rejected before a path is walked, so an epoch minted over the append-only accumulator cannot be read as this one.
FINDING_STATUS_PROOF_SEMANTICSsiblings_leaf_to_root_v1Sibling order is part of the contract, not a convention: the first sibling is adjacent to the leaf and the last is adjacent to the root.
Four domain labelschio.finding.status.v1:key, :empty-leaf, :occupied-leaf, :branchEvery hash the map takes is prefixed with a NUL-terminated label for its role, so a leaf hash can never be read as a branch hash.

A key is finding_status_key_hash: the key domain label, the fixed nonce big-endian, the length of the finding id big-endian, then the id itself, hashed once with SHA-256. The id is required to be 64 lowercase hex characters before any of that runs. The 256 bits of that digest are the path. An occupied leaf hashes the occupied-leaf label, the key hash, the literal retracted, and the retraction intent digest; an empty leaf is the hash of the empty-leaf label alone, with nothing keyed into it, which is what lets one precomputed value stand for every unoccupied slot at a given height.

rendering
How verify_finding_status_non_inclusion recomputes a root from an absence claim. The empty-leaf hash is the same value at every unoccupied slot, so the path carries no leaf and the key bits alone decide each pairing.
sourcecrates/trust/chio-revocation-oracle/src/finding_status_sparse.rs:235-277at fe56570
crates/trust/chio-revocation-oracle/src/finding_status_sparse.rs233-253rust
/// Verify a portable sparse non-inclusion proof without consulting local
/// state. The exact empty-leaf hash starts the path computation.
pub fn verify_finding_status_non_inclusion(
    root_hash: &Hash,
    finding_id: &str,
    proof: &FindingStatusSparseProof,
) -> Result<()> {
    let key_hash = finding_status_key_hash(finding_id)?;
    if proof.key_hash != key_hash
        || proof.leaf.is_some()
        || proof.siblings.len() != FINDING_STATUS_SPARSE_DEPTH
    {
        return Err(RevocationOracleError::InvalidProof);
    }
    verify_path(
        root_hash,
        &key_hash,
        finding_status_empty_leaf_hash(),
        &proof.siblings,
    )
}

Read that signature against the accumulator’s. verify_finding_status_non_inclusion is a free function whose only trust input is a root hash. It takes no &self, so it does not need the live map, and it returns a Result rather than a bare bool, so a refusal is typed. That is the whole of the difference from the accumulator’s verify_non_inclusion, and it is the difference between an absence claim that travels and one that does not. verify_finding_status_inclusion is the mirror: it demands the leaf, checks its finding id and retraction intent digest, and walks the same path from the occupied-leaf hash.

The module is not a spare part. chio-finding wraps both verifiers in verify_status_proof_input, which is what a chio.finding.status-proof-input.v1 document is checked by, and which additionally requires the signed chio.finding.status-epoch.v1 envelope to declare this map version, these proof semantics, this key domain nonce, this tree depth, and these four domain labels before a path is walked at all. The control plane’s status publisher rebuilds the map from its sticky leaves, signs one advancing epoch, and persists the epoch, the proof, and any new leaf together. The CLI reaches the same verifiers on its finding-status path, where it also pins the proof bytes by digest and refuses anything that is not strict canonical input.


Signing, and the split that makes forgery a type error

The core design idea is stated in the architecture record as a type-level split: EpochRootSigner implementations hold private key material, EpochRootVerifier implementations hold only a pinned public key and an expected signer_id. A verify-only deployment never links a type that can sign. The message is fixed and domain-separated:

crates/trust/chio-revocation-oracle/src/signer.rsrust
pub const DOMAIN_SEPARATION_CONTEXT: &[u8] = b"chio-revocation-oracle:v1:epoch-root";

fn signing_message(root: &EpochRoot) -> Result<Vec<u8>> {
    let canonical = canonical_json_bytes(root)
        .map_err(|err| RevocationOracleError::Serialization(err.to_string()))?;
    let mut message = Vec::with_capacity(DOMAIN_SEPARATION_CONTEXT.len() + canonical.len());
    message.extend_from_slice(DOMAIN_SEPARATION_CONTEXT);
    message.extend_from_slice(&canonical);
    Ok(message)
}

canonical_json_bytes is the RFC 8785 canonicalization from chio-core-types, so a verifier that re-canonicalizes the same EpochRoot reconstructs identical bytes and a fixed seed produces a byte-identical signature twice (signatures_are_deterministic_for_a_fixed_seed). verify_epoch_root has no partial-credit path: a mismatched signer_id, an algorithm tag other than ed25519, a signature_bytes length other than exactly 64, or a failed Ed25519 check all return the same SignatureVerificationFailed. Separate unit tests drive each of those cases, including the one that matters most for a pinned deployment: a different key claiming the same signer_id is rejected. Construction on both halves is equally blunt. from_signing_key takes either the literal generate, which mints a fresh keypair for development, or a 32-byte hex seed with an optional 0x prefix, and rejects anything else as SignerRejected; from_public_key_hex refuses an undecodable key with SignatureVerificationFailed, so no verifier is ever built around trust material that will not parse.


The freshness window belongs to the reader

crates/trust/chio-revocation-oracle/src/freshness.rsrust
pub fn verify_fresh_epoch_root(
    root: &EpochRoot,
    now_unix_ms: u64,
    config: FreshnessConfig,
) -> Result<()> {
    if now_unix_ms < root.issued_at_unix_ms {
        return Err(RevocationOracleError::InvalidEpochTransition);
    }

    let age = now_unix_ms.saturating_sub(root.issued_at_unix_ms);
    if age <= config.allowed_age_ms() {
        Ok(())
    } else {
        Err(RevocationOracleError::StaleRoot)
    }
}

Two refusals, and they are different errors on purpose. A root stamped after the reader’s clock is an InvalidEpochTransition, not a stale root, because a future stamp is a producer or clock fault rather than a delivery delay. Everything else is age against a single budget, max_staleness_ms plus offline_grace_ms, added with saturation. The two knobs are additive and nothing distinguishes them inside the check; the split is documentary, separating a steady-state window from a deliberate allowance for a reader that has been offline.

ConfigRoot issued atNowOutcome
default(), which is fail_closed(): 0 and 010001000Ok. Only an age of exactly zero passes.
default()10001001StaleRoot after one millisecond
with_offline_grace(100, 900)10002000Ok at exactly the 1000 ms budget, which is inclusive
with_offline_grace(100, 900)10002001StaleRoot
any1000999InvalidEpochTransition, read off the code rather than a test

The first four rows are tests/freshness.rs verbatim. A default-constructed config is unusable in production and is meant to be: Default forwards to fail_closed, so a caller who forgets to configure a window denies every root that took any time at all to arrive, rather than accepting one silently.

Note who calls it, which is nobody yet. The architecture record puts verify_fresh_epoch_root at step 5 of the epoch lifecycle, gating any received root, but the only in-tree callers are this crate’s own freshness tests and a federation refinement test. Neither the gossip layer nor the QUIC lane invokes it. The freshness check that actually runs on a shipped dispatch is a second, independent implementation of the same shape in the kernel, covered below.


One revoke, end to end

rendering
An insert at the authority reaching a peer's dispatch path. Every gate between them is fail-closed, and the merge into the peer's cache is all-or-nothing at the batch.

The wire envelope is RevocationRootGossip in chio-federation: a schema tag pinned to chio.federation-revocation-root-gossip.v1, the signed root, and two values duplicated outside the signature for cheap routing, the epoch and the signer_id. Serde is deny_unknown_fields, so a protocol upgrade has to bump the schema rather than sneak a key past older receivers. validate_envelope checks the schema and that both duplicated values agree with the signed body, and its own doc comment is explicit that this is structural only and callers must still verify the signature. A fifth field, ts_unix_ms, carries the sender’s emission time and is documented as what a receiver uses for freshness gating. Nothing enforces it: validate_envelope never reads it, it sits outside the signature so a sender can set it to anything, and no receiver in the tree gates on it. The stamp a receiver can actually trust is issued_at_unix_ms, inside the signed EpochRoot.

RevocationGossipPushQueue holds a bounded FIFO per subscribed peer and coalesces inside a tick: a strictly higher epoch evicts every queued lower one, an equal epoch replaces, and a lower epoch is dropped rather than enqueued. A full queue evicts the oldest entry. Both that eviction and the same rule in the oracle’s own InMemoryEpochBroadcaster are deliberate, and both are justified in comments by the same fallback: the catch-up path covers a peer that fell behind. A zero capacity is refused at construction in both places, so nobody accidentally configures a sink that drops everything. The broadcast helper above them, tick_and_broadcast, publishes to its broadcasters in slice order and returns the first error, so a mid-list rejection still leaves every earlier broadcaster holding the root. It is fail-closed on reporting, not atomic.

What the transport adds, and what it refuses to

The QUIC lane in chio-federation-transport-iroh carries pushes and catch-up on one ALPN, chio/federation/revocation-root/1, capped at 16 MiB per frame. Its module doc names the thing that makes revocation different from the other lanes: there is no authenticated sender field to lean on, because revocation wire types carry an opaque signer_id and neither an endpoint nor a key. So the lane requires a signer_id to (EndpointId, verifying key) directory, derived as a projection of the one issuer-signed transport directory rather than assembled by hand. That makes the origin pin structural: the key that authenticates a root and the endpoint allowed to originate it come from the same signed entry. Every peer-dependent accept step is bounded per phase and shed above an in-flight cap, so a slowloris peer resets the connection rather than pinning an accept slot. This whole transport is feature-gated and off by default in the shipped binary; see Iroh Transport.

verify_batch runs the gates below, in order, before a single root is merged, and returns the verified roots without merging them:

  • The authenticated endpoint must resolve to an admitted kernel. The admission gate already ran; this is a re-resolve the comment labels defense in depth.
  • The batch schema and every frame envelope must pass the structural check from chio-federation.
  • The batch must name this responder in recipient_kernel_id, so a misrouted or cross-recipient-replayed batch cannot mutate a cache it was not addressed to.
  • Each frame’s signer_id must resolve to a pinned binding, and that binding’s endpoint must be the endpoint that presented the frame.
  • The envelope consistency check runs again per frame, before any cryptography.
  • Each SignedEpochRoot must verify against the bound verify-only key.

Only then does the lane call merge_batch on the caller-supplied sink. That trait method has the sharpest fail-closed default in this whole path: it does not loop the single-root merge, because a loop would leave earlier roots applied when a later one failed. A sink that forgets to implement an atomic stage-then-commit batch merge gets a total SinkRejected rejection rather than a partial cache advance.

Catch-up, and the blob lane under it

A peer that fell behind asks for a range. RevocationCatchupRequest rejects an inverted range and a span above REVOCATION_CATCHUP_MAX_EPOCHS (4096) at construction, so one stalled peer cannot make a responder materialize unbounded history. The request’s requester_kernel_id is self-asserted, and the contract’s own doc says so: it is informational, not a substitute for a transport identity pin. The lane supplies the pin. Before serving either catch-up shape it rejects with RequesterMismatch unless the claimed requester is the kernel admitted at the authenticated endpoint, and nothing is served or published on that path. respond_to_catchup walks the range against whatever RevocationCatchupHistory the responder holds, and its two behaviors on a missing epoch differ by position: a miss before the first hit is a pre-history skip and the walk continues, while a miss after the first hit ends the run. A responder never fabricates a root, and validate_response re-checks on the receiving side that every frame is exactly one epoch past its predecessor. That is why an honest partial answer is a suffix rather than a set with holes in it.

Inline frames do not scale to a long history, so the same lane can answer with a manifest instead: a list of (signer_id, epoch, blob_hash) entries the follower feeds to the blob client, with the bulk root bytes riding iroh-blobs. The address is one derivation shared by three call sites, the BLAKE3 hash of the RFC 8785 canonical JSON of the SignedEpochRoot, which is what makes the advertised hash and the downloaded hash the same number by construction. The follower’s walk is all-or-nothing and re-checks everything: manifest schema, entry cap, single-signer binding, strict contiguity, the pinned signer and its authority endpoint, a per-blob size cap of 1 MiB read from the stored blob status before the bytes are materialized, a BLAKE3 re-check, the pinned-signer signature, and a per-entry epoch pin.

Integrity is not authenticity

The catch-up module states the distinction and then acts on it. iroh-blobs gives BLAKE3 verified streaming, which proves the bytes match the requested hash and nothing about who signed the root inside them. Every fetched blob is therefore deserialized and signature-checked against the pinned verifier before it counts. The authority side is symmetric: with a publisher wired it writes each root into the store it serves blobs from before advertising that root’s hash, and with no publisher it refuses to advertise a manifest at all and falls back to inline catch-up rather than point a follower at a hash it may not hold.

What the kernel core is handed

The kernel core does not link this crate. It defines its own RevocationSnapshot and RevocationViewSubject instead, which the architecture record calls out as deliberate: the read path stays decoupled from the federation layer above it and does not re-verify a snapshot’s signature itself. The module holding both is gated behind chio-kernel-core’s revocation-view feature, which implies std because arc-swap needs it. That feature is on by default for hosted builds and off in the --no-default-features build the portable no-std proof compiles for wasm, which falls back to whatever explicit denylist the embedder passes through the guard context. A snapshot carries the epoch, the root hash, the issuance stamp, and a sorted BTreeSet of revoked subjects. Writers install through install_if_newer, which refuses any candidate whose epoch does not strictly advance the installed one and leaves the active snapshot untouched. That mechanism, the arc-swap read, and the 500 ms staleness gate applied at dispatch are documented at the rung that owns them: Revocation Store.

What matters here is where the trust stops. A stale epoch fails closed at dispatch, and the gate that does it is the kernel’s own rather than the crate’s. consult_revocation_view_at loads the snapshot, and a snapshot older than DEFAULT_REVOCATION_VIEW_MAX_STALENESS_MS or issued in the future denies with KernelError::DelegationInvalid before any membership test runs. A view that is installed but never updated holds the empty sentinel, epoch 0 issued at 0, which is arbitrarily stale and therefore denies every delegated dispatch (empty_view_denies_as_stale). Silence from an installed view is a denial, not an allow.

Read that precondition strictly, because the no-view case goes the other way. consult_revocation_view takes an Option and returns Ok(()) immediately when it is None; the module doc calls that the no-view-installed path and says it falls back to the per-row RevocationStore lookup that already runs on every dispatch. Since set_revocation_view has exactly one in-tree caller and it is a test, a default build reaches the early return, not the freshness gate. An absent view is not a stale view.

The window is also coarser than the constant reads. consult_revocation_view computes its clock as current_unix_timestamp() multiplied by 1000, and that helper returns whole seconds. The snapshot’s issued_at_unix_ms is real milliseconds, so a snapshot stamped part-way into the current second compares as issued in the future and denies until the second rolls over. A writer targeting this gate has to satisfy a second-truncated comparison, not a smooth 500 ms window.

The signature does not cover the revoked set

Read the snapshot type against the signed type. A signature covers an EpochRoot: epoch, root hash, leaf count, issuance stamp. The revoked set the view actually consults is not in that structure. The snapshot doc says the set is the one the embedding kernel has materialised locally, and the root_hash field is documented as diagnostic, for correlating a deny with the snapshot that produced it. Nothing in the tree checks a snapshot’s subject set against the root hash it carries, and no code path derives that set from an inclusion proof. The two sides do not share a namespace either: the oracle’s leaves are keyed on (subject_id, epoch_nonce), while the view is consulted with capability ids lifted off the delegation chain and the leaf token. The signed root authenticates that an epoch existed and when. Whoever builds the snapshot is trusted for what is in it.

One more piece of drift to settle before you write a writer. The module doc on revocation_view.rs states a third writer obligation, that the candidate’s epoch must agree with an embedded signed_root_epoch field. No such field exists on RevocationSnapshot, whose serde is deny_unknown_fields, so that check has no place to sit. Treat the monotone epoch rule as the whole of what install_if_newer enforces.


Guarantees and limits

StatusClaimEvidence
ShippedAn inclusion proof is checkable offline: verify_inclusion is a static function over the audit path, the leaf hash, the leaf index, and the leaf count. It binds a leaf to the EpochRoot inside the proof and no further; that root is unsigned, so a holder who wants authenticity pairs it with a SignedEpochRoot.sparse_merkle.rs; property test inclusion_proof_soundness over generated multi-leaf key sets, plus inclusion_proof_rejects_tampered_key
ShippedAbsence is portable in the crate’s other backend. verify_finding_status_non_inclusion is a free function taking a root hash, a finding id, and a 256-sibling path; it requires the proof to carry no leaf, starts from the fixed empty-leaf hash, and recomputes the root. No live map, no local state, and a typed refusal.finding_status_sparse.rs; consumed through chio_finding::verify_status_proof_input by the control plane’s status publisher and by the CLI’s finding-status verification
ShippedA verify-only deployment cannot forge a root. Signing and verifying are separate traits, and the Ed25519 verifier holds only a public key and an expected signer id.signer.rs; wrong_key_is_rejected, wrong_signer_id_is_rejected, wrong_algorithm_tag_is_rejected, short_signature_bytes_fail_closed
ShippedA batch that fails any gate merges nothing. Verification collects roots without applying them, and the sink’s batch-merge default refuses rather than partially applying.RevocationHandler::verify_batch and RevocationRootSink::merge_batch, lanes/revocation.rs
ShippedOnce a view is installed, a stale or future-stamped snapshot denies a delegated dispatch before any membership test, and an installed-but-never-updated view denies everything. With no view installed the consultation returns Ok and defers to the per-row revocation-store lookup, so this is a gate on an installed view rather than an unconditional one.consult_revocation_view_at, no_view_installed_returns_ok, and empty_view_denies_as_stale, chio-kernel/src/kernel/delegation.rs. The consultation itself sits behind the delegation cargo feature, which is in chio-kernel’s default set.
Proved by testBackward wall-clock skew across inserts cannot make a newer epoch carry an earlier issuance stamp.issued_at_is_monotone_under_clock_skew, the only test that inserts with a backward stamp; epoch_monotone covers the ordering rule under forward stamps
Proved by testA replayed root does not rewind a verifier. Three peers advance monotonically under a burst and reject a replay of an already-installed epoch.verifier_view_is_monotonically_advancing_after_burst and verifier_rejects_replayed_root_after_advance, chio-federation/tests/oracle_gossip_e2e.rs
Measured, in processMedian revoke-to-deny under 500 ms across 100 trials, with a 1500 ms hard per-trial timeout so a hang fails rather than scoring zero. The three "kernels" are oracle plus view plus receipt-log triples in one process. The companion proof reader re-runs the fixture for 8 trials and asserts every allow receipt has seen_epoch < revoke_epoch.three_tier_swarm_revoke_to_deny_under_500ms_median and receipt_chain_proof, both behind the dev-only delegation feature. Read the scope narrowly: transport is an in-process queue, each child’s consult is the harness’s own snapshot.is_revoked mirror of the kernel chain walk rather than consult_revocation_view_at (so no freshness gate runs), and install_frame lifts the frame’s epoch and root hash into a locally built snapshot without verifying the signature. It bounds the oracle, queue, and view-install path, not a link and not the kernel’s dispatch gate.
Measured, benchmarkInsert plus inclusion proof at a p99 of 200 microseconds over 10,000 seeded subjects, asserted before the Criterion run rather than reported after it.benches/oracle_throughput.rs. A 64-sample p99 on one machine; treat it as a regression tripwire, not a published figure.
ModeledUnbounded propagation delay, out-of-order delivery, and stale or duplicate messages are modeled in TLA. NoAllowAfterRevoke holds at every reachable state: no receipt carries allow once its issuer has observed any revocation epoch for that capability.formal/tla/RevocationPropagation.tla, checked as part of SafetyInv at PROCS=4, CAPS=8, DEPTH_MAX=4 by formal/tla/MCRevocationPropagation.cfg. The model has one shared clock rather than a per-peer one, and no action drops a message, so peer skew, loss, and partition are outside it.
Not claimedRefinement between those models and this code. The manifest states its own boundary: the distributed production gate checks deterministic scalar trace projections for one pinned origin and one view, it is not full-state refinement, it does not inject faults into the shipped transport, and it does not establish multi-origin isolation in Rust. Matching mirror hashes detect drift; they do not prove semantic equivalence.formal/proof-manifest.toml, audited assumptions
LimitNon-inclusion against the revocation accumulator is not portable. NonInclusionProof carries a key, a root, and a timestamp, and verify_non_inclusion is a method on the live oracle that re-queries membership and compares the current root. It returns a bare bool rather than a Result, so a refusal carries no reason. A remote holder cannot check it at all, and it goes false the moment the oracle moves. The sparse status map is the other backend and does not share this limit.verify_non_inclusion; non_inclusion_proof_fails_closed_after_insert; the architecture record says so in its own lifecycle step 4
LimitThe signature authenticates the epoch commitment, not the subject list a verifier consults. Nothing checks a snapshot’s revoked set against the root hash it carries.EpochRoot fields; RevocationSnapshot doc comment on root_hash
LimitThe dispatch-side freshness comparison is coarser than its constant. The kernel derives now_unix_ms from a whole-second clock, so a snapshot stamped part-way into the current second reads as future-dated and denies until the second rolls over.consult_revocation_view calling current_unix_timestamp().saturating_mul(1000), chio-kernel/src/kernel/delegation.rs
LimitNothing here is durable. InMemoryRevocationOracle holds its layers and leaf records in process memory, and the crate ships no other implementation, so a restart of the authority loses the accumulator unless the caller rebuilt it.InMemoryRevocationOracle fields; RevocationOracle as an extension point in the architecture record
Extension pointThe lane is driven by the process that hosts the revocation view, not by a Chio binary. set_revocation_view, install_if_newer, tick_and_broadcast, enqueue_signed_root, and flush_batches_at are the embedder’s to call, and RevocationRootSink is the embedder’s to implement. chio pheromone relay serve refuses the lane rather than faking it: it errors at load time when --iroh-lanes names revocation, because the handler needs a sink backed by a live revocation-view cache and a catch-up history the relay process does not host, and a no-op sink would be fail-open.chio-kernel/src/kernel/construction.rs; parse_iroh_lanes in chio-cli/src/cli/chio/dispatch/pheromone/iroh_mount.rs
UnsupportedUn-revocation, expiry, and pruning at this layer. The trait has no removal method, so the accumulator only grows and an operator error is permanent for the life of the oracle. Catch-up assumes history can be pruned somewhere, but nothing in the crate prunes it.RevocationOracle; the invariant list in the architecture record

Next Steps

  • Revocation Propagation · the same word under one authority keypair, with no signature, no epoch, and a cursor instead of a root
  • Revocation Store · the read-only view in full, the 500 ms staleness gate, and the durability gate an installed view satisfies
  • Iroh Transport · the feature-gated QUIC layer the lane and the blob catch-up ride on, and what a handshake actually proves
  • Revocation Epochs · a third object that also says epoch, carried inside a delegation bundle and depending on none of this
  • Fail-Closed Semantics · the general rule the two freshness gates on this page are instances of
Revocation Oracle · Chio Docs