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

Step 3 of the 5-step chain. Implementation plan gating Step 4.

§1. File plan

src/domains/consensus/gossip-wire.ts (~280 LOC)

Structural outline:

1. Header JSDoc — module identity, ADR-003 Option C note, reuse list
2. Imports — node:crypto NAMED imports (KeyObject, sign, verify),
              messages.js named imports (canonicalSerialize,
              ConsensusSerializationError, nextLogical)
3. Type exports — IHAVE, IWANT, GossipMessage, ReceiverState,
                   TripleAnchorResult
4. Internal helpers:
   - stripSigForGossip(msg) — shallow copy + delete .signature
   - canonicalUnsigned(msg) — canonical bytes of stripped form
5. signGossipMessage(msg, privateKey): Buffer
6. verifyGossipMessage(msg, publicKey): boolean
7. validateTripleAnchor(msg, receiver): TripleAnchorResult
8. buildIWANT(ihave, locally_have, options?): IWANT-shape
9. withinRetentionHorizon(msg_epoch, current_epoch, retention_epochs?): boolean

Buffer encoding for canonicalize: messages.ts’s canonicalSerialize walks structurally (its Buffer→hex rewriter doesn’t inspect TS types), so casting our IHAVE/IWANT to unknown as ConsensusMessage | Vote at the boundary works at runtime. JSDoc the cast.

src/__tests__/domains/consensus/gossip-wire.test.ts (~500 LOC)

Mirror the messages.test.ts groupings. 16 groups, ~29 tests total. Generate Ed25519 keypair per group via generateKeyPairSync('ed25519') (a node:crypto call inside test code is allowed; the static scanner runs only against gossip-wire.ts).

§2. Implementation specifics

2.1 signGossipMessage internals

export function signGossipMessage(
  msg: IHAVE | IWANT,
  privateKey: KeyObject,
): Buffer {
  const stripped = stripSigForGossip(msg);
  const body = canonicalUnsigned(stripped);
  return sign(null, body, privateKey); // RFC 8032 PURE mode
}

stripSigForGossip:

function stripSigForGossip(msg: IHAVE | IWANT): Record<string, unknown> {
  const copy: Record<string, unknown> = {
    ...(msg as unknown as Record<string, unknown>),
  };
  delete copy.signature;
  return copy;
}

canonicalUnsigned:

function canonicalUnsigned(stripped: Record<string, unknown>): Buffer {
  // Cast to the union type that canonicalSerialize accepts at compile time.
  // The runtime encoder walks structurally — discriminator value is irrelevant.
  return canonicalSerialize(stripped as unknown as ConsensusMessage);
}

The cast is documented in JSDoc: it bridges the typed IHAVE|IWANT to messages.ts’s ConsensusMessage|Vote union. Runtime encoder is type-discriminator-agnostic.

2.2 verifyGossipMessage internals

export function verifyGossipMessage(
  msg: IHAVE | IWANT,
  publicKey: KeyObject,
): boolean {
  const stripped = stripSigForGossip(msg);
  const body = canonicalUnsigned(stripped);
  return verify(null, body, publicKey, msg.signature);
}

2.3 validateTripleAnchor internals

export function validateTripleAnchor(
  msg: IHAVE,
  receiver: ReceiverState,
): TripleAnchorResult {
  // Anchor 1: rule_version
  if (Buffer.compare(msg.rule_version_hash, receiver.active_rule_version) !== 0) {
    return { valid: false, failed_anchor: 'rule_version' };
  }
  // Anchor 2: state_root continuity
  const knownKey = msg.state_root_pre.toString('hex');
  const inKnownSet = receiver.known_state_roots.has(knownKey);
  // Gap: how far past the last checkpoint is the message claiming to be?
  const epochGap = msg.msg_epoch - receiver.last_checkpoint_epoch;
  const withinGap = epochGap >= 0n && epochGap <= 1n;
  if (!inKnownSet && !withinGap) {
    return { valid: false, failed_anchor: 'state_root' };
  }
  // Anchor 3: fork_id
  if (Buffer.compare(msg.fork_id, receiver.current_fork_id) !== 0) {
    return { valid: false, failed_anchor: 'fork_id' };
  }
  return { valid: true };
}

2.4 buildIWANT internals

export interface BuildIWANTOptions {
  readonly seen?: (event_id: Buffer) => boolean;
  readonly sender_id?: string;
  readonly timestamp_logical?: bigint;
}

export function buildIWANT(
  ihave: IHAVE,
  locally_have: (event_id: Buffer) => boolean,
  options?: BuildIWANTOptions,
): IWANT {
  const seenPred = options?.seen ?? ((_id: Buffer) => false);
  const wanted: Buffer[] = [];
  for (const id of ihave.event_ids) {
    if (seenPred(id)) {
      continue;
    }
    if (locally_have(id)) {
      continue;
    }
    wanted.push(id);
  }
  return {
    msg_type: 'IWANT',
    event_ids: wanted,
    sender_id: options?.sender_id ?? '',
    timestamp_logical: options?.timestamp_logical ?? 0n,
    signature: Buffer.alloc(0),
  };
}

The returned IWANT has zero-length signature; caller signs externally.

2.5 withinRetentionHorizon internals

export function withinRetentionHorizon(
  msg_epoch: bigint,
  current_epoch: bigint,
  retention_epochs: bigint = 2n,
): boolean {
  // Messages from future epochs: receiver is behind sender; not a retention
  // concern. Treat as within-horizon. The state_root continuity check
  // handles future-epoch divergence.
  if (msg_epoch >= current_epoch) {
    return true;
  }
  const age = current_epoch - msg_epoch;
  return age <= retention_epochs;
}

§3. Test plan

Test file structure mirrors messages.test.ts:

- Helper builders: bufN(seed), makeBareIHAVE(overrides), makeBareIWANT(overrides),
                    makeReceiverState(overrides)
- Group 1: Module surface  — 2 tests
- Group 2: IHAVE/IWANT shape construction + canonical roundtrip  — 3 tests
- Group 3: signGossipMessage / verifyGossipMessage roundtrip  — 3 tests
- Group 4: validateTripleAnchor — all pass  — 1 test
- Group 5: validateTripleAnchor — rule_version fail  — 2 tests
- Group 6: validateTripleAnchor — state_root fail  — 3 tests
- Group 7: validateTripleAnchor — fork_id fail  — 1 test
- Group 8: validateTripleAnchor — first-fail short-circuit  — 2 tests
- Group 9: buildIWANT — locally_have filter  — 2 tests
- Group 10: buildIWANT — seen dedup filter  — 2 tests
- Group 11: buildIWANT — single-arbiter (empty)  — 1 test
- Group 12: withinRetentionHorizon  — 3 tests
- Group 13: Signature-before-anchor composition  — 1 test
- Group 14: Phase-0 schema fit  — 1 test
- Group 15: Static scanner  — 1 test
- Group 16: No partial acceptance  — 1 test

Total: 29 tests across 16 groups.

3.1 Static scanner (Group 15)

Replicate the messages.test.ts Group 9 pattern. Load src/domains/consensus/gossip-wire.ts, strip comments, run the same FORBIDDEN_PATTERNS regex array, expect zero hits.

3.2 Composition fixture (Group 13)

function processIHAVE(
  ihave: IHAVE,
  senderPubKey: KeyObject,
  receiver: ReceiverState,
): { rejected: true; reason: 'signature' | 'rule_version' | 'state_root' | 'fork_id' }
  | { rejected: false } {
  if (!verifyGossipMessage(ihave, senderPubKey)) {
    return { rejected: true, reason: 'signature' };
  }
  const r = validateTripleAnchor(ihave, receiver);
  if (!r.valid) {
    return { rejected: true, reason: r.failed_anchor };
  }
  return { rejected: false };
}

Test: with a tampered signature, processIHAVE returns {rejected: true, reason: 'signature'} even if all anchors WOULD pass. The implicit short-circuit is observable through this composition wrapper, which lives in the test file (not the library — the library’s verifyGossipMessage is independent of validateTripleAnchor by design).

§4. Gates

  • npm run build — TypeScript strict pass
  • npm run lint — ESLint clean (especially: no any leaks, no unused imports, no console)
  • npm test — All tests pass; new tests in gossip-wire.test.ts pass; pre-existing 2685 passing tests stay green (baseline includes 1 reputation/tools flake — treat 2685 as min floor on green run, target 2715+ after this slice)

§5. Out of scope

  • Real publish/subscribe layer (P3.3.3 — adaptive fanout)
  • Bloom filter implementation (P3.3.2)
  • libp2p integration (would require ADR-003 Option B; Option C in-process is locked)
  • Wiring into η proof store for known_state_roots (Phase 1.5)
  • The receiver state machine that triggers checkpoint sync on state_root failure
  • MCP tool registration for gossip operations (Phase 1.5+)

§6. Commit message templates

  • Step 4: feat(p3-3-1-gossip-ihave-iwant): IHAVE/IWANT wire + triple-anchor validator — Option C (R89 θ Wave 2)
  • Step 5: verify(p3-3-1-gossip-ihave-iwant): test evidence

§7. Sign-off

Packet approved. Step 4 (implement) proceeds.


Back to top

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

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