P3.1.3 — Round / View State Machine — Audit

Step 1 of the 5-step chain (CLAUDE.md §6). Phase 3 θ Wave 3b — the commit-reveal voting protocol FSM that drives every consensus round, plus a view-change handler wired into P3.6.1’s selectLeader for next-leader rotation. This slice consumes P3.1.1’s typed message shapes, P3.1.2’s hasQuorum + detectDoubleVote, and P3.6.1’s VRF stub for leader election. It does NOT modify, override, or extend any of them.

§1. Surface inventory at base SHA 4ab4e8e8

Path Exists? Role
src/domains/consensus/ Yes Existing θ domain — 7 modules (messages, quorum, finality, equivocation, gossip-wire, time-anchors, vrf-stub)
src/domains/consensus/messages.ts Yes (P3.1.1 #234) REUSEVote, Commit, Reveal, ViewChange, canonicalSerialize, hashMessage, signMessage, verifySignature, nextLogical, resetLogicalForTesting
src/domains/consensus/quorum.ts Yes (P3.1.2 #236) REUSEquorumThreshold, hasQuorum, detectDoubleVote, voteGroupKey
src/domains/consensus/vrf-stub.ts Yes (P3.6.1 #238) REUSEselectLeader(arbiters, seed, round_id)
src/domains/consensus/finality.ts Yes (P3.2.1 #239) Reference (sibling FSM — independent state machine, not consumed by P3.1.3)
src/domains/consensus/equivocation.ts Yes (P3.5.1 #240) Reference (consumes detectDoubleVote output; P3.1.3 emits the trigger but does not slash)
src/domains/consensus/round-state.ts No — to create Commit-reveal FSM + view-change handler
src/__tests__/domains/consensus/round-state.test.ts No — to create Protocol traces, timeout, view-change scenarios, single-arbiter, 4-node BFT
src/domains/trail/schema.ts Yes (P0.7.1) Reference (THOUGHT_TYPES tuple — see §2.4 mismatch resolution)

No source path collides with the new file. Greenfield slice with runtime imports from messages.ts, quorum.ts, vrf-stub.ts only. No production import from equivocation.ts or finality.ts (siblings in the same domain; tested independently and consumed by their own callers).

§2. Spec inventory

2.1 Voting protocol (consensus.md §Voting protocol §60-90)

# 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, σ)

2.2 Commit-hash construction — canonical resolution

The consensus.md pseudocode reads c = SHA-256(vote || salt). The P3.1.1 module already documents the corresponding caller contract at messages.ts §142-147:

commit_hash = SHA-256(canonicalSerialize(vote) || salt) — the construction is a caller responsibility.

The two phrasings are consistent: the consensus.md vote symbol refers to the canonical signed bytes that P3.1.1’s canonicalSerialize emits over a Vote shape (the 3-tuple plus sender_id, vote_type, timestamp_logical, signature). P3.1.3 honors the documented P3.1.1 contract — commit_hash = SHA-256(canonicalSerialize(vote) || salt) where || is Buffer.concat. This matches the existing public docstring on Commit.commit_hash and the task prompt’s §P3.1.3 acceptance criteria.

2.3 View-change procedure (consensus.md §View-change procedure §149-161)

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)
    proceed round_id with leader = next_leader

Reasons recognized: timeout, equivocation_observed, malformed_proposal (already a typed union ViewChangeReason in P3.1.1 §123-124). A view change emits a VIEW_CHANGE_ACCEPTED event into ζ.

2.4 ζ emission — THOUGHT_TYPES schema mismatch

The task prompt §P3.1.3 line 654 / consensus.md §Common gotchas say:

Use the existing thought_record interface … set thought_type="consensus" (new value, document it).

The runtime ζ schema (src/domains/trail/schema.ts §47-52) defines THOUGHT_TYPES = ['plan', 'analysis', 'decision', 'reflection'] as as const, with the docstring stating “Adding a new type is a breaking change.” Modifying ζ schema is out of scope for a θ-domain slice and would cascade through the trail repository, the β task_update writeback hook, the MCP thought_record tool, and the Phase 0 Zod validator.

Resolution: P3.1.3 accepts an INJECTED ζ sink callback in its constructor — a thin ZetaSink interface that takes {event_type, payload}. The interface intentionally does not require the caller to be the actual P0.7.2 thought_record MCP tool — it can also be a test spy or a future P3.x adapter that maps to whichever shape the trail layer accepts at that time. The consensus module itself never calls THOUGHT_TYPES-typed APIs directly.

When a downstream adapter integrates the sink with the real ζ trail, the choice is between (a) widening THOUGHT_TYPES to include 'consensus' (one-line breaking change with a migration), or (b) mapping the consensus event onto the existing 'analysis' value with an event_type: 'VIEW_CHANGE_ACCEPTED' discriminator inside the content payload. Both are downstream choices — out of scope here.

The acceptance criterion “emits VIEW_CHANGE_ACCEPTED event into ζ” is satisfied by the structural emit through the injected sink, plus a test asserting the emitted payload shape.

2.5 Liveness fault vs equivocation (consensus.md §90; prompt §575-577)

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).

Liveness fault distinguishing condition: a Commit was observed under round R from arbiter A, but no matching Reveal arrived within T_reveal_phase. Equivocation is a separate code path (P3.5.1) — a Reveal whose signature verifies but whose hash does NOT match the committed commit_hash is NOT equivocation; the spec calls this a separate “mismatched commit” condition. P3.1.3 treats:

  • No-reveal-after-commit → liveness fault (injectable callback).
  • Mismatched commit/reveal → liveness fault (same callback) — the arbiter failed to honor their commit. NOT equivocation because there is no second signed tuple under the same round.
  • detectDoubleVote returns non-empty during VERIFY_PHASE → view-change with reason 'equivocation_observed'. The actual slashing is P3.5.1’s job; P3.1.3 only flags the round.

The task prompt §P3.1.3 says the liveness-fault callback is λ.markLivenessFault(arbiter_id). λ does not currently export markLivenessFault — the reputation surface today is apply_penalty(band='minor'|...) (P2.2.2 #229). P3.1.3 therefore defines its own interface point (LivenessSink) and leaves wiring to a future λ slice. The single-call site is the constructor.

2.6 Timer constants (consensus.md §65-69)

T_round       = 30s
T_timeout     = 2 * T_round   # 60s
T_commit_phase = 10s
T_reveal_phase = 10s

In bigint-ms (per CLAUDE.md §13 / κ determinism):

  • T_round = 30000n
  • T_commit_phase = 10000n
  • T_reveal_phase = 10000n
  • T_timeout = 60000n

The four constants are exported as readonly module-level bigints, governable under rule_version_hash in a later π slice. P3.1.3 does NOT wire the κ rule-engine lookup; the constants are compile-time.

2.7 Single-arbiter clause (consensus.md §Phase 0 posture §193-198)

The runtime accepts θ-shaped APIs but always returns “trivially finalized” because n = 1.

quorumThreshold(1n) === 1n (per P3.1.2 §29-33). The FSM short- circuits when arbiters.length === 1:

COMMIT_PHASE → REVEAL_PHASE → VERIFY_PHASE → COMPLETED

in a single tick. The injected clock advances 4 ticks (one per state transition) so the audit trace shows the canonical phase sequence.

2.8 4-node BFT worked example (consensus.md §92-120)

Setup: n = 4, f = 1, quorum = 3. Nodes A, B, C honest; D Byzantine — D commits with merkle_root = 0xCAFE… (divergent).

Expected trace:

  • t=0 (COMMIT_PHASE): A, B, C, D each broadcast a Commit. After all 4 commits received (commit_count=4 >= quorum=3) → REVEAL_PHASE.
  • t=1 (REVEAL_PHASE): A, B, C, D each broadcast a Reveal. All four reveals pass SHA-256(canonicalSerialize(vote) || salt) === commit_hash (each node honored its own commit — D’s commit was over 0xCAFE… and D reveals 0xCAFE… faithfully). After all 4 reveals received → VERIFY_PHASE.
  • t=2 (VERIFY_PHASE): The 4 revealed Votes are grouped by voteGroupKey. The 0xab12… group has 3 members; the 0xCAFE… group has 1. hasQuorum(group_largest, n=4) is true for 0xab12… (3 >= 3). Aggregate signatures from {A, B, C} → threshold σ; emit QUORUM event with merkle_root = 0xab12….
  • t=3: COMPLETED.

No view-change in this happy path. D’s vote is isolated — if D had ALSO signed 0xab12… in this same round, that would be a double-vote (detectDoubleVote non-empty), and a view-change with reason='equivocation_observed' would have fired during VERIFY_PHASE. The current trace assumes D commits and reveals ONLY 0xCAFE… — a “minority dissent” but not equivocation.

§3. Runtime dependencies

Module Imports Notes
node:crypto createHash, randomBytes, sign, verify, KeyObject All NAMED imports — no crypto.X dotted access
./messages.js Vote, Commit, Reveal, ViewChange, VoteType, ViewChangeReason, canonicalSerialize, hashMessage, signMessage, verifySignature type-only where possible
./quorum.js hasQuorum, quorumThreshold, detectDoubleVote, voteGroupKey  
./vrf-stub.js selectLeader  

No gray-matter, no merkletreejs, no third-party crypto libs.

§4. Forbidden-token surface (κ determinism)

Per bps-constants.ts + the existing θ-domain self-scan tests (messages.test.ts §forbidden-token; quorum.test.ts §forbidden-token; finality.test.ts §forbidden-token; vrf-stub.test.ts §forbidden-token; equivocation.test.ts §forbidden-token), the source body of every consensus-domain module must contain none of:

  • Math.<id> — no Math.random, Math.floor, etc.
  • Date.<id> — no Date.now, new Date(...), Date.UTC
  • setTimeout, setInterval, setImmediate — no wall-clock waits
  • process.hrtime, performance.now — no high-resolution clocks
  • Number(<expr>) — no implicit-coercion to Number
  • crypto.<id> dotted access — must use NAMED imports
  • Floating-point literals (/\b\d+\.\d+\b/) — bigint only

The audit guarantees the new file round-state.ts will be authored against these constraints. Tests are added per §6.

§5. Module boundary

The new file is a single-class export plus six type re-exports. The class is pure in the sense that:

  • No I/O (no fs, no console, no DB, no network).
  • No wall-clock reads (the only “time” is the caller-injected logical clock).
  • No randomness (the only RNG is the caller-injected salt-generator).
  • All async-shaped behavior (wait_until ...) is modeled as event-driven transitions; the class has no Promise / no setTimeout.

Stateful — the class holds round-scoped maps of received commits and reveals. State is private; the test surface exposes getters for asserting trace.

§6. Acceptance criteria summary

Twenty-eight acceptance criteria mapped 1:1 to the task prompt §P3.1.3 acceptance list, the contract §9, and the test cases in round-state.test.ts. Final list lives in the contract; this audit records the SHAPE of coverage:

Bucket Count Notes
State enum + timer constants 5 AC#1–AC#5
Phase-A (commit) transitions 4 AC#6–AC#9
Phase-B (reveal) transitions 4 AC#10–AC#13
Phase-C (verify) transitions 4 AC#14–AC#17
View-change handler 4 AC#18–AC#21
Liveness fault sink 2 AC#22–AC#23
ζ emission 2 AC#24–AC#25
4-node BFT worked example 1 AC#26
Single-arbiter clause 1 AC#27
Forbidden-token self-scan 1 AC#28

§7. Risks + mitigations

Risk Mitigation
ζ schema mismatch ('consensus' not in THOUGHT_TYPES) Injected sink interface; downstream caller chooses encoding
Liveness sink shape unknown (no λ.markLivenessFault) Injected sink with arbiterId: string, roundId: bigint signature; future λ slice wires it
Salt-RNG determinism in tests Constructor accepts rngSource: () => Buffer defaulting to randomBytes(32); tests inject a seeded counter
Logical-clock determinism Constructor accepts clock: () => bigint defaulting to a private monotonic counter; tests inject a step controller
4-node BFT determinism All Ed25519 keys are generated once per test via generateKeyPairSync and re-used; canonical serialization is platform-stable per P3.1.1
Test file size Target 800-1000 lines including fixtures; mirrors equivocation.test.ts, finality.test.ts scope

§8. Out of scope

  • Modifying THOUGHT_TYPES to add 'consensus' (handled in a later trail-domain slice).
  • Implementing λ.markLivenessFault (deferred to a later λ slice; the P3.1.3 sink interface is the contract point).
  • Wiring κ governance of timer constants (handled by a later π slice).
  • Persisting consensus rounds — round state is in-memory only in this slice; persistence is a Phase 3.5 concern.
  • BFT signature aggregation (the “σ” aggregate) — P3.1.3 emits an aggregator-shape placeholder (the list of verified Vote signatures); the cryptographic threshold-aggregate primitive lands in a later η slice. Per ADR-003 the choice (BLS vs MuSig2 vs naïve concat) is still open.

End of audit. Next: contract.


Back to top

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

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