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) | REUSE — Vote, Commit, Reveal, ViewChange, canonicalSerialize, hashMessage, signMessage, verifySignature, nextLogical, resetLogicalForTesting |
src/domains/consensus/quorum.ts |
Yes (P3.1.2 #236) | REUSE — quorumThreshold, hasQuorum, detectDoubleVote, voteGroupKey |
src/domains/consensus/vrf-stub.ts |
Yes (P3.6.1 #238) | REUSE — selectLeader(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_recordinterface … setthought_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 = 30000nT_commit_phase = 10000nT_reveal_phase = 10000nT_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 over0xCAFE…and D reveals0xCAFE…faithfully). After all 4 reveals received → VERIFY_PHASE. - t=2 (VERIFY_PHASE): The 4 revealed Votes are grouped by
voteGroupKey. The0xab12…group has 3 members; the0xCAFE…group has 1.hasQuorum(group_largest, n=4)is true for0xab12…(3 >= 3). Aggregate signatures from {A, B, C} → threshold σ; emit QUORUM event withmerkle_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>— noMath.random,Math.floor, etc.Date.<id>— noDate.now,new Date(...),Date.UTCsetTimeout,setInterval,setImmediate— no wall-clock waitsprocess.hrtime,performance.now— no high-resolution clocksNumber(<expr>)— no implicit-coercion to Numbercrypto.<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, noconsole, 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 noPromise/ nosetTimeout.
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_TYPESto 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.