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 passnpm run lint— ESLint clean (especially: noanyleaks, no unused imports, noconsole)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 η
proofstore forknown_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.