MESH ONLINECODENAME: Paranoid
v0.36
release note

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 print Done! 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 of net::ffi in a process meant two copies of parking_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_node in 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 a NaN-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 _channel and installs an exact payload filter, so no name validation, roster, membership ACK, capability auth, or PublishReport was ever on that path. Fifteen findings closed, five of them as documented gaps. The name grammar is now validated in the TypedChannel constructor and exported as validate_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_KEY is 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 gained TokenRoots with the token_roots JSON 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 from token.rs by 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 (stream 7 polled on shard 0, when 7 % 4 == 3). Fixed twice: sweep every shard, then rotate the sweep start, because a continuously fed shard 0 that fills limit starves 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, with reliability_rejects_every_near_miss pinning it. net_poll honours the ordering, filter and shards it 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_x10 is written as u32 at all three boundaries instead of saturating at 65_535 or 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 wildcard scope is exposed across TypeScript and NAPI and unknown scopes are now rejected, issuerGeneration is 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.5 became 1 and 2**32 became 0 and was then blamed as zero_ttl. zero_ttl and ttl_too_long are real error kinds now rather than collapsing into invalid_format.
  • Packaging. Published Python wheels omitted org and deck — neither is a crate default, and the release workflow built with redis,extension-module while 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-sdk could not resolve. The declared dependency was net-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 --help hung. All of --help, -h, --version and 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, because echo | net-deck has a perfectly good terminal on stdout and still hung forever. NET_DECK_ALLOW_NON_TTY is the deliberate override.
  • net-mesh snapshot get reported 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. --local is now mandatory on snapshot get and snapshot status, and under --local the caveat prints on stderr so a pasted result carries it.
  • Go with the default CGO_ENABLED=0 produced a hollowed-out API. The failure surfaced as undefined: net.New in the user's file. A //go:build !cgo guard 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() returned 0.8.0 from a 0.35.0 build. A hand-written b"0.8.0\0" that survived 27 minor releases; the ABI guard beside it was reporting abi=4 expected=4, which is why nobody noticed. It is derived from CARGO_PKG_VERSION now.
  • The shipped examples announced success they had not achieved. The Go example printed Polled 0 events (has_more=false) and then Done!; 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 via net_stats_ex. The Go example also split main from run, because log.Fatalf calls os.Exit and skipped its own defer bus.Shutdown().
  • The published C quickstart did not compilecall to undeclared library function 'free', with only net.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 _native and call TypedMeshRpc.fromMesh, which throws Failed to recover 'NetMesh' type from napi value because the native pointer lives in a WeakMap and the cast yields undefined. MeshNode.rpc() is the accessor that should have existed; localAddr(), publicKey(), nodeId() and entityId() join it, and the quickstart's PSK is typed as what it always had to be — a 64-character hex string, not a Uint8Array.
  • The shipped .d.ts failed a trivial strict consumererror TS2304: Cannot find name 'DaemonBridgeTsfns', plus DuplexHandlerArgs and GreedyConfigJs, all now declared via ts_args_type with the former error quoted at each fix site. MeshRpc.close had been on the Rust side since it was added and was simply absent from the hand-written interface, so rpc.raw.close() did not type-check.
  • npm install said found 0 vulnerabilities, then the binary exited 127 with Failed 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.lib is what the linker needs and net.dll what the loader needs; the MSVC and MinGW paths, the gendef/dlltool route, and the PATH-based loader setup are all documented now, along with CGO_LDFLAGS for 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 took net-mesh as a path dependency and therefore embedded and re-exported its own full copy, verified by reading the export tables: libnet_org defined all 57 net_mesh_* entry points itself. Link-order interposition unifies the functions. It does not unify statics. parking_lot_core keeps its parked-thread registry in a process-global static hashtable — one per shared object — so a thread that parked on MeshNode::announce_mu through libnet's table was never woken by an unlock_slow consulting a different copy's, and the mutex was left free with no owner anywhere.
  • The symptom. TestLiveSubnetExportedCallFromAGeneratedScenario blocked roughly nine minutes inside net_mesh_announce_capabilities, in RawMutex::lock_slow, while a core dump showed every worker thread in the process parked idle. Go handed an Arc<MeshNode> from net_mesh_new in one library to net_subnet_serve_exported in 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-ffi is now the single cdylib: it takes [lib] name = "net", so -lnet in every existing document stays correct and simply means more than it used to, and the root crate gives up its own cdylib crate-type. The seven surfaces become rlibs, pulled in by eight load-bearing extern crate lines 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.py now 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 ship libnet_rpc), and carries a self-test containing the exact sentences that shipped.
  • CI counts the copies. A four-assertion gate checks that every cgo LDFLAGS line ends in -lnet, that libnet.so exports at least one symbol for each of the eight prefixes, that no stray libnet_*.so exists — a second cdylib carrying net::ffi is 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 under GOTRACEBACK=crash with a fixed-path test binary, and its first gdb command is info 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 bare tokio::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 at create() 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_defaults was 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 0x0002 against a 0x0004 implementation — the two cancellation functions had gained a leading MeshRpcHandle* at 0x0004, so cancellation routes through the substrate's per-mesh CancelRegistry, 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_version compared runtime >= expected, so a stale 0x0002 header checking a 0x0004 library passed: the one guard that existed was blind to precisely the change it was there to catch. Both are fixed, net_org_check_abi_version had the same defect and got the same correction, and check-rpc-abi-parity.py now re-derives header-versus-implementation agreement mechanically across four surfaces (nrpc, org, meshos, compute) — widened after net_org_set_handler_dispatcher changed void to int and 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: callStreaming and callServiceStreaming passed the AbortSignal straight 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 because fromMesh() pinned the node's Arc<MeshNode> and made shutdown() fail with outstanding references exist, and requestWindowInitial is finally declared on the public CallOptions — it existed natively, was referenced by the implementation and the docs, and typed callers hit TS2353 and 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_generation and delegate_with_generation are real, and Identity carries 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 chain root -> machine -> gateway, the machine -> gateway link carried root's generation while being checked against machine's floor. Revocation stays transitive: TokenChain checks 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/findBestNodeScoped and find_best_node/find_best_node_scoped land 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 maps NaN to 0.0 explicitly, because f32::clamp returns NaN unchanged and the scoring guards read weight > 0.0, which is also false for NaN: 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 NaN compares false in both directions — so a NaN on the lowest id was admitted unconditionally and beat every real score behind it. A NaN score 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/minMemoryMb while the native binding generates minVramGb/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.sh is the fix. fail is a counter, not a flag — as a flag, the first failure made every later section print its green tick. $PYTHON is 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_checker classifies 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.sh is 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 invokes python3 directly.
  • 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.py skips when PyYAML is missing locally but takes --require-yaml in 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 from token.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 turn skipLibCheck off), check-script-permissions.py (a workflow step dying at exit 126 over a missing executable bit in git's index), and go-test-with-native-stacks.sh. Every Python checker carries a --self-test that plants the exact defect that shipped.
  • Binary-interrogating tests join them on the Rust side: help_is_self_contained.rs walks the whole clap tree — over thirty help pages — and fails on ten repo-internal file names and on net <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.rs extracts every command the README publishes and resolves it against the real binary, with a guard-the-guard test for the snapshot show that never existed. snapshot_no_false_success.rs pins the refusal. go/header_parity_test.go now 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; RpcError carries Kind and Message, so both arrive inside a string, and there is no cancelled kind because cancellation surfaces as context.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_HOPS is 16, with a three-node witness.
  • Prefix subscription does not exist. Documents said a subscriber to sensors/lidar receives sensors/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, and OrgCapabilityRegistration still 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_capabilities is now bounded and refuses after 30 s instead of blocking indefinitely, no DashMap guard crosses an .await on any send or forwarding path, and a PeerRegistrationGuard rewritten 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") from UnknownChannel ("fix your config") from TooManyChannels ("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 filledTokenChain subscription, 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.h generated — the mechanical parity checker is what shipped in place of generation.
  • The async Python poll still reads shard 0. AsyncNetMesh.poll calls poll_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/sdk peer-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 against net_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_version now requires equality, not runtime >= expected. net_org_check_abi_version changes the same way. The golden vector moves to "abi_version_expected": 4 and the Node and Python fixtures follow.

  • The C link line is -lnet, and nothing else. Build cargo build --release -p net-ffi; the per-surface crates are rlibs and produce nothing to link. Linking any libnet_<surface> alongside it is the lost-wakeup hang, not a redundancy.

  • The C ABI fails closed on values it used to ignore. net_poll rejects unrecognized keys and non-object requests, and honours ordering, filter and shards — a typo like "order" is now InvalidJson rather 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 to Text or 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 unknown reliability rejects the config. The vocabularies are case-sensitive and listed beside each function.

  • Go: New rejects an unrecognized Reliability. "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. Use net.ReliabilityModes if you validate caller input yourself.

  • Go: StreamConfig.WindowBytes becomes *uint32. A plain uint32 under omitempty cannot 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. Use net.WindowBytesOf(n), net.UnboundedWindow(), or nil to inherit. This is a compile error at every call site by design: nothing changes meaning without you seeing it.

  • Go: IngestBatch and IngestBatchChecked differ deliberately. Nothing changed for an upgrader; the two look interchangeable and are not. IngestBatch skips values whose marshal fails and ingests the rest, so its count can be short for two reasons its signature cannot distinguish. IngestBatchChecked reports 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.timestamp and StoredEvent.insertionTs were number; 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 narrowed u64 through i64 besides. No compatibility alias was added — one would have preserved incorrect data rather than compatibility. Arithmetic needs bigint literals (1_000_000n), mixing with number is a TypeError, and JSON.stringify throws on a bigint, so convert where you serialize. Sub-microsecond deltas are now trustworthy, which they were not before.

  • TypeScript: StoredEvent gains a required rawBytes: Buffer. Reading is unaffected; anything that constructs a StoredEvent needs the field. The native binding always preserved the payload bytes and this wrapper dropped them, so binary accepted through its own emitBuffer() could not be read back through it at all. Note that raw is deliberately empty for non-UTF-8 payloads — check rawBytes rather than treating an empty raw as an empty event.

  • TypeScript: CapabilityFilter.minVramMb and .minMemoryMb become minVramGb and minMemoryGb — 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() and emitRaw() also narrow from Receipt | null to Receipt: backpressure arrives as a throw, never as null.

  • @net-mesh/sdk requires @net-mesh/core >=0.35.0. findBestNode and findBestNodeScoped dispatch 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-sdk pins net-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 to licenses/, which fixes the maturin collision that had been failing the sdist jobs.

  • Python: TypedChannel.publish returns a Receipt, the same one emit returns, 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 inside json.dumps pointing at the wrapper; it now raises TypeError naming 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 without slots=True), and the _channel routing tag is stripped before a custom parse= callable sees it.

  • Rust: install_rpc_service_defaults returns Result, and serve_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 is MAX_NAME_LEN - len(".replies.0123456789abcdef"); names that fit are unaffected.

  • Rust: PermissionToken::delegate stamps the signer's generation, not the parent's. A delegated child inherited parent.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 use delegate_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; NaN no longer silently disables an axis in Rust.

  • CLI: net-mesh snapshot get and snapshot status require --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 --local for shape checks and smoke tests; use net-mesh aggregator, net-mesh peer or net-deck to observe an actual node.

  • net-deck answers 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. Set NET_DECK_ALLOW_NON_TTY if 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_capabilities can 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

  1. Rebuild anything that links Net's C surface against -lnet alone, built with cargo build --release -p net-ffi, and pass your MeshRpcHandle* 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.
  2. 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.
  3. Go callers: expect compile errors at WindowBytes and at New. 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 to IngestBatchChecked.
  4. TypeScript callers: bigint for nanosecond fields, Gb for the memory and VRAM filter axes, and rawBytes on any StoredEvent you construct. Plain-JavaScript callers get no diagnostic on the filter rename; grep for minVramMb and minMemoryMb first. Upgrade @net-mesh/sdk and @net-mesh/core together — a skew shows up as a missing method at the call site, not at install time.
  5. Python callers: install core and SDK at matching versions, and stop relying on the {"_value": …} wrapper for non-mapping payloads. If your handshake code ran connect and accept in sequence on one thread, it never worked; run the responder concurrently.
  6. 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.
  7. Operators: add --local to snapshot get and snapshot 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.
  8. 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): pyo3 0.29.2, clap 4.6.6 / clap_complete 4.6.9 / clap_mangen 0.3.2, thiserror 2.0.20, async-trait 0.1.92, blake3 1.8.6, napi 3.12.1 / napi-build 2.4.1 / napi-derive 3.6.3, apple-native-keyring-store 1.0.2, plus transitive refreshes to cc, wasm-bindgen, js-sys/web-sys, zerocopy, portable-atomic, regex-automata, bstr, ctor, xml-rs, keccak, kqueue and find-msvc-tools.
  • Docs / web (web/): motion v13 (the cycle's only major), @tanstack/react-table 9.1.2, @sentry/nextjs and @sentry/profiling-node 10.70.0, immer 11.1.16, react-hook-form 7.85.0, shiki 4.4.3, ws 8.21.3, eslint 10.8.1, and the routine posthog-js / posthog-node cadence.
  • Python: packaging 26.3 in the binding lock; sdk-py gains a test-only packaging>=24.0 behind 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-module is 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.