Chio/Docs
LOGIN · JOIN

Quorum & Anti-Eclipse

Check that enough distinct publishers saw a federated listing recently, and agreed on it, before your operator acts on it.

Concept chio@5f63e6e

On this page

Before acting on a remote listing, the receiving operator checks who else has seen it. A quorum report records which publishers observed the listing, how old each observation was, and the policy that says whether that coverage is enough, so one publisher alone cannot decide what the receiver sees.

The running example is the reference report fqr-1: an origin, a mirror, and an indexer observing listing-liability-provider-1 30, 40, and 45 seconds before the report, with threshold 2, a 300-second age ceiling, and the declared state converged.

The validator checks the report, and the receiver checks what the report claims

Three checks run inside validate_federation_quorum_report. Two more, the policy floor and listing agreement, belong to the operator that receives the report.

Quorum at the receiverChoose a report.
300 soorigin · hop 01111…mmirror · hop 11111…iindexer · hop 11111…validatorreceivernrolestfreshsstateπfloorhdigestsvalidateρreport fqr-1threshold 2vreceiveraccepted under receiver policy300 soorigin · hop 01111…mmirror · hop 11111…iindexer · hop 11111…nrolestfreshsstateπfloorhdigestsvalidateρreport fqr-1threshold 2vreceiveraccepted under receiver policy

Three fresh publishers agree, and the receiver accepts

The origin, a mirror, and an indexer observed the listing 30, 40, and 45 seconds before the report, all under the 300-second ceiling. Three fresh observations meet threshold 2, the declared state is converged, the policy meets the receiver's floor, and every digest matches. The worked run.

The reference quorum report fqr-1 as a receiving operator checks it: three publishers, their observation ages against the 300-second ceiling, the validator's three checks, and the receiver's two. The eclipsed copy is illustrative; the other altered copies are the worked run's. Signatures over the report and the publishers' registry URLs are left out.

The validator establishes that a report is consistent under the policy it declares. The receiver establishes that the policy is strict enough and that the publishers saw the same listing. A report that passes both establishes who has seen the listing.

The anti-eclipse policy requires an origin, an indexer, and two distinct operators

The default FederationAntiEclipsePolicy needs at least two distinct operator ids, an observation from the named origin, an indexer observation, and no observation more than one upstream hop away. A hop-count violation rejects the whole report.

The origin test has two halves: the observation's role is origin, and its operator id equals the report's originOperatorId. The indexer test checks the role alone. Distinct ids are distinct strings, so independent ownership is a judgment the receiver makes about the operators it sees.

The policy type and its defaults in Rust
crates/trust/chio-federation/src/quorum.rsrust
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct FederationAntiEclipsePolicy {
    pub minimum_distinct_operators: u32,
    pub require_origin_publisher: bool,
    pub require_indexer_observation: bool,
    pub max_upstream_hops: u32,
}

impl Default for FederationAntiEclipsePolicy {
    fn default() -> Self {
        Self {
            minimum_distinct_operators: 2,
            require_origin_publisher: true,
            require_indexer_observation: true,
            max_upstream_hops: 1,
        }
    }
}

Read more in the policy fields.

Only fresh observations count, and the declared state must agree with the count

An observation is stale when its ageSecs exceeds the report's maxReplicaAgeSecs, or when its state is stale. Every other observation adds one to the fresh count.

crates/trust/chio-federation/src/quorum.rs153-159rust
if publisher.freshness.age_secs > report.max_replica_age_secs
    || publisher.freshness.state == GenericListingFreshnessState::Stale
{
    stale_count += 1;
} else {
    fresh_count += 1;
}

The producer declares finalState, and after the coverage checks pass the validator tests that label against the counts and the supplied conflicts:

  1. converged. No conflicts, and the fresh count meets quorumThreshold.
  2. conflicting. At least one conflict entry.
  3. insufficient_quorum. The fresh count is below the threshold once every preceding gate has passed.
  4. stale. Every observation is stale.

With a 300-second ceiling and threshold 2, three observations at age 400 leave a fresh count of 0, so a converged declaration is refused. A divergent observation inside the ceiling counts as fresh here; the trust-activation evaluator refuses divergent listings separately.

Read more in the final-state rules.

The receiver checks what the validator reads as supplied

The receiving operator runs four checks of its own around the validator:

  1. Signature. SignedFederationQuorumReport wraps the body in a SignedExportEnvelope. The receiver verifies the envelope and its trusted signer, then validates the body.
  2. Age. The receiver derives each observation's age from its own trusted clock before building the input.
  3. Agreement. The receiver compares observedListingSha256 across publishers. See listing agreement.
  4. Floor. The policy travels inside the report, so the receiver compares antiEclipsePolicy, the threshold, and the ceiling with its own requirements.

The quorum program applies the floor and agreement checks to the reference report and three altered copies.

Validate a report under the receiver’s policy

Load the complete report, validate its declared state, then apply a local policy floor and listing-agreement check.

Complete the project setup, then run the program from federation-guide/. The download includes every constructor and input.

Terminalbash
cargo run --locked --bin quorum
Output on darwin/arm64 · exit 0
reference report: accepted under receiver policy
weaker declared policy: refused
different listing digest: refused
stale observations with converged declaration: refused

The supplied report is a reference artifact with declared ages. Authenticate observations and derive their age from a trusted receiver clock before constructing this input. Different operator IDs do not prove independent ownership.

Read the complete program 63 lines · complete file
src/bin/quorum.rsrust
use chio_federation::quorum::{validate_federation_quorum_report, FederationQuorumReport};
use serde_json::Value;

// These checks belong to the receiving application. They supplement the report
// validator; they cannot authenticate publishers or prove independent ownership.
fn receive(value: Value) -> Result<(), String> {
    let report: FederationQuorumReport =
        serde_json::from_value(value).map_err(|e| e.to_string())?;
    validate_federation_quorum_report(&report).map_err(|e| e.to_string())?;
    let policy = &report.anti_eclipse_policy;
    if policy.minimum_distinct_operators < 2
        || !policy.require_origin_publisher
        || !policy.require_indexer_observation
        || policy.max_upstream_hops > 1
    {
        return Err("report weakens receiver policy".into());
    }
    if report.quorum_threshold < 2 || report.max_replica_age_secs > 300 {
        return Err("report weakens receiver freshness or threshold".into());
    }
    if report
        .publishers
        .iter()
        .any(|p| format!("{:?}", p.freshness.state) != "Fresh")
    {
        return Err("receiver requires fresh agreeing observations".into());
    }
    let first = &report.publishers[0].observed_listing_sha256;
    if report
        .publishers
        .iter()
        .any(|p| &p.observed_listing_sha256 != first)
    {
        return Err("publisher listing digests disagree".into());
    }
    if report.final_state != chio_federation::quorum::FederationQuorumState::Converged {
        return Err("receiver requires a converged report".into());
    }
    Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let path = std::env::args()
        .nth(1)
        .unwrap_or_else(|| "quorum-report.json".into());
    let report: Value = serde_json::from_slice(&std::fs::read(path)?)?;
    receive(report.clone())?;
    println!("reference report: accepted under receiver policy");
    let mut weak = report.clone();
    weak["antiEclipsePolicy"]["minimumDistinctOperators"] = 1.into();
    assert!(receive(weak).unwrap_err().contains("weakens"));
    println!("weaker declared policy: refused");
    let mut divergent = report.clone();
    divergent["publishers"][1]["observedListingSha256"] = "f".repeat(64).into();
    assert!(receive(divergent).unwrap_err().contains("disagree"));
    println!("different listing digest: refused");
    let mut stale = report;
    for p in stale["publishers"].as_array_mut().unwrap() {
        p["freshness"]["ageSecs"] = 400.into();
    }
    assert!(receive(stale).is_err());
    println!("stale observations with converged declaration: refused");
    Ok(())
}

Open the source file. Each program asserts its expected refusals; keys, timestamps, and digests change between runs.

You should see the reference report accepted, then a copy declaring minimumDistinctOperators 1, a copy whose mirror reports a different listing digest, and a copy with every observation at age 400, each refused. The complete reference report is the input. To write your own, collect the observations first and record conflicts from a comparison of their digests.

chio federation quorum verify runs the signature check, the validator, and the floor and agreement checks on a received report, against a floor file you keep. The report that declares one distinct operator is refused at your floor:

Terminalbash
chio federation quorum verify fqr-1.json fqr-1-one-operator.json \
  --trusted-signer ./quorum-signer.pub --floor ./quorum-floor.json
Output
ACCEPTED  fqr-1  converged, 3 of 3 fresh, 3 distinct operators, listing digests agree
REFUSED   fqr-1-one-operator  minimumDistinctOperators 1 is below your floor of 2

A valid report organizes evidence and authorizes nothing

Quorum reports answer who has seen a listing. Bilateral Co-Sign and FROST Quorum authorize actions, and accepting a report invokes neither. The federation import contract requires explicit local activation, and a qualification matrix that names a quorum report holds the reference as a string.

Read more in the federation admission path.

Quorum report fields, states, and refusals

The contract and its checks are in crates/trust/chio-federation/src/quorum.rs, whose tests cover missing roles, malformed observations, policy failures, conflicting state declarations, and the reference report. The wire format is camelCase and rejects unknown fields on the report, observation, policy, conflict, and artifact-reference types.

Anti-eclipse policy fields

FieldDefaultRefuses a report with
minimumDistinctOperators
2
fewer unique operatorId values
requireOriginPublisher
true
no origin observation from originOperatorId
requireIndexerObservation
true
no observation with role indexer
maxUpstreamHops
1
any observation over the hop ceiling

Defaults are constructor values. The validator requires only that minimumDistinctOperators is non-zero, so a receiver compares the declared values with its own. Duplicate operator ids are a separate rejection, the origin, indexer, and distinct-id tests can count stale observations, and hop counts are supplied values.

Final states

StateAccepted whenRefusal message
converged
no conflicts; fresh_count >= quorumThreshold
converged quorum reports cannot include conflicts; converged quorum reports require fresh observations meeting the quorum threshold
conflicting
conflicts non-empty
conflicting quorum reports require conflict evidence
insufficient_quorum
fresh count below threshold, coverage met
insufficient_quorum requires a real quorum shortfall
stale
stale_count equals the publisher count
stale quorum reports require all observations to be stale

The validator tests the declared label and leaves the choice of state to the producer. All-stale observations satisfy both stale and insufficient_quorum, and supplied conflicts below threshold satisfy both conflicting and insufficient_quorum, so compare the underlying observations when comparing reports. Missing required publishers invalidate the report before the state is read.

Shortfall check

The insufficient_quorum arm refuses a report that already meets every coverage and count requirement.

crates/trust/chio-federation/src/quorum.rs235-246rust
FederationQuorumState::InsufficientQuorum => {
    if fresh_count >= report.quorum_threshold
        && publisher_ids.len()
            >= report.anti_eclipse_policy.minimum_distinct_operators as usize
        && (!report.anti_eclipse_policy.require_origin_publisher || has_origin)
        && (!report.anti_eclipse_policy.require_indexer_observation || has_indexer)
    {
        return Err(FederationContractError::InvalidQuorum(
            "insufficient_quorum requires a real quorum shortfall".to_string(),
        ));
    }
}

Timestamps and nested freshness

The quorum count uses the report-level ceiling and the supplied ageSecs. Observation observedAt and report generatedAt are carried for the receiver, which assesses age against its own clock. The nested freshness validator requires a positive maxAgeSecs and validUntil > generatedAt.

Establish listing agreement separately

observedListingSha256 must be a well-formed 64-character hex digest. The quorum validator counts observations and leaves digest comparison to the receiver, so different digests with enough fresh observations and an empty conflict list validate under converged.

The listing aggregator, aggregate_generic_listing_reports, detects divergence over source digest, lifecycle status, ownership, and registry URL. It removes conflicting groups from its results and emits divergence evidence, which the caller records as conflicts in the quorum report. See Capability Discovery.

Report fields

conflicts may be omitted when empty and note when absent; every other field is required.

FieldTypeValidator rule
schema
String
equals chio.federation-quorum-report.v1, checked first
reportId
String
non-empty after trimming; never compared
generatedAt
u64
carried, compared with nothing
namespace
String
non-empty, not normalized
listingId
String
non-empty
originOperatorId
String
non-empty; half of the origin test
quorumThreshold
u32
non-zero; read by two state arms
maxReplicaAgeSecs
u64
non-zero; the ceiling every ageSecs meets
publishers
Vec<FederationPublisherObservation>
non-empty; unique operator ids
conflicts
Vec<FederationConflictEvidence>
unique divergence keys; non-empty key, reason, and id list
antiEclipsePolicy
FederationAntiEclipsePolicy
four required fields, declared by the report
finalState
FederationQuorumState
one of four states, each with its rule
note
Option<String>
omitted when absent; never inspected

listingId is not tied to any observation's reportRef, and namespace is not matched against a publisher. validate_federation_quorum_report accepts the bare body; the signed form is SignedFederationQuorumReport.

Publisher observations

Each publisher contributes its identity and role, a listing-report reference, an observed listing digest, freshness values, an observation time, and an upstream hop count. Each operator id appears once.

crates/trust/chio-federation/src/quorum.rsrust
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct FederationPublisherObservation {
    pub publisher: GenericRegistryPublisher,
    pub report_ref: FederationArtifactReference,
    pub observed_listing_sha256: String,
    pub freshness: GenericListingReplicaFreshness,
    pub observed_at: u64,
    pub upstream_hop_count: u32,
}

reportRef.kind is listing_report and reportRef.operatorId equals the publisher's id. Both digest fields take exactly 64 ASCII hex characters with no surrounding whitespace, uppercase accepted; an empty digest returns MissingField and a malformed one InvalidReference. Reference schema, artifact id, and operator id are non-empty, and the reference URI is carried unresolved.

A publisher needs a non-empty operator id and http:// or https:// prefixes on its registry and upstream URLs, which the validator checks by prefix without contacting them. The nested publisher and freshness types accept unknown fields.

Validation order

Validation returns on the first failure: schema and report fields, each publisher and its references (including the hop ceiling), freshness and role counts, required roles and the distinct-id minimum, conflict entries, then the declared final state. A missing origin, a missing indexer, too few distinct ids, or a hop violation produces InvalidQuorum before the state is read.

crates/trust/chio-federation/src/quorum.rsrust
if report.anti_eclipse_policy.require_origin_publisher && !has_origin {
    return Err(FederationContractError::InvalidQuorum(
        "anti-eclipse policy requires an origin publisher observation".to_string(),
    ));
}
if report.anti_eclipse_policy.require_indexer_observation && !has_indexer {
    return Err(FederationContractError::InvalidQuorum(
        "anti-eclipse policy requires an indexer observation".to_string(),
    ));
}
if publisher_ids.len() < report.anti_eclipse_policy.minimum_distinct_operators as usize {
    return Err(FederationContractError::InvalidQuorum(
        "insufficient distinct operators for anti-eclipse policy".to_string(),
    ));
}

Refusal conditions and payloads

The path returns UnsupportedSchema, MissingField, InvalidReference, DuplicateValue, or InvalidQuorum. Match the variant and its payload; a handler for only the last variant misses the others.

ConditionVariantPayload
Schema is not the v1 constant
UnsupportedSchema
the offending schema string
Blank reportId, namespace, listingId, or originOperatorId
MissingField
federation_quorum.report_id and the three siblings
Zero threshold or zero max replica age
InvalidQuorum
quorum_threshold must be non-zero; max_replica_age_secs must be non-zero
Empty publisher vector
MissingField
federation_quorum.publishers
Zero minimumDistinctOperators
InvalidQuorum
minimum_distinct_operators must be non-zero
Publisher fails its own validation
InvalidQuorum
publisher.operator_id must not be empty or publisher.registry_url must start with http:// or https://
Blank schema, artifactId, operatorId, or sha256 in reportRef
MissingField
federation_quorum.publishers.report_ref
Malformed reportRef.sha256
InvalidReference
federation_quorum.publishers.report_ref must be a 64-character lowercase-compatible hex digest
reportRef.kind is not listing_report
InvalidQuorum
publisher report_ref must reference a listing report
reportRef.operatorId disagrees with the publisher
InvalidQuorum
publisher report_ref operator_id must match publisher.operator_id
Blank observedListingSha256
MissingField
federation_quorum.publishers.observed_listing_sha256
Malformed observedListingSha256
InvalidReference
the field name plus must be a 64-character lowercase-compatible hex digest
Freshness block fails its own validation
InvalidQuorum
freshness.max_age_secs must be greater than zero; freshness.valid_until must be greater than generated_at
Hop count above the declared ceiling
InvalidQuorum
publisher upstream_hop_count exceeds anti-eclipse policy
Two observations from one operator id
DuplicateValue
the repeated operator id
The three anti-eclipse gates
InvalidQuorum
the three messages in Validation order
Blank conflict key or reason, or a blank id in a conflict list
MissingField
federation_quorum.conflicts.divergence_key, .reason, .publisher_operator_ids
Repeated id in one conflict, or repeated divergence key
DuplicateValue
federation_quorum.conflicts.publisher_operator_ids: {value}, or the repeated key
State contradicts the body
InvalidQuorum
one of the five messages in Final states

Next: agree on the actions a listing can lead to

Quorum & Anti-Eclipse · Chio Docs