Repository navigation
feat(protocols): W6.4, gossipsub v1.1 - #59
Conversation
The pure half of gossipsub v1.1: the RPC codec, the signing check, the score function, the caches, and the total router step. The loop task and the publish and subscribe seam stay out. An inbound publish goes to every other mesh peer, and a local publish records its id so an echo is not forwarded again. The heartbeat emits gossip after it settles the mesh and before it shifts the cache, to peers in neither the mesh nor the fanout. The default message id is the payload, because the validator uses strictNoSign.
rpcCapLeMaxLen unfolded Streams.Mock.Varint.maxBytes. PR etheorem#58 deletes that module. decide closes the same goal against the current framing and against the framing after that deletion, so the proof names neither.
The one-exchange initiator returned the first 32 bytes and dropped the tail, so a 33-byte echo passed. Reject an accumulator above the payload size with .wrongSize. The repeating echo handler is unchanged.
The done-when of etheorem#18 names the byte-split two-party runs over the sim. The suite ran both parties over pipe pairs, and every write arrived as one chunk, so a reader that depended on write boundaries could not fail in the suite. The streams package merged as etheorem#32 and its sim takes `splitWrites`, so the runs use the real sim: every write arrives one byte at a time. The suite adapts the sim connection to the mock shape the upgrader takes until the mock drop (etheorem#25). A chunk-agnostic handshake reads the fixed-key marker with a read loop, so the split transport is legal for the whole pipeline. Six checks: the negotiation in both roles, the `na` retry, the size of the responder's chunks, and the full pipeline in both directions with the muxer selected on the wire. The tests change no package code and no mock. Gates on this head: `lake build --wfail` 96 jobs; 7760 passed and 0 failed under the default run and under `+RTS -N1`; 3 skipped; the import lint checked 65 module files with 0 violations.
The TopicParams docstring said the mesh delivery window feeds the core's delivery accounting. The core stores the window and never reads it, and every accepted message from a mesh peer counts toward P3. The docstring now says that, and Core.lean keeps the record. Two blank-line nits ride along.
|
Reviewed at Checks
Every number matches the body's claims. The suite counts match too: 40 RPC, 19 signing, 33 score, 32 cache, 25 core, 149 new checks. The core and its proofsThe shape follows ARCHITECTURE §3.3. Bounded inputs hold. This PR adds no task, no channel, and no shared cell, so the §4.1 and §5 contracts have no surface to check here. §5.6 does not apply either: the PR runs no negotiation and hands no stream. Stringly-typed surfaces stay wrapped: topic names sit in I ran The router theorems say what a reviewer wants them to say. MocksThe mocks comply with RULES.md. The swap obligations at merge time: add the ledger rows on issue #25 for Merge orderIssue #23 needs #19, #20, and #1. #1 is merged. #19 has no PR. #20 is PR #45, open, with a conflict against This PR does not stack on #45. It sits on The known overlap is real. Both branches add Scope honestyThe PR carries the pure half of the issue's list: the RPC codec with the v1.0 and v1.1 control messages, the mesh with GRAFT, PRUNE, and backoff, the heartbeat as a tick, the IHAVE and IWANT handling, the seen-cache dedup, the score function P1 through P7 with decay, the signing decision, and the spec defaults at The PR covers part of issue #23. The loop task and the publish and subscribe seam, unit 3b's remainder at One item has no owner anywhere: the issue's done list names the trickle regressions, and trickle appears in no package document and no unit row. Record the choice, either a unit row in the package Nits fixedOne commit,
The issueThe PR does not implement all of issue #23. It implements the router core, the codecs, the score function, the caches, and the signing check that the package plan schedules for units 3a and 3b's pure half, with proofs. The gate and the live loop follow in their own units. The work present is complete, checked, and honest about its bounds. APPROVE |
The package drops Mock/Varint and Mock/Streams. Message imports Varint and keeps `boundedVarint` as the total length constructor. The upgrader imports Streams.Conn, Streams.BufReader, and Streams.Constants: it writes through the real Connection, keeps the BufReader that each read returns, and takes the 30 s deadline from Streams. Core keeps its mock protocol and error types, but its connection and stream fields now name Streams.Connection and Streams.Stream. That edit is forced by the dropped module: Core imported Mock.Streams and used its types. The identity mock is unchanged. The upgrade suite drops its pipe-to-mock adapter, gives the pipe connections local and remote addresses, and runs the split checks on Streams.Sim connections directly. Tests/Upgrade imports the modules it uses instead of the Streams root, so its own import closure does not reach Streams.Tcp. The test executable still builds TCP and the shim through Tests/Streams. Docstrings and the package architecture note now match the code: the responder writes its header before the loop, `Action` has no error constructor, and the deadline closes the connection through `closeQuietly`.
…ecoder The identify validate now checks every listen and observed address by decoding its bytes with Multiformats.Multiaddr.ofBytes? instead of the local mock predicate. The mock module and its architecture row are gone. The invalid-address tests use a truncated ip4 component, because the real decoder accepts the empty address. Identify and ping name the Streams modules they use instead of the package root. Mock/Core.lean still imports the Streams root, so Identify and Ping still reach Streams.Tcp through that mock. The package docs now describe the current validation, cap, push, and ping behavior.
Issue etheorem#23's done-when names trickle regressions, and no package document named them. Unit 3b owns the loop that schedules the delay, so the row is recorded under that unit.
|
The organiser revises the verdict: One pull request completes one issue. This PR closes #23 and carries units 3a and 3b's pure half, while the loop and seam of 3b and the interop gate of 3c wait in the package PLAN. The issue's done-list also names the trickle regressions, and no unit owns them today. The remaining work lands in this PR before merge:
The code this PR carries reviewed clean (frame caps, axioms, mocks), and that judgment stands. The verdict changes because the issue is not done. |
The done-list of the issue names identify with push. The receiver landed earlier; this adds the sender. `Identify.pushTo` writes the local record on the stream that the host opened under `pushId`, and then half-closes. The host owns the schedule. Go's identify service pushes one record per local address or protocol set change (the spike's `vendor/go-libp2p/p2p/protocol/identify/id.go`, the `loop` and `sendPushes` functions), so the sender starts no timer. The package docs record that decision, and the package plan gains the sender milestone. The responder and the sender share one private writer, so a push and a response carry identical wire bytes by construction. The new test runs the sender against the push handler over the stream pair, and reads the addresses back from the peerstore double. Local checks on this head: the protocols module and its suite build with `--wfail` (71 jobs); the suite passes in isolation with 46 passed and 0 failed; the import lint checks 102 module files with 0 violations. The full suite runs in CI.
The review of the previous commit found three gaps. First, the schedule wording claimed one push per event; go skips a push when the snapshot did not change, merges pending pushes into one round, and skips a connection that already carries the current record. Second, the half-close was untested because the test stream `close` is a no-op; the test now closes the pipe and asserts the clean end. Third, two contract sentences still named `respondTo` where the shared writer lives. The module doc, the package docs, and the error message now say record where they mean the identify message. The docs record the go delta: go splits a record that carries a signed peer record and passes 2 KiB into two frames, and caps one response at 8 KiB, while this package writes one frame and caps at 64 KiB. Local checks on this head: the protocols module and its suite build with `--wfail` (71 jobs); the suite passes in isolation with 47 passed and 0 failed, including the new half-close check; the import lint checks 102 module files with 0 violations.
# Conflicts: # Tests/Host.lean # Tests/Interop/Noise.lean # Tests/Main.lean # Tests/Mux.lean # Tests/Negotiate.lean # Tests/Noise.lean # Tests/Protocols.lean # Tests/Upgrade.lean # lakefile.toml # packages/libp2p/host/Libp2p/Host/Dedup.lean # packages/libp2p/host/Libp2p/Host/Error.lean # packages/libp2p/host/Libp2p/Host/Policy.lean # packages/libp2p/host/Libp2p/Host/Registry.lean # packages/libp2p/host/Libp2p/Host/Swarm.lean # packages/libp2p/mux/Libp2p/Mux.lean # packages/libp2p/mux/Libp2p/Mux/Constants.lean # packages/libp2p/mux/Libp2p/Mux/Test.lean # packages/libp2p/mux/Libp2p/Mux/Test/Session.lean # packages/libp2p/mux/Libp2p/Mux/Test/State.lean # packages/libp2p/negotiate/Libp2p/Negotiate.lean # packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean # packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean # packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean # packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean # packages/libp2p/negotiate/docs/ARCHITECTURE.md # packages/libp2p/protocols/Libp2p/Protocols.lean # packages/libp2p/protocols/Libp2p/Protocols/Constants.lean # packages/libp2p/protocols/Libp2p/Protocols/Identify.lean # packages/libp2p/protocols/Libp2p/Protocols/Ping.lean # packages/libp2p/protocols/docs/ARCHITECTURE.md # packages/libp2p/security/Libp2p/Security/Constants.lean # packages/libp2p/security/Libp2p/Security/Handshake.lean # packages/libp2p/security/Libp2p/Security/Noise.lean
|
Head moved to
Rehearsal on the merged stack, 32 nodes, Gates at this head: build 281 jobs; |
|
Head Sixteen runs cover four compositions on the head binary
Four limits apply. No cell ran Rust alone. A mixed cell has no comparable p50, because Go and Rust stamp records on the 2000-01-01 synthetic epoch and Lean on 1970-01-01. A mixed duplicate count covers both implementations, and the Go and Lean split is 16 and 16 in seeds 1 and 2 and 19 and 13 in seed 3. The Lean-only cell reproduced at seed 1: two runs gave the same analysis file. The Go and Lean cell did not match exactly at any seed: two runs per seed gave 6.385 and 6.258, 6.617 and 6.669, 6.802 and 6.143 duplicates per node, so its run-to-run spread reaches about 10 percent and any duplicate tolerance has to allow for that. The identify fix came before these runs. The Go and Lean seed-1 run of 2026-11-07 had failed the reach check at 0.48; the post-fix Go and Lean seed-1 runs reach 1.0. The 6 November review kept unit 3c in this PR behind #19 and #20. Both merged on 7 November, so these runs used the merged base. The harness checks reach only. We would treat 100 percent reach of the 12 checked messages as the pass condition for 3c, and report p50 and duplicates as information. If 3c needs a tolerance on duplicates or on p50, tell us the number and we will run against it. Is that what you intend? |
|
Delta re-review at What the delta delivers of the unit 3c gateThe adapter: delivered. The harness cells over the real stack: delivered. The green matrix run: recorded, with a broken evidence chain. The body records reach 1.0 for Go only, Lean only, Go and Lean over three seeds with a repeat, and Rust and Lean over three seeds. The numbers are detailed and the pre-fix 0.48 cell is honest. Two facts stop the run from closing the row. First, the body names the run binary's commit as The branch and the current tipThe head does not contain the current tip Mock stateThe gossipsub, identify, ping, negotiate, security, mux, and host paths on this branch take the real seam types. What remains under Checks
The axiom auditI ran Nits fixedOne commit,
Two optional cleanups for the author: What remains for the gate
The adapter and the cells are approved work. The run record is the one open item, and the merged stack is its precondition. NEED CHANGES |
# Conflicts: # Tests/Mux.lean # Tests/Upgrade.lean # packages/libp2p/host/Libp2p/Host/Error.lean # packages/libp2p/host/Libp2p/Host/Mock/Negotiate.lean # packages/libp2p/host/Libp2p/Host/Policy.lean # packages/libp2p/host/Libp2p/Host/Swarm.lean # packages/libp2p/mux/Libp2p/Mux/Constants.lean # packages/libp2p/mux/Libp2p/Mux/Test.lean # packages/libp2p/mux/Libp2p/Mux/Test/Session.lean # packages/libp2p/mux/Libp2p/Mux/Test/State.lean # packages/libp2p/mux/Libp2p/Mux/Yamux.lean # packages/libp2p/negotiate/Libp2p/Negotiate.lean # packages/libp2p/negotiate/Libp2p/Negotiate/Constants.lean # packages/libp2p/negotiate/Libp2p/Negotiate/Message.lean # packages/libp2p/negotiate/Libp2p/Negotiate/Step.lean # packages/libp2p/negotiate/Libp2p/Negotiate/Upgrade.lean # packages/libp2p/negotiate/docs/ARCHITECTURE.md # packages/libp2p/protocols/Libp2p/Protocols.lean # packages/libp2p/protocols/Libp2p/Protocols/Constants.lean # packages/libp2p/protocols/Libp2p/Protocols/StreamError.lean # packages/libp2p/protocols/docs/ARCHITECTURE.md # packages/libp2p/security/Libp2p/Security/Constants.lean # packages/libp2p/security/Libp2p/Security/Handshake.lean # packages/libp2p/security/Libp2p/Security/Noise.lean
|
Head Merge. The merge resolved 24 conflicts to the tip's reviewed files. Two temporary pieces remain.
Gates at the head. The build is clean. The two cells ran on a binary built from this head; its SHA-256 is
Each cell used 32 nodes on A mixed cell has no comparable p50. Go and Rust stamp their records on the 2000-01-01 synthetic epoch; Lean stamps its records on 1970-01-01. We report the duplicate counts as information, since the harness compares reach only. |
|
Delta re-review at The merge
The tip moved after the push: Mock stateThe gossipsub modules import no mock. The run recordThe body now names head ChecksI ran the gates at
Every number matches the author's claims in the comment and the body. The axiom auditI ran Nits fixedOne commit,
What remainsNothing of the three demands. The merge landed on the reviewed seam versions with one disclosed exception tied to issue #70. The gates are green on the merged head. The run evidence carries a verifiable head SHA and both cells at reach 1.0. The host exception and the identify adapter leave with #70 and #55. APPROVE |
# Conflicts: # lakefile.toml
|
Merge-loop note. The verdict demanded one tip merge on the reviewed seam versions; the author's push brought tip |
Summary
Adds unit 3 of
libp2p/protocols: gossipsub v1.1 as a totalstep, with the loop and the application seam that drive it. Unit 3a and the pure half of 3b bring the codec, the signing check, the score function, the caches and the router core. Unit 3b adds the session loop withpublishandsubscribe, the heartbeat schedule that the trickle regressions pin, and the Go-encoded RPC fixture that pins the wire bytes.Refs #23. Row 3c of the package PLAN stays open; the two small questions below ask about the gate.
The live interop gate of unit 3c cannot run before the dependency units listed under "The 3c gate" merge, and the package PLAN gives its row 3c the dependencies unit 3b and W5.6. This description records what we measured instead: nine Shadow control cells of the vendored harness, the attribution of every Lean cell to the unit that blocks it, and the adapter contract. The measurements ran on the author's host. The branch carries the adapter's pure half, and its socket half follows the W5 stack. The image is the
Shadow release tarball plus the Go 1.27.3 and Rust 1.99.1 toolchains over Ubuntu 24.04, and it needs
--security-opt seccomp=unconfined --shm-size=8g. The harness runs its own orchestrator and checks,with one small Makefile override, because the default recipe builds every composition and the
baselines need only the two reference binaries.
The router models a subset of the v1.1 spec. The omissions and the spec deltas are documented in the module header of
Core.leanand in the module header ofNode.lean.What changed
packages/libp2p/protocols:Libp2p/Protocols/Constants.lean: the gossipsub protocol ids/meshsub/1.1.0,/meshsub/1.0.0and/floodsub/1.0.0, and the RPC cap of 1 MiB.Libp2p/Protocols/Mock/: the seam stand-ins for the unmerged dependencies, listed under Risk.Mock/Host.leannow also declaresHostSeam, the two calls the loop makes, andSwitch, an in-memory double of that seam that serves the loop suite.Mock/Core.leancarries the sender peer inStreamHandler, because the router scores, greylists and forwards by it.Libp2p/Protocols.lean: importsConstants, the gossipsub modules andGossipsub/Node.lean.Libp2p/Protocols/Gossipsub/Types.lean: topic and message ids, the published message, the fixed-point score unit, the score thresholds, and the time unit.Libp2p/Protocols/Gossipsub/Rpc.lean: the RPC message and its control envelope, with thePb.ProtoMessageinstances. Field numbers and cardinalities follow the vendored protobufs invendor/specs/pubsub.Libp2p/Protocols/Gossipsub/Sign.lean:strictSign,strictNoSign, the signature input, and the spec's default message id.Libp2p/Protocols/Gossipsub/Score.lean: the P1 to P7 formulas and the decay.Libp2p/Protocols/Gossipsub/Cache.lean: the message cache, the seen cache, the backoff table, and the IWANT promise table.Libp2p/Protocols/Gossipsub/Core.lean:State,Event,Action,ProtocolError, and the totalstep.rundrives a list of events.Libp2p/Protocols/Gossipsub/Node.lean: the loop task over the core, the heartbeat alarm, one writer task and a bounded queue per peer, the/meshsub/1.1.0inbound handler, the receipt observer, and the application seam.packages/libp2p/protocols/docs/PLAN.md: the trickle regressions gain an owner under## Test milestones (unit 3), with the loop suite's checks beside it.packages/libp2p/protocols/tools/go-rpc-fixtures: the generator that writes the Go-encoded RPC fixture, with itsgo.modandgo.sum. Go tooling is test only (repo CLAUDE.md, "Dependencies").Tests:
Tests/Gossipsub/{Rpc,Sign,Score,Cache,Core}.leanreplay fixed transcripts.Tests/Gossipsub/Node.leanruns the loop over the in-memory switch ofMock/Host.leanand a test clock.Tests/Gossipsub/Wire.leandecodesTests/fixtures/gossipsub-rpc-go.jsonand checks it. All of them are wired intoTests/Main.lean.The loop and the seam (unit 3b)
One task owns the router state, and it parks only on the one event channel that
stopcloses. Every action the loop performs returns without parking, so no peer can hold the loop. The delivery callback ofsubscribeand theConfig.onRpcobserver run on the loop task, and the module header requires both to return without parking.Conc.Alarmfor the next due time.stopcloses the event channel, resets installed streams and joins the tasks. A writer parked insideHostSeam.newStreamkeepsstopparked until the host ends that call. The alarm task posts the wake. The loop is arepeatwith a mutable state, as the yamux session loop is, so a ready park costs no stack.Node.droppedcounts the drops, both here and at the pending-send bound. The writer opens the peer's stream, writes in order, and closes the stream when it ends. A failed write retires the entry and closes the queue, so the next send opens a fresh stream; the peer stays in the router, because its inbound stream is still open.Constants.rpcCap, allocates the body once, and copies each input byte once with a cursor. It posts one event per frame with the opener as its sender, and resets the stream on a bad prefix, an over-cap frame or an end of stream inside a frame. Small reads therefore cost linear time, and the reader keeps constant stack for any number of chunks. It announces its opener first, because the core ignores an RPC from a peer it does not know.publishbuilds the message of a topic and a payload and offers it to the queue.subscriberegisters a callback that must not park, andunsubscribedrops the callbacks of a topic.peerJoinedandpeerLefttake the connection events of the host.stopsets a closing flag, closes the channel, closes every writer queue and resets every installed stream, and joins the owned tasks.Config.onRpcsees each inbound RPC, with its sender, before the core step, so a harness can log every received message including a duplicate.The module header records the decisions, five of which cite the exact Go or Rust source line they mirror:
Core.validationOkusesstrictNoSign, which requiresfromandseqnoabsent. The Go and Rust reference implementations configure the same policy (go-libp2p/main.go:35-37,rust-libp2p/src/main.rs:120-131).Core.joinannounces our topics when a peer joins. Nothing announces a subscribe after the join, so the loop sends the one record, before the core step. The Go library announces from its own layer (pubsub.go:1205-1232), outside its router.rand; the hash keeps the sampling shape and makes the test deterministic.gossipsub.go:2078-2092). The loop can spread them across the interval, andConfig.tricklefixes the spread. Its default spreads nothing, so the default behavior matches Go. The suite pins the spread with an explicit function, because issue [W6.4] gossipsub v1.1 (gate) #23's done list names the trickle regressions and the schedule belongs to the loop.What the step implements
strictSignandstrictNoSign, and the signature inputlibp2p-pubsub:followed by there-encoded message with
signatureandkeycleared.dHighdown towardd,the graft up toward
dfrom peers at or above score 0 and outside backoff, and the negativeprune.
message to every other peer of
mesh[topic].fanout when the router does not subscribe.
and the fanout sends its ids to peers of the supplied order that sit in neither set and score at
or above the gossip threshold, up to
dLazyper topic.What the step does not implement
The step omits these. No flag enables them.
PubsubProtois stored on the peer record and no path reads it, so aninbound message goes to the mesh only.
ControlPrune.peersdecodes and round-trips, buthandlePruneignores it,and
acceptPXThresholdis never read.opportunisticGraftThresholdis never read.fanoutTtlis never read, so a fanout entry lives until the router subscribesto the topic or the peer leaves.
handleIhaverecords a promise, and nothing callsPromiseTable.deliverorPromiseTable.broken, so an expired promise changes no score.iwantDeadlineis written and never compared.appScore,ipColocationorbehaviourPenalty, andPeerDatacarries no address, so P6 cannot be computed from the statethe router keeps. The score suite tests the formulas directly.
meshMessageDeliveriesWindowis stored and never read, so a meshpeer counts toward P3 for every accepted message rather than only for a first or near-first
delivery inside the window.
leavedeletes the peer record. The spec keeps the score forRetainScore.planTopicdrops the oldest entries.dScoreis neverread and there is no outbound flag, so the spec's
D_outquota is absent.gossipFactoris stored and never read, so the emission count stays atdLazyinstead of growing with the number of eligible peers.Ignore outcome, the policy is not per topic, and
validationOkusesstrictNoSign.publishThresholdisstored and never read. The gossip threshold and the graylist threshold are read.
unsubscribewritespruneBackoff. The spec uses a shorterUnsubscribeBackoff, andsubscribedoes not consult the backoff.Recorded deltas
Message.fromis bytes. The spec saysoptional string. The codec reads and writes bytes,so a
fromvalue that is not valid UTF-8 round-trips here and is rejected by a conforming peer.data,seqno,signatureandkeyare bytes in the spec too.keyand drops unknown fields. The spec signs the marshalledbytes with
signatureremoved and with unknown fields kept. This follows the Go implementationand clears
keyas well, and it re-encodes, so an unknown field that arrived on the wire is notin the signed bytes.
Sign.leanstates thekeypart.SubOpts.topicidand the controltopicIdfieldscollapse an absent field to
"", so a re-encode emits a field the input omitted.P7has a threshold. The vendored spec describes P7 as the square of the counter, and theglobal parameter table names no threshold. This module carries
behaviourPenaltyThreshold, asthe Go implementation does, and squares the excess over it. A threshold of 0 reproduces the
spec, and
Score.leanstates this.validationOkusesstrictNoSign, which requiresfromandseqnoto be absent, so the spec's default idfrom ++ seqnois empty for everyaccepted message and the seen cache would treat all of them as one.
defaults.msgIdis thepayload bytes, which is the content-addressed choice the spec pairs with
strictNoSign. Theconsequence, which the spec's own margin note records, is that two messages with the same
payload collide inside the seen-cache window.
Sign.defaultMsgIdremains for a caller thatpairs it with
strictSign.PRUNE. The v1.1spam measures say to ignore it, because answering opens a way to flood the router with
GRAFTs. This step follows v1.1, andcore.reject.graft-unknown-topic-ignoredpins thatchoice.
historyLength. An entry written at age 0survives
historyLength - 1shifts, andgossipIdslists entries withage < historyGossip.The loop's deltas from the reference implementations.
Core.defaultspins the payload bytes, which is the content-addressed choice that pairs withstrictNoSign. The harness adapter suppliesCore.Params.msgId.Node.droppedcounts the drops.newStream,stopwaits for that call to return; the module header assigns the end of the open to the host session teardown, and the mock switch never parks.The 3c gate (unit 3c)
Every unit of the row's closure merged on 2026-11-07: #11 identity (#63), #14 yamux (#62), #15 core (#64),
#16 Noise (#69), #17 mux (#49), #18 negotiate (#40), #19 host (#61) and #20 identify/ping (#45), with
#43/#75 ENR and #46 req/resp.
hackathon26stands atfd70eeb, and this branch carries the merged stack.The adapter's pure half.
Libp2p/Protocols/Gossipsub/Interop/reads theparams.jsonscript (theseven in-scope instructions, with the two v1.2 ones rejected), writes the NDJSON records, resolves the
node number from the
nodeNname, formats the harness message id, reads the hosts table, and plans therun over a virtual clock.
Tests/Gossipsub/Interop.leanchecks it with 160 checks, registered in thesuite runner, so CI runs them.
The socket half.
packages/cli/GossipsubInterop.lean,GossipsubSession.leanandMain.leanbuildthe
gossipsub-interopexecutable over the real host: TCP, Noise, the yamux adapter, multistream-select,identify and the gossipsub loop. A small C shim in
packages/cli/hostnameresolves thenodeNname.One interop fix. The identify response lists
/ipfs/id/1.0.0and/meshsub/1.1.0now. Before thechange, go-libp2p-pubsub never opened its outbound pubsub stream to a Lean peer, because it notifies a
peer only when identify lists the pubsub protocol id. In a 32-node seed-1 mixed cell, Go's outbound RPCs
to Lean peers went from 0 to 3851, subscriptions from 0 to 153, GRAFTs from 0 to 44 and publishes from 0
to 991. The mixed cell reached 48 percent before the fix and 100 percent after it. That 0.48 run predates
the cells below.
The 3c gate runs on the merged head. Head
7ac636a89f5e207a2a6785320d4ff661c7f50093is the nit commitd1c1ab3plus the merge ofhackathon26at6a1f832. The two 3c cells below ran on it. We built theparticipant from this head; its SHA-256 is
0e083022d1896af1ecb8145931e278b406288c3c9823f60b390f51865a5a9e2a.That digest belongs to the executable file. The earlier
cc4a74aeis also the executable digest of the407878cbuild.Every run in this section used 32 nodes on topic
subnet-blob-msg, in Shadow 3.3.0 and thegate-shadow:2026-11-06image, with the vendored harness at/work:Reach 1.0 means all 12 checked messages reached every non-publisher node. The check skips the 4 warmup
messages. p50 is the mean of the 12 per-message median dissemination times. Duplicates per node is the
analyzer's average duplicate count per node over the same messages.
An earlier matrix of sixteen runs on head
407878c, with the executablecc4a74ae, gives the seed and repeatmeasurements:
The analyzer's cross-implementation p50 is therefore unusable.
16 Lean in seeds 1 and 2, and 19 Go and 13 Lean in seed 3. The Lean-only cell reproduced byte for byte at seed
1; the Go and Lean cell's count moved by up to about 10 percent between two runs of the same seed.
The harness compares reach only.
checks/subnet_blob_msg.pyfails below 1.0, andanalyze_message_deliveries.pyreports p50 and the average duplicate count without comparing them. We reportthe duplicate counts above as information.
Test plan
On this host. The full
lake testdoes not link on Windows, because the crypto C shims need OpenSSL 3.2or newer headers that the Windows toolchain does not carry. The five gates of
.github/workflows/ci.ymlon this head ran in a Linux container with the pinned toolchain and the vendored sources.
lake build --wfail: "Build completed successfully (281 jobs)", zero warnings.lake test: "summary: 27696 passed, 0 failed, 0 todo (known gaps), 10 skipped (needs phase 1+ features)".lake test -- +RTS -N1 -RTS: the same summary line.scripts/lint-imports.sh: "lint-imports: checked 180 module files, 0 violations".scripts/vendor-pull.sh --verify: every vendored tree reports OK.#print axiomsover the public theorems of the gossipsub modules is unchanged by this head: nosorry, noadmit, nonative_decide, nounsafe, and no module declares an axiom.407878c.The gossipsub suites add the loop checks (109), the wire checks (104) and the interop checks (160).
Notes for reviewers
Score monotonicity requires non-zero penalty weights.
Params.Validforces the penalty weightsto be non-positive, and
defaultssets every weight to 0, so under those parameters themonotonicity theorems hold because both sides are 0. They become non-trivial only when the caller
configures a non-zero weight, which is what the score suite does. The core suite uses the zero table except for
the one check that configures
invalidMessageDeliveriesWeight.heartbeatMeshBoundholds for any score parameters. The mesh bound relies only on theList.takeatdHighand on the fact that the negative prune only removes peers, so the mesh sizeinvariant is independent of the score configuration.
The gossip score gate covers every gossip action.
mem_gossipActionsconcludes the thresholdfor every action
gossipActionsbuilds, because every one of them is an ihave.tickGossipGatedlifts that to the heartbeat by showing that the prune and graft actions fail the ihave test.
rpcGraylistIgnoredproves the gate. The graylist premise is the only hypothesis, and theconclusion is
step p s (.rpc sender bytes) = .ok (s, []), so the gate runs before the decode andthe bytes are never consulted.
Deduplication is one step.
stepDeliverUnseensays a delivered message was absent from theseen cache of the pre-state. It does not say that a message is delivered at most once across a run
of
run, because the one-step form is what the acceptance loop establishes directly.Two graft laws are private equations. A graft into an active backoff and a graft into a mesh
that already holds
dHighpeers each produce a prune. Both areprivate theorems,handleGraft_backoffandhandleGraft_overfull, and both have a suite check. Neither is statedas a property of
step.The backoff exclusion covers the heartbeat only.
tickNoBackoffGraftsays the heartbeat nevergrafts a peer whose backoff is active.
subscribealso grafts and filters backoff, and no theoremcovers that path.
An inbound forward never returns to the sender.
forwardPubs_excludes_sendersays so, andcore.forward.meshchecks a three-peer mesh.publishLocalrecords the id it forwards, so a laterecho of the same id is neither delivered nor forwarded again, which
core.forward.own-echo-droppedchecks on a non-empty mesh.Risk / trust footprint
No new Lean dependency. The gossipsub modules import
Pb,Streams.Framing,Constantsand the gossipsub modules themselves.packages/libp2p/protocols/tools/go-rpc-fixturesis a test-only Go tool (repo CLAUDE.md, "Dependencies": Go tooling stays test only and never on a shipped or proof path). Lake does not build it, and the import lint reads only Lean modules.When this branch started, four dependencies were unmerged, so it declared their interfaces in
Libp2p/Protocols/Mock/. Every one of them merged on 2026-11-07, and the branch now deletes the six mocks it owned: this package's core, host and negotiate mocks, the negotiate core mock, the mux core mock and the security core mock. The gossipsub loop, the upgrader, the host, the muxer and identify takeLibp2p.Coretypes, and the inbound handler readsctx.remotePeerfromLibp2p.Core.StreamContext.Libp2p/Protocols/Mock/keeps the req/resp mocks that #46 landed with its own package. They move with that package, not with this branch.#print axiomsover the public theorems shows the standard axioms only, apart from the three RPC frame theoremsdecodeFrame?Encode,decodeFrame?EncodeRpcanddecodeFrame?OverCap. Those reduce toStreams.Framing.Frame, which takesStreams.Mock.Varint.decodeEncodeSuffix. That axiom belongs tonet/streams, which this pull request does not touch, so removing it belongs toFraming.lean.LICENSE (LGPL version 3)