Net v0.35 — "Doubleback"
Named after ZZ Top's 1990 single, written for a film about going back over a timeline you have already lived in order to repair what you got wrong the first time.
Seven tracks land, and not one of them is a new capability:
- Seven cross-language consistency audits — channels, EventBus, nRPC, identity/token, mesh+stream, discovery/capability, packaging — each walked across the same five-language spine (Rust core + Rust SDK, Node/NAPI + TypeScript, PyO3 + Python, Go, C). 58 findings. The recurring shape is method-name parity concealing semantic divergence: a reliability string that fails open, an unknown modality that broadens a filter, a receive path that reads one shard in four.
- Four black-box usability scans — one per surface, following only public material and freezing every witness before any implementation source was read. 23 findings: 1 BLOCKER, 10 MAJOR, 10 MODERATE, 2 MINOR. Their common shape is not "it is broken" but "the documented path is not the real path": a package whose dependency range is the empty set, a binary that hangs on
--help, examples that printDone!having achieved nothing, a published C program that does not compile, an SDK accessor the docs invented. - One library. The C and cgo surface collapses from eight cdylibs to one
libnet, because two copies ofnet::ffiin a process meant two copies ofparking_lot_core's parked-thread registry — a lock released without waking its waiter. That is the nine-minute Go CI hang, root-caused inside the load-balancer slice and fixed by a packaging change. - nRPC outside Rust actually serves. Registration entered no Tokio runtime, and no binding installed the strict channel policy the protocol requires. Both are fixed at the layer that owns them; the C ABI is resynchronized at
0x0004; and the guard that was supposed to catch exactly that drift was comparing>=. - Identity generation becomes durable state. Generation-based revocation was a one-way door: every public issuance path hardcoded generation 0, so
revoke_below(issuer, 1)retired an identity permanently. Issuance is now generation-aware, issuer state is durable and versioned, and every delegation link carries its own signer's generation. find_best_nodein all five bindings — and the stop gate in front of that parity work found the core selection path reading the fold twice, scoring under both read guards, and letting aNaN-scoring candidate win by arriving first.- The guards, and the guards' own defect. Thirteen new checkers, plus the discovery that several existing ones had been reporting success without executing anything at all.
The organizing observation follows the last three cycles and turns them inward. v0.32 was a fast path layered over a correctness path that never moves; v0.33 was one shared answer layered over an authority that never moves; v0.34 was a boundary layered over an identity that never travels. v0.35 is a claim layered over evidence nobody took. Every defect this cycle lives in the gap between a surface that resolves and a surface that works, and the gap was invisible precisely because something green was standing in front of it — a copy-equality check that proves mirrors match while proving nothing about ownership, transport, policy, return values, or lifecycle. So remediation lands twice: once in the code, once as a mechanical guard that fails when the claim drifts back. That is why a release which adds no plane still moves 344 files.
The audits — parity of names is not parity of meaning
Seven passes, each fixing one surface and walking it across all five bindings plus the documentation that describes it. Findings are per-boundary source citations backed by live boundary reproductions, and each carries a required closure that usually offers two branches: expose the behavior consistently, or record the gap and delete the guidance that promised it. Taking the second branch is written down as closed as documented gap rather than left to a reader's inference.
- Channels (18 findings, the largest pass).
NetNode.channel()in TypeScript and Python was never a distributed mesh channel: it tags generic EventBus JSON with_channeland installs an exact payload filter, so no name validation, roster, membership ACK, capability auth, orPublishReportwas ever on that path. Fifteen findings closed, five of them as documented gaps. The name grammar is now validated in theTypedChannelconstructor and exported asvalidate_channel_name/validateChannelName— ≤255 bytes (a 128-character two-byte name fails), no empty name, no leading or trailing/, no//, no uppercase, no.or..segments, with 52 inverse tests in Python and 74 in TypeScript.CHANNEL_TAG_KEYis exported and the routing tag is stripped before typed delivery on both parse paths, so a strict Pydantic model stops rejecting every event on the channel over metadata it never declared;subscribeRaw()/subscribe_raw()remain the escape hatch that still yields the tag. Go gainedTokenRootswith thetoken_rootsJSON tag the C FFI had always parsed — without it, a token-gated channel was unreachable from Go and C. And the token wire size is 169 everywhere (five surfaces said 161, Go said 159), now derived fromtoken.rsby a canary that immediately found two stale comments the manual sweep had missed. - Mesh and streams. Inbound traffic lands on
stream_id % num_shards;recv()and every binding poll read shard 0 only, so at the default four shards three quarters of stream ids were unreadable — and the guide's own examples were impossible (stream7polled on shard 0, when7 % 4 == 3). Fixed twice: sweep every shard, then rotate the sweep start, because a continuously fed shard 0 that fillslimitstarves the rest. - EventBus. Reliability parsing was fail-open —
"FULL","ful","reliable"all constructed successfully and silently downgraded delivery from acknowledged and retransmitted to fire-and-forget. It now fails closed, withreliability_rejects_every_near_misspinning it.net_pollhonours theordering,filterandshardsit had been accepting and discarding, and rejects unknown keys rather than reverting to defaults. - Discovery and capabilities. An unknown modality string meant four different things: Node and PyO3 coerced it to
Modality::Text, advertising a capability the node does not have; C and Go dropped it, which on a filter is fail-open — the constraint disappears and the query widens to every otherwise-eligible node, so a scheduler can pick a node that cannot do the work. It is now rejected uniformly.fp16_tflops_x10is written asu32at all three boundaries instead of saturating at65_535or round-tripping through an f32 mantissa; an ordering test proves 1e9 and 2e9 no longer compare equal. - Identity and tokens. Beyond the generation repair below: the
wildcardscope is exposed across TypeScript and NAPI and unknown scopes are now rejected,issuerGenerationis surfaced in parsed projections, detached signature verification is available from the bindings, and TypeScript validates token numerics before NAPI instead of coercing them —ttlSeconds: 1.5became 1 and2**32became 0 and was then blamed aszero_ttl.zero_ttlandttl_too_longare real error kinds now rather than collapsing intoinvalid_format. - Packaging. Published Python wheels omitted
organddeck— neither is a crate default, and the release workflow built withredis,extension-modulewhile CI's own test wheel enabled both. So the wheels shipped without organization auth or Deck while the capability record marked both supported and the v0.34 note claimed authority verbs in five languages. - nRPC. Non-Rust serving was not viable at all; see below.
The pass records its own structural finding: the green checks that had been standing guard proved symbols resolve and mirrors match, and so propagated a false interpretation all the way into released artifacts.
The usability scans — the documented path was not the real path
Four scans, one per surface, run against a pinned head, following install pages, READMEs, go doc, the registries, and the shipped .d.ts — nothing else. The template each finding fills in is the point: user intent → public path followed → first divergence → expected/actual → workaround (kept separate from the defect) → implementation-source inspection required: yes/no. Nearly every finding says no.
pip install net-mesh-sdkcould not resolve. The declared dependency wasnet-mesh>=0.35.0,<0.35.0— the empty set — so resolution failed before any wheel was fetched: "Because only net-mesh-sdk==0.35.0 is available and net-mesh-sdk==0.35.0 depends on net-mesh ∅, we can conclude that all versions of net-mesh-sdk cannot be used." The over-correction that produced it is now guarded by a test that reads the checked-in metadata for all four Python distributions and fails on any specifier that excludes every version near its bounds.net-deck --helphung. All of--help,-h,--versionand an unknown flag entered the alternate-screen TUI:EXIT_CODE=124 ELAPSED_SECONDS=3 STDOUT_BYTES=5204— five kilobytes of ANSI into a pipe, killed by timeout. Deck now parses argv before starting: help and version exit 0, an unknown flag exits 2, and both streams are TTY-checked, becauseecho | net-deckhas a perfectly good terminal on stdout and still hung forever.NET_DECK_ALLOW_NON_TTYis the deliberate override.net-mesh snapshot getreported a healthy cluster having inspected nothing. Exit 0 in 0.039 s with entirely plausible JSON —{"daemons":{},"replicas":{},"peers":{}, "local_maintenance":"Active", …}— because there is no attach path: the Deck client is built from a supervisor the invocation itself started milliseconds earlier. An empty snapshot and a healthy idle cluster are the same document.--localis now mandatory onsnapshot getandsnapshot status, and under--localthe caveat prints on stderr so a pasted result carries it.- Go with the default
CGO_ENABLED=0produced a hollowed-out API. The failure surfaced asundefined: net.Newin the user's file. A//go:build !cgoguard now fails the package itself by calling an undeclared identifier whose name is the message:this_package_requires_cgo__set_CGO_ENABLED_1_and_install_a_C_compiler__see_https_ai2070_net_docs_start_install_go(). net.Version()returned0.8.0from a 0.35.0 build. A hand-writtenb"0.8.0\0"that survived 27 minor releases; the ABI guard beside it was reportingabi=4 expected=4, which is why nobody noticed. It is derived fromCARGO_PKG_VERSIONnow.- The shipped examples announced success they had not achieved. The Go example printed
Polled 0 events (has_more=false)and thenDone!; the C twin printed nothing and exited 0. The repair is honesty rather than behavior: the default memory adapter counts events and discards them, by design, and the only success an example on it can assert is producer-side acceptance vianet_stats_ex. The Go example also splitmainfromrun, becauselog.Fatalfcallsos.Exitand skipped its owndefer bus.Shutdown(). - The published C quickstart did not compile —
call to undeclared library function 'free', with onlynet.h,<stdio.h>and<string.h>included. Every complete C program in the docs is now compiled against the real headers under-std=c11 -Wall -Wextra -Werror. - The TypeScript docs taught a private-field reach-around that never worked. The announce page said to cast to
_nativeand callTypedMeshRpc.fromMesh, which throwsFailed to recover 'NetMesh' type from napi valuebecause the native pointer lives in a WeakMap and the cast yieldsundefined.MeshNode.rpc()is the accessor that should have existed;localAddr(),publicKey(),nodeId()andentityId()join it, and the quickstart's PSK is typed as what it always had to be — a 64-character hex string, not aUint8Array. - The shipped
.d.tsfailed a trivial strict consumer —error TS2304: Cannot find name 'DaemonBridgeTsfns', plusDuplexHandlerArgsandGreedyConfigJs, all now declared viats_args_typewith the former error quoted at each fix site.MeshRpc.closehad been on the Rust side since it was added and was simply absent from the hand-written interface, sorpc.raw.close()did not type-check. npm installsaidfound 0 vulnerabilities, then the binary exited 127 withFailed to locate @net-mesh/cli-win32-x64, because npm silently skips an unresolvable optional dependency.- The Python quickstart's handshake could not work as printed. A sequential snippet died after ~20 s with
accept: connection error: handshake timeout; both halves have to be in flight at once, and the page now says so and runs the responder on a thread. - Windows link and loader steps were missing entirely — a linked executable exited 127 with nothing on stderr after a build that reported success.
net.dll.libis what the linker needs andnet.dllwhat the loader needs; the MSVC and MinGW paths, thegendef/dlltoolroute, and thePATH-based loader setup are all documented now, along withCGO_LDFLAGSfor the fetched Go module cache.
One library
The consolidation is one line for a consumer — link -lnet, and nothing else — and the reason it is not cosmetic is a lost wakeup.
- Eight cdylibs, eight copies of
net::ffi. Each surface crate tooknet-meshas a path dependency and therefore embedded and re-exported its own full copy, verified by reading the export tables:libnet_orgdefined all 57net_mesh_*entry points itself. Link-order interposition unifies the functions. It does not unifystatics.parking_lot_corekeeps its parked-thread registry in a process-global static hashtable — one per shared object — so a thread that parked onMeshNode::announce_muthroughlibnet's table was never woken by anunlock_slowconsulting a different copy's, and the mutex was left free with no owner anywhere. - The symptom.
TestLiveSubnetExportedCallFromAGeneratedScenarioblocked roughly nine minutes insidenet_mesh_announce_capabilities, inRawMutex::lock_slow, while a core dump showed every worker thread in the process parked idle. Go handed anArc<MeshNode>fromnet_mesh_newin one library tonet_subnet_serve_exportedin another; the announce parked in the first table and the serve-triggered re-announce unlocked through the second. - The fix is topology.
bindings/go/net-ffiis now the single cdylib: it takes[lib] name = "net", so-lnetin every existing document stays correct and simply means more than it used to, and the root crate gives up its owncdylibcrate-type. The seven surfaces become rlibs, pulled in by eight load-bearingextern cratelines under#![allow(unused_extern_crates)]— a dependency nothing refers to may never be linked, and#[no_mangle]does not make an item reachable. The crate defines exactly one symbol of its own,net_ffi_abi_version() -> 1, stamping the collapsed layout. - The text half was the half that shipped wrong. The code had consolidated while eleven headers, both skill corpora, and every cgo prelude still told readers to link libraries that are no longer built — including
go/net.h, the copy cgo actually compiles, which an earlier sweep had missed while updating the published mirror beside it.check-one-library-docs.pynow bans per-surface link flags, per-surface build targets, and counted claims of more than one shared library in consumer-facing text; it is deliberately narrow, exempts the release-notes directory (v0.12 really did shiplibnet_rpc), and carries a self-test containing the exact sentences that shipped. - CI counts the copies. A four-assertion gate checks that every
cgo LDFLAGSline ends in-lnet, thatlibnet.soexports at least one symbol for each of the eight prefixes, that no straylibnet_*.soexists — a second cdylib carryingnet::ffiis the hang — and that DWARF is present, because a hang dump that cannot unwind past the FFI entry point is not a diagnosis. The Go hang harness runs both suites underGOTRACEBACK=crashwith a fixed-path test binary, and its first gdb command isinfo sharedlibrary: the loaded-library list is the one-cdylib check.
nRPC outside Rust
Three independent defects stacked so that no non-Rust binding could serve, while the suites named "cross-language" stayed green — they run handlers in-process against stubs, and none of them starts a mesh or registers a native service.
- Registration entered no runtime.
MeshNode::serve_rpc*is synchronous but spawns its inbound bridge with a baretokio::spawn, and the FFI exports are called from a cgo thread with nothing in context:there is no reactor running, must be called from the context of a Tokio 1.x runtime, process exit 127 on Node. Runtime ownership stays with the binding, deliberately — core must not construct a private runtime or silently pick a global one. Node captures its NAPI handle atcreate()and enters it around every registration; Go and C enter their existing static runtime. New in-crate witnesses drive each of the four exports from a freshly spawned thread that first asserts no runtime is current. - No binding had any nRPC channel policy. Node and Python install a strict, empty channel registry by default and reject unknown channels — and the sole caller of
install_rpc_service_defaultswas a Rust SDK hop that no binding passes through. Core's four serve seams now install it themselves, the helper became fallible (ServeError::InvalidServiceName, returned having mutated nothing), and both entries go in under one lock so a concurrent reader cannot see half a policy. Insert order is prefix-first on purpose: the two entries are not atomically visible, and cannot be without putting a lock on the per-packet authorization path, so the window is arranged to fail closed. A test over 20,000 freshly published registries fails deterministically if the inserts are swapped. The now-callerless SDK hop is emptied and pinned empty, and core is pinned at exactly four call sites. - The C ABI shipped at
0x0002against a0x0004implementation — the two cancellation functions had gained a leadingMeshRpcHandle*at0x0004, so cancellation routes through the substrate's per-meshCancelRegistry, and the header never followed. A consumer built against it passed whatever was in the first argument register as a mesh pointer. Worse,net_rpc_check_abi_versioncomparedruntime >= expected, so a stale0x0002header checking a0x0004library passed: the one guard that existed was blind to precisely the change it was there to catch. Both are fixed,net_org_check_abi_versionhad the same defect and got the same correction, andcheck-rpc-abi-parity.pynow re-derives header-versus-implementation agreement mechanically across four surfaces (nrpc, org, meshos, compute) — widened afternet_org_set_handler_dispatcherchangedvoidtointand nothing failed, because a gate covering one of several identical surfaces mostly teaches you which one is covered. - Two adjacent defects, one of them filed wrong. Aborting a server-streaming call did nothing at all:
callStreamingandcallServiceStreamingpassed theAbortSignalstraight into the raw options, reserving no cancel token and registering no listener (reserve = 0 / cancel = 0 / cancelToken = null). And terminal frames arrived up to 15.8 seconds late on loopback, first diagnosed as cross-call interference specific to server streaming; it was neither. Every streaming shape was waiting on a garbage collection to release the JS-side response sink. The sink is now dropped when the handler finishes.MeshRpc.close()exists becausefromMesh()pinned the node'sArc<MeshNode>and madeshutdown()fail with outstanding references exist, andrequestWindowInitialis finally declared on the publicCallOptions— it existed natively, was referenced by the implementation and the docs, and typed callers hitTS2353and could not enable upload flow control without an unsafe cast.
Identity: generation becomes durable state
Generation-based revocation was a one-way door. Every public issuance path hardcoded generation 0 and the documented try_issue_with_generation did not exist, so after revoke_below(issuer, 1) that identity could never mint a valid token again — revocation and permanent retirement were the same operation.
PermissionToken::try_issue_with_generationanddelegate_with_generationare real, andIdentitycarries durable, versioned issuer state (to_state_bytes,from_state_bytes,at_generation) so a rotation survives a restart.- Every delegation link now carries its own signer's generation. A delegated child used to inherit
parent.issuer_generation, which stamped an epoch belonging to an entity that did not sign the child — in a chainroot -> machine -> gateway, themachine -> gatewaylink carried root's generation while being checked against machine's floor. Revocation stays transitive:TokenChainchecks each link against the floor for that link's own issuer. Re-applying a generation at the ceiling is idempotent rather than an error. - Node, Python and C all expose issuer generation and durable state, and the same repair reached the SDK delegation builders, which sign with their own identity's generation.
- The correction is deliberately invisible in behavior today: with the public issuance surface as it stood, every reachable parent was already at generation zero — which is exactly why the old rule could be wrong for this long without anything failing.
find_best_node, and what a coherent selection costs
Single-winner weighted placement existed in Rust, Go and C; Node/TypeScript and Python had only find_nodes. Closing that hole was the small half of the work.
- Parity, with one scoring authority.
findBestNode/findBestNodeScopedandfind_best_node/find_best_node_scopedland in the Node and Python bindings; the bindings marshal a filter plus four optional weights and call core, which owns the clamp. Non-finite weights are rejected at the dynamic-language boundaries (InvalidArg,ValueError), and Rust — the one permissive surface — now mapsNaNto0.0explicitly, becausef32::clampreturnsNaNunchanged and the scoring guards readweight > 0.0, which is also false forNaN: the caller asked for a preference, got no error, and got scoring that ignored it. - An unscoreable candidate must not win by arriving first. Candidates arrive in ascending node-id order and a later one displaces only on a strictly greater score, and
NaNcompares false in both directions — so aNaNon the lowest id was admitted unconditionally and beat every real score behind it. ANaNscore is now skipped rather than ranked, with a separate lowest-matching fallback so an all-unscoreable set still returns the lowest matching id: they matched the filter, so "no winner" would be the wrong answer. - One snapshot, and scoring outside the guards. Selection used to read the fold twice — membership from one generation, per-candidate scoring input from another — which could return a winner selected on one generation's capabilities and scored on another's, or scored against a default capability set after an eviction: a zero-capacity node beating a live one under a memory weight. Neither result existed in any single fold state. Filtering, scope evaluation and per-candidate synthesis now happen inside one fold read that hands back owned values, and the caller-supplied scorer runs after the read guards are released, because it carries no fold-purity contract and would otherwise hold both guards — with every fold writer queued behind it — across candidate-controlled work. The first snapshot witness was vacuous, evicting before the query began; its replacement evicts from inside the scorer, which makes it a deadlock witness too.
- The cost is documented rather than implied. Lock hold time now scales with the candidate set instead of with the membership decision — a full tag parse plus a metadata clone per admitted candidate, under both read guards, at multi-microsecond-per-node scale — and peak memory holds every candidate's set at once. Accepted because the callers are operator-initiated placement queries; the per-packet paths stay on the cached single-node route, and a hot caller appearing here is the signal to revisit, not a reason to widen it.
- Two TypeScript filter axes had never once applied. The wrapper declared
minVramMb/minMemoryMbwhile the native binding generatesminVramGb/minMemoryGb; napi passes through the fields it knows and neither side treats an unknown key as an error, so both filters were dropped in transit and every threshold matched everything. A query written as "at least 16 GB of VRAM" returned nodes with none — and the test that covered it passed, because its assertion held without the filter.
The guards, and the guards' own defect
Thirteen new checkers ship this cycle, and the reason they are worth naming is what was found while adding them: a checker can exit having executed nothing, and the calling shell reads silence as a clean corpus. Two live instances. On a cp1252 shell, two skill checkers raised UnicodeDecodeError on the first source file containing an em-dash — every run, reported as success. On Windows, the Microsoft Store shim occupies the name python3 and exits 49 without running anything, so every Python-backed section printed a green tick having executed no Python. That is also how a false positive got recorded as a real defect earlier in the cycle: a description measured at "3011 chars against a 3000 budget" was actually 2991, because an unpinned encoding decoded every em-dash as three characters.
lib/checker.shis the fix.failis a counter, not a flag — as a flag, the first failure made every later section print its green tick.$PYTHONis resolved by running candidates and requiring ≥3.8, and its absence stops the run rather than skipping the checks. Four benign warning categories are silenced at the source so the stderr rule can stay strict, and MSYS path conversion is suppressed per invocation, after Git Bash rewrote--exclude /releases/into a program-files path and produced 78 phantom findings.run_checkerclassifies four outcomes, two of which are new: an exit above 1, and a non-zero exit with no output, both mean treat its verdict as unrun.check-checker-lib.shis the regression test for that classification, run against planted fixture checkers so its verdict cannot move when the corpus changes; it also fails any sibling that invokespython3directly.- Fail closed on absent prerequisites. An example manifest that fails to list leaves every language section reporting "no examples" and exiting green having compiled nothing — that now names the exit status and fails.
check-script-permissions.pyskips when PyYAML is missing locally but takes--require-yamlin CI, where a silent skip checks nothing and reports success. - A list that read maintained and could never fire was deleted from the version checker: every entry in its allowlist had a non-zero major, and the test three lines below already excluded all of them.
- The rest of the new gates, each holding one class of this cycle's defects:
check-rpc-abi-parity.py(header/implementation drift across four surfaces),check-token-wire-size.py(a documented constant drifting fromtoken.rs),check-c-doc-snippets.py(published C that does not compile),check-one-library-docs.py(text promising a link topology that no longer exists),check-npm-platform-packages.py(an aggregate whose per-platform binary is missing — cross-checking the three lists that must agree, and again against the registry immediately before publish, because a succeeded matrix is not a covering matrix),check-python-wheel-features.py(a published wheel with fewer features than the tested one),check-npm-peer-range.py(an SDK whose own co-released core cannot satisfy its peer range),check-install-version.py(install pages naming a superseded release),check-ts-consumer.sh(declarations that only fail for consumers who turnskipLibCheckoff),check-script-permissions.py(a workflow step dying at exit 126 over a missing executable bit in git's index), andgo-test-with-native-stacks.sh. Every Python checker carries a--self-testthat plants the exact defect that shipped. - Binary-interrogating tests join them on the Rust side:
help_is_self_contained.rswalks the whole clap tree — over thirty help pages — and fails on ten repo-internal file names and onnet <verb>for twenty-nine known verbs, after 112 sites spelled the binary wrong and the root help pointed at a plan document that ships in no package.readme_commands.rsextracts every command the README publishes and resolves it against the real binary, with a guard-the-guard test for thesnapshot showthat never existed.snapshot_no_false_success.rspins the refusal.go/header_parity_test.gonow diffs functions, constants, typedefs and ordered struct fields between the header cgo compiles and the header that is published, in both directions — the identity-state surface had been added to one and not the other.
The docs stopped claiming things
The audits' output landed as corrections, not caveats. Eight documentation claims were found unbacked by code and rewritten.
- nRPC durability. The README advertised crash recovery, at-least-once handler execution, in-flight migration, a replayable per-service audit trail and time-travel debugging, "for free". Serve state is a bounded in-memory channel plus a fold; a full queue drops the request or the response and the caller times out. The paragraph now documents backpressure, which is the property that holds, and warns explicitly that execution is at-most-once.
- The nRPC wire is raw bytes with a codec layered on, not JSON, and retries, hedging and circuit breakers are Rust-only — Go has deadlines and context cancellation and none of the three. A Go caller does not see the application status code and body as fields;
RpcErrorcarriesKindandMessage, so both arrive inside a string, and there is nocancelledkind because cancellation surfaces ascontext.Canceled. - Revocation is a monotonic floor per issuer, not a nonce paired with a revocation list — the nonce is for replay separation. The page now states the granularity trade-off it implies: revoking one credential invalidates its siblings.
- Capability propagation is not one-hop. Both the Rust and TypeScript pages said multi-hop was deferred;
MAX_CAPABILITY_HOPSis 16, with a three-node witness. - Prefix subscription does not exist. Documents said a subscriber to
sensors/lidarreceivessensors/lidar/front; rosters key on the exact channel id, the tagged filter is an exact match, and the prefix helper has no production call site. What is prefix-matched is publisher-side ACL resolution. Relatedly, "Rust has no named channels" was backwards — Rust has the richest distributed-channel surface; what Rust, Go and C lack is the tagged-topic convenience wrapper. shutdown()is not idempotent in TypeScript. The native binding returns already shut down and the SDK forwards it, while Python, Go and C are idempotent — a real cross-language difference that had been documented away.- The published quickstarts showed round trips on a transport that does not deliver. A default node selects the memory Noop adapter, whose poll always returns empty, so the first program a reader copied was one that hangs.
- The "cross-language" nRPC suites are codec fixtures and mocks, which is why they stayed green while the first live
serve()on Node terminated the process.
Structurally, one coarse capability row — channels, pub/sub with capability auth, supported everywhere — was split into eleven rows exposing the parity facts it had been hiding: Python channels are core-only, prefix ACLs and distributed batch fan-out are Rust-only, permissive mode is Python and Node only, the membership rejection taxonomy collapses outside Rust, and both TokenChain subscription and delegated publish chains exist in core and in no binding at all — while the concepts page had been telling readers to use them. Every positive cell carries a CI-resolved anchor. And the v0.34 note's own fourteen documentation links, written as site-absolute routes that resolve only inside the site renderer, were rewritten as absolute URLs: those files are read on GitHub and inside the published crate. That had left the doc-link guard red on master and every branch since it landed; the guard now names the two correct forms in its hint, and the release-sync script gained the inverse transform instead of an ignore entry.
What's deferred (honestly)
- Organization load balancing stays dark, and moved anyway. The scoped route pool's actor build cycle landed — capture, build off-lock, pin, revalidate, publish-if-current, against a node-owned published session projection whose generation lives inside the
Arc— and it is reachable from nothing: the pool accessor carries#[allow(dead_code)]awaiting the next slice, andOrgCapabilityRegistrationstill drops with leader path not lit. The step is recorded as implemented at a candidate and not signed, having been held twice, and no public API changed by design: the slice that publishes a routing surface is the slice that may change one. What an application can observe is collateral, all of it forced out by the Go hang investigation:announce_capabilitiesis now bounded and refuses after 30 s instead of blocking indefinitely, no DashMap guard crosses an.awaiton any send or forwarding path, and aPeerRegistrationGuardrewritten from a field-enumerating shape to a plain disarm flag stops leaking three strong references per successful routed registration — three routing handles had been added to the guard and not to its manual drop. CI's routing-plane floors rise to 86 wiring, 62 registry and 41 state witnesses. - The channel rejection taxonomy is outstanding. A C or Go caller still cannot tell
RateLimited("retry later") fromUnknownChannel("fix your config") fromTooManyChannels("raise a limit"); closing it is a wire-to-binding change across four languages and was out of scope for the pass. It is the one finding whose required closure offered no second branch. - Five channel findings closed as documented gaps, not filled —
TokenChainsubscription, prefix ACL registration, queue-group policy, mesh-level batch fan-out, and the permissive-registry opt-out. Exposing any of them is new public API across five languages and deliberately not in this pass. - nRPC's non-Rust serving repair is landed but the witnesses are owed. The source fixes are in with in-crate witnesses; the two-OS-process and cgo-backed live witnesses belong in CI, and until they run the item stays HOLD — native witness pending.
- Additive ABI compatibility is given up for now. Exact equality can reject a future purely-additive release unnecessarily; that failure is safe, and additive compatibility can return behind an explicit major/minor or supported-range contract. Nor is
net_rpc.hgenerated — the mechanical parity checker is what shipped in place of generation. - The async Python poll still reads shard 0.
AsyncNetMesh.pollcallspoll_shard(0, …)while the synchronous path rotates across every shard. The fix is the same fix; it has not been applied to that entry point. - The API-homonym semantic canary the channel audit asked for does not exist. Three of the requested checks landed as scripts; the rest remain unautomated, which means the class of defect this whole cycle is about is still found by reading, not by CI.
- Two remediations were documentation-only, on purpose. Go's cgo architecture and its checkout-relative linker directive are unchanged — the Windows fresh-module link path is documented, not fixed. And Python handshakes stay blocking with threads: their concurrent contract works and is now written down, so there is no async variant and no Python twin of the TypeScript
rpc()accessor. - The
@net-mesh/sdkpeer-range gate fails today, which is the point. Which version to publish is a release decision, not a code fix; the strict gate stands in the publish workflow until core and SDK publish atomically. Registry lookups stay out of ordinary CI for the same reason in reverse: a flaky registry must not fail a docs PR.
Breaking changes
Every break this cycle is a correction — a value that was being ignored, a number that was already wrong, or a success that was not one.
-
nRPC C ABI synchronizes at
0x0004, and the compatibility check becomes exact. Affects anyone compiling C or cgo againstnet_rpc.h.▸ c/* was */ uint64_t net_rpc_reserve_cancel_token(void); /* was */ void net_rpc_cancel_call(uint64_t token); /* now */ uint64_t net_rpc_reserve_cancel_token(MeshRpcHandle* handle); /* now */ void net_rpc_cancel_call(MeshRpcHandle* handle, uint64_t token);Rebuild against the new header and pass your
MeshRpcHandle*to both; reserve and cancel must use the same handle.net_rpc_check_abi_versionnow requires equality, notruntime >= expected.net_org_check_abi_versionchanges the same way. The golden vector moves to"abi_version_expected": 4and the Node and Python fixtures follow. -
The C link line is
-lnet, and nothing else. Buildcargo build --release -p net-ffi; the per-surface crates are rlibs and produce nothing to link. Linking anylibnet_<surface>alongside it is the lost-wakeup hang, not a redundancy. -
The C ABI fails closed on values it used to ignore.
net_pollrejects unrecognized keys and non-object requests, and honoursordering,filterandshards— a typo like"order"is nowInvalidJsonrather than a silent revert to defaults, and{}remains valid because every key is optional. An unknown capability modality rejects the whole call instead of falling back toTextor being dropped: falling back advertised a capability the node does not have, and dropping a filter constraint widens the query to every otherwise-eligible node. An unknownreliabilityrejects the config. The vocabularies are case-sensitive and listed beside each function. -
Go:
Newrejects an unrecognizedReliability."FULL","ful","reliable"used to construct successfully and downgrade delivery to fire-and-forget; the error now names the value, and the C parser refuses it independently. Usenet.ReliabilityModesif you validate caller input yourself. -
Go:
StreamConfig.WindowBytesbecomes*uint32. A plainuint32underomitemptycannot express zero, so the documented "0 disables backpressure entirely" was erased before the C parser saw it and the 64 KiB default applied — the one value the field documented as special was the one value it could not send. Usenet.WindowBytesOf(n),net.UnboundedWindow(), ornilto inherit. This is a compile error at every call site by design: nothing changes meaning without you seeing it. -
Go:
IngestBatchandIngestBatchCheckeddiffer deliberately. Nothing changed for an upgrader; the two look interchangeable and are not.IngestBatchskips values whose marshal fails and ingests the rest, so its count can be short for two reasons its signature cannot distinguish.IngestBatchCheckedreports the failure instead — nothing ingested, the error names the index, count 0 — which is what Rust, TypeScript and Python do. Prefer it for new code: a drop is backpressure and may be retried, a marshal failure is a payload bug that will fail identically forever. -
Node and TypeScript: nanosecond timestamps are
bigint.Receipt.timestampandStoredEvent.insertionTswerenumber; Unix-epoch nanoseconds crossed JavaScript's exact-integer ceiling about 104 days past 1970, so every realistic value had already lost its low-order digits, and the native path narrowedu64throughi64besides. No compatibility alias was added — one would have preserved incorrect data rather than compatibility. Arithmetic needsbigintliterals (1_000_000n), mixing withnumberis aTypeError, andJSON.stringifythrows on abigint, so convert where you serialize. Sub-microsecond deltas are now trustworthy, which they were not before. -
TypeScript:
StoredEventgains a requiredrawBytes: Buffer. Reading is unaffected; anything that constructs aStoredEventneeds the field. The native binding always preserved the payload bytes and this wrapper dropped them, so binary accepted through its ownemitBuffer()could not be read back through it at all. Note thatrawis deliberately empty for non-UTF-8 payloads — checkrawBytesrather than treating an emptyrawas an empty event. -
TypeScript:
CapabilityFilter.minVramMband.minMemoryMbbecomeminVramGbandminMemoryGb— rename and rescale, so{ minVramMb: 16_384 }becomes{ minVramGb: 16 }. Queries on these axes were returning more nodes than asked for and will now return the correct, smaller set; nothing that used to match stops matching for any reason other than the filter finally applying. TypeScript callers get a compile error; plain JavaScript callers get neither an error nor a warning, because the old key is ignored exactly as before and the filter stays inert instead of becoming correct. Grep for both names before upgrading.emit()andemitRaw()also narrow fromReceipt | nulltoReceipt: backpressure arrives as a throw, never asnull. -
@net-mesh/sdkrequires@net-mesh/core >=0.35.0.findBestNodeandfindBestNodeScopeddispatch straight into the native binding, and 0.34.0 has neither symbol — against that core they resolve at install and fail at the call site. -
Python:
net-mesh-sdkpinsnet-mesh>=0.35.0,<0.36.0. Mixed core and wrapper versions are now unresolvable rather than silently installed. Inside PyPI sdists the license texts move tolicenses/, which fixes the maturin collision that had been failing the sdist jobs. -
Python:
TypedChannel.publishreturns aReceipt, the same oneemitreturns, instead of discarding the native result. The behavior change is in serialization: an event that is not a dict, dataclass, Pydantic model, or object with__dict__/__slots__used to be wrapped as{"_value": event}and then die insidejson.dumpspointing at the wrapper; it now raisesTypeErrornaming the type at the point of the call. Two fixes ride along — slotted dataclasses are handled (the old duck-typed chain checked__dict__first, so "any dataclass works" was only true withoutslots=True), and the_channelrouting tag is stripped before a customparse=callable sees it. -
Rust:
install_rpc_service_defaultsreturnsResult, andserve_rpc*can fail on a long service name. It returned()and swallowed validation failures, so a serve call succeeded against a registry that had installed no policy and every request was then refused as an unknown channel — on the caller's side, far from the registration that caused it. The longest usable service name isMAX_NAME_LEN - len(".replies.0123456789abcdef"); names that fit are unaffected. -
Rust:
PermissionToken::delegatestamps the signer's generation, not the parent's. A delegated child inheritedparent.issuer_generation, stamping an epoch belonging to an entity that did not sign it and comparing it against a different issuer's floor.delegate()itself stamps generation zero; anything maintaining issuer state should usedelegate_with_generation. Observable behavior is unchanged today, which is why the rule could be wrong this long without failing. -
Scope-filter converters and placement weights reject instead of guessing. Non-finite weights are refused at the Node and Python boundaries;
NaNno longer silently disables an axis in Rust. -
CLI:
net-mesh snapshot getandsnapshot statusrequire--local. Without it they exit 2 with an explanation. These commands never read a running deployment — there is no attach path — so the only snapshot they could produce was of a supervisor the invocation itself had just created, and an empty snapshot and a healthy idle cluster are the same document. Add--localfor shape checks and smoke tests; usenet-mesh aggregator,net-mesh peerornet-deckto observe an actual node. -
net-deckanswers argv instead of starting. Help and version exit 0, an unknown flag exits 2, and a redirected stdin or stdout refuses rather than painting a TUI into a pipe. SetNET_DECK_ALLOW_NON_TTYif you meant it. -
Go builds without cgo now fail at the package, naming cgo and the install page, instead of surfacing as undefined identifiers in your own file. The checked-in Go example requires Go 1.26, matching the module it replaces.
-
announce_capabilitiescan now fail. It acquires the announce lock under a 30-second bound and returns a connection error rather than blocking an exported call indefinitely. Signatures are unchanged, in Rust and through every binding and the C ABI.
Everything else is additive: the capability plane, transports, folds, reliability, streams, organization and subnet authority, and payments are unchanged in shape.
How to upgrade
- Rebuild anything that links Net's C surface against
-lnetalone, built withcargo build --release -p net-ffi, and pass yourMeshRpcHandle*to both cancellation functions. If a process still loads two Net shared objects, expect the lock-with-no-owner hang rather than a link error. - Re-read the C-ABI vocabularies you were passing. A typo'd poll key, an unknown modality, or a near-miss reliability string used to succeed and quietly change what you asked for; each is now a refusal. If any of them starts failing, that call was not doing what you thought.
- Go callers: expect compile errors at
WindowBytesand atNew. Both are intentional — the first because a pointer is the only way to send an explicit zero, the second because a misspelled reliability mode was silently costing you delivery guarantees. Switch new batch code toIngestBatchChecked. - TypeScript callers:
bigintfor nanosecond fields,Gbfor the memory and VRAM filter axes, andrawByteson anyStoredEventyou construct. Plain-JavaScript callers get no diagnostic on the filter rename; grep forminVramMbandminMemoryMbfirst. Upgrade@net-mesh/sdkand@net-mesh/coretogether — a skew shows up as a missing method at the call site, not at install time. - Python callers: install core and SDK at matching versions, and stop relying on the
{"_value": …}wrapper for non-mapping payloads. If your handshake code ranconnectandacceptin sequence on one thread, it never worked; run the responder concurrently. - Non-Rust nRPC serving works now. If you had given up on serving from Node, Python, Go or C, the runtime-entry and channel-policy defects that made it impossible are both fixed — and if you were aborting a server-streaming call, that abort now actually cancels.
- Operators: add
--localtosnapshot getandsnapshot status, or switch to a surface that attaches to a running node. Any monitoring built on their previous exit-0 output was reporting on nothing. - Everyone else gets the audits' fixes, the collapsed link topology, and no behavior change to existing paths.
Dependency updates
The crate version bumps 0.34.0 → 0.35.0 across every workspace member, and one new member appears: net-ffi (publish = false), which takes the cdylib crate-type the root crate gives up. The cycle spans 195 non-merge commits over 344 files (+29.2k/−8.5k) — about 5.8k of those deletions being a single stale tracked build mirror under sdk-py/build/lib/ that had drifted 55 lines from src/ and never reached a published wheel. The Rust toolchain pin does not move (1.97.1), no Rust dependency range changed, and no first-party crypto moved.
- Rust (lockfile only, 55 entries):
pyo30.29.2,clap4.6.6 /clap_complete4.6.9 /clap_mangen0.3.2,thiserror2.0.20,async-trait0.1.92,blake31.8.6,napi3.12.1 /napi-build2.4.1 /napi-derive3.6.3,apple-native-keyring-store1.0.2, plus transitive refreshes tocc,wasm-bindgen,js-sys/web-sys,zerocopy,portable-atomic,regex-automata,bstr,ctor,xml-rs,keccak,kqueueandfind-msvc-tools. - Docs / web (
web/):motionv13 (the cycle's only major),@tanstack/react-table9.1.2,@sentry/nextjsand@sentry/profiling-node10.70.0,immer11.1.16,react-hook-form7.85.0,shiki4.4.3,ws8.21.3,eslint10.8.1, and the routineposthog-js/posthog-nodecadence. - Python:
packaging26.3 in the binding lock;sdk-pygains a test-onlypackaging>=24.0behind the new packaging-metadata guard. - CI: thirteen new scripts (
lib/checker.sh,check-checker-lib.sh,check-rpc-abi-parity.py,check-one-library-docs.py,check-c-doc-snippets.py,check-token-wire-size.py,check-install-version.py,check-npm-peer-range.py,check-npm-platform-packages.py,check-python-wheel-features.py,check-script-permissions.py,check-ts-consumer.sh,go-test-with-native-stacks.sh), the four-assertion one-cdylib gate, diagnostic debug info on the Go FFI build, and both Go suites routed through the native-stack harness.PY_RELEASE_FEATURES: redis,org,deck,extension-moduleis now the single source for all three wheel jobs.
Released 2026-08-10.
License
Dual-licensed under MIT OR Apache-2.0, at your option.