Audit — P3.2.1 Finality State Machine (5 levels)
Task: monotonic 5-level finality FSM (PENDING → SOFT → QUORUM → HARD → ABSOLUTE) gating external side effects.
Branch: feature/p3-2-1-finality-sm
Worktree: .worktrees/claude/p3-2-1-finality-sm
Base: origin/main @ 498e6ea5 (post P3.1.2 BFT quorum).
Spec source: docs/spec/s06-consensus.md §Finality levels.
Concept source: docs/3-world/physics/laws/consensus.md §The five finality levels.
Task prompt: docs/guides/implementation/task-prompts/p3.1-theta-consensus.md §P3.2.1 (L740-L919).
1. Surface inventory — pre-task
src/domains/consensus/ already ships:
| File | Origin | Exports relevant to P3.2.1 |
|---|---|---|
messages.ts |
P3.1.1 (#234) | Vote, Commit, Reveal, EquivocationProof, nextLogical, canonicalSerialize, signMessage, verifySignature, hashMessage. P3.2.1 imports Vote type only. |
quorum.ts |
P3.1.2 (#237) | quorumThreshold(n), hasQuorum(votes, n), voteGroupKey(v), detectDoubleVote. P3.2.1 consumes hasQuorum + voteGroupKey. |
time-anchors.ts |
P3.4.1 (#235) | STA broadcast/median — not consumed by P3.2.1. |
gossip-wire.ts |
P3.3.1 (#236) | IHAVE/IWANT — not consumed by P3.2.1. |
Test scaffolding pattern — src/__tests__/domains/consensus/quorum.test.ts shows the established style: bigint LCG for property tests, makeVote helper, AC-numbered describe blocks, contract-source citations.
2. Spec & concept-doc cross-reference
s06 §Finality levels (lines 17-27)
| Level | Condition (s06) | External effects (s06) |
|---|---|---|
| PENDING | Event submitted | None |
| SOFT | Some votes, below quorum | None |
| QUORUM | votes ≥ threshold |
None (dispute window active) |
| HARD | Quorum held for full dispute window (≥24h) | Yes |
| ABSOLUTE | HARD + no pending appeals | Irreversible effects |
Rule: no irreversible external side-effects before HARD finality.
consensus.md §The five finality levels (L42-L54)
| 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 |
consensus.md worked example (L118)
“Next round: if round 43 also reaches QUORUM without a conflicting reveal, round 42 transitions
QUORUM → HARD. Epoch sealing later promotes it toABSOLUTE.”
Task-prompt overrides
- Dispute window: s06 says “≥24h”; task prompt §P3.2.1 says “100 epochs” (L764). The FSM does NOT run a wall timer (gotcha note L913-L915 in the source prompt) —
dispute_window_epochs: bigint = 100nis configurable, default 100. - QUORUM→HARD trigger (gotcha L908-L912): “keep last round’s QUORUM evidence; on next round’s QUORUM, compare merkle_roots; if same and no equivocation observed between, transition to HARD. Otherwise stay at QUORUM and reset the counter.”
Resolution
The two spec sources are not contradictory once read together:
- s06 framing — “Quorum held for full dispute window” — is the prose generalization.
- consensus.md framing — “Two consecutive rounds have reached QUORUM without conflicting votes” — is the operationalization.
- The task prompt formalizes “two consecutive rounds without conflicting reveal” as the runtime rule and uses 100-epoch dispute window as a parallel governance gate (after HARD, no rollback for 100 epochs before promotion to ABSOLUTE via epoch seal).
The implementation follows the task prompt’s operational rule as authoritative — backed by both concept doc L51 and L118.
3. Target API (from task prompt L788-L825)
type FinalityLevel = "PENDING" | "SOFT" | "QUORUM" | "HARD" | "ABSOLUTE"
const FINALITY_ORDER: FinalityLevel[] = ["PENDING", "SOFT", "QUORUM", "HARD", "ABSOLUTE"]
class FinalitySM {
constructor(round_id: bigint, n_arbiters: bigint, dispute_window_epochs: bigint = 100n)
current(): FinalityLevel
receiveVote(vote: Vote, currentEpoch: bigint): void
sealEpoch(epoch: bigint, sealRoot: Buffer): void
requireExternalEffectsAllowed(): void
transitions(): Array<{from: FinalityLevel, to: FinalityLevel, epoch: bigint, evidence: Buffer}>
}
Errors:
FinalityRollbackError— thrown when monotonicity is violated.PrematureExternalEffectError— thrown byrequireExternalEffectsAllowedifcurrent() < HARD.
4. Determinism & Phase 3 forbidden tokens
Per task prompt §Forbidden (L865-L870 of source plus the surrounding p3.1-theta-consensus.md §Forbidden tokens global block):
Math.*,Date.*,Math.random(already κ-forbidden)numberliterals forround_id/epoch(bigint only)- floats anywhere
- mutating state via getters
- external side effects before HARD
The messages.ts source contains a forbidden-token self-scan; quorum.ts has one too. P3.2.1 will follow the same convention — a corpus self-scan test verifying finality.ts body contains none of Math., Date., Math.random, setTimeout, setInterval, process.hrtime, performance.now.
The FSM body has NO time reads, NO randomness, NO I/O. All inputs are caller-supplied bigints + Buffers.
5. Single-arbiter clause (task prompt L827-L832)
For n=1:
quorumThreshold(1n) === 1n(already proven in P3.1.2 corpus, AC#5).- First
receiveVote→ SOFT. - Same
receiveVote(or vote group with cardinality 1) → QUORUM (becausehasQuorum([v], 1n) === true). - Same group two rounds in a row → HARD.
sealEpoch→ ABSOLUTE.
Trace:
new FinalitySM(round_id=1, n=1, window=100)
current() === "PENDING"
receiveVote(v_r1, epoch=10)
current() === "QUORUM" # n=1: SOFT and QUORUM coalesce
# Note: transitions() shows PENDING→SOFT then SOFT→QUORUM as TWO entries
# because the FSM walks one level per state-machine step
receiveVote(v_r2, epoch=11) # same root + rule_version_hash
current() === "HARD"
sealEpoch(12, sealRoot)
current() === "ABSOLUTE"
Two interpretations resolve into one — the FSM steps through SOFT for ONE tick at n=1 (records the SOFT→QUORUM transition with the single-vote evidence) then advances. The “trivially within one round” semantics from L770 is enforced by the loop in receiveVote re-checking quorum after every vote.
6. Evidence types per transition (task prompt L771-L774)
| Transition | Evidence (Buffer encoding) |
|---|---|
| PENDING → SOFT | canonical bytes of the triggering vote (canonicalSerialize(vote)) |
| SOFT → QUORUM | canonical bytes of the array of votes in the matching group (the votes that achieved quorum) |
| QUORUM → HARD | canonical bytes of the pair {prev: <round-N votes>, curr: <round-N+1 votes>} |
| HARD → ABSOLUTE | the sealRoot: Buffer passed to sealEpoch |
Encoding reuses κ canonical-serialize via canonicalSerialize from messages.ts — round-trip stable per κ P1.5.4. Buffer fields inside the structure get the hex-rewrite pass via rewriteBuffersToHex (which is private but canonicalSerialize(vote_array) handles arrays of votes correctly because Vote is a plain object).
Wait — canonicalSerialize is typed ConsensusMessage | Vote, not Vote[]. The evidence for SOFT→QUORUM is a list. Solution: wrap into a plain object {votes: [...]} — this is a plain object whose values are arrays of plain Vote objects, both supported by rewriteBuffersToHex + κ canonicalize. However the exported canonicalSerialize signature won’t accept that wrapper at compile time. The FSM must use a local SHA-256 helper that hashes a Buffer concatenation, OR re-marshal evidence via a thin wrapper.
Decision: P3.2.1 implements its own encodeEvidence(value) helper INSIDE finality.ts that goes through κ canonicalize directly (it’s a public export of src/domains/rules/canonical.ts per the import in messages.ts L71). Buffer pre-rewrite is done locally to avoid coupling. This keeps the FSM module self-contained and avoids widening canonicalSerialize’s public type.
7. Monotonicity & error semantics (gotcha L916-L918)
“DO NOT silently ignore a downward attempt; throw a typed error.”
FinalityRollbackError extends Error with name = 'FinalityRollbackError'. Constructor takes from and to levels and produces a deterministic message string.
PrematureExternalEffectError similar pattern. Used by requireExternalEffectsAllowed; HARD and ABSOLUTE pass, the rest throw.
The FSM itself never produces a downward attempt; the throw is for misuse (e.g. caller subclass bug). Defensive guard implemented via private helper __advance(target: FinalityLevel) that verifies FINALITY_ORDER.indexOf(target) >= FINALITY_ORDER.indexOf(this.level) before mutating internal state.
8. State exposed via getters that DO NOT mutate (AC L775)
current()returns the current level (string enum value — immutable by JS semantics).transitions()returns a fresh shallow copy of the internal transition log array (gotcha L919). Caller may not mutate internal state.
getters DO NOT mutate is satisfied trivially because the internal level is a string and the internal log is an array — the returned copies are detached.
9. Tests planned (acceptance criteria → test mapping)
| AC | Test |
|---|---|
| AC1: 5 levels, strict order | FINALITY_ORDER shape assertion |
| AC2: PENDING→SOFT on first vote | n=4, single vote → current() === 'SOFT' |
AC3: SOFT→QUORUM via hasQuorum |
n=4, 3 matching votes → current() === 'QUORUM' |
| AC4: QUORUM→HARD on 2 consecutive | round 1 quorum, round 2 quorum (same root+rule), no conflicting reveal → HARD |
| AC5: HARD→ABSOLUTE on sealEpoch | seal → ABSOLUTE |
| AC6: Monotonicity rollback throws | internal helper exercise via setLevelForTesting → throws FinalityRollbackError |
| AC7: requireExternalEffectsAllowed throws at PENDING/SOFT/QUORUM | three asserts |
| AC8: requireExternalEffectsAllowed silent at HARD/ABSOLUTE | two asserts |
| AC9: Single-arbiter n=1 trace | full 5-level path with one vote per round |
| AC10: Evidence recorded per transition | transitions() length === 4 after full traversal, every entry has epoch: bigint, evidence: Buffer non-empty |
| AC11: Conflicting reveal between rounds resets HARD counter | round 1 quorum, round 1 equivocation observed, round 2 quorum → still QUORUM (not HARD) |
| AC12: 100-epoch dispute window | dispute_window_epochs configurable; default 100; epoch gap between HARD and ABSOLUTE must be ≥ window for promotion |
| AC13: Different root in round N+1 resets counter | r1 quorum at root_A, r2 quorum at root_B → still QUORUM |
AC14: transitions() returns copy |
mutate returned array → internal log unchanged |
| AC15: Forbidden-token self-scan | regex over compiled file body |
| AC16: 5-level full path | PENDING→SOFT→QUORUM→HARD→ABSOLUTE in 4 transitions |
AC17: nArbiters < 1n throws |
constructor invariant via QuorumError propagation |
Total ≈ 25-40 test cases (L task estimate per packet).
10. Build / lint / test gates
npm run build— TypeScript strict check (errors must be zero).npm run lint— ESLint with the project’s Phase 3 ruleset.npm test— full Jest run; baseline 2818 passing + 1 pre-existing failure inreputation/tools.test.ts(better-sqlite3 schema issue — not in P3.2.1 scope). Expected delta: +25 to +40 (L task).
11. Out of scope
consensus_finalityMCP tool — that’s P3.7.1 (next wave).- Persistence of FSM state — Phase 3 in-memory only; persistence to
mcp_consensus_votestable (per consensus.md L197) is a later phase. - VRF leader rotation hookup — that’s P3.6.1 (parallel slice).
- Wall-clock dispute window timer — explicitly out per gotcha L913-L915.
- Multi-FSM coordination — one FSM = one round. Cross-round aggregation is consumer concern.
12. Approval
Step 1 of 5 complete. Proceeding to Step 2 (contract).