P3.1.1 — Vote Message Types + Canonical Wire — Packet

Step 3 of the 5-step chain. The execution plan gating Step 4 (implementation).

§1. Files to ship

Path Purpose LOC est.
src/domains/consensus/messages.ts 5 typed message shapes + Ed25519 + κ-canonical helpers + Lamport clock ~360
src/__tests__/domains/consensus/messages.test.ts Roundtrip, determinism, signature, static scanner ~530

No other repo state touched (no doc edits except this packet+verification later; no source moves; no dependency changes).

§2. Module skeleton (messages.ts)

// §1. Module banner (~60 lines)
//   - JSDoc summary
//   - Canonical references (audit/contract/packet, consensus.md, version-hash.ts)
//   - Determinism self-scan declaration
// §2. Imports
//   - import { createHash, sign, verify, KeyObject } from 'node:crypto';
//   - import { canonicalize, CanonicalSerializationError } from '../rules/canonical.js';
// §3. Error class
//   - ConsensusSerializationError
// §4. Type exports
//   - VoteType, ViewChangeReason
//   - VoteTuple
//   - Vote, Commit, Reveal, ViewChange, EquivocationProof
//   - ConsensusMessage union
// §5. Lamport clock
//   - module-level __logicalClock = 0n
//   - nextLogical(), resetLogicalForTesting()
// §6. Canonical wire — Buffer rewriting + canonicalize
//   - rewriteBuffersToHex(value): unknown      (recursive walk)
//   - canonicalSerialize(msg): Buffer
// §7. Hash
//   - hashMessage(msg): Buffer
// §8. Sign / verify
//   - stripSignatureForSig(msg): unknown        (returns shallow copy without signature)
//   - signMessage(msg, privateKey): Buffer
//   - verifySignature(msg, publicKey): boolean
// §9. Equivocation construction
//   - buildEquivocationProof({...}): EquivocationProof

§3. Critical implementation details

3.1 Buffer pre-pass

function rewriteBuffersToHex(value: unknown): unknown {
  if (value === null) { return null; }
  if (Buffer.isBuffer(value)) {
    return value.toString('hex');   // lowercase, no prefix
  }
  if (Array.isArray(value)) {
    return value.map((v) => rewriteBuffersToHex(v));
  }
  if (typeof value === 'object') {
    const proto = Object.getPrototypeOf(value);
    if (proto === null || proto === Object.prototype) {
      const out: Record<string, unknown> = {};
      for (const k of Object.keys(value as Record<string, unknown>)) {
        out[k] = rewriteBuffersToHex((value as Record<string, unknown>)[k]);
      }
      return out;
    }
    // Non-plain object that's not a Buffer — wrap in our error.
    throw new ConsensusSerializationError(
      `unrepresentable object type: ${(value as object).constructor?.name ?? 'unknown'}`,
    );
  }
  return value;  // bigint, string, number, boolean, undefined, symbol, function — let canonicalize judge
}

3.2 canonicalSerialize

export function canonicalSerialize(msg: ConsensusMessage | Vote): Buffer {
  let rewritten: unknown;
  try {
    rewritten = rewriteBuffersToHex(msg);
  } catch (err) {
    if (err instanceof ConsensusSerializationError) { throw err; }
    throw new ConsensusSerializationError(
      `Buffer rewrite failed: ${(err as Error).message}`,
    );
  }
  let body: string;
  try {
    body = canonicalize(rewritten);
  } catch (err) {
    if (err instanceof CanonicalSerializationError) {
      throw new ConsensusSerializationError(
        `canonical encoding failed: ${err.message}`,
      );
    }
    throw err;
  }
  return Buffer.from(body, 'utf8');
}

3.3 signMessage / verifySignature

function stripSignatureForSig(msg: Vote | Commit | Reveal | ViewChange): unknown {
  // Shallow copy — top-level signature is the only field stripped.
  // For Reveal, the nested vote.signature stays in place (it is what was
  // committed; signing the Reveal wraps that).
  const copy: Record<string, unknown> = { ...(msg as Record<string, unknown>) };
  delete copy.signature;
  return copy;
}

export function signMessage(
  msg: Vote | Commit | Reveal | ViewChange,
  privateKey: KeyObject,
): Buffer {
  if ((msg as { msg_type?: string }).msg_type === 'EQUIVOCATION_PROOF') {
    throw new ConsensusSerializationError(
      'EquivocationProof is not directly signed — its integrity is in the embedded votes',
    );
  }
  const stripped = stripSignatureForSig(msg);
  const body = canonicalSerializeRaw(stripped);  // sub-path that accepts pre-rewritten input
  return sign(null, body, privateKey);
}

Implementation note: signMessage needs canonical bytes of the WITHOUT-signature form, but canonicalSerialize’s public signature takes a typed ConsensusMessage | Vote. We factor a canonicalSerializeUnchecked(value: unknown): Buffer helper that runs the same pre-pass + canonicalize, and canonicalSerialize delegates to it. The public/private split exists so type-checking on the public API stays strict.

3.4 buildEquivocationProof

export function buildEquivocationProof(args: {
  attacker_id: string;
  epoch: bigint;
  round_id: bigint;
  signed_vote_a: Vote;
  signed_vote_b: Vote;
  submitter: string;
}): EquivocationProof {
  const { signed_vote_a: a, signed_vote_b: b } = args;
  if (a.sender_id !== args.attacker_id || b.sender_id !== args.attacker_id) {
    throw new ConsensusSerializationError(
      'both votes must be signed by the attacker',
    );
  }
  if (a.round_id !== args.round_id || b.round_id !== args.round_id) {
    throw new ConsensusSerializationError(
      'both votes must be on the same round',
    );
  }
  if (
    Buffer.compare(a.merkle_root, b.merkle_root) === 0 &&
    Buffer.compare(a.rule_version_hash, b.rule_version_hash) === 0
  ) {
    throw new ConsensusSerializationError(
      'the two votes must differ in tuple — identical tuples are not equivocation',
    );
  }
  const body = canonicalSerializeUnchecked({ a, b });
  const evidence_hash = createHash('sha256').update(body).digest();
  return {
    msg_type: 'EQUIVOCATION_PROOF',
    attacker_id: args.attacker_id,
    epoch: args.epoch,
    round_id: args.round_id,
    signed_vote_a: a,
    signed_vote_b: b,
    submitter: args.submitter,
    evidence_hash,
  };
}

3.5 Lamport clock

let __logicalClock = 0n;

export function nextLogical(): bigint {
  __logicalClock = __logicalClock + 1n;
  return __logicalClock;
}

export function resetLogicalForTesting(): void {
  __logicalClock = 0n;
}

Test-only export resetLogicalForTesting is documented in the function JSDoc as “Test-only — NOT for production paths.” ESLint does not flag the leading underscore on the module-level variable.

§4. Test plan (messages.test.ts)

Group 1 — types compile + import surface

  • Import all 5 type names + all 6 function names; ensure module loads with no I/O.

Group 2 — Lamport clock

  • Reset → nextLogical() returns 1n.
  • 100 sequential calls — strictly increasing.
  • No clock read (covered by static scanner Group 9).

Group 3 — canonicalSerialize roundtrip × 5 types

  • Build each of Vote, Commit, Reveal, ViewChange, EquivocationProof with fixture data.
  • Run canonicalSerialize(msg) → parse with JSON.parse(bytes.toString('utf8')) → assert structural equality (after rehydrating bigints from strings and Buffers from hex).
  • Assert byte length > 0 + UTF-8 round-trippable.

Group 4 — determinism × 1000 iterations

  • canonicalSerialize(vote) 1000 times — first bytes vs each subsequent iteration: Buffer.compare(...) === 0.
  • hashMessage(vote) 1000 times — same digest.

Group 5 — Ed25519 sign/verify roundtrip

  • Per signable type (Vote, Commit, Reveal, ViewChange):
    • Generate keypair via generateKeyPairSync('ed25519').
    • Build message with placeholder signature Buffer.alloc(0).
    • sig = signMessage(msgWithoutSig, privKey) — note: signMessage strips signature internally; passing a placeholder is fine.
    • Set msg.signature = sig.
    • verifySignature(msg, pubKey) returns true.
    • Generate a SECOND keypair; verifySignature(msg, otherPubKey) returns false.

Group 6 — EquivocationProof construction

  • Build 2 conflicting Votes (same round_id, different merkle_root).
  • proof = buildEquivocationProof({...}).
  • Assert proof.evidence_hash is 32 bytes, deterministic over 1000 iterations.
  • Re-verify both embedded votes’ signatures separately.
  • Negative tests:
    • Same merkle_root + same rule_version_hash → throws.
    • sender_id mismatch on either vote → throws.
    • round_id mismatch → throws.

Group 7 — Buffer rewriting edge cases

  • Buffer with codepoint < 0x20 bytes: hex encodes safely (no JSON escapes needed since hex output is ASCII).
  • Empty Buffer: '' (zero-length hex string).
  • Buffer at deep nesting: still encoded.

Group 8 — signMessage / verifySignature on EquivocationProof

  • Both throw ConsensusSerializationError.

Group 9 — Static scanner

const FORBIDDEN_PATTERNS: readonly RegExp[] = [
  /\bMath\.[A-Za-z_]\w*/g,
  /\bDate\.[A-Za-z_]\w*/g,
  /\bnew\s+Date\b/g,
  /\b(?:setTimeout|setInterval|setImmediate)\b/g,
  /\b(?:fetch|XMLHttpRequest)\b/g,
  /\bfrom\s+['"](?:fs|node:fs)['"]/g,
  /\bcrypto\.[A-Za-z_]\w*/g,            // catches member-access form; named imports OK
  /\bprocess\.(?:hrtime|nextTick)\b/g,
  /\bawait\b/g,
  /\basync\s+(?:function|\()/g,
  /(?<![0-9n])\b\d+\.\d+\b/g,
  /\[native code\]/g,
];

Scan readFileSync('src/domains/consensus/messages.ts', 'utf-8') AFTER stripping JSDoc block comments (/\*\*[\s\S]*?\*\//g) and line comments (/\/\/.*$/gm). Assert no matches.

Documentation strings ARE stripped — JSDoc references like crypto.sign in a doc-comment example wouldn’t trigger. Source-body references would.

Group 10 — Phase-0 schema fixture (AC #11)

const SCHEMA_FIT = {
  round_id: 'INTEGER (bigint)',
  sender_id: 'TEXT (string)',
  merkle_root: 'BLOB (Buffer)',
  rule_version_hash: 'BLOB (Buffer)',
  signature: 'BLOB (Buffer)',
} as const;

Assert each Vote field maps to the expected SQL type. Pure documentation fixture — no DB call.

Group 11 — Spec-illustrated worked example

Build the 4-node BFT example from consensus.md §Worked example: node A signs with its key, B signs differently, B → equivocation. Assert the resulting EquivocationProof decodes / re-verifies.

Group 12 — VoteType enumeration

  • ACCEPT / REJECT / ABSTAIN literals match spec exactly.
  • ViewChangeReason: timeout / equivocation_observed / malformed_proposal literals match.

Total expected tests

~28 tests across 12 groups. Within the prompt’s stated +20–30 range.

§5. Implementation order

  1. §2 + §3 in messages.ts — types and error class (no logic yet, just shapes).
  2. §5 Lamport clock — trivial; lets tests start.
  3. §6 + §3.2 canonicalSerialize — the workhorse. Test Group 3 & 4 unblock once this exists.
  4. §7 hashMessage — one-liner. Test Group 4 hash determinism.
  5. §8 sign/verify — Test Group 5 + Group 8.
  6. §9 buildEquivocationProof — Test Group 6.
  7. Static scanner Group 9 — should pass first try since we never write crypto.<member> or Math.<member>.

§6. Gate plan

After implementation:

cd .worktrees/claude/p3-1-1-vote-messages
npm run build                    # tsc clean
npm run lint                     # eslint clean
npm test                          # +28 tests, baseline 2647 → 2675-ish

A pre-existing failure in src/__tests__/domains/reputation/tools.test.ts:635 was observed at baseline (unrelated SQL-init issue from R89 λ work). It is not introduced by this slice; verification doc will record it as a baseline anomaly.

§7. Risk register

Risk Probability Mitigation
Buffer.isBuffer returns false for some edge-case (e.g. SlowBuffer) Low Node ≥ 20 unified all Buffers; we test with Buffer.from(hex, 'hex') only.
κ canonical rejects post-rewrite output (some shadow non-plain object snuck through) Low Pre-pass returns either string, bigint, plain-object, or array — all canonical-safe.
Ed25519 KeyObject vs raw key bytes mismatch Medium We type the parameters as KeyObject and document generateKeyPairSync('ed25519') as the construction path.
Module-level Lamport counter pollutes test isolation Medium resetLogicalForTesting() documented; every test that depends on counter value calls it.
noUncheckedIndexedAccess complains about Buffer[i] reads Low We never index Buffers; we always .toString('hex') or Buffer.compare(...).
exactOptionalPropertyTypes complains about partial mocks in tests Low All shapes have required fields; tests construct full objects.

§8. Out-of-band notes

  • ADR-003 is PROPOSED, not gating. We deliberately encode our message format on its own canonical wire — when a BFT library is picked, it must adapt to OUR canonical format (not the other way) because κ’s rule_version_hash MUST be in the signed tuple per spec.
  • The prompt’s §gotcha “Buffer vs string in canonical — κ canonical serializes Buffer as hex by default” is inaccurate as written — κ canonical (src/domains/rules/canonical.ts) REJECTS Buffer entirely (it is non-plain object). Our slice supplies the Buffer→hex pre-pass. This is documented in audit §3 and contract §3.1, and is the principled interpretation of the gotcha intent.

Back to top

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

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