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

Step 2 of the 5-step chain. Behavioral contract for src/domains/consensus/gossip-wire.ts.

§1. Module identity

  • Path: src/domains/consensus/gossip-wire.ts
  • Domain: θ Consensus (Phase 3, R89 Wave 2)
  • Depends on: src/domains/consensus/messages.ts (P3.1.1 — canonicalSerialize, ConsensusSerializationError, nextLogical)
  • Pure module: no I/O, no DB, no network, no async, no wall-clock, no Math.*, no randomness from the module body. All inputs flow as function arguments.

§2. Type exports

2.1 IHAVE

interface IHAVE {
  readonly msg_type: 'IHAVE';
  readonly event_ids: readonly Buffer[];
  readonly state_root_pre: Buffer;
  readonly rule_version_hash: Buffer;
  readonly fork_id: Buffer;
  readonly msg_epoch: bigint;         // epoch-of-origin (NOT Lamport)
  readonly sender_id: string;
  readonly timestamp_logical: bigint; // Lamport
  readonly signature: Buffer;          // Ed25519, 64 bytes when populated
}

Note on msg_epoch: per s08 retention is measured in epochs but the gossip envelope at consensus.md §172-185 carries timestamp_logical as a Lamport counter. The p3.1-theta-consensus.md §P3.3.1 gotcha §1122-1124 explicitly says “Retention is computed in epochs, so the helper needs both.” We add msg_epoch to the IHAVE shape so the retention check is unambiguous and the receiver knows where the sender claims to be (which the triple-anchor validator also uses for state_root continuity).

2.2 IWANT

interface IWANT {
  readonly msg_type: 'IWANT';
  readonly event_ids: readonly Buffer[];
  readonly sender_id: string;
  readonly timestamp_logical: bigint;
  readonly signature: Buffer;
}

2.3 GossipMessage union

type GossipMessage = IHAVE | IWANT;

Local to gossip-wire.ts. Does NOT extend ConsensusMessage (which is messages.ts’s vote-message union — kept distinct so the existing canonicalSerialize discriminator catalog stays stable).

2.4 ReceiverState

interface ReceiverState {
  readonly active_rule_version: Buffer;
  readonly last_checkpoint_state_root: Buffer;
  readonly last_checkpoint_epoch: bigint;
  readonly current_fork_id: Buffer;
  readonly known_state_roots: ReadonlySet<string>; // hex(buffer) keys
}

known_state_roots is hex-keyed for Set<string> reachability — Buffer.equals doesn’t work with Set membership. Receiver pre-encodes via buf.toString('hex').

2.5 TripleAnchorResult

type TripleAnchorResult =
  | { readonly valid: true }
  | { readonly valid: false; readonly failed_anchor: 'rule_version' | 'state_root' | 'fork_id' };

Discriminated by valid. On failure carries failed_anchor naming the first anchor to fail. Order of checks (first-fail short-circuit): rule_version → state_root → fork_id. Other orders would also be valid per spec; we lock this one for determinism.

§3. Function exports

3.1 signGossipMessage(msg, privateKey)

function signGossipMessage(
  msg: IHAVE | IWANT,
  privateKey: KeyObject,
): Buffer;

Strips the top-level signature field, canonical-serializes via messages.ts’s canonicalSerialize, signs Ed25519 PURE-mode. Returns 64-byte signature Buffer. Caller assigns to msg.signature.

Invariants:

  • Pure — no I/O, no module state mutation
  • The canonical body MUST be identical to what verifyGossipMessage reconstructs
  • Throws ConsensusSerializationError if the message contains un-representable structure

3.2 verifyGossipMessage(msg, publicKey)

function verifyGossipMessage(
  msg: IHAVE | IWANT,
  publicKey: KeyObject,
): boolean;

Strips signature, canonical-serializes, verifies Ed25519 PURE. Returns true iff the signature on msg was produced by the private key paired with publicKey.

Invariants:

  • Pure
  • A false result MUST be treated by callers as terminal — do NOT proceed to triple-anchor checks (the gotcha rule). Callers compose: if (!verifyGossipMessage(...)) reject; if (!validateTripleAnchor(...).valid) reject; ....

3.3 validateTripleAnchor(msg, receiver)

function validateTripleAnchor(
  msg: IHAVE,
  receiver: ReceiverState,
): TripleAnchorResult;

Per s08 §Triple-Anchor validation. Three independent checks, first-fail short-circuit:

  1. rule_version: Buffer.compare(msg.rule_version_hash, receiver.active_rule_version) === 0. If not, return {valid: false, failed_anchor: 'rule_version'}.
  2. state_root continuity: Either
    • msg.state_root_pre is in receiver.known_state_roots (keyed by msg.state_root_pre.toString('hex')), OR
    • (msg.msg_epoch - receiver.last_checkpoint_epoch) <= 1n AND msg.msg_epoch >= receiver.last_checkpoint_epoch (gap ≤ 1 epoch forward; backward / same-epoch is always reachable in principle) If neither, return {valid: false, failed_anchor: 'state_root'}.
  3. fork_id: Buffer.compare(msg.fork_id, receiver.current_fork_id) === 0. If not, return {valid: false, failed_anchor: 'fork_id'}.

All three pass → return {valid: true}.

Interpretation note for state_root: the spec is fuzzy (“no gaps > 1 epoch”). Our interpretation, per p3.1-theta-consensus.md §1109-1114: if the sender’s state_root_pre is in the receiver’s known set (the receiver has independently verified that root somehow), accept. Otherwise the sender claims a future state we have not verified — accept ONLY if it’s at most 1 epoch beyond what we’ve checkpointed. Beyond that, the receiver should request a full checkpoint sync (out of scope for this slice).

Determinism: pure function over typed inputs; no randomness; no clocks.

3.4 buildIWANT(ihave, locally_have, options?)

function buildIWANT(
  ihave: IHAVE,
  locally_have: (event_id: Buffer) => boolean,
  options?: {
    seen?: (event_id: Buffer) => boolean;
    sender_id: string;
    timestamp_logical: bigint;
  },
): Omit<IWANT, 'signature'> & { signature: Buffer };

For each event_id in ihave.event_ids:

  • If seen(event_id) returns true (default: never) → skip (dedup)
  • Else if locally_have(event_id) returns true → skip (already have it)
  • Else → include in resulting IWANT

The function returns the IWANT with a zero-length signature Buffer; the caller signs externally via signGossipMessage. If options.sender_id / options.timestamp_logical are omitted, the function uses placeholders (sender_id = ‘’, timestamp_logical = 0n) that the caller MUST replace before sending.

Pragma: keeping the function signature simple — most production callers pass all three options. Tests can omit them.

Single-arbiter no-op: if ihave.event_ids is empty, returns IWANT with empty event_ids. Caller may decide not to actually transmit a zero-event IWANT.

3.5 withinRetentionHorizon(msg_epoch, current_epoch, retention_epochs?)

function withinRetentionHorizon(
  msg_epoch: bigint,
  current_epoch: bigint,
  retention_epochs?: bigint, // default 2n
): boolean;

Returns true iff (current_epoch - msg_epoch) <= retention_epochs. Messages from FUTURE epochs (i.e. msg_epoch > current_epoch) are also accepted as “within horizon” — a receiver that sees a future-epoch message simply means the receiver is behind; rejection is a state_root continuity concern, not a retention concern.

The default retention horizon 2n comes from consensus.md §Gossip envelope line 187 (“Retention horizon default: 2 epochs”).

§4. Behavioral invariants (I-series, for test traceability)

  • I1IHAVE and IWANT are structurally distinct; the msg_type discriminator never overlaps.
  • I2signGossipMessage and verifyGossipMessage are inverses: for any IHAVE/IWANT with stripped signature, signing then verifying under the corresponding keypair returns true.
  • I3 — Signature verification depends on EVERY field except signature itself. Mutating any field (including event_ids order, state_root_pre bytes, msg_epoch) after signing invalidates the signature.
  • I4validateTripleAnchor first-fail order: rule_version → state_root → fork_id. A rule_version failure SHORT-CIRCUITS before state_root or fork_id are checked. Test asserts by mutating the rule_version_hash AND fork_id together; result must be failed_anchor: 'rule_version'.
  • I5validateTripleAnchor is PURE: same inputs → same output, no module state.
  • I6buildIWANT is order-stable: output event_ids appear in the same relative order as the input IHAVE’s event_ids (the missing/seen filter is monotone).
  • I7buildIWANT over an empty input IHAVE returns IWANT with empty event_ids (single-arbiter no-op).
  • I8withinRetentionHorizon semantics: (current - msg) <= retention is the inclusion test. current=5, msg=3, retention=2(5-3)=2 ≤ 2 → true. current=5, msg=2, retention=2(5-2)=3 > 2 → false.
  • I9 — Signature verification ALWAYS precedes anchor checks in the documented composition pattern. The library does NOT enforce this (each function is independent), but the tests assert the pattern is observable: a callable that wraps verify+validate rejects on bad sig WITHOUT inspecting anchors. This is policy guidance, codified in the JSDoc and a test fixture.
  • I10gossip-wire.ts source contains no forbidden tokens: no Math.*, no Date.*, no new Date, no setTimeout/setInterval/setImmediate, no fetch/XMLHttpRequest, no require('fs'), no crypto.X dotted access (named imports OK), no process.hrtime/nextTick, no await/async function, no float literals, no [native code]. Static scanner replicates messages.test.ts Group 9.
  • I11 — Buffer field encoding: when canonical-serialized, every Buffer becomes its lowercase hex string. event_ids: Buffer[] becomes an array of hex strings.
  • I12bigint field encoding: msg_epoch, timestamp_logical canonical-encode as decimal string (no n suffix), inheriting κ P1.5.4’s behavior.
  • I13 — No partial acceptance: when any one anchor fails, validateTripleAnchor returns {valid: false, failed_anchor: <which>} immediately. The caller MUST drop the entire batch (no per-event-id acceptance carve-outs).

§5. Error surface

All thrown errors are instances of ConsensusSerializationError (re-exported from messages.ts via the import). New error MESSAGES introduced by this slice:

  • “gossip IHAVE event_ids must be Buffer[]” — type guard at signGossipMessage boundary
  • “gossip IWANT event_ids must be Buffer[]” — same
  • “gossip msg signature must be Buffer” — same

These are defensive throws; the TypeScript types should already enforce these but a runtime mismatch (e.g. caller using any) would throw with a clear name.

Non-throw failure paths (return false or {valid: false}):

  • verifyGossipMessage returns false on signature mismatch (no throw)
  • validateTripleAnchor returns {valid: false, failed_anchor} (no throw)
  • withinRetentionHorizon returns false on out-of-horizon (no throw)

§6. Determinism guarantee

  • Two distinct Node ≥ 20 processes producing the same IHAVE byte-for-byte will produce identical canonical-serialized bodies.
  • Same body + same private key → identical signature (Ed25519 PURE is deterministic per RFC 8032).
  • validateTripleAnchor is data-only deterministic.
  • withinRetentionHorizon is bigint-arithmetic deterministic.
  • buildIWANT ordering is input-order-stable.

Phase-0 reality (per consensus.md §195): θ is spec-stable with n=1; gossip messages are never actually exchanged. This slice ships the wire+validator library used by future P3.3.x slices.

§7. Test surface (mapped to invariants)

Group Tests Maps to
1. Module surface 2 I1
2. Type shape construction 3 I1, I11, I12
3. Sign / verify roundtrip 3 I2, I3
4. validateTripleAnchor — all pass 1 I4
5. validateTripleAnchor — rule_version fail 2 I4, I5
6. validateTripleAnchor — state_root fail 3 I4, I5
7. validateTripleAnchor — fork_id fail 1 I4
8. validateTripleAnchor — first-fail short-circuit ordering 2 I4, I13
9. buildIWANT — filter via locally_have 2 I6
10. buildIWANT — filter via seen 2 I6
11. buildIWANT — single-arbiter (empty input) 1 I7
12. withinRetentionHorizon — boundaries 3 I8
13. Signature-before-anchor composition 1 I9
14. Phase-0 schema fit (bigint, Buffer) 1 I11, I12
15. Static scanner 1 I10
16. No partial acceptance 1 I13

Total: ~29 tests.

§8. Sign-off

Contract complete. Step 3 (packet) follows: implementation plan with file layout, helper internals, and the static-scanner pattern.


Back to top

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

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