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:

  1. 5 typed message shapesVote, Commit, Reveal, ViewChange, EquivocationProof (plus the supporting union ConsensusMessage + auxiliary types VoteTuple, VoteType, ViewChangeReason).
  2. HelperscanonicalSerialize, signMessage, verifySignature, hashMessage, nextLogical, resetLogicalForTesting.
  3. Error classConsensusSerializationError extends Error.

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:" form per spec.
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:

  1. Walk msg recursively, producing a plain-object copy with every Buffer value replaced by value.toString('hex') (lowercase hex string, no 0x prefix).
  2. Pass the plain-object copy through κ’s canonicalize() (src/domains/rules/canonical.ts).
  3. 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 (no n suffix).
  • Strings escaped using canonical 7-char + \u00XX lowercase 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):

  1. Compute bodyForSig = canonicalSerialize(stripSignature(msg)) — where stripSignature returns a copy of msg with the top-level signature field deleted. (For Vote, removes signature. For Commit/Reveal/ViewChange, removes top-level signature. For EquivocationProof, see §3.4.)
  2. Call crypto.sign('ed25519', bodyForSig, privateKey) (PURE mode — Ed25519 PH unsupported in Node and not desired).
  3. Return the resulting Buffer (signature length: 64 bytes for Ed25519).

verifySignature(msg, publicKey):

  1. Compute bodyForSig identically.
  2. Call crypto.verify('ed25519', bodyForSig, publicKey, msg.signature).
  3. 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, ...) throws ConsensusSerializationError.
  • verifySignature(equivocationProof, ...) throws ConsensusSerializationError.

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:

  1. Compute body = canonicalSerialize(msg) — INCLUDES the signature field (unlike signMessage).
  2. 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).
  • resetLogicalForTesting exists so tests can deterministically seed timestamp_logical: 1n for 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_id AND signed_vote_b.sender_id === args.attacker_id
  • signed_vote_a.round_id === signed_vote_b.round_id === args.round_id
  • The two tuples differ (merkle_root or rule_version_hash differs; if both match, throws — that’s not equivocation)
  • Computes evidence_hash as SHA-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, ...) or verifySignature(equivocationProof, ...)
  • buildEquivocationProof precondition violations
  • Underlying CanonicalSerializationError from κ is propagated WRAPPED — the caller sees ConsensusSerializationError with a cause linking 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 have msg_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, no 0x); canonical form is what gets signed
  • EquivocationProof carries 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_type on each of the 4 wire types
  • Schema-ready columns documented (§5) in test fixture

Back to top

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

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