Quorum & Anti-Eclipse
Check that enough distinct publishers saw a federated listing recently, and agreed on it, before your operator acts on it.
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.
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.
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
#[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.
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:
converged. No conflicts, and the fresh count meetsquorumThreshold.conflicting. At least one conflict entry.insufficient_quorum. The fresh count is below the threshold once every preceding gate has passed.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:
- Signature.
SignedFederationQuorumReportwraps the body in aSignedExportEnvelope. The receiver verifies the envelope and its trusted signer, then validates the body. - Age. The receiver derives each observation's age from its own trusted clock before building the input.
- Agreement. The receiver compares
observedListingSha256across publishers. See listing agreement. - 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.
cargo run --locked --bin quorumreference 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
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:
chio federation quorum verify fqr-1.json fqr-1-one-operator.json \
--trusted-signer ./quorum-signer.pub --floor ./quorum-floor.jsonACCEPTED 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
Search for observedListingSha256, insufficient_quorum, or DuplicateValue.
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
| Field | Default | Refuses a report with |
|---|---|---|
minimum | 2 | fewer unique operatorId values |
require | true | no origin observation from origin |
require | 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
| State | Accepted when | Refusal 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_ | fresh count below threshold, coverage met | insufficient_ |
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.
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.
| Field | Type | Validator rule |
|---|---|---|
schema | String | equals chio., 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 |
origin | String | non-empty; half of the origin test |
quorumThreshold | u32 | non-zero; read by two state arms |
max | u64 | non-zero; the ceiling every ageSecs meets |
publishers | Vec<Federation | non-empty; unique operator ids |
conflicts | Vec<Federation | unique divergence keys; non-empty key, reason, and id list |
anti | Federation | four required fields, declared by the report |
finalState | Federation | 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.
#[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.
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.
| Condition | Variant | Payload |
|---|---|---|
Schema is not the v1 constant | Unsupported | the offending schema string |
Blank reportId, namespace, listingId, or origin | MissingField | federation_ and the three siblings |
Zero threshold or zero max replica age | InvalidQuorum | quorum_; max_ |
Empty publisher vector | MissingField | federation_ |
Zero minimum | InvalidQuorum | minimum_ |
Publisher fails its own validation | InvalidQuorum | publisher. or publisher. |
Blank schema, artifactId, operatorId, or sha256 in reportRef | MissingField | federation_ |
Malformed report | Invalid | federation_ |
reportRef.kind is not listing_report | InvalidQuorum | publisher report_ref must reference a listing report |
report disagrees with the publisher | InvalidQuorum | publisher report_ref operator_id must match publisher. |
Blank observed | MissingField | federation_ |
Malformed observed | Invalid | the field name plus must be a 64-character lowercase- |
Freshness block fails its own validation | InvalidQuorum | freshness.; freshness. |
Hop count above the declared ceiling | InvalidQuorum | publisher upstream_ |
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_, .reason, .publisher_ |
Repeated id in one conflict, or repeated divergence key | DuplicateValue | federation_, or the repeated key |
State contradicts the body | InvalidQuorum | one of the five messages in Final states |