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
verifyGossipMessagereconstructs - Throws
ConsensusSerializationErrorif 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
falseresult 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:
- rule_version:
Buffer.compare(msg.rule_version_hash, receiver.active_rule_version) === 0. If not, return{valid: false, failed_anchor: 'rule_version'}. - state_root continuity: Either
msg.state_root_preis inreceiver.known_state_roots(keyed bymsg.state_root_pre.toString('hex')), OR(msg.msg_epoch - receiver.last_checkpoint_epoch) <= 1nANDmsg.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'}.
- 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)
- I1 —
IHAVEandIWANTare structurally distinct; themsg_typediscriminator never overlaps. - I2 —
signGossipMessageandverifyGossipMessageare inverses: for any IHAVE/IWANT with stripped signature, signing then verifying under the corresponding keypair returnstrue. - I3 — Signature verification depends on EVERY field except
signatureitself. Mutating any field (includingevent_idsorder,state_root_prebytes,msg_epoch) after signing invalidates the signature. - I4 —
validateTripleAnchorfirst-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 befailed_anchor: 'rule_version'. - I5 —
validateTripleAnchoris PURE: same inputs → same output, no module state. - I6 —
buildIWANTis order-stable: outputevent_idsappear in the same relative order as the input IHAVE’sevent_ids(the missing/seen filter is monotone). - I7 —
buildIWANTover an empty input IHAVE returns IWANT with empty event_ids (single-arbiter no-op). - I8 —
withinRetentionHorizonsemantics:(current - msg) <= retentionis 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.
- I10 —
gossip-wire.tssource contains no forbidden tokens: noMath.*, noDate.*, nonew Date, nosetTimeout/setInterval/setImmediate, nofetch/XMLHttpRequest, norequire('fs'), nocrypto.Xdotted access (named imports OK), noprocess.hrtime/nextTick, noawait/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. - I12 —
bigintfield encoding:msg_epoch,timestamp_logicalcanonical-encode as decimal string (nonsuffix), inheriting κ P1.5.4’s behavior. - I13 — No partial acceptance: when any one anchor fails,
validateTripleAnchorreturns{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}):
verifyGossipMessagereturnsfalseon signature mismatch (no throw)validateTripleAnchorreturns{valid: false, failed_anchor}(no throw)withinRetentionHorizonreturnsfalseon 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).
validateTripleAnchoris data-only deterministic.withinRetentionHorizonis bigint-arithmetic deterministic.buildIWANTordering 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.