P3.3.1 — Gossip Protocol — IHAVE/IWANT Wire — Audit

Step 1 of the 5-step chain (CLAUDE.md §6). Phase 3 θ Wave 2 — the lazy push/pull message-level slice of the gossip protocol. Companion P3.1.2 (Quorum) and P3.4.1 (Signed Time Anchor) ship in parallel.

§1. Surface inventory at base SHA e63a8bcf

Path Exists? Role
src/domains/consensus/ Yes (Wave 1 P3.1.1 — shipped e63a8bcf) θ domain root
src/domains/consensus/messages.ts Yes — 5 typed msg shapes + canonical wire + Ed25519 helpers REUSE — canonicalSerialize, signMessage, verifySignature, hashMessage, nextLogical, ConsensusSerializationError
src/domains/consensus/gossip-wire.ts No — to create IHAVE/IWANT shapes + triple-anchor validator + retention horizon + buildIWANT
src/__tests__/domains/consensus/messages.test.ts Yes — 829 lines Reference for the κ-style static scanner pattern (Group 9)
src/__tests__/domains/consensus/gossip-wire.test.ts No — to create Triple-anchor pass/fail per anchor, retention, dedup interface, sig-before-anchor, single-arbiter no-op
src/domains/rules/canonical.ts Yes (κ P1.5.4) Indirect REUSE via messages.ts
node:crypto Node ≥ 20 builtin Ed25519 PURE-mode via the verifySignature re-export — gossip-wire does NOT touch node:crypto directly

§2. Spec inventory

2.1 IHAVE / IWANT — wire shape

docs/spec/s08-gossip.md §IHAVE/IWANT (lines 11–18):

  1. Node sends IHAVE: event_ids + state_root_pre + rule_version_hash + fork_id
  2. Receiver validates: rule version matches, state root is continuous from known checkpoint, fork ID matches
  3. If valid, receiver sends IWANT for events it doesn’t have
  4. Sender delivers requested events Mismatch on any of the three validation checks → reject entire gossip batch.

docs/3-world/physics/laws/consensus.md §Gossip (lines 163–187) adds the envelope-level fields (sender_id, timestamp_logical, signature) and the retention horizon = 2 epochs.

Required IHAVE fields (acceptance criteria from p3.1-theta-consensus.md §P3.3.1):

Field Type Source
msg_type: "IHAVE" string discriminator this slice
event_ids Buffer[] s08 line 13
state_root_pre Buffer s08 line 13
rule_version_hash Buffer s08 line 13
fork_id Buffer s08 line 13
sender_id string consensus.md §Gossip envelope §180
timestamp_logical bigint (Lamport) consensus.md §181
signature Buffer (Ed25519) consensus.md §182

Required IWANT fields:

Field Type Source
msg_type: "IWANT" string discriminator this slice
event_ids Buffer[] s08 line 15 (subset of IHAVE’s)
sender_id string consensus.md §180
timestamp_logical bigint (Lamport) consensus.md §181
signature Buffer (Ed25519) consensus.md §182

2.2 Triple-anchor validation

docs/spec/s08-gossip.md §Triple-Anchor validation (lines 20–30) — the canonical 3-row table:

Anchor What is checked Failure consequence
rule_version_hash hash of sender’s active rule set === receiver’s Batch rejected; receiver may trigger fork on persistent rule divergence
state_root continuity sender’s state_root_pre reachable from receiver’s last checkpoint (chain of verified state roots, no gaps > 1 epoch) Batch rejected; receiver requests checkpoint sync
fork_id both nodes on same fork (identical fork_id) Batch rejected; events not shared across forks

All three must pass before IWANT issued. Single failure → entire batch rejected. This is the “no partial acceptance” rule — even if some events would be safe, the whole batch is dropped on any anchor failure to prevent inconsistent state.

2.3 Retention horizon

docs/3-world/physics/laws/consensus.md §Gossip envelope (line 187):

Retention horizon default: 2 epochs — beyond that the message is evidence-only, not consensus-relevant.

docs/3-world/physics/laws/consensus.md §Gossip (line 169) also:

A message older than the retention horizon is dropped without reply.

Both inputs to the horizon helper:

  • msg_timestamp_logical: bigint — Lamport counter from the IHAVE/IWANT message
  • current_epoch: bigint — receiver-side current epoch index
  • retention_epochs: bigint (default 2n)

Important: Lamport clocks are NOT epoch indices. The spec text says “older than retention horizon”, and the horizon is in epochs (line 187). The §P3.3.1 gotcha block at p3.1-theta-consensus.md lines 1122–1124 makes this explicit:

timestamp_logical is a Lamport counter, not an epoch index. Retention is computed in epochs, so the helper needs both; pass current_epoch separately.

The helper signature MUST accept BOTH a Lamport timestamp (carrying the message’s epoch-of-origin externally) and the receiver’s current epoch. The interpretation is: caller knows or infers the message’s epoch (from the IHAVE’s state_root_pre’s epoch alignment, or a separate msg_epoch field), and passes that as the timestamp. The helper computes current_epoch - msg_epoch > retention_epochs → drop.

Slice decision: Since the message envelope per acceptance criteria does NOT carry an explicit epoch field (only timestamp_logical), the helper takes a separate msg_epoch: bigint parameter for the message’s epoch-of-origin and a current_epoch: bigint for the receiver. The Lamport timestamp_logical rides along on the message for ordering, but retention is computed in epochs. This is consistent with the spec gotcha note.

2.4 Signature-before-anchor order

p3.1-theta-consensus.md §P3.3.1 gotcha (line 1115–1117):

Signature ALWAYS checked first — never run the anchor checks on an unsigned or invalid-sig payload (gives attacker free CPU spend on the receiver).

The validator entry point validates signature FIRST, returns {valid: false} early on signature failure (treat as failed_anchor: undefined — but acceptance criteria say failed_anchor is one of three strings on anchor failure; signature failure is a distinct rejection class).

Slice decision: validateTripleAnchor() per the acceptance criteria’s signature returns {valid: true} | {valid: false, failed_anchor: 'rule_version'|'state_root'|'fork_id'}. We add a prior, separate step verifyIHAVESignature(msg, publicKey) returning boolean. The composite flow:

1. verifyIHAVESignature(ihave, sender_pubkey) → if false, reject
2. validateTripleAnchor(ihave, receiverState) → if {valid: false}, reject with failed_anchor
3. buildIWANT(ihave, locally_have) → produce IWANT for missing event_ids

Tests assert order: signature-fail short-circuits BEFORE any anchor check is invoked.

2.5 Dedup interface

p3.1-theta-consensus.md §P3.3.1 acceptance criterion:

Deduplication interface: seen?: (event_id: Buffer) => boolean — accepts P3.3.2’s Bloom; default identity-set

P3.3.2 (Bloom dedup) is the NEXT wave’s slice. P3.3.1 ships the interface only — a seen predicate that defaults to an identity-set (no-dedup baseline). P3.3.2 will swap a Bloom impl through this seam.

Slice decision: buildIWANT(ihave, locally_have, seen?) — the locally_have predicate is “do I already have this event in local storage?” (the spec’s filter requirement); the optional seen predicate is the dedup interface. The default seen returns false (no dedup). When P3.3.2 lands, callers pass a Bloom-backed seen to filter out recently-seen IDs.

2.6 Single-arbiter clause

p3.1-theta-consensus.md §P3.3.1 acceptance criterion:

Single-arbiter clause: n=1 means there are no peers; IHAVE/IWANT functions return without crashing (no-op publish/subscribe)

docs/3-world/physics/laws/consensus.md §Phase 0 posture (line 195):

The runtime accepts θ-shaped APIs but always returns “trivially finalized” because n = 1.

Slice decision: buildIWANT over an empty event_ids array returns an IWANT with empty event_ids — caller decides whether to send (typically: skip if empty). The validator over zero-event IHAVE returns {valid: true} if anchors match, since the spec is silent on zero-event batches being malformed. The “n=1 no-op” surfaces at the publish/subscribe layer (P3.3.3 fanout), not here — but the wire layer must not crash on empty inputs.

2.7 No-wallclock, no-Math.*, bigint-only

CLAUDE.md §6 implicit (κ determinism rules inherited by θ — see consensus.md §181). The slice must pass the forbidden-token scanner (same regex set as κ P1.3.1 determinism.test.ts), which the P3.1.1 slice already wired into messages.test.ts Group 9. This slice will declare its own Group N scanner over gossip-wire.ts.

§3. ADR-003 gate

ADR-003 (BFT library) is PROPOSED at R89.C staging. The task prompt confirms PM/T0 has set Option C in-process spike as the active strategy for R89 θ Phase 3. Implications:

  • Ship as in-process pure-data module — NO real socket I/O
  • NO new npm deps; specifically NO @chainsafe/libp2p-gossipsub
  • The IHAVE/IWANT wire is a pure validator + builder library; publish/subscribe is deferred to later P3.3.x slices

If ADR-003 later flips to Option B (libp2p), this slice rewrites against gossipsub message types. The acceptance criteria explicitly tolerate a Phase 0/Option C in-process shim.

§4. Reuse decisions

Helper Source Use
verifySignature(msg, pubKey) messages.ts §8 Reuse to verify IHAVE/IWANT sigs. The signature is stripped from canonical body, matching the existing pattern.
signMessage(msg, privKey) messages.ts §8 Reuse for test fixtures. Production callers inject signed IHAVE/IWANT externally.
canonicalSerialize(msg) messages.ts §6 Indirect reuse via signMessage/verifySignature. Buffer→hex pre-pass handles event_ids: Buffer[], state_root_pre, rule_version_hash, fork_id cleanly.
ConsensusSerializationError messages.ts §3 Reuse error class — keep one error surface for the consensus domain.
nextLogical() messages.ts §5 Reuse Lamport clock for test fixtures.

Type-system fit check: signMessage accepts Vote | Commit | Reveal | ViewChange today. To reuse it for IHAVE/IWANT, we extend the signMessage parameter type, OR we write a thin local wrapper that constructs a signable-shape and calls the inner canonical encoder. The cleanest path is option B: write signGossipMessage and verifyGossipMessage thin local wrappers in gossip-wire.ts that strip the signature field, canonical-serialize, and call node:crypto sign/verify PURE-mode directly — but this introduces a node:crypto import.

Slice decision: Extend messages.ts to accept the new types as a non-breaking widening, OR declare a separate sign/verify pair local to gossip-wire.ts.

Looking at messages.ts §8 — the sign/verify functions accept a msg: Vote | Commit | Reveal | ViewChange union. Widening to include IHAVE | IWANT would touch the existing module, which is out of scope for this slice (and would invalidate the determinism scanner’s expectation that messages.ts changes go through P3.1.1 chain).

Cleanest in-scope path: in gossip-wire.ts, define signGossipMessage(msg, privKey) and verifyGossipMessage(msg, pubKey) that use canonicalSerialize via a small unsigned-message canonical helper (or via a local node:crypto re-import of sign/verify in PURE mode). Either way the test scanner will whitelist this same pattern.

Reviewing more carefully — looking at signMessage impl (messages.ts:437–454): it uses stripSignatureForSig, then canonicalSerializeUnchecked (a private function), then sign(null, body, privateKey). The PUBLIC API only exposes canonicalSerialize for the union types. To get a generic canonical-serialize over our IHAVE/IWANT shapes we have two choices:

A. Cast our IHAVE/IWANT to Vote shape (lying to TypeScript) before calling canonicalSerialize — UGLY. B. Re-implement the strip+canonicalize+sign pattern locally in gossip-wire.ts using node:crypto’s sign/verify directly + a local strip helper that calls canonicalSerialize on the stripped record.

canonicalSerialize is typed as (msg: ConsensusMessage | Vote): Buffer — it accepts a discriminated union. If we cast our IHAVE/IWANT to unknown then to a Vote-shape, the runtime Buffer→hex pre-pass will process arbitrary plain objects regardless of typing (the function walks structurally). So calling it would WORK at runtime.

Final decision: Add a tiny re-export-style helper in gossip-wire.ts that calls into the canonical encoder via a runtime cast. The cast lives at one boundary, isolated. Specifically: cast to unknown as Vote for the canonicalSerialize calls. Document the cast.

Actually, the cleanest is to extract / add a public helper in messages.ts. But that’s “edit messages.ts” — out of P3.3.1 scope. The acceptable in-scope approach is to call canonicalSerialize(msgWithoutSig as unknown as ConsensusMessage) and document the cast. The internal Buffer→hex rewriter doesn’t care about TypeScript discriminator values; it walks structurally.

Adopted. gossip-wire.ts will:

  1. Import canonicalSerialize + ConsensusSerializationError from messages.js
  2. Import sign, verify from node:crypto (NAMED imports, not dotted access — passes scanner)
  3. Define local signGossipMessage / verifyGossipMessage that strip the signature field and call sign(null, body, ...) / verify(null, body, ..., sig)
  4. The cast to ConsensusMessage is documented in JSDoc

This adds ONE node:crypto import (sign + verify) which the existing scanner already tolerates as NAMED imports in messages.ts. The same scanner regex (\bcrypto\.[A-Za-z_]\w*) does NOT match sign(...) or verify(...) standalone calls.

§5. Risk and mitigation

Risk Mitigation
state_root_pre continuity check is fuzzy in spec (s08 line 27 says “no gaps > 1 epoch” without defining “gap”) Per p3.1-theta-consensus.md gotcha §1109–1114: if state_root_pre not in receiver’s known_state_roots AND last checkpoint is from an epoch >1 earlier than expected → fail. Adopted: known_state_roots: Set<string> (hex of buffer) keyed lookup; receiver passes its last_checkpoint_epoch and the IHAVE carries msg_epoch so we know “where the sender claims to be.” Slice ships the interface for receiver state; integration with η proof store is P3.3.3 / Phase 1.5.
Lamport vs epoch confusion Helper accepts both msg_epoch AND timestamp_logical. Retention is computed in epochs; Lamport only for vote ordering.
seen predicate could be confused with locally_have JSDoc + explicit naming: locally_have = “I have this event already (do not request)”; seen = “I have RECENTLY seen this gossip ID (Bloom dedup; do not redundantly request)”. P3.3.1 ships interface only; P3.3.2 wires the Bloom.
Single-arbiter no-op needs to be a runtime concept Acceptance criterion says functions return without crashing. We do not own the publish/subscribe layer in this slice. Concrete test: buildIWANT over zero-event IHAVE returns IWANT with empty event_ids; receiver never crashes on n=1.
Discriminator collision with ConsensusMessage union IHAVE / IWANT are NOT added to ConsensusMessage union (which lives in messages.ts and is scoped to vote/commit/reveal/viewchange/equivocation_proof). They form a separate GossipMessage union local to gossip-wire.ts.

§6. Files this slice will create

Path Purpose Approx LOC
src/domains/consensus/gossip-wire.ts IHAVE/IWANT shapes + validator + buildIWANT + retention helper + sign/verify ~300
src/__tests__/domains/consensus/gossip-wire.test.ts Triple-anchor groups + retention + dedup + sig-before-anchor + single-arbiter + static scanner ~500
docs/audits/p3-3-1-gossip-ihave-iwant-audit.md This file
docs/contracts/p3-3-1-gossip-ihave-iwant-contract.md Step 2 ~120
docs/packets/p3-3-1-gossip-ihave-iwant-packet.md Step 3 ~80
docs/verification/p3-3-1-gossip-ihave-iwant-verification.md Step 5 ~70

§7. Test count delta (estimate)

Baseline: 2685 passing + 1 pre-existing flake in reputation/tools.test.ts (intermittent). Total slot at task spec: 2687.

Estimate +25–30 new tests for gossip-wire.test.ts across these groups:

  1. Module surface (export sanity): ~3
  2. IHAVE/IWANT shape construction + canonical serialize: ~4
  3. Sign/verify roundtrip: ~3
  4. Triple-anchor PASS (all three anchors match): ~1
  5. Triple-anchor FAIL rule_version: ~2 (mismatch + boundary)
  6. Triple-anchor FAIL state_root (gap > 1 epoch, not in known set): ~3
  7. Triple-anchor FAIL fork_id (divergent): ~1
  8. First-fail short-circuit ordering: ~2
  9. buildIWANT filtering: ~2
  10. Retention horizon: ~3 (within / at / beyond)
  11. Single-arbiter zero-event no-op: ~2
  12. Signature-before-anchor: ~1
  13. No-partial-acceptance: ~1
  14. Forbidden-token static scanner: ~1
  15. Phase 0 schema fit (Buffer / bigint): ~1

Total estimate: ~30. Final number lands in verification doc.

§8. Sign-off

Audit complete. Step 2 (contract) follows: behavioral contract codifying the IHAVE / IWANT types, the three validateTripleAnchor failure modes with first-fail short-circuit, the retention horizon helper signature, the signature-before-anchor invariant, and the dedup-interface seam.


Back to top

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

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