P3.1.3 — Round / View State Machine — Contract

Step 2 of the 5-step chain (CLAUDE.md §6). Behavioural contract for src/domains/consensus/round-state.ts — the commit-reveal voting FSM with view-change handler. This file fixes the public surface; the packet (Step 3) translates it into an execution plan; the implementation (Step 4) honors every clause.

§1. Public surface

// State enum (string union, exported as readonly tuple + type alias)
export const ROUND_STATES = [
  'COMMIT_PHASE',
  'REVEAL_PHASE',
  'VERIFY_PHASE',
  'VIEW_CHANGE',
  'COMPLETED',
] as const;
export type RoundState = (typeof ROUND_STATES)[number];

// Timer constants (bigint ms; governable via π under rule_version_hash)
export const T_round: bigint;        // 30000n
export const T_commit_phase: bigint; // 10000n
export const T_reveal_phase: bigint; // 10000n
export const T_timeout: bigint;      // 60000n  (== 2n * T_round)

// Injected sinks
export interface ZetaSink {
  emit(event: ZetaEvent): void;
}
export interface ZetaEvent {
  readonly event_type: 'VIEW_CHANGE_ACCEPTED' | 'QUORUM_REACHED';
  readonly round_id: bigint;
  readonly logical_clock: bigint;
  readonly payload: Record<string, unknown>;
}

export interface LivenessSink {
  markLivenessFault(arbiter_id: string, round_id: bigint): void;
}

// Configuration
export interface RoundStateConfig {
  readonly round_id: bigint;
  readonly n_arbiters: bigint;
  readonly arbiter_id: string;                  // who AM I
  readonly arbiters: readonly string[];         // committee
  readonly leader: string;                      // current proposer
  readonly prev_merkle_root: Buffer;            // VRF seed for next leader
  readonly privKey: KeyObject;                  // my Ed25519 signing key
  readonly my_vote_tuple: VoteTuple;            // (round_id, merkle_root, rule_version_hash)
  readonly vote_type?: VoteType;                // default 'ACCEPT'
  readonly publicKeyByArbiter: ReadonlyMap<string, KeyObject>;
  readonly clock?: () => bigint;                // injected logical clock, default monotonic counter
  readonly rngSource?: () => Buffer;            // injected 32-byte salt source, default randomBytes(32)
  readonly zetaSink?: ZetaSink;                 // ζ Decision Trail sink
  readonly livenessSink?: LivenessSink;         // λ liveness-fault sink
}

// Errors
export class RoundStateError extends Error { ... }

// Quorum event (emitted at COMPLETED)
export interface QuorumOutcome {
  readonly round_id: bigint;
  readonly merkle_root: Buffer;
  readonly rule_version_hash: Buffer;
  readonly aggregated_signatures: readonly Buffer[];   // {A,B,C} sig list
  readonly winning_voters: readonly string[];          // sorted arbiter ids
}

// The class
export class RoundState {
  constructor(cfg: RoundStateConfig);

  // -- Inspection (pure getters) -------------------------------------------
  state(): RoundState;
  round_id(): bigint;
  arbiter_id(): string;
  current_leader(): string;
  clock(): bigint;                              // current logical-clock reading
  myCommit(): Commit | null;                    // my own commit message (after begin())
  receivedCommits(): readonly Commit[];         // all observed commits this round
  receivedReveals(): readonly Reveal[];         // all observed reveals this round
  receivedViewChanges(): readonly ViewChange[]; // all observed view-changes this round
  quorumOutcome(): QuorumOutcome | null;        // populated at COMPLETED
  livenessFaults(): readonly string[];          // arbiters flagged this round
  zetaEvents(): readonly ZetaEvent[];           // events emitted to ζ sink (mirror)

  // -- Drivers --------------------------------------------------------------
  // Phase A driver: compute MY commit, broadcast (return for caller to publish)
  begin(): Commit;

  // Receive a commit from a peer (or my own echoed back).
  receiveCommit(commit: Commit): void;

  // Receive a reveal from a peer (or my own).
  receiveReveal(reveal: Reveal): void;

  // Receive a view-change from a peer.
  receiveViewChange(vc: ViewChange): void;

  // Advance the FSM in response to clock tick. Idempotent — calling on a
  // state that has nothing to do is a no-op.
  tick(): void;

  // Externally signal a view-change trigger (from caller-observed
  // malformed_proposal or proposer timeout).
  triggerViewChange(reason: ViewChangeReason): void;

  // -- Phase-B driver: produce MY reveal once I'm in REVEAL_PHASE -----------
  myReveal(): Reveal;

  // -- Phase-C driver: explicit verify call (typically auto-driven by tick) -
  verify(): void;
}

§2. Class semantics

2.1 Constructor

  • Validates that arbiters includes arbiter_id.
  • Validates that arbiters.length === Number(n_arbiters) (the only Number(…) call inside the body — used only for length-vs-bigint equality during input validation, where the bigint is bounded to committee size). Actually — to avoid the forbidden token — the body checks BigInt(arbiters.length) === n_arbiters.
  • Validates that leader is in arbiters.
  • Validates that publicKeyByArbiter has an entry for every arbiter.
  • Validates that prev_merkle_root.length === 32.
  • Initial state: COMMIT_PHASE.
  • Internal clock: caller-provided clock or a private counter that starts at 0n and ticks +1n per FSM transition.
  • Internal RNG: caller-provided rngSource or randomBytes(32).

2.2 begin() — Phase A start

Preconditions: state must be COMMIT_PHASE; begin() must not have been called yet this round.

Effects:

  • Generate salt = rngSource() (32 bytes; fixed-length asserted).
  • Build my Vote:
    • sender_id = arbiter_id
    • 3-tuple = my_vote_tuple
    • vote_type = vote_type ?? 'ACCEPT'
    • timestamp_logical = clock() snapshot
    • signature = signMessage(bareVote, privKey)
  • Compute commit_hash = SHA-256(canonicalSerialize(myVote) ++ salt).
  • Build my Commit:
    • msg_type = 'COMMIT'
    • round_id
    • sender_id = arbiter_id
    • commit_hash
    • timestamp_logical = clock()
    • signature over the canonical Commit body
  • Record myCommit + my salt + my Vote in private state.
  • Auto-call receiveCommit(myCommit) so the FSM accounts for it.

Returns: the Commit to publish.

2.3 receiveCommit(c)

Preconditions: c.msg_type === 'COMMIT'; c.round_id === round_id; c.sender_id is in arbiters; signature verifies under publicKeyByArbiter.get(c.sender_id); we have not previously received a commit from c.sender_id this round.

Effects on valid commit:

  • Append to receivedCommits.
  • If state is COMMIT_PHASE AND receivedCommits.length === Number(n_arbiters) (replaced with bigint equality), or if BigInt(receivedCommits.length) >= quorumThreshold(n_arbiters), then advance to REVEAL_PHASE (see §2.6 on transitions).

Invalid commit: silently dropped — the caller’s responsibility to schema-validate before delivering. The body asserts only structural identity (own-round + known sender + non-duplicate + signature valid).

2.4 receiveReveal(r)

Preconditions: r.msg_type === 'REVEAL'; r.round_id === round_id; r.sender_id is in arbiters; we have a stored Commit from r.sender_id; we have not previously received a reveal from r.sender_id this round; r.vote.signature verifies; the outer r.signature verifies.

Hash-binding check:

  • Compute expected = SHA-256(canonicalSerialize(r.vote) ++ r.salt).
  • If expected !== r.commit.commit_hash:
    • This is a liveness fault (the reveal does not honor the commit).
    • Call livenessSink.markLivenessFault(r.sender_id, round_id) if a sink is configured.
    • Record r.sender_id in livenessFaults.
    • Do NOT append to receivedReveals.
    • Do NOT trigger view-change — single mismatched reveal is below the equivocation bar.

On valid reveal:

  • Append to receivedReveals.
  • Advance toward VERIFY_PHASE per §2.6.

2.5 receiveViewChange(vc)

Preconditions: vc.msg_type === 'VIEW_CHANGE'; vc.round_id === round_id; vc.sender_id is in arbiters; vc.current_leader === leader; signature verifies; not a duplicate.

Effects:

  • Append to receivedViewChanges.
  • If BigInt(receivedViewChanges.length) >= quorumThreshold(n_arbiters):
    • Compute next_leader = selectLeader(arbiters, prev_merkle_root, round_id).
    • Update current_leader to next_leader.
    • Emit ζ event VIEW_CHANGE_ACCEPTED (see §2.7).
    • Transition to COMMIT_PHASE of the rotated round (the FSM restarts the commit phase under the new leader). Per consensus.md §158, the round_id stays the same but the leader is rotated.
    • Reset receivedCommits, receivedReveals, receivedViewChanges in private state.

2.6 Transition rules (the FSM)

From Event To Trigger condition
COMMIT_PHASE receiveCommit (own or peer) REVEAL_PHASE BigInt(commits) >= quorumThreshold(n_arbiters) OR clock - phaseStart > T_commit_phase (when timeout-driven, only if quorum already met)
COMMIT_PHASE tick — clock - phaseStart > T_commit_phase AND commits < quorum VIEW_CHANGE reason = 'timeout'; auto-broadcast my ViewChange
REVEAL_PHASE receiveReveal VERIFY_PHASE BigInt(reveals) >= quorumThreshold(n_arbiters)
REVEAL_PHASE tick — clock - phaseStart > T_reveal_phase AND reveals < quorum VIEW_CHANGE reason = 'timeout'
VERIFY_PHASE verify (auto on entry) COMPLETED largest group size >= quorum AND no double-vote detected
VERIFY_PHASE verify VIEW_CHANGE detectDoubleVote(...) non-empty → reason = 'equivocation_observed'
VERIFY_PHASE verify VIEW_CHANGE largest group size < quorum → reason = 'malformed_proposal'
VIEW_CHANGE receiveViewChange COMMIT_PHASE (with rotated leader) BigInt(view_changes) >= quorumThreshold(n_arbiters)
COMPLETED any COMPLETED terminal (idempotent)

Single-arbiter clause: when n_arbiters === 1n AND arbiters.length === 1:

  • begin() auto-runs Phase A through Phase C in a single call. The returned Commit is the same one the FSM treats as its own. The caller may invoke myReveal() and verify() immediately, or call tick() once to drive the FSM through REVEAL_PHASE → VERIFY_PHASE → COMPLETED. Each tick() advances by exactly one state. After 4 ticks (one for each state change), state is COMPLETED and quorumOutcome() is populated.
  • Equivalently: in single-arbiter mode, tick() from COMMIT_PHASE to COMPLETED requires exactly 3 ticks (one per state edge).

2.7 ζ emission

When a state transition is VIEW_CHANGE → COMMIT_PHASE (the view-change is accepted — quorum-many ViewChange messages received), the FSM emits exactly ONE VIEW_CHANGE_ACCEPTED event into zetaSink if configured.

Event shape:

{
  event_type: 'VIEW_CHANGE_ACCEPTED',
  round_id: <current round_id>,
  logical_clock: clock(),
  payload: {
    previous_leader: <previous>,
    next_leader: <selected by VRF>,
    reasons_observed: ['timeout', ...] (deduplicated, sorted),
    view_change_count: BigInt(receivedViewChanges.length).toString(),
    quorum_required: quorumThreshold(n_arbiters).toString(),
  },
}

When the FSM enters COMPLETED with a winning quorum, the FSM emits ONE QUORUM_REACHED event:

{
  event_type: 'QUORUM_REACHED',
  round_id: <round>,
  logical_clock: clock(),
  payload: {
    merkle_root: <hex>,
    rule_version_hash: <hex>,
    winning_voters: [...sorted...],
    quorum_size: <count as decimal string>,
  },
}

The ζ events are also mirrored in zetaEvents() for test inspection.

2.8 Liveness fault sink

Called from:

  • receiveReveal when hash-binding check fails.
  • tick() when REVEAL_PHASE times out (clock - phaseStart > T_reveal_phase) and a peer committed but did not reveal — every such arbiter is marked.

Callback signature: markLivenessFault(arbiter_id: string, round_id: bigint): void. The sink is fire-and-forget; the FSM does not consume any return value.

2.9 verify() — Phase C

Idempotent — calling multiple times produces the same result.

Logic:

  1. Build votes: Vote[] = receivedReveals.map(r => r.vote).
  2. For each unique r.sender_id, check detectDoubleVote(senderId, round_id, votes) — if any pair returned, trigger view-change with reason 'equivocation_observed'. Note: in a normal flow each arbiter contributes exactly one Reveal per round, so the detectDoubleVote check is a defense against the same arbiter slipping in two different signed Reveals. The actual cross-arbiter detection (a sender_id appears twice in votes array) is what detectDoubleVote scans.
  3. Group votes by voteGroupKey. Find the largest group.
  4. If BigInt(largestGroup.length) >= quorumThreshold(n_arbiters):
    • Build QuorumOutcome from largestGroup.
    • Emit QUORUM_REACHED event.
    • Transition to COMPLETED.
  5. Else:
    • Transition to VIEW_CHANGE with reason 'malformed_proposal'.
  6. If transitioning to VIEW_CHANGE, auto-broadcast my own ViewChange and call receiveViewChange(myVC).

2.10 triggerViewChange(reason)

External entry point for caller-observed conditions ('malformed_proposal' from schema failure, 'timeout' from proposer-failure timer). Constructs my ViewChange, signs it, and calls receiveViewChange(myVC). Idempotent against receivedViewChanges set.

§3. Determinism invariants

  • The FSM produces no side effects beyond:
    1. Returning Commit / Reveal / ViewChange from drivers.
    2. Calling zetaSink.emit(...) (at most twice per round).
    3. Calling livenessSink.markLivenessFault(...) (at most n times per round).
  • Two parallel runs with the same constructor args + same external message order produce byte-identical observable state. Tested in AC#26 (4-node BFT determinism) and AC#27 (single-arbiter determinism).
  • No Math.*, no Date.*, no setTimeout / setInterval, no performance.now, no process.hrtime, no Number(<expr>), no floating-point literals. Tested in AC#28 (forbidden-token scan).

§4. Error classes

export class RoundStateError extends Error {
  override readonly name = 'RoundStateError';
}

Thrown for:

  • Constructor precondition violations.
  • begin() called twice.
  • myReveal() called before REVEAL_PHASE.
  • Internal invariant violations (defensive — should not fire in well-formed callers).

Never thrown for invalid received messages — those are silently dropped (the caller is responsible for schema validation upstream).

§5. Test surface

src/__tests__/domains/consensus/round-state.test.ts covers 28 acceptance criteria across the following test groups. Each group maps to one or more describe blocks.

AC#1–AC#5 — Constants

  • AC#1: ROUND_STATES is ['COMMIT_PHASE', 'REVEAL_PHASE', 'VERIFY_PHASE', 'VIEW_CHANGE', 'COMPLETED'].
  • AC#2: T_round === 30000n.
  • AC#3: T_commit_phase === 10000n.
  • AC#4: T_reveal_phase === 10000n.
  • AC#5: T_timeout === 60000n.

AC#6–AC#9 — Phase A (commit)

  • AC#6: New FSM at COMMIT_PHASE.
  • AC#7: begin() returns a valid Commit with commit_hash = SHA-256(canonicalSerialize(myVote) ++ salt).
  • AC#8: After quorum-many commits received → REVEAL_PHASE.
  • AC#9: COMMIT_PHASE timeout (with < quorum commits) → VIEW_CHANGE with reason 'timeout'.

AC#10–AC#13 — Phase B (reveal)

  • AC#10: myReveal() after entering REVEAL_PHASE returns a valid Reveal.
  • AC#11: After quorum-many reveals → VERIFY_PHASE.
  • AC#12: Mismatched commit/reveal hash → liveness fault flagged, reveal dropped.
  • AC#13: REVEAL_PHASE timeout (with < quorum reveals) → VIEW_CHANGE with reason 'timeout'; non-revealing arbiters flagged as liveness fault.

AC#14–AC#17 — Phase C (verify)

  • AC#14: Happy path → COMPLETED with QuorumOutcome.
  • AC#15: detectDoubleVote non-empty → VIEW_CHANGE with reason 'equivocation_observed'.
  • AC#16: Largest group < quorum → VIEW_CHANGE with reason 'malformed_proposal'.
  • AC#17: verify() is idempotent (multiple calls do not duplicate ζ events or change outcome).

AC#18–AC#21 — View-change handler

  • AC#18: triggerViewChange('timeout') → my VC broadcast.
  • AC#19: After quorum-many VCs received → COMMIT_PHASE with rotated leader via VRF.
  • AC#20: Leader rotation calls selectLeader(arbiters, prev_merkle_root, round_id).
  • AC#21: View-change reasons accumulate (multiple peers can VC for different reasons; the FSM tracks them all and reports in payload.reasons_observed).

AC#22–AC#23 — Liveness sink

  • AC#22: Hash-binding mismatch calls markLivenessFault once with the offending arbiter id.
  • AC#23: REVEAL_PHASE timeout calls markLivenessFault for every committed-but-not-revealed arbiter.

AC#24–AC#25 — ζ emission

  • AC#24: VIEW_CHANGE_ACCEPTED event emitted on rotation accept; payload has previous_leader, next_leader, reasons_observed, view_change_count, quorum_required.
  • AC#25: QUORUM_REACHED event emitted on COMPLETED; payload has merkle_root, rule_version_hash, winning_voters, quorum_size.

AC#26 — 4-node BFT worked example

  • AC#26: n=4, A/B/C honest, D dissents on 0xCAFE…. Trace reaches COMPLETED with winning_voters = ['A','B','C'] and merkle_root = 0xab12….

AC#27 — Single-arbiter clause

  • AC#27: n=1 reaches COMPLETED in one external driver chain (begin() then myReveal() then verify()); quorumOutcome is populated; no view-change occurs.

AC#28 — Forbidden-token scan

  • AC#28: Source body of round-state.ts contains none of Math.<id>, Date.<id>, setTimeout, setInterval, setImmediate, process.hrtime, performance.now, Number(<expr>), dotted crypto.<id> (NAMED imports only), or floating-point literals.

§6. Out-of-scope (re-iterated from audit §8)

  • κ governance hooks for timer constants (later π slice).
  • λ.markLivenessFault stable wiring (later λ slice).
  • THOUGHT_TYPES schema widening (later trail slice).
  • BFT threshold signature aggregation (later η slice; ADR-003 open).
  • Persistence of round state (Phase 3.5+).

§7. Sign-off

Contract approved for execution. Proceed to packet (Step 3).


Back to top

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

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