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):
- Node sends IHAVE:
event_ids + state_root_pre + rule_version_hash + fork_id- Receiver validates: rule version matches, state root is continuous from known checkpoint, fork ID matches
- If valid, receiver sends IWANT for events it doesn’t have
- 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 messagecurrent_epoch: bigint— receiver-side current epoch indexretention_epochs: bigint(default2n)
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_logicalis a Lamport counter, not an epoch index. Retention is computed in epochs, so the helper needs both; passcurrent_epochseparately.
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:
- Import
canonicalSerialize+ConsensusSerializationErrorfrom messages.js - Import
sign,verifyfromnode:crypto(NAMED imports, not dotted access — passes scanner) - Define local
signGossipMessage/verifyGossipMessagethat strip the signature field and callsign(null, body, ...)/verify(null, body, ..., sig) - The cast to
ConsensusMessageis 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:
- Module surface (export sanity): ~3
- IHAVE/IWANT shape construction + canonical serialize: ~4
- Sign/verify roundtrip: ~3
- Triple-anchor PASS (all three anchors match): ~1
- Triple-anchor FAIL rule_version: ~2 (mismatch + boundary)
- Triple-anchor FAIL state_root (gap > 1 epoch, not in known set): ~3
- Triple-anchor FAIL fork_id (divergent): ~1
- First-fail short-circuit ordering: ~2
- buildIWANT filtering: ~2
- Retention horizon: ~3 (within / at / beyond)
- Single-arbiter zero-event no-op: ~2
- Signature-before-anchor: ~1
- No-partial-acceptance: ~1
- Forbidden-token static scanner: ~1
- 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.