name: tn-domain-consensus description: | Domain expert reference for the telcoin-network BFT consensus layer — Bullshark ordering, certificate construction and validation, vote aggregation, header chains, DAG invariants, and quorum math. Loaded by tn-rust-engineer and tn-domain-reviewer when work touches the primary, certifier, proposer, executor (consensus output), or aggregator code paths. NOT user-invocable. Loaded programmatically by tn-* agents via the Skill tool.
tn-domain-consensus
Consensus is the layer where Byzantine fault tolerance is actually enforced. Up to f of 3f+1 validators may be malicious. Every rule in this layer exists to ensure that no two honest validators ever commit conflicting state, even when up to f peers are actively trying to make that happen.
If you are about to modify code that:
- lives under
crates/consensus/primary/**,crates/consensus/worker/**,crates/consensus/executor/** - handles certificates, headers, votes, parents, or any DAG construction
- aggregates votes, validates quorum, or makes leader-election decisions
- emits, consumes, or transforms
ConsensusOutput - crosses the consensus/execution boundary (executor crate)
…load this skill before writing a single line.
Why consensus is different
Honest validators are not enough — the protocol must work despite up to f validators acting arbitrarily. Every check, every aggregation, every state transition must assume the input might be adversarial. The two consequences:
- Validation is non-negotiable. Every certificate, header, and vote that crosses an interface must have its signatures verified against the active committee for that message's epoch, with quorum thresholds enforced exactly. Off-by-one in quorum math is a safety violation, not a bug.
- Equivocation must be detectable. A validator producing two conflicting headers in the same round breaks safety. The DAG construction and certifier must reject (and ideally surface) equivocation rather than silently absorbing it.
Invariants
Quorum is
2f+1of the active committee for the message's epoch, not the receiving node's epoch. A header for epoch N requires2f+1votes from epoch N's committee. The local node may already be in epoch N+1; quorum still references the message's epoch. Mixing committees across epochs corrupts the validation.Certificate validation must verify all parent references exist and are themselves validated. A certificate at round R points at certificates from round R-1. Accepting a certificate without validating its parents breaks DAG integrity — equivocation in a parent silently propagates.
Headers within an epoch use strictly sequential rounds. Round R+1 cannot be voted on until round R has a
2f+1quorum of certificates. Skipping rounds, accepting late round R after round R+2 has formed quorum, or building parents from non-adjacent rounds violates DAG construction.Equivocation rejection is mandatory. Two distinct headers from the same author in the same round is a safety violation. The certifier must reject the second header and refuse to vote for it. Storing both is acceptable for evidence; signing both is not.
ConsensusOutputmust be idempotent and ordered. The executor must process outputs inconsensus_numberorder with no gaps and no repeats. Reordering or skipping an output causes execution divergence.
Pre-write Checklist
What committee am I validating against? Name the epoch. If the answer is "the local node's current epoch", check whether the message's epoch matches — if not, the validation is wrong.
What's the quorum threshold?
2f+1wheref = floor((n-1)/3). Computenfrom the message's epoch's committee, not the local committee. Off-by-one is a safety bug.Are parents validated before the child? If you're processing a certificate or header, walk its parent references and confirm each parent passed validation. Don't trust a child whose parents you haven't checked.
What's the equivocation story? If the operation involves an author signing something, what stops them from signing two conflicting things? Is the existing certifier check still in the code path you're touching?
Does this preserve
ConsensusOutputordering? If you're emitting or transformingConsensusOutput, what guarantees consecutive numbering and no skips?Is any input from a peer (not yet validated)? If yes, treat it as adversarial. Validate before deriving anything from it.
Canonical Sources
| Value | Source | Why this and not other sources |
|---|---|---|
| Active committee for epoch N | EpochRecord for epoch N in ConsensusChain |
Live ConsensusRegistry may have rolled to N+1 |
| Quorum threshold for epoch N | 2 * f + 1 where f = (committee.size() - 1) / 3 |
Hardcoded numbers, "majority", or committee.len() / 2 + 1 are all wrong |
| Last committed leader for round R | ConsensusChain::leader_at(R) after consensus has resolved R |
Local computation before commit; speculative leaders |
| Parent certificates of cert C | C.parents() — must be validated independently |
Trusting that C.parents() exist because C signed them |
| Vote signature scheme | BLS12-381 via blst, with epoch-tagged signing payload |
Any non-epoch-tagged payload (allows replay across epochs) |
| Round monotonicity | Local round counter advanced only after 2f+1 certs at current round |
Time-based round advancement; speculative advance |
Common Bug Patterns
Pattern 1: Wrong-committee quorum check
// WRONG — uses local committee, but the cert is for a different epoch
let quorum = 2 * (self.committee.size() - 1) / 3 + 1;
if cert.signers().count() >= quorum { ... }
The fix: derive the committee and quorum from cert.epoch(), not from self. If the local node doesn't have that epoch's committee in storage, that's an error to surface — not a reason to fall back to the current epoch.
Pattern 2: Trusting unvalidated parents
// WRONG — accepts cert without validating parents exist and are correct
fn process_cert(&mut self, cert: Certificate) -> Result<()> {
self.store(cert)?;
Ok(())
}
The fix: walk cert.parents(), look each up in storage, and confirm validation status. Reject the cert if any parent is missing or unvalidated.
Pattern 3: Hardcoded quorum threshold
// WRONG — assumes a fixed committee size
const QUORUM: usize = 5;
if votes.len() >= QUORUM { ... }
Committee sizes change at epoch boundaries (validators join, exit, get slashed). Always derive quorum from the current committee size.
Pattern 4: Silent equivocation tolerance
// WRONG — overwrites the prior header instead of detecting equivocation
self.headers.insert((author, round), new_header);
The fix: check whether (author, round) already has a different header. If yes, that's equivocation — reject the new one (and surface it for slashing if applicable).
Further Reading
references/invariants.mdreferences/bug-patterns.mdreferences/canonical-sources.md