P3.1.1 — Vote Message Types + Canonical Wire — Contract
Step 2 of the 5-step chain. The behavioral contract every downstream θ task depends on.
§1. Module summary
src/domains/consensus/messages.ts exports:
- 5 typed message shapes —
Vote,Commit,Reveal,ViewChange,EquivocationProof(plus the supporting unionConsensusMessage+ auxiliary typesVoteTuple,VoteType,ViewChangeReason). - Helpers —
canonicalSerialize,signMessage,verifySignature,hashMessage,nextLogical,resetLogicalForTesting. - Error class —
ConsensusSerializationErrorextendsError.
The module is pure synchronous TypeScript — no I/O, no clock reads (other than the Lamport counter, which is fed externally), no await, no Math.random. Imports limited to node:crypto named imports + κ’s canonical.ts.
§2. Type shapes (canonical TypeScript)
/**
* The 3-tuple that every θ vote signs over.
* Per consensus.md §What the arbiters vote on, this is the unit of agreement.
*/
export interface VoteTuple {
readonly round_id: bigint;
readonly merkle_root: Buffer; // SHA-256 of the η root being voted on
readonly rule_version_hash: Buffer; // SHA-256 of canonical κ ruleset
}
/** Per consensus.md §Five finality levels — votes themselves are typed. */
export type VoteType = 'ACCEPT' | 'REJECT' | 'ABSTAIN';
/** Per consensus.md §View-change procedure §155. */
export type ViewChangeReason = 'timeout' | 'equivocation_observed' | 'malformed_proposal';
/**
* A single arbiter's signed vote.
*
* Note: Vote carries no `msg_type` discriminator. It is the LEAF type carried
* inside Reveal.vote and EquivocationProof.signed_vote_a/b. Top-level wire
* messages always carry a discriminator; Vote is wrapped, not bare.
*/
export interface Vote extends VoteTuple {
readonly sender_id: string; // soul:<sha256-of-pubkey>
readonly vote_type: VoteType;
readonly timestamp_logical: bigint; // Lamport clock
readonly signature: Buffer; // Ed25519 sig over canonicalSerialize(thisMessage withoutSignature)
}
/** Commit phase (consensus.md §Voting protocol Phase A). */
export interface Commit {
readonly msg_type: 'COMMIT';
readonly round_id: bigint;
readonly sender_id: string;
readonly commit_hash: Buffer; // SHA-256(canonical(vote) || salt)
readonly timestamp_logical: bigint;
readonly signature: Buffer;
}
/** Reveal phase (consensus.md §Voting protocol Phase B). */
export interface Reveal {
readonly msg_type: 'REVEAL';
readonly round_id: bigint;
readonly sender_id: string;
readonly vote: Vote; // the previously committed vote (must match commit_hash)
readonly salt: Buffer; // the salt used to compute commit_hash
readonly timestamp_logical: bigint;
readonly signature: Buffer;
}
/** View-change message (consensus.md §View-change procedure). */
export interface ViewChange {
readonly msg_type: 'VIEW_CHANGE';
readonly round_id: bigint;
readonly sender_id: string;
readonly current_leader: string;
readonly reason: ViewChangeReason;
readonly timestamp_logical: bigint;
readonly signature: Buffer;
}
/** Equivocation proof (consensus.md §Equivocation proof structure). */
export interface EquivocationProof {
readonly msg_type: 'EQUIVOCATION_PROOF';
readonly attacker_id: string;
readonly epoch: bigint;
readonly round_id: bigint;
readonly signed_vote_a: Vote;
readonly signed_vote_b: Vote; // must differ from signed_vote_a in tuple
readonly submitter: string;
readonly evidence_hash: Buffer; // SHA-256 over canonical( {a, b} )
}
/** Top-level wire message union. */
export type ConsensusMessage = Commit | Reveal | ViewChange | EquivocationProof;
Field summary — every shape carries:
| Field | Type | Required | Notes |
|---|---|---|---|
msg_type (top-level) |
string literal | Yes | Discriminator. Absent on bare Vote (Vote is a leaf type). |
round_id |
bigint |
Yes | Always present on every shape. |
sender_id / submitter / attacker_id |
string |
Yes | “soul: |
signature |
Buffer |
Yes (except EquivocationProof) | Ed25519. EquivocationProof’s “signature” is the embedded votes’ signatures. |
timestamp_logical |
bigint |
Yes (top-level wire types) | Lamport — never wall-clock. |
§3. Canonical wire format
3.1 Serialization pipeline
canonicalSerialize(msg: ConsensusMessage | Vote): Buffer:
- Walk
msgrecursively, producing a plain-object copy with everyBuffervalue replaced byvalue.toString('hex')(lowercase hex string, no0xprefix). - Pass the plain-object copy through κ’s
canonicalize()(src/domains/rules/canonical.ts). - Encode the resulting string as UTF-8 →
Buffer.
3.2 Determinism guarantee
For any well-formed input msg, two calls to canonicalSerialize(msg) in the same or distinct Node ≥ 20 processes produce byte-identical output. Inherited from κ P1.5.4:
- Object keys sorted by UTF-16 code unit (locale-independent).
- bigint → decimal-string
toString(nonsuffix). - Strings escaped using canonical 7-char +
\u00XXlowercase hex for codepoints < 0x20. - Arrays in source order.
- Whitespace eliminated.
Plus this slice’s contribution:
- Buffer →
.toString('hex')lowercase, length 2× byte count.
3.3 Signature scope
signMessage(msg, privateKey):
- Compute
bodyForSig = canonicalSerialize(stripSignature(msg))— wherestripSignaturereturns a copy ofmsgwith the top-levelsignaturefield deleted. (ForVote, removessignature. ForCommit/Reveal/ViewChange, removes top-levelsignature. ForEquivocationProof, see §3.4.) - Call
crypto.sign('ed25519', bodyForSig, privateKey)(PURE mode — Ed25519 PH unsupported in Node and not desired). - Return the resulting
Buffer(signature length: 64 bytes for Ed25519).
verifySignature(msg, publicKey):
- Compute
bodyForSigidentically. - Call
crypto.verify('ed25519', bodyForSig, publicKey, msg.signature). - Return the boolean result.
3.4 EquivocationProof signature handling
EquivocationProof has no top-level signature field. The embedded signed_vote_a.signature and signed_vote_b.signature are the cryptographic anchors. signMessage/verifySignature on EquivocationProof return early with an error — equivocation proofs are constructed (not signed) and their integrity is the sum of their two contained Vote signatures plus the evidence_hash.
Specifically:
signMessage(equivocationProof, ...)throwsConsensusSerializationError.verifySignature(equivocationProof, ...)throwsConsensusSerializationError.
To verify an EquivocationProof: caller invokes verifySignature(proof.signed_vote_a, attackerPubKey) AND verifySignature(proof.signed_vote_b, attackerPubKey) AND checks that the two votes’ tuples differ AND that the evidence_hash matches the canonical hash of {a: signed_vote_a, b: signed_vote_b}.
3.5 Hash scope
hashMessage(msg: ConsensusMessage | Vote): Buffer:
- Compute
body = canonicalSerialize(msg)— INCLUDES the signature field (unlike signMessage). - Return
crypto.createHash('sha256').update(body).digest()(32 bytes).
This is what evidence_hash is built from for EquivocationProof, and what commit_hash is built from for Commit (with a salt suffix).
3.6 Commit hash construction
commit_hash = SHA-256(canonicalSerialize(vote) || salt) per spec §74.
This is a caller responsibility, not a function in this module — the caller composes the Vote, then computes the hash. Our hashMessage(vote) returns the SHA-256 over canonical bytes WITHOUT salt; callers append the salt to form commit_hash. Helper omitted to keep the module surface small; documented in §6.
§4. Lamport clock
Module-level bigint counter:
let __logicalClock = 0n;
export function nextLogical(): bigint {
__logicalClock = __logicalClock + 1n;
return __logicalClock;
}
/** Test-only — resets the counter to 0n. NOT for production paths. */
export function resetLogicalForTesting(): void {
__logicalClock = 0n;
}
- Increments monotonically per call.
- Never reads any clock (per spec §187: “Wall-clock is never part of the signed payload”).
- Single-process: process-global. Multi-process / cross-host: caller must merge clocks via standard Lamport-merge rules (
local = max(local, remote) + 1n); P3.1.1 does NOT ship the merge logic — it belongs to the gossip layer (P3.3.x). resetLogicalForTestingexists so tests can deterministically seedtimestamp_logical: 1nfor the first call.
§5. Schema-fit fixture (AC #11)
Per consensus.md §Phase 0 posture §197:
CREATE TABLE mcp_consensus_votes (
round_id BIGINT NOT NULL,
arbiter_id TEXT NOT NULL, -- maps to Vote.sender_id
merkle_root BLOB NOT NULL, -- maps to Vote.merkle_root
rule_version_hash BLOB NOT NULL, -- maps to Vote.rule_version_hash
signed BLOB NOT NULL, -- maps to Vote.signature
threshold_count INTEGER NOT NULL, -- aggregate (caller computes from voter set)
finality_level TEXT NOT NULL CHECK (finality_level IN
('PENDING','SOFT','QUORUM','HARD','ABSOLUTE'))
);
A test fixture in messages.test.ts declares this mapping in a static as const object; the test asserts that every Vote field maps to a column with a compatible JS type (bigint↔INTEGER/BIGINT, Buffer↔BLOB, string↔TEXT).
§6. Function signatures (final)
export function canonicalSerialize(msg: ConsensusMessage | Vote): Buffer;
export function signMessage(
msg: Vote | Commit | Reveal | ViewChange,
privateKey: KeyObject,
): Buffer;
export function verifySignature(
msg: Vote | Commit | Reveal | ViewChange,
publicKey: KeyObject,
): boolean;
export function hashMessage(msg: ConsensusMessage | Vote): Buffer;
export function nextLogical(): bigint;
export function resetLogicalForTesting(): void;
/** Convenience constructor — fills computed fields. */
export function buildEquivocationProof(args: {
attacker_id: string;
epoch: bigint;
round_id: bigint;
signed_vote_a: Vote;
signed_vote_b: Vote;
submitter: string;
}): EquivocationProof;
buildEquivocationProof enforces the spec preconditions:
signed_vote_a.sender_id === args.attacker_idANDsigned_vote_b.sender_id === args.attacker_idsigned_vote_a.round_id === signed_vote_b.round_id === args.round_id- The two tuples differ (
merkle_rootorrule_version_hashdiffers; if both match, throws — that’s not equivocation) - Computes
evidence_hashasSHA-256(canonicalSerialize({a: signed_vote_a, b: signed_vote_b}))where the sub-object follows κ canonical sorting (so{a, b}→{a, b}post-canonicalize, deterministic regardless of construction order).
Violation throws ConsensusSerializationError.
§7. Error taxonomy
export class ConsensusSerializationError extends Error {
override readonly name = 'ConsensusSerializationError';
constructor(message: string) {
super(message);
}
}
Thrown on:
- Buffer-rewriting pre-pass encountering an unrepresentable value
signMessage(equivocationProof, ...)orverifySignature(equivocationProof, ...)buildEquivocationProofprecondition violations- Underlying
CanonicalSerializationErrorfrom κ is propagated WRAPPED — the caller seesConsensusSerializationErrorwith acauselinking the underlying error (so the consensus layer presents one error class to its callers).
§8. Invariants (testable)
| ID | Invariant | Test ref |
|---|---|---|
| I1 | canonicalSerialize is bit-identical across N ≥ 2 calls on the same input |
determinism — 1000 iter |
| I2 | hashMessage is bit-identical across N ≥ 2 calls on the same input |
determinism — 1000 iter |
| I3 | signMessage(msg, privKey) then verifySignature(msg', pubKey) returns true if msg' is the signed msg |
roundtrip — all 4 signable types |
| I4 | verifySignature(msg', WRONG_pubKey) returns false |
mismatch — 1 per signable type |
| I5 | nextLogical() returns strictly increasing bigints over N calls |
strict monotonicity — 100 calls |
| I6 | buildEquivocationProof throws on identical tuples |
precondition test |
| I7 | buildEquivocationProof evidence_hash is bit-identical across N construction calls |
determinism (via I1+I2) |
| I8 | Source messages.ts contains no forbidden tokens (Math., Date., await, async, Math.random, Date.now, new Date, setTimeout, fetch, process.hrtime, [native code]) |
static scanner |
| I9 | Roundtrip: build → canonicalSerialize → parse-via-JSON → structural-equality (after re-coercing bigint and Buffer) | 5 types |
| I10 | Phase-0 schema fit: Vote field types align with mcp_consensus_votes columns |
fixture test |
§9. Out-of-scope (re-asserted from audit)
- Quorum threshold computation (P3.1.2)
- Vote aggregation / threshold signatures (P3.2.x)
- Gossip transport / dedup (P3.3.x)
- VRF leader selection (P3.6.x)
- Persistence — this slice is pure types + helpers; no DB writes
- Time anchors (S06 §Signed time anchors)
- Equivocation penalty application (λ reputation domain, P3.5.x)
§10. Acceptance criteria roll-up (from prompt §Acceptance criteria + §Verification checklist)
- 5 message types exported:
Vote,Commit,Reveal,ViewChange,EquivocationProof(Vote is bare leaf; 4 wire types havemsg_type) - All wire messages carry the 3-tuple (where applicable) +
sender_id+signature+timestamp_logical(bigint Lamport) - Vote types:
ACCEPT,REJECT,ABSTAIN - All messages signed Ed25519; sign+verify roundtrip tested
- Canonical JSON: deterministic key order (alphabetical UTF-16), no whitespace, bit-identical across calls
Buffer→ hex string (lowercase, no0x); canonical form is what gets signedEquivocationProofcarries 2 conflicting signed Votes +attacker_id,evidence_hash,submitter- Hash determinism: SHA-256 over canonical bytes — same digest across 1000 iterations
- No
Date.now(),Math.random(), or wall-clock in module - Discriminator field
msg_typeon each of the 4 wire types - Schema-ready columns documented (§5) in test fixture