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) |
REUSE — canonicalize() for JSON encoding |
src/domains/rules/version-hash.ts |
Yes (κ P1.5.1 — shipped 0150dcd1) |
REUSE — computeVersionHash() 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_aandsigned_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.prototypeinstances (e.g.Map,Set,Date,RegExp,Buffer). bigint→ decimal-string toString (nonsuffix).Object.keys()sorted by UTF-16 code unit.
Constraint — Buffer is a non-plain object (Buffer.prototype inherits from Uint8Array.prototype, not Object.prototype). It will throw CanonicalSerializationError.
Decision — canonicalSerialize(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
0xprefix - 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 viagenerateKeyPairSync('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 targetssrc/domains/rules/only, NOTsrc/domains/consensus/. We use NAMED imports (import { sign, verify, generateKeyPairSync, createHash } from 'node:crypto') so thecrypto.Xdotted form never appears in our source body. This is the same pattern κ uses inversion-hash.ts:72. Test fixtures usegenerateKeyPairSyncfrom 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 —exactOptionalPropertyTypesmakes{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 testssrc/__tests__/domains/rules/version-hash.test.ts— 1000-iteration determinism test patternsrc/__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
maincheckout (this audit is in thefeature/p3-1-1-vote-messagesworktree 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:
- The exact 5 TypeScript shapes + their literal-typed discriminators
- The canonical serialization pre-pass for Buffer rewriting
- The signature scope (signed bytes =
canonicalSerialize(msgWithoutSignature)) - The hash scope (
hashMessage(msg)= SHA-256 overcanonicalSerialize(msg)INCLUDING signature, for evidence-hash purposes) - The Lamport clock initialization + monotonicity guarantee
- The error taxonomy (
ConsensusSerializationError)