← Blog Anemos Team

The Height-3648 Halt: When an Exclusion Rule Excludes Everyone

The Height-3648 Halt: When an Exclusion Rule Excludes Everyone

Anemos gates who is allowed to propose a block on two recomputed scores: the inherited consensus availability score, and the Anemos oracle-availability score described in our oracle-hardening posts. Both are exclusion-only — a low score blocks a validator from proposing, it never touches stake. That is the right call when the excluded set is a minority. It is not obviously safe when it is not, and on the live testnet at block 3648 it was not.

The trap: a gate that heals only by doing the thing it forbids

Both scores are recomputed from committed history — they only change when a block actually commits. That is a reasonable determinism rule on its own. The trap appears when you combine it with an exclusion gate: if the gates ever exclude every seated committee member’s turn to propose at once, no block commits, and if no block commits, the scores that would let someone recover their eligibility never change either. The chain does not degrade gracefully in that state — it halts, permanently, because the only way out (a commit) is the very thing the gate is preventing. We observed this live: over a thousand change-proposer rounds cycling at the same height, and a restart did not help, because the gate reads committed state, not process state.

Two bounded escapes, both propose-local

The fix (issue #922) does not touch the scores themselves — the escalating oracle-deviation slash, the availability scoring, and the tiered quorum all behave exactly as designed. It bounds how far the gate built on top of them is allowed to go, with two deterministic escapes in shouldSkipProposer:

  1. An exclusion cap under one-third. The gate may exclude at most f = (committee_size − 1) / 3 members at any one time — the same fraction BFT already tolerates as Byzantine. If more members than that would be gated by their scores, the highest-scored of the gated members are kept eligible anyway (a deterministic tie-break by validator number when scores tie), so at least committee_size − f proposers — a safe supermajority — always remain available to take a turn.
  2. A full-rotation escape. If a height has already cycled through a full round of proposers (round >= committee_size) without a single commit, the gates are ignored entirely for that attempt. Liveness wins over exclusion once the rotation has proven the committee cannot otherwise make progress.

Both escapes are propose-local decisions — they only affect whether this node, acting as the current proposer, decides to skip its own turn. They are never consulted when validating a block someone else proposed. That matters for safety: a node that disagrees about whether the cap or the rotation escape should apply cannot fork the chain over it, because the disagreement can only ever manifest as “who proposes this round,” which the existing change-proposer mechanism already resolves the same way it resolves any other proposer disagreement.

What this does and does not change

Nothing about the underlying scores or slashing changed — this is purely a bound on the propose gate that reads them. The deterrence story from the oracle-participation and magnitude-fair-slash posts still holds in the common case: a validator that deviates or goes silent still loses income and, eventually, its ability to propose. What changed is the edge case where enforcing that deterrence with no escape would have meant enforcing it into a permanent halt. The escape hatches are deliberately narrow — under one-third, and only after a full rotation has already failed — so they cost a small, bounded amount of deterrence under contention in exchange for a hard liveness guarantee: the committee can never gate itself into a corner it cannot get out of.

The practical lesson mirrors the one from the committee-size bug we wrote about earlier: this was not a wrong rule. The availability gate and the score-recompute-from-committed-history rule are each correct in isolation. The bug lived in the interaction — a liveness mechanism whose only recovery path required the liveness it was supposed to guarantee.