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

Step 1 of the 5-step chain (CLAUDE.md §6). Phase 3 θ Wave 1 — the foundational slice every downstream θ task depends on.

§1. Surface inventory at base SHA 86a98c01

Path Exists? Role
src/domains/consensus/ No — to create New θ domain directory
src/domains/consensus/messages.ts No — to create Typed message shapes + Ed25519 helpers + canonical wire
src/__tests__/domains/consensus/messages.test.ts No — to create Roundtrip, determinism, signature verification
src/domains/rules/canonical.ts Yes (κ P1.5.4 — shipped 799e70a9) REUSEcanonicalize() for JSON encoding
src/domains/rules/version-hash.ts Yes (κ P1.5.1 — shipped 0150dcd1) REUSEcomputeVersionHash() format reference (no direct call; we accept Buffer hashes from upstream)
src/domains/rules/determinism.ts Yes (κ R83.A) Reference forbidden-op scanner pattern
src/__tests__/domains/rules/determinism.test.ts Yes Reference for static scanner test (Group 12 corpus self-scan); we replicate the pattern locally for messages.ts
src/domains/reputation/ Yes (λ Phase 2) Sibling domain; reference for module conventions
src/domains/proof/ Yes (η Phase 0) Sibling domain; reference for Buffer-handling conventions
node:crypto Node ≥ 20 builtin Ed25519 PURE mode (RFC 8032) via sign('ed25519', null, key) / verify(...) + createHash('sha256')

§2. Spec inventory

2.1 Voting tuple

Per docs/3-world/physics/laws/consensus.md §What the arbiters vote on:

Each vote is a signature over a 3-tuple: (round_id, merkle_root, rule_version_hash).

The 3-tuple appears in three distinct messages:

  • Vote — direct tuple + sender_id + vote_type + signature + Lamport clock
  • Reveal — wraps a Vote with the COMMIT salt (commit-reveal anti-last-mover protocol)
  • EquivocationProof — embeds 2 conflicting signed Votes with signed_vote_a and signed_vote_b

2.2 Gossip envelope (spec §172-187)

{
  "msg_type": "COMMIT" | "REVEAL" | "VIEW_CHANGE" | "EQUIVOCATION_PROOF",
  "round_id": <uint64>,
  "sender_id": "soul:<sha256-of-pubkey>",
  "payload": { ... msg-specific ... },
  "timestamp_logical": <uint64>,   # Lamport clock, not wall-clock
  "signature": "<ed25519-sig-over-canonical-serialization>"
}

Note: the prompt’s typed shape inlines the “payload” fields rather than nesting them — the discriminator + remaining fields together form the canonical body. (Spec is illustrative JSON; the TS shape is the authoritative wire — see Contract §3.)

2.3 Equivocation proof structure (spec §126-145)

{
  "type": "equivocation",
  "attacker_id": "soul:<sha256-of-pubkey>",
  "epoch": 17,
  "round_id": 42,
  "signed_vote_a": { "tuple": [42, "0xab12…", "rv:sha256:…"], "signature": "<ed25519-sig-A>" },
  "signed_vote_b": { "tuple": [42, "0xCAFE…", "rv:sha256:…"], "signature": "<ed25519-sig-B>" },
  "submitter": "soul:<who-observed-it>",
  "evidence_hash": "<sha256-over-the-two-signed-votes>"
}

Prompt re-shapes this slightly: msg_type: "EQUIVOCATION_PROOF" replaces the loose "type": "equivocation", and signed_vote_a / signed_vote_b are full Vote objects (not anonymous {tuple, signature} records). Prompt is authoritative — the bridge is documented in Contract §4.

2.4 Phase 0 schema (spec §197)

(round_id, arbiter_id, merkle_root, rule_version_hash, signed BLOB, threshold_count INT, finality_level ENUM)

P3.1.1 ships message shapes that schema-fit these columns without conflict. AC #11 of the prompt requires we document this fit in a test fixture (see Contract §5).

§3. κ canonical reuse — Buffer handling

src/domains/rules/canonical.ts is strict:

  • Plain-object guard rejects non-Object.prototype instances (e.g. Map, Set, Date, RegExp, Buffer).
  • bigint → decimal-string toString (no n suffix).
  • Object.keys() sorted by UTF-16 code unit.

ConstraintBuffer is a non-plain object (Buffer.prototype inherits from Uint8Array.prototype, not Object.prototype). It will throw CanonicalSerializationError.

DecisioncanonicalSerialize(msg) first walks msg and rewrites every Buffer field to a lowercase hex string (the canonical form per task-prompt gotcha §1: “hex strings … do not auto-base64; two different hex normalizations break determinism”). Hex normalization rules:

  • All lowercase
  • No 0x prefix
  • Length determined by source bytes (no padding)

After the rewrite, the resulting plain-object value passes through canonicalize() unchanged.

§4. Ed25519 reuse — node:crypto

node:crypto.sign('ed25519', null, privateKey) and verify('ed25519', message, publicKey, signature) implement RFC 8032 Ed25519 PURE mode (NOT Ed25519ph — Ed25519 prehashed mode requires the second arg to be a hash name; passing null selects PURE per Node docs).

The key shapes we use:

  • privateKey: crypto.KeyObject — generated via generateKeyPairSync('ed25519')
  • publicKey: crypto.KeyObject — paired with the private key

Tests will call generateKeyPairSync('ed25519') directly. Production code remains key-agnostic — sign/verify receive opaque KeyObjects.

§5. Forbidden-token inventory for the new module

The κ-style inspectFunctionForbidden scanner (in src/domains/rules/determinism.ts) forbids:

  • \bMath\.[A-Za-z_]\w* — not used (no rounding logic)
  • \bDate\.[A-Za-z_]\w* — not used (Lamport logical clock only)
  • \bnew\s+Date\b — not used
  • \b(?:setTimeout|setInterval|setImmediate)\b — not used
  • \bfetch|XMLHttpRequest\b — not used
  • \bfrom\s+['"](?:fs|node:fs)['"] — not used
  • \bcrypto\.[A-Za-z_]\w*WOULD trip; the κ scanner targets src/domains/rules/ only, NOT src/domains/consensus/. We use NAMED imports (import { sign, verify, generateKeyPairSync, createHash } from 'node:crypto') so the crypto.X dotted form never appears in our source body. This is the same pattern κ uses in version-hash.ts:72. Test fixtures use generateKeyPairSync from the same import.
  • \bprocess\.(?:hrtime|nextTick)\b — not used
  • \bawait\b — not used (pure synchronous module)
  • \basync\s+(?:function|\() — not used
  • Float-literal regex — not triggered (all numeric inputs are bigint or hex string)
  • \[native code\] — not triggered

Our static scanner test in messages.test.ts will replicate the κ pattern locally (Phase 0 has no shared scanner export under src/domains/consensus/; we mirror the regex set inside the test file).

§6. TypeScript strictness inventory

tsconfig.json enables strict, noImplicitAny, strictNullChecks, noImplicitReturns, noFallthroughCasesInSwitch, noUncheckedIndexedAccess, exactOptionalPropertyTypes. The new module must:

  • Avoid any
  • Use unknown + narrowing on external inputs
  • Declare discriminator unions with literal msg_type (TS will narrow correctly without manual casts)
  • Avoid ?: on object types that need to round-trip — exactOptionalPropertyTypes makes {x?: T} distinct from {x?: T | undefined}. All fields in our shapes are required.

§7. Test pattern inventory

Reference suites for the test file:

  • src/__tests__/domains/rules/canonical.test.ts — bigint serialization, key-order tests, hex escape tests
  • src/__tests__/domains/rules/version-hash.test.ts — 1000-iteration determinism test pattern
  • src/__tests__/domains/rules/determinism.test.ts §Group 12 — corpus self-scan style (readFile + regex)

The static scanner test in our file scans messages.ts via readFileSync (the κ scanner uses readdir + readFile over a directory; we scan just our one file).

§8. Gates required

Per CLAUDE.md §5:

npm run build && npm run lint && npm test

All three are MANDATORY gates. Baseline: 2647 tests (1 pre-existing flake in reputation/tools.test.ts unrelated to this slice). Expected delta: +20–30 new tests.

§9. Forbidden-by-task list (from prompt §FORBIDDEN)

  • No floating point — all ids/counts use bigint
  • No Date.now() / wall-clock — Lamport logical clocks only
  • No Math.random() / non-determinism
  • No new npm deps beyond node:crypto + existing κ deps
  • No re-implementation of κ canonical (REUSE src/domains/rules/canonical.ts)
  • No class hierarchies — pure data + discriminator unions only
  • No editing main checkout (this audit is in the feature/p3-1-1-vote-messages worktree per CLAUDE.md §3)

§10. Risk inventory

Risk Mitigation
Buffer-in-canonical throws serializeForCanonical() pre-pass rewrites Buffer → lowercase hex string
Hex case drift across hosts All Buffer.toString('hex') calls are spec’d lowercase (Node default)
Ed25519 PH (prehashed) accidentally selected Always pass null as second arg to sign('ed25519', null, key) (RFC 8032 PURE)
timestamp_logical ambiguity with wall clock Module-level bigint counter + nextLogical() helper; never reads any clock
Discriminator drift between TS and wire msg_type is a literal-typed required field on every message; TS exhaustiveness check on union
Tests assume single shared key Each test generates its own keypair via generateKeyPairSync('ed25519')

§11. Out-of-scope

  • Vote aggregation / threshold signatures (P3.2.x)
  • Quorum computation (P3.1.2)
  • Gossip transport (P3.3.x)
  • Signed time anchors (S06 §Signed time anchors — future phase)
  • Persistence schema (Phase 0 has the columns; this slice is pure types + helpers)

§12. Inputs to Step 2 (Contract)

The contract will fix:

  1. The exact 5 TypeScript shapes + their literal-typed discriminators
  2. The canonical serialization pre-pass for Buffer rewriting
  3. The signature scope (signed bytes = canonicalSerialize(msgWithoutSignature))
  4. The hash scope (hashMessage(msg) = SHA-256 over canonicalSerialize(msg) INCLUDING signature, for evidence-hash purposes)
  5. The Lamport clock initialization + monotonicity guarantee
  6. The error taxonomy (ConsensusSerializationError)

Back to top

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

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