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
arbitersincludesarbiter_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 checksBigInt(arbiters.length) === n_arbiters. - Validates that
leaderis inarbiters. - Validates that
publicKeyByArbiterhas an entry for every arbiter. - Validates that
prev_merkle_root.length === 32. - Initial state:
COMMIT_PHASE. - Internal clock: caller-provided
clockor a private counter that starts at0nand ticks +1n per FSM transition. - Internal RNG: caller-provided
rngSourceorrandomBytes(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()snapshotsignature = signMessage(bareVote, privKey)
- Compute
commit_hash = SHA-256(canonicalSerialize(myVote) ++ salt). - Build my
Commit:msg_type = 'COMMIT'round_idsender_id = arbiter_idcommit_hashtimestamp_logical = clock()signatureover 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_PHASEANDreceivedCommits.length === Number(n_arbiters)(replaced with bigint equality), or ifBigInt(receivedCommits.length) >= quorumThreshold(n_arbiters), then advance toREVEAL_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_idinlivenessFaults. - 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_leadertonext_leader. - Emit ζ event
VIEW_CHANGE_ACCEPTED(see §2.7). - Transition to
COMMIT_PHASEof 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,receivedViewChangesin private state.
- Compute
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 invokemyReveal()andverify()immediately, or calltick()once to drive the FSM throughREVEAL_PHASE → VERIFY_PHASE → COMPLETED. Eachtick()advances by exactly one state. After 4 ticks (one for each state change), state isCOMPLETEDandquorumOutcome()is populated.- Equivalently: in single-arbiter mode,
tick()fromCOMMIT_PHASEtoCOMPLETEDrequires 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:
receiveRevealwhen hash-binding check fails.tick()whenREVEAL_PHASEtimes 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:
- Build
votes: Vote[] = receivedReveals.map(r => r.vote). - For each unique
r.sender_id, checkdetectDoubleVote(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 invotesarray) is what detectDoubleVote scans. - Group
votesbyvoteGroupKey. Find the largest group. - If
BigInt(largestGroup.length) >= quorumThreshold(n_arbiters):- Build
QuorumOutcomefromlargestGroup. - Emit
QUORUM_REACHEDevent. - Transition to
COMPLETED.
- Build
- Else:
- Transition to
VIEW_CHANGEwith reason'malformed_proposal'.
- Transition to
- If transitioning to
VIEW_CHANGE, auto-broadcast my ownViewChangeand callreceiveViewChange(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:
- Returning
Commit/Reveal/ViewChangefrom drivers. - Calling
zetaSink.emit(...)(at most twice per round). - Calling
livenessSink.markLivenessFault(...)(at most n times per round).
- Returning
- 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)andAC#27 (single-arbiter determinism). - No
Math.*, noDate.*, nosetTimeout/setInterval, noperformance.now, noprocess.hrtime, noNumber(<expr>), no floating-point literals. Tested inAC#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 beforeREVEAL_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_STATESis['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 validCommitwithcommit_hash = SHA-256(canonicalSerialize(myVote) ++ salt). - AC#8: After quorum-many commits received →
REVEAL_PHASE. - AC#9:
COMMIT_PHASEtimeout (with < quorum commits) →VIEW_CHANGEwith reason'timeout'.
AC#10–AC#13 — Phase B (reveal)
- AC#10:
myReveal()after enteringREVEAL_PHASEreturns a validReveal. - AC#11: After quorum-many reveals →
VERIFY_PHASE. - AC#12: Mismatched commit/reveal hash → liveness fault flagged, reveal dropped.
- AC#13:
REVEAL_PHASEtimeout (with < quorum reveals) →VIEW_CHANGEwith reason'timeout'; non-revealing arbiters flagged as liveness fault.
AC#14–AC#17 — Phase C (verify)
- AC#14: Happy path →
COMPLETEDwithQuorumOutcome. - AC#15:
detectDoubleVotenon-empty →VIEW_CHANGEwith reason'equivocation_observed'. - AC#16: Largest group < quorum →
VIEW_CHANGEwith 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_PHASEwith 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
markLivenessFaultonce with the offending arbiter id. - AC#23:
REVEAL_PHASEtimeout callsmarkLivenessFaultfor every committed-but-not-revealed arbiter.
AC#24–AC#25 — ζ emission
- AC#24:
VIEW_CHANGE_ACCEPTEDevent emitted on rotation accept; payload hasprevious_leader,next_leader,reasons_observed,view_change_count,quorum_required. - AC#25:
QUORUM_REACHEDevent emitted on COMPLETED; payload hasmerkle_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 on0xCAFE…. Trace reachesCOMPLETEDwithwinning_voters = ['A','B','C']andmerkle_root = 0xab12….
AC#27 — Single-arbiter clause
- AC#27:
n=1reachesCOMPLETEDin one external driver chain (begin()thenmyReveal()thenverify());quorumOutcomeis populated; no view-change occurs.
AC#28 — Forbidden-token scan
- AC#28: Source body of
round-state.tscontains none ofMath.<id>,Date.<id>,setTimeout,setInterval,setImmediate,process.hrtime,performance.now,Number(<expr>), dottedcrypto.<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).