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,EquivocationProofwith fixture data. - Run
canonicalSerialize(msg)→ parse withJSON.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.
- Generate keypair via
Group 6 — EquivocationProof construction
- Build 2 conflicting Votes (same round_id, different merkle_root).
proof = buildEquivocationProof({...}).- Assert
proof.evidence_hashis 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/ABSTAINliterals match spec exactly.- ViewChangeReason:
timeout/equivocation_observed/malformed_proposalliterals match.
Total expected tests
~28 tests across 12 groups. Within the prompt’s stated +20–30 range.
§5. Implementation order
- §2 + §3 in
messages.ts— types and error class (no logic yet, just shapes). - §5 Lamport clock — trivial; lets tests start.
- §6 + §3.2 canonicalSerialize — the workhorse. Test Group 3 & 4 unblock once this exists.
- §7 hashMessage — one-liner. Test Group 4 hash determinism.
- §8 sign/verify — Test Group 5 + Group 8.
- §9 buildEquivocationProof — Test Group 6.
- Static scanner Group 9 — should pass first try since we never write
crypto.<member>orMath.<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_hashMUST 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.