Consensus (θ)

θ is how multiple arbiters agree on a single order of events when they each see a potentially different subset of the network. It is the bridge between “one process wrote something” (η’s guarantee) and “the network agrees it happened” (the legitimacy axis’s guarantee).

Phase 0 reality: Colibri runs with a single writer against a WAL-mode SQLite database. θ is specified but not activated. A one-arbiter run is trivially consistent, so consensus has nothing to do. The spec below is the shape the runtime will grow into.

Authoritative spec: ../../../spec/s06-consensus.md. BFT library choice: ADR-003.

Quorum formula

For n arbiters with Byzantine tolerance f:

quorum = floor(2n / 3) + 1
f      = floor((n - 1) / 3)

The system tolerates f < n / 3 adversarial or failing arbiters. Below that bound, safety (no conflicting finalized states) is preserved. Above that bound, no BFT protocol can guarantee safety; θ’s response is to halt and signal a governance event rather than proceed with a corrupted chain.

Quorum math — worked table

n arbiters f tolerated quorum required Notes
4 1 3 Minimum viable BFT set — 1 Byzantine tolerated
7 2 5 Comfortable small set; fork merge quorum (7/10 checkpoint = relaxed variant)
10 3 7 Phase-5+ fork checkpoint target (matches ι 7-of-10 signature rule)
13 4 9 Scaled arbiter committee

The formula is quorum = floor(2n/3) + 1; the Byzantine ceiling is f = floor((n-1)/3). For any n, quorum + f = n + 1 - (n mod 3 == 0 ? 1 : 0) — an honest-majority invariant any two quorums must intersect in.

The five finality levels

A round moves through staged finality:

Level What holds
PENDING Event received; no arbiter has voted
SOFT A single arbiter has signed
QUORUM quorum arbiters have signed the same root
HARD Two consecutive rounds have reached QUORUM without conflicting votes
ABSOLUTE Epoch-sealed: the round’s root is embedded in the next epoch’s genesis

Downstream systems choose the level they require. A read of “current state” accepts SOFT. A cross-system integration that mutates external resources should wait for QUORUM at minimum and may require HARD for irreversible effects.

What the arbiters vote on

Each vote is a signature over a 3-tuple: (round_id, merkle_root, rule_version_hash). Voting over all three prevents an arbiter from signing the same root under a different rule set — a class of attack the rule engine’s versioning (see rule-engine.md) makes detectable.

Voting protocol (commit-reveal pseudocode)

θ uses a two-phase commit-reveal to defeat last-mover bias: no arbiter sees another’s vote before committing its own.

# Constants (Phase 0 defaults; governable via π under rule_version_hash)
T_round       = 30s    # one full commit+reveal cycle
T_timeout     = 2 * T_round   # 60s: leader-unresponsive threshold
T_commit_phase = 10s   # time to collect commitments
T_reveal_phase = 10s   # time to collect reveals

# Phase A: commit
salt   = random_bytes(32)
vote   = sign(my_key, (round_id, merkle_root, rule_version_hash))
c      = SHA-256(vote || salt)
broadcast( COMMIT(round_id, arbiter_id, c) )
wait_until commit_count >= quorum OR elapsed > T_commit_phase

# Phase B: reveal
broadcast( REVEAL(round_id, arbiter_id, vote, salt) )
wait_until reveal_count >= quorum OR elapsed > T_reveal_phase

# Phase C: verify
for each (vote_i, salt_i) received:
    require SHA-256(vote_i || salt_i) == committed_c_i
    require signature valid under arbiter_i public key
aggregate signatures → threshold signature σ
emit QUORUM(round_id, merkle_root, σ)

An arbiter that fails to reveal after committing is penalized as a liveness fault (softer than equivocation, handled by λ reputation decay rather than an immediate scar).

Worked example — 4-node BFT vote on event E

Setup: n = 4, f = 1, quorum = 3. Nodes: A, B, C honest; D Byzantine. Event E has merkle_root = 0xab12….

Commit phase (t=0→t=10s):

Node Broadcasts Commit hash
A COMMIT(round=42, A, c_A) c_A = SHA-256(sig_A(42, 0xab12, rv) || salt_A)
B COMMIT(round=42, B, c_B) c_B = SHA-256(sig_B(42, 0xab12, rv) || salt_B)
C COMMIT(round=42, C, c_C) c_C = SHA-256(sig_C(42, 0xab12, rv) || salt_C)
D COMMIT(round=42, D, c_D) c_D = SHA-256(sig_D(42, 0xCAFE, rv) || salt_D) — divergent root

At t=10s: 4/4 commits received — proceed to reveal.

Reveal phase (t=10s→t=20s):

  • A, B, C reveal their honest votes; each verifies SHA-256(vote || salt) == committed_c. All three check out against merkle_root = 0xab12….
  • D reveals its vote with merkle_root = 0xCAFE…. Hash verifies (D committed faithfully) but the root is the minority — D’s vote is isolated.

Aggregation:

  • Count of matching (merkle_root, rule_version_hash) on reveal: 0xab12… = 3 (≥ quorum), 0xCAFE… = 1.
  • Aggregate signatures from {A, B, C} into threshold signature σ.
  • Finality transition: PENDING → SOFT (at first arbiter sign) → SOFT → QUORUM (at 3rd matching signature).

Next round: if round 43 also reaches QUORUM without a conflicting reveal, round 42 transitions QUORUM → HARD. Epoch sealing later promotes it to ABSOLUTE.

D’s divergent vote: stored as a candidate equivocation trigger — if D also signed 0xab12… this round (double-commitment on the same (round_id, finality_level)), it becomes an equivocation proof (see below).

Equivocation

An arbiter that signs two distinct tuples at the same finality level in the same round is equivocating. θ detects this deterministically: any two conflicting signatures by the same key constitute a proof-of-fault. The λ reputation engine (see ../../social/reputation.md) applies a hard penalty; the κ rule engine may cap the arbiter’s future voting weight or suspend them via the π governance process.

Equivocation proof structure

{
  "type": "equivocation",
  "attacker_id": "soul:<sha256-of-pubkey>",
  "epoch": 17,
  "round_id": 42,
  "signed_vote_a": {
    "tuple": [42, "0xab12…", "rv:sha256:…"],
    "signature": "<ed25519-sig-A>"
  },
  "signed_vote_b": {
    "tuple": [42, "0xCAFE…", "rv:sha256:…"],
    "signature": "<ed25519-sig-B>"
  },
  "submitter": "soul:<who-observed-it>",
  "evidence_hash": "<sha256-over-the-two-signed-votes>"
}

Verification is pure and local: re-check both signatures against the attacker’s published public key; both valid + distinct tuples + same (round_id, finality_level) ⇒ provable fault. Penalty application is governed by s04-reputation (hard scar on arbitration domain, weight −10; see ../../social/reputation.md) and escalated to π for potential suspension.

View-change procedure

If the current round’s leader (the arbiter responsible for proposing the block) fails to emit a proposal within T_timeout = 2 * T_round (default 60s), any participating arbiter initiates a view change:

broadcast( VIEW_CHANGE(round_id, current_leader, reason="timeout") )
collect VIEW_CHANGE messages from peers
if view_change_count >= quorum:
    next_leader = VRF(prev_merkle_root, round_id)   # see ADR-002
    proceed round_id with leader = next_leader

The VRF seed is the previous round’s Merkle root, making leader selection deterministic-given-history but unpredictable-in-advance — an arbiter cannot campaign to be the next leader. Reasons recognized by the view-change flow: timeout, equivocation_observed, malformed_proposal. A view change emits a VIEW_CHANGE_ACCEPTED event into ζ (decision trail) so later audits can reconstruct why leadership rotated mid-round.

Gossip

Arbiters share signed messages via a gossip protocol whose topology is undirected, with bounded fan-out. The spec (not implemented in Phase 0) requires:

  • Each message is signed by its sender.
  • Duplicate messages are dropped on receipt (deduplication by content hash).
  • A message older than the retention horizon is dropped without reply.
  • Network partitions resolve by longest-chain rule only if the tip satisfies the rule-version gate (see rule-engine.md).

Gossip message envelope

All gossip messages share a common envelope:

{
  "msg_type": "COMMIT" | "REVEAL" | "VIEW_CHANGE" | "EQUIVOCATION_PROOF",
  "round_id": <uint64>,
  "sender_id": "soul:<sha256-of-pubkey>",
  "payload": { ... msg-specific ... },
  "timestamp_logical": <uint64>,   # Lamport clock, not wall-clock
  "signature": "<ed25519-sig-over-canonical-serialization>"
}

Wall-clock is never part of the signed payload (κ forbids clock reads in rule bodies; θ inherits the constraint). Ordering uses logical clocks plus the round_id. Retention horizon default: 2 epochs — beyond that the message is evidence-only, not consensus-relevant.

Interaction with ι (fork)

When θ cannot reach quorum — either because an arbiter partition lasts too long or because the vote space genuinely splits — the engine creates a fork via ι. See state-fork.md. A fork is not a failure of consensus; it is consensus’s graceful mode for “we do not yet agree.” Forks reconcile when a later epoch embeds both roots in its genesis and one branch becomes canonical.

Phase 0 posture

  • The runtime accepts θ-shaped APIs but always returns “trivially finalized” because n = 1.
  • Voting, gossip, and equivocation detection are schema-ready: columns exist, tools are stubbed.
  • Schema for mcp_consensus_votes stub: (round_id, arbiter_id, merkle_root, rule_version_hash, signed BLOB, threshold_count INT, finality_level ENUM('PENDING','SOFT','QUORUM','HARD','ABSOLUTE')). Written exclusively by the single writer; all rows have finality_level='QUORUM' via trivial 1-of-1.
  • The first real θ activation is not scheduled before Phase 1.5 (the multi-model milestone in ADR-005) at earliest; BFT activation target is R121+ per ../../../5-time/roadmap.md; BFT library selection tracked separately in ADR-003.

What θ is not

  • Not an ordering engine for free. θ orders what its arbiters see. If an event never reaches an arbiter, θ cannot order it.
  • Not a liveness guarantee under adversity. BFT protocols sacrifice liveness for safety when f is exceeded; θ halts rather than proceeds.
  • Not a replacement for the audit chain. θ signs roots; it does not produce them. The roots come from η over ζ.

See also


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.