no notes
net.
moves
at light.
A latency-first encrypted mesh where every computer, app and device is a first-class node. Existing networks operate in milliseconds (10⁻³). NET operates in nanoseconds (10⁻⁹).
No clients. No servers. No coordinators. The mesh propagates state, not connections.
arpanet assumed scarcity.
net assumes abundance.
TCP was designed when nuclear war was a real possibility. Packets were precious. The network had to guarantee delivery because the next packet might not get through.
That was the right design for 1969. It's the wrong design now. Sensors don't pause. Token streams don't wait. Market feeds don't care that your queue is full. The firehose doesn't have a pause button.
In a world of abundance, guaranteeing delivery is a threat — you're promising to deliver data that will bury the receiver. The bottleneck isn't delivery. It's processing. Arrival doesn't equal usefulness.
NET inverts the default. TCP starts with trust and detects abuse. NET starts with zero assumptions and lets trust emerge from consistent behavior.
Nodes reject work they can't process within a time window. Dropping a packet and re-requesting from a faster node costs nanoseconds. Waiting for a congested node's guaranteed response costs milliseconds. When dropping is cheaper than waiting, delivery guarantees become overhead.
The remaining latency is physics: NIC, wire, speed of light. The software got out of the way.
a new class of system.
Existing networking falls into two categories. NET is neither.
nine axioms.
one runtime.
Latency-first
Sub-nanosecond header serialization. Nanosecond heartbeats, hops, recovery. Packet scheduling at timescales reserved for local function calls.
0.20 ns ▸ fwd
sub-ns floorStreaming-first
Data is continuous flow, not documents. Sharded ring buffers, adaptive batching. No requests and responses — everything is a stream.
▶▶▶▶▶▶▶▶▶▶▶▶▶▶ ░░░░░░░░░░░░░░
Zero-copy
Ring buffers, no garbage collector, native Rust. No unsafe. Forwarding doesn't allocate or copy payload data. Design principle, not optimization.
[mem]──refs──▶[wire] no alloc
Encrypted E2E
Noise protocol handshakes. ChaCha20-Poly1305 AEAD with counter nonces. Every packet encrypted source→dest. Intermediate nodes never see plaintext.
A ─ChaCha20──▶ B
relay sees ░░░Untrusted relay
Nodes forward packets without decrypting payloads. The mesh routes through infrastructure you don't trust. Networks grow through adversarial nodes.
trust := observation
not assumptionSchema-agnostic
Transport moves bytes, not structures. Raw event = payload + hash. Protocol never inspects content. Structure emerges where participants agree.
[hdr][hash][░▒▓█▓]
opaque payloadOptionally ordered
Ordering is per-stream, not global. Unordered path is the fast path. Causal ordering available where streams need it. Cost paid only by streams that require it.
e₁ → e₂ → e₃ chain.verify()
Optionally typed
The protocol doesn't care what's in the payload. Behavior plane can. Typing is a local agreement between nodes, not a network requirement.
type ∈ peer-pair
not networkNative backpressure
Nodes drop without reply. Not a failure mode — the design. The proximity graph makes silence a signal. Automatic rerouting.
silent → suspect suspect → reroute
existence proofs.
All numbers measure packet scheduling — the time to process, route, encrypt, and queue a packet for transmission. They do not include NIC transfer or wire latency.
| operation | M1 Max | i9-14900K |
|---|---|---|
| ▸ routing | ||
| routing header forward | 0.57 ns1.75G/s | 0.20 ns4.99G/s |
| header serialize | 2.19 ns456M/s | 1.21 ns829M/s |
| routing lookup (hit) | 40 ns25.2M/s | 38 ns26.3M/s |
| ▸ multi-hop forwarding | ||
| 1 hop | 57 ns17.4M/s | 53 ns18.7M/s |
| 3 hops | 160 ns6.23M/s | 122 ns8.18M/s |
| 5 hops | 257 ns3.90M/s | 196 ns5.09M/s |
| ▸ failure detection & recovery | ||
| heartbeat | 29 ns34.7M/s | 36 ns28.0M/s |
| circuit breaker check | 9.55 ns105M/s | 11 ns90.3M/s |
| full fail + recover | 274 ns3.65M/s | 249 ns4.02M/s |
| ▸ swarm / discovery | ||
| pingwave roundtrip | 0.93 ns1.07G/s | 0.69 ns1.46G/s |
| new peer discovery | 93 ns10.8M/s | 47 ns21.2M/s |
| ▸ capability system | ||
| filter (require GPU) | 47 ns21.4M/s | 44 ns22.8M/s |
| GPU check | 40 ns25.3M/s | 41 ns24.7M/s |
// scheduling floor
Routing header forward on i9-14900K. Per-packet overhead. Software is not the bottleneck — physics is.
// hot path
Operations per second on a single core for the forward path. Five billion. Per second. Per core.
// SDK ingest
Python via PyO3 batch ingest. The "slow" binding language hits seven million events per second.
// test systems
► M1 Max macOS, aarch64
► i9-14900K @5GHz, Win11
► date 2026-06-01
► profile release + LTO + CG=1
▸ BENCHMARKS.md↗
state moves.
connections don't.
In Cyberpunk, Mikoshi is Arasaka's construct for storing engrams — consciousness held in digital space, minds persisting outside their original hardware.
Mikoshi in NET lets a deamon hop between machines. The machine underneath changes; the daemon keeps its identity, its history, its pending work, and its place in the conversation. The source packages its state, the target unpacks it, and for a brief moment the daemon exists on both nodes, then collapses onto the target as routing cuts over.
The daemon doesn't know it moved. Neither does anything talking to it. The hardware shifted; the stream didn't notice.
A factory controller hops from a dying edge box to a healthy one mid-shift. A trading agent migrates to a node closer to the exchange without dropping a single tick.
Mikoshi doesn't move a copy. Mikoshi moves the daemon itself.
compute
lives on
the wire.
A program on NET is called a daemon. Its identity is a public key — an origin_hash derived from ed25519, which doesn't change when the daemon moves. Its history is a causal chain — every event it produces is signed and links to the previous one, verifiable by any node. Its location is wherever in the mesh has the capabilities it asked for. When that location goes away, the daemon doesn't.
// what is a daemon
Stateful programs that live on the mesh, not on a machine. It holds working state, snapshots periodically, and exposes five trait methods. Everything else — placement, migration, durability — is the runtime.
- cryptographic identity — origin_hash from ed25519. survives moves.
- causal chain — every event signed, links to parent. self-authenticating.
- capability requirements — daemon declares needs. mesh finds matching node.
- snapshot + replay — state captured periodically. gap replayed on restore.
- opaque to mesh — what the daemon does is its business. mesh just hosts.
Mikoshi migration · 6 phases
zero-downtime cutover · ~280ns totalreplica
member 0 ● event #58 → result member 1 ○ idle member 2 ○ idle round-robin · seq=58
For horizontal scale on stateless workloads. Each replica has its own causal chain derived from a deterministic seed — fail one, spawn another with the same identity. No state to transfer.
fork
parent @ seq=42 · single chain, no divergence · awaiting fork directive pre-fork · monitoring
For experiments, A/B testing, scenario branching. Each fork carries a cryptographic sentinel linking back to the parent at the fork point. Forks share a past but not a future.
standby
active ● processing seq=102 standby ○ synced_through=98 standby ○ synced_through=101 all healthy · 3 nodes online
For stateful services that need failover without running duplicate copies. One active daemon runs; warm standbys stay synced. If it fails, the most-recent standby takes over and catches up. Zero duplicate compute.
0x0500Dataforts:
data became
a fluid.
For 60 years, files were objects nailed to a location — a disk in a box. Traditional storage treats files like permanent objects locked to a single machine.
Dataforts treats storage as flow and data as fluid. When a device approaches capacity, it overflows onto the mesh. The capacity is the mesh. The folder stays local. Reads create gravity. Hot data moves closer. Everything is in motion.
Overflow
storage doesn't run out. when one disk fills up, the mesh catches the spillover.
Data Gravity
the files aren't moved. files settle near nodes that use them.
Content-Addressed
the hash is the handle. one address gets you the file — wherever it lives.
Durability Tiers
pick your guarantee. fire-and-forget, fsync, or N-peer replicated. one call.
MeshOS:
programs move.
clusters think.
Programs move between machines without stopping. Storage autobalances. Daemons migrate seamlessly across the mesh while maintaining full state.
Placement happens intelligently — gravity pulls workloads toward their data, capabilities match tasks to nodes, and drift detection triggers automatic rebalancing. No central orchestrator. No single point of failure. Just self-organizing coordination at nanosecond scale.
Mikoshi Lifecycle
spawn, migrate, supervise. daemons hop between machines without losing state, history, or place in the conversation.
Gravity Placement
workloads pull toward their data. compute lands near the bytes it touches — gravity-based scoring, not central scheduling.
Daemon Supervision
start, drain, restart, gate. exponential backoff. backpressure signals. graceful shutdown or forced.
Capability Match
nodes advertise what they are — device, compute, storage, daemon, datafort. MeshOS routes daemons to nodes that fit.
MeshOS turns your mesh into a living system. Sensors adapt. Storage flows. The daemons move.
four primitives.
one mesh.
The mesh moves bytes. Everything above is a thin, optional layer — local-first, feature-flagged, opt-in. Light up the ones you need; the wire doesn't care which.
nRPC
Request/response semantics built from a pair of streams. A server registers a handler with serve_rpc; clients dispatch with call_typed. The streams stay primitive — nRPC just wraps them in a typed handle and completes when the response lands.
RedEX
The log unbundled and local. 20-byte index records, optional disk persistence per channel, atomic backfill-then-live tailing. A Pi keeps a tiny log of its own readings; a server keeps a huge one. No cluster consensus — log is local, replay is local, retention is local.
CortEX
A reactive, queryable projection of the log, updated event-by-event. Your "database" isn't a process you connect to — it's a Vec<Task> or HashMap<Uuid, Memory> in your code, updating as events fold in. Queries are direct memory access.
NETDB
One handle bundling typed collections under db.tasks, db.memories, and friends. Prisma-style find_unique / find_many across Rust, TypeScript, and Python — whole-database snapshots round-trip between languages.
five languages.
one engine.
All SDKs wrap the same Rust core. The SDK is the developer experience, the engine is Rust.
// C bindings via net.h — build cdylib with . Lower-level bindings (skip SDK ergonomics, talk directly to the engine): net-mesh, @net-mesh/core, net-mesh (PyPI binding).
everything that
can't wait.
Anywhere latency matters. Anywhere the cloud round-trip is too slow. Anywhere there's no central infrastructure to route through.
AI Agents
Tool calls, state, and memory transfer between heterogeneous GPU nodes. Token streams flow through the mesh; an agent's working memory follows it from node to node mid-conversation. The mesh is the runtime.
Vehicular Sensor Mesh
Cars sharing LIDAR, radar, camera. Vehicles sync intent — braking, turning, route changes. The car behind doesn't react to braking. It knows about the braking before the brake pads touch the rotor.
Robotics Factory Floor
Robots don't need line-of-sight for networking. The mesh routes through whatever nodes are reachable. Reroute scheduled in sub-microsecond time. The assembly line doesn't stop.
Energy Grids & Extraction
Electrical substations, oil and gas pipelines, drilling rigs, mine haul trucks, distributed solar — coordinating in real time across geographies that fiber doesn't reach. Protective relays trip in single-digit milliseconds; the mesh isolates faults before they cascade. Routes through whatever radios and edge boxes survive.
Remote Surgery
Control signals and haptic feedback routed across the mesh. If the primary compute node lags, the mesh reroutes mid-operation. The surgeon doesn't notice. The patient doesn't notice. The scalpel doesn't stop.
Drone Swarms
Coordinated flight without a ground controller. A drone that loses a motor broadcasts the failure; the swarm adjusts formation before the drone has begun to fall.
Live Performance
Lighting, audio, video, pyro synchronized across hundreds of nodes. A DMX controller dies, another node picks up the cue list. Audio sync tighter than the speed of sound across the venue.
Medical Nanorobotics
Swarms of nanoscale machines coordinating in vivo — drug-delivery vectors, targeted ablation, vascular monitoring. Sub-microsecond reroute when a node leaves the swarm. No cloud round-trip; the patient is the network.
safety isn't declared.
it's derived.
In Cyberpunk, the Blackwall isn't a wall around the threats — it's a wall around the safe zone. NET works the same way. The "safe mesh" is the part you can observe: nodes that respond within heartbeat intervals, honor their capability announcements, don't flood, respect TTL.
The wall isn't one mechanism. It's the emergent effect of every constraint working together.
▸ Backpressure
Nodes limit in-flight events, prevent overload, and apply pushback by going silent. No node can be forced to accept more than it can process.
▸ Bounded queues
No infinite buffers. Ring buffers have explicit capacity limits. A flood fills a buffer and gets evicted, it doesn't grow the buffer.
▸ Fanout limits
Events don't propagate to everyone. Dissemination is controlled by the proximity graph and routing table. Prevents O(n²) explosion.
▸ Deduplication
The same event doesn't explode repeatedly. Idempotency at the event level protects against loops and amplification.
▸ TTL limits
Events expire. Pingwaves have a hop radius. A misbehaving node's traffic dies at the boundary of its TTL, not the edge of the mesh.
▸ Rate limits
Per-node, per-peer limits. One node cannot flood the mesh. Its neighbors enforce their own limits independently through device autonomy rules.
Any single mechanism can be overwhelmed. All of them together form the wall. No single point to breach because the Blackwall is the mesh itself.
net releases.
Every tagged release pulled directly from ai-2070/net.
no notes
no notes
A security release — one critical auth fix, and the nRPC wire path keeps shrinking
Where v0.27.1 was pure performance and nothing on the wire moved, v0.27.2 leads with a four-pass security audit of the net crate and the fixes it surfaced — headlined by a critical authorization-bypass in the capability fold — then continues the hot-path work on the nRPC dispatch layer the hot-path audit opened. The full security review is recorded in docs/misc/SECURITY_AUDIT_2026_06_09_NET_CRATE.md; this log is the operator-facing summary.
The reassuring part first: the audit found the crate unusually well-hardened. Untrusted-wire parsing, token/chain auth, nonce/randomness handling, handshake identity binding, secret hygiene, and the filesystem/path surfaces all came back clean and verified (not assumed) — most classic hazards already had named, tested mitigations. One finding stood apart and is fixed below; the rest are medium/low hardening and defense-in-depth.
Interop: honest v0.27.1 peers are unaffected. The critical fix only rejects forged input that the old code silently trusted — a legitimate node always announced its own node_id, so nothing on the honest path changes. No wire-format change.
🔴 The critical fix — the capability fold now binds wire node_id to the verified signer
SignedAnnouncement::verify checked that an announcement carried a valid Ed25519 signature over a transcript that included node_id — but never that the claimed node_id was actually the signer's. The dispatch and apply layers then keyed all capability/reservation state on that attacker-supplied node_id.
The exploit chain (all four links confirmed against the code):
- Peer A — legitimately authenticated via PSK + Noise — signs a
CapabilityMembershipenvelope with its own entity key but sets the internalnode_idto victim C's. verifypasses: it is a valid signature by A over those bytes; nothing required the node id to be A's.applyinstalls the entry under key(class_hash, C)— a forged capability now lives in C's state (e.g.tags:[nrpc:<service>], allowed_nodes:[A]).- A calls the gated service; the callee gate reads
by_node[C], finds the forged entry, and returnstrue.
Impact: complete bypass of the per-node nRPC capability allow-list — any authenticated participant could invoke any capability-gated service on any node — plus global forge/overwrite/strip of other nodes' advertised capabilities (cap-stripping DoS, scheduler-placement poisoning). The same unbound-node_id primitive hit ReservationFold, enabling reservation/lock hijacking on behalf of arbitrary node ids.
The fix is exactly the one the audit prescribed — surgical, outsized impact: verify / decode_and_verify (and the reservation path) now reject any envelope where ann.node_id != publisher.node_id(), returning WireError::NodeIdMismatch. This closes capability injection and reservation hijack simultaneously. The check is effectively free — Ed25519 verification (~50 µs) already dominates every inbound envelope. Pinned by a full-dispatch multi-publisher regression test, and the Fold::restore trust invariant ("only restore from local snapshots") is now documented alongside it.
FFI hardening — the aggregator handles join the crate's UAF protection
Two aggregator FFI handles (RegistryClientHandle, FoldQueryClientHandle) did an unconditional drop(Box::from_raw(handle)) on free, lacking the HandleGuard every other opaque handle in the crate carries — so a caller racing free against an in-flight op (a pattern the handles' own docs invite) could deallocate the client out from under a live read. Closed:
- Both handles adopt the standard
HandleGuard+ leak-on-free +try_enter()-gated ops treatment, with quiesce-on-free, and no longer hold the guard across the blocking RPC. net_registry_last_error_detail/net_fold_query_last_error_detailnow return a caller-ownedchar*(freed withnet_free_string) instead of a pointer into aMutex-ownedCStringa concurrent erroring op could free out from under the reader;net.hdocuments the ownership, and the Go bindings free the returned strings.- Free now warns only on a genuine drain timeout (via
begin_free_detailed), and a new-handle adoption checklist was added tohandle_guardso the next FFI handle gets this by construction.
Filesystem — symlink-escape closed in directory reconstruction, including the subtle FS bypasses
fetch_dir sanitized a symlink's link path via safe_join but wrote its target verbatim from the attacker-controlled manifest — the classic "symlink in an archive" exposure. v0.27.2 rejects absolute / escaping symlink targets, and — crucially — closes the bypasses a naïve check misses:
- Composed-link and symlinked-parent escapes (a link whose escape only materializes through an earlier-reconstructed link).
- Case- and normalization-insensitive FS bypasses — default macOS APFS compares filenames both case- and normalization-insensitively, so the lexical traversal check now folds component case and applies NFC before comparing. (Reconstruction was already strictly ordered — dirs, then files, then symlinks last — so this was never a traversal write; the fix removes the residual risk to whatever later reads the tree.)
Hardening grab-bag (medium/low, from the audit's backlog)
- Constant-time secret compares.
GroupId(32-byte) /SubnetId(16-byte) bearer secrets now compare viasubtle::ConstantTimeEqinstead of derivedPartialEq/Vec::contains(early-exit, data-dependent timing). Remote timing recovery of a 128/256-bit secret was already impractical; closed for completeness. - PSK config permissions. The aggregator daemon now warns when its TOML config (which holds the mesh PSK) is group/world-readable — mirroring the
0600discipline the CLI identity seed already enforces. The check runs before parse and warns on non-Unix too. - Cap-filters documented as advisory.
subscribe_caps/publish_capsare self-asserted matchmaking, not an access boundary — the real boundary isrequire_token+token_roots(root-anchoredTokenChain). This is now prominently documented so no one mistakes a cap-filter for access control. - Fuzz coverage widened. New fuzz targets for the nRPC request decode, channel-membership decode, migration bindings decode, and blob-transfer header decode — attacker-reachable, manually-hardened decoders that previously lacked a continuous regression guard. The fuzz crate gained the
bytes/postcarddeps and thecortex/datafortsfeatures to reach them.
Correctness — a node no longer expires its own capability entry (self-inflicted outage)
Surfaced while baselining the nRPC QPS bench (audit §15): capability fold entries carry a TTL (default 300 s) and the sweeper reaps them on expiry, but nothing periodically re-announced the node's own entry — serve_rpc's announce is one-time. So any node serving RPC continuously past one TTL (≈5 min) without re-announcing would start rejecting all inbound calls (the callee-side cap gate finds no self-entry → CapabilityDenied) and drop off peer discovery — masked until now because every test/bench runs far under 300 s.
Fixed with a periodic re-announce loop (spawn_capability_reannounce_loop) that re-broadcasts the node's capabilities every MeshNodeConfig::capability_reannounce_interval (default 150 s), refreshing both the local self-index (callee gate) and peers' folds (discovery). Re-broadcasting needs an owned Arc, so MeshNode::start_arc(self: &Arc<Self>) stores a Weak the loop upgrades each tick; the SDK (Mesh::start) and FFI (net_mesh_start) call it. A bare start(&self) keeps its signature — no test-caller churn — and simply omits the loop. A/B tests pin both directions.
A follow-up review hardened the TTL math: because announce_capabilities_with rate-limits the network broadcast to min_announce_interval, a re-announce interval set below it would have let peer entries expire before the throttle released the next broadcast. The stamped TTL is now 2 × max(reannounce_interval, min_announce_interval) — sized to the cadence peers actually see a refresh at, not the bare tick. (The local self-index is refreshed every call regardless, so only peers were ever at risk.)
A companion bench-harness fix bounds call_*_retrying backpressure retries with a 20 s RETRY_DEADLINE, so a saturated benchmark bar fails fast (transport saturated … not measurable here) instead of livelocking past a TTL.
nRPC hot path — fewer wakeups, fewer allocations per round-trip
The audit's headline conclusion holds — the system is syscall- and wakeup-bound, not compute-bound (51% wake/scheduling, 22% transport syscalls, ~5% AEAD). v0.27.2 lands the contained, no-wire-change items that attack the wakeup count on the unary response leg:
- §8a — one fewer
tokio::spawnper response. The response emit closure used to spawn a task for every response publish (~1–2 µs of scheduling on a wake-bound path). It now builds the wire payload synchronously and hands anRpcResponseJobto a single per-service drain task (the same drainer pattern as grant-coalescing) — bounded channel, drop-on-overflow, FIFO. Streaming/duplex variants keep their per-emit spawn; unary is the QPS hot path. - §8b — reply-channel name cached per caller.
format!("{service}.replies.{caller_origin:016x}") + ChannelName::new()was two heap allocs on every response, deterministic from(service, caller_origin). Now cached alongside the response path's origin cache — a cache hit is anArcbump. - T2.2 —
RpcPayload::encode_into. The encode path allocated aVecthenextend_from_slice'd it into the caller's buffer — a double copy.encode_into(&mut buf)writes once (≈200–400 ns saved at 1 KiB).
Measurement honesty: these are µs-and-below wins per round-trip — below this dev box's Windows-loopback variance floor. A before/after on nrpc_qps moved c1/32B ~−3.6% with the other bars inside noise; the authoritative measurement remains the Linux flamegraph environment, consistent with the rest of the wire-path audit. They are landed because they are correct, contained, and directly reduce the 4–5 wakeups/RT the flamegraph attributes 51% of CPU to — not because the dev-box microbench can prove the delta.
Memory-amplification guard — and an unexpected speedup
A review of the §8b work flagged that the per-serve_rpc caller-keyed caches (the reply-channel cache and the response-path origin→node cache) were unbounded maps keyed by the wire-claimed caller origin — and the origin cache is populated before the capability gate. A single authenticated peer could spray distinct origins and amplify server memory without limit; "bounded by peer count" held only for well-behaved callers.
Both are now a single bounded OriginKeyedLru (parking_lot::Mutex<lru::LruCache>) capped at the legitimate active-caller working set per service (4096). Eviction is always safe — a miss just rebuilds the channel name or falls back to the roster lookup, never correctness.
The bound turned out to be faster, not a trade-off: DashMap's hash-to-shard + per-shard RwLock buys nothing when one bridge/fold task owns the cache, and the LRU under an uncontended mutex skips it. Measured on the single-accessor workload serve_rpc actually produces:
| Operation | Before — DashMap |
After — Mutex<LruCache> |
Change |
|---|---|---|---|
| hit (per response) | 23.35 ns | 17.50 ns | −25% |
| insert (first time an origin is seen) | ~39.5 ns | ~30.7 ns | −22% |
So the hardening caps memory and shaves ~6 ns off the per-response hot path. A before/after micro-bench (benches/origin_cache_bench.rs) ships alongside it.
Benchmark hygiene — corrections and a first capture
- FailureDetector rows annotated (audit §14).
failure_detector/check_all(~342 ms) andstats(~80–100 ms) inBENCHMARKS.mdare benchmark-fixture artifacts of an O(nodes) scan reported per-element, not hot-path costs —check_allruns once perheartbeat_interval(default 5 s) and costs ~204 µs at 5,000 nodes (~40 µs/s amortized);statsis observability-only. The genuine per-heartbeat costs are 14–242 ns. The rows now carry a warning so nobody chases them. bench_append_batch_diskrun for the first time (audit §10). 64-event batches: 64 B → 20.2 µs/batch (~3.17 M ev/s), 1 KiB → 62.5 µs/batch (~1.02 M ev/s) ≈ ~315 ns/event. The policy companion confirms the RedEX Phase 3/4 background-fsync design directly:never/default ~23 µs/batch vsevery_n_1(synchronous fsync per batch) ~853 µs/batch — coalescing is ~40× faster than per-batch fsync.
What's deliberately parked (and why)
The audit's three highest-leverage levers are identified, scoped, and intentionally not in this release — each is gated on something this release isn't the place to spend:
- Recv-loop batching (§1) —
recvmmsgbatched ingress is built through Stage 5 but stays default-off behind a Cargo feature + runtime flag, pending the c128 latency measurement (and a ~40-LoC channel-hop gap-fix that must land first, or the measurement understates the design). - Ack-piggyback (§2) — the one lever on the unary QPS ceiling (~70K → 150–200K target), but it's a wire-format change with cross-binding compat work; scheduled as its own effort.
- Crypto SIMD (§3) —
RUSTFLAGS="-C target-feature=+avx2"recovers 5–10× on the per-packet AEAD cost, but a baked-in floor wouldSIGILLon pre-AVX2 x86-64. The committed-config decision stands; the open question is per-artifact build flags for the published wheels/prebuilds.
Breaking changes
None on the wire, and none for honest peers. v0.27.2 interoperates with honest v0.27.1 / v0.27.0 peers freely — the capability node_id binding only rejects forged envelopes (a node_id that doesn't match the signer) that no legitimate node ever produced; they now return WireError::NodeIdMismatch instead of being silently accepted.
Two source/ABI-level notes for FFI consumers (no in-tree non-test callers affected):
- Aggregator error strings are now caller-owned.
net_registry_last_error_detail/net_fold_query_last_error_detailreturn a heapchar*the caller must release withnet_free_string(the bundled Go bindings already do). Previously they returned a borrowed pointer. - The aggregator handle free path now leaks-on-free + quiesces (matching every other handle); double-free / free-during-op transitions to a logged drain warning rather than UB.
The SDK surface (Mesh::start, etc.) is unchanged — start_arc is wired internally.
How to upgrade
Upgrade promptly — this release closes a critical authorization bypass. Bump the dependency to 0.27.2; for the common case (Rust core + SDK) it is drop-in, with no atomic peer roll and no config changes. Notes:
- C/Go FFI consumers of the aggregator error-detail accessors must free the returned string with
net_free_string(or use the updated Go bindings, which do). - Capability re-announce is automatic — long-lived RPC servers now refresh their own entry every 150 s by default; tune via
MeshNodeConfig::with_capability_reannounce_interval(set toDuration::MAXto disable). If you set it belowmin_announce_interval, the TTL is sized to the throttle, so peers stay live regardless. - Optional, unchanged from v0.27.1: rebuild the x86-64 target class with
RUSTFLAGS="-C target-feature=+avx2"to unlock the AEAD fast path. Default builds are unchanged.
A pure performance release — nothing on the wire moves
v0.27.1 ships no new systems, no new SDK surface, and no protocol changes. Every change either replaces an O(shards) operation with an O(1) atomic, swaps an O(n) full-scan for an index read, deletes an allocation, or corrects a benchmark fixture that was reporting fiction. The work is recorded in full in docs/misc/PERF_AUDIT_2026_06_08_BENCHMARK_WINS.md; this log is the operator-facing summary.
The organizing observation, the same shape as v0.27's: the substrate was answering cheap questions expensively. len(), node_count(), and stats() are called on admission gates and per-selection hot paths, and the default DashMap shards to 4 × num_cpus (128 on a 32-thread host), so every one of those calls locked and summed 128 shards regardless of how few entries the map held — an ~950 ns fixed cost to read a number the code could have maintained as it went. v0.27.1 maintains it as it goes.
DashMap::len() was a 128-shard walk on hot paths
The cross-cutting fix. Five subsystems carried AtomicUsize (and AtomicU64) counters that are now maintained exactly on every insert / remove / eviction, replacing the per-shard walk:
LocalGraph(swarm.rs) —num_nodes/num_edges/num_seen. The hot one: theseen_pingwavessoft-cap gate ran on every accepted pingwave, paying the shard walk per admission.local_graph/on_pingwave_duplicatedrops from 974 ns → 16 ns (~60×).ProximityGraph(behavior/proximity.rs) —num_nodes/num_edges/num_seen.MetadataStore(behavior/metadata.rs) —node_count, andstats()now reads its inverted indexes (status / tier / continent) instead of full-scanning every node with aStringallocation per entry.FailureDetector(failure.rs) —num_nodes, pluscheck_all()now reads the monotonic clock once per sweep instead of once per node.RoutingTable(route.rs) —num_routes/num_streams, including the per-novel-stream admission gate.
node_count() / len() / stats() reads collapse from ~950 ns to a sub-nanosecond atomic load. The FailureDetector per-status (healthy / suspected / failed) tally is deliberately kept as a scan — it's observability-only and node status is mutated in place, so a maintained per-status counter would silently drift. The scan is always exact.
Capability serialize — a one-word fix
sorted_tag_vec sorted capability tags with sort_by_key(|t| t.to_string()), which re-renders each Tag to a String on every comparison (~N log N allocations). Switched to sort_by_cached_key, which renders each tag exactly once (N allocations). Output order is byte-identical, so signed CapabilityAnnouncement bytes stay stable across peers — pinned by a regression test. capability_set/serialize drops 65.3 µs → 9.6 µs (~6.8×); capability_announcement/serialize 71.7 µs → 11.8 µs (~6.1×).
API registry — O(1) counts, index-derived stats, allocation-free path match
ApiRegistry (behavior/api.rs) got the same treatment plus an allocation fix:
len()/is_empty()/stats().total_nodesand the register capacity gate now readnode_count/total_endpointsatomics.api_registry_basic/len: 1.42 µs → 0.20 ns.stats()readsapis_by_namefrom theby_api_nameinverted index (provider count per name, skipping empty buckets) rather than full-scanning every node and schema with aStringclone per schema.api_registry_basic/stats: ~201 ms → ~7 µs.find_by_endpointcalledmatches_path(..).is_some(), allocating twoVecs + aHashMap+ aStringper endpoint per node just to extract a bool. A new allocation-freeApiEndpoint::path_matches() -> boolreplaces it at the three params-discarding call sites (the full scan is retained — it's correct for endpoints whose first path segment is a parameter, which a prefix index would miss).api_registry_query/find_by_endpoint: 6.98 ms → 1.88 ms (~3.7×), all from dropped allocation.
stats()'s apis_by_name is now distinct provider nodes per API name (the index is a provider set); this differs from the old per-schema-instance count only when one node advertises the same API name in two schemas — a degenerate case, documented and pinned by a test.
Load balancer — snapshot selection, right-sized hash ring
LoadBalancer::select (behavior/loadbalance.rs) is a per-dispatch hot path in GroupCoordinator, and get_available_endpoints iterated the endpoints DashMap via DashMap::iter — a 128-shard walk regardless of endpoint count.
- Endpoint snapshot. The authoritative
DashMapis kept for point lookups (reservation, health/metric updates);select/stats/endpoints/endpoint_countnow iterate a flatArcSwap<Vec<Arc<EndpointState>>>snapshot rebuilt only when the endpoint set changes. Per-endpoint atomic state (health, connections, circuit) stays live through the sharedArcs.lb_strategies/round_robin: 8.24 µs → ~340 ns (~24×);lb_scaling/select/10: 5.59 µs → ~370 ns (~15×). - Right-sized hash ring.
consistent_hashselection walks the separatehash_ringDashMap, which the snapshot doesn't cover; it was over-sharded the same way. Pinning it to 8 shards (HASH_RING_SHARDS) cutlb_strategies/consistent_hash~20% (49.1 µs → 39.8 µs), no new invariants.
A documented experiment (in the audit, "Snapshot vs. right-sized DashMap") confirmed the snapshot is not over-engineering: replacing it with a merely right-sized endpoints DashMap regressed select ~2× (a wait-free ArcSwap load over a contiguous Vec beats locking even 8 shards over scattered HashMap buckets on the iterate-heavy path). The snapshot stays; only the ring — which it doesn't cover — was right-sized.
Concurrency hardening (correctness, shipped with the perf work)
The dual-store and counter changes drew a review pass that closed five latent races before they could ship:
LoadBalancermembership lock —add_endpoint/remove_endpointnow serialize the map mutation + snapshot rebuild under aMutex, so concurrent membership changes can't store a stale snapshot last (which would silently drop a just-added endpoint from rotation). Off the hot path;selectonly reads.- Removed-endpoint flag — an
EndpointState.removedbit, set on removal and checked inis_available(), so a selector reading a snapshot taken just before a concurrent removal filters the gone endpoint out instead of burning a reservation retry into a transient falseNoEndpointsAvailable. ApiRegistry::registermade atomic per node — the read-old / re-index / insert sequence now runs under a singlenodesentry lock (mirroringMetadataStore::upsert), so concurrent re-registration of the same node can't drifttotal_endpoints(which, decremented withfetch_sub, could otherwise underflow to a huge value).ApiRegistry::cleardrains instead ofstore(0)— per-key decrement through the same chokepoints the live paths use, so a concurrentunregisterracingclearcan't underflow the counters.RoutingTable::get_stream_statsgated on the cap — it created astream_statsentry for any id unconditionally, bypassing theMAX_STREAM_STATSsoft cap therecord_*paths enforce; now gated, returningOption.
All five carry regression tests (including multi-thread stress tests for the counter races).
Benchmark fixtures — corrections, not wins
Three of the largest "before" numbers were never real production costs — they were shared, growing Criterion fixtures bleeding into each other. The audit's §7 records them so nobody chases the wrong number, and the O(1)/fixture work makes them moot:
failure_detector/check_all(670 ms),failure_detector/stats(198 ms), andmetadata_store_basic/stats(169 ms) were inflated by theheartbeat_new/register_newbenches ballooning a shared detector/store that the laterstats/check_allclosures reused.check_allis genuinely O(n), so its bench got a dedicatedgrowth_detector; thestats/lennumbers are moot post-rework because those methods are now O(1) regardless of map size. Post-fix: check_all 16.7 µs, stats 16 µs, metadata stats 15.9 µs.
Measured results
Full table in the audit doc. Headline figures (Intel i9-14900K, Criterion defaults):
| Benchmark | Before | After | Change |
|---|---|---|---|
local_graph/node_count |
958 ns | 0.20 ns | ~4770× |
local_graph/stats |
2.89 µs | 0.33 ns | ~8850× |
local_graph/on_pingwave_duplicate |
974 ns | 16 ns | ~60× |
metadata_store_basic/len |
956 ns | 0.20 ns | ~4750× |
routing_table/aggregate_stats |
13.1 µs | 6.07 µs | ~2.2× |
capability_set/serialize |
65.3 µs | 9.63 µs | ~6.8× |
api_registry_basic/len |
1.42 µs | 0.20 ns | ~6970× |
api_registry_query/find_by_endpoint |
6.98 ms | 1.88 ms | ~3.7× |
lb_strategies/round_robin |
8.24 µs | ~340 ns | ~24× |
lb_scaling/select/10 |
5.59 µs | ~370 ns | ~15× |
lb_strategies/consistent_hash |
50.6 µs | 39.8 µs | ~1.27× |
Absolute "after" figures on the sub-µs select/lb rows carry ±40–50% run-to-run variance on the dev box; they're representative, not precise, and the audit's re-verification note documents the spread. The multipliers and the order-of-magnitude wins are stable.
SIMD crypto (documented, opt-in). The audit's highest-leverage item — the ChaCha20-Poly1305 AEAD running on the software backend rather than AVX2 — is documented but deliberately not enforced in committed config: a baked-in +avx2 floor would SIGILL on pre-AVX2 x86-64 and is meaningless on ARM. Operators opt in per target class via RUSTFLAGS="-C target-feature=+avx2" (or target-cpu=native); default builds keep the software path, so nothing regresses and the ~5–10× data-path win is unlocked per deploy. See §1 of the audit.
Breaking changes
None on the wire, and none to behavior. v0.27.1 interoperates with v0.27.0 peers freely.
One minor source-level API refinement: RoutingTable::get_stream_stats now returns Option<Ref<…>> instead of Ref<…> (it returns None for a novel stream id once MAX_STREAM_STATS is reached, closing an unbounded-growth path). The type is re-exported, so an external caller would need to handle the Option; there are no in-tree callers outside tests.
How to upgrade
Drop-in. Bump the dependency to 0.27.1 — no source changes required for the common case, no atomic peer roll, no config changes. The performance wins apply automatically. Two optional levers:
- SIMD crypto: rebuild the x86-64 target class with
RUSTFLAGS="-C target-feature=+avx2"to unlock the AEAD fast path. Default builds are unchanged. get_stream_statscallers (if any exist downstream) add anOptionmatch /expect.
Dependency updates
Routine patch bumps only — no major or minor version changes, no behavioral surface change. The wasm-bindgen family and the js-sys/web-sys pair move together as usual:
http (1.4.1 → 1.4.2), js-sys (0.3.99 → 0.3.100), uuid (1.23.2 → 1.23.3), wasm-bindgen / wasm-bindgen-macro / wasm-bindgen-macro-support / wasm-bindgen-shared (all 0.2.122 → 0.2.123), web-sys (0.3.99 → 0.3.100). Cargo.lock carries the exact pinned versions.
Named after Prince's 1984 closer to the album and the film — the eight-minute power ballad cut live one August night at First Avenue in Minneapolis in 1983 and never re-recorded, the Wendy Melvoin guitar lead and the Lisa Coleman piano answering each other under a vocal Prince once said started life as a Bob Seger country song before he heard it differently. "Dearly beloved, we are gathered here today to get through this thing called life." The film's last shot is the Kid walking offstage after the band has finally come together; the album's last note is the long-decaying piano chord that follows. v0.27 is the substrate's same shape: the long-running reliability, security, and concurrency threads that have been running through the codebase since v0.21 close out together. Stream retransmit wires every piece of the reliable-stream machinery the substrate had implemented but never connected. The reliable-stream hardening pass closes the cluster of deficiencies that wiring surfaced. The channel-auth audit replaces bare-token credentials with root-anchored token chains. The capability fold's bulk-query path turns 100 ms scans into 100-µs lookups. The polling-to-event-driven SDK migration ends a class of CPU waste across every language tier. And a new fair-scheduler transport primitive ships datafort blob transfer in the same release as the SDK that exposes it. Same nRPC, same fold, same fold-driven discovery — but the substrate finally plays the chord that resolves the act.
A long act closes; a new transport primitive opens the next one
The v0.27 release converges a stack of work that has been threading through the codebase since v0.21. None of it introduces a new system — every piece either finishes wiring a substrate machine the codebase had built but never connected, hardens a path that has been carrying production traffic, or strips waste from a layer that has been overpaying. The public type surface bumps in a handful of places where the shape change earns its keep many times over (root-anchored token chains, the new scheduled flag on StreamConfig); everything else lands under the hood.
The organizing observation: the substrate already had every primitive it needed, just not always connected to itself. The reliability layer's retransmit code had shipped in isolation and lived in a separate code path the MeshNode receive loop didn't use; v0.27 wires it. The fair scheduler arbitrated relayed traffic but not originating sends; v0.27 adds an opt-in scheduled flag and a new blob-transfer subprotocol that rides it. The capability fold had a query surface but cloned the whole CapabilityMembership payload to extract a NodeId from each match; v0.27 makes the bulk-query path index-driven and the payload clone go away. The MeshOS snapshot publisher fired its change signal every tick regardless of whether anything structural had changed; v0.27 gates the signal on a structural-view diff while keeping the snapshot itself live. The substrate stops paying for what it isn't using, finishes what it's using halfway, and ships a new SDK surface for what's been sitting under it.
Below: the wins, grouped by where they fire.
Reliable streams — retransmit wired end-to-end, full hardening pass
MeshNode reliable streams provided dedup + in-order accounting + flow control, but did not retransmit lost packets — a dropped packet was a permanent gap, and the receiver stalled to the 30-second transfer timeout. The machinery existed (ReliableStream::{on_send, on_nack, get_timed_out, build_nack} were all implemented), but send_on_stream never called on_send, there was no retransmit loop, and the receive path never emitted a NACK. v0.27 connects every piece and then closes the cluster of deficiencies the connection surfaced.
Retransmit wired end-to-end. MeshNode::send_on_stream now registers a RetransmitDescriptor on every reliable send. The receive path emits a NackPayload-carrying SUBPROTOCOL_STREAM_NACK packet whenever an out-of-order arrival opens a gap, coalesced per (session, stream) through a per-mesh drainer. The sender consumes NACKs and resends from its descriptor window. A timeout backstop walks active reliable streams every RTO interval, resending tail packets a lost-final-packet case can't NACK. Verified by a test that drives a multi-MiB transfer under 1-in-10 drop and asserts byte-for-byte completion.
Retransmit window auto-sized to the tx-window. Pre-v0.27 the retransmit window was fixed at 32 entries; a tx-credit window admitting more than 32 in-flight packets silently evicted unacked descriptors and lost them permanently. v0.27 derives max_pending from the tx-window so the invariant tx-window ≤ retransmit-window holds for any window. Eviction-as-silent-loss is now a misconfiguration the runtime won't reach by default.
untracked_evictions surfaced. The eviction counter that should have been a metric for years gets a rate-limited warn! (first occurrence + every 64th) and an untracked_evictions() accessor so production loss is visible in dashboards.
Hard-failure signal on retransmit give-up. A descriptor past max_retries now flags the stream failed and emits a SUBPROTOCOL_STREAM_RESET to the peer; the receiver's blob-transfer engine maps the reset to BlobError and fails its pending read promptly instead of stalling to the caller's 30-second timeout.
Ack-driven pruning of the retransmit window. The retransmit window was never pruned on the happy path — packets lingered until the RTO and spuriously resent. The receiver's next_expected is now piggybacked on StreamWindow grants (now 24 bytes, +ack_seq); the sender prunes via ReliableStream::on_ack. Without this, the new give-up signal turned the spurious resend into a spurious give-up.
Proactive gap NACKs. A receiver whose consumption stalls on a gap can't drive a grant-piggybacked NACK. The retransmit loop now calls collect_gap_nacks per tick so recovery happens within an RTO instead of waiting on the sender's timeout backstop.
Adaptive RTO. RFC 6298 SRTT/RTTVAR with Karn's algorithm, clamped to [10 ms, 2 s]. Replaces the fixed 50 ms RTO that spurious-resent on slow WANs and was sluggish on fast links.
Reno-style congestion window. Slow-start and congestion-avoidance growth, multiplicative decrease on NACK loss, reset-to-floor on timeout. Gates send_on_stream via can_send; no-op on loss-free paths.
Graceful close. New MeshNode::close_stream_graceful waits for the reliable layer to drain (every send acked) or a timeout before closing — serve_chunk's hand-rolled ack-wait close becomes a substrate primitive.
In-order contract clarified. The substrate delivers events in arrival order plus seq; the blob-transfer engine reorders by seq itself; nRPC frames its own order and is fire-and-forget. Reliability::Reliable's docstring previously claimed in-order delivery; v0.27 corrects it and pins the contract at the delivery site. A general in-order buffer is deferred (no consumer needs it).
Channel auth — root-anchored token chains, locally-held publish chains
Root-anchored credentials. Bare-token credentials are replaced with TokenChain everywhere; a presented credential is honored only if it roots at one of the channel's token_roots. The subscribe path carries the chain over the wire end to end (subscribe_channel_with_chain).
Locally-held publish chains. The above broke delegated publishers — a node holding a publish grant via owner → org → this_node could only wrap its leaf token from the local cache, whose issuer is the immediate delegator, not the channel owner; the root-anchor check then failed. v0.27 adds MeshNode::set_publish_chain(channel, chain) so a delegated publisher can install the full chain locally; publish_many consults published_chains first and falls back to the cache-derived single-link form for direct-issued grants. Direct-issued publishers (the common case) need no change.
The publish self-check gates a node against itself, so this is correctness for honest delegated publishers rather than a closed attack surface — a deployment that grants publish rights by delegation silently lost the ability to publish post-audit until v0.27.
Capability fold — bulk-query path goes index-driven
v0.25 moved CapabilitySet's typed fields into a canonical HashSet<Tag> source of truth. The fold's bulk-query path didn't get the corresponding rework — composite_query was still cloning the whole CapabilityMembership payload for every candidate so find_nodes_matching could read the NodeId and throw the rest away. v0.27 closes the gap.
Whole-candidate-set clone removed. The bulk-query path returns NodeIds directly; the payload clone is gone.
Index-driven complex queries. query_model / query_tool were full-scan + clone + re-parse-every-tag operations against ~10k-node folds. v0.27 makes the index seed the candidate set; the post-filter walks the index, not the payload.
Benchmarks (M1 Max, 10k-node fold):
| query | before | after | factor |
|---|---|---|---|
query_single_tag |
14.2 ms | 184 µs | ~77× |
query_complex |
14.2 ms | 364 µs | ~39× |
query_require_gpu |
29.1 ms | 366 µs | ~79× |
query_gpu_vendor |
29.5 ms | 614 µs | ~48× |
query_min_memory |
29.7 ms | 486 µs | ~61× |
query_model |
108 ms | 88 µs | ~1230× |
query_tool |
109 ms | 374 µs | ~290× |
The locking surface is unchanged — concurrent queries already parallelize through the dual-RwLock-read structure that v0.22 shipped. The fix is to make each individual query cheaper, not to touch the locks.
MeshOS — snapshot change-gating, structural-view diff
The MeshOS loop runs publish_snapshot() at the end of every reconcile pass (default tick_interval 500 ms). Pre-v0.27 the call unconditionally stored a fresh MeshOsSnapshot and fired the change signal, waking every Deck consumer twice a second on a totally quiet cluster.
Structural-view signal gate. MeshOsSnapshot derives PartialEq, which makes "publish only when new != last" look obvious — except the snapshot is pervaded by server-projected relative-time fields (age_ms, freeze_remaining_ms, restart-backoff until_ms, migration elapsed_ms, peer since_ms, avoid-list TTLs, recently_emitted[].age_ms) that advance every tick. Equality gating would never suppress; gating the store would freeze the counters. v0.27 keeps the store live (counters tick, the swap is cheap) and gates only the change signal on whether the structural content changed. The alternative — explicit "what changed" telemetry on the producer side — is documented in the design record as deliberately not taken.
Polling → event-driven SDK migration
Several SDK "watch" surfaces ran interval poll loops that re-walked the capability fold every second and emitted a delta. The audit traced four candidates from binding down to substrate source; three turned out to already be push-based (memories watch, tasks watch, redex tail). The fourth — the Deck cohort (watch, watch_timeout, SnapshotStream, StatusSummaryStream) — was the real candidate and now consumes the substrate's existing change signal directly. Latency floor drops from 100 ms to single-digit ms; idle CPU drops to zero.
The substrate-side MeshNode::watch_tools is the load-bearing one — every binding's watchTools / watch_tools / WatchTools forwards its ToolListChange stream, so fixing it once at the substrate fixes all four SDKs.
Datafort blob transfer — fair-scheduler transport, SDK in five languages
The substrate's router has had every primitive bulk byte movement needs — streams, fair scheduling, per-packet priority, per-packet reliability flags, the FIN-driven lifecycle, ChaCha20-Poly1305 encryption — but no convention layer that said "blob transfer uses streams this way." v0.27 ships that layer plus the SDK that exposes it.
Scheduled streams. New StreamConfig.scheduled: bool (default false). When set, MeshNode::send_on_stream enqueues each built packet to the router's scheduler instead of calling socket.send_to directly — so the per-stream weights from set_stream_weight actually apply on originating sends, not just relayed ones. Default-false keeps every existing caller (nRPC streaming, replication) on the direct path.
SUBPROTOCOL_BLOB_TRANSFER. A new subprotocol carries content-addressed fetches over scheduled streams. Discovery rides the capability fold's causal:<hex> advertisement; the requester picks a peer, sends a control packet on a freshly-allocated transfer stream, the server validates possession-of-hash as the capability and chunks the blob into ≤8108-byte reliable events terminated by FIN; the receiver concatenates by arrival order and verifies BLAKE3. The hash is an unguessable 256-bit bearer token — sensitive-content callers must treat it as a secret or layer channel / capability auth above this transport.
Atomic fetch_dir. dataforts::dir::fetch_dir used to write directly under the caller's dest and leave it in a partial state on any mid-fetch failure. v0.27 replaces the direct write with a sibling-temp-path + atomic-rename pattern — the destination either becomes the complete new tree or remains exactly as it was before the call. Failure is a complete rollback with no SDK-side wrapping required.
Streaming send and receive. Pre-v0.27, single-file transfers were bounded by available process memory, not by disk: the receive path assembled the entire blob in one BytesMut before writing, and the send path slurped the whole source file before chunking. v0.27 streams chunk-at-a-time on both sides. The receive path appends each verified chunk to an <out>.partial and renames on commit; the send path reads through a new store_blob_reader substrate helper that hashes and stores each chunk as it's consumed. Large directory leaves (above one chunk) get the same treatment inside fetch_dir. Peak memory drops to roughly one chunk (4 MiB) everywhere; the only remaining cap is the per-chunk TRANSFER_MAX_CHUNK_BYTES ceiling (16 MiB), and total transfer size is now disk-bound. The CLI's recv-blob also gains a determinate byte-progress bar driven from the per-chunk loop.
Transport SDK in five tiers. Rust (net_sdk::transport), C (net.h extensions), Python (pyo3), TypeScript (napi-rs), Go (CGO over C) all gain fetch_blob(blob_ref) -> Bytes, store_dir(adapter, root) -> blob_ref, fetch_dir(adapter, root_blob_ref, dest), plus the DirManifest / DirEntry introspection types. The SDK stays thin — no retry policy, no rollback machinery, no directory-sync primitives. Substrate primitives exposed; applications compose policy above.
Operator CLI — net-mesh transfer and net-mesh typegen
The operator surface grows two new subcommands, both layered over primitives that already shipped.
net-mesh transfer. A first-class CLI for blob and directory transport. Six verbs against a live MeshNode resolved through the standard CliContext:
| Command | What it does |
|---|---|
net-mesh transfer recv-blob <source> <ref> --out <path> |
Fetch a single blob from a peer and stream it to disk |
net-mesh transfer send-blob <path> [--store] |
Chunk a file (or stdin via -), optionally persist each chunk, and print the resulting BlobRef |
net-mesh transfer recv-dir <source> <root-ref> --dest <path> |
Materialize a directory tree atomically — the destination either becomes the complete tree or stays untouched |
net-mesh transfer send-dir <path> |
Walk a directory, hash everything, print the root manifest's BlobRef |
net-mesh transfer ls |
List active transfers on the local node |
net-mesh transfer status <transfer-id> / cancel <transfer-id> |
Inspect or abort an in-flight transfer |
Progress renders as a determinate byte bar for sized fetches and a spinner for unknown sizes; piping into send-blob from stdin or redirecting recv-blob to stdout lets the verbs compose with the rest of the shell. The commands ship behind the existing cli feature flag — library consumers don't pay the clap build cost.
net-mesh typegen. Code generation from discovered AI tool descriptors. Walks the capability fold for ai-tool:* tags, fetches each matching descriptor's metadata (via tool.metadata.fetch), and emits typed bindings:
net-mesh typegen generate --language ts --tags weather --out ./generated
net-mesh typegen generate --language python --tools acme.web-search --out ./generated
Output is one module per tool. The tool's JSON Schema lowers to TypeScript interfaces (for ts) or Pydantic v2 models (for python); each module also exports a typed call helper (callAcmeWebSearch(mesh, request) for TS, call_acme_web_search(mesh, request) for Python) plus a …Meta constant carrying the descriptor's metadata (tool id, version, streaming flag, tags, description). The bindings work cross-language by construction — every typed call lands on the same wire RPC, so a Python agent calling a TypeScript tool calling a Go server is the same shape as a Rust client calling a Rust server.
The companion verbs make the workflow reproducible:
| Command | What it does |
|---|---|
net-mesh typegen snapshot --out <file> |
Capture the current matching descriptors into a versioned snapshot |
net-mesh typegen generate --from-snapshot <file> |
Regenerate bindings from a snapshot without re-querying the mesh — useful for hermetic CI builds |
net-mesh typegen diff <a> <b> |
Show what changed between two snapshots (added/removed tools, schema deltas) |
The substrate-side surface (list_tools, the capability fold, tool.metadata.fetch) all shipped earlier; v0.27 is the operator-facing assembly.
nRPC — recv batching, send batching, QPS scaling diagnosis
The v0.27 nRPC pass started as a nrpc_qps audit and turned into three threads. The diagnosis itself is in tree; the implementation is opt-in.
recvmmsg ingress batching (opt-in). New build feature batched-ingress + runtime MeshNodeConfig::batched_ingress enables the Linux recvmmsg path through BatchedPacketReceiver for the mesh receive loop. The BatchedPacketReceiver itself shipped in v0.21 and was already used by NetAdapter; v0.27 wires it into the MeshNode side. The shared receiver also now hands a whole recvmmsg batch over the channel per syscall instead of one packet per blocking_send. Default off until the c128 measurement justifies the cross-thread channel-hop tax.
sendmmsg batching for relayed traffic. A per-mesh group-by-dest drain on the scheduler send loop coalesces packets to the same destination into a single sendmmsg syscall. Disabled on the originating fast path because nrpc_qps is latency-bound with send-queue depth ≈ 1 — the diagnosis explains why the send-batching premise that worked for saturated one-way blasts doesn't apply to request/response. Where it does apply (concurrent relayed traffic between two peers), the win is real.
QPS scaling diagnosis. nrpc_qps scales c1 → c16 at ~4×, not 16×. The wall is the shared recv loop's single-consumer pipeline (recv → AEAD decrypt → bridge task → fold mutex), not the send path or the handler. The fix is the ack-piggyback protocol, in flight for a future release; v0.27 lands the diagnosis and the bench infrastructure that pins the ceiling.
Breaking changes
Wire format for channel auth. The token-chain change is breaking on the auth path; v0.26 and v0.27 peers don't interoperate on auth-gated channels. Roll the substrate across peers atomically for any deployment that uses channel auth tokens. Direct-issued publishers continue to work via the single-link fallback in publish_many; delegated publishers must install their chain locally (see below).
StreamConfig.scheduled (new field). Default false; the existing call surface is unchanged. Callers that constructed StreamConfig exhaustively without ..Default::default() need to add the field.
SUBPROTOCOL_STREAM_RESET (new subprotocol). Allocated for the hard-failure signal. Receivers must register a handler if they want the prompt-fail behavior; without the handler the legacy 30-second-timeout path still applies.
StreamWindow payload grew to 24 bytes (+ack_seq). Required by ack-driven pruning. The grant codec is versioned by SUBPROTOCOL_STREAM_WINDOW; old peers reading the new grant get a BadRange NACK and fall back to the heartbeat-cycle recovery path — backwards-readable, not backwards-compatible.
MeshNode::set_publish_chain (new method). Required only by deployments using delegated publish grants. Direct-issued publishers need no change.
MeshNodeConfig::batched_ingress (new field, feature-gated). Present only when the crate is built with --features batched-ingress; default false. Linux-only effect.
How to upgrade
- Roll the substrate across peers atomically if the deployment uses channel auth tokens. v0.27 doesn't handshake cleanly with v0.26 on auth-gated channels.
- Delegated publishers install their chain via
MeshNode::set_publish_chain(channel, chain)at node startup. Direct-issued publishers need no change. - Reliable-stream consumers see the new
SUBPROTOCOL_STREAM_RESETif they want the prompt-fail behavior on retransmit give-up; otherwise the legacy 30-second timeout is the fallback. - Deck consumers see fewer wakeups on quiet clusters; no source change required.
- Capability fold users get the new query latencies automatically; no API change.
- Linux deployments wanting
recvmmsgrebuild with--features batched-ingressand setMeshNodeConfig::batched_ingress = true. Default behavior is unchanged.
Dependency updates
One major-version bump and a clutch of routine patches.
shlex 1.3.0 → 2.0.1. The only major-version bump in the set. The crate's quoting surface at the substrate's call sites is unchanged in behavior, but downstream consumers that pin shlex = "1" in their own Cargo.toml will need to widen the requirement to resolve cleanly against v0.27.
Routine patch bumps. Alphabetical: bitflags (2.11.1 → 2.12.1), cc (1.2.62 → 1.2.63), ctor (1.0.6 → 1.0.7), generator (0.8.8 → 0.8.9), hyper (1.10.0 → 1.10.1), igd-next (0.17.0 → 0.17.1), log (0.4.30 → 0.4.31), mio (1.2.0 → 1.2.1), redis (1.2.1 → 1.2.2), rustls-native-certs (0.8.3 → 0.8.4), socket2 (0.6.3 → 0.6.4), typenum (1.20.0 → 1.20.1), unicode-segmentation (1.13.2 → 1.13.3), uuid (1.23.1 → 1.23.2), zerocopy and zerocopy-derive (both 0.8.49 → 0.8.50). Cargo.lock carries the exact pinned versions.
Named after Skid Row's 1991 single — the opening track and lead-off shot from Slave to the Grind, the record that blew up the band's bubblegum-metal reputation and, in the same swing, became the first hard-rock album to debut at number one on the Billboard 200 in the SoundScan era. Their 1989 debut had floated on power ballads — "18 and Life," "I Remember You" — and the label wanted more of the same; the band handed back a heavier, meaner, downtuned record and put "Monkey Business" first, Rachel Bolan and Snake Sabo's swampy, menacing strut, all swagger and trouble grinning in the doorway.
A full-surface security pass, and the eight places code drifted from its own safety protocols
v0.26 is a security hardening release. It is the result of a full-surface review across the parts of the crate where a mistake costs the most: wire-protocol parsing, the crypto primitives, the C-ABI FFI boundary, identity / token / auth, on-disk storage, and the client SDKs.
Most of the classic traps — the off-by-one slice, the unchecked length prefix, the malleable signature, the path-traversal write — already carry an explicit guard and a regression test pinning it. The eight issues that came out of the pass cluster in one place: where a single piece of code diverged from a safety protocol the rest of the codebase already follows. A blob handle that skipped the quiescing dance every other handle does. An inbound length cast the wide way in one binding and the narrow way in another. A token expiry that had a saturating add but no ceiling. The fixes mostly amount to making the outlier match the rule.
A blob handle that didn't play by the handle rules. The crate documents a per-handle quiescing protocol for exactly one hazard: a foreign thread (a Go cgo callback, a Python thread, a Node worker) sitting inside an FFI call while another thread frees the same handle. Every mesh / cortex / redis handle embeds a small guard, gates each operation on it, and on free leaks the handle box rather than deallocating it — so a racing call always lands on valid memory, sees the "freeing" flag, decrements, and bails. The mesh blob-adapter handle was the one that never got the treatment: it carried only the inner pointer, and its free did an unconditional deallocation. A store / fetch / exists racing a free read freed memory; a second free was a double-free. v0.26.0 embeds the guard, gates every operation on it, and makes free leak the box and drop only the inner — the adapter now follows the same recipe as every handle around it. A regression test pins both properties: an operation on a freed handle returns the null-pointer code instead of corrupting memory, and a double-free is a no-op.
An inbound length cast the narrow way. Inbound nRPC request bodies and the MeshOS causal-event / snapshot-restore payloads were copied from the native buffer with a 64-bit size cast down to a 32-bit signed int. A length with the high bit set went negative and crashed the copy before the handler's panic recovery could catch it; a length at or past 4 GiB modulo 2³² produced a short copy — a truncated body whose framing still claimed the original size, a clean parse-desync primitive. Both are reachable from whatever a peer puts on the wire. One binding file already did this correctly — checking the length against the platform-int maximum and copying through a wide slice — but the inbound trampolines had not been updated, in two separate binding copies. v0.26.0 routes every inbound site through one guarded helper that rejects an over-range length and copies through a wide slice, applied to both copies.
Tokens that could outlive the heat death. A permission token's expiry was a saturating add of issue-time plus requested duration, with no cap on the duration — a caller could mint a token with a TTL of u64::MAX, whose expiry saturated into a timestamp that never arrives. The only way to retire such a token is an advisory revocation floor that has to be distributed out of band and that a given node might never learn to bump. v0.26.0 rejects any TTL past a one-year ceiling at issue time with a typed TtlTooLong error. Delegation only ever copies a parent's expiry, so the bound holds transitively down the whole chain. Long-lived grants now have to be periodically re-issued — which re-checks the issuer's signing key and current policy — and the blast radius of any single leaked token is capped at a year.
Constructors that skipped the guard. The registry-client, fold-query-client, and channel-registration entry points, plus the blob-adapter constructor, dereferenced the inner mesh / redex node after only a null check, with no free-race guard. A concurrent free that won its race left them reading a dropped pointer. Same class as H1, narrower blast radius — these run before the handle is widely shared. v0.26.0 gates each on the relevant handle's guard; the node-clone accessors now hold the guard across the clone and return an Option, and every caller surfaces a null / error result when the handle is being torn down.
Clock skew with no ceiling. The token cache's clock-skew tolerance — a knob for absorbing NTP and container-clock drift — accepted any value. A large skew symmetrically widens every token's validity window: an expired token stays accepted for that many extra seconds, across the whole cache. The default is strict (zero), so this was misconfiguration-gated rather than on by default, but there was no guardrail. v0.26.0 clamps the tolerance to five minutes, which comfortably covers real drift while keeping a fat-fingered config from turning the expiry check into a rubber stamp.
Test hygiene
- Every fix that could carry a regression test does. The H1 fix pins that an operation on a freed handle bails with the null-pointer code and that a double-free is a no-op. The H3 fix pins rejection at and past the TTL ceiling and a valid, non-saturating token at exactly the ceiling. The M2 fix pins the skew clamp on both the constructor and the setter. The L3 fix plants a symlink to an out-of-root secret and asserts that fetch, exists, and stream all refuse it.
- A follow-up review caught two things the fixes themselves introduced. Bounding the TTL turned the SDK's infallible token-issue helper — which unwraps the fallible path — into a panic on an over-long TTL; it now soft-clamps to the ceiling instead, matching the existing zero-TTL soft-clamp, with its own release / debug / fallible test trio. The new read-path symlink test was gated to the platforms that can plant a unix symlink, and the blob existence probe re-applied its regular-file contract so a directory sitting at a blob slot is not reported as present.
- The full library test suite passes, including the new regression tests.
Breaking changes
TokenError has a new TtlTooLong variant
Additive, but TokenError is a plain enum — downstream code that matches it exhaustively without a wildcard arm will need a new arm for the variant. The binding error-string maps were updated in lockstep (ttl_too_long).
Token TTL is capped at one year
try_issue returns TtlTooLong for any duration past the one-year ceiling; the infallible issue wrapper panics on it (use try_issue for untrusted input). The SDK's infallible issue_token soft-clamps to the ceiling rather than panicking. Callers that were minting multi-year or never-expiring tokens must re-issue inside the bound or move to a periodic re-issue.
Clock-skew tolerance is capped at five minutes
TokenCache::with_clock_skew / set_clock_skew clamp any larger value to five minutes. A config that set a larger skew silently receives the clamp.
New public constants
MAX_TOKEN_TTL_SECS (one year) and MAX_TOKEN_CLOCK_SKEW_SECS (five minutes) are exported from the identity module for callers that want to check before they call.
How to upgrade
Most consumers — bump the dependency. The fixes are on by default and need no source changes unless you mint tokens with very long TTLs, configure a large clock skew, or match
TokenErrorexhaustively.Token issuers — check your TTLs. Anything past one year is now rejected on the fallible path and clamped on the SDK's infallible path. If you were relying on a never-expiring token, switch to a periodic re-issue — that is the point of the cap.
MAX_TOKEN_TTL_SECSis the ceiling to check against.Anyone matching
TokenError— add theTtlTooLongarm. Exhaustive matches without a wildcard will not compile until you do.Operators who tuned clock skew — confirm your value. Anything above five minutes is now clamped to it. If you genuinely needed a wider window you were papering over a clock problem; fix the clock instead.
Foreign-language callers sharing handles — no API change, but the race is now safe. Sharing a blob-adapter handle across threads and racing a free against an in-flight call no longer corrupts memory — the racing call bails with the null-pointer code. No code change required.
Wire format is unchanged; v0.25 and v0.26.0 peers handshake cleanly.
Named after the lead single from Billy Idol's 1993 album Cyberpunk — the one he cut as a concept record about networks reshaping how people would work, recorded with a Mac LC III in the booth and a Macromedia Director CD-ROM tucked into the jewel case, panned at release for being too-soon and now read as a marker of the moment the network stopped being a thing other people did. Same wire, same nRPC, same capability fold — but every typed service is now an LLM-callable tool, and the capability subsystem stopped paying for what every other discovery layer is paying for.
One surface every agent can call, and a capability hot path that got back to single-digit nanoseconds
The v0.25 release is the result of two pushes against the same mesh-discovery surface from opposite ends. The agent-facing push exposes every typed nRPC service as an LLM tool — serve_tool / list_tools / watch_tools / call_tool in Rust, Node, Python, and Go, plus format translators for OpenAI / Anthropic / Gemini / MCP so the descriptor lowers directly into whichever provider the agent already runs. The substrate-facing push is a perf audit against the capability subsystem after Phase A.5.N moved CapabilitySet's typed-struct fields into a canonical HashSet<Tag>: a per-tag String::clone in Tag::axis_key() plus a Tag::to_string()-keyed sort in the wire serializer had quietly turned a 3.7 ns match_min_memory filter into a 46 µs one. Four targeted fixes recovered the regression; the perf audit doc lands in tree alongside the release.
The release's organizing observation: discovery should be free in the hot path and cheap to author at the edges. The capability fold already aggregates every node's capabilities — agent discovery just walks it. The tag-set source-of-truth pattern is the right architecture, but allocating a String per tag per predicate match isn't its tax to pay.
Where v0.25 lands against the rest of the service-discovery field
In-process capability-filter evaluation in v0.25 sits 3–7 orders of magnitude below the published latencies of the network-coordinated discovery systems the field treats as fast:
| Layer | Operation | Typical latency | vs Net has_* (~30 ns) |
|---|---|---|---|
| Net v0.25 | has_gpu / has_tool / has_model |
20–44 ns | 1× |
| Net v0.25 | match_min_memory (single-field predicate) |
15 ns | 0.5× |
| Net v0.25 | match_complex (6 chained predicates, decodes models) |
3.8 µs | ~130× |
| Net v0.25 | CapabilitySet::to_bytes_compact (full set, postcard) |
2.0 µs | ~70× |
| Consul | DNS lookup, cached | 100–200 µs | 3,300–6,700× |
| Consul | DNS lookup, uncached (server) | 600–700 µs | 20,000–23,000× |
| Consul | client initial query | 1.6–3 ms | 53,000–100,000× |
| etcd | lookup, recommended P99 target | < 10 ms | > 330,000× |
| Kubernetes / CoreDNS | service lookup (ndots:5 default) | 100+ ms | > 3,300,000× |
| mDNS / DNS-SD | best-case local resolution | < 1 ms | > 33,000× |
Caveat — apples-vs-oranges: the v0.25 numbers measure in-process predicate evaluation against capability announcements already gossiped into the local fold. Consul / etcd / Kubernetes DNS are answering "where is service X across the cluster" with a network round-trip and (usually) a consensus quorum read. They aren't doing the same job. The fair comparison is the in-mesh agent scheduling loop: once announcements are in your fold (Net does that propagation via the same gossip path every other capability rides), filtering and dispatching against them is genuinely four to seven orders of magnitude faster than the registries an agent author would otherwise reach for.
External sources for the published latencies in the table: Consul DNS perf thread, Consul DNS perf issue #1535, Consul server resource requirements, etcd recommended practices (OKD), Kubernetes DNS ndots:5 latency, mDNS / DNS-SD discovery.
Below: the wins, grouped by where they fire.
AI tool calling — every typed nRPC service is an LLM-callable tool
NRPC_AI_TOOL_CALLING_AND_AGENT_DX.md (the plan shipping alongside this release) makes the bet that tool calling is what nRPC already does — "send a JSON object to a named handler, await a JSON response, optionally stream chunks" — with three gaps: metadata so a model can decide when/how to call, a server-streaming primitive matching the unary call_service, and a structured event envelope for streaming output. v0.25 closes all three and ships the agent-author surface across every binding.
One identifier, one source of truth. A tool registered as web_search IS the nRPC service at channel nrpc:web_search.requests IS the announcement carrying the ai-tool:web_search capability tag. No separate registry, no mapping table. Plain rpc.serve("x", handler) continues to register a service without the ai-tool:* tag — invisible to list_tools(). The serve_tool / tool({...}) / @tool opt-in is what makes a service agent-discoverable; operators retain control.
Discovery is capability-fold-native, not RPC-fanout. The capability fold already aggregates ToolCapability instances across every node. list_tools(matcher) walks the fold in-memory and returns ToolDescriptors carrying id + version + node_count + small metadata. Heavy fields (oversized JSON Schemas) fall back to an on-demand tool.metadata.fetch RPC, which serve_tool auto-installs on the host the first time it's called. Subnet visibility, capability auth, region filtering — all inherited from the existing fold + TagMatcher plumbing.
Streaming tools share one event envelope. ToolEvent is a tagged JSON enum every streaming handler emits per chunk:
start { tool_id, call_id, metadata? }— fires once on open.progress { pct?, message? }— coarse progress for spinners.delta { data }— partial output (model tokens, file bytes, log lines).result { data }— terminal full result; client sees one on success.error { code, message, details? }— terminal failure with structured detail.
Unary tools synthesize a single result envelope under the hood. The convention lets every adapter (OpenAI / Anthropic / Gemini / MCP / Hermes / custom) lower envelopes into the framework's native streaming protocol without per-pair negotiation. Two synthesized error shapes round out the contract: missing_terminal on the streaming caller when the server closed without a result/error chunk, and handler_error on the streaming server when the handler raised mid-stream. Both are part of the T-2 JSON byte-equality fixture so adapters can match on the code reliably.
serve_tool is atomic w.r.t. observable mesh state. Either all of (handler registration, capability-fold publish, nrpc:<tool_id> tag, ai-tool:<tool_id> tag, auto-installed tool.metadata.fetch if first) succeed, or none do. Drop on the returned handle reverses all four.
Cross-language by construction. The wire is unchanged: call_tool is call_service with the typed wrapper, call_tool_streaming rides the new call_service_streaming substrate primitive (mirror of call_service returning an RpcStream). A Python Hermes agent calling a Go-hosted database tool calling a TypeScript browser tool is transparent over the existing nRPC wire. The T-1 cross-language test pins byte-equality of every format translator output (to_openai_tool / to_anthropic_tool / to_gemini_tool / to_mcp_tool) across Rust / Node / Python / Go for every fixture descriptor.
Surface by language:
| Surface | Rust | Node TS | Python | Go |
|---|---|---|---|---|
serve_tool / call_tool (unary) |
✅ | ✅ | ✅ (sync + async) | ✅ |
serve_tool_streaming (handler returns Stream<ToolEvent>) |
✅ | ✅ | ✅ (sync + async-gen) | ✅ |
call_tool_streaming (capability-routed caller) |
✅ | ✅ | ✅ (sync + async) | ✅ |
list_tools / watch_tools |
✅ | ✅ (polling) | ✅ (polling) | ✅ (polling) |
tool.metadata.fetch (caller + auto-install server) |
✅ | ✅ | ✅ | ✅ |
| Format translators × 4 (OpenAI / Anthropic / Gemini / MCP) | ✅ | ✅ | ✅ | ✅ |
missing_terminal + handler_error synthesis |
✅ | ✅ | ✅ | ✅ |
AbortSignal / cancel on watch_tools |
✅ | ✅ | ✅ | ✅ (ctx) |
Format translators ship in one package per language. net-mesh-tools (pip) carries formats/{openai,anthropic,gemini,mcp} submodules; @net-mesh/tools (npm) carries formats/{openai,anthropic,gemini,mcp} submodules. Each translator is a small pure function from ToolDescriptor → provider tool-array entry, plus a reverse lower_tool_call(call) -> CallSpec for going from a provider's tool_use block back into a typed nRPC call. No transitive dep on any provider SDK — users wire the translator output into their OpenAI / Anthropic / Hermes / framework-of-choice client themselves.
No wire ABI bump for unary tool calls. Streaming tools use the new call_service_streaming substrate primitive; the wire shape of an individual stream is unchanged from call_streaming today. ToolEvent envelopes are JSON-encoded chunks on existing streams. NET_RPC_ABI_VERSION stays at 0x0004.
Capability perf — closing the Phase A.5.N regression cliff
PERF_AUDIT_2026_05_28_CAPABILITY.md (the audit doc shipping alongside this release) compared two M1 Max criterion runs and found that the Phase A.5.N migration — which moved CapabilitySet's typed HardwareCapabilities / Vec<ModelCapability> / etc. fields into a canonical HashSet<Tag> source of truth — had silently regressed eight capability microbenchmarks by 100× to 1,200,000×. The headline cases:
| Benchmark | Run 1 (typed fields) | Run 2 (post-A.5.N regression) |
|---|---|---|
capability_filter/match_gpu_vendor |
3.74 ns | 46.17 µs |
capability_filter/match_min_memory |
3.74 ns | 46.16 µs |
capability_filter/match_complex |
10.28 ns | 47.04 µs |
capability_set/has_model |
934 ps | 620.70 ns |
capability_set/serialize |
930 ns | 43.97 µs |
The migration was the right architectural call — tag-set as source of truth makes the diff / aggregation / federated-predicate stories cohere — but four hot-path costs piggybacked on the change. v0.25 closes all four:
Fix 1 — cheaper decoder sort (capability.rs). CapabilityViews::sorted_tags() and the three From<&CapabilitySet> projection impls were calling sort_by_key(|t| t.to_string()) — a fresh String allocation per comparison, ~150 allocations per views() call for a 35-tag set. v0.25 adds a separate decoder_sorted_tag_vec using Tag's derived Ord via sort_unstable(). The original sorted_tag_vec stays in place for the wire serializer (signed-announcement bytes need the Tag::to_string() canonical order for cross-version signature verification) — only the decoder paths switch.
Fix 2 — tag-direct fast paths in CapabilityFilter::matches. Single-field hardware predicates were forcing a full HardwareCapabilities decode (sort + per-tag axis_key parse + per-field value.parse()) just to read one tag. v0.25 adds CapabilitySet::axis_value(axis, key) -> Option<&str> (pub(crate)) and rewrites matches() so min_memory_gb / gpu_vendor / min_vram_gb probe the tag set directly the way has_gpu() already did. The views() call is now lazily guarded behind min_context_length and require_modalities — predicates that don't set those fields never decode.
Fix 3 — drop axis_key()'s per-tag String::clone (has_model / has_tool and 14 hot-path callers). Tag::axis_key() returns an owned TagKey containing a cloned key string. Every caller that iterated a tag set through it was paying ~35 String allocations per call. v0.25 adds Tag::axis_key_ref() -> Option<(TaxonomyAxis, &str)> and migrates the five view decoders (hardware_from_tags, software_from_tags, resource_limits_from_tags, models_from_tags, tools_from_tags), the five is_*_owned_tag predicates, Predicate::Exists, match_axis_tag, RequiredCapability::AxisKey, and MatchKey::{Axis, AxisKey} in capability aggregation. axis_key() is kept for callers that genuinely need an owned TagKey (diff.rs collects into HashSet<TagKey>).
Fix 4 — postcard compact codec for CapabilitySet. to_bytes is serde_json::to_vec and isn't going anywhere on the wire (signed-announcement byte stability + cross-version peer compat). v0.25 adds CapabilitySet::to_bytes_compact that emits 0x01 <postcard payload>, and from_bytes sniffs the first byte (b'{' → JSON, 0x01 → postcard, anything else → None) so receivers on this code accept both formats. The actual win came from serialize_tags_sorted branching on serializer.is_human_readable(): JSON keeps the canonical sort, postcard skips it (no signing on this path; the only consumer is a from_bytes that reconstructs the same HashSet regardless of element order).
Benchmarks (Windows host, same-run before/after per fix):
| Benchmark | Pre-fix | v0.25 | Δ |
|---|---|---|---|
capability_filter/match_gpu_vendor |
67.96 µs | 115 ns | ~590× |
capability_filter/match_min_memory |
58.94 µs | 25.75 ns | ~2289× |
capability_filter/match_complex |
4.42 µs (post fixes #1+#2) | 3.74 µs | −15.9% |
capability_filter/match_require_gpu |
74.90 ns | 38.91 ns | −48% |
capability_set/has_model |
755.54 ns | 31.65 ns | ~24× |
capability_set/has_tool |
680.02 ns | 34.69 ns | ~19.6× |
capability_set/serialize_compact |
54 µs (JSON) | 1.96 µs | ~27× |
capability_set/roundtrip_compact |
60 µs (JSON) | 6.35 µs | ~9.4× |
All 4137 lib tests pass (3 new tests pin the compact codec round-trip and the unknown-format rejection). Wire format is unchanged for any current peer: to_bytes is still JSON, the wire serializer keeps Tag::to_string() sorting, signed announcements stay byte-stable across versions. The compact codec is opt-in via the new to_bytes_compact — flipping the default writer to compact is a separate, deliberate rollout commit (every receiver must be on v0.25 first).
What's not in this release. CapabilityAnnouncement::to_bytes_compact is deferred. The struct has six #[serde(skip_serializing_if = ...)] fields (signature, hop_count, reflex_addr, the three allowed_* lists) whose omission is load-bearing for pre-M-1 / pre-v0.4 signed-byte compat, and postcard's positional encoding can't reconstruct an omitted field. A separate canonicalized wire struct is the right fix; tracked in the audit doc as a follow-up.
Test hygiene
- Two new audit docs shipped in tree.
docs/plans/NRPC_AI_TOOL_CALLING_AND_AGENT_DX.mdcovers the agent surface (eight locked decisions, phasing, per-binding status);docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.mdcovers the capability perf pass (headline regressions, root causes with file:line pointers, ranked fixes with risk/touch columns, before/after numbers per fix). T-1cross-language tool-format byte-equality, ratcheted across all four bindings. Thetests/cross_lang_tool_formats/golden_vectors.jsonfixture is consumed by Rust / Node / Python / Go verifiers in lockstep — adding a new descriptor / lower case / error case means updating all four. Drift surfaces as CI failure, not a runtime surprise.T-2ToolEventenvelope round-trip, same posture across all four bindings. JSON tag-form ({"type": "start", ...}) deserializes + re-serializes byte-equal for every variant + every optional-field combination listed intests/cross_lang_tool_formats/tool_event_vectors.json. The synthesizedError { code: "missing_terminal", ... }shape is part of the fixture so adapters can match on the code reliably.- Capability perf — all 195 capability lib tests pass at every commit in the perf series (
bd58b90b,20dba467,00aa6f75,2cb28f7d). Three new tests pin the compact codec:compact_wire_format_round_trips_and_interops_with_json,from_bytes_rejects_unknown_format_tag,announcement_*(JSON-only, since the announcement compact path is deferred). cargo clippy --features meshos,deck,aggregator --all-features --all-targets -- -D warningsclean. The strict floor from v0.20.2 stays armed; theclippy::useless-veclint that landed in Rust 1.95 caught one pre-existingvec![]in the capability test suite — fixed indeebf93e.cargo doc --features meshos,deck,aggregator --no-depsclean underRUSTDOCFLAGS="-D warnings". All newToolDescriptor/ToolEvent/tool::*intra-doc links resolve; the compact-codec docstrings inline0x01instead of linking to the privateCOMPACT_FORMAT_TAGconstant (94c87537).
Breaking changes
tool cargo feature on net-mesh
New optional tool = [] feature gates the tool.rs module + ToolEvent wire type. The Node / Python / Go binding default feature sets include tool — most users see no change. Direct net-mesh consumers who want serve_tool / call_tool need cargo add net-mesh --features tool.
The wire-level pieces this composes against (ToolCapability in behavior::capability, the capability fold, call_service_streaming) compile unconditionally so peers without the feature still exchange ToolCapability announcements.
call_service_streaming is a new substrate primitive
Mesh::call_service_streaming mirrors Mesh::call_service returning an RpcStream instead of a single response. Capability-routed + auth-gated through the same path as the unary variant. Every streaming tool client (Rust / Node / Python / Go) depends on it; downstream consumers who built their own streaming client on top of capability fold lookups can switch to this primitive.
tool.metadata.fetch is a new reserved RPC service name
Auto-installed by serve_tool on the first tool registration per node. Downstream consumers MUST NOT register an unrelated handler under this name — the auto-install asserts the slot is theirs and panics on collision. The reserved-name boundary is documented in docs/AGENT_TOOLS.md.
CapabilitySet::from_bytes accepts both JSON and the compact (0x01-prefixed postcard) format
Behavior-preserving for every JSON caller. A byte stream whose first byte is neither b'{' nor 0x01 now returns None instead of attempting a JSON parse — previously the JSON parser would have returned its own None, so the observable contract is unchanged. The first-byte sniff is documented on from_bytes.
CapabilitySet::to_bytes_compact is a new opt-in serializer
Default to_bytes is still JSON; flipping the default writer to compact is a separate rollout decision (every receiver must be on v0.25 first or it can't decode the new bytes). The compact codec is for local-only callers and a future deliberate wire-format flip.
Tag::axis_key_ref is a new method on Tag
Additive. axis_key() is unchanged (returns owned TagKey); axis_key_ref() returns Option<(TaxonomyAxis, &str)> without cloning. Hot-path iteration callers SHOULD prefer the borrowing variant — the cloning variant is only worth it when the caller actually needs an owned TagKey (e.g. collecting into HashSet<TagKey> for diff).
serialize_tags_sorted now branches on serializer.is_human_readable()
Internal-only break. JSON callers continue to get the sorted canonical form (signed-announcement byte stability); postcard callers skip the sort. No observable change unless a downstream consumer was relying on the Tag::to_string() order in a non-human-readable serializer output — that wasn't a supported contract.
CapabilityAnnouncement does NOT have a to_bytes_compact
Deferred. The struct's six #[serde(skip_serializing_if)] fields are required for pre-M-1 / pre-v0.4 signed-byte cross-version compat, and postcard's positional encoding can't tolerate omitted fields. A separate canonicalized wire struct is the right path; not in this release.
gpu_vendor_str is now pub(crate) in tag_codec.rs
Internal-only. Required by CapabilityFilter::matches's tag-direct vendor probe (constructs the expected Tag::AxisValue from the matcher's GpuVendor for O(1) HashSet::contains). No public surface.
ai-tool:<tool_id> capability tag is reserved
Substrate emits this automatically on every serve_tool registration. Downstream code SHOULD NOT emit ai-tool:* tags by hand — list_tools() filters on this prefix and a hand-emitted tag without the matching nrpc:<tool_id> service registration would surface as a phantom tool with no handler.
How to upgrade
Rust consumers — update the dependency to
0.25. No source changes required unless you (a) want to author or call tools (serve_tool/call_tool— enable thetoolfeature), or (b) iterate a tag set throughTag::axis_key()in a hot path (switch toaxis_key_ref()for the per-call allocation saving).Agent authors — pick your binding and follow
docs/AGENT_TOOLS.md. Rust:Mesh::serve_tool<Req, Resp>(...)(the#[tool]proc macro is the follow-up; runtime APIs are usable as-is). Node:tool({ name, description, schema, handle })with Zod schemas. Python:@tooldecorator on a Pydantic-typed handler (sync or async). Go:net.RegisterTool[Req, Resp](rpc, descriptor, handler). Discovery is the same shape in every binding:list_tools(matcher?)returns descriptors,watch_tools(matcher?)streamsToolListChange::{Added, Removed, NodeCountChanged}.Agent authors using OpenAI / Anthropic / Gemini / MCP — install the format package. Python:
pip install net-mesh-tools; import fromnet_mesh.tools.formats.{openai,anthropic,gemini,mcp}. Node:npm install @net-mesh/tools; import from@net-mesh/tools/formats/{openai,anthropic,gemini,mcp}. Each translator is a pure function fromToolDescriptor→ provider tool-array entry; the reverselower_<provider>_tool_call(call)returns aCallSpecyou pass intocall_tool/call_tool_streaming. No transitive provider-SDK dep — wire the translator output into your existing OpenAI / Anthropic client.Operators with capability-filter throughput pressure — expect the µs→ns recovery to land out of the box. No config knobs to flip. The four perf fixes are unconditional on the substrate path. Re-run
cargo bench --bench net -- "capability_(filter|set)"to confirm against your hardware; the audit doc has the same-host before/after numbers for cross-checking.Operators with binary-size budgets —
toolis opt-in. Directnet-meshconsumers who don't want the agent surface keep their default feature list. Binding artifacts: the binding'stoolfeature flag is on by default in Node / Python / Go; downstream consumers who don't want it pass--no-default-featuresand enumerate the features they do want.Downstream consumers caching capability bytes — opt into
to_bytes_compactwhen you control both sides. Local persistence, intra-process caches, and any storage path where the byte format is yours to choose can switch to the compact codec for the ~27× serialize win and ~10× roundtrip win. Wire callers (mesh.rs,swarm.rs,proximity.rs, the CLI announce path) should NOT switch until the entire fleet is on v0.25 — receivers on this release accept both formats, but receivers on v0.24 can't decode0x01-prefixed bytes.Operators on mixed-version fleets — wire format is unchanged.
CapabilitySet::to_bytesis still JSON,CapabilityAnnouncement::to_bytesis still JSON,serialize_tags_sortedstill produces theTag::to_string()canonical order for JSON serializers, signed-announcement bytes are byte-stable across versions. v0.24 and v0.25 peers handshake cleanly.Downstream Go binding consumers — ABI version unchanged.
NET_RPC_ABI_VERSIONstays at0x0004. The Go tool surface (net.RegisterTool,net.RegisterStreamingTool,net.CallToolStreaming,net.ListTools,net.WatchTools) is additive.CI — no config change required. Strict clippy floor stays armed (the new
clippy::useless-vecin Rust 1.95 caught one pre-existing test-fixture site, fixed in this release); rustdoc warnings stay denied; the cross-language tool-format byte-equality fixture is the new CI gate. Adding a new descriptor / lower case / error case intests/cross_lang_tool_formats/golden_vectors.jsonmust be done in lockstep across all four binding verifiers.Operators — bump the binary. Pre-built
net-mesh,net-deck,net-aggregator-daemonarchives land for every supported target (Linux x86_64 / aarch64, macOS x86_64 / aarch64, Windows x86_64). Wire format is unchanged from v0.24.
Named after the Dire Straits track that opened side two of Brothers in Arms in 1985 — the one Mark Knopfler wrote standing in a New York appliance store listening to a delivery guy heckling MTV, the one Sting flew in to harmonize the "I want my MTV" hook over a single take. Same wire, same semantics, same surface — money for nothing, and the bytes for free.
One audit pass, two wins, one binary that finally stopped paying for what it wasn't using
The v0.24 release is the result of one perf-audit pass against the nRPC hot path, one binary-size audit against the napi release artifact, and one structural follow-through on a footgun the size audit uncovered. The audit pass found two systemic costs that every nRPC call paid by default: a per-packet tokio::spawn + AEAD encrypt + sendto to emit a StreamWindow grant on each accepted inbound packet, and a per-reply roster lookup + ACL check + subnet filter + per-recipient Vec<Bytes> fan-out for response legs that already knew the caller. The size audit found regex — pulled into every binding's release artifact by an unread compiled_patterns field and a single matcher variant that most consumers never touched — was costing ~1.10 MiB on the napi win-x86_64 cdylib alone, and that the feature-disabled fallback silently returned empty matches.
The release's organizing observation: when the substrate emits work that the caller has already computed, the work is free to skip. The grant drainer skips the per-packet spawn / encrypt / sendto by coalescing every (session_id, stream_id) grant in a per-mesh map that a single drainer task drains on a 1 ms tick. The direct-response fast path skips the roster lookup by caching the AEAD-verified from_node at the bridge layer and routing the reply through publish_to_peer directly. And the regex-gate skips the binary cost entirely for consumers who don't construct a Regex matcher — and when they do construct one against a regex-less build, the binary now tells them so loudly instead of silently returning nothing.
Below: the wins, grouped by where they fire.
nRPC perf — drainer-batched grants, direct-send responses
PERF_AUDIT_2026_05_19_NRPC.md (the audit doc shipping alongside this release) flagged two costs that hit every nRPC call regardless of payload shape, contention, or transport. v0.24 closes both.
T1.1 — StreamWindow grant batching via a per-mesh drainer. The receive path previously emitted one wire grant per accepted packet: a tokio::spawn + AEAD encrypt + sendto round-trip per inbound packet, even for unary RPC where the response leg would have replenished credit on its own. v0.24 decouples emission from the receive path entirely:
- Per-
MeshNodestate:pending_stream_grants: Mutex<HashMap<(session_id, stream_id), PendingStreamGrant>>+ a singleNotify. - Receive path now does one lock + insert +
notify_oneper accepted packet — nospawn, no encrypt, nosendto. - A per-mesh drainer task (
spawn_stream_grant_drainer_loop) wakes on theNotifyor on a 1 ms safety-net interval, swaps the map out withstd::mem::take, and emits one wire grant per unique(session_id, stream_id). - Same-key receives between drain cycles overwrite the value (latest-wins). Grants are authoritative — every emission carries the receiver's full
total_consumed— so the latest entry subsumes every pending earlier one and the drainer never undercounts.
Supersedes a threshold-coalesce attempt (c38f01f5) whose RxCreditState::take_pending_grant heuristic deadlocked any sender configured with a tx_window smaller than the receiver's coalesce threshold. The receiver auto-creates streams with DEFAULT_STREAM_WINDOW_BYTES (64 KiB) regardless of the sender's config, so a sender opening a 512-byte stress stream (sdk/tests/mesh_stream_backpressure.rs) would stall waiting for a grant that wouldn't fire until 32 KiB of consumption. The drainer pattern has no threshold — every accepted packet enqueues, every drain cycle emits — so the deadlock can't recur.
T1.2 — Direct-send RPC responses via publish_to_peer. The four reply emit sites (unary, server-streaming, client-stream terminal, duplex chunks) previously built a ChannelPublisher and called mesh.publish, which runs the roster lookup + ACL check + subnet filter + per-recipient Vec<Bytes> alloc fan-out path before forwarding to publish_to_peer anyway. The response leg already knows the caller from the AEAD-verified inbound from_node.
- Per-service
RpcOriginNodeCache(Arc<DashMap<origin_hash, from_node>>) populated by the bridge from the inboundfrom_nodeat REQUEST receive time. - New
publish_response_to_callerhelper consults the cache, then falls back to the mesh's global origin-hash reverse index, then finally tomesh.publish— preserving correctness for loopback / test paths that emit withfrom_node==0. - Applied to every reply shape: unary, server-stream chunks, client-stream terminal response, duplex chunks.
Benchmarks (May 19 audit hardware, 14900K, c128 client mesh):
| benchmark | baseline | v0.24 (drainer + direct-send) | delta |
|---|---|---|---|
nrpc_qps c1/32B |
69.6 µs | 42.5 µs | -39% |
nrpc_qps c128/32B |
1.84 ms | 1.12 ms | -39% |
Per-RT, T1.2 alone clips ~3-8 µs off the response publish path; the rest of the win comes from T1.1's elimination of the per-packet grant overhead. The c128 case scales the win proportionally because the saved syscalls compound across concurrent in-flight RPCs.
Test posture. All 36 nRPC integration tests + 41 session unit tests + the previously-failing sdk/tests/test_sdk_send_with_retry_succeeds_through_backpressure are green. The drainer is exercised under both the small-window stress path (the test that deadlocked the v1 threshold-coalesce approach) and the c128 saturation path.
regex is now an opt-in Cargo feature (-1.10 MiB on every binding artifact)
regex was unconditional in Cargo.toml and pulled in by every consumer of net-mesh — the Node/Python/Go bindings, the CLI, downstream SDK users. Two consumers held references:
behavior::safety::SafetyEnforcer::compiled_patterns— held but unread (marked#[allow(dead_code)]); the safety enforcer never wired the pre-compiled pattern fast-path that field was reserved for.behavior::fold::capability_aggregation::TagMatcher::Regex— live, but the variant is one of six matcher kinds; consumers who never construct a Regex matcher pay the binary cost for zero functional benefit.
The cost is non-trivial: ~1.10 MiB on the napi win-x86_64 release artifact (9.49 MiB → 8.39 MiB after gating). The same delta lands on every binding (Python wheel, Go cdylib, C ABI, CLI).
v0.24 makes regex optional and gates the live usage:
Cargo.toml:regex = { version = "1", optional = true }; the previously-emptyregex = []alias becomesregex = ["dep:regex"].capability_aggregation.rs: the wire-formatTagMatcher::Regexvariant stays in the enum unconditionally — peers exchanging serialized matchers must keep working regardless of the receiver's feature set. TheCompiledMatcher::Regexarm and itsmatches_onebranch gate on#[cfg(feature = "regex")].safety.rs: thecompiled_patternsfield and its initializer gate on the feature.
Consumers who want regex matching turn it on:
cargo add net-mesh --features regex
The Node / Python / Go bindings re-export the feature through their own feature lists; downstream binding consumers flip it in their package.json / pyproject.toml / Go build tags the same way they flip every other binding feature.
TagMatcherError::RegexNotBuiltIn — explicit error, no more silent empty
The first cut of the regex gate routed TagMatcher::Regex to MatchesNothing on regex-less builds. Compiled cleanly, preserved the existing "invalid pattern → matches nothing" fail-closed contract — and silently returned empty results that looked indistinguishable from "no entries match this pattern." Operators couldn't tell whether their query was wrong or the binary couldn't evaluate it.
v0.24 replaces the silent fallback with a structured error and a loud panic at the call site:
- New
TagMatcherError::RegexNotBuiltIn { pattern }carries the offending pattern + an actionable Display message ("Rebuild with--features regexor use a different matcher"). - New
TagMatcher::validate(&self) -> Result<(), TagMatcherError>for proactive callers (RPC handlers, language-binding constructors, CLI parsers) that accept user-supplied matchers and want structured failure surfacing. compile()panics on the regex-less-build +Regex-variant combo with the same Display message. Callers that skippedvalidate()see the build-time-config mismatch loudly at first use rather than silently for the lifetime of the deployment.
Wire format is unchanged: TagMatcher::Regex stays in the enum unconditionally so peers can still exchange it. The doc on the variant calls out the gate and the validate-first contract.
Two new tests pin the behavior under #[cfg(not(feature = "regex"))]:
matcher_regex_without_feature_validate_returns_explicit_error— surfaces the structured error.matcher_regex_without_feature_aggregate_panics_with_actionable_message— surfaces the panic message.
The existing matcher_regex_with_invalid_pattern_matches_nothing test runs under #[cfg(feature = "regex")] only — its premise (regex crate compiles and then rejects a bad pattern) requires the feature.
async-nats 0.49 — PublishErrorKind::MaxPayloadExceeded classified as fatal
The Renovate-driven async-nats 0.23 → 0.49 bump added a new PublishErrorKind::MaxPayloadExceeded variant, which broke the exhaustive match in JetStreamAdapter::is_transient_error. v0.24 classifies the variant as fatal alongside StreamNotFound and the WrongLast* family — oversized payloads will not become recoverable on retry, and retrying would loop until an operator intervenes (the same production-down scenario that drove the Other → fatal classification in v0.20.2).
No SDK-surface changes. The classification matrix grows one structural-fatal row:
transient? retry?
TimedOut yes yes
BrokenPipe yes yes
MaxAckPending yes yes
StreamNotFound no no
WrongLastMessageId no no
WrongLastSequence no no
MaxPayloadExceeded (new) no no
Other no no (logged before return)
Operators who hit the new variant in production logs are getting a hard signal that a producer is exceeding the stream's max_msg_size config — the fix is upstream (chunk the payload, raise the stream limit), not in the retry loop.
Test hygiene
- Perf-audit doc shipped in tree.
docs/plans/NRPC_FLAMEGRAPH.mdlands alongside the perf wins — the flame-graph methodology, the 14900K bench rig config, and the before/after numbers are pinned in the repo so the next perf pass starts from a known reference frame. - The previously-failing backpressure test now passes.
sdk/tests/mesh_stream_backpressure.rs::test_sdk_send_with_retry_succeeds_through_backpressurewas deadlocked under the v1 threshold-coalesce approach (small-window sender + receiver's default 64 KiB stream → no grant ever fired). The drainer pattern has no threshold, so the test passes. cargo clippy --features meshos,deck,aggregator --all-features --all-targets -- -D warningsclean. Strict floor from v0.20.2 stays armed across the feature-flagged regex split.cargo doc --features meshos,deck,aggregator --no-depsclean underRUSTDOCFLAGS="-D warnings". Intra-doc links across the newTagMatcherError,validate(), andcompile()panic docs all resolved.- Feature-matrix CI. Both the default (regex-on, matches every existing binding default) and the regex-off path (consumers who explicitly disable regex to trim binary size) run their unit + integration suites. The two regex-off-only tests run only in the regex-off job; the live-regex test runs only in the regex-on job.
- Codecov coverage unchanged in posture — ~90% substrate, informational on CI status.
Breaking changes
regex is no longer pulled in by default for direct net-mesh consumers
Cargo.toml flips regex from an unconditional dep to optional = true. The regex = [] feature alias becomes regex = ["dep:regex"]. Direct net-mesh consumers who relied on transitive access to the regex crate via net-mesh need to depend on regex directly. Downstream consumers who construct TagMatcher::Regex must enable --features regex (or the matcher will return a structured error / panic per the new contract below).
The Node, Python, and Go binding default feature sets include regex — most users see no behavior change unless they intentionally trim the binding's feature list.
TagMatcher::Regex on a regex-less build now errors or panics instead of silently matching nothing
The previous regex-feature-off fallback was MatchesNothing — invisible to the caller. v0.24 surfaces it:
- Callers using
TagMatcher::validate(&matcher)?getTagMatcherError::RegexNotBuiltIn { pattern }. - Callers who skip validation and pass the matcher straight to
Fold::aggregate/Fold::capacity_rankingget a panic with the same actionable message.
The previously-pinned "invalid pattern matches nothing" contract still holds with the feature on — an invalid pattern (e.g. unbalanced parens) compiles to CompiledMatcher::Regex { re: None } and matches nothing, exactly as before. The behavior change is strictly for the feature-off path.
JetStreamAdapter::is_transient_error classifies MaxPayloadExceeded as fatal
Wire-shape compatible — the PublishError envelope is async-nats's own type. Behavior change: an oversized publish previously hit the catch-all branch (and now panics at the exhaustive-match compile error if anyone has been pinning async-nats 0.49 without the variant arm). v0.24 classifies it as fatal so the retry loop terminates and the underlying misconfig surfaces.
Per-packet StreamWindow grant emission is gone (internal-only break, observable as wire-rate)
The receive path no longer fires one grant per accepted packet. Operators watching wire traffic with tcpdump see fewer grant control packets per RPC — on a unary call, typically one terminal grant instead of N (one per inbound packet). Grants remain authoritative on every emission, so backpressure semantics are unchanged from the caller's perspective.
Direct-send response routing falls back to mesh.publish only when no peer hint resolves
Internal-only break. The four serve_rpc_* reply emit sites now consult a per-service RpcOriginNodeCache and fall back to a global origin-hash reverse index before reaching mesh.publish. Loopback / test paths that emit with from_node==0 still resolve through mesh.publish as before; production paths get the direct route. Downstream consumers who hooked the mesh.publish path for response-leg telemetry will see fewer events from that hook on the response side.
STREAM_GRANT_DRAIN_INTERVAL is a new private constant
Hard-coded to 1 ms. Not exposed as a tunable. The constant lives in adapter/net/mesh.rs and is documented inline alongside the drainer; a future config tunable is a one-line plumbing change against the constant's call site.
PendingStreamGrant is a new private struct
Internal to adapter/net/mesh.rs. Captures the AEAD session (cipher + packet pool + next_control_tx_seq) and the peer's wire address. Not exported; the per-binding APIs are unchanged.
How to upgrade
Rust consumers — update the dependency to
0.24. No source changes required unless you (a) constructTagMatcher::Regexdirectly and don't enable theregexfeature, or (b) match exhaustively onPublishErrorKindin your own code. The former: addvalidate()ahead of compile, or enable the feature. The latter: addMaxPayloadExceeded => ...to your match.Operators with
TagMatcher::Regexin their query mix — pin theregexfeature explicitly. Directnet-meshconsumers:cargo add net-mesh --features regex. Bindings: enable the binding'sregexfeature flag (Node + Python + Go default to on; downstream wrappers may differ). The structuredTagMatcherError::RegexNotBuiltInshows up invalidate()results when the build is wrong; the panic atFold::aggregateshows up at first use when validation is skipped.Operators with binary-size budgets — flip
regexoff explicitly. The napiwin-x86_64artifact drops 1.10 MiB. Downstream binding builds: pass--no-default-featuresand enumerate the features you do want (substitute the binding's own default list minusregex). Verify by greppingcargo tree -e featuresforregex— it should not appear.Operators watching
nrpc_qpsbenchmarks — expect the -39% delta to land out of the box. No config knobs to flip. The drainer's 1 ms interval is hard-coded; the response cache populates automatically on first inbound request per origin.Operators on async-nats 0.49 or later — the
MaxPayloadExceededclassification fix is automatic. A producer hitting the variant gets a hard fatal in the logs (look for the existingJetStream publisherror tracing); previously this same path would loop forever silently as part of the catch-all. Upstream fix: chunk the payload or raise the stream'smax_msg_size.Downstream consumers who hook
mesh.publishfor response-leg telemetry — re-wire to the substrate observer. The four reply sites now bypassmesh.publishon the production path. The substrate observer surface from v0.23 (setObserver/set_observer/SetObserver) fires on every RPC reply and is the supported way to observe the response leg.No CI config change required. Strict clippy floor stays armed; rustdoc warnings stay denied; the feature-matrix job runs both regex-on and regex-off paths. The Renovate config tracks async-nats minor bumps; future bumps that add
PublishErrorKindvariants will fail the exhaustive match at compile time, exactly as 0.49 did.Operators — bump the binary. Pre-built
net-mesh,net-deck,net-aggregator-daemonarchives land for every supported target (Linux x86_64 / aarch64, macOS x86_64 / aarch64, Windows x86_64). Wire format is unchanged from v0.23; mixed-version fleets handshake cleanly and the v0.23TypedMeshRpc.Regexvariant transmitted from a regex-on peer to a regex-off peer surfaces as the structured error on receive instead of silent empty.Downstream Go binding consumers — ABI version unchanged.
NET_RPC_ABI_VERSIONstays at0x0004. No symbol additions in this release.
Named after the Rolling Stones' 1969 opener on Let It Bleed — the one Keith Richards wrote during a thunderstorm at his Robert Fraser flat, the one Merry Clayton recorded in a single overnight session. Same wire, same semantics, same surface — gimme shelter, or I'm gonna fade away.
Three waves, one substrate primitive, every binding finally idiomatic
The v0.23 release is the result of three planning passes against the user-facing nRPC + Python surfaces. The first wave — Slice 2 + Slice 1 from NRPC_STREAMING_PARITY_AND_GO_BINDING.md — closes the streaming-typed gap on Node and Python (client-streaming + duplex typed wrappers + observer + metrics) and ships a Go typed binding from day one with the same shapes. The second wave — NRPC_V3_OBSERVER_MPSC_AND_CANCELLATION.md — promotes cancellation to a substrate primitive (Mesh::reserve_cancel_token / Mesh::cancel(token)) and routes every binding through it instead of letting three parallel binding-local cancel registries diverge further; in the same wave, every observer hook gets a bounded mpsc + drop counter so a slow callback can no longer pin the substrate's dispatch thread. The third wave — PYTHON_ASYNC_SDK_SIDE_BY_SIDE.md — adds Async-prefixed siblings to every Python class that today calls runtime.block_on(...), so asyncio-native consumers (FastAPI, LangGraph, an aiohttp sidecar, an asyncio.gather fan-out) don't have to fall back to a ThreadPoolExecutor.
The release's organizing observation: every binding had been growing its own shim layer to compensate for substrate gaps. The napi binding owned a cancel_registry: HashMap<u64, AbortHandle> with its own CR-13 race fix. The pyo3 binding owned a Cancellable pyclass with its own close_notify + tokio::select! pattern. The Go FFI owned a cancel_registry with its own Q18 orphan-TTL GC. Three implementations of the same idea, each with its own subtle bug fixes. v0.23 promotes the idea once, at the substrate, and the bindings stop holding their own state. Same shape for the observer machinery: three "callbacks must be cheap" footguns collapse into one bounded queue at the substrate boundary with a single drop counter that surfaces on every language's metricsSnapshot / metrics_snapshot / MetricsSnapshot. And the Python async surface lifts every block_on site once instead of asking users to wrap each binding call in a thread pool.
Below: the wins, grouped by where they fire.
Streaming parity across Node, Python, Go (one typed shape, four call shapes)
Before v0.23, the typed-nRPC matrix had holes. Node + Python had unary + server-streaming typed wrappers but no client-streaming or duplex typed surface. Go had no typed wrapper at all. v0.23 fills the matrix.
Node TypedMeshRpc.serveClientStream + callClientStream + TypedClientStreamCall. Mirror of the Rust SDK's serve_rpc_client_stream_typed + call_client_stream_typed. JSON encode on send, JSON decode on finish; encode failures throw nrpc:codec_encode, decode failures throw nrpc:codec_decode, all through the existing classifyError mapping. The server-side handler shim decodes each chunk and surfaces a malformed-request as RpcAppError(NRPC_TYPED_BAD_REQUEST, ...) so callers observe typed Application status instead of generic Internal.
Node TypedMeshRpc.serveDuplex + callDuplex + duplex typed wrappers. TypedDuplexCall<Req, Resp> with send / finishSending / next / intoSplit / close; TypedDuplexSink<Req> + TypedDuplexStream<Resp> for the split halves; TypedResponseSink<Resp> for the server side. Handler signature is the JS-idiomatic (stream, sink) => form, not the napi-binding's destructured [stream, sink] tuple — the typed wrapper destructures before invoking the user handler.
Python TypedMeshRpc.serve_client_stream + call_client_stream + TypedClientStreamCall. Same shape as Node. Sync iterator + context-manager (__enter__ / __exit__); decode failure on a chunk raises RpcCodecError and closes the underlying stream. Handler signature is (stream: TypedRequestStream) -> Resp; decode failure on the first request chunk surfaces as RpcAppError(NRPC_TYPED_BAD_REQUEST, ...).
Python TypedMeshRpc.serve_duplex + call_duplex + duplex typed wrappers. TypedDuplexCall / TypedDuplexSink / TypedDuplexStream / TypedResponseSink; __next__ raises StopIteration on EOF, decode failure closes the call and raises RpcCodecError.
TypedMeshRpc.setObserver + metricsSnapshot on Node and Python. The raw napi + pyo3 MeshRpc classes gain setObserver(handler) + metricsSnapshot() (and set_observer(callable) + metrics_snapshot() for Python); the typed wrappers expose the same surface. RpcCallEvent is a JS interface / Python dataclass with tagged-union status (Ok / Error(message) / Timeout / Canceled), per-call latency, request/response byte counts, and direction. The mid-call swap is atomic via the substrate's ArcSwapOption<RpcObserverHandle>.
Go typed binding — full surface in one file. New bindings/go/net/mesh_rpc_typed.go ships every shape from day one:
TypedCall[Req, Resp]+TypedCallService[Req, Resp]+TypedServe[Req, Resp]— unary, mirror ofrpc.call<Req, Resp>(...)ergonomics with one extra positional argument (the*TypedMeshRpcitself), free-function shape because Go forbids type parameters on methods.TypedCallStreaming[Req, Resp]+*TypedRpcStream[Resp]— server-streaming withRecv()returning(Resp, error)+ErrStreamDonesentinel on EOF.TypedCallClientStream[Req, Resp]+*TypedClientStreamCall[Req, Resp]+TypedServeClientStream[Req, Resp]— client-streaming.TypedCallDuplex[Req, Resp]+*TypedDuplexCall[Req, Resp]+Split()halves +TypedServeDuplex[Req, Resp]— duplex.(*TypedMeshRpc).SetObserver(handler)+MetricsSnapshot()— observer + metrics through the newnet_rpc_set_observer+net_rpc_metrics_snapshotFFI symbols.
RpcAppError(code, detail) minted through NewRpcAppError(NrpcTypedBadRequest, ...) / NewRpcAppError(NrpcTypedHandlerError, ...) matches the canonical nrpc:app_error:0x<code>:<body> shape; the Rust binding's parse_js_app_error reuses the same parser for Go consumers. The existing *RpcError Go type classifies codec failures as RpcKindCodecEncode / RpcKindCodecDecode — no RpcError changes needed.
Cross-language streaming round-trip test. tests/cross_lang_nrpc/ grows golden vectors for client-stream + duplex Application-status round-trips; Rust-side reference asserts the typed-handler-raising-RpcAppError shape lands at the caller as the expected wire-level error.
Observer dispatch as bounded mpsc + drop counter
The v1 typed-nRPC observer contract was "callbacks must be cheap; the substrate dispatch thread blocks until your callback returns." That contract lasts about a week in production before a user wires a Prometheus exporter or a disk-flushing log sink into setObserver and mesh-wide RPC latency spikes. v0.23 fixes the contract.
Bounded-mpsc per mesh, drop counter as a monotonic u64. Each binding now wires a 1024-event bounded mpsc between the substrate's dispatch path and the observer worker. Substrate on_call does try_send; full → atomic-counter increment, never blocks. The dispatch thread's per-event cost drops from "TSFN Mutex acquire" (napi) / "fresh spawn_blocking per event" (pyo3) / "synchronous C function pointer call" (Go FFI) to "atomic counter inc on a single AtomicU64."
One worker per binding installs the observer. The worker drains the receiver and pumps each event to the registered consumer: napi → TSFN; pyo3 → GIL-acquired Python callable; Go FFI → C function pointer. One worker = serialized callback invocation, matching each language's natural threading model. The worker dies when the sender drops (i.e. when setObserver(None) is called and the observer Arc is released).
observerDroppedTotal / observer_dropped_total / ObserverDroppedTotal on every snapshot. Process-global AtomicU64; reads-and-leaves (monotonic) for Prometheus exporter ergonomics. Surfaces as a top-level field on every binding's RpcMetricsSnapshot. Go consumers additionally get a net_rpc_observer_dropped_total() -> u64 FFI symbol so they can read the counter without paying the JSON-decode cost on the snapshot path.
Cortex-side consolidation. The mpsc plumbing lives in the substrate's cortex module (ObserverChannel<E> + OBSERVER_BUFFER_CAPACITY constant) — one centralized implementation, three thin per-binding wrappers. A future tunable on the buffer capacity is a one-place change instead of three.
Arc<RpcCallEvent> through the channel. Observer events now flow as Arc<RpcCallEvent> from the substrate's emit site, deferring the per-binding POD-conversion work to the drain worker. The dispatch thread allocates the event once, increments the Arc, and moves on; the worker pays the per-binding conversion cost on a non-hot-path thread.
Cancellation as a substrate primitive
Three bindings, three cancel registries. Promoted to one substrate primitive in v0.23.
CallOptions::cancel_token: Option<u64> + Mesh::reserve_cancel_token + Mesh::cancel(token). Reserve a token from the mesh; pair with cancel(token) from any thread to abort the in-flight call. Honored uniformly by call / call_service / call_streaming / call_client_stream / call_duplex — the substrate registers the token's abort handle at construction and removes it on resolution. Drop-on-cancel emits CANCEL on the wire via the existing per-call-shape Drop impls (UnaryCallGuard::Drop, ClientStreamCallRaw::Drop, DuplexCallRaw::Drop).
Per-mesh cancel_registry. parking_lot::Mutex<HashMap<u64, CancelEntry>> keyed by token. CancelEntry carries cancelled: bool (CR-13: cancel before register), handle: Option<AbortHandle> (unary + streaming construction), close_notify: Option<Weak<Notify>> (streaming post-construction), marked_at: Option<Instant> (Q18: orphan TTL). Lifted from the napi binding's existing pattern with the Go FFI's orphan-TTL GC (default 120s) merged in.
Race-safe across the reserve-then-call gap. A cancel that arrives BEFORE the call's abort handle is registered (the gap between reserve and call construction) latches a cancelled = true flag on the orphan entry; when the call later registers, it observes the flag and aborts immediately. Mirrors the napi binding's CR-13 fix at the SDK layer once instead of three times.
Binding migration: thin pass-through over the substrate primitive.
- napi.
lock_cancel_registry()andNEXT_CANCEL_TOKEN: AtomicU64are deleted.reserveCancelToken/cancelCallnapi methods now delegate toMesh::reserve_cancel_token/Mesh::cancel.callClientStreamandcallDuplexpopulateopts.cancel_tokenfrom the incomingCallOptions. The typed wrapper dropsstripSignalfor streaming entries and wireswireAbortSignalend-to-end. - pyo3.
Cancellable.__init__reserves a token from the mesh;Cancellable.cancel()callsmesh.cancel(token).call_client_stream/call_duplexextractopts['cancel']and populateCallOptions::cancel_token. The Notify-basedclose_notifypath onPyClientStreamCall/PyDuplexCallbecomes an internal implementation detail; the substrate registers aWeak<Notify>against it. - Go FFI. The file-local
cancel_registryis deleted.net_rpc_reserve_cancel_token/net_rpc_cancel_callbecome pass-throughs. New cancellable FFI variants —net_rpc_call_client_stream_cancellable+net_rpc_call_duplex_cancellable— populateopts.cancel_tokenand forward to the SDK. The Go typed wrapper'sTypedCallClientStream/TypedCallDuplexnow propagatectx.Contextthrough unchanged; the raw layer honors it.
Typed-wrapper pass-throughs. Node AbortSignal for streaming, Python Cancellable for streaming, Go ctx.Context for streaming — all wired end-to-end. The v1-era "v1: close()-only" caveat is gone from every streaming-entry docstring.
SDK-level cancel-contract integration tests. tests/integration_mesh_cancel.rs pins the contract before any binding depends on it: cancel_unary_mid_flight_emits_cancel_on_wire, cancel_streaming_mid_drain_emits_cancel, cancel_client_stream_mid_send_emits_cancel, cancel_duplex_mid_send_emits_cancel, cancel_before_construction_aborts_cleanly, cancel_after_resolution_is_noop, cancel_zero_token_is_noop, orphan_ttl_gc_evicts_unused_reservations. The bindings then test only their pass-through layer.
Cancellation cookbook (cross-binding). Documented in the v3 plan and surfaced in the per-binding README: Node AbortSignal, Python Cancellable, Go context.Context — three idiomatic surfaces, one substrate primitive, the same wire-level outcome. Power users in every language can also reserve tokens directly via the raw FFI surface for cross-call cancel sharing.
Python async SDK — side-by-side Async* surface for every I/O class
The pyo3 binding spans 16 modules and ~141 runtime.block_on(...) call sites. Every one of those took a Python thread + a tokio worker hostage for the call's duration; in an asyncio-native consumer that pattern serializes what should be concurrent and burns the application's thread budget. v0.23 adds Async-prefixed siblings for every class with async-worthy I/O. Existing sync API is unchanged.
AsyncNetMesh + AsyncNetStream. Shared MeshNode with the sync NetMesh — AsyncNetMesh(mesh) constructs against the existing peer-connection state without re-handshaking. connect / accept / stream enumeration return awaitables; peer_count / node_id / public_key are sync (in-memory reads).
AsyncMeshRpc — full raw client + server.
call/call_service/find_service_nodes(unary + service-discovery unary + local lookup).call_streaming→AsyncRpcStreamwith__aiter__+__anext__(server-streaming).call_client_stream→AsyncClientStreamCallwith awaitablesend/finish(client-streaming).call_duplex→AsyncDuplexCall/AsyncDuplexSink/AsyncDuplexStream(duplex + split halves).serve/serve_client_stream/serve_duplex— accept EITHER syncdeforasync defhandlers, detected viainspect.iscoroutinefunctionat register time. Sync handlers run on the substrate'sspawn_blockingpath; async handlers run as coroutines on a dedicated dispatcher event loop so the tokio worker can drive them without a Python loop on its own thread.
AsyncTypedMeshRpc + every typed streaming companion. AsyncTypedMeshRpc, AsyncTypedRpcStream, AsyncTypedClientStreamCall, AsyncTypedDuplexCall, AsyncTypedDuplexSink, AsyncTypedDuplexStream, AsyncTypedRequestStream, AsyncTypedResponseSink — JSON-encode/decode the same way the sync wrappers do; only difference is await self._raw.foo(...) vs self._raw.foo(...).
Wave T2 production essentials. AsyncNetDb (get/put/delete/list/batch_put), AsyncRedex + AsyncRedexFile + AsyncRedexTailIter (async for evt in file.tail(from_seq)), AsyncMemoriesAdapter + AsyncMemoryWatchIter, AsyncTasksAdapter + AsyncTaskWatchIter (every push-stream becomes a PEP-525 async iterator). AsyncDaemonRuntime + AsyncDaemonHandle + AsyncMigrationHandle (daemon spawn / migrate / wait). AsyncMeshBlobAdapter + module-level async_blob_publish / async_blob_resolve.
Wave T3 operator / specialty. AsyncDeckClient + AsyncSnapshotStream + AsyncStatusSummaryStream + AsyncAdminCommands + AsyncIceCommands. AsyncMeshOsDaemonSdk + AsyncMeshOsDaemonHandle (async receive / publish_log / graceful_shutdown). AsyncMeshQueryRunner.execute (async-RPC backed; the query AST classes stay sync — they're pure data builders). AsyncFoldQueryClient + AsyncRegistryClient.
Shared tokio runtime + shared MeshNode. Sync and async classes share one runtime per process and one Arc<MeshNode> per NetMesh. A server registered via MeshRpc.serve(...) is callable from AsyncMeshRpc.call(...); an async def handler registered via AsyncMeshRpc.serve(...) is callable from MeshRpc.call(...). Same wire, same identity, same cap-index entries.
Async substrate-cancel propagation via the v3 primitive. Every async I/O method mints a substrate cancel token, attaches an asyncio-cancel listener that calls Mesh::cancel(token), detaches on call resolution. asyncio.wait_for(async_rpc.call(...), timeout=0.1) on a long-running call surfaces asyncio.TimeoutError on the caller and Cancelled on the server's observer — same shape as Node's AbortSignal and Go's ctx.Context, just driven by the Python task lifecycle.
Server-side dispatcher event loop. async def server handlers dispatched from a tokio worker need a Python asyncio loop to drive the coroutine. The bridge lazily spawns a single daemon Python thread running asyncio.run_forever() on a fresh loop and routes every handler coroutine through pyo3_async_runtimes::into_future_with_locals(&dispatcher_locals, coro) — one loop per process, serialized GIL acquisition on the drain worker, no per-handler thread costs.
pyo3-async-runtimes (tokio backend) as a default dep. Bridges init-once via init_with_runtime(&runtime) in the pymodule init. Wheel grows ~50 KB; no feature flag bifurcation.
Test surface — TX cross-cutting matrix. tests/test_async_interop.py pins the (sync | async) caller × (sync | async) server matrix on the unary shape (4 tests; full-shape coverage lands incrementally as a follow-up). AsyncNetMesh(mesh) shared-handshake invariant pinned (no re-handshake when constructed against an already-connected mesh). Per-module smoke tests cover the T2 + T3 surfaces.
Test hygiene
- Lib suite continues to expand. New tests across the v3 cancel contract (
integration_mesh_cancel.rs— every call shape, the orphan-TTL GC, the CR-13 race), the bounded-mpsc drop counter (per-binding under-load tests in napi + pyo3 + rpc-ffi), the cross-language streaming round-trip (tests/integration_nrpc_cross_lang_streaming.rs— client-stream + duplex + observer firing), and the Python async surface (tests/test_async_interop.py+ per-module smoke tests for everyAsync*class). - Cross-language wire contract test. Every binding now also pins the canonical observer event shape and metrics-snapshot envelope (
observer_dropped_totalfield present,abi_version_expected = 4). A binding that drifts fails its compatibility test. - ABI version bump to
0x0004(rpc-ffi). Additive — existing 0x0003 symbols stay unchanged; new symbols arenet_rpc_call_client_stream_cancellable,net_rpc_call_duplex_cancellable,net_rpc_set_observer,net_rpc_metrics_snapshot,net_rpc_observer_dropped_total. cargo clippy --features meshos,deck,aggregator --all-features --all-targets -- -D warningsclean. Strict floor from v0.20.2 stays armed.cargo doc --features meshos,deck,aggregator --no-depsclean underRUSTDOCFLAGS="-D warnings". Intra-doc links across the new substrate cancel API + the per-binding cancel cookbook + the Python async surface all resolved.- Codecov coverage unchanged in posture — ~90% substrate, informational on CI status.
Breaking changes
NET_RPC_ABI_VERSION bumps from 0x0003 to 0x0004
Additive symbol additions only; existing 0x0003 functions are unchanged. Downstream Go binding consumers compiled against the pre-bump version panic at process init via the ExpectedABIVersion check at bindings/go/net/mesh_rpc.go:586-595. Override with NET_RPC_SKIP_ABI_CHECK=1 for in-development consumers; the next downstream Go binding cut should pin 0x0004.
Observer dispatch is no longer synchronous on the substrate dispatch thread
The v1 contract "callbacks must be cheap; the dispatch thread blocks until your callback returns" no longer holds. Observer events flow through a 1024-event bounded mpsc + worker task per binding. Behavior change: slow callbacks no longer pin the substrate; on overflow, events are dropped and the observer_dropped_total counter increments. Consumers that relied on the sync ordering (none in tree) get the new shape; callbacks should still be cheap, but the substrate is no longer on fire when they aren't.
CallOptions::cancel_token lands on the substrate CallOptions struct
Additive Rust-side field with Default::default() == None. Existing ..Default::default() callers continue to compile. Direct struct-literal callers that named every field need to add cancel_token: None.
Per-binding cancel registries are gone (internal-only break)
The napi binding's lock_cancel_registry + NEXT_CANCEL_TOKEN, the pyo3 binding's Cancellable internal state, and the Go FFI's cancel_registry + CancelEntry are deleted. Public APIs on each binding (reserveCancelToken / cancelCall napi methods, Cancellable pyclass, net_rpc_reserve_cancel_token / net_rpc_cancel_call FFI symbols) are preserved as pass-throughs; consumers that reached into the binding's private state directly need to switch to the substrate primitive.
Node TypedMeshRpc.callClientStream / callDuplex honor signal (was previously ignored)
The streaming entries' opts.signal was documented v1-and-only as close()-only; the wrapper called stripSignal to drop it. v0.23 wires wireAbortSignal end-to-end, so signal.aborted now fires raw.cancelCall(token) and the call rejects with RpcCancelledError. Consumers passing opts.signal to streaming entries were no-ops before; they'll start firing on cancel now.
Python TypedMeshRpc.call_client_stream / call_duplex honor opts['cancel'] (was previously ignored)
Same shape as Node. Passing a Cancellable to a streaming entry was a no-op in v1; it now propagates to substrate-level cancel. Consumers that were relying on "Cancellable is ignored for streaming" need to invoke cancel() only when they actually want cancel — the previous behavior of silent ignoring is no longer the default.
Go TypedCallClientStream / TypedCallDuplex honor ctx.Done() (was previously deadline-only)
The streaming entries previously honored ctx.Deadline() for the wire deadline but did not wire ctx.Done() to a cancel propagation path. v0.23 wires both. Cancelling the context now fires CANCEL on the wire.
RpcMetricsSnapshot grows observer_dropped_total (envelope-level u64)
Wire shape additive — postcard appends; existing readers tolerate the additional field. SDK consumers that built the struct by hand grow one field; consumers that rendered the snapshot via the per-binding MetricsSnapshot POD see the new field populated.
New Python Async* classes are exported from net
from net import AsyncMeshRpc, AsyncTypedMeshRpc, AsyncNetMesh, AsyncNetStream, AsyncNetDb, AsyncRedex, AsyncRedexFile, AsyncRedexTailIter, AsyncMemoriesAdapter, AsyncMemoryWatchIter, AsyncTasksAdapter, AsyncTaskWatchIter, AsyncDaemonRuntime, AsyncMigrationHandle, AsyncMeshBlobAdapter, AsyncDeckClient, AsyncAdminCommands, AsyncIceCommands, AsyncMeshOsDaemonSdk, AsyncMeshOsDaemonHandle, AsyncMeshQueryRunner, AsyncRegistryClient, AsyncFoldQueryClient all succeed. Existing sync imports are unchanged. Consumers that introspect dir(net) may see the new names.
Go typed binding lives in a new file
bindings/go/net/mesh_rpc_typed.go is new. Existing consumers of the raw *MeshRpc are unaffected; consumers who want the typed surface import the new symbols from the same net package.
How to upgrade
Rust consumers — update the dependency to
0.23. Direct struct-literal callers ofCallOptionsaddcancel_token: None; everyone else recompiles unchanged.Operators with custom observer callbacks — re-read the contract. The "callbacks must be cheap" guidance still applies (don't intentionally make the worker the bottleneck), but the substrate no longer pins the dispatch thread when a callback misbehaves. Monitor
observerDroppedTotal/observer_dropped_total/ObserverDroppedTotalinmetricsSnapshotto see if your callback is too slow for production load; size the upstream queue or push events into anasyncio.Queue/ Go channel / Node async iterator if drops are appearing.Cancellation users — migrate from binding-specific cancel surfaces to idiomatic per-language ones.
- Node:
new AbortController()+ passsignalinCallOptions. Streaming entries now honor it end-to-end. - Python:
Cancellable()+ passopts={'cancel': cancellable}. Streaming entries now honor it. Async callers useasyncio.wait_for(...)ortask.cancel()for free — the bridge converts asyncio cancellation into a substrateMesh::cancel. - Go:
context.WithCancel(parent)+ passctxto every call. Streaming entries now honorctx.Done(). - Power users in any language: raw
reserveCancelToken/reserve_cancel_token/net_rpc_reserve_cancel_token+ pass the token across multiple calls for shared-cancel scenarios.
- Node:
Node + Python typed users — adopt client-streaming + duplex.
TypedMeshRpc.serveClientStream/serveDuplex(Node) andserve_client_stream/serve_duplex(Python) are the new entry points. Handler signatures match the Rust SDK's typed surface. JSON codec is unchanged.Go users — adopt
TypedMeshRpcfrom day one.import "net/bindings/go/net";t := NewTypedMeshRpc(rawMesh);result, err := TypedCall[Req, Resp](ctx, t, target, "svc.echo", req). Streaming + observer / metrics work the same way as the Rust SDK.Python asyncio consumers — flip every blocking call to its async sibling.
MeshRpc(mesh)→AsyncMeshRpc(mesh);rpc.call(...)→await arpc.call(...);for chunk in stream→async for chunk in astream. Sync API unchanged; both APIs coexist on the sameNetMesh. The migration cookbook inbindings/python/README.mdwalks through a service-by-service move. asyncio cancellation works transparently viaasyncio.wait_for/task.cancel().async defserver handlers — Python only. Register anasync defhandler withAsyncMeshRpc.serve(...)orAsyncMeshRpc.serve_client_stream(...)orAsyncMeshRpc.serve_duplex(...). The bridge detects the coroutine function at register time and dispatches every invocation through a server-side dispatcher event loop. Sync handlers continue to run onspawn_blocking; the choice is per-handler, not per-class.No CI config change required. Strict clippy floor stays armed; rustdoc warnings stay denied; the test-side allow-list is unchanged. CI adds the cross-language streaming round-trip job and bumps the Go binding's pinned ABI version to
0x0004.Operators — bump the binary. Pre-built
net-mesh,net-deck,net-aggregator-daemonarchives land for every supported target (Linux x86_64 / aarch64, macOS x86_64 / aarch64, Windows x86_64). Wire format is additive from v0.22; mixed-version fleets handshake cleanly. The newRpcMetricsSnapshot.observer_dropped_totalfield and theCallOptions::cancel_tokenfield are postcard-appended; pre-v0.23 readers ignore them.Downstream Go binding consumers — update or override the ABI pin.
bindings/go/net/mesh_rpc.go::ExpectedABIVersionis now0x0004. Consumers compiled against0x0003panic at init.NET_RPC_SKIP_ABI_CHECK=1is the development override.
Named after Bob Dylan's 1967 cut from John Wesley Harding — the one Jimi Hendrix took six months later and turned into his own song so completely that Dylan started covering Hendrix's arrangement instead of his own ("he found things in the song that I didn't realize were there"). Twelve lines, three verses, no chorus: the joker tells the thief there must be some way out of here, the thief replies that the hour is getting late, and the camera pulls back to a watchtower where princes watch the women come and go and two riders are approaching from outside the frame. The whole song is built around the vantage point — somebody up on the parapet, looking down at the territory below, naming what they see. v0.22 puts that vantage point in the substrate. An aggregator daemon sits one tier up from a source subnet, subscribes to that subnet's detail channels through the existing gateway, summarizes what it sees, and publishes the summary to channels visible at the parent or peer tier. The mesh already had four-level hierarchical SubnetId on every packet, a gateway that enforced visibility at subnet boundaries by reading header fields only, label-based subnet assignment, and replica groups for any daemon role. v0.21 was about shrinking the gap between call and arrival on the hot path; v0.22 is about giving every tier a watchtower without inventing a parallel scoping mechanism. One generic state-aggregation framework replaces three would-be-separate fold implementations. One aggregator daemon role, deployed via the existing replica-group infrastructure, bridges tiers — N watchers per subnet, all publishing independently, subscribers picking the freshest by generation. One RPC surface lets operators spawn, scale, and query those watchers from any node, any language, any process. And one Deck retab puts the whole topology on the cyberdeck so the operator has the same vantage point the substrate just built.
One framework, three folds, watchers in front of all of it
The v0.22 release is the result of four planning passes that converged on the same insight: the substrate already had the primitives for million-node scale, but three layers above it were duplicating work to consume them. The capability index was a bespoke per-class store with its own subscription model and its own eviction; the routing table was a pingwave-driven sorted-by-metric thing with its own staleness sweeper; reservations were going to be a third bespoke layer doing the same shape of work for a third domain. The fold framework lands one generic runtime parameterized by a typed FoldKind trait — apply, expire, query, snapshot, recovery, audit emission, metrics — and three concrete instantiations on top of it. The legacy CapabilityIndex and the pingwave-driven routing table delete in the same diff that lands their fold replacements. No bridges, no dual-publish, no transition window.
Above the folds, the aggregator role lands as a normal daemon — but a daemon with a lifecycle. The substrate gets a new LifecycleDaemon async sibling trait to MeshDaemon (the existing sync/WASM-friendly trait), a LifecycleHandle RAII wrapper that owns the daemon's tokio loop, and a generic LifecycleGroup<L> that manages N replicas of any LifecycleDaemon as a unit. Aggregators are the first application; future tier services (market matchers, settlement bridges, reputation oracles) reuse the primitive. Placement spreads across failure domains via the scheduler; per-replica health drives auto-replacement via a background HealthMonitor with exponential backoff; a register_with_monitor constructor wires registry + monitor together so the operator never has to thread them by hand.
On top of the lifecycle layer, the aggregator.registry RPC service lets any node enumerate, spawn, scale, and unregister aggregator groups on any other node. The new turnkey net-aggregator-daemon binary boots from a TOML config, registers templates the operator can instantiate by name, defaults to auto-respawn-on-failure, and prints a single JSON bootstrap line on stdout for tools that need its bound address and pubkey. Five language bindings (Rust, TypeScript, Python, Go, C) get the same RegistryClient + FoldQueryClient surface with the same typed error kinds and the same wire contract locked in a single table. The CLI grows remote-attach: net aggregator query / spawn / scale / ls --remote against --node-addr <ip:port> --node-pubkey <hex> --node-id <n> round-trips through a live daemon. The Deck grows three new tabs and a focus page so operators can see subnet hierarchy, gateway state, and aggregator health without leaving the cyberdeck.
Below: the wins, grouped by where they fire.
Multi-fold framework: one runtime, three typed instantiations
The Fold<K> runtime is the new spine of the substrate's typed-state layer. One implementation handles every fold; concrete folds are trait impls.
FoldKind trait + Fold<K> runtime. FoldKind is parameterized by Key, Payload, Query, Result, and Index associated types; the fold author supplies key_for, merge, build_index, query, and optional audit_event. The runtime owns FoldState<K> (primary HashMap<K::Key, FoldEntry<K>> + a reverse HashMap<NodeId, HashSet<K::Key>> for O(1) node eviction), the expiry task, the audit sink, and the metrics handle. Snapshot/restore round-trips identical state — restored entries are naturally superseded by live announcements with higher generation numbers, so warm starts don't need any "I'm catching up" coordination.
SignedAnnouncement<P> wire format. One ed25519-signed envelope carries kind (the u16 KIND_ID that names the fold), class (the fold-specific sub-bucket — capability class hash, routing tier, reservation pool), node_id, generation, announced_at micros, ttl_secs, flags, and the typed payload. Subnet scope is not in the envelope — every packet already carries NetHeader.subnet_id, and the substrate's existing ChannelConfig::visibility (SubnetLocal / ParentVisible / Exported / Global) plus SubnetGateway handle scoping at the wire layer. Folds reuse this; they don't invent a parallel scoping model. The dispatch layer reads kind from the announcement header, looks up the registered fold instance, verifies the signature, and calls Fold::apply. Wire encoding is postcard; the format is versioned via the KIND_ID namespace.
CapabilityFold — replaces the legacy CapabilityIndex outright. Each (capability class, node) is one entry; subscribers learn which nodes are in which classes. The legacy CapabilityIndex and every caller (MeshNode::capability_index, Scheduler::place_*, ReplicaGroup / ForkGroup / StandbyGroup placement paths, the FFI surface, the Deck capability panel) was rewired in one diff. The inverted indices (by_tag, by_region, by_state) are part of the fold's Index type; tag-inverted lookup runs in the same shape it did pre-cutover, but now under the generic runtime with snapshot/restore and audit emission for free.
RoutingFold — replaces the pingwave-driven RoutingTable outright. Destination is the key; multiple announcements per destination from different routers compete via metric-based merge. The Router::lookup and MeshNode::dispatch_packet call sites rewired in the same diff. Pingwave packets become SignedAnnouncement<RouteAnnouncement> publishes on the fold:route: channel — same wire RTT measurement, new envelope. The route-staleness sweeper goes away; TTL expiry on the fold runtime handles it.
ReservationFold — new typed fold for single-holder resources. Each resource has at most one active reservation; ReservationState::Free | Reserved { holder, until } | Active { holder, job_id }; merge enforces a state machine (legal transitions accepted, illegal rejected with audit event). The same owner can transition through states; a different owner can only claim when the current state is Free. The fold's per-state summarizer derives stable bucket labels from a fixed-label match (not from format!("{state:?}")), so summary cardinality stays bounded regardless of how many distinct holders pass through.
Subscription dispatch + FoldRegistry. One FoldRegistry per node owns the HashMap<u16, Arc<dyn FoldDispatch>>. Channel-layer messages route by kind ID; signature verification happens at dispatch time using existing identity machinery. Replay protection is generation comparison; reorder protection is the same.
Audit + snapshot + metrics integration. Per-fold metrics: fold_entries_total, fold_applies_total{outcome}, fold_expiries_total, fold_queries_total, fold_query_duration, fold_apply_duration, fold_subscription_lag. Audit events: FoldEntryCreated / Replaced / Expired / Evicted / Rejected flow through the existing audit chain. Snapshots serialize at configurable cadence (default 5 min) and on graceful shutdown.
Aggregator daemons: a lifecycle layer above MeshDaemon
The aggregator role is inherently async (tokio::interval + mesh.publish().await); the existing MeshDaemon is documented sync-only / WASM-compatible (process(&CausalEvent) -> Vec<Bytes>). v0.22 introduces an async sibling and builds the aggregator on top.
LifecycleDaemon async sibling trait + LifecycleHandle RAII wrapper. LifecycleDaemon is the trait async daemons implement (async fn on_start, async fn on_stop, etc.); LifecycleHandle::start(daemon) owns the tokio loop and stops it cleanly on drop. The shutdown-aware tick loop checks the shutdown flag between publishes, so a long-running publish().await doesn't get its task dropped mid-flight by the backstop timeout. The backstop itself bumped from "summary interval + 100 ms" to a value that absorbs realistic publish latencies under load.
LifecycleGroup<L> — N-replica HA generic over the lifecycle daemon. Hoisted out of an aggregator-specific group type so any future tier service uses the same primitive. Deterministic per-replica keypairs via derive_replica_keypair(group_seed, index); spawn_with_placement consults the scheduler to spread replicas across failure domains within the source subnet; requirements() on the trait flows through to placement constraints. In-place grow/shrink via new add_replica (takes a factory closure, returns the new index) and remove_last (returns the stopped replica's Arc); deterministic-last-as-victim keeps the lowest-indexed replicas across resizes so identity continuity is preserved.
ReplicaHealth + LifecycleGroup::replace. Per-replica liveness is derived from start_instant + generation (no last_tick_at field needed — generation already advances on every successful summary). Unhealthy replicas can be swapped via replace(index, new_daemon); group-level health is "≥1 healthy replica."
HealthMonitor — background auto-respawn driver. Periodic per-replica health checks; failed replicas get re-spawned via a cached factory with exponential backoff so a persistently-broken daemon doesn't spin in a respawn loop. Configurable; register_with_monitor is the one-call constructor that wires registry + monitor together as the operator-facing entry point.
AggregatorDaemon as a LifecycleDaemon. AggregatorConfig { source_subnet, summary_visibility, summary_targets, fold_kinds, summary_interval, custom_summarizers }. On start, subscribes to source-subnet detail channels for each configured fold kind; on tick, walks each fold's Summarizer to produce SummaryAnnouncements and publishes them at the configured visibility. Validates at boot via a dry-run AggregatorDaemon::new so a misconfigured template is rejected on the operator's terminal, not on the first tick.
All replicas publish independently. No election machinery; subscribers see N summary announcements per cycle and the fold's merge picks the latest by generation. Operator can scale_to(1) to reduce summary traffic when availability isn't the constraint. State across re-placements rebuilds from incoming channel announcements + TTL refreshes within one TTL cycle (~30-60s); other replicas in the group publish full summaries during rebuild.
Built-in summarizers per fold. CapabilityFoldSummarizer (count by class + state, aggregate hardware capacity, distribution across sub-subnets) and ReservationFoldSummarizer (count by resource class + state, fixed-label state buckets). Routing is intentionally not summarized — routing wants full detail or none. Custom summarizers are Rust-only; bindings get the two built-ins via the daemon's template registry.
AggregatorRegistry on MeshNode. First-class registry surface — net aggregator inspect reads it; the RPC service publishes from it; MeshOS inspection surfaces include aggregator groups alongside DaemonRegistry's mesh-daemon entries. Holds LifecycleGroup directly (rather than wrapping it), so registry entries carry enough state to fill RegistryGroupSummary fields without a second indirection.
Turnkey net-aggregator-daemon binary
For operators who don't want to embed the substrate in their own process, the new net-aggregator-daemon crate ships a turnkey binary + library that boots from a single TOML file.
Templates + groups. [[template]] blocks declare named aggregator specs (source_subnet, summary_visibility, fold_kinds, summary_interval); [[group]] blocks instantiate templates at boot. Templates are validated up-front via a dry-run AggregatorDaemon::new — if a template's config is broken, the daemon fails on start with a copy that points at the bad field, not silently at the first tick.
Spawn / Unregister / List / Scale via RPC. Once running, the daemon serves the aggregator.registry RPC service. Operators ship a config with zero [[group]] blocks and spawn dynamically via the wire, or pre-declare groups in TOML and let the wire surface only handle scale + lifecycle. The Spawn { template_name, group_name, replica_count } op resolves the template, derive_seed_from_name (blake3, deterministic) computes the group seed from the operator-supplied name, and a factory closure constructs each replica with the resolved spec.
Auto-respawn by default. HealthMonitor is installed by default; operators who want bare-metal control opt out. Replica failures trigger respawn via the cached factory with exponential backoff on persistent failures.
--print-bootstrap flag. Emits a single JSON line to stdout before entering the wait loop: {"node_id": N, "bound_addr": "127.0.0.1:54321", "public_key_hex": "abcd…"}. Binding test fixtures and CLI subprocesses read the first stdout line, parse it, and use the triple to drive their handshake — no more parsing tracing output.
Parallel group spawn at boot. Groups declared in TOML start their replicas in parallel via try_join_all (replica on_start is independent within a group; group-level startup is independent across groups). Boot time on a 4-group × 3-replica config drops from sequential 4 × 3 × on_start_time to max(on_start_time).
AggregatorSpec unification. GroupConfig and TemplateConfig unify behind one AggregatorSpec; the spawn-and-register path is shared between boot-time groups and RPC-spawned groups.
Cross-subnet detail-on-demand RPC
When a subscriber sees a summary (via ParentVisible / Global summary channels) and wants detail from the source subnet, it RPCs the aggregator. The wire and client surface are first-class.
FoldQueryService (fold.query). Aggregator daemons install the service automatically. Query shape: (kind: u16, class: u64, query: Bytes) → Bytes (fold-specific, postcard-encoded). The aggregator answers from its local fold state; the gateway forwards the RPC based on subnet_id + channel visibility per the substrate's normal routing. No new wire protocol, no special-case routing.
FoldQueryClient with cache semantics. query_latest(target, kind) consults a per-target LRU keyed on (target_node_id, kind) with DEFAULT_QUERY_CACHE_TTL (5s) before going to the wire; query_summarize_now(target, kind) forces a fresh tick on the host and bypasses the cache. with_ttl / with_deadline builders override defaults; invalidate_cache / invalidate_target give explicit eviction. Same cache semantics across every language binding.
Discovery via the registry. Replicas are tagged with role:aggregator; the source subnet is in their identity. route_event on the underlying group picks the closest healthy replica.
Copy-on-write latest buffer. The aggregator's latest-summaries buffer is Arc<Vec<SummaryAnnouncement>> — tick_once returns its novel batch directly, SummarizeNow reads it without re-copying, and the buffer evicts oldest-first via VecDeque.
aggregator.registry RPC + RegistryClient
A single RPC service drives every aggregator operation across the wire.
Wire surface (RegistryRequest / RegistryResponse).
List→Groups(Vec<RegistryGroupSummary>)— every registered group with per-replica health.Spawn { template_name, group_name, replica_count }→Spawned(RegistryGroupSummary)— daemon resolves the template, derives the seed from the group name, spawns N replicas via the lifecycle group.Unregister { group_name }→Unregistered { existed }— stops the group cleanly.Scale { group_name, template_name, target_replica_count }→Scaled(RegistryGroupSummary)— dedicated in-place grow/shrink; held replicas keep their tokio loops and their identities across the resize (no Unregister + Spawn churn). The server re-resolves the template and compares against the group's storedsource_subnet+fold_kindsso a--templatetypo is caught before any state change.
Server split. The registry service splits into RegistryReadHandler (List-only — installable without a spawner) and RegistryHandler (full read + write). Read-only deployments don't pull in spawn-side dependencies.
RegistryClient + BoundRegistryClient. RegistryClient::new(mesh).with_deadline(d).list(target_node_id) / spawn(...) / unregister(...) / scale(...). BoundRegistryClient::for_node(mesh, target_node_id) binds the target once so subsequent calls don't repeat it.
Typed error discrimination. RegistryClientError { Transport, Codec, UnknownTemplate, DuplicateGroupName, SpawnRejected, SpawnNotSupported, ScaleRejected, UnknownGroup, Server(detail) } — kind discrimination flows through to every language binding's native error type.
Wire metadata on RegistryGroupSummary. Carries name + group_seed (32 raw bytes → 64-char lowercase hex in language SDKs) + source_subnet + fold_kinds + replicas: [{generation, healthy, diagnostic, placement_node_id}]. The source_subnet + fold_kinds fields land in the same wire bump as Scale so net aggregator ls --remote renders the full spec without a separate Describe op.
Parallel scale grow. Scale-up grows via a bulk add_replicas helper that runs each replica's on_start in parallel via try_join_all. Same shape as boot-time parallel group spawn.
typed_call helper. A thin MeshRpc::typed_call wrapper carries the postcard codec + deadline plumbing for both FoldQueryClient and RegistryClient; client code stops re-implementing the marshal-call-unmarshal-translate-error chain per surface.
CLI remote-attach
The CLI grows the ability to drive a remote daemon, not just inspect the local one.
CliContext::with_mesh + remote-attach flags. CliContext now optionally carries an Arc<MeshNode> constructed at build time when --node-addr <ip:port> --node-pubkey <hex> --node-id <n> are passed (or the --remote <NAME> shortcut pulls all three from a named profile). The local mesh boots on 127.0.0.1:0 with a PSK from the profile, connects to the remote daemon via the routed handshake path (see below), and starts the dispatch loop before any verb runs.
net aggregator query / spawn / scale / ls against live daemons. Each verb consumes ctx.mesh_node() and dispatches via the typed client.
query --kind <hex>→FoldQueryClient::query_latest(or--fresh→query_summarize_now). Output renders theSummaryAnnouncements as JSON via the existingSummaryRowshape.spawn --template <NAME> --name <NAME> --replica-count <N>→RegistryClient::spawn.--source-subnetis gone fromspawn— the template owns it, and the daemon resolves it on the wire side.scale --template <NAME> --name <NAME> --replica-count <N>→RegistryClient::scale. Atomic in-place resize; held replicas keep their generations across the call.ls --remote→RegistryClient::list. Renderssource_subnet+fold_kinds+ per-replica rows from the wire shape.
Routed handshake via connect_via. The substrate's dispatch loop drops direct handshake msg1 packets from peers it hasn't pre-accepted — only the initiator-side has a post-start registry; the responder side was explicitly deferred. CLI subprocesses generate fresh ephemeral identities, so the daemon can't pre-accept them. The CLI now connects via connect_via (routed) — handle_routed_handshake Case 2 already accepts msg1 from fresh initiators against a running dispatch loop. Substrate change: connect_via populates addr_to_node[relay_addr] via entry().or_insert(...) (preserves true multi-hop semantics when relay ≠ final dest), and honors the same handshake_retries knob as direct connect.
Profile + flag precedence. [default].psk_hex + [default].node_addr / node_pubkey / node_id in the CLI config serve as bootstrap shortcuts when always pointing at the same daemon; flags override. Bad pubkey / wrong PSK / wrong node_id all map to typed CliError::RemoteAttach with exit code 6 ("connection / handshake failure").
CLI integration test. cli/tests/aggregator_remote.rs boots net-aggregator-daemon (in-process via the library helper, not as a subprocess — same trick the bootstrap pin test uses), reads node_id / bound_addr / public_key from the booted handle, then invokes each verb via assert_cmd::Command::cargo_bin("net") and asserts exit codes + JSON shapes. Positive paths plus bad pubkey (exit 6), wrong PSK (exit 6), unknown template (exit 3).
SDK surface across five languages
Every operator-facing aggregator surface ships in Rust, TypeScript, Python, Go, and C — same wire types, same error kinds, same factory-callback infrastructure where applicable.
net_sdk::aggregator module (Rust). Re-exports the client-only types (RegistryClient, FoldQueryClient, error types, default constants) plus the daemon-author surface (AggregatorConfig, AggregatorDaemon, AggregatorRegistry, LifecycleGroup, HealthMonitor, Summarizer, the two built-in summarizers, the registry service installers). BoundRegistryClient::for_node binds a target id once so subsequent calls don't repeat it. install_default_service is the one-call read-only registry installer. Aggregator is promoted to a first-class default SDK feature flag — operators don't opt in via --features aggregator; it's on by default everywhere the SDK is consumed.
TypeScript (NAPI + @net-mesh/sdk). import { RegistryClient, FoldQueryClient } from '@net-mesh/sdk'. Constructors take a MeshNode; withDeadline(ms) / withTtl(ms) are builders; ops return promises. Group seeds are 64-char lowercase hex strings (BigInt is awkward at 32 bytes); u64 fields are BigInt. Errors are JS class RegistryClientError extends Error with kind: string + optional serverDetail.
Python (PyO3). from net_mesh.aggregator import RegistryClient, FoldQueryClient. asyncio futures via pyo3-asyncio (already in the binding's dep set). Returned shapes are dicts; errors are typed subclasses of RegistryClientError (UnknownTemplate, DuplicateGroupName, SpawnRejected, SpawnNotSupported, etc.).
Go (CGO). net_mesh.NewRegistryClient(mesh).List(ctx, targetNodeID) / .Spawn(...) / .Scale(...) / .Unregister(...). context.Context carries the deadline (honors ctx.Deadline() if set, falls back to the client's configured default). RegistryClientError { Kind, Detail } implements error + matches via errors.Is. A consumer-side Go wrapper around the cgo cdylib keeps the test surface idiomatic.
C ABI (in the main net-mesh cdylib). net_registry_client_new / free / with_deadline / list / spawn / unregister. Errors as net_registry_error_kind_t discriminant + net_registry_last_error_detail accessor. Also: net_visibility_t (GLOBAL / PARENT_VISIBLE / SUBNET_LOCAL) + net_register_channel(mesh, name, visibility) — C consumers can now configure channels with the visibility tier, not just read snapshots.
Wire contract locked across languages. One table fixes group_seed encoding (32 raw bytes → 64-char hex), u64 marshaling per language, deadline carriers (TS: number ms; Py: float seconds; Go: time.Duration), and the canonical error-kind string set (transport, codec, unknown-template, duplicate-group-name, spawn-rejected, spawn-not-supported, unknown-group, scale-rejected). Bindings that diverge fail their compatibility test.
Deck: subnets, gateways, aggregators as first-class tabs
The cyberdeck grows three new tabs in the tab strip plus a focus page that drills into a single subnet.
Tab strip overflow + horizontal scrolling. The strip scrolls horizontally to keep the current tab visible; trailing letter-key hints render on overflowed entries so operators can still hit them by key. SUBNETS, GATEWAYS, AGGREGATORS, and AUDIT join the strip alongside the existing tabs.
SUBNETS tab. Cursor-navigable table of subnets with PARENT / HEALTH / AGG columns. Local subnet renders a LOCAL: yes/— marker (the prior name-highlighting was visually noisy and dropped). Pressing Enter drills into a per-subnet focus page.
SUBNETS focus page. Per-subnet health rollup at the top; members render via the shared NODES table widget so cursor + filter behavior matches the main NODES tab. The focus title pops off the redundant id row.
GATEWAYS tab. Cursor-navigable per-channel rows with CHANNEL / VIS / REACH columns — operator sees, at a glance, which channels have which visibility and which subnets they're reaching. Forwarded / dropped counters render in the existing rollup section.
AGGREGATORS tab. Cursor + scrolling over the registry snapshot. Live read of AggregatorRegistry::snapshot (which bundles every field a renderer needs in a single lock — no fan-out reads per row).
Demo fixtures. deck::demo::fixtures ships canned SUBNETS / GATEWAYS / AGGREGATORS shapes so the demo mode renders the new tabs against believable data.
Widget refactors. A section_title helper de-duplicates panel-title boilerplate across widgets; subnets_with_members is shared between the CLI and the SUBNETS tab so the two views render the same shape.
Test hygiene
- Lib suite at 4050+ tests (was 3950+ at v0.21 release). 100+ net new tests across the fold framework (property tests for apply-then-query consistency, TTL expiry determinism, snapshot-restore round-trips, 100K applies/sec stress, concurrent apply + query), the lifecycle layer (
add_replica_grows_in_place_preserving_existing_replicas,remove_last_stops_only_the_last_replica,remove_last_refuses_to_drop_below_one, parallel-Vec invariants underspawn_with_placement), the registry service (scale_grows_existing_group_via_template,scale_shrinks_existing_group_to_target,scale_rejects_unknown_group,scale_rejects_template_mismatch,scale_to_same_count_is_noop_and_returns_current_snapshot,scale_to_zero_is_rejected), and the CLI remote-attach surface (cli/tests/aggregator_remote.rs— every verb against an in-process daemon). - Cross-language wire round-trip pinning. Every binding has a test that pins the canonical error-kind string set + the
group_seed-as-hex encoding against the locked wire table. A binding that drifts fails its compatibility test. cargo clippy --features meshos,deck,aggregator --all-features --all-targets -- -D warningsclean. The strict floor from v0.20.2 (unwrap_used,expect_used,undocumented_unsafe_blocks,multiple_unsafe_ops_per_block) stays armed; aggregator-side hits caught (manual_is_multiple_ofin theHealthMonitorbackoff retry-gate).cargo doc --features meshos,deck,aggregator --no-depsclean underRUSTDOCFLAGS="-D warnings". Doc-comment hygiene includes rustdoc intra-doc links surfaced under the new warning floor.- Codecov coverage sits at ~90% on the substrate feature set, informational on the CI status — same posture as v0.21.
- CI pipeline additions. Aggregator-daemon + registry-RPC test job; consumer-side Go wrapper exercised against the cdylib; bindings CI enables the
aggregatorfeature so the surface isn't gated out of the published bindings.
Breaking changes
CapabilityIndex removed; callers migrate to Fold<CapabilityFold>
The legacy CapabilityIndex module is deleted. Callers (MeshNode::capability_index, Scheduler::place_*, replica/fork/standby placement, the FFI surface, the Deck capability panel) all moved in the same diff. Consumers reaching into the legacy type directly need to switch to Fold<CapabilityFold> query / snapshot; the inverted-index tag/region/state lookups are part of the fold's Index and surface via CapabilityQuery.
RoutingTable removed; callers migrate to Fold<RoutingFold>
The pingwave-driven RoutingTable is deleted. Router::lookup and MeshNode::dispatch_packet consult the fold instead. Pingwave packets are repurposed as SignedAnnouncement<RouteAnnouncement> on the fold:route: channel — same wire RTT measurement, new envelope.
MeshDaemon is no longer the trait an aggregator-shaped daemon implements
MeshDaemon stays sync-only / WASM-compatible for compute daemons. Async tier services (aggregators today, market matchers / settlement bridges / reputation oracles later) implement the new LifecycleDaemon trait and are deployed via LifecycleGroup<L>. Existing MeshDaemon implementations are unaffected; this is a sibling trait, not a replacement.
net aggregator spawn --source-subnet is gone; --template is required
net aggregator spawn takes --template <NAME> (required) — the template owns the source subnet, summary visibility, fold kinds, and summary interval. --source-subnet was parse-only in v0.21 (the verb errored out before doing anything); no scripted CLI consumer broke, but the help text changed and the flag is removed.
net aggregator scale takes --template <NAME>
Scale needs the operator to re-supply the template name so the server can sanity-check against the group's stored spec (template mismatch → ScaleRejected("template mismatch") before any state change). Same shape as spawn — operators copy the spawn invocation, swap the verb, change the replica count.
RegistryGroupSummary gains source_subnet + fold_kinds
Wire shape additive — postcard appends; existing readers tolerate the additional fields. SDK consumers that rendered the old shape see the new fields populated; constructors that built the struct by hand grow two parameters.
RegistryRequest / RegistryResponse grow a Scale variant
Operators ship CLI + daemon together; no backwards-compatibility constraint. Variant added at the tail; existing match arms compile unchanged.
AggregatorDaemon::on_stop no longer drops mid-publish work
The shutdown-aware tick loop checks the shutdown flag between publishes, and the backstop deadline bumped to absorb realistic publish latencies. Behavior change: an in-flight mesh.publish().await at shutdown now completes (up to the new backstop) instead of being aborted. Consumers that relied on the abort timing (none in the substrate) need to revisit.
AggregatorGroupEntry lives behind AggregatorRegistry::snapshot
Registry inspection goes through AggregatorRegistry::snapshot(), which bundles every field a renderer needs in one lock. Direct field access on AggregatorGroupEntry is no longer the supported path.
aggregator is a default SDK feature
net-sdk ships with the aggregator surface on by default. Consumers who explicitly disabled default features and want the aggregator client get it via --features aggregator; consumers on default features see the new module unconditionally.
How to upgrade
Rust consumers — update the dependency to
0.22. Most consumers see only the additions. Direct consumers of the legacyCapabilityIndex/RoutingTableneed to switch to the fold APIs (the compiler points at every site).Daemon authors with an async daemon — implement
LifecycleDaemon, notMeshDaemon. If your daemon doestokio::intervalwork orawait-blocking publish/subscribe, it's aLifecycleDaemon. Deploy viaLifecycleGroup<L>::spawnorspawn_with_placement; auto-respawn viaregister_with_monitor. ExistingMeshDaemonimpls are unchanged.Operators running aggregators — switch to
net-aggregator-daemon. Drop the binary in/usr/local/bin(or your platform's equivalent), write a TOML config with[[template]]blocks (and optionally[[group]]blocks for boot-time instantiation), runnet-aggregator-daemon --config foo.toml. Spawn additional groups dynamically vianet aggregator spawn --template … --name … --replica-count N --node-addr … --node-pubkey … --node-id …. The bootstrap triple comes fromnet-aggregator-daemon --config foo.toml --print-bootstrap(first stdout line, JSON).CLI users — adopt
--node-addr / --node-pubkey / --node-idor a--remote <NAME>profile.net aggregator query / spawn / scale / ls --remotenow round-trips against any daemon you can reach. Set[default].psk_hex+[default].node_addr / node_pubkey / node_idin your CLI config for a one-flag--remote defaultshortcut.SDK consumers (TypeScript / Python / Go / C) —
RegistryClient+FoldQueryClientare first-class. Pull the surface from@net-mesh/sdk/net_mesh.aggregator/net_meshGo package / the C cdylib'snet_registry_client_*symbols. Same wire contract across every language — error kinds,group_seedas 64-char hex, u64 marshaling per the locked table.net aggregator spawncallers — switch to--template. The--source-subnetflag is gone. Templates live in the daemon's TOML; operators reference them by name.--name+--replica-countare unchanged.No CI config change required. The strict clippy floor stays armed; rustdoc warnings stay denied; the test-side allow-list is unchanged. CI adds an aggregator-daemon + registry-RPC test job and enables the
aggregatorfeature on bindings — repo CI picks both up automatically.Operators — bump the binary. Pre-built
net-mesh,net-deck, andnet-aggregator-daemonbinaries land in the release archive for every supported target (Linux x86_64 / aarch64, macOS x86_64 / aarch64, Windows x86_64). Drop in/usr/local/binand restart. Wire format is additive from v0.21; mixed-version fleets handshake cleanly, though the new fold envelopes andScaleRPC obviously won't reach pre-v0.22 peers.Deck users — three new tabs.
SUBNETS,GATEWAYS, andAGGREGATORSappear in the tab strip; the strip scrolls horizontally on overflow. Press Enter on a subnet row to drill into its focus page; cursor + filter on every new table.
Named after Golden Earring's 1973 cut — the one with Cesar Zuiderwijk's two-bar drum intro that every garage band on three continents has tried to copy, and George Kooymans' lyric about a driver getting a wordless lover's-distress signal at half past four in the morning and burning it down the highway to answer it. "I been driving all night, my hand's wet on the wheel" — the song's whole urgency is in the gap between the call landing and the driver arriving, and shrinking that gap to as close to zero as the road will allow. v0.19 pushed the substrate past its prior throughput ceilings; v0.20 added a signed authorization gate on top of every nRPC invoke. v0.21 turns the dial toward latency — eliminating dead time on the hot path. Per-packet RX no longer pre-zeroes a 1500-byte buffer just for the kernel to overwrite it. Manifest fetches no longer wait for the previous chunk before requesting the next one. The capability index no longer clones a HashSet to compute an intersection. The replay-window check no longer takes the same lock twice. Across ~100 fixed items the substrate gets a faster reflex arc on every hot path that runs more than once per request.
A faster reflex arc on every hot path
The v0.21 release is the result of five back-to-back performance audits across the substrate (net-perf-analysis.md), the dataforts compositional layer (net-dataforts-analysis.md), the crypto + session + reliability wire-path (net-crypto-session-reliability-analysis.md), the discovery + routing surface (net-discovery-routing-analysis.md), the compute runtime (net-compute-analysis.md), and the dormant federated-query layer (net-meshdb-analysis.md). Each audit produced a ranked-by-impact list of "this allocates per event when it doesn't need to" / "this takes a lock per call when it could take none" / "this scans linearly when an index would be O(1)" / "this re-encodes when it could measure" items. v0.21 lands roughly 100 of them — the high-impact ones on every audit, plus the cleanest of the mediums where the fix was small enough to bundle.
The pattern across all five audits is the same: the substrate had a steady-state shape that worked but paid for it in allocator pressure, lock contention, and memcpy. Pre-v0.21 a 1M-packet-per-second receive workload spent more time zero-filling a buffer before each recvmmsg slot than it spent verifying the AEAD tag on the resulting packet. A 1024-chunk manifest fetch waited for each chunk's HTTP-equivalent round-trip before asking for the next one — a 1 s wall-clock fetch where 64 ms was the actual chunk-service-time minimum. A LeastLatency endpoint selection at 100 endpoints did 100 RwLock acquires and 100 LoadMetrics deep-clones per event just to read the values used to pick one. None of it was visible at a small scale; all of it bit at the throughput ceiling the v0.19 streaming surface exposed.
The fixes are localized — no architectural rework, no protocol changes, no fold rewrites. The wire format is unchanged; the public substrate API moves on a handful of types where the shape change pays for itself many times over (Bytes vs Vec<u8> on RPC and blob bodies, Arc<Batch> instead of Batch on the bus retry path, Vec<Arc<Memory>> instead of Vec<Memory> on CortEX query returns). Everything else lands under the hood — operators get the wins by bumping the dependency.
Below: the wins, grouped by where they fire.
Transport + crypto: zero-copy RX, half the locks per packet
The per-packet receive path was the single biggest pool of waste in the substrate. v0.21 closes most of it.
Kill the recv-buffer zero-fill. PacketReceiver::recv used to call resize(MAX_PACKET_SIZE, 0) per packet — a ~1500-byte memset whose only purpose was to give the kernel a slice to overwrite milliseconds later. The fix is tokio's recv_buf_from/recv_buf/try_recv_buf_from, which write directly into BufMut spare capacity without the pre-zero. The same pattern showed up on three sibling NetSocket entry points and on Linux's BatchedTransport::recv_batch (which zero-filled all 64 batch slots, ~512 KiB of memset per batch). At 1 M pps that's around 9 GB/sec of memory bandwidth gone — entirely.
Zero-copy RX decrypt. The receive path used to call an allocating decrypt that produced a fresh Vec per packet. v0.21 adds PacketCipher::decrypt_to_bytes, which tries Bytes::try_into_mut first (the common UDP refcount-1 case decrypts in place into the inbound buffer's allocation) and falls back to allocation only when the buffer is shared. At 1 M pps with 1 KB packets this saves roughly 1 GB/sec of allocator churn.
Cached nonce template. nonce_from_counter rebuilt the 12-byte AEAD nonce from scratch (prefix memcpy + zero-init + counter write) on every encrypt and decrypt. v0.21 caches the template on the cipher; only the counter bytes get written per call.
Single-lock RX admit. The replay-window check used to take two separate parking_lot::Mutex acquisitions per inbound packet: one before decrypt (is_valid_rx_counter) and one after (update_rx_counter). v0.21 collapses them into a single try_admit_rx_counter. Replays now pay an AEAD verify before rejection (priced in — replays are rare), and the steady-state path drops 10–20 M lock ops/sec at 1 M pps.
Verify-only heartbeat path. Heartbeats used the allocating decrypt purely to drop the result (the call was decrypt(...).is_err()). v0.21 adds PacketCipher::verify, which runs the AEAD tag check via in-place decrypt over a single scratch BytesMut. No alloc per heartbeat.
Arc-shared retransmit descriptors. RetransmitDescriptor carries a Vec<Bytes> per outbound chunk-group; the reliability layer used to deep-clone it on every NACK or timeout emission. v0.21 switches the reliability-mode trait to exchange Arc<RetransmitDescriptor> — retransmits are one refcount bump regardless of inner Vec length.
Dataforts: parallel chunk I/O, Bytes through the trait, lookup-table hex
The blob fabric grew a 16-wide concurrent fetch and an end-to-end Bytes flow.
Parallel manifest fetch + store. MeshBlobAdapter::fetch(Manifest) used to walk chunks sequentially — for a 1024-chunk replicated blob at 1 ms per chunk, that was a 1-second wall-clock fetch where the actual chunk-service-time minimum was 64 ms. v0.21 wraps the chunk iteration in stream::iter(...).buffered(16) (ordered, to preserve assembly correctness). The store path gets the symmetric treatment via buffer_unordered(16) — content-addressed writes are order-independent and idempotent — with a hoisted verification prepass so the "no chunks stored on a caller-poisoned manifest" contract still holds. Roughly a 15× latency reduction on bulk manifest operations.
Bytes through the BlobAdapter trait. BlobAdapter::fetch / fetch_range / MeshBlobAdapter::fetch_chunk returned Vec<u8>, which forced a .to_vec() memcpy of the chunk's Bytes payload at every layer boundary. v0.21 switches the trait surface across every implementor (Rust, FFI, Python, Node) to Bytes. The blob-tree node cache also moves to Bytes — cache hits become Arc clones rather than Vec::clone. On bulk-fetch workloads this saves gigabytes per second of memcpy.
Lookup-table hex encoding. hex32 and chunk_channel used to call write!("{:02x}", b) 32 times per blob op. v0.21 adds a HEX_LOWER lookup table + zero-alloc hex32_into(&[u8; 32], &mut [u8; 64]) — roughly 10× faster, and zero allocation. parse_blob_heat_tag got the symmetric treatment on the decode side via a nibble lookup table.
Single-pass manifest verification. verify_manifest_chunks used to walk the chunk list twice (a sum-check pass for total-size validation, then a hash-check pass for content validation). v0.21 fuses them into a single pass with checked_add for overflow and an end > fetched.len() bounds-check before each slice.
Measure, don't re-encode. BlobRef::encoded_len used to do a full re-encode for Manifest and Tree variants — allocate a Vec, postcard-serialize, read .len(), drop. The common pairing of encoded_len + encode was paying the encode cost twice. v0.21 switches encoded_len to postcard::experimental::serialized_size, which walks the type tallying bytes without allocating.
Greedy + heat micro-wins. GreedyCacheRegistry::contains_origin was O(N), called per admission carrying a colocation hint; v0.21 adds an origin_counts: HashMap<u64, usize> reverse index for O(1) lookups. GreedyRuntime::local_caps was Mutex<Arc<CapabilitySet>> cloned per dispatch_event; v0.21 switches to ArcSwap — reads become one lock-free Acquire load. Post-fetch heat-bump used to build a Vec<[u8; 32]> of hashes per call; bump_heat now takes impl IntoIterator<Item = [u8; 32]> and streams directly. Manifest-assembly Vec is pre-allocated to total_size.min(MAX_BULK_FETCH_BYTES) (256 MiB cap protects against hostile manifests).
Discovery + routing: single-pass filters, cached resolution, smaller dedup
The capability surface and the publish path picked up a clutch of localized wins where the per-call work was steady-state O(N) on values that didn't need to be.
Cached session NodeId. dispatch_packet used to resolve session → NodeId per inbound packet, which meant two DashMap lookups and a possible O(N) peer scan when the source address had drifted. v0.21 caches the resolved id on NetSession as cached_node_id: AtomicU64 (sentinel 0 = unresolved); the fast path is one Relaxed load.
Arc-shared publish events. publish_many used to clone the events Vec<Bytes> per spawned task. For 100 subscribers × 1000 events: 100 Vec allocs + 100 K Bytes refcount bumps. v0.21 hoists into Arc<[Bytes]> — 1 Vec alloc + 1 K Bytes bumps + 100 Arc bumps.
Single-pass subscriber filter. publish used to do two sequential retain passes over the subscriber Vec (subnet visibility, then auth/token). v0.21 fuses them into one retain closure with the cheapest check first — single walk over the Vec.
Vec-based capability intersection. CapabilityIndex::build_candidate_set used to clone full HashSets to intersect them — one HashSet alloc per indexed filter clause. v0.21 switches the working set to Vec<u64>: the first match materializes one Vec, subsequent clauses use in-place retain(|n| index.contains(n)) — no new container per clause.
Threshold-based dedup HashSet. dispatch_recipients did O(N) out.contains(&picked) for dedup. v0.21 keeps the linear scan as the fast path and promotes to HashSet<u64> only once out crosses a 32-entry threshold — the common small-recipient-set case stays branch-predictor-friendly, the large case stops being O(N²).
Single-pass scoped find. find_nodes_scoped used to run the filter then re-iterate doing get(node_id) per survivor (full CapabilitySet clone per node) just to read caps.tags. v0.21 folds scope resolution into the same shard-lock guard as filter re-validation — zero CapabilitySet clones.
Compute + load balance: lock-free metrics, O(1) reverse indexes
The compute runtime gets its hot paths the same treatment as the wire path.
Lock-free LoadMetrics. EndpointState::metrics() used to do a RwLock read plus a 9-field clone per call — per endpoint per select. At 100 endpoints with LeastLatency, that was 100 RwLock acquires + 100 deep clones per event. v0.21 switches to ArcSwap<LoadMetrics> and adds a load_score() helper that reads via guard; the metrics fields stay private but the score is one indirection.
max_by, not sort. Scheduler::pick_best_candidate was doing an O(N log N) sort_by only to take the first element. v0.21 switches to O(N) max_by with inverted tie-break direction.
Reverse index in the group coordinator. origin_hash_for_entity_id was a linear scan comparing 32-byte NodeIds. For a 100-member group at 100 K ev/s that meant 10 M × 32-byte comparisons/sec. v0.21 adds origin_hash_by_entity_id: HashMap<NodeId, u64> — lookup is O(1).
Skip horizon encode on empty outputs. DaemonHost::deliver was calling horizon.encode() (walks the horizon map + xxh3 per entry) per event even when the daemon produced no outputs. v0.21 early-returns when outputs.is_empty() after observation accounting.
Streaming parent-hash. compute_parent_hash used to allocate a Vec, memcpy the 32-byte link + payload in, hash, drop. At 100 K ev/s with 1 KB payloads: 100 K allocs/sec + 100 MB/sec memcpy. v0.21 switches to streaming Xxh3::update — zero alloc.
AtomicU64 last_selected. EndpointState::last_selected was Mutex<Instant> used purely as cell storage. v0.21 switches to AtomicU64 of nanos since a OnceLock<Instant> baseline. For 100 K successful selections/sec: 100 K lock+unlock pairs gone.
O(1) member-index lookup. mark_healthy / mark_unhealthy / update_member_placement used to do a linear iter_mut().find(|m| m.index == index). v0.21 switches to members.get_mut(index as usize) with a defensive re-check — O(1) with the same correctness invariant.
Core bus + RedEX + RPC: zero-copy reads, Bytes payloads, Arc-shared batches
The substrate's per-event spine — the bus, the append-only log, the RPC payloads — gets the same treatment.
Arc-shared batch on retry. dispatch_batch used to clone the entire Batch on every retry attempt, including attempt 0. v0.21 switches Adapter::on_batch to take Arc<Batch> — retries are now a refcount bump. For a 1000-event batch with retries, this saves 1000+ Bytes refcount bumps and one Vec alloc per dispatch.
ArcSwap shard selection. Mapper::select_shard used to do two Vec allocs + a RwLock read per event in dynamic mode. v0.21 pre-computes the selection into ArcSwap<SelectionTable> and reads via guard. At 10 M ev/s this removes 20 M allocs/sec.
Amortized TLS pool reaping. ThreadLocalPool::acquire/release used to run a HashMap retain + Weak::strong_count walk on every call. v0.21 amortizes to every 4096th call via a per-thread counter. The published "thread-local 2× slower than shared" benchmark anomaly should erase.
Zero-copy HeapSegment::read. Reads used to do Bytes::copy_from_slice per call. v0.21 switches the internal buffer to Bytes; reads are refcount slices, appends use Bytes::try_into_mut. At 4 KB payloads × 100 K ev/s watcher load, this is 400 MB/sec of pure memcpy gone.
Binary search read_one / read_range. RedexFile::read_one and read_range were linear scans over a sorted index. v0.21 switches to partition_point — O(N) → O(log N).
Compiled consumer filter. Filtered poll used to re-split the path string and re-parse indices per event. v0.21 adds CompiledFilter, which runs the path-split + integer-parse once per poll.
Bytes-based RPC payloads. RpcRequestPayload / RpcRequestChunkPayload / RpcResponsePayload::body was Vec<u8>, which forced a .to_vec() memcpy per frame decode. v0.21 switches the body field to Bytes end-to-end across substrate + Node / Python / Go FFI boundaries. At 100 K RPCs/sec with 1 KB bodies: 100+ MB/sec of memcpy gone.
In-place router forward. Router::route_packet used to allocate a fresh buffer and full-copy the body just to flip an 18-byte header. v0.21 adds RoutingHeader::write_at and uses the Bytes::try_into_mut fast path for sole-owned UDP packets, with allocate-and-copy as the fallback.
Lemire shard hash. select_shard_by_hash in static mode used hash % n (~20–25 cycles per event). v0.21 switches to Lemire multiply-shift reduction (~3 cycles) — ~7× cycle reduction on a per-event hot path.
Inline hot byte codecs. RedexEntry::to_bytes / from_bytes and EventMeta::to_bytes / from_bytes weren't marked #[inline]; v0.21 marks them #[inline(always)] — the codec is small enough that inlining lets the compiler erase the wrapper-call overhead across every event-handling site.
Redis adapter micro-wins. redis::serialize_event used to_string().as_bytes() for u64/u16; v0.21 uses write! into the existing Vec — two String allocs per event gone. parse_xrange_response was setting last_seen_id (String alloc) on every iteration even though only the last mattered; v0.21 tracks last_seen_idx: Option<usize> and materializes once — 9999 wasted allocs per 10 K-entry response gone. is_transient_error used to_string().to_uppercase() per classified error; v0.21 uses zero-alloc RedisError::detail() + starts_with.
CortEX content + title search: ASCII fast path
CortEX's MemoriesQuery::matches and TasksFilterSpec::matches used to_lowercase().contains() per row — a full Unicode case-folding pass on the entire content body, then a contains walk, per search predicate per row. v0.21 adds ContentNeedle and TitleNeedle wrappers with an ASCII fast path: when the needle is pure ASCII (the overwhelming common case), the match runs as eq_ignore_ascii_case byte scan with zero allocation. Non-ASCII needles fall back to the existing Unicode path. For 100 K memories at 4 KiB each, this eliminates roughly 400 MB of allocation and 400 MB of case-folding per content search.
Arc-shared memory state. MemoriesState used to store Memory by value, which meant query/watcher returns deep-cloned every Memory the caller observed. v0.21 switches the store to Arc<Memory>; query and watcher APIs return Vec<Arc<Memory>>; writers use Arc::make_mut for copy-on-write semantics; the FFI surface uses Arc::try_unwrap to avoid double-clone on the unwrap path.
MeshDB: pre-activation hardening for the dormant federated layer
The federated-query layer is gated behind the meshdb Cargo feature and currently dormant in production. v0.21 lands the pre-activation hardening pass so the layer is ready when the feature flips on.
- Parallel hash-join sub-fetches.
tokio::try_join!around the two halves of a federated hash-join: pure 2× on every remote/remote join (50 ms RTT each: 100 ms → 50 ms wall). - DashMap caller-side inflight. Caller-side inflight map used to be
Arc<RwLock<HashMap>>; every send took a write lock and concurrent sends to distinct call_ids serialized. v0.21 switches toArc<DashMap>— sharded per call_id. - Cached
approx_bytesonCachedResult. Walked every row per LRU bookkeeping call; v0.21 caches at construction in a privateu64field — single field load. - Pre-sized
drain_rows. Switched fromVec::new()+ grow-by-doubling toVec::with_capacity(DRAIN_INITIAL_CAPACITY). - Lookup-table planner
chain_hex.format!("{:016x}", origin_hash)replaced with aHEX_NIBBLES16-shift unroll into a 16-byte stack buffer.
Architecture work: subnet spec + multifold plan land as design docs
Two architectural design documents land alongside the perf work, both targeting future releases:
SCALING_SUBNET_SPEC.md— formal specification of how the substrate carves an arbitrarily-large mesh into operator-defined subnets, the membership protocol, the cross-subnet routing primitives, and the auth model that interacts with the v0.20 capability-authSubnetIdallow-list axis. Design doc only in v0.21; the implementation tracks separately.SCALING_MULTIFOLD_PLAN.md— plan for parallelizing the substrate's fold (consume-events-into-state) layer across multiple shards per stream rather than the current single-fold-per-stream model. Same — design only in v0.21.
Operators tracking the substrate's scaling roadmap should read both; neither is wired into the runtime yet.
Test hygiene
- Lib suite at 3950+ tests (was 3850+ at v0.20.2 release). 100+ net new tests across the perf-pinning regressions — zero-copy reads pinned against accidental
Bytes::copy_from_slicereintroduction,ArcSwapselection table pinned against ABA on concurrent rebuilds, ASCII fast-path pinned to fall back correctly on non-ASCII needles, single-lock RX admit pinned against replay-after-AEAD-verify ordering, parallel manifest fetch pinned against ordering-vs-correctness, and the surface tests on the new public API (Bytes-returning blob fetches,Arc<Memory>-returning CortEX queries,Arc<Batch>-accepting bus adapters). cargo clippy --features meshos,deck --all-features --all-targets -- -D warningsclean. The strict floor from v0.20.2 (unwrap_used,expect_used,undocumented_unsafe_blocks,multiple_unsafe_ops_per_block) stays armed.cargo doc --features meshos,deck --no-depsclean underRUSTDOCFLAGS="-D warnings". Doc-comment hygiene includes a sweep of bracketed perf-doc refs that rustdoc was misinterpreting as broken intra-doc links.- Codecov coverage sits at ~90% on the substrate feature set, informational on the CI status — same posture as v0.20.2.
Breaking changes
Adapter::on_batch signature
Adapter::on_batch(&self, batch: Batch) → Adapter::on_batch(&self, batch: Arc<Batch>). Retries are now a refcount bump on the same batch. Adapters that need ownership of the batch can use Arc::try_unwrap(batch).unwrap_or_else(|b| (*b).clone()) — falls into the clone branch only when a retry is actually in flight against the same batch.
Mesh::send_to_peer / Mesh::send_routed take &Batch
Both used to take Batch by value, which forced callers to clone-or-move. v0.21 takes &Batch — callers retain ownership and can re-send without re-cloning.
Rpc{Request,Response}{,Chunk}Payload.body is bytes::Bytes
The body field on every nRPC payload moves from Vec<u8> to bytes::Bytes. Constructors: Bytes::from(vec), Bytes::from_static(b"..."), Bytes::copy_from_slice(&buf). Accessors: as_ref() for &[u8]. The Node, Python, and Go FFI bindings wrap at the boundary so binding consumers are unaffected; Rust consumers update construction sites.
BlobAdapter::fetch / fetch_range / resolve_payload return bytes::Bytes
The blob trait surface returns Bytes instead of Vec<u8> across every implementor (Rust, FFI, Python, Node). Use as_ref() for &[u8]; call to_vec() only when you genuinely need an owned Vec. Small-range reads also use Bytes::slice internally — repeated range fetches over the same blob share a backing allocation.
CortEX MemoriesState query / watch returns Vec<Arc<Memory>>
Memory queries and watcher streams used to return Vec<Memory> (deep-cloning every observation). v0.21 returns Vec<Arc<Memory>>. Treat as read-only by default; call Arc::try_unwrap (or clone the inner Memory) when you need an owned mutable copy. Writers use Arc::make_mut for copy-on-write semantics, so observed handles remain stable.
bump_heat signature
bump_heat(hashes: &[[u8; 32]]) → bump_heat<I: IntoIterator<Item = [u8; 32]>>(hashes: I). Callers that previously built a Vec to pass in can now stream directly (iter::once, chunks.iter().map(|c| c.hash)).
Removed static shard hash via %
select_shard_by_hash static mode no longer does hash % n internally — it uses Lemire multiply-shift. The function signature is unchanged; the change is observable only via cycle count.
How to upgrade
Rust consumers — update the dependency to
0.21. Most of the wins land transparently. The breaking changes above are mechanical and the compiler points at every site.Adapter implementations — switch to
Arc<Batch>. If yourAdapter::on_batchmutates the batch, replacebatch.clone()withArc::try_unwrap(batch).unwrap_or_else(|b| (*b).clone()). If youron_batchis read-only, drop the leading clone entirely.nRPC callers / handlers — construct payloads with
Bytes. Anywhere you built aRpcRequestPayloadwith aVec<u8>body, swap toBytes::from(vec)(no allocation, takes ownership) orBytes::copy_from_slice(&slice)(one allocation for the new buffer). Handlers reading the body:payload.body.as_ref()for&[u8].Blob consumers — handle
Bytes.BlobAdapter::fetchand friends now returnBytes. Most call sites change fromfetch(...).await?(returningVec<u8>) tofetch(...).await?(returningBytes); downstream&[u8]access uses.as_ref(). Sites that genuinely need an ownedVec<u8>call.to_vec(), but this is rarely the right answer —Bytesis cheaper to pass around.CortEX consumers —
Arc<Memory>handles. Query and watcher returns now carryArc<Memory>. Reads work unchanged via deref coercion (memory.title,memory.content); mutation requiresArc::try_unwrap(succeeds when the Arc is sole-owned) or an explicit clone of the innerMemory.No CI config change required. The strict clippy floor from v0.20.2 is still the floor; the test-side allow-list is unchanged.
Operators — bump the binary. Pre-built
net-meshandnet-deckbinaries land in the release archive for every supported target (Linux x86_64 / aarch64, macOS x86_64 / aarch64, Windows x86_64). Drop in/usr/local/bin(or your platform's equivalent) and restart the daemon. Wire format is unchanged from v0.20.x; mixed-version fleets handshake cleanly.Operators tracking the scaling roadmap — read the new design docs.
SCALING_SUBNET_SPEC.mdandSCALING_MULTIFOLD_PLAN.mdlive underdocs/. Neither is implemented in v0.21; both target future minor releases. Comments and pushback welcome before the implementation lands.
Correctness and hygiene patch on top of v0.20. No public API changes, no wire-format changes — drop-in for v0.20.x consumers.
What's in it
Panic-hygiene audit. Library .unwrap() and .expect() calls in production code (src/, excluding #[cfg(test)], integration tests, benches, and examples) are now zero. The audit started from a claim of ~3,090 unwraps and ~853 expects; the actual baseline was 119 and 161. Every remaining call site is either a ? propagation against a real fallible error or an expect("infallible: …") tied to a static guarantee (e.g. <[u8; N]>::try_into on a fixed-size slice).
Lock-poisoning surface removed. Sixteen production files migrated from std::sync::{Mutex, RwLock} to parking_lot::{Mutex, RwLock} — nine in net/, the rest across sdk/, deck/, and the Go/Node/Python bindings. The substrate's lock-holding paths never recovered from poison; parking_lot drops the Result and the poison concept, so the panic-hygiene pass doesn't have to choose between .unwrap() on a PoisonError and propagating an error nothing else handles. A clippy.toml disallowed-methods entry keeps the migration from regressing.
Lint floor. rustfmt.toml and clippy.toml land at net/crates/net/. [lints.clippy] on the net crate warns on unwrap_used, expect_used, undocumented_unsafe_blocks, and multiple_unsafe_ops_per_block. CI splits the clippy job: production code runs strict (--lib --bins -- -D warnings); the test surface allows the four panic-hygiene lints. New unsafe or unwrap in src/ fails CI; the same code in tests/ doesn't.
Unsafe documentation. 195 unsafe blocks across substrate + bindings. The 15 outside the FFI surface already carried per-block SAFETY: comments. The 180 in FFI bridge code share one contract per file, so the eight FFI files (ffi/mod.rs, ffi/mesh.rs, ffi/cortex.rs, ffi/blob.rs, ffi/predicate.rs, ffi/predicate_debug.rs, ffi/schema.rs, ffi/redis_dedup.rs) grow a module-level SAFETY preamble plus a file-level #![expect(undocumented_unsafe_blocks, reason = "…")]. The lint stays armed everywhere else. The audit's static mut and transmute flags didn't match anything in the tree — zero of either.
Codecov telemetry. coverage.yml runs cargo llvm-cov against the full substrate feature set on every push and uploads lcov to Codecov via codecov-action@v6. The job is informational — fail_ci_if_error: false, status checks set to informational in codecov.yml. The first run came back at ~90%; targeted tests close the gaps in transport.rs, proxy.rs, stream.rs, linux.rs, and netdb/db.rs that pinned real behavior. Pure Debug-string and Display-string pins were cut during review — see net/crates/net/docs/TEST_COVERAGE_PLAN.md for the test-worth rule (a test pins behavior a future refactor could plausibly break, not a coverage line count).
Two CI flakes fixed. publish_skips_expired_subscriber_when_sweep_is_disabled had a 1 s TTL racing the handshake on slow runners (bumped to 3 s). meshdb_subprotocol_wire had two inflight_calls() == 0 assertions firing before server-side cleanup drained (replaced with bounded polling loops).
Breaking changes
None.
How to upgrade
Bump the dependency to 0.20.2. No code change required.
Named after Deep Purple's 1972 track — the one written from a hotel window across Lake Geneva on the night the Montreux Casino burned down. December 4th, 1971: Deep Purple had booked the casino's empty gambling theater to record what would become Machine Head, but the day before the session started they sat in on a Frank Zappa & the Mothers of Invention show in the same hall. Somebody in the audience fired a flare gun into the rattan-covered ceiling, the whole building went up, and the band watched the smoke drift out over the lake while the casino — and most of the gear they'd been planning to record on — collapsed into Funky Claude's frantic evacuation. The riff every guitar shop in the world has heard ten million times is what came out of that view. v0.19 pushed the substrate past its prior throughput ceilings: bidirectional streaming on nRPC, hierarchical manifests + erasure coding + durable staging on Dataforts. v0.20 turns the lens onto what every peer is allowed to actually do with that throughput. The mesh-wide capability surface gains a signed allow-list model — every announcer decides who can invoke its capabilities, the gate fires on both sides of the call, and the operator drives it from one CLI verb. Underneath, a deep-read audit of the cryptographic token surface closes a clutch of cross-channel collision, revocation, dedup-race, canonicalization, and clock-skew hazards that would otherwise have been load-bearing for the new gate.
When the smoke clears
For three releases the mesh-wide capability surface was discovery-only. A node announce_capabilities()'d, peers indexed the announcement, find_service_nodes(...) resolved targets, and that was it. Anyone who could reach a peer over the wire could call_service it; the only "auth" was channel-auth tokens scoped to channels (pub/sub), not capabilities. Anyone who could reach an nRPC endpoint could invoke any service the endpoint published.
v0.20 closes that gap end-to-end. Every CapabilityAnnouncement is now a signed policy unit — the announcer carries three explicit allow-lists (allowed_nodes, allowed_subnets, allowed_groups) directly on the wire, and the substrate honors them at every invoke. Empty allow-lists are the permissive default (any caller admitted, byte-identical to the pre-v0.20 wire form); non-empty lists union into a "node OR subnet OR group" admit rule. Revocation is just a new announcement at a higher version — no separate verb, no separate channel, no operator-key subsystem distinct from the entity key that already signs the announcement. The model is deliberately not an ACL engine: it's the smallest correct gate around announce and execute.
Underneath the new gate, the v0.20 hardening pass closes eight long-standing hazards on the cryptographic token surface and the multi-hop capability dispatch path — items that the v0.19 channel-hash widening had narrowed but not eliminated. Token revocation, forwarded-announcement dedup races, exact-match reserved-key gating on inbound peers, channel-name canonicalization, defense-in-depth subject cross-checks, clock-skew tolerance on delegation validity, and a wildcard-slot DoS shape. None of them were exploitable end-to-end on the v0.19 stack without operator misconfiguration, but several were one refactor away from becoming load-bearing — v0.20 closes them before the new authorization gate gets stacked on top.
Capability execution authorization
The mesh now answers a question it couldn't answer before: given that B can reach A's nRPC endpoint, is B authorized to invoke A's capabilities? Pre-v0.20 the answer was always yes; v0.20 lets the answer be no, decided by A's own signed policy, enforced on both sides of the wire.
The shape
Two new identity types land under net::adapter::net::behavior::{subnet, group}:
SubnetId([u8; 16])— 16-byte opaque identifier for a topology partition. Operators pick the value (random 16 bytes, ablake2s-of-name truncated to 16, or any operator-stable convention); the substrate doesn't interpret the bytes. A peer self-declares subnet membership via asubnet:<hex32>tag on its own announcement; the capability index parses the tag at fold time and storesNodeId → SubnetIdon the peer view.GroupId([u8; 32])— 32-byte opaque identifier for an operator-defined named collection of peers. Same self-declared pattern viagroup:<hex64>tags; a peer can emit multiple group tags to claim membership in multiple groups. The wider value-as-secret space lets operators use a randomGroupIdthat's effectively unguessable, matching the substrate's existing channel-auth-token idiom.
Self-declaration is safe because the announcement is signed and TOFU-bound to the entity's ed25519 key — a peer can only claim membership for itself. Operators who want stricter membership use a random ID that's hard to guess; operators who want advisory routing use a public blake2s-of-name.
Three additive fields land on CapabilityAnnouncement:
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_nodes: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_subnets: Vec<SubnetId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_groups: Vec<GroupId>,
All three default empty + skip-when-empty, so the signed byte form of an unrestricted announcement is byte-identical to the v0.19 wire shape — pre-v0.20 peers round-trip cleanly, and a v0.19 signature verifies on a v0.20 reader. Length caps at 64 entries per axis enforced both on the announce side (the CLI rejects oversized lists at build time) and the wire side (CapabilityAnnouncement::from_bytes rejects oversized payloads at deserialize, so a malicious or buggy peer can't ship a million-entry list and force linear scans on every may_execute call).
The gate
CapabilityIndex::may_execute(target_node, capability_tag, caller_node) -> bool is the canonical entry point. Permissive by default — an announcement with all three allow-lists empty admits any caller. Once any list is non-empty, the union of all three is enforced (node OR subnet OR group); the scan short-circuits on the first match. Returns false when the target has no indexed announcement, when the target's announcement doesn't list the requested capability tag, or when the target restricts and the caller matches no axis.
Two call sites consult it:
Caller-side, inside
Mesh::call_service. The candidate set returned byfind_service_nodesis filtered throughmay_executebefore the routing policy picks a target — so the policy never selects a peer the caller can't actually call, and "no peer advertises X" stays distinguishable from "every peer that does advertise X refused me." When the filter empties the set, the call returnsRpcError::CapabilityDenied { target, capability }referencing one of the originally-advertised peers as a representative.Callee-side, inside
serve_rpc's bridge — defense in depth for the well-behaved client path. A caller that bypasses the caller-side gate (directcall()instead ofcall_service, an out-of-date local index, or a buggy client) gets rejected at the receiver. The bridge emits anRpcStatus::CapabilityDenied(0x0008) response that the caller'sMeshNode::callsurfaces as the typedRpcError::CapabilityDenied— same variant regardless of which side of the gate fired.
serve_rpc lazily emits a default-permissive self-announcement at registration time that merges every currently-registered nrpc:<service> tag, so the callee-side gate always observes a real policy from the very first inbound event — no cold-start window, no order dependency between serve_rpc and announce_capabilities. The same call also schedules a peer-side broadcast so other nodes learn about the new service without the operator having to re-announce manually.
Membership parse determinism
Subnet membership is single-valued by design. An announcement carrying multiple distinct subnet:<hex> tags is out-of-model malformed input — the v0.20 parser collapses it to None (no membership) rather than picking one tag based on HashSet<Tag> iteration order, which is unspecified and would otherwise produce hash-order-dependent gate verdicts that diverge across receivers folding the same signed bytes. Single subnet tag works as expected; duplicate tags pointing at the same SubnetId also work (the underlying set dedups them). Groups sort by byte value so the iteration sequence is stable across receivers.
The CLI
One new operator verb on the existing CLI:
net-mesh cap announce \
--tag nrpc:my-service \
--tag dataforts.blob.overflow \
--allow-node 42 \
--allow-node 0xDEADBEEF \
--allow-subnet 112233445566778899aabbccddeeff00 \
--allow-group deadbeefcafef00d... \
--key /etc/net-mesh/operator.toml \
--version 7 \
--ttl-secs 300
Builds a signed CapabilityAnnouncement with the supplied allow-lists and emits the JSON bytes to stdout (or --out <PATH>). The operator ships those bytes through any pub/sub path that calls CapabilityIndex::index on receipt. There's no separate revoke verb — revocation is a new announcement with a tighter allow-list (or [only_me] to deny everyone else), so the audit trail stays uniform. The --node-id override is supported only as an explicit confirmation that the supplied id matches the signing key's derived value; a mismatch is rejected at the CLI rather than producing bytes a receiver would refuse.
Wire status + caller-facing error
RpcStatus::CapabilityDenied = 0x0008 slots into the reserved canonical-status band; the reserved range pushes to 0x0009..=0x7FFF. RpcError::CapabilityDenied { target: u64, capability: String } is the typed caller-side variant. default_retryable(RpcError::CapabilityDenied) returns false — a deny verdict won't change on retry, so the retry budget isn't burned on a deterministic deny.
Conformance
tests/capability_auth_conformance.rs pins the six-scenario contract end-to-end against real MeshNode instances: permissive baseline admits any caller; allow-by-node admits the listed peer and denies others; allow-by-subnet admits subnet members; allow-by-group admits group claimants; revocation via a new announcement supersedes the old policy; callee-side defense in depth rejects when the caller bypasses the local gate. Plus standalone regression tests for the multi-subnet collapse, the wire-side allow-list cap, the caller-side candidate filter, and the call-path filtering when only a subset of advertising peers authorize a given caller.
Token + identity hardening
The v0.19 release widened ChannelHash from u32 to u64 (raising the targeted-collision cost from ~2^32 to ~2^64) and shipped the new 169-byte PermissionToken wire format. v0.20 closes the remaining items from the cryptographic-token surface audit — none of which were exploitable end-to-end on the v0.19 stack without operator misconfiguration, but several of which would have become load-bearing the moment the new capability-auth gate stacked on top.
Token revocation
Pre-v0.20 the substrate had no way to invalidate a token short of natural expiry. A parent that delegated a 1-year token to a child carried the child's signature past any "revoke" intent — even after rotating the parent's key, every cache holding the old EntityId continued to honour the child. v0.20 adds a per-issuer generation epoch on PermissionToken that the cache cross-checks on every check(). Bumping an issuer's generation drops every descendant in O(chain_depth) at lookup time without per-token state. The new field rides inside the signed payload so it can't be tampered post-issue. Operators rotate via EntityKeypair::bump_generation() followed by a re-issue of the still-current tokens at the new generation; the rotation step is one operator call, and the propagation is the same gossip path the existing identity broadcast already uses.
PermissionToken::delegate also now caps the child's not_after at min(parent.not_after, now + DELEGATION_MAX_TTL) — the operator-recovery window for a compromised delegate is bounded by the constant rather than the parent's full remaining lifetime.
Forwarded-announcement dedup race
The pre-v0.20 capability-announcement handler keyed the dedup table on (node_id, version) only, and the dedup insert ran BEFORE the TOFU bind that records from_node → entity_id for channel-auth lookups. A forwarded copy of a victim's signed announcement could land first, prime the dedup slot, and silently drop the victim's subsequent direct announcement on arrival — the victim's binding was never written, and any require_token channel keyed on that binding failed closed until the victim's next version increment.
v0.20 widens the dedup key to (node_id, version, hop_count == 0) so a direct announcement is never short-circuited by a prior forwarded copy. The TOFU bind runs unconditionally on every direct arrival. The forwarded-poisoning-then-direct-arrives race is now covered by a regression test alongside the existing forwarded_announcement_does_not_tofu_pin_forwarder_to_victim_entity invariant.
Reserved-key gate on inbound metadata
CapabilitySet::with_metadata enforced the prefix reserved-key list but not the exact-match reserved-key list (intent, colocate-with, priority, owner). These four keys are intentionally writable by user code on the local node, but the same field is populated by deserializing inbound peer announcements — and that path ran no gate at all. A peer could stamp intent = "high-priority-tenant-X" on its own announcement and steer the receiving node's greedy-admission to itself for tenant X's workloads.
CapabilityAnnouncement::strip_reserved_metadata is the new boundary: receivers strip the exact-match reserved keys (and any reserved-prefix matches) from inbound peer announcements before metadata is consulted by greedy admission, placement scoring, or anything else that lets a metadata value steer substrate decisions. The local node's own announcements still carry the keys — the strip runs only on the receive path. Greedy admission's chain_caps.metadata.get("intent") lookup now reads the local node's view, never an attacker-stamped peer value.
Channel-name canonicalization
ChannelName::new rejected explicit path-traversal (/./ and /../) but admitted trailing dots, repeated dots within a segment (foo..bar), and case-folded duplicates — foo.bar and FOO.BAR hashed to different ChannelHash outputs and registered as parallel namespaces. Combined with a registry miss falling into the permissive "no ACL" branch of authorize_subscribe, this opened a quiet bypass path for operators who registered one casing of a name but not the other.
v0.20 canonicalizes channel names on construction. Names lowercase to a single form before hashing; trailing dots, leading dots, and empty/dot-only segments are rejected with ChannelNameError::Malformed. Existing registered names keep working — the lowercase normalization is idempotent on already-lowercase names and the rejection rules only fire on names that previously hashed to a separate namespace from a sibling. The authorize_subscribe fall-through is also tightened: a registry miss with no matching prefix entry now returns Unauthorized rather than the prior permissive default.
Defense-in-depth subject cross-check on token cache
TokenCache::check walked the slot keyed on (subject_bytes, channel_hash) and authorized any token in the slot whose authorizes(action, channel_hash) returned true. Today the inserts always key by token.subject.as_bytes() so the invariant holds, but the predicate didn't re-confirm that the stored token's subject field matched the lookup key — a future refactor that indexed by hash-of-subject (for memory savings) or added a replace_unchecked constructor would silently authorize the wrong entity.
v0.20 adds the cross-check directly to the predicate: a stored token authorizes a lookup only if token.subject.as_bytes() == lookup_subject.as_bytes() in addition to the existing scope / channel / validity checks. Cost is one memcmp per check; benefit is a durable invariant the cache enforces on every lookup regardless of how future indexers are built.
Clock-skew tolerance on token validity
PermissionToken::is_valid did raw now < not_before / now >= not_after comparisons with no skew window. A node whose system clock drifted forward refused to delegate a freshly-issued parent token (it appeared expired locally); a node whose clock drifted backward refused not-yet-valid tokens. Worse, a node with a clock that ran 30 seconds slow accepted tokens that the rest of the mesh treated as expired — the channel-auth fast path used the same is_valid call.
v0.20 adds MeshNodeConfig::clock_skew_tolerance_secs (default 60 seconds) that applies symmetrically to both bounds: now + skew < not_before for the not-yet-valid check, now - skew >= not_after for the expired check. The constant is operator-tunable; the documentation calls out the source-of-truth assumption (the mesh trusts its own clock source within the configured window). Token issuers compensate by pulling not_after back by the skew window on mint, so a clock that ran skew-forward sees the same expiry boundary as a clock that ran on-time.
Wildcard-slot fast path
Every WILDCARD token landed in the slot (subject_bytes, 0), and the fallback path on check() walked the wildcard slot every time the exact slot missed. An attacker with a valid signing key and DELEGATE scope could mint up to MAX_TOKENS_PER_SLOT = 32 distinct WILDCARD tokens under the same subject and force every check for that subject to walk all 32 entries. Mostly a latency issue rather than a privilege gain, but a measurable CPU drag on hot lookup paths.
v0.20 caches a bool on each slot — slot.has_wildcard — that's set on insert and cleared on the last eviction. The check's fallback to the wildcard slot skips entirely when the bool is false. The exact-slot path is unaffected.
Test hygiene
- Lib suite at 3850+ tests (was 3700+ at v0.19 release). 150+ net new tests across the capability-auth allow-list wire format (signed byte-identity vs the v0.19 shape, round-trips with each axis populated, tamper detection on each new field), the gate semantics (permissive / allow-by-node / allow-by-subnet / allow-by-group / no-tag / no-announcement / multiple allow-lists overlap / revocation supersedes / cap enforcement / subnet-parse determinism), the call-path integration (caller-side candidate filter + callee-side defense in depth + auto-self-index from
serve_rpc), the six-scenario conformance file, the CLIcap announceregression suite (signed-bytes round-trip, stdout-vs-file equivalence,--node-idmismatch rejection, duplicate-tag acceptance, malformed-arg exit codes), and the token-surface hardening regressions (revocation via generation bump, forwarded-then-direct dedup race, reserved-key strip on inbound, channel-name canonicalization, subject cross-check, clock-skew bounds, wildcard fast path). cargo clippy --features meshos,deck --all-features --all-targets -- -D warningsclean across substrate + every binding crate + the deck demo + the deck TUI + the net-mesh CLI.cargo doc --features meshos,deck --no-depsclean underRUSTDOCFLAGS="-D warnings"— every public item in the v0.20 surface carries a doc comment; intra-doc links resolve through the public re-exports.- CI matrix carries the capability-auth feature gates alongside the v0.19 nrpc-streaming and dataforts-tree feature builds. Python / Node / Go / C bindings pick up the
CapabilityDeniedstatus code in their status enums and the typed error variant in their error mapping; cross-binding round-trip tests run on every CI build.
Breaking changes
CapabilityAnnouncement fields
Three new fields on CapabilityAnnouncement (allowed_nodes, allowed_subnets, allowed_groups). Wire-compatible with v0.19 when empty — the signed byte form of an unrestricted announcement is byte-identical to the v0.19 shape via #[serde(default, skip_serializing_if = "Vec::is_empty")]. Direct struct construction in v0.19 code (uncommon — most callers go through CapabilityAnnouncement::new) needs the three new fields explicitly. CapabilityAnnouncement::from_bytes rejects (returns None) on any announcement whose allow-list axis exceeds 64 entries.
RpcStatus::CapabilityDenied + RpcError::CapabilityDenied
New canonical status code 0x0008 and matching typed error variant. The reserved canonical-status range shifts to 0x0009..=0x7FFF. Application-layer status codes (0x8000..=0xFFFF) are unaffected. Callers matching on RpcError exhaustively need a new arm for CapabilityDenied { target, capability }; default_retryable returns false for the variant.
serve_rpc auto-self-indexes
MeshNode::serve_rpc now synchronously self-indexes a fresh CapabilityAnnouncement carrying every currently-registered nrpc:<service> tag before installing the dispatcher, and schedules a peer-side broadcast in the background. Callers that previously relied on serve_rpc being a no-op against the local capability index will see a self-announcement appear there immediately; callers that registered services before calling announce_capabilities no longer need to remember to re-announce.
Channel-name canonicalization
ChannelName::new lowercases names on construction and rejects trailing / leading dots, repeated dots within a segment, and empty/dot-only segments. Existing all-lowercase, dot-segmented names are unaffected. Operators with mixed-case registered names need to lowercase the registration; subscribers automatically address the lowercased form via the new normalization. authorize_subscribe registry-miss-with-no-matching-prefix now returns Unauthorized rather than the prior permissive default — channels with no ACL configured at all are deny-by-default.
PermissionToken generation epoch
PermissionToken wire size grows from 169 bytes to 173 bytes — a generation: u32 field rides inside the signed payload so the cache can drop every descendant when an issuer rotates. Pre-v0.20 tokens (169 bytes) are rejected on decode; reissue tokens at v0.20. Short-TTL tokens roll naturally; long-TTL tokens require an explicit reissue pass.
delegate not_after cap
PermissionToken::delegate caps the child's not_after at min(parent.not_after, now + DELEGATION_MAX_TTL) where DELEGATION_MAX_TTL is a substrate constant (default 30 days, operator-tunable on MeshNodeConfig::delegation_max_ttl). Existing long-lived delegations from v0.19 continue to verify until natural expiry; new delegations cap at the constant.
MeshNodeConfig::clock_skew_tolerance_secs
New field on MeshNodeConfig (default 60 seconds). Symmetric tolerance window applied to both ends of PermissionToken::is_valid. Pre-v0.20 callers using struct-literal construction of MeshNodeConfig need to add the field; MeshNodeConfig::new(addr, psk) is unaffected (uses the default).
CapabilityAnnouncement::strip_reserved_metadata
New method on CapabilityAnnouncement. The receive path (handle_capability_announcement) now calls it on every inbound peer announcement before metadata is consulted by greedy admission, placement scoring, or any other substrate decision-maker. Custom dispatchers that route around handle_capability_announcement should call ann.strip_reserved_metadata() after from_bytes and before consuming ann.capabilities.metadata.
How to upgrade
Operators issuing restrictive policies. The new
net-mesh cap announcesubcommand builds and signs aCapabilityAnnouncementcarrying allow-lists for the supplied identity. Pipe stdout into your gossip path or use--out <PATH>to write to disk. Use the same--key <PATH>your other operator subcommands already accept. Revocation is a newcap announceat a bumped--versionwith a tighter allow-list — there's no separaterevokeverb.Callers handling
RpcError. Add aRpcError::CapabilityDenied { target, capability }arm to exhaustive match expressions. The variant is non-retryable; surface it to the application layer rather than looping. Caller-sidecall_servicefilters the candidate set before target selection, so aCapabilityDeniedreturn fromcall_servicemeans every peer advertising the service refused this caller — distinct fromNoRoute, which means no peer advertises the service at all.Servers using
serve_rpc. No code change required.serve_rpcself-indexes a permissive baseline announcement immediately on registration and schedules a peer-side broadcast. Operators who previously calledannounce_capabilitiesafterserve_rpccan remove the call (it's now redundant); operators who called it beforeserve_rpcno longer need to remember the order.Servers wanting to restrict access. Publish a policy announcement via
net-mesh cap announce(offline build + ship) or by constructing aCapabilityAnnouncementwith non-empty allow-lists and folding via your existing capability-broadcast path. The version bump rule still applies — restrictive policies use aversionstrictly greater than any prior announcement from the samenode_id.Subnet and group membership. Peers self-declare via
subnet:<hex32>andgroup:<hex64>tags on their own announcement. Use the CLI's--tag subnet:<value>and--tag group:<value>to add them, orCapabilitySet::add_tag(...)programmatically. Operators picking subnet/group identifiers can use random bytes (value-as-secret) or ablake2s-of-name (advisory routing).Reissue tokens. v0.19 tokens (169 bytes) fail decode on v0.20 (which expects 173 with the generation epoch). Run your token-mint pipeline against the v0.20 SDK and propagate the new tokens to every client. Short-TTL tokens roll naturally; long-TTL tokens require an explicit reissue pass.
Rotate compromised issuers. Call
EntityKeypair::bump_generation()on the rotated issuer, then re-issue any still-current tokens against the new generation. Every cache holding a descendant of the old generation drops the descendant on the nextcheck()— no per-token revocation entry, no CRL gossip, just the generation cross-check the cache already runs.Channel-name casing. Lowercase any registered channel names that contain uppercase characters. The new canonicalization treats
foo.barandFOO.BARas the same channel rather than parallel namespaces. Subscribers callingsubscribe(name)against a previously-uppercase name continue to work — the lowercase normalization is applied symmetrically on both sides.Clock-skew tuning. The default 60-second
clock_skew_tolerance_secscovers typical NTP-synced nodes. Tighten viaMeshNodeConfig::with_clock_skew_tolerance_secs(secs)on networks with strict clock discipline; widen for satellite or air-gapped deployments where the clock source drifts more.Operator dashboards. New per-node metrics:
capability_denied_caller_sideandcapability_denied_callee_side(counts of denials by each gate),subnet_parse_collapsed_multi_tag(counts announcements with multiple subnet tags that collapsed to no membership),token_dropped_by_generation(counts tokens dropped by a generation-epoch bump). All start at zero in steady state and only fire under attack or misconfiguration; alarm on sustained non-zero.
Named after Paul Engemann's 1983 anthem from the Scarface soundtrack. v0.18 stood up the operator plane — the TUI cyberdeck, the CLI, and five-language MeshOS / Deck SDKs sitting on top of three releases of substrate. v0.19 pushes the substrate itself past its prior ceilings: nRPC grows client-streaming, server-streaming for client-streamed requests, and full duplex; Dataforts moves from "blob store that paged once" to a terabyte-scale fabric with hierarchical manifests, content-defined chunking, Reed–Solomon erasure coding, durable streaming staging, and per-stream bandwidth classes; the carry-forward bug audit and a five-pass review of the bugfixes branch close 50+ replication / migration / blob / FFI / consumer-loop hazards. And the substrate gets a new name on its packages.
Push past the limits
For three releases the nRPC layer was unary-only. A daemon could call_typed(target, channel, request) and get one response back. Anything that wanted to stream — log tail, snapshot transfer, training-batch upload, multi-shot model inference — either chunked at the application layer and paid the per-chunk round-trip cost, or pinned a request/response pair per chunk and paid the per-call session overhead. v0.19 closes that gap.
For three releases the blob layer was flat. BlobRef::Small for under-a-chunk payloads, BlobRef::Manifest for everything else, with chunk lists serialized in a single u32-bounded array. That topology runs out somewhere around 4 GiB of single-blob payload — past that, the manifest itself stops fitting in a single segment, and a manifest sitting on one node becomes a single point of failure for an arbitrarily-large blob. v0.19 lifts that ceiling too.
v0.19 lands the full nRPC streaming surface — Phase A through Phase F of NRPC_BIDI_STREAMING_PLAN.md — covering wire-format additions (REQUEST_INIT / REQUEST_CHUNK / REQUEST_END / REQUEST_CANCEL plus stream-direction window grants), the substrate RpcStreamingRequestFold server-side handler, the ClientStreamCall<Req, Resp> / DuplexCall<Req, Resp> caller-side surfaces, the SDK-layer Chunk<T> typed wrappers, and the benchmark suite that pins the streaming surface against the unary baseline the nrpc-benchmarks PR established. v0.19 lands Dataforts blob storage v0.3 — BlobRef::Tree (hierarchical manifests with internal/leaf nodes that page through arbitrarily-large blobs without pinning a single-node manifest hotspot), bounded-memory store_stream, durable streaming staging (begin_streaming_store / append_to_stream / finalize_streaming_store / resume_streaming_store so a publisher can crash mid-upload and resume from the last checkpoint without restarting), content-defined chunking (CDC with rolling-hash boundaries — content-aligned dedup across small edits), Reed–Solomon erasure coding (data + parity chunks with stripe-aware GC), per-stream bandwidth classes (Foreground / Background / Realtime), and resume metrics for operator dashboards. v0.19 closes 30+ carry-forward bugs from BUG_AUDIT_2026_05_18_CARRIED_FORWARD.md (R-20 replication peer auth, R-21 leader→replica FSM, X-1 standby epoch fencing, X-9 / X-10 / X-11 / X-18 migration hardening, D-1 / D-11 / D-14 / D-15 blob hardening, O-1 / O-4 / O-5 audit ordering, S-1 / S-2 / S-3 / S-4 subprotocol peer binding, and the long-tail of mediums + lows). And it closes the high-priority items from a five-pass code review of the bugfixes branch (CODE_REVIEW_2026_05_18_BUGFIXES_15.md — C-1 / C-2 silent-state-loss criticals; H-1 through H-8 distributed-consistency hazards on the fixes themselves).
The hardening posture is real work. Five parallel deep-read passes covered the 110 commits / ~98 code files / +7,401 / −1,382 LOC on the bugfixes-15 branch — replication, compute / migration, MeshOS + capability / auth, dataforts + adapters, and FFI / bus / shard / consumer / tests. Each pass flagged items where the fix landed cleanly (verified clean) and items where the fix was itself buggy. The Criticals and Highs land in v0.19 with regression coverage; mediums and lows track on a follow-up sweep doc.
Renamed: net → net-mesh
The substrate moves to the net-mesh family across every package registry. Old crate/package names continue to resolve at deprecated-on-publish for one minor version so existing consumers see a build warning before a hard break.
| Component | Rust crate | npm | PyPI | Binary |
|---|---|---|---|---|
| Core lib | net-mesh |
@net-mesh/core |
net-mesh |
— |
| SDK | net-mesh-sdk |
@net-mesh/sdk |
net-mesh-sdk |
— |
| CLI | net-cli |
@net-mesh/cli |
net-mesh-cli |
net-mesh |
| Deck | net-deck |
@net-mesh/deck |
net-deck |
net-deck |
The crate-id-as-discriminator name ai2070-net (and the binding equivalents @ai2070/net, ai2070-net, ai2070-net-sdk) was confusing first-time consumers ("is this a tenant-specific fork?") and conflated the org with the substrate. net-mesh is the substrate name; the org owns the registry namespace. Existing consumers: update Cargo.toml from ai2070-net = "0.18" to net-mesh = "0.19", package.json from @ai2070/net to @net-mesh/core, and requirements.txt from ai2070-net to net-mesh. The Go binding's import path stays at github.com/ai-2070/net/go for now — the migration to a net-mesh import path tracks a Go-side breaking-change window.
The CLI binary renames from net to net-mesh so it doesn't shadow /usr/bin/net on some distros. The Deck binary remains net-deck. Both binaries are now published as release artifacts — operators no longer have to cargo install from a workspace member. Pre-built net-mesh and net-deck binaries land in the release archive for Linux x86_64, Linux aarch64, macOS x86_64, macOS aarch64, and Windows x86_64. Distro packages (deb / rpm / Homebrew formula / scoop manifest) ship from CI as the release pipeline matures.
nRPC bidirectional streaming
NRPC_BIDI_STREAMING_PLAN.md ships in full — Phases A through F. The new surfaces live in the substrate (adapter::net::cortex::rpc + adapter::net::mesh_rpc) and a typed-veneer layer (net_mesh_sdk::mesh_rpc).
The wire-format additions sit cleanly alongside the existing unary REQUEST / RESPONSE frames. REQUEST_INIT opens a streaming request channel with the chunk-decoder hint; REQUEST_CHUNK carries each input frame with a per-channel monotonic seq; REQUEST_END signals "no more input — the response stream starts now"; REQUEST_CANCEL unwinds in either direction. Request-direction window grants mirror the response-direction grants v0.16 shipped — a sender that fills its window blocks until the receiver acks more credit, so a slow-consumer hot reader can't OOM a fast producer through the in-flight queue. Termination + cancel semantics carry the same trace-context + deadline plumbing the unary path already had. Existing unary call sites compile unchanged; the constants are additive.
The substrate-side fold is RpcStreamingRequestFold. Each in-flight request is keyed on its call_id and carries a per-channel mpsc::Sender<Chunk<RawBytes>>, a deadline timestamp, the trace context, and a CancellationToken. The fold dispatches REQUEST_INIT to the registered handler (which has the channel-typed input + output stream signatures), routes REQUEST_CHUNK / REQUEST_END frames into the per-call sender, and emits the handler's output frames back on the wire as RESPONSE / RESPONSE_END pairs (or RESPONSE_CHUNK / RESPONSE_END for server-streaming responses). Panic / error semantics are: handler Err → wire RESPONSE_ERROR with the structured kind; handler panic → RESPONSE_ERROR { kind: HandlerPanic } + tracing log; cancellation from either side → both directions drop and the receiver gets a typed Cancelled.
Two handler traits land:
#[async_trait]
pub trait RpcClientStreamingHandler {
type Request: DeserializeOwned;
type Response: Serialize;
async fn call(
&self,
ctx: RpcContext,
requests: impl Stream<Item = Result<Self::Request, RpcError>> + Send + Unpin,
) -> Result<Self::Response, RpcError>;
}
#[async_trait]
pub trait RpcDuplexHandler {
type Request: DeserializeOwned;
type Response: Serialize;
type OutputStream: Stream<Item = Result<Self::Response, RpcError>> + Send + Unpin;
async fn call(
&self,
ctx: RpcContext,
requests: impl Stream<Item = Result<Self::Request, RpcError>> + Send + Unpin,
) -> Result<Self::OutputStream, RpcError>;
}
The caller-side surfaces are ClientStreamCall<Req, Resp> (many input frames → one response) and DuplexCall<Req, Resp> (many in, stream out). Both expose send(req) for input frames, finish / finish_sending to close the request side, call_id for trace correlation, and a grant_request_window accessor for advanced flow control. DuplexCall::into_split returns a (DuplexSender, DuplexReceiver) pair so the input and output halves can move into separate tasks without contention on a shared handle.
The SDK typed-veneer layer carries the application-friendly shape. RequestStreamTyped<Req> wraps a chunked input stream into Stream<Item = Result<Req, RpcError>>; Chunk<T> is the SDK-internal frame type with Init / Data / End variants the codec dispatches against. The benchmark suite from the nrpc-benchmarks PR extends to cover client-streaming throughput, duplex round-trip latency, and the cancel-mid-stream timing distribution. Phase G — cross-binding parity (Python / Node / Go / C) — defers to a separate plan; the substrate surface ships first, and the bindings catch up in a follow-up release.
Dataforts blob storage v0.3
DATAFORTS_BLOB_STORAGE_PLAN_V2.md ships in full — Phases A through D. The blob fabric now scales past the single-manifest ceiling and survives publisher crashes mid-upload without restarting.
BlobRef::Tree is the new wire variant for hierarchical manifests. A small blob still ships as BlobRef::Small { hash, size }. A medium blob still ships as BlobRef::Manifest { encoding, chunks, size }. A large blob ships as BlobRef::Tree { encoding, root_hash, total_size, depth } — the root hash points at a TreeNode::Internal { children: Vec<Hash> } whose children are further internal nodes until the depth hits zero, where leaves carry TreeNode::Leaf { chunks: Vec<ChunkRef> }. The fan-out is configurable per policy; the default (8K chunks per leaf, 4K children per internal) places the cross-over at ~32 GiB before depth-2 fires. Tree manifests page through arbitrarily-large blobs without pinning a single-node hotspot — every internal node has the same replication policy applied as the root, so the fabric self-balances against access pattern.
Bounded-memory store_stream lands as the streaming-publish entry point. A publisher with a Stream<Item = Bytes> no longer materializes the full blob in memory before computing the manifest; store_stream consumes the input incrementally, chunks via the configured strategy, hashes each chunk on the fly, accumulates leaves until the leaf-fanout cap, and emits internal nodes lazily as leaves complete. Memory bound is O(leaf_fanout × chunk_size + depth × internal_fanout × hash_size) — operator-tunable, predictable, and independent of total blob size.
Durable streaming staging is the most operator-visible piece. A long upload that crashes mid-stream used to restart from byte 0; v0.19 lets it resume. The four-step API:
let upload = adapter.begin_streaming_store(config).await?; // returns StagingHandle
adapter.append_to_stream(&upload, chunk_bytes).await?; // repeatable
adapter.append_to_stream(&upload, chunk_bytes).await?; // ...
let blob_ref = adapter.finalize_streaming_store(upload).await?; // commits
// OR
adapter.abort_streaming_store(upload).await?; // explicit cancel
// OR — after crash + restart:
let upload = adapter.resume_streaming_store(staging_id).await?;
A StagingCheckpoint { seq, chunking, encoding, completed_leaves, completed_internals, last_chunk_byte_offset, last_checkpoint_unix_ms } persists every N bytes (default 64 MiB) under the staging directory; resume_streaming_store rolls forward to the last checkpoint, replays any uncheckpointed chunks from the publisher's resumed input stream, and continues. Aborted staging directories GC after the configured grace period (default 24 hours).
Streaming + range fetch over BlobRef::Tree ships as fetch_range(blob_ref, range, output_stream). A consumer reading bytes 12_345..67_890 of a 50 GiB tree blob does not fetch the entire tree — the range descends the tree only as deep as needed, fetches the leaf nodes whose chunk ranges intersect, and streams those chunks through to the output. The range path also gets the 32-bit usize::MAX guard from the carry-forward audit (D-2) so a malicious or buggy publisher can't crash a 32-bit consumer with a range that overflows.
Streaming verification runs the hash chain in lock-step with the fetch — each leaf's chunks: Vec<ChunkRef> is verified against the leaf's parent hash before the chunks are surfaced; each internal node's children: Vec<Hash> is verified against the parent's hash before recursing. A tampered hash anywhere in the tree halts the fetch with a typed BlobError::ChainMismatch { at_depth, parent_hash, child_hash }.
Content-defined chunking (CDC) is the new default chunking strategy. Fixed-size chunking still ships as ChunkingStrategy::Fixed { size } for callers who need deterministic chunk boundaries (replication slabs, structured-document stores). ChunkingStrategy::Cdc { avg, min, max } uses a rolling-hash boundary detector — content-aligned chunks dedup across small edits to large blobs (insert a byte at offset 1 GiB in a 10 GiB blob → only the chunks straddling the insertion point change, not every chunk after). Default::default() returns Cdc { avg: 1MiB, min: 256KiB, max: 4MiB }; operators tune via DataGravityPolicy::chunking_strategy.
Reed–Solomon erasure coding lands behind the ChunkRole::{Data, Parity { stripe_index }} tag. A ChunkRef { hash, size, role } now carries its role in the stripe. Tree builders that opt into RS coding (MeshBlobAdapter::store_stream_with_rs(k, n) for n total chunks from k data) emit n-k parity chunks per stripe; the fetch path tolerates up to n-k chunks missing per stripe before erroring. GC + RS interaction invariants are pinned: parity chunks are GC-protected by the same refcount-on-manifest model as data chunks (the manifest references all n chunks; deleting only data without deleting parity leaves an unrecoverable stripe and is now a typed error rather than silent corruption).
Per-stream bandwidth classes carry through the resume path. BandwidthClass::Foreground is the operator-interactive default — full configured bandwidth, prioritized over background traffic. BandwidthClass::Background for cold-tier replication and scheduled backups — throttled when foreground traffic competes. BandwidthClass::Realtime for migration / live replay — bypasses backpressure entirely with a configured emergency reserve. Resume metrics (current_bandwidth_class, bytes_in_class, paused_for_higher_class_ms) surface through the operator dashboard the v0.18 Deck TUI already exposes.
Migration path / wire compat: existing v0.18 BlobRef::Small and BlobRef::Manifest payloads decode unchanged. New BlobRef::Tree payloads are rejected by pre-v0.19 consumers with the existing typed-decode error. Operators publishing tree blobs to a mixed-version fleet should gate the new variant behind a capability tag (dataforts.blob_tree_v3) until the fleet rolls.
Carry-forward bug audit
BUG_AUDIT_2026_05_18_CARRIED_FORWARD.md documents the carry-forward audit's five passes (plus a sixth-pass subprotocol sweep and a seventh-pass follow-up). v0.19 closes the Criticals + Highs + a majority of the Mediums. Some highlights of what landed:
Replication peer authentication. Pre-fix any mesh peer could ship
SyncResponse/Heartbeatagainst a channel they had no role in; the runtime trusted the wire-suppliedfrom_nodefor both delivery andbelieved_leadertracking. v0.19 binds replica delivery to areplica_setregistered at channel-open time and gatesbelieved_leaderupdates against thefrom_nodematching a recorded leader claim. A spoofed heartbeat from outside the replica set is now rejected at the dispatch arm.Permanent dual-leader resolution. The replication FSM had no
Leader → Replicatransition — once a node elected itself leader for a channel, it stayed leader until process exit, even after observing a higher-tail-seq leader heartbeat from the rejoining partition. v0.19 adds the transition: a Leader observing another Leader heartbeat with strictly-higher tail (or equal tail + lower node_id as tiebreak) flips to Replica and adopts the other side as itsbelieved_leader. The dual-leader convergence test pins the rule.Migration dispatch peer binding. Pre-fix the migration subprotocol arms (
SnapshotReady,CleanupComplete,ActivateTarget,MigrationFailed) accepted state-mutating wire input from any session peer; a forgedActivateTargetfrom a non-orchestrator could force cutover while the source still believed it owned the daemon — divergent chain heads. v0.19 binds each arm to a recorded principal:SnapshotReadychecks againstsource_node,CleanupCompleteagainstsource_node,ActivateTargetagainstorchestrator_node,MigrationFailedagainst the union of recorded participants. The orchestrator-on-third-party-node topology gets its long-promised wire-shippedtarget_headinReplayCompleteso the orchestrator no longer falls back to a syntheticparent_hash: 0that no downstream verifier could reconcile.Migration phase-guard hardening.
MigrationTargetHandler::replay_eventsno longer rewinds Cutover → Replay (which had been enabling double-delivery of post-cutover events).MigrationSourceHandler::cleanupgains a phase guard so a pre-cutover replayedCleanupCompleteno longer destroys a live daemon.MigrationTargetHandler::pending_eventsis bounded (64 MiB / 1M events per origin) so a wire-driven OOM is no longer reachable. Source-sidebuffered_eventsgains a matching cap; the cap admission bound moves to an O(1) running byte counter on a follow-up sweep.StandbyGroup epoch fencing. The local epoch scaffolding lands (
term: u64field bumped on promote / try_recover); cross-node wire enforcement defers to a follow-up wire-protocol bundle. Partial-sync replay filter (X-19) lands alongside.Subprotocol from_node binding. The sixth-pass sweep caught three correlation bugs in the rendezvous and membership subprotocols.
RendezvousMsg::PunchIntroduceandPunchAckpreviously correlated on payload-only fields (intro.peer/ack.from_peer) — any session peer could cancel a victim's introduce waiter or hijack an ack.MembershipMsg::Ackcorrelated on a sequential-counter nonce — predictable nonces let any session peer spoof Subscribe / Unsubscribe responses. v0.19 binds each correlation arm to the wire-authenticatedfrom_node(read from the inbound session, not the payload) and switches membership nonces togetrandom-sourced u64s. nRPC response delivery gets the same binding — a sequentialcall_idbecomes agetrandomu64 and the reply-channel ACL gates response delivery to the originating peer.Blob hardening. Sweep
D-1(the GCsweep_gcTOCTOU where a concurrentincrwas silently dropped) closes via aremove_ifguarded byshould_sweep.D-11adds manifest chunk-size validation + defensiveget(..)so an untrusted publisher can't slice-panic the consumer with arbitrary per-chunk sizes.D-14branchesresolve_payloadonis_chunked()so the top-level verify skip for manifests no longer unconditionally fails.D-15makes GCdelete_chunkactually unlink the persistent segment file.D-18fixes a publisher-crafted UTF-8-boundary panic in the FS adapter's URI sanitizer.Audit-chain durability ordering.
O-4(chain_append_failurescounter + chain record appended BEFORE dispatch — pre-fix the chain record appended AFTER dispatch and a chain-appender failure left an audit gap on a real event).O-5(record_admin_auditchain append before ring push, ring/chain divergence regression test).Substrate-wide ChannelHash widening.
ChannelHashwidens from u32 to u64. The targeted-collision cost rises from ~2^32 (feasible offline) to ~2^64. The wireNetHeader::channel_hashstays u16 (per the existing u64-canonical / u16-wire / u32-future precedent — fast-path filter hint, may bucket-collide at scale, ACL/storage/config decisions key on the canonical hash via registry disambiguation). ThePermissionTokenwire form grows to 169 bytes (issuer + subject + scope + 64-bit channel hash + issuer generation + not_before + not_after + delegation depth + nonce + ed25519 signature); the FFInet_channel_hashwidens to*mut u64; every binding (Python / Node / Go / C) consumes the JSONchannel_hashas int64 / BigInt / uint64 /uint64_trespectively.
The full list lives in the audit doc; the carry-forward sweep covers replication availability (R-25 / R-28 / R-40 priority lane + catchup backoff + NACK retention), replication coordinator state decoupling (R-31 / R-32), replication lows (R-29 / R-30 / R-33 / R-34 / R-35 / R-36 / R-37 / R-38 / R-39), SDK correctness (O-1 UUID epoch + O-2 plumb this_node), MeshOS observability (O-3 / O-7 / O-8), cluster backpressure (O-21 / O-25), maintenance state (O-22 / O-23 / O-24), group lifecycle (X-4 / X-5), compute mediums (X-13 unhealthy-slot recovery + X-16 / X-17 / X-21 / X-22), MeshDB drain (MD-1 / MD-2), blob hardening lows (D-6..D-10 / D-12 / D-13 / D-16), and heat-emission ordering (D-17).
Code review — the fixes are themselves clean
The carry-forward audit closed the original bugs. CODE_REVIEW_2026_05_18_BUGFIXES_15.md reviews the fixes themselves — five parallel deep-read passes covering replication, compute / migration, MeshOS + capability / auth, dataforts + adapters, and FFI / bus / shard / consumer / tests. 110 commits, ~98 code files, +7,401 / −1,382 LOC. Where the fix landed cleanly, no entry; where the fix was itself buggy or left a new hazard open, the review flags the item.
v0.19 closes the Criticals and Highs from that review:
C-1 (
MigrationSourceHandler::cleanupunregisters daemon on lookup miss). Pre-fix the new Cutover phase guard ran insideif let Some(entry) = ..., but themigrations.getmiss fell through todaemon_registry.unregister(daemon_origin)unconditionally — a spurious or replayedCleanupCompletefor an origin we never migrated tore down a live local daemon. Fix: moveunregisterinside theSomebranch; the miss path is now a no-op with atracing::debuglog.C-2 (
StandbyGroup::try_recover_innerclobbers the active). Pre-fix the unhealthy filter did not excludeself.active_index; if the active was briefly marked unhealthy (transient node heartbeat staleness), recovery constructed a freshDaemonHost::newwith empty state andregistry.replaced the live active — silently dropping all committed state and the post-sync buffer. Fix: route active-side unhealthiness throughpromote, not slot re-placement; the filter excludes the active by construction. ForkGroup / ReplicaGroup were unaffected (no "active" concept) and stay unchanged.H-1 (replication dual-leader sticky-tiebreak inconsistency). The runtime convergence tiebreaks on
(higher tail_seq, lower node_id); the heartbeat-recording layer tiebroke onlower node_idonly and was sticky. A real leader L1 (high tail, high id) could stay Leader while also recording L2 (low tail, low id) asbelieved_leader. v0.19 unifies on the runtime convergence rule across both layers and pins the regression test.H-2 (
MigrationTargetHandler::activateflipsCutoverbeforedrain_pending). Pre-fix a mid-drainErrleft phase already atCutover;replay_eventsno-oped,buffer_eventrejected, and the undelivered tail was reinserted intopending_eventswith no future call able to drain it. v0.19 flipsCutoveronly on successful drain; activate's retry path drains on next call without the early-return.H-3 (StandbyGroup
try_recover_innerdoes not bumpterm). The X-1 fencing gap thatForkGroupandReplicaGroupalready guarded against was open on StandbyGroup. v0.19 bumpstermintry_recoverto matchpromote/promote_with_placement.H-4 (
PollMerger::polldiscardsset_checkedbool). Both Step-1 (adapter next_id) and Step-2 (last-event override) writes routed throughset_checkedbut ignored theboolreturn. On a format-mismatch refusal, fetched events were still returned inall_events, but the cursor was not advanced — the caller next-polled with the same cursor, got identical events, and entered an infinite duplicate-delivery loop. v0.19 drops the offending shard's events on refusal and marks the shard infailed_shards.H-5 (
SnapshotReadyTOFU into orchestrator binding). For adaemon_originwith no prior record,restore_on_targetran andtarget_handler.orchestrator_nodewas recorded asfrom_node— any session peer that beat the legitimate orchestrator with a forgedSnapshotReadybecame the bound orchestrator and could driveActivateTarget/MigrationFailedpast the new peer-auth gates. v0.19 closes the TOFU window withDaemonFactoryRegistry::bind_expected_orchestrator— operators who know the orchestrator out-of-band can pre-bind it at factory-install time; when bound, a mismatching sender is rejected at the dispatch arm beforerestore_on_targetrecords anything.H-6 (D-1 sweep can orphan on-disk chunks). The fix dropped the refcount entry before
close_and_unlink_file; on a close failure the refcount was gone and no future GC sweep could find the orphan. v0.19 reverses the order — close first, thenrefcount.removeonly on success — and adds a disk-inventory orphan-sweep follow-up as a tracked enhancement.H-7 (empty-response backoff misfires on stale
leader_tail). The backoff recorded "empty" whenevernew_tail == pre_apply_tail && leader_tail > new_tail, butleader_tailwas the cached value from the last received heartbeat. v0.19 keys backoff on the response'sleader_first_retained_seq/ leader-tip hint, only counting empties when the request explicitly asked above tail.H-8 (
record_tail_seqfromon_tickadvertises pre-quorum tail). The leader was bumpingtail_providerthe moment a local write landed (pre-quorum), and advertising that via capability tags + the dual-leader tiebreak rule biased future elections toward the partition with un-replicated writes. v0.19 advertises the quorum-confirmed tail (last_quorum_tail) instead, and the leader's pre-quorum tail surfaces only through the per-replicapending_applymetric.
The Mediums and Lows from the review track on a follow-up cleanup sweep (BUG_AUDIT_2026_05_25_CARRYFWD.md); none of them open immediate-blast-radius hazards but several are observable in production over time (M-3 password leak on unencoded @, M-4 duplicate emissions on concurrent tick, M-7 sentinel loopback fallback, M-9 budget-refund-on-Ok, M-10 / M-11 token leak on role flip).
Test hygiene
- Lib suite at 3700+ tests (was 3115+ at v0.18 release). 500+ net new tests across the nRPC streaming server fold + client streaming + duplex surfaces, the Dataforts tree manifest + staging + CDC + RS coding paths, the carry-forward audit regression coverage (peer-auth gates, phase guards, audit ordering, channel-hash widening), and the review-driven regressions for C-1 / C-2 / H-1 through H-8.
cargo clippy --features meshos,deck --all-features --all-targets -- -D warningsclean across substrate + every binding crate + the deck demo + the deck TUI + the net-mesh CLI.cargo doc --features meshos,deck --no-depsclean underRUSTDOCFLAGS="-D warnings"— every public item in the v0.19 surface carries a doc comment; intra-doc links resolve through the public re-exports.- CI matrix expanded. The Rust step now builds with the
nrpc-streaminganddataforts-treefeatures in addition to the default set so the new surfaces compile on every PR. Python / Node / Go / C bindings pick up theChannelHashu32 → u64 widening regression suite — every binding'schannel_hashround-trip + token-parse path runs on every CI build. - Code-review regression suite.
tests/review_2026_05_18_*.rscovers each Critical and High from the bugfixes-15 review with a regression that would have failed pre-fix and passes post-fix. The naming convention pins the review-pass provenance so future regressions trip an obvious tag.
Breaking changes
Crate / package renames
ai2070-net → net-mesh. @ai2070/net → @net-mesh/core. ai2070-net-sdk (PyPI) → net-mesh-sdk. Old names continue to publish at deprecated-on-resolve for one minor version; consumers see a build warning before a hard break in v0.20. Update your Cargo.toml / package.json / requirements.txt accordingly.
CLI binary rename
net → net-mesh. Operator scripts referencing /usr/local/bin/net should update to net-mesh. Distro packages and tab-completion shims pick up the new name automatically.
PermissionToken wire format
Token wire size grows from 161 bytes to 169 bytes. The added bytes are the issuer-generation u32 (already in the signed payload after the v0.17 revocation-registry change) and the channel-hash widening (u32 → u64 — was 4 bytes, now 8). Pre-v0.19 tokens are rejected on decode; reissue tokens to clients. The signed-payload field shifts mean old signatures don't verify against the new layout — there is no in-place upgrade.
MigrationMessage::ReplayComplete wire format
ReplayComplete now carries a target_head: CausalLink (32 bytes) so a third-party orchestrator (a node that is neither source nor target) can stamp a verifiable continuity-proof anchor without consulting its local daemon registry. Pre-v0.19 ReplayComplete payloads (40 bytes) are rejected on decode; v0.19 payloads are 72 bytes. The new field is mandatory; the target node fetches it from the freshly-replayed daemon's head_link() before sending.
BlobRef::Tree wire variant
New variant on BlobRef. Pre-v0.19 decoders reject the variant with a typed error. Operators publishing tree blobs to a mixed-version fleet should gate the variant behind a capability tag (dataforts.blob_tree_v3) until the fleet rolls.
nRPC dispatch constants
New wire-level constants for the streaming surface: REQUEST_INIT = 0x10, REQUEST_CHUNK = 0x11, REQUEST_END = 0x12, REQUEST_CANCEL = 0x13, plus REQUEST-direction WINDOW_GRANT mirror. Pre-v0.19 dispatchers reject the constants with a typed "unknown dispatch" error. Existing unary callers are unaffected.
MigrationOrchestrator::on_replay_complete signature
The orchestrator's on_replay_complete(daemon_origin, replayed_seq) becomes on_replay_complete(daemon_origin, replayed_seq, target_head: CausalLink). The new parameter is the wire-shipped target head; the function is pure (no implicit local-registry dependency). Callers using the dispatcher path (MigrationSubprotocolHandler) pick up the new arg automatically; direct orchestrator callers (tests, integration harnesses) update by passing the daemon's head_link or a synthetic test link.
How to upgrade
Rename your dependencies.
Cargo.toml:ai2070-net = "0.18"→net-mesh = "0.19".package.json:@ai2070/net→@net-mesh/core.requirements.txt:ai2070-net→net-mesh. The old names continue to resolve in v0.19 with a deprecation warning; in v0.20 they hard-break.Reissue tokens. v0.18 tokens (161 bytes) fail decode on v0.19 (which expects 169). Run your token-mint pipeline against the v0.19 SDK and propagate the new tokens to every client. Short-TTL tokens roll naturally; long-TTL tokens require an explicit reissue pass.
Operators — install the binary. v0.19 ships pre-built
net-meshandnet-deckbinaries for every supported target. Download from the release archive (Linux x86_64 / aarch64, macOS x86_64 / aarch64, Windows x86_64), drop in/usr/local/bin(or your platform's equivalent), and runnet-mesh --help. The Cargo install path (cargo install --path cli) still works from a workspace checkout. Generate an operator identity withnet-mesh identity generate <name>and install the public key into the cluster's operator registry as before.nRPC client-streaming callers.
let mut call = mesh_rpc.call_client_streaming::<Req, Resp>(target, channel).await?;returns aClientStreamCall<Req, Resp>. Callcall.send(req).await?per input frame,call.finish().await?to close the request side. The single response comes back fromfinish. Pass a tracing context via the existingRpcContext::with_tracebuilder if needed.nRPC duplex callers.
let call = mesh_rpc.call_duplex::<Req, Resp>(target, channel).await?;returns aDuplexCall<Req, Resp>implementingStream<Item = Result<Resp, RpcError>>. Callcall.send(req).await?for input,call.finish_sending().await?to close the request side, poll the stream side via.next().awaitfor responses.call.into_split()returns a(DuplexSender, DuplexReceiver)pair for tasks that need the halves separately.Streaming blob publishers. Long uploads benefit from the new staging API.
let upload = adapter.begin_streaming_store(config).await?;→ loopadapter.append_to_stream(&upload, chunk).await?;→let blob_ref = adapter.finalize_streaming_store(upload).await?;. On crash,let upload = adapter.resume_streaming_store(staging_id).await?;rolls forward to the last checkpoint. TheStagingHandle::staging_id()accessor returns a stable id you can persist alongside your upload-tracking record.Tree blob consumers. No code change —
MeshBlobAdapter::fetch_range(blob_ref, range, output)handlesSmall/Manifest/Treetransparently. Mixed-version fleets where some nodes are still v0.18 should gateTreepublishes behind adataforts.blob_tree_v3capability tag; v0.18 consumers will reject the variant.Reed–Solomon erasure coding. Opt in via
MeshBlobAdapter::store_stream_with_rs(input, k_data_chunks_per_stripe, n_total_chunks_per_stripe, config). The fetch path tolerates up ton-kchunks missing per stripe before erroring. GC observes the role-aware refcount model — parity chunks are GC-protected by the same refcount-on-manifest as data chunks.Bandwidth classes. Tag a stream at open:
BlobStoreConfig::default().with_bandwidth_class(BandwidthClass::Background)for cold-tier replication;Realtimefor migration / live replay. The default isForeground(operator-interactive). Operator dashboards surfacecurrent_bandwidth_class/bytes_in_class/paused_for_higher_class_msper stream.Migration orchestrator on a third node. No code change for SDK consumers — the wire format change ships under the hood. Direct
MigrationOrchestrator::on_replay_completecallers in test harnesses update their call sites to pass a thirdtarget_head: CausalLinkargument; use the daemon'shead_link()if registered locally, orCausalLink::genesis(origin, 0)for snapshot-only test cases.Replication FSM Leader → Replica. No call-site change — the runtime handles the transition internally when it observes a higher-tail-seq leader heartbeat from a recovering partition. Operators monitoring
believed_leader_changessee the additional flip count under partition-heal scenarios.Subprotocol peer binding. No code change for SDK consumers — the peer-auth gates ship under the hood. Operators monitoring the
subprotocol_peer_auth_rejectionscounter see legitimate-zero in steady state and non-zero only under attack or misconfiguration; alarm on sustained non-zero.
Named after Guns N' Roses's 1987 Appetite for Destruction opener. v0.15 stood up the Dataforts data plane. v0.16 stacked the MeshDB query plane on top. v0.17 stacked the MeshOS behavior plane on both. v0.18 is the operator plane — the TUI cyberdeck, the command-line surface, and the daemon-author / operator SDKs across five languages — that turns the substrate the prior releases built into something a human can see, command, and break-glass.
The operator plane
For three releases the substrate has been growing in capabilities that nothing outside the cluster could observe. v0.15 made replicas place themselves and blobs move under operator-defined policies; v0.16 made every chain federally queryable; v0.17 turned every node into a single reconciling event loop with admission control and an admin-event ledger. By the end of v0.17 the cluster was a living distributed operating system — and the only way to interact with it was to write Rust against the substrate crate. v0.18 closes that gap.
Three surfaces land in this release. Deck is the operator TUI — a real-time terminal cyberdeck rendering everything MeshOS, MeshDB, RedEX, and Dataforts are doing, with signed admin actions, ICE break-glass overrides, and a blast-radius preview that simulates every dangerous action before it commits. Net CLI is the command-line operator surface — the same admin verbs Deck exposes (drain / cordon / maintenance / drop-replicas / restart-all / invalidate-placement / clear-avoid-list), the same ICE break-glass actions, the same audit-chain reads, the same NetDB local-store + MeshDB query surfaces, every command JSON-output-able for scripting, every operation gated by the same operator identity. MeshOS SDK and Deck SDK ship daemon-author and operator-tooling surfaces in Rust (canonical), Python (pyo3), TypeScript (napi-rs), Go (cgo), and C (raw FFI) — five-language parity behind one common substrate-side wire contract.
There is no separate observability service to provision. There is no admin RPC to harden. The TUI, the CLI, and every binding compose against the same MeshOsRuntime + DeckClient + admin-chain primitives the substrate already ships. Operators see the cluster move, and they can move with it, in whatever language they already write.
v0.18 lands the full Deck TUI (cluster topology map, replica + placement inspector, daemon supervision panel, maintenance node control, behavior timeline, admin surface with signed ops, MeshDB console, log matrix, operator identity + audit trail, node inventory, ICE break-glass with blast-radius preview — see DECK_FEATURES.md for the operator-facing tour), the Net CLI Phase 1 + 2 + 3 (read-only inspection, mesh + nRPC client + capability surface, admin verbs + ICE preview + audit + identity store + NetDB + MeshDB query — see NET_CLI_PLAN.md), the MeshOS daemon-author SDK in all five languages (Rust canonical, Python pyo3 wrapper with Protocol class + async control-event iterator, TypeScript napi-rs with full TSFN-bridged daemon trait + AsyncIterable control events, Go cgo with MeshOsDaemon interface + cgo trampolines for snapshot/restore/onControl/health/saturation, C raw FFI with vtable + last-error pair — see MESHOS_SDK_PLAN.md), and the Deck operator SDK in all five languages covering DeckClient lifecycle, all 9 AdminCommands verbs, ICE break-glass simulate → commit typestate, snapshot + status-summary + log + failure + audit streams (see DECK_SDK_PLAN.md).
Every binding ships behind one common wire contract. The Python wheel, the npm package, the Go binding's bindings/go/net tree, and the C libnet_deck / libnet_meshos cdylibs all serialize the same MeshOsSnapshot + ChainCommit + StatusSummary shapes the substrate emits, with one operator-id + signature envelope across every admin commit. A Python operator's blast-radius dry-run produces the same affected_nodes / affected_replicas / affected_daemons count a Rust operator would see; a TypeScript admin verb commits the same ActionChainRecord a CLI invocation would. Cross-language SDK consumers see one cluster.
The hardening posture from the Black Diamond → Rebel Yell → Eye of the Tiger → Atomic Playboys line continues. Five coordinated code-review passes landed before the v0.18 branch cut.
The Rust crate now ships the same default feature stack as the Python wheel and the Node npm package (net, nat-traversal, cortex, meshdb, meshos, dataforts) — cargo add ai2070-net previously gave you nothing; v0.18 gives Rust consumers the full operator stack out of the box. The redis external-service dep stays opt-in. The Python and Node defaults are unchanged.
No new dependencies. No protocol changes. The crate version moves from 0.17.x to 0.18.0.
Deck
The operator cyberdeck. Lives in the workspace member deck/ (binary-only — [[bin]] with no [lib]).
deck/src/
├── app.rs — main event loop + tab routing
├── tabs/
│ ├── net_map.rs — cluster topology map (RTT edges, avoid-list, maintenance flags)
│ ├── replicas.rs — replica + placement inspector
│ ├── daemons.rs — daemon supervision panel
│ ├── daemon_page.rs — per-daemon log tail + control surface
│ ├── nodes.rs — node inventory + saturation trends
│ ├── node_page.rs — per-node deep dive
│ ├── behavior.rs — MeshOsSnapshot timeline
│ ├── blobs.rs — blob explorer (replica locations, heat, ancestry)
│ ├── dataforts.rs — local + remote adapter telemetry
│ ├── admin.rs — signed admin surface
│ ├── ice.rs — break-glass overrides with blast-radius
│ ├── meshdb.rs — MeshDB query console
│ ├── logs.rs — RED/HEAT/INFO log matrix
│ ├── audit.rs — operator audit trail
│ ├── failures.rs — recent-failure ring
│ └── groups.rs — ReplicaGroup / ForkGroup / StandbyGroup roster
├── widgets/
│ ├── confirm.rs — blast-radius confirm modal
│ ├── footer.rs — toast + status footer
│ └── cursor.rs — cursor + `/` filter primitives
├── streams.rs — RedEX + Deck subscription routing
├── bookmarks.rs — persistent cluster bookmarks
├── lineage.rs — chain ancestry walks
└── demo/ — 9-node spawn harness for local development
The TUI composes against DeckClient (the operator SDK that ships in the same release — see below). Every tab is a ratatui widget that reads from a MeshOsSnapshotReader + a set of stream readers, renders at 60 fps when there's activity, idles at 1 Hz otherwise. The cursor + / filter primitives are tab-uniform; g / G jump to top / bottom on every cursor tab; Enter opens a detail page; ? shows context-aware help. A blast-radius confirm modal pre-flights every admin commit — the modal prints "This action affects N nodes, M replicas, K daemons. Type YES to confirm" and refuses to dispatch if the operator types anything else. Warnings beyond the modal's 3-row cap surface as … +N more (see AUDIT).
The ICE break-glass surface is the cyberpunk SRE panel — seven force-level operators (force-drain, force-evict-replica, force-restart-daemon, force-cutover, kill-migration, flush-avoid-lists, freeze-cluster / thaw-cluster) each gated through a simulate() → commit(signatures) typestate. The simulation runs the same reconcile arms the production loop runs and surfaces the projected blast radius (which replicas move, which daemons restart, which nodes become hot, expected drain delay, placement stability impact). The commit threshold defaults to 1-of-1 in development; production deployments raise it to 2-of-N via DeckClientConfig::ice_signature_threshold and the M-of-N gate enforces operator-id deduplication before counting signatures.
Behavior visibility folds back through the MeshDB query plane the prior releases shipped. The behavior timeline tab reads MeshOsSnapshot from a MeshQuery::Latest against the per-node snapshot chain — no Deck-specific RPC, no separate observability stack. The MeshDB console tab is a fully-interactive query editor: write a QueryBuilder chain in the operator's preferred shape, hit Enter, watch the federated executor route to a node holding the relevant fold, scroll the streaming result rows.
The render pipeline carries the polish details a TUI needs to feel alive: net_map's unreachable peers render as the hollow diamond the legend advertises (not a red filled diamond — the second-pass review caught this); tabs::logs reuses an ascii_icontains helper instead of lowercasing the haystack per record per render (the previous form reintroduced a per-frame String alloc); the BLOBS poll unions every wired adapter (not just blob_adapters[0]) and surfaces per-adapter errors as footer toasts; the cursor_to_bottom for DATAFORTS uses the visible row count (not blob_adapters.len()) so G lands on the last visible row when remote dataforts exist. tabs::short_id is canonical across daemon_page and groups (0xXXXXXX, 6-padded). The fmt_ts_hms_ms and unix_now_ms helpers are hoisted once so the three prior copies don't drift.
Net CLI
The command-line operator surface. Lives in cli/ (a [[bin]]-only workspace member; binary name net).
cli/src/
├── main.rs — clap dispatch + global flags
├── context.rs — identity / config / output-format plumbing
├── identity.rs — operator key generate / load / rotate
├── node.rs — node start / status / health
├── chain.rs — RedEX chain inspect + tail
├── netdb.rs — local NetDB CRUD (tasks / memories)
├── meshdb.rs — federated MeshDB query
├── admin.rs — 9 signed admin verbs
├── ice.rs — 7 ICE break-glass verbs with simulate → commit
├── audit.rs — admin + ICE audit reads
├── logs.rs — log stream subscription + filter
├── daemon.rs — daemon roster + supervision
├── rpc.rs — typed nRPC client
└── version.rs — semver + feature surface report
The CLI is the same admin surface Deck exposes, mapped onto clap subcommands. net admin drain --node N --drain-for 30s commits the same ChainCommit Deck's admin tab would; net ice freeze-cluster --reason "incident X" --preview runs the same simulate-before-commit pre-flight Deck's ICE tab runs and prints the blast-radius JSON to stdout for scripting; net netdb tasks ls --json reads the local NetDB store and emits JSON for piping into jq. Every command honors --output {pretty,json} for human vs. script output and exits with stable error codes — 0 success, 1 generic failure, 2 invalid arguments, 3 not found, 4 already exists, 5 permission denied (no operator key for an admin commit), 6 not connected, 7 timeout, 8 confirmation refused on a non-TTY ICE without --yes.
The ICE preview workflow is locked in. net ice <verb> --preview runs IceProposal::simulate() and prints the BlastRadius JSON without committing — the same dry-run shape that ships in every SDK. net ice <verb> --yes commits without the TTY confirmation gate; net ice <verb> alone (TTY) prints the simulation and prompts for Type YES to confirm: before reaching the substrate. The TTY-only --yes path is the same gate net admin uses for cordon / drain / drop-replicas — the simulation isn't the gate, the confirmation is.
The identity store at ~/.config/net/operator/<name>.key is the canonical authentication source for every admin / ICE commit. net identity generate <name> creates a fresh ed25519 keypair, prints the public key for registry installation, and writes the private key to disk with 0600 mode. net identity ls enumerates installed identities; net identity rotate <name> rotates with audit-trail commitment. The CLI refuses to commit an admin or ICE event under an ephemeral keypair — admin writes require an explicit operator identity (the second-pass review caught the silent ephemeral-keypair fallback).
JSON output is stable. OperatorIdentity's operator_id is a u64 decimal; ChainCommit's event_kind is a string enum (drain / enter-maintenance / cordon / etc., not the Rust Debug form — the first-pass review caught TaskRow and MemoryRow shoving debug-printed structs into named fields and corrected them); timestamps are ISO 8601 with Z suffix; durations honor humantime input but always emit milliseconds-as-integer.
86 help-text snapshot tests pin every subcommand's --help output so wording can't drift accidentally. 11 exit-code tests cover the documented exit-code contract end-to-end through a real substrate boot. The CLI is the operator surface; "scriptable + stable + signed" is the contract.
MeshOS SDK
The daemon-author SDK. Mirrors the canonical Rust surface from v0.17 (MeshOsDaemonSdk + MeshOsDaemonHandle) in four more languages with one shared wire contract. Lives in:
- Rust (canonical):
sdk/src/meshos/—MeshOsDaemonSdk::start(config)/register_daemon(daemon, identity)returningMeshOsDaemonHandlewithnext_control()/try_next_control()/publish_log()/publish_capabilities()/graceful_shutdown(). - Python (pyo3):
bindings/python/python/net/+sdk-py/src/net_sdk/meshos.py—MeshOsDaemonProtocol class for type-checker verification;MeshOsDaemonSdk.start()returns a handle with syncnext_control+ asyncanext_control+async for ev in handleiterator; context-manager dunders drive graceful shutdown on scope exit. - TypeScript (napi-rs):
bindings/node/+sdk-ts/src/meshos.ts—registerDaemon(daemon: DaemonObjectTsfns, identity)accepts a full daemon object with TSFN-bridgedprocess/snapshot/restore/onControl/health/saturation; the napiMeshOsDaemonHandleexposes async control-event iteration through anAsyncIterable<DaemonControl>. - Go (cgo):
bindings/go/net/meshos.go—MeshOsDaemoninterface with cgo trampolines (goMeshOsProcessTrampoline,goMeshOsSnapshotTrampoline,goMeshOsRestoreTrampoline,goMeshOsOnControlTrampoline,goMeshOsHealthTrampoline,goMeshOsSaturationTrampoline);MeshOsDaemonSdk.RegisterDaemonreturns a handle withControlEvents() <-chan DaemonControl(context.Context-cancellable) +TryNextControl(). - C (raw FFI):
include/net_meshos.h+libnet_meshos.{so,dylib,dll}—NetMeshOsDaemonVtablecarryingname/process/health/saturation/on_control/snapshot/restorefunction pointers;net_meshos_register_daemon(sdk, &vtable, ctx, &identity)returning an opaque handle;net_meshos_next_control(handle, timeout_ms, out)for blocking control reads; per-threadnet_meshos_last_error_message/net_meshos_last_error_kinddiscriminator after every non-OK return.
Daemon-side only by lock. No placement APIs in any binding. No admin-event issuance. No MeshOS-control surfaces. The SDK is the daemon contract in five languages; operator tooling, federated interactions, and MeshDB queries belong to separate SDKs (the Deck SDK below, plus the existing MeshDB SDK that v0.16 shipped).
The cross-language hardening list is real work. The Go MeshOsDaemonHandle.Free() now blocks on the pump goroutine's in-flight NextControl before the C-free, so a concurrent shutdown can't race the cgo destructor (the second-pass review caught this); the Python PyDeckClient.close() path no longer swallows shutdown errors asymmetrically vs. Node; the Python standalone constructor gains __enter__ / __exit__ + __del__ so dropping a client without an explicit close still tears down the supervisor; the TypeScript handle classes gain explicit dispose() / [Symbol.dispose]() for TC39 explicit resource management; the Go Deck streams pick up the same Close-vs-Next race fix the MeshOS streams shipped; operator seed bytes are zeroized at the binding boundary across Node / Python / Go standalone constructors.
Cargo features gate the feature surface uniformly: meshos activates the runtime symbols, cortex activates the snapshot fold layer the runtime composes against. Wheels / npm packages / Go cdylibs ship the full default set; bindings/python/python/net/__init__.py and the npm @ai2070/net package's lazy feature checks raise ImportError (Python) or surface a typed missing-feature error (Node) rather than AttributeError if the wheel was built without a feature.
The Python _net.pyi stub now carries full method signatures for every feature-gated class — MeshOS (MeshOsDaemonSdk, MeshOsDaemonHandle), MeshDB (MeshQuery, MeshQueryRunner, QueryBuilder, Predicate, the result-row family), and Deck (every class below). A test_stub_drift.py regression test parametrizes over every stub class and asserts the runtime symbol matches, so future PyO3 method renames trip CI rather than silently break IDE autocomplete.
Deck SDK
The operator-tooling SDK. Mirrors the Rust DeckClient surface in four more languages with one shared substrate-side wire contract:
- Rust (canonical):
sdk/src/deck/—DeckClient::new(operator_identity, config)/from_runtime(runtime, ...)returning a client with.admin()/.ice()/.audit()/.snapshots()/.status_summary_stream()/.subscribe_logs(filter)/.subscribe_failures(since_seq). - Python (pyo3):
bindings/python/python/net/+sdk-py/src/net_sdk/deck.py—DeckClient(operator_seed, config)+DeckClient.from_seed(seed, **config_kwargs)wrapper; admin verbs return typedChainCommitdataclasses; ICE break-glass typestate enforced throughIceProposal→SimulatedIceProposal→commit(signatures); context-manager dunders drive shutdown on scope exit. - TypeScript (napi-rs):
bindings/node/+sdk-ts/src/deck.ts—DeckClientwithreadonly admin/readonly iceproperties holding typed verb dispatchers; async-iterableSnapshotStream/StatusSummaryStream/LogStream/FailureStream;DeckSdkErrorcarries the structuredkinddiscriminator the substrate emits. - Go (cgo):
bindings/go/net/deck.go+ top-levelgo/deck.gocompanion —DeckClient.Admin().Drain(node, drainForMs)/DeckClient.ICE().FreezeCluster(reason, ttl).Simulate()/.Commit(signatures); the stream surfaces use buffered<-chanwith context-aware shutdown;DeckErrorwraps the substrate's<<deck-sdk-kind:KIND>>MSGenvelope. The binding-tierbindings/go/net/deck.gocovers all three slices (admin + ICE + logs + audit + failures); the top-levelgo/deck.goships slice 1 (admin + status streams) — slices 2/3 callers use the binding tier directly. - C (raw FFI):
include/net_deck.h+libnet_deck.{so,dylib,dll}—net_deck_client_new(this_node, …, operator_seed, &out)constructor; 9net_deck_admin_*verbs; 7net_deck_ice_*factories returningNetIceProposal*that consumes itself on_simulate(yieldingNetSimulatedIceProposal*that consumes itself on_commit); snapshot / status-summary / log / failure / audit streams; per-threadnet_deck_last_error_kind/net_deck_last_error_message.
The break-glass typestate is enforced across every language. IceProposal carries an issued_at_ms-tagged signing payload with a substrate-side nonce, domain-separation prefix, and one-minute commit-window expiry; simulate() builds a SimulatedIceProposal and freezes the substrate's reconcile arms against the proposal's effect projection; commit(signatures) verifies signatures against the operator registry's M-of-N threshold (deduplicating by operator id before counting — same operator can't sign twice), then commits to the admin chain and clears the per-node ICE cooldown. The substrate enforces simulate-before-commit; commit without a fresh simulation returns consumed rather than re-running simulate from scratch. The simulate path consumes itself on success — a second simulate() on the same proposal returns consumed, not a fresh blast-radius (the first-pass review caught the consumed-state sentinel reading back as a valid timestamp; the typestate flip closes both regressions).
impl DeckClient {
pub fn new(identity: OperatorIdentity, config: DeckClientConfig) -> Self;
pub fn from_runtime(runtime: &MeshOsRuntime, identity: OperatorIdentity, ...) -> Self;
pub fn with_operator_registry(self, registry: OperatorRegistry) -> Self;
pub fn snapshots(&self) -> SnapshotStream;
pub fn status(&self) -> Result<MeshOsSnapshot, DeckError>;
pub fn status_summary(&self) -> Result<StatusSummary, DeckError>;
pub fn status_summary_stream(&self) -> StatusSummaryStream;
pub fn subscribe_logs(&self, filter: LogFilter) -> LogStream;
pub fn subscribe_failures(&self, since_seq: Option<u64>) -> FailureStream;
pub fn audit(&self) -> AuditQuery;
pub fn admin(&self) -> AdminCommands<'_>;
pub fn ice(&self) -> IceCommands<'_>;
}
pub struct DeckClientConfig {
pub snapshot_poll_interval: Duration,
pub ice_signature_threshold: usize, // M-of-N for ICE commits
}
AdminCommands exposes the 9 substrate admin verbs (drain / enter-maintenance / exit-maintenance / cordon / uncordon / drop-replicas / invalidate-placement / restart-all-daemons / clear-avoid-list). IceCommands exposes the 7 break-glass operators (freeze-cluster / thaw-cluster / flush-avoid-lists / force-evict-replica / force-restart-daemon / force-cutover / kill-migration). The AuditQuery builder is fluent — .recent(n) / .by_operator(id) / .between(start, end) / .force_only() / .since(seq) / .collect() / .stream() — and surfaces the same admin-event ledger across every binding.
The operator registry is the M-of-N gate. OperatorRegistry::insert(id, public_key) registers operators; verify(payload, signatures) returns Ok(()) when the signature set meets the threshold and rejects duplicates by id. The registry survives RedEX chain replay so registrations made on one node propagate to every other node; bundle-verify (verify_bundle) covers the multi-signature ICE-commit path with the same dedup-by-id rule.
Default feature parity
net/crates/net/Cargo.toml now ships defaults matching the Python wheel and Node npm package, minus redis:
[features]
default = [
"net",
"nat-traversal",
"cortex", # → redex, redex-disk transitively
"meshdb",
"meshos",
"dataforts",
]
redis = ["dep:redis"] # opt-in: external service dep
cargo add ai2070-net now gives Rust consumers the same operator stack Python and Node consumers already get. Existing Rust consumers who want the prior minimal surface can opt out with --no-default-features.
The Cargo features section at the bottom of every binding README (bindings/python/README.md, bindings/node/README.md, bindings/go/net/README.md, sdk-py/README.md, sdk-ts/README.md) documents the five relevant feature flags (cortex, redex-disk, netdb, meshdb, meshos), what each enables, and the build invocation for each binding so consumers building from source know what to pass.
C ABI consolidation
The C SDK header story is cleaned up. net.h is the bus + shared error enum; net_cortex.h is the RedEX + CortEX + NetDb surface (new in this release — split out of the prior net.go.h catch-all); net_rpc.h is the RPC surface; net_meshdb.h is the MeshDB query layer; net_meshos.h is the MeshOS daemon-author surface; net_deck.h is the Deck operator surface. The convenience net.go.h #includes net_cortex.h and inlines RPC + Deck declarations for callers who want everything in one place.
The NetDb FFI lands in this release as well — net_netdb_open / net_netdb_open_from_snapshot / net_netdb_snapshot / net_netdb_tasks / net_netdb_memories / net_netdb_close / net_netdb_free plus net_netdb_free_bundle for the snapshot bytes. Adapter accessors hand out independent Arc-cloned net_tasks_adapter_t* / net_memories_adapter_t* handles — freeing them does NOT close the underlying adapter, and the NetDb itself can be freed before the adapter clones. The Go binding consumes this through bindings/go/net/netdb.go; Python and Node already consumed the Rust core directly.
The MeshDB and MeshOS cdylibs are unchanged (still separate libraries — libnet_meshdb.{so,dylib,dll}, libnet_meshos.{so,dylib,dll}). The new libnet_deck.{so,dylib,dll} cdylib ships alongside, built from the net-deck-ffi workspace member at bindings/go/deck-ffi. C consumers link the libs they want.
Substrate hardening — pre-watcher pass
Alongside the SDK / TUI / CLI work, a three-pass bug audit closed 42 substrate + CLI items across 42 commits before the v0.18 branch cut (BUG_AUDIT_2026_05_17_NET_CLI.md). Pass 1 covered the Net CLI command surface (17 items). Pass 2 extended outward into adapter/net/**, sdk/src/**, and the Python / Node / Go bindings (11 items). Pass 3 was specifically scoped to the layers a future netdb-watcher subscriber will sit on top of — the cortex adapter's fold loop, RedexFile::tail / append, and the NetDb façade (9 items). These are not the kind of bug that surfaces in unit tests at low load — they manifest under burst-write contention, lagged subscribers, or after the first restart. The Criticals would have made a watcher look broken; closing them ahead of any consumer means the watcher writes against substrate that already behaves.
Three Criticals closed in the cortex fold loop. The fold task used to be tokio::spawn-ed after file.tail(start_seq) already registered the live watcher — between registration and the spawned task being polled, concurrent appends could call notify_watchers, evict the watcher with try_send(Err(Lagged)), and the fold task's first stream.next() would yield Some(Err(Lagged)) and break out of the loop before processing a single event. Moving the tail call to be the first statement inside the spawned task gives registration and consumption a deterministic ordering. The second Critical was the live Lagged match arm permanently killing the adapter — any subscriber falling behind tail_buffer_size once now re-subscribes at folded_through_seq + 1 instead of silently halting the fold task forever. The third was wait_for_seq returning Ok(()) when running == false, which couldn't distinguish "your seq is folded" from "the fold task crashed before reaching your seq"; it now returns Err(folded_through_seq()) mirroring the sibling wait_for_applied_seq's contract, so a watcher polling for a seq the substrate will never reach gets a typed signal instead of false success.
Three Highs closed across the RedEX and watermark layers. notify_watchers previously fired before fsync, so a crash after the broadcast but before the kernel flushed the page cache could lose an event from disk that subscribers had already acted on; the durability contract is now explicit and watchers reconcile from last_persisted_seq + 1 on restart by (channel, seq) key. The next_seq.fetch_add(1) allocator could be visible to a concurrent LiveOnly opener across a failed-write rollback — a LiveOnly adapter opening during the rollback window would start its tail at the inflated seq, then silently filter out the real append at the lower seq. The applied_through_seq strict-prefix advance used wrapping_add(1), which collided with the u64::MAX "nothing applied yet" sentinel when seq reached the boundary — the snapshot would persist None, restore would re-replay everything, and every pending wait_for_applied_seq would block forever. All three close before the watcher tier writes.
Three Mediums closed in the NetDb façade and the cortex changes_tx ordering. NetDbBuilder::build() with both want_tasks=false and want_memories=false used to silently return a no-op NetDb whose accessors panicked on first use — combined with the CLI's --with-tasks / --with-memories flag work, a misconfigured profile or test fixture would have turned a config error into a process panic. The builder now returns NetDbError::NoModelsEnabled explicitly. NetDb::snapshot()'s sequential per-adapter capture documents its lack of a cross-model barrier so watchers snapshotting between event deliveries know to coordinate ordering. The changes_tx.send(seq) edge-trigger broadcast moved inside the state write-lock block so subscribers treating the seq value as authoritative ordering can't observe out-of-order seqs under contention.
The CLI-side fixes touch every operator-facing path. The restore safety gate no longer treats I/O errors as "store is empty" and lets restore overwrite a populated dir without --force. The --origin flag now requires an explicit value (or --allow-origin-zero) so a stray missing flag can't silently fold against the wrong chain. Snapshot writes are atomic (tmp.<pid> → fsync → rename → fsync parent) so a crash mid-write doesn't truncate the operator's previous snapshot. Operator seed files are created with OpenOptions::mode(0o600).create_new(true) so the seed never hits disk world-readable. The Windows strict-permissions path warns unconditionally on reads without --insecure-permissions instead of silently no-oping the gate the module-header doc promises. --force restore is now scoped to the replace semantic the verb name implies, not the silent-merge it was doing before. The ICE confirm prompt uses tokio::io::stdin so a blocking read doesn't park a Tokio worker thread for as long as the operator stares at the gate. The netdb subcommand finally honors --config / --profile (it was the only top-level that ignored both, so an operator with netdb = "/srv/netdb" in their profile would silently land in the default XDG path and write mutations into the wrong store).
The pass-2 substrate items hit the SDK and bindings: a thundering-herd retry jitter source that was contributing ~0 ns of entropy now seeds from a process-epoch Instant; three u32 → u8 truncation bugs in the Go compute-ffi spawn paths (replica count 300 was silently becoming 44) now mirror the scale_to validation that already existed alongside; the tasks/memories adapter fetch-add-then-ingest path either re-instates the rollback or rewrites the contract docs to acknowledge the gap; the set_local_capabilities lost-update race between fetch_add and the subsequent store(version) is corrected so the capability version monotonically advances; the loadbalance connections.fetch_sub(1) underflow that silently removed endpoints from rotation forever now saturates.
A handful of items reached "obsolete" rather than "fixed" — re-reading the code showed the audit had misread the contract (the has_more cursor advances correctly via last_seen_seq, PyNetDb::open's make_runtime() is already a process-wide OnceLock<Arc<Runtime>> singleton so multiple adapters share one runtime, next_seq() already takes the state lock per the existing docstring, changes_tx.send runs sequentially from the spawn task's loop so the ordering already matches the watermark ordering). The audit doc records them under Obsolete so a future reader knows the agents looked at those call sites and the contract was already correct.
The watcher work itself follows this release. The substrate beneath it is now clean.
Test hygiene
- Lib suite at 3115+ tests (was 2715+ at v0.17 release). 400+ net new tests across the Deck TUI snapshot suite + the cross-language SDK surfaces + the Net CLI exit-code / help-text regression suite + the substrate-side ICE / operator-registry / blast-radius simulation tests + the action-chain
MeshOsSnapshotpostcard / JSON forward-compat regression layer. cargo clippy --features meshos,deck --all-targets -- -D warningsclean across substrate + every binding crate + the deck demo + the deck TUI + the net CLI.cargo doc --features meshos,deck --no-depsclean underRUSTDOCFLAGS="-D warnings"— every public item in the v0.18 surface carries a doc comment; intra-doc links resolve through the public re-exports.- CI matrix expanded. The Go CI step builds
net-deck-ffialongside the existingnet-compute-ffi,net-meshdb-ffi,net-meshos-fficdylibs so the newgo/deck.gocgo block links. The Python CI step builds withmeshdbenabled sotest_meshdb.pyand the newtest_stub_drift.pyMeshDB class coverage exercise on every run. - 86 help-text snapshot tests pin every Net CLI subcommand's
--helpoutput. 14 deck-pipeline integration tests cover the substrate-end-to-end behavior the TUI relies on (MeshOsSnapshotpublish →MeshOsSnapshotFoldconsume → MeshDBLatestquery → TUI render). 11 exit-code tests cover the documented Net CLI exit-code contract through a real substrate boot.
Breaking changes
Crate-level default features
ai2070-net's default = [...] moves from [] to ["net", "nat-traversal", "cortex", "meshdb", "meshos", "dataforts"]. Existing Rust consumers who add the crate without a --no-default-features flag will compile a larger feature surface and pull additional transitive deps (chacha20poly1305, snow, ed25519-dalek, x25519-dalek, blake3, postcard, tokio-stream, async-trait). No code breakage — every default-activated feature is stable; consumers paying for the previous minimal surface should --no-default-features and re-add what they need.
Workspace — new members
cli/ (Net CLI binary) and bindings/go/deck-ffi/ (Deck cdylib) are new workspace members. Existing build invocations are unaffected; consumers who cargo build without -p <name> will see the new members compiled by default. cargo build --workspace --release now produces five cdylibs (libnet, libnet_compute, libnet_meshdb, libnet_meshos, libnet_deck) and one new bin (net).
MeshOsSnapshot wire format
The snapshot's postcard wire format gains a wire_version: u8 prefix that the MeshOsSnapshotFold's decoder checks before postcard dispatch. Existing consumers reading raw postcard bytes need to strip the version byte before decode; consumers using MeshOsSnapshotReader::read() are unaffected (the reader returns the decoded struct). Regression tests pin a captured legacy byte string so accidental field reorders trip CI.
Deck SDK signing payload
ICE-commit signing payloads now carry a domain-separation prefix (b"net-deck-ice:"), a substrate-side nonce, and a one-minute commit-window expiry. Operators who had cached a signed payload from a prior version must re-sign — the substrate verifies the prefix + expiry before accepting the signature. The substrate-side bump is invisible to operators using the SDK's simulate() → commit(signatures) typestate; only consumers who hand-rolled the signing-payload bytes need to update.
Python MeshOsDaemonSdk.start signature
The MeshOS SDK plan documented a callback_timeout_ms parameter on MeshOsDaemonSdk.start; the runtime never accepted it (the original stub was wrong — see the cross-language review). The stub now matches the runtime: start(config: Optional[dict] = None, *, control_capacity: Optional[int] = None). Python consumers passing callback_timeout_ms to start() will see a TypeError; remove the argument.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.18 line. Rust consumers who want the prior minimal default-feature set adddefault-features = falseto theirai2070-netdependency and re-list the features they need; everyone else gets the full operator stack out of the box. - Operators. Install the Net CLI via
cargo install --path cli(workspace-relative) or your distro's release artifact. Generate an operator identity withnet identity generate <name>, install the public key into the cluster's operator registry, and start running admin / ICE / audit commands. Runnet --helpfor the full subcommand map. - Deck. Build with
cargo build --release -p deck(binary attarget/release/deck). Configure the deck connection target via~/.config/net/deck.tomlor the--clusterflag. Rundeckfrom the operator workstation — the TUI auto-discovers reachable maintenance nodes and renders the cluster live. Press?in any tab for context-aware help. - Daemon authors. Pick your language:
- Rust:
MeshOsDaemonSdk::start(...)returningMeshOsDaemonHandle— seesdk/src/meshos/. - Python:
pip install ai2070-net-sdk(or build from source with--features meshos); implement theMeshOsDaemonProtocol and register viaMeshOsDaemonSdk.start().register_daemon(daemon, identity). - TypeScript:
npm install @ai2070/net-sdk; implement the daemon object shape (name,process, optionalsnapshot/restore/onControl/health/saturation) and pass it toregisterDaemon. - Go:
import "github.com/ai-2070/net/bindings/go/net"; implement theMeshOsDaemoninterface and callmeshos.RegisterDaemon(daemon, identity). - C:
#include <net_meshos.h>; populate aNetMeshOsDaemonVtableand callnet_meshos_register_daemon(...). Link againstlibnet_meshos.
- Rust:
- Operator-tooling authors. Same per-language path with the Deck SDK:
DeckClient::new(operator_identity, config)(Rust) /DeckClient.from_seed(seed)(Python) /new DeckClient({operatorSeed, ...})(Node) /net.NewDeckClient(seed, config)(Go) /net_deck_client_new(...)(C). Drive.admin()for signed commits,.ice()for break-glass,.audit()for the admin-event ledger. - ICE workflow. Every break-glass operator runs through
simulate()→commit(signatures). Build a proposal withclient.ice().freeze_cluster(reason, ttl); callproposal.simulate().awaitto get aSimulatedIceProposalcarrying the blast-radius projection; collect operator signatures oversimulated.signing_payload(); callsimulated.commit(signatures).await. The substrate enforces simulate-before-commit; commit without a fresh simulation returnsconsumed. - MeshOS daemon trait additions. If you implement
MeshDaemonand want supervision participation, overridehealth()/saturation()/on_control(DaemonControl). Defaults preserve compatibility from v0.17. - NetDB from Go. Go consumers who previously opened
OpenTasksAdapter+OpenMemoriesAdapterseparately can now useOpenNetDb(redex, NetDbConfig{...})for the cross-adapter façade + snapshot bundle that round-trips across every binding. Seebindings/go/net/netdb.go. - C ABI consumers. Migrate
#include "net.go.h"callsites to the per-surface headers (net_cortex.hfor RedEX/CortEX/NetDb,net_rpc.hfor RPC,net_meshdb.hfor MeshDB,net_meshos.hfor MeshOS,net_deck.hfor Deck). The conveniencenet.go.h#includesnet_cortex.hautomatically; existing consumers compile unchanged.
Named after Steve Stevens's 1989 solo album — same guitarist as v0.15's Rebel Yell, next chapter. v0.15 made the Dataforts data plane stand up. v0.16 stacked the MeshDB query plane on top. v0.17 stacks the MeshOS behavior plane on both: a per-node event loop + reconcile + admit + dispatch + scheduler + chain integration that composes against the capability index, proximity graph, replication election, daemon registry, migration orchestrator, and MeshDB snapshot fold the prior releases shipped.
MeshOS
MeshOS is the cluster-behavior engine that turns the Net substrate into a living distributed operating system, and v0.17 is where it lands. The substrate before MeshOS shipped every primitive a cluster needs — replicas placed by PlacementFilter, chains advertised through the capability index, blobs moved by Dataforts, queries answered by MeshDB, sessions cryptographically pinned by Net — but every primitive ran as its own independent reactor. The replication coordinator spawned per-channel heartbeat tasks. The CortEX adapter folded events wherever a consumer asked. The migration orchestrator handled handoffs but was wired by hand. Each reactor was correct in isolation; nothing wired them into a single coherent observation of what the node is doing right now.
MeshOS is that single observation point. One canonical event loop per node consumes the union of seven event types — replica updates, daemon lifecycle signals, RTT samples, node health flips, admin actions, blob announcements, placement intent — folds them into a MeshOsState view, compares against the DesiredState Dataforts continuously emits, and produces a minimal action list per tick: StartDaemon / StopDaemon / PullReplica / DropReplica / RequestPlacement / RequestEviction / MigrateBlob / MarkAvoid / ApplyBackoff / CommitMaintenanceTransition. Actions ride through a single admission gate (admit()) that funnels every outbound act through one coherent backpressure layer — global pull cooldown, drain rate-limit, per-daemon crash-loop gating, per-chain replica stabilization windows, cluster-wide hysteresis flag — and dispatch to a pluggable ActionDispatcher that bridges to the existing subsystems. The substrate stays unchanged; MeshOS composes against it.
The behavior layer is what makes the cluster move. Daemons that crash get exponential backoff, then a crash-loop gate at five failures per minute. Replicas that under-score relative to a PlacementFilter-driven PlacementScorer get evicted by the chain's elected leader and refilled on the next reconcile pass — same primitive, applied as a feedback loop. RTT samples cross a 250 ms degradation threshold and become avoid-list entries; the leader's continuous-rebalance scoring loop reads them. Admin events ride RedEX chain commits — EnterMaintenance, Drain, Cordon, DropReplicas, ClearAvoidList, InvalidatePlacement — so every node converges on the same operator-driven view without RPC coordination. The maintenance state machine (Active → EnteringMaintenance → Maintenance → ExitingMaintenance → Recovery, with DrainFailed as the deadline-elapsed sideways arc) is per-node and chain-driven; every transition is idempotent under replay.
The supervisor surface that daemons see is small and disciplined. MeshDaemon gains three optional methods with default impls — health() -> DaemonHealth, saturation() -> f32, on_control(DaemonControl) — so every existing daemon compiles unchanged while new daemons can participate in graceful shutdown, drain coordination, and cluster-wide backpressure. The control surface is the WASM-friendly DaemonControl enum carrying relative-millisecond deadlines (the loop-internal MeshOsControl keeps Instant-anchored deadlines for scheduling and bridges via to_daemon_control(now)). Variants cover the full operational range — Shutdown { grace_period_ms }, DrainStart { grace_period_ms }, DrainFinish, BackpressureOn { level }, BackpressureOff — and arrive through the canonical control channel the supervisor owns.
Observability folds back through the same model the rest of the substrate uses. The behavior snapshot — daemons (lifecycle, health, saturation, restart-state), replicas (holders, desired count, elected leader), peers (RTT, health, maintenance-mirror), the local avoid list, the node's own maintenance state, pending actions, and a bounded recent_failures ring buffer — is a MeshOsSnapshot published live behind an ArcSwap<MeshOsSnapshot> (lock-free reads from any thread) and committed durably through an ActionChainAppender whose records ride a RedEX chain. MeshOsSnapshotFold (impl RedexFold<MeshOsSnapshot>) consumes those records on every node, so Deck queries a per-node folded snapshot through MeshDB's MeshQuery::Latest against the snapshot chain — no new wire protocol, no separate observability stack. The federated query plane v0.16 shipped becomes the cluster-jungle render surface v0.17 promises.
There is no separate orchestration service to provision. There is no scheduler daemon to deploy. The reconciliation loop is on the mesh because the substrate is the cluster.
v0.17 lands the full MeshOS substrate behind the meshos Cargo feature — the canonical event loop, the desired-vs-actual reconcile, daemon supervision (BackoffTracker with crash-loop gating + the MeshDaemon trait extension + graceful-shutdown plumbing), replica enforcement (leader-only Request* emission + per-node LocalReplicaIntent projection of admin events), locality awareness (RTT-driven MarkAvoid emission + pull-via-tick proximity / health probes), admin events (chain-driven EnterMaintenance / ExitMaintenance / Drain / Cordon / DropReplicas / ClearAvoidList / InvalidatePlacement), the maintenance state machine (chain-driven transitions with per-state metadata), the behavior snapshot (ArcSwap-published per-tick build + RedexFold<MeshOsSnapshot> over the action chain), the single admit() backpressure layer (pull cooldown / replica stabilization / drain rate-limit / cluster hysteresis), the action executor (admit → dispatch → retry-through-admit → record-to-chain), the continuous-rebalance scoring loop (per-chain leader-driven eviction emission gated on score_floor + hysteresis_gap + cooldown), and the action chain integration (postcard-versioned ActionChainRecord + ActionChainAppender trait + MeshOsSnapshotFold that updates recent_failures from the chain replay). Two source-converter patterns ship in lockstep: push-via-observer for the DaemonRegistry and ReplicationCoordinator (low-latency lifecycle / replica-transition events) and pull-via-tick for the proximity graph (RTT samples + health classifications via LocalityProbe + HealthProbe traits, with the [u8; 32] ↔ u64 id-bridge pinned to the substrate's mesh::graph_id_to_node_id convention). The full surface ships behind MeshOsRuntime::start(config, dispatcher) — one call replaces the hand-wired loop + executor + handle + reader + scheduler + probes wiring every consumer would otherwise re-implement.
The hardening posture from the Black Diamond / Rebel Yell / Eye of the Tiger line continues. Two coordinated code-review passes landed before the v0.17 branch cut, covering the async stitching layer, the pure-sync decision logic, the backpressure / dispatch / chain integration, and the SDK plan + README in one combined punch list — 8 Criticals, 23 Importants, 16 Nits across the two passes. Every item closed in-tree with per-item regression coverage where the shape made one possible. The list is real work: the dispatch retry path no longer bypasses admit (transient errors used to drift cooldown counters permanently); the cluster-backpressure broadcast is wired into the executor (update_cluster_backpressure was unit-tested but disconnected at runtime); the scheduler's eviction emission is idempotent under double-reconcile (a pending_evictions: HashSet<ChainId> written by the loop and cleared on observed holder-count drop); reconcile drops on a full action queue are counted + surfaced through RuntimeStats instead of silent let _ = try_send(...); the snapshot publish path is genuinely lock-free (ArcSwap<MeshOsSnapshot> replacing the prior parking_lot::RwLock<Arc<MeshOsSnapshot>>); ApplyBackoff no longer re-emits every tick while a daemon is BackingOff (a last_applied_backoff sentinel on MeshOsState); Phase C and the scheduler arm can no longer double-evict the same chain on the same tick; the fold side now anchors every Instant::now() on the loop's last_tick for replay determinism; MeshOsState::replicas is a BTreeSet<NodeId> rather than Vec<NodeId> so reconcile is O(N log N) across many chains; MeshOsRuntime has a Drop impl that aborts both tasks (no more leak when a consumer forgets shutdown()); probe and dispatcher panics are caught with std::panic::catch_unwind and recorded as FailureRecords rather than killing the loop or executor task; the defer heap enforces a max_defer_count (default 16) before dropping a poison-pill action; MissedTickBehavior::Delay replaces Skip so a slow tick doesn't silently lose reconcile passes; BufferingActionChainAppender is bounded with drop-oldest semantics; ActionChainRecord carries a one-byte wire-format version that the decoder checks before postcard dispatch; MeshOsHandle::publish_timeout(event, Duration) lands so source converters with a wedged loop don't park indefinitely; FailureRecord.age_ms derives from emitted_at_ms at snapshot-read time rather than the misleading constant zero; tracing instrumentation lands across every loop entry / shutdown / panic / dropped action / probe install; Instant + Duration arithmetic uses checked_add everywhere; ReplicaTransitionEvent::LeaderLost fires on Leader → {Replica, Idle} so MeshOsState::replica_leader clears properly; MeshOsLoop::new returns a MeshOsLoopParts struct rather than a 4-tuple so adding a probe registry or stats handle later isn't a breaking change; probe_counts() reads both lengths under a single guard; MeshOsSnapshot::from_state now populates recent_failures from MeshOsState::recent_failures (previously hard-coded empty); MeshOsRuntime exposes register_daemon(...) so the trait-implementor SDK path doesn't have to reach into the runtime's internals; the snapshot's pending field is correctly named after what it carries (recently-emitted-but-not-yet-acknowledged actions); CommitMaintenanceTransition { target: DrainFailed } now carries a reason field; public Config structs gain #[non_exhaustive]; the BackpressureState::release_failed_admit rollback fix replaces a wrong-entry pop-by-equality bug; the replica step-down path emits a single committed event rather than two events that could fragment under back-pressure; run_reconcile samples Instant::now() once per tick rather than three times. 172 meshos unit tests + 11 pipeline integration tests + 13 daemon-registry + 9 daemon-trait + 15 replication-coordinator tests all pass. cargo clippy --features meshos --lib --tests -- -D warnings clean. RUSTDOCFLAGS="-D warnings" cargo doc --features meshos --no-deps --lib clean.
The MeshOS SDK plan covering Rust / Python / Node / Go / C ships alongside as a design document — MESHOS_SDK_PLAN.md. The Rust SDK is the canonical surface; Python / Node / Go / C land in dependency order per consumer demand, all gated on the daemon-side-only restriction (no placement APIs, no admin-event issuance, no MeshOS-control surfaces in any binding, ever). A new sdk workspace member at crates/net/sdk/ opens the slot.
No new dependencies. No protocol changes. The crate version moves from 0.16.x to 0.17.0 to reflect the new feature surface; the workspace gains the sdk member.
The canonical event loop
The single per-node event loop that everything composes against. Lives in src/adapter/net/behavior/meshos/event_loop.rs.
pub struct MeshOsLoop { /* ... */ }
pub struct MeshOsLoopParts {
pub loop_: MeshOsLoop,
pub handle: MeshOsHandle,
pub actions_rx: mpsc::Receiver<PendingAction>,
pub snapshot_reader: MeshOsSnapshotReader,
}
impl MeshOsLoop {
pub fn new(config: MeshOsConfig) -> MeshOsLoopParts { ... }
pub fn with_probe_registry(self, registry: ProbeRegistry) -> Self { ... }
pub fn with_scheduler_registry(self, registry: SchedulerRegistry) -> Self { ... }
pub async fn run(self) -> u64 { ... }
}
pub enum MeshOsEvent {
Tick,
ReplicaUpdate(ReplicaUpdate),
DaemonLifecycle { daemon: DaemonRef, signal: DaemonLifecycleSignal },
RttSample { peer: NodeId, rtt: Duration },
NodeHealth { peer: NodeId, health: NodeHealth },
AdminEvent(AdminEvent),
BlobAnnouncement(BlobAnnouncement),
PlacementIntent(PlacementIntent),
DaemonIntentUpdate(DaemonIntentUpdate),
LocalReplicaIntent(LocalReplicaIntentUpdate),
ReplicaLeaderUpdate { chain: ChainId, leader: Option<NodeId> },
MaintenanceTransitionObserved { node: NodeId, state: MaintenanceState },
Shutdown,
}
One mpsc receiver, one heartbeat-aligned tick timer (default 500 ms, MissedTickBehavior::Delay), one reconcile pass per tick. Every source converts to a MeshOsEvent and publishes through MeshOsHandle; reconcile runs (actual, desired, this_node, locality, maintenance, scheduler, scorer) -> Vec<MeshOsAction> as a pure-sync function, idempotent under replay. Actions land on actions_tx (drop-and-count on overflow, surfaced via RuntimeStats.dropped_actions); the action executor drains. The snapshot publishes through ArcSwap<MeshOsSnapshot> after every reconcile pass.
MeshOsLoopParts replaces the prior 4-tuple constructor — adding a probe registry or stats handle in a future slice no longer requires a breaking change to callers.
Daemon supervision
The MeshDaemon trait gains three optional methods with default impls:
pub trait MeshDaemon: Send + Sync {
/* existing required: name / requirements / process / snapshot / restore */
fn health(&self) -> DaemonHealth { DaemonHealth::Healthy }
fn saturation(&self) -> f32 { 0.0 }
fn on_control(&mut self, _event: DaemonControl) {}
}
pub enum DaemonHealth { Healthy, Degraded { reason: String }, Unhealthy }
pub enum DaemonControl {
Shutdown { grace_period_ms: u64 },
DrainStart { grace_period_ms: u64 },
DrainFinish,
BackpressureOn { level: f32 },
BackpressureOff,
}
Defaults preserve source compatibility for every existing daemon. DaemonHealth lives in compute::daemon as the canonical type; MeshOS re-exports it. DaemonControl carries WASM-friendly relative-millisecond deadlines so daemons running in any clock domain can react.
The supervisor side runs in behavior::meshos::supervision. Per-daemon BackoffTracker records crash timestamps in a rolling window, advances RestartState through Idle → BackingOff { until } → BackingOff { until } (window doubles per crash up to 60 s cap) → CrashLooping { until } after five crashes within 60 s. A "stable run" (longer than stable_run_threshold, default 60 s) resets the window back to initial. The gate state is observable as RestartState::is_admissible(now); reconcile reads it to decide whether StartDaemon is admissible. ApplyBackoff { daemon, until } records the gate until on the snapshot fold when a desired-Run daemon is currently gated — and now only re-emits when the until actually changes, not every tick.
StopDaemon emits with a 30 s grace deadline (STOP_GRACE_PERIOD). The supervisor sends MeshOsControl::Shutdown { deadline } and waits; past the deadline the supervisor force-terminates. Both StopDaemon and ApplyBackoff carry relative-ms deadlines on the wire — Instant-anchored values stay loop-internal.
A new DaemonLifecycleObserver trait on compute::daemon lets the DaemonRegistry's register / replace / unregister paths fire lifecycle events through MeshOsDaemonLifecycleSink into the loop. attach_to_daemon_registry(registry, handle) is the one-line wiring helper.
Replica enforcement
Two arms per the canonical leader/follower split:
pub enum MeshOsAction {
/* … */
PullReplica { chain: ChainId, source: NodeId },
DropReplica { chain: ChainId },
RequestPlacement { chain: ChainId, exclude: Vec<NodeId> },
RequestEviction { chain: ChainId, victim: NodeId },
/* … */
}
Per-node intent (any node). DesiredState::desired_local_replicas carries a per-chain Hold / Drop projection from the leader's RequestPlacement / RequestEviction decisions. Reconcile emits PullReplica { chain, source = lex-smallest other holder } when the local intent is Hold and this node isn't already a holder; DropReplica when the local intent is Drop and this node currently holds.
Cluster-wide count (leader-only). Reconcile reads MeshOsState::replica_leader[chain] and emits RequestPlacement / RequestEviction only when this node is the elected leader. Naive victim selection picks the lex-smallest holder; the continuous-rebalance scheduler refines this with placement-score ranking. A pending_evictions: HashSet<ChainId> written by the loop on emission and cleared when the fold observes the holder-count drop gates the scheduler arm so double-reconcile within one cooldown window doesn't pile on duplicate evictions.
MeshOsState::replicas is a BTreeSet<NodeId> keyed by chain — the deterministic-iteration property the lex-smallest selection relies on is preserved while the set membership / iteration costs are O(N log N) instead of Vec's O(N²).
A new ReplicaTransitionObserver trait on redex::replication_coordinator fires BecameHolder / Idled / LeaderChanged / LeaderLost events from the coordinator's transition_to success path. MeshOsReplicaTransitionSink translates each to the matching MeshOsEvent (with LeaderLost → ReplicaLeaderUpdate { leader: None } so replica_leader clears properly when the elected leader steps down). attach_to_replication_coordinator(coord, handle, this_node) wires per-channel.
Locality + admin events
pub enum AdminEvent {
EnterMaintenance { node: NodeId, deadline: Option<Instant> },
ExitMaintenance { node: NodeId },
Drain { node: NodeId, deadline: Instant },
Cordon { node: NodeId },
Uncordon { node: NodeId },
RestartAllDaemons { node: NodeId },
ClearAvoidList { node: NodeId },
DropReplicas { node: NodeId, chains: Vec<ChainId> },
InvalidatePlacement { node: NodeId },
}
RTT samples above LocalityConfig::degraded_rtt_threshold (default 250 ms, 2× heartbeat cadence) emit MarkAvoid { peer, reason, ttl }. Gated on whether the peer is already in MeshOsState::avoid_list so a persistently-bad peer produces one action, not one per tick. Emission sorts by peer id for byte-stable output. Avoid-list entries expire after avoid_ttl (default 5 min); the per-Tick fold GCs expired entries.
DropReplicas { node, chains } projects into DesiredState::desired_local_replicas[chain] = Drop for the named chains when node == this_node. The same DropReplica emission path the leader-driven scheduler uses handles the actual action — operator-commanded drops and scheduler-driven drops share one code path. ClearAvoidList empties MeshOsState::avoid_list in the fold; subsequent reconcile passes re-evaluate RTT and re-emit MarkAvoid if the underlying RTT is still bad.
Admin commits are signed via the existing channel-auth guards (CHANNEL_AUTH_GUARD_PLAN.md); unauthorized commits are rejected at the chain-commit layer and never reach the reconcile pass. The fold consumes them identically on every node, so two operators racing each other resolve at the chain-commit ordering rather than via RPC coordination.
Pull-via-tick probes — proximity + heartbeat
Two pluggable probe traits:
pub trait LocalityProbe: Send + Sync + 'static {
fn rtt_samples(&self) -> Vec<(NodeId, Duration)>;
}
pub trait HealthProbe: Send + Sync + 'static {
fn health_samples(&self) -> Vec<(NodeId, NodeHealth)>;
}
Polled by the loop on every Tick, BEFORE reconcile, so the freshest fold drives the diff. The cadence-bound poll coalesces what would otherwise be a per-pingwave observer firing on the hot path — proximity-graph edge updates run many per second per peer, but reconcile only needs the latest sample per tick.
ProximityGraphLocalityProbe reads RTT from ProximityGraph::all_nodes(). ProximityGraphHealthProbe derives Healthy / Degraded / Unreachable from ProximityNode::last_seen against thresholds (defaults 1.5 s degraded, 5 s stale — 3× and 10× heartbeat). The [u8; 32] ↔ u64 id-bridge follows the substrate's mesh::graph_id_to_node_id convention (first 8 bytes little-endian) — pinned at the SDK boundary so MeshOS's u64 NodeId and the proximity graph's 32-byte form interoperate cleanly.
ProbeRegistry is a clone-shared cell (Arc<RwLock<Vec<...>>>) so consumers can install probes after MeshOsRuntime::start — the runtime retains its registry clone; the loop reads through; additions take effect on the next Tick. Each registered probe is wrapped in std::panic::catch_unwind; a panicking probe records a FailureRecord rather than killing the loop task.
Maintenance state machine
Per-node state machine driven by chain-committed admin events and condition-driven forward transitions:
pub enum MaintenanceState {
Active,
EnteringMaintenance { since: Instant, deadline: Option<Instant> },
Maintenance { since: Instant },
ExitingMaintenance { since: Instant },
DrainFailed { since: Instant, reason: String },
Recovery { since: Instant },
}
AdminEvent::EnterMaintenance { node, deadline } flips local_maintenance to EnteringMaintenance when node == this_node. Reconcile observes the conditions on every Tick: when all local replicas have migrated AND all daemons are stopped, emit CommitMaintenanceTransition { target: Maintenance }. When the deadline elapses with conditions unmet, emit CommitMaintenanceTransition { target: DrainFailed { reason } } — the reason rides on the wire so the operator surfacing on Deck carries the actual failure mode, not a generic flag. AdminEvent::ExitMaintenance flips from Maintenance (or DrainFailed) to ExitingMaintenance; reconcile observes daemon-restart health and emits Recovery once all daemons are running healthy. The Recovery ramp-up window (default 5 min via MaintenanceConfig::recovery_ramp_window) ends with a CommitMaintenanceTransition { target: Active }.
The transition round-trip through the chain lands via MeshOsEvent::MaintenanceTransitionObserved { node, state } — the action executor commits, the chain replay surfaces it, the fold gates the local state advance on whether the prior state was a valid predecessor. since is anchored on last_tick so two replays of the same admin-event sequence produce identical state.
CommitMaintenanceTransition's target enum carries the reason: String field for DrainFailed directly — no out-of-band metadata required.
Behavior snapshot fold for Deck
The serializable projection of every loop's view, published live behind ArcSwap<MeshOsSnapshot>:
pub struct MeshOsSnapshot {
pub daemons: BTreeMap<u64, DaemonSnapshot>,
pub replicas: BTreeMap<ChainId, ReplicaSnapshot>,
pub peers: BTreeMap<NodeId, PeerSnapshot>,
pub avoid_list: BTreeMap<NodeId, AvoidEntrySnapshot>,
pub local_maintenance: MaintenanceStateSnapshot,
pub recently_emitted: Vec<PendingActionSnapshot>,
pub recent_failures: VecDeque<FailureRecord>,
}
All fields Serialize + Deserialize; Instant is flattened to milliseconds-relative-to-snapshot for wire portability. Tests pin postcard + JSON round-trip across every variant; the wire shape is part of the public API once Deck integrates. FailureRecord.age_ms derives at snapshot-build time from the record's emitted_at_ms (previously hard-coded zero — the field is now meaningful, not just stable).
recently_emitted is the ring buffer of actions reconcile has emitted but the executor hasn't acknowledged; bounded by action_queue_capacity. recent_failures collects entries from three sources: dispatcher errors (with retry_after_ms if any), admit-time gate trips (with cooldown_ms if any), and probe / dispatcher panic catches. MeshOsSnapshot::from_state(actual, desired, recently_emitted) builds the projection on demand; the loop publishes after every reconcile.
MeshOsSnapshotReader::read() clones the Arc<MeshOsSnapshot> under a single ArcSwap::load_full — no read lock, no contention with the publisher.
A MeshOsSnapshotFold (impl RedexFold<MeshOsSnapshot>) consumes ActionChainRecords and updates a per-node snapshot on chain replay:
pub struct ActionChainRecord {
pub id: u64,
pub kind: String,
pub emitted_at_ms: u64,
pub disposition: ActionDisposition,
}
pub enum ActionDisposition {
Dispatched,
Failed { reason: String, retry_after_ms: Option<u64> },
Gated { reason: String, cooldown_ms: Option<u64> },
}
Dispatched records leave the fold silent (the recently-emitted ring covers them); Failed and Gated records push FailureRecords onto recent_failures, bounded by RECENT_FAILURES_CAPACITY = 256. The record carries a one-byte wire-format version that the decoder checks before postcard dispatch; an older / newer record surfaces as DecodeError::UnsupportedVersion rather than garbled deserialization. BufferingActionChainAppender for tests is bounded with drop-oldest; NoOpActionChainAppender is the bootstrap default.
Deck queries the snapshot via MeshDB's MeshQuery::Latest against the snapshot chain — the federated executor routes to a node holding the fold; the result row carries the postcard-encoded snapshot. No new wire protocol, no Deck-specific RPC. The v0.16 federated query plane becomes the v0.17 observability surface.
Continuous-rebalance scheduler
The leader-driven scoring loop. For each chain where this node is the elected leader, score every holder via a pluggable PlacementScorer, pick the lowest, and emit RequestEviction when (worst score < score_floor) AND (best alternative > worst + hysteresis_gap) AND (cooldown elapsed):
pub trait PlacementScorer: Send + Sync + 'static {
fn score(&self, chain: ChainId, node: NodeId) -> Option<f32>;
fn best_alternative(&self, chain: ChainId, exclude: &[NodeId]) -> Option<(NodeId, f32)>;
}
pub struct SchedulerConfig {
pub score_floor: f32, // default 0.5
pub hysteresis_gap: f32, // default 0.2
pub cooldown: Duration, // default 5 min
}
The trait abstracts the substrate's PlacementFilter so production wires a PlacementFilter-backed impl and tests mock the score table. The eviction emission is idempotent across reconcile passes — a pending_evictions: HashSet<ChainId> written by the loop on each emission and cleared when the fold observes the holder count drop. Phase-C's existing diff observes the holder-count drop and refills via RequestPlacement on the next tick — two-stage rebalance with no new action variant. Per-chain MeshOsState::last_rebalance records the most recent eviction's Instant so the cooldown survives transient state and the same chain doesn't flap A→B→A within the window.
Scheduler emission sorts by chain id for byte-stable output across reconcile calls regardless of HashMap iteration. The scheduler arm short-circuits when Phase C's overcount diff already emitted an eviction for the same chain on the same tick — the two arms can no longer fragment the leader's view of the chain.
Single admit() backpressure layer
One function gates every outbound action:
pub enum AdmissionResult {
Admit,
Defer { retry_after: Duration },
Gate { cooldown_until: Instant, reason: &'static str },
}
impl BackpressureState {
pub fn admit(
&mut self,
action: &MeshOsAction,
now: Instant,
config: &BackpressureConfig,
) -> AdmissionResult { ... }
}
Throttles applied: global pull cooldown (default 250 ms), per-chain replica stabilization (default 60 s), per-daemon gate driven by BackoffTracker::release_at, drain rate-limit (default 10/sec/zone), cluster-wide hysteresis flag (default 1000 high / 200 low). Same admit() for every action variant — a drain-triggered migration cannot dodge the pull cooldown; a crash-looping daemon cannot dodge the gate just because its restart was admin-driven.
The dispatch retry path now routes through admit() rather than directly re-pushing onto the defer heap. BackpressureState::release_failed_admit(action, now) rolls back the per-action reservations (drain_window push, last_pull_admitted stamp, chain_stabilization window) when a dispatch error fires after admit returned Admit — counters no longer drift permanently after transient errors. The defer heap enforces max_defer_count (default 16) before dropping a poison-pill action with a FailureRecord.
ActionExecutor runs update_cluster_backpressure once per handle_one with the current queue depth and surfaces the returned ClusterBackpressureChange through the dispatcher's MeshOsControl::BackpressureOn { level } / BackpressureOff broadcast — the plan's promise is no longer dead code. Per-tick tick() GCs elapsed daemon gates + chain stabilization windows so the state stays bounded under churn.
Action executor
Drains the loop's Receiver<PendingAction>, runs each through admit(), dispatches via a pluggable ActionDispatcher, and records the outcome to the action chain:
pub trait ActionDispatcher: Send + Sync + 'static {
fn dispatch<'a>(
&'a self,
action: MeshOsAction,
) -> BoxFuture<'a, Result<(), DispatchError>>;
}
pub struct DispatchError {
pub reason: String,
pub retry_after: Option<Duration>,
}
LoggingDispatcher ships for bootstrap and tests — records every dispatch in an internal Mutex<Vec<MeshOsAction>> and supports fail_next(err) for exercising the failure / retry paths. Production dispatchers wrap the existing subsystems (DaemonRegistry for start/stop, the migration orchestrator for pull/drop/migrate, the admin chain commit path for CommitMaintenanceTransition).
The executor's with_chain_appender(...) builder installs an ActionChainAppender; the dispatcher, gate, and retry paths all append records via append_dispatched / append_failed / append_gated. The chain replay drives the snapshot's recent_failures ring buffer.
Probe + dispatcher panics are caught via std::panic::catch_unwind; the panic message rides into a FailureRecord and the executor / loop continues. Stats are exposed live via ExecutorHandle::stats() and on shutdown via RuntimeStats.executor.
MeshOsRuntime — one-call entry point
impl MeshOsRuntime {
pub fn start<D: ActionDispatcher>(config: MeshOsConfig, dispatcher: Arc<D>) -> Self;
pub fn start_with_probes<D: ActionDispatcher>(/* ... */) -> Self;
pub fn start_full<D: ActionDispatcher>(/* ... */) -> Self;
pub fn handle(&self) -> &MeshOsHandle;
pub fn handle_clone(&self) -> MeshOsHandle;
pub fn snapshot(&self) -> MeshOsSnapshot;
pub fn snapshot_reader(&self) -> &MeshOsSnapshotReader;
pub fn executor_stats(&self) -> ExecutorStatsSnapshot;
pub fn add_locality_probe(&self, probe: Arc<dyn LocalityProbe>);
pub fn add_health_probe(&self, probe: Arc<dyn HealthProbe>);
pub fn install_placement_scorer(&self, scorer: Arc<dyn PlacementScorer>);
pub fn register_daemon(&self, daemon: Box<dyn MeshDaemon>, keypair: EntityKeypair)
-> Result<DaemonHandle, RuntimeError>;
pub async fn shutdown(self) -> Result<RuntimeStats, RuntimeShutdownError>;
}
impl Drop for MeshOsRuntime {
fn drop(&mut self) { /* aborts loop + executor tasks, warns if shutdown wasn't called */ }
}
start(config, dispatcher) spawns the loop + executor as tokio tasks; the returned struct exposes the publish handle, snapshot reader, probe / scheduler registries, executor stats, and a graceful shutdown path. Source-converter helpers (attach_to_daemon_registry, attach_to_replication_coordinator) plug into the runtime's handle.
register_daemon(...) is the daemon-side path — implementors of the extended MeshDaemon trait register through the runtime rather than reaching into the underlying DaemonRegistry. The runtime's Drop impl aborts both tasks (loop + executor) and emits a tracing::warn when shutdown wasn't called explicitly — no more leaked tasks on accidental drop.
MeshOsHandle::publish_timeout(event, Duration) complements publish and try_publish for source converters that need timeout semantics without blocking. The module-level example uses try_publish per the new doc-comment guidance.
SDK plan
The MeshOS SDK plan covering Rust / Python / Node / Go / C ships as a design document at docs/plans/MESHOS_SDK_PLAN.md. The Rust SDK is the canonical surface — MeshOsDaemonHandle + daemon_main! macro + integration tests against MeshOsRuntime with LoggingDispatcher. Python (pyo3, sync-first), Node (napi-rs, AsyncIterable control events), Go (cgo + context.Context-aware control channels), and C (vtable + last-error surface mirroring MeshDB's FFI pattern) land in dependency order per consumer demand. A new sdk workspace member at crates/net/sdk/ opens the slot.
The plan locks in ten decisions, most importantly the non-goals: no placement APIs in any binding, no admin-event issuance, no MeshOS-control surfaces. The SDK is the daemon contract, exposed in five languages. Operator tooling, federated interactions, and MeshDB queries belong to separate SDKs.
Toolchain + dependency upgrades
No new dependencies. The arc-swap = "1.7.1" already in the workspace gets a new consumer (MeshOsSnapshot publish path). The tracing = "0.1" workspace dep gets a new consumer (every meshos::* module emits debug! / warn! / error! events at lifecycle and failure boundaries). The crate version moves from 0.16.x to 0.17.0; the workspace gains the sdk member at crates/net/sdk/.
The meshos Cargo feature gates the entire surface. It pulls in cortex (which pulls in redex); the substrate builds clean without --features meshos and the meshos cdylib path is purely additive.
Test hygiene
- Lib suite at 2715+ tests (was 2645+ at v0.16 release). 200+ net new tests across the MeshOS surface + cross-cutting fixes; every numbered review item from both hardening passes ships with at least one regression where the shape made one possible. Notable additions:
- Reconcile + scheduler:
reconcile::scheduler_eviction_is_idempotent_when_loop_writes_back_last_rebalance,reconcile::phase_c_overcount_eviction_suppresses_phase_d1_eviction_for_same_chain,reconcile::apply_backoff_is_not_re_emitted_after_the_loop_records_it, the 13-test scheduler reconcile arm covering leader-only gating + hysteresis + cooldown + worst-victim selection + chain-id-sorted emission. - Backpressure + executor:
BackpressureState::release_failed_admit_*(3 cases — pull cooldown, drain window, chain stabilization),executor::cluster_backpressure_edges_surface_through_dispatcher_hook,executor::dispatch_failure_with_retry_releases_pull_cooldown,executor::dispatcher_panic_does_not_kill_executor,executor::dispatch_retry_drops_after_exceeding_max_defer_count. - Event loop:
event_loop::snapshot_reader_does_not_stall_under_concurrent_reads,event_loop::dropped_actions_counter_increments_when_action_queue_is_full,event_loop::panicking_probe_does_not_kill_the_loop,event_loop::publish_timeout_returns_queue_full_when_loop_is_wedged,event_loop::shutdown_event_short_circuits_pending_events_after_it(re-pinned with actual assertions). - State + maintenance:
state::enter_maintenance_since_is_anchored_on_last_tick_for_replay_determinism, theMaintenanceStateround-trip tests includingDrainFailed { reason }, theMaintenanceTransitionObservedgated-state-advance tests. - Runtime + chain:
runtime::dropping_runtime_without_shutdown_aborts_tasks,runtime::register_daemon_round_trip_through_executor,chain::buffering_appender_drops_oldest_when_at_capacity,chain::decode_rejects_payload_with_unknown_wire_version,chain::decode_rejects_empty_payload,chain::encode_decode_round_trip_preserves_record, the end-to-end executor → buffering appender → fold → snapshot test. - Snapshot + sources:
snapshot::failure_record_age_ms_derives_from_recorded_at_ms,sources::leader_lost_event_clears_replica_leader_via_none_update.
- Reconcile + scheduler:
cargo clippy --features meshos --all-targets -D warningsclean across substrate + every binding crate.cargo doc --features meshos --no-depsclean underRUSTDOCFLAGS="-D warnings"— every public item in themeshossurface carries a doc comment; intra-doc links resolve through the public re-exports.- 172 meshos unit tests + 11 pipeline integration tests + 13 daemon-registry + 9 daemon-trait + 15 replication-coordinator tests all pass.
Breaking changes
API — MeshOS surface is new
MeshOsLoop + MeshOsRuntime + MeshOsHandle + MeshOsSnapshot + MeshOsSnapshotReader + MeshOsState + DesiredState + MeshOsConfig + MeshOsEvent + MeshOsAction + MeshOsControl + ProbeRegistry + SchedulerRegistry + ActionDispatcher + ActionExecutor + ActionChainAppender + every operator family are all new in v0.17. Behind the meshos Cargo feature; non-meshos builds see the substrate path unchanged.
MeshDaemon trait gains three optional methods
health() / saturation() / on_control(DaemonControl) land on the trait itself (not feature-gated) with default impls. Every existing daemon compiles unchanged. DaemonHealth and DaemonControl are new public types in compute::daemon; the latter is the WASM-friendly relative-ms form daemons receive.
DaemonRegistry gains a lifecycle observer
DaemonRegistry::set_lifecycle_observer(Option<Arc<dyn DaemonLifecycleObserver>>) is new. The hot path is unaffected when no observer is installed (one RwLock<Option<Arc>> read + is_none check). The unregister path uses try_lock against the inner Mutex to avoid a deadlock when called from inside a with_host closure on the same id; observers see an empty name on that path and correlate by id with the prior Registered event.
ReplicationCoordinator gains a transition observer
ReplicationCoordinator::set_transition_observer(Option<Arc<dyn ReplicaTransitionObserver>>) is new. BecameHolder / Idled / LeaderChanged / LeaderLost events fire from the successful path of transition_to after the chain-tag side effect lands.
Workspace — new sdk member
crates/net/sdk/ is a new workspace member. The slot opens for the Rust MeshOS SDK; the directory is empty in this release and populates once the SDK plan's Phase 1 lands.
Behavioral fixes that may surface as test breakage
MissedTickBehavior::DelayreplacesSkipon the loop's heartbeat timer. Tests that asserted skipped ticks under load will see delayed ticks instead.MeshOsRuntime::dropaborts both tasks. Tests that relied on the loop / executor running past a dropped runtime will see the tasks aborted.MeshOsHandle::publishis still async-blocking on a full queue; tests that hung previously now havepublish_timeout(event, Duration)available as a non-blocking-on-deadline alternative.- Probe panics no longer kill the loop. Tests that asserted
JoinError::Panicpropagation through the loop task will see the probe's panic surface inrecent_failuresinstead.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.17 line. Recompile / rebuild the binding cdylib with themeshosCargo feature on when you want the MeshOS surface; without it, the substrate is unchanged from v0.16. - MeshOS opt-in. Channels that want the cluster-behavior engine: build the substrate with
--features meshosand callMeshOsRuntime::start(MeshOsConfig::default(), dispatcher)wheredispatcherwiresMeshOsActionvariants to the existing subsystems (DaemonRegistryforStartDaemon/StopDaemon, the migration orchestrator forPullReplica/DropReplica, the admin-chain commit path forCommitMaintenanceTransition). - Source converters. Attach the daemon-registry sink via
attach_to_daemon_registry(®istry, runtime.handle_clone()). Attach a per-coordinator replica sink viaattach_to_replication_coordinator(&coord, runtime.handle_clone(), this_node). Install proximity probes viaruntime.add_locality_probe(...)andruntime.add_health_probe(...)against a sharedArc<ProximityGraph>. - Placement scorer. Install a
PlacementScorerimpl viaruntime.install_placement_scorer(scorer). The substrate ships the trait + scheduler arm; the impl wires toPlacementFilterper consumer. - Action chain. Install an
ActionChainAppenderon the executor (production: writes to a RedEX chain that theMeshOsSnapshotFoldconsumes on every node). The defaultNoOpActionChainAppendermakes the chain optional; Deck integration drives the wiring. - Daemon trait additions. If you implement
MeshDaemonand want supervision participation: overridehealth()/saturation()/on_control(DaemonControl). Defaults preserve compatibility; overrides opt into graceful shutdown, drain coordination, and cluster-wide backpressure. - Shutdown. Always call
runtime.shutdown().awaitrather than dropping the runtime. The newDropimpl aborts the tasks and warns, but an explicit shutdown is the contract for clean lifecycle. - Snapshot consumers. Read the snapshot via
runtime.snapshot()(cheap — oneArcSwap::load_full) or sample executor stats viaruntime.executor_stats(). Deck queries arrive through MeshDB once the snapshot chain is wired.
Named after Survivor's 1982 Rocky III anthem — a release that asks the substrate to see, after Rebel Yell asked it to hold. v0.15 made the Dataforts data plane stand up — content-addressed blobs, heat-driven gravity, read-your-writes. v0.16 stacks the MeshDB query plane on top: a federated AST + planner + executor that composes against the existing capability index, proximity graph, and causal: / fork-of: tag layer the Warriors substrate ships. No new substrate primitive — every operator rides what was already there. The meshdb Cargo feature gates whether the surface compiles at all; the substrate path is unchanged on non-meshdb builds.
MeshDB
MeshDB in Net is the query layer that grows on top of the substrate, and v0.16 is where it lands. Every prior approach to "query the cluster" presupposes a homogeneous shape — a SQL warehouse holds rows in tables, a graph database holds nodes in indexes, a search engine holds documents in shards. There is a query language, and there is data, and the language is shaped to the data. MeshDB inverts the relation. The data is causal chains of events across nodes; the query language composes operators against those chains; the capability index is the planner, the proximity graph is the cost model, the local RedEX file is the storage engine. There is no central catalog. There is no schema service. There is no shuffle plan.
A query in MeshDB is a tree of operators that traverse three axes the substrate already exposes — time (a chain's history at a specific seq, or across a seq range), lineage (the fork-of: graph back to a common ancestor, sibling chains, descendant cohorts), and chains (joins across causally-related but distinct chains, aggregates folded across them). The planner reads the capability index to discover which nodes hold which chains, walks the proximity graph to pick the cheapest holder, and emits an execution plan whose root operator is the data and whose leaves are remote sub-queries. Atomic operators (At / Between / Latest) read events from the substrate; composite operators (Join / Filter / Aggregate / Window / LineageEmit) compose against atomic results without owning state of their own. The runtime is per-node; the plan is per-query; the substrate is unchanged.
The same primitives that let The Warriors find a chain's holders let MeshDB find a chain's history. The same fork-of: propagation that lets Distributed RedEX replicate a chain forward lets MeshDB walk a chain's parents backward. The same PredicateWire that the Capability System uses to filter peer capabilities lets MeshDB filter rows. Hash-joins and sort-merge joins, exact-min / exact-max / exact-distinct-count / nearest-rank percentile aggregates, tumbling windows on seq, and a single-node LRU result cache all compose without a new wire protocol — every operator either rides the existing capability index, the existing RedEX read path, or a new SUBPROTOCOL_MESHDB envelope between federated executors. Plans are byte-deterministic; cache keys are content-addressed off the plan; cache invalidation is pull-based against a global CapabilityIndex mutation counter that bumps on every announcement / removal / GC sweep.
Federated execution arrives in code with the substrate. The FederatedMeshQueryExecutor fans atomic operators out to remote target_nodes via a pluggable MeshDbTransport; the LoopbackTransport drives three-node integration tests in-process. The wire-side hookup that registers the new SUBPROTOCOL_MESHDB = 0x0F00 on MeshNode's subprotocol dispatcher is the one piece that stays parked for a consumer to drive — the envelope shapes, the cancellation model, and the cross-node call multiplexing all ship in v0.16. The same model lifts MeshDB out of test-only loopback the moment a real subprotocol consumer (Hermes telemetry replay; Deck cross-rack metrics; AI fine-tuning across forked experiments) wires the dispatch.
The bindings ship in lockstep. Python, Node, Go, and C SDKs all expose the full operator surface — MeshQuery.at(...) through MeshQuery.join(...), the typed Predicate builder for filters, the fluent QueryBuilder for chained pipelines, the CachePolicy { Permanent | TimeBound { ttl } } knobs — plus a sentinel-envelope decoder that turns aggregate / joined / window result rows into host-language objects. Errors carry a structured kind discriminator (planner_error, executor_error, join_memory_exceeded, ambiguous_discovery, query_cancelled, runtime_panic, …) so callers can branch without parsing message strings. The substrate's MeshError is the single source of truth; every binding reflects it.
There is no separate query service to provision. There is no catalog to maintain. The query plan is on the mesh because the substrate is the database.
v0.16 lands the full MeshDB substrate behind the meshdb Cargo feature — AST + planner, local + federated executors, lineage walks via the fork-of: graph, hash + sort-merge joins (row-intrinsic + payload-keyed, all four JoinKinds), Count / Sum / Avg / Min / Max / DistinctCountExact / PercentileExact aggregates, Filter via synthetic-tag PredicateWire evaluation, tumbling-on-seq windowing, and the single-node LRU result cache with pull-based capability-version invalidation are all in code. The wire-subprotocol hookup that registers SUBPROTOCOL_MESHDB on MeshNode's dispatcher waits for a consumer to drive — the envelope shapes ship and the FederatedMeshQueryExecutor already speaks the protocol against a LoopbackTransport in three-node in-process integration tests today. The full surface ships across Rust core and Python / Node / Go / C SDKs.
The hardening posture from the Black Diamond / Rebel Yell line continues. Two coordinated code-review passes landed before the v0.16 branch cut, surfacing 52 items total — 9 Blockers, 19 Majors, 20 Minors, 4 Nits. Every Blocker and Major closed in-tree with regression tests; two Majors deferred with rationale (the deferred items need SDK surfaces — FederatedMeshQueryExecutor exposure, configurable budgets, Discovered resolution — that ship with their respective future slices). The four Minor deferrals all closed post-pass: substrate-side join-watermark clamp helper with f64-input tests pins the contract the Python test_join_accepts_watermark_secs_kwarg couldn't observe; substrate Unicode / singleton-aggregate / long-lineage test-gap fillers land; the Arc<LocalMeshQueryExecutor> indirection is dropped from all three runners; LineageEntry.depth is BigInt in the Node SDK for shape parity.
Alongside MeshDB, v0.16 carries a substrate-level routed-handshake replay-guard fix that was masking as a flaky NAT-traversal test. The guard previously refused any legitimate re-handshake from a peer with the same Noise static, indistinguishable from a passive attacker replaying captured msg1 bytes. The fix tracks the initiator's Noise ephemeral (in the clear at the front of NKpsk0 msg1) and only refuses replays that match BOTH static and ephemeral — a fresh ephemeral can only be produced by the static + PSK holder, per the Noise threat model. Plus a Duration::MAX-sentinel handling fix in the periodic sweep loops (spawn_token_sweep_loop, spawn_capability_gc_loop) that previously panicked on Instant-overflow when the documented "disable the sweep" sentinel was used.
The toolchain moves forward: Go 1.26, CI reads the Go version from go/go.mod (no more divergence between the local toolchain and the CI matrix), and the cross-binding cgo integration test creates responder / initiator nodes in parallel — eliminating the pre-fix handshake deadlock that randomly flaked the suite. Dependency bumps land cleanly: ctor 0.11.1 → 1.0.5, napi 3.8.6 → 3.9.0, napi-build 2.3.1 → 2.3.2, napi-derive 3.5.5 → 3.5.6.
MeshQuery AST + planner
The composable query language and the planner that translates queries into typed ExecutionPlans. Lives in src/adapter/net/behavior/meshdb/{query,planner,error}.rs.
MeshQuery versioned outer enum
pub enum MeshQuery {
V1(QueryV1),
}
pub enum QueryV1 {
At { origin: ChainRef, seq: SeqNum },
Between { origin: ChainRef, start: SeqNum, end: SeqNum },
Latest { origin: ChainRef },
LineageBack { origin: ChainRef, max_depth: u32 },
LineageForward { origin: ChainRef, max_depth: u32 },
Join { left: Box<MeshQuery>, right: Box<MeshQuery>,
on: JoinKey, kind: JoinKind,
strategy: JoinStrategy, watermark_secs: f64 },
Filter { inner: Box<MeshQuery>, predicate: PredicateWire },
Aggregate { inner: Box<MeshQuery>, group_by: Vec<Expr>,
agg_fn: AggregateFn },
Window { inner: Box<MeshQuery>, spec: WindowSpec },
Project { inner: Box<MeshQuery>, columns: Vec<Expr> },
OrderBy { inner: Box<MeshQuery>, by: Vec<Expr>, limit: Option<u32> },
}
The MeshQuery::V1(...) wrapper is the stability hatch — postcard + JSON round-trip carries the version tag at the front of every wire encoding, so a v2 AST can land alongside without breaking on-disk plans. ChainRef separates direct origin-hash references (OriginHash(u64)) from capability-predicate references (Discovered(PredicateWire)); the planner resolves Discovered against the capability index at plan time and surfaces a typed MeshError::AmbiguousDiscovery { matches } when multiple origins match (deferring multi-origin fan-out until a future slice ships it explicitly, rather than silently truncating to the first match).
MeshQueryPlanner
impl<'a, F: Fn(NodeId) -> Option<Duration>> MeshQueryPlanner<'a, F> {
pub fn new(index: &'a CapabilityIndex, rtt_lookup: F) -> Self { ... }
pub fn plan(&self, q: &MeshQuery) -> Result<ExecutionPlan, MeshError> { ... }
}
Translates atomic operators to typed ExecutionPlans with proximity-ordered target_nodes (RTT-asc, lex-NodeId tiebreak). Composite operators wrap their planned children in NotYetImplemented placeholders so the tree still type-checks for variants outside this release's executor coverage (Project, OrderBy).
Plans are byte-deterministic. Two non-determinism leaks the review closed in this release: (1) caps.tags is a HashSet whose iteration order is RNG-stable across a single process but not across runs, so parent_of / children_of / collect_coverage collect every candidate, sort numerically, and pick the smallest; (2) CapabilityIndex::all_nodes iterates a DashMap whose order is unstable, so cross-replica fork-of selection now collects across all hosting nodes before picking. The cache key is content-addressed off the plan, so byte determinism is load-bearing for cache hit rate.
Time-travel + federated execution
🚧 Wire-side subprotocol dispatch hookup outstanding. Substrate complete; the envelope shapes, the cancellation model, and a LoopbackTransport-driven three-node integration test all ship — the one piece that waits for a consumer is MeshNode::register_subprotocol_handler(SUBPROTOCOL_MESHDB, ...).
MeshQueryExecutor async trait + LocalMeshQueryExecutor
#[async_trait]
pub trait MeshQueryExecutor: Send + Sync {
async fn execute(&self, plan: ExecutionPlan)
-> Result<RunningQuery, MeshError>;
async fn execute_with(&self, plan: ExecutionPlan, options: ExecuteOptions)
-> Result<RunningQuery, MeshError>;
}
pub struct RunningQuery {
pub handle: QueryHandle, // cooperative cancellation
pub rows: ResultStream, // Box::pin(Stream<Item = Result<ResultRow>>)
}
LocalMeshQueryExecutor<R: ChainReader> walks atomic plans against a pluggable ChainReader (in-memory store for tests; the integration layer wires it to RedEX). Cancellation flows via QueryHandle::cancel which flips an Arc<AtomicBool> checked at every row boundary.
Replica-aware routing — CausalClaim parsing
Three causal: tag forms get parsed into typed coverage claims: causal:<hex> (Presence — no range, permissive fallback), causal:<hex>:<tip_seq> (Tip — covers [0, tip_seq + 1)), causal:<hex>[start..end] (Range — covers [start, end)). The planner picks the most-specific-claim winner per holder (Range > Tip > Presence) with a deterministic tie-break key, then filters holders by covers_seq / covers_range. HistoricalRangeUnavailable carries per-replica available-range hints so callers can negotiate.
Wire protocol envelopes
pub const SUBPROTOCOL_MESHDB: u16 = 0x0F00;
pub enum MeshDbRequest {
Execute { call_id: u64, plan: ExecutionPlan },
Resume { call_id: u64, token: ContinuationToken },
Cancel { call_id: u64 },
}
pub enum MeshDbResponse {
Batch { call_id: u64, batch: ResultBatch },
End { call_id: u64 },
Error { call_id: u64, error: MeshError },
}
Envelopes are defined and round-trip cleanly; MeshNode::register_subprotocol_handler(SUBPROTOCOL_MESHDB, ...) is the one piece that ships unwired until a consumer drives it. Substrate-side FederatedMeshQueryExecutor<T: MeshDbTransport> already speaks this protocol against LoopbackTransport in three-node in-process integration tests.
FederatedMeshQueryExecutor + LoopbackTransport
Fans atomic operators out to their proximity-ordered target_nodes over MeshDbTransport. On TransportError::NoRoute(target) the executor falls through to the next target; any other transport error bubbles up inside MeshError::ExecutorError. Composite operators (HashJoin / Aggregate* / Window / Filter) recurse on the federated executor so atomic leaves still dispatch via the transport.
Cancellation correctness. Pre-fix, each recursive execute_uncached allocated a fresh QueryHandle; the outer running.handle.cancel() was a no-op against the materialized futures::stream::iter(out) output of composite operators. Post-fix, one outer handle is allocated in execute_with and threaded through execute_uncached_with_handle into every recursive sub-fetch, and a stream_results_cancellable adapter re-checks the cancel flag per emitted row.
Call-ID uniqueness. The wire contract says call_id is "unique per (caller, executor) pair while in-flight". Pre-fix, each FederatedMeshQueryExecutor drew IDs from its own AtomicU64, so two federated executors on the same caller could collide at a shared remote demultiplexer. Post-fix, a process-global FEDERATED_CALL_ID_COUNTER trivially satisfies the contract.
Replay-guard fix in the mesh's routed-handshake path
Hardening surfaced a routed-handshake replay guard that flagged any legitimate re-handshake from a peer with the same Noise static as a passive replay attack — connect_direct(peer, via = X) against an existing session via R would time out at B's side because B refused the new handshake. The fix tracks the initiator's Noise ephemeral (in the clear at the front of NKpsk0 msg1 by Noise pattern) and only DropReplays when BOTH the static AND the ephemeral match. A fresh ephemeral can only be produced by the static + PSK holder (the legitimate peer); a captured-and-replayed msg1 has the original ephemeral verbatim.
struct PeerInfo {
node_id: u64,
addr: SocketAddr,
session: Arc<NetSession>,
remote_static_pub: [u8; 32],
last_initiator_ephemeral: Option<[u8; 32]>, // new
}
fn routed_rotation_outcome(
existing: &PeerInfo,
new_static: &[u8; 32],
new_ephemeral: &[u8; 32],
session_timeout: Duration,
) -> RoutedRotationOutcome {
if existing.remote_static_pub == *new_static {
if existing.last_initiator_ephemeral.as_ref() == Some(new_ephemeral) {
return RoutedRotationOutcome::DropReplay;
}
return RoutedRotationOutcome::AcceptRotation;
}
if existing.session.is_timed_out(session_timeout) {
RoutedRotationOutcome::AcceptRotation
} else {
RoutedRotationOutcome::RefuseFresh
}
}
Lineage walks via fork-of: graph
OperatorPlan::LineageEmit { origin, direction, entries } carries a materialized walk result. The planner walks the local capability-index snapshot at plan time — parent_of for back, BFS children_of lex-sorted for forward, both deterministic across runs. Cycle detection ships as explicit visited-set guards (MeshError::LineageCycleDetected { origin, cycle } with the path through the cycle for debugging). Depth bounds surface as MeshError::LineageMaxDepthExceeded { origin, depth }.
The executor emits one ResultRow per entry — payload empty, origin = entry.origin, seq = entry.tip_seq.unwrap_or(SeqNum(0)). Callers compose with At / Between to fetch event content for each ancestor / descendant. The federated executor handles LineageEmit locally (no remote dispatch needed; the walk already happened at plan time).
max_depth = 0 is correctly handled as "just-the-origin", not as a bound violation. Both walks previously surfaced LineageMaxDepthExceeded whenever the start origin had any unvisited neighbour, even when the caller explicitly asked for zero steps.
Cross-chain joins
Inner hash-join on row-intrinsic keys
OperatorPlan::HashJoin { left, right, key_mode, kind, strategy, watermark } with JoinKeyMode::{Origin, Seq, OriginSeq} for the row-intrinsic join-key extraction modes. Both local and federated executors implement build-on-left / probe-on-right; the federated path recurses through itself so atomic leaves still dispatch via the transport. Joined rows are sentinel ResultRows (origin = 0, seq = 0) whose payload is a postcard-encoded JoinedRowPayload { left, right }. MeshError::JoinMemoryExceeded surfaces at the 256-MiB build-side bound.
Outer joins + sort-merge + payload-keyed
All four JoinKinds ship: Inner / LeftOuter / RightOuter / FullOuter. JoinKeyMode::Field(String) extends the join-key surface to JSON payload paths via row::extract_string_projection; try_encode_join_key returns Option<Vec<u8>> so rows whose key field can't be resolved are silently dropped from both sides. JoinStrategy::{HashBroadcast, SortMerge} lets the planner pick between in-memory hashing (default; trips JoinMemoryExceeded past the bound) and sort-merge (sort both sides + two-pointer walk; memory-bounded by the inputs).
The three-way duplicated hash-join body (local one-sided + local full-outer + federated mirror) factored into a shared build_hash_join_table(rows, key_mode, strategy_label) -> Result<HashJoinTable, MeshError> helper. try_encode_join_key canonicalizes JoinKeyMode::Field("origin"|"seq"|"origin,seq") to the matching row-intrinsic encoding so probe tables built under Origin and Field("origin") cross-correlate.
Watermark is informational under snapshot semantics; streaming activation needs a future windowed-join slice. The default is 5 s.
Filter, aggregates, and tumbling windows
Count
OperatorPlan::AggregateCount { input, group_by } over row-intrinsic group keys (Origin, Seq, OriginSeq). Sentinel ResultRow per group with a postcard-encoded AggregateRowPayload { group, value: Count(u64) }.
Filter
Reuses the Capability System's PredicateWire. Every ResultRow projects to a synthetic (Vec<Tag>, BTreeMap) view via row::synthetic_row_view — dataforts.origin, dataforts.seq, plus flat JSON-object payload fields. Non-JSON payloads are opaque; predicates against missing fields simply don't match.
The FFI's JSON predicate parser bounds caller-supplied recursion at 64 deep (PREDICATE_PARSE_MAX_DEPTH); the substrate's Predicate::to_wire converts from recursion to a heap-allocated work stack so 10k+-deep typed predicates from Python / Node factories don't overflow the Rust thread stack on every execute.
Sum / Avg
OperatorPlan::AggregateNumeric { input, group_by, field_path, kind: Sum | Avg } over row::extract_numeric (JSON path → f64). Rows whose field fails to resolve are skipped; Avg(None) covers the empty-group case.
Min / Max / DistinctCountExact / PercentileExact
OperatorPlan::AggregateReduction { kind: Min | Max | Percentile { p } } over f64::total_cmp (so NaN ordering is well-defined) + OperatorPlan::AggregateDistinct { field_path } (canonical-string projection into a per-group BTreeSet). Nearest-rank percentile. The HLL p=14 / T-Digest c=100 sketch variants (DistinctCountHll, PercentileTDigest) remain PlannerError until a consumer drives the algorithmic complexity; the exact variants are the recommended path today.
Tumbling-on-seq windows
QueryV1::Window { inner, spec: WindowSpec::TumblingSeq { size } } buckets rows into fixed-size half-open intervals on seq; the executor emits one sentinel ResultRow per non-empty bucket with a postcard-encoded WindowBoundary { start, end, rows }. Sliding + session windows extend cleanly via additional WindowSpec variants when a consumer drives the shape.
Result cache
CachePolicy + ExecuteOptions
pub enum CachePolicy {
Permanent, // hold until LRU eviction
TimeBound { ttl: Duration }, // TTL-bounded; default 5 s
}
pub struct ExecuteOptions {
pub bypass_cache: bool, // skip both lookup AND writeback
pub cache_policy: CachePolicy,
}
TimeBound { ttl: 5s } is the default policy (mirroring the join watermark). Permanent is the explicit-opt-in for queries over closed substrate ranges (At, bounded Between with end ≤ current_tip). bypass_cache skips both lookup and writeback (Deck operator-view authoritative reads; Hermes skill-routing under churn; diagnostics).
Global cache version, pull-based invalidation
CapabilityIndex carries an AtomicU64 mutation_version that bumps on every index / remove / gc mutation. The MeshDB cache key encodes the live version into CacheKey { plan_hash: u64, capability_version: u64 }; any divergence misses. Aggressive invalidation by design — softening it is not the answer to churn, the bypass_cache flag and the Permanent policy together cover the cases where staleness is preferable.
CacheKey::for_plan is encode-failure-safe
impl CacheKey {
pub fn for_plan(plan: &ExecutionPlan, capability_version: u64) -> Option<Self>;
}
Returns None when the plan can't be postcard-encoded (currently: any plan variant carrying a PredicateWire, because PredicateNodeWire uses #[serde(tag = "kind")] which postcard rejects on decode). Cache call sites treat None as a transparent bypass rather than a panic — defence-in-depth against future plan variants that become un-encodable.
Hand-rolled LRU
HashMap<CacheKey, Node> + intrusive doubly-linked list over a Vec<Node>. Defaults: LRU_MAX_ENTRIES = 1024, LRU_MAX_BYTES = 256 MiB; either bound trips eviction of the LRU end. DefaultHasher over postcard-encoded plan bytes; no new external dependency.
insert of an oversized result (approx_bytes() > max_bytes) refuses up-front instead of inserting at head and immediately evicting itself from the tail. Pre-fix, a Permanent-policy cache call for an oversized result silently re-ran the plan on every subsequent execute; post-fix the no-op insert leaves the cache entry-count + byte-count untouched and the prior entry at the same key (if any) survives.
Top-level only — sub-plan executes inside the federated path bypass the cache. Recursive caching at HashJoin sides / Aggregate inner is a follow-up if profiling justifies the bookkeeping.
SDK shims — Python / Node / Go / C
Every binding ships the full operator surface in lockstep: atomic factories (at / between / latest), composite factories (window / count / numeric_agg / percentile / join / filter / lineage_emit), the typed Predicate builder, the fluent QueryBuilder, the cache options, and a sentinel-envelope decoder that turns aggregate / joined / window result rows into host-language objects. The substrate's MeshError reflects through every shim with a structured kind discriminator.
Python — pyo3 + maturin
MeshQuery / MeshQueryRunner / ResultRow / Predicate / QueryBuilder ship as #[pyclass] types in the _net extension module, re-exported from the net Python package behind the dataforts / meshdb extras. The sync MeshQueryRunner.execute(query, options) returns list[ResultRow]; aggregate / joined / window payloads decode via ResultRow.decode_aggregate() / decode_joined() / decode_window().
MeshDbError carries a structured kind attribute set via PyO3 setattr on the raised instance — callers branch on except MeshDbError as e: if e.kind == "join_memory_exceeded": ....
Node — napi-rs
MeshQuery / MeshQueryRunner / MeshQueryStream / ResultRow / Predicate ship through napi-rs 3.9. runner.execute(query, options) returns a Promise<MeshQueryStream>; the TS shim at bindings/node/meshdb.ts attaches Symbol.asyncIterator so for await (const row of stream) works.
The AsyncIterable shim defines return() and throw() hooks that call MeshQueryStream::release() on a break / exception unwind, freeing the backing Vec<ResultRow> immediately rather than pinning it on the AsyncMutex until JS GC fires.
Node errors embed the kind discriminator in the reason string via a <<meshdb-kind:KIND>>MSG prefix; the SDK ships parseMeshDbErrorKind(err) -> { kind, message } | null to decode it.
Go — cgo + reference SDK contract
net-meshdb-ffi is a cdylib exporting the C ABI (net_meshdb_* symbols); the Go-side reference contract at bindings/go/net/meshdb.go wraps it in a cgo-importing package with MeshDBReader / MeshDBQuery / MeshDBRunner / MeshDBQueryStream / MeshDBPredicate types. Execute returns a <-chan MeshDBResult; the fluent MeshDBQueryBuilder chains source / filter / aggregate / window / join steps.
Hardening closed for the Go SDK and the underlying FFI cdylib:
- Safe
size_t → intpayload conversion viaunsafe.Slice+bytes.Clone— refuses payloads abovemath.MaxIntwithErrMeshDBRuntimerather than lettingC.GoBytes'sC.intcast silently truncate. ExecuteContext/ExecuteWithContextrun the FFI execute call inside the spawned goroutine; the caller is never blocked on cgo, andctx.Done()races the executor concurrently with row pumping.- An
ffi_guard!macro wraps every FFI entry point incatch_unwind; panics across the C ABI becomenull_mut()returns with kindruntime_panicpopulated on the thread-local last-error pair. - Every factory validation null-return populates
net_meshdb_last_error_message/_kindwith a descriptiveinvalid_argmessage; Go-sidewrapMeshDBError(sentinel)reads both into aMeshDBErrorthat wrapsErrMeshDBInvalidArg/ErrMeshDBRuntimeforerrors.Isrouting. MeshDBQueryBuildersource-resets (.At/.Between/.Latest) preserve the accumulatedb.errso Build still surfaces the first error in the chain; deterministically free the prior*MeshDBQueryhandle in place; aliasing semantics documented explicitly.
C — libnet_meshdb cdylib + net_meshdb.h
The C header at include/net_meshdb.h documents every entry point: opaque handles (MeshDbReader / MeshDbQuery / MeshDbRunner / MeshDbIter), atomic + composite factories, runner + execute, the sentinel-envelope decoder, and the per-thread last-error trio (net_meshdb_last_error_message / _kind / _clear_last_error). A runnable example at examples/meshdb.c walks the canonical lifecycle — reader populate → atomic / composite / lineage query → execute → drain — plus a fourth section exercising the cached runner under NET_MESHDB_CACHE_PERMANENT.
runner_new / runner_new_cached / runner_execute / runner_execute_with take their borrowed handles by const T* for C++ const-correctness; Rust FFI signatures match (*const T).
Hardening — MeshDB code review
Two coordinated review passes landed before the v0.16 branch cut. The first pass surfaced 32 items (6 Blockers, 10 Majors, 12 Minors, 4 Nits); the second pass verified those closures and surfaced 20 new items (3 Blockers, 9 Majors, 8 Minors). Every Blocker and Major closed in-tree with regression tests; two Majors and four Minors deferred with rationale (the deferred items need SDK surfaces — FederatedMeshQueryExecutor exposure, configurable budgets, Discovered resolution — that ship with future slices).
Blockers (9, all closed)
CacheKey::for_plannow returnsOption<CacheKey>. Defence-in-depth against future un-encodable plans; pinned with a regression test verifying current Filter plans still encode.- Federated
handle.cancel()no longer no-ops on composite-operator output streams. The outer handle is threaded through every recursive sub-fetch and the materialized output wraps in a cancel-aware adapter. - Go FFI reader / runner lifetime contract documented. Snapshot-then-free vs keep-alive, never free-then-append.
- Every Go FFI execute path traps panics via
catch_unwind. The structuredMeshError(display + kind) flows through a thread-localLAST_ERROR_*and three getters. - Go SDK
ExecuteContext/ExecuteWithContexttakecontext.Context. Pumping goroutineselects onctx.Done()per send. Drop-the-channel-to-cancel was a documented lie. MeshDBQueryBuildersource-resets free the prior*MeshDBQueryhandle deterministically.- Go SDK
pumpIterRowsContextno longer truncatessize_tpayloads toC.int.unsafe.Slice+bytes.Clone+ amath.MaxIntguard surfacesErrMeshDBRuntimeon oversized payloads rather than lettingC.GoBytessilently sign-flip. ExecuteContextruns the FFI execute inside the spawned goroutine. Pre-fix it ran on the caller's stack before the pump goroutine spawned, soctx.Done()was ignored until the executor returned.- Every FFI entry point (not just the two
runner_execute*paths) wraps incatch_unwindvia a newffi_guard!($default, { ... })macro. Panics becomenull_mut()/NET_MESHDB_RUNTIME_ERRwith kindruntime_panicpopulated.
Majors (19 — 13 closed in code, 6 deferred with rationale)
Closed:
- Planner non-determinism via
HashSet<Tag>iteration.parent_of/children_of/collect_coveragecollect every candidate, sort, and pick the smallest with a deterministic tie-break key. Discoveredresolution surfacesMeshError::AmbiguousDiscovery { matches }when multiple origins match, rather than silently truncating to the first.call_iduniqueness — process-globalFEDERATED_CALL_ID_COUNTERreplaces the per-executor counter.- AST drift across FFI shims —
"origin,seq"canonicalized as the single accepted join-key separator across Python / Node / Go. - Structured error
kinddiscriminator onMeshError; surfaced through every binding. - Node cache-policy factory validation brought to parity with Python / Go (reject non-finite / negative
ttlSecondsat construction). - Watermark API parity on Python's
MeshQuery.join(...)(already shipped; pinned with a regression test). - BFS in lineage walks uses
VecDeque::pop_frontand cacheschildren_of. - Go SDK wraps every non-OK FFI return with
MeshDBError { Sentinel, Kind, Message }that reads the thread-local last-error pair. - Lineage walks accept
max_depth = 0as "just-the-origin"; previously a present parent / child trippedLineageMaxDepthExceeded. parent_ofcollects across all replica hosts before picking the lex-smallest parent. Pre-fix the outer DashMap iteration short-circuited on the first hosting node, drifting the plan + cache key across runs.LruResultCache::insertof an oversized result refuses up-front instead of silently evicting itself.- JSON predicate parsing bounds depth at 64;
Predicate::to_wireconverts to an iterative heap-allocated work stack. - Every Go FFI factory's validation null-return populates
last_error_*with a descriptiveinvalid_argmessage. - Node AsyncIterable shim defines
return()/throw()that release the backingVec<ResultRow>via a newMeshQueryStream::release()napi method. include/README.mderror-reporting paragraph rewritten to match the actualnet_meshdb_last_error_*contract; operator-families table gains the last-error row; quickstart migrated to<inttypes.h>PRIx64/PRIu64.MeshDBQueryBuildersource-resets preserveb.err; aliasing across source-resets documented explicitly.
Deferred with rationale:
- Federated SDK tests. Need
FederatedMeshQueryExecutor+LoopbackTransportexposed through the SDK shims; ships with a future federated-surface slice. Substrate-side coverage is solid in the meantime. - Runner-side error-path coverage in SDKs. The runtime
MeshErrorvariants the review listed (JoinMemoryExceeded,QueryBudgetExceeded,AmbiguousDiscovery,HistoricalRangeUnavailable) aren't currently triggerable from the SDK surfaces — they need configurable per-query budgets,ChainRef::Discoveredexposure, and capability-index gating, none of which ship in v0.16. Thekinddiscriminator plumbing is pinned with a Node-sideparseMeshDbErrorKindtest against synthetic errors.
Minors (20) and Nits (4)
Closed:
group_key_fordefensive fallback forJoinKeyMode::Fieldreplaced withunreachable!()and a descriptive message.row_overhead: u64 = 64magic constant replaced withstd::mem::size_of::<ResultRow>() as u64.translate_responsesemitsMeshError::ExecutorErroron premature transport stream termination instead of treating it as clean EOS.- The three-way duplicated hash-join body factored into the shared
build_hash_join_tablehelper. - C header threading section documents move-safe / not-Sync semantics for
MeshDbRunnerandMeshDbIter. meshdb.tsdrops the typed-class re-export (the shim's job is just the AsyncIterable side-effect).- Shared
OnceLock<Runtime>per FFI shim instead ofRuntime::new()per runner. MESHDB_PLAN.mdandCORTEX_ADAPTER_PLAN.mdreconciled with shipped reality.JoinKeyMode::Field("origin"|"seq"|"origin,seq")canonicalizes to the matching row-intrinsic encoding.parseMeshDbErrorKindregex accepts[a-z0-9_]+for future numeric-suffixed kinds.- C header const-correctness on
runner_new/runner_execute/runner_execute_with. - C example exercises the cached runner.
examples/meshdb.cuses<inttypes.h>PRIx64/PRIu64.- Python
lineage_emitdoc-comment attached to the correct factory. - Go FFI
ffi_cached_runner_round_tripsactually asserts a cache hit (mutates the underlying store between calls and verifies thePermanent-policy fetch returns pre-mutation bytes). translate_responseslast-err rebuild uses the original error rather than re-constructing.- Node
LineageEntry.depthisbigint(shape parity withoriginHash/tipSeq). The factory rejects values exceedingu32::MAXwith a typed error. Breaking for any Node SDK caller that previously constructed entries with plainnumberliterals: pass0n,1n, … instead of0,1, ….
Closed (post-pass):
MeshDbRunner.executor: Arc<LocalMeshQueryExecutor>indirection dropped across all three shims — the runner owns the executor directly, the FFI / NAPI / pyo3 entry points borrow it for the lifetime of the call.- Substrate-side join-watermark clamp helper lands as
clamp_join_watermark_secs(secs: Option<f64>) -> Durationinbehavior::meshdb::query, alongsideDEFAULT_JOIN_WATERMARK_SECS = 5. All three SDK shims now route theirf64watermark input through the helper, and four substrate-level unit tests pin the contract (None/ NaN / +/-inf / negative → 5 s; finite non-negative → passes through). Closes the deferred concern that the Pythontest_join_accepts_watermark_secs_kwargcould only assert row count, not the clamp choice. - Substrate test-gap fillers for the items the SDK suites couldn't reach cleanly: Unicode payload values (CJK / combining marks / emoji-ZWJ) under
Filter; singleton-input percentile + avg aggregates across the fullp ∈ [0, 1]range; empty-inputgroup_by = originaggregates that must not fabricate buckets; long-linear lineage walks (N = 500) backward and wide-fanout lineage walks (N = 1000) forward without stack overflow.
Substrate-side hardening (alongside the MeshDB passes)
- Routed-handshake replay guard now tracks the initiator's Noise ephemeral. Pre-fix, the guard refused any same-static re-handshake — indistinguishable from a passive attacker replaying captured msg1 bytes. The
connect_direct(peer, via = X)retarget path (connect_direct_retargets_coordinator_does_not_short_circuit_on_stale_session) failed with a handshake-timeout against an existing session. Post-fix,routed_rotation_outcomeonlyDropReplays when BOTH the static AND the initiator's ephemeral match. Duration::MAXsentinel handled in periodic sweep loops.spawn_token_sweep_loopandspawn_capability_gc_loopboth documentedDuration::MAXas "disable the loop". The implementations forwarded that value totokio::time::interval(MAX), which panics onInstant + MAXoverflow. Both loops now short-circuit toshutdown_notify.notified().awaitwhen the interval isMAX.
Toolchain + dependency upgrades
Go 1.26
The Go toolchain bumps from 1.21 to 1.26. CI now reads the Go version directly from go/go.mod (go-version-file: in actions/setup-go@v5) so the local toolchain and the CI matrix can't drift. The bump unlocks Go's improved unsafe.Slice ergonomics that the safe size_t → int payload conversion uses.
Integration-test parallel handshake setup
The cross-binding cgo integration test (go/integration_test.go) refactored to create responder and initiator nodes in parallel via errgroup.Group. Pre-fix, sequential construction would occasionally deadlock when both nodes' handshake state machines waited on each other's first packet; the parallel construction breaks the cycle and reduces flakiness across CI runs.
Dependency bumps
ctor0.11.1 → 1.0.5 (Rust constructor / destructor attributes; cleaner 1.x API for the static-init registration paths).napi3.8.6 → 3.9.0 (napi-rs runtime — Node binding surface).napi-build2.3.1 → 2.3.2 (napi-rs build script).napi-derive3.5.5 → 3.5.6 (napi-rs derive macros).
No source-level changes in the bindings — straight Cargo.lock refresh.
Test hygiene
- Lib suite at 2715+ tests (was 2645+ at v0.15 release). 70+ net new tests across the MeshDB surfaces + cross-cutting fixes; every numbered review item from both hardening passes ships with at least one regression where the shape made one possible. Notable additions:
- Substrate:
error::tests::kind_discriminator_is_stable_across_variants,cache::tests::lru_rejects_oversized_entry_instead_of_self_evicting,cache::tests::key_for_plan_handles_filter_plans_without_panicking,federated::tests::cancel_after_composite_aggregate_short_circuits_materialized_stream,federated::tests::call_id_is_unique_across_federated_executors_on_same_host,planner::tests::plan_chainref_discovered_multiple_origins_surfaces_ambiguous_error,planner::tests::lineage_back_with_multiple_fork_of_tags_is_deterministic,planner::tests::lineage_back_across_multiple_replica_hosts_is_deterministic,planner::tests::lineage_{back,forward}_with_max_depth_zero_returns_only_start_no_error,planner::tests::lineage_back_walks_a_long_linear_chain_without_stack_overflow,planner::tests::lineage_forward_walks_a_wide_fanout_without_stack_overflow,predicate::tests::to_wire_handles_deep_nesting_without_stack_overflow,executor::tests::join_key_field_origin_canonicalizes_to_intrinsic_encoding,executor::tests::filter_matches_unicode_payload_value,executor::tests::aggregate_percentile_singleton_returns_the_only_value,executor::tests::aggregate_avg_singleton_returns_the_only_value,executor::tests::aggregate_count_with_empty_input_group_by_origin_returns_zero_rows,query::tests::clamp_join_watermark_{passes_through_finite_non_negative_seconds, falls_back_to_default_on_{none, non_finite, negative}},mesh::*::routed_rotation_outcome_accepts_reinit_with_fresh_ephemeral. - Go FFI:
ffi_guard_traps_panics_and_records_last_error,ffi_factory_validation_failure_populates_last_error,ffi_filter_with_pathologically_deep_predicate_returns_null,ffi_null_handle_populates_last_error,ffi_mesh_error_kind_round_trip_covers_known_variants, instrumentedffi_cached_runner_round_trips. - Python:
test_join_accepts_watermark_secs_kwarg. - Node:
parseMeshDbErrorKind decodes the <<meshdb-kind:...>> prefix,cachePolicyTimeBound rejects non-finite / negative ttlSeconds at the factory,execute rejects a hand-rolled cachePolicy with a negative ttlSeconds,execute rejects a hand-rolled cachePolicy with an unknown kind,break inside for-await releases the backing row buffer,exception inside for-await releases the backing row buffer,lineageEmit rejects a depth that exceeds u32::MAX.
- Substrate:
cargo clippy --all-features --all-targets -D warningsclean across substrate + every binding crate. The MeshDB executor's hash-join probe-table type alias (HashJoinTable) lands to silenceclippy::type_complexityon the shared helper.cargo doc --features meshdb --no-depsclean underRUSTDOCFLAGS="-D warnings"— broken intra-doc links incache.rs(DefaultHasher/PredicateWire) andredex/config.rs(the dataforts-gatedBlobAdapter/RedexFile::resolve_onereferences that don't resolve under meshdb-only builds) all closed.- CI nextest groups + non-cascading test failures so a flake in one integration test doesn't take down unrelated suites. The connect_direct retarget test that was masking the routed-handshake replay-guard bug now passes reliably.
Breaking changes
API — MeshDB surface is new
MeshQuery AST + MeshQueryRunner + MeshQueryPlanner + FederatedMeshQueryExecutor + MeshDbTransport + LoopbackTransport + CachePolicy + ExecuteOptions + MeshError + every operator family (AggregateCount / AggregateNumeric / AggregateReduction / AggregateDistinct / HashJoin / Window / Filter / LineageEmit) are all new in v0.16. Behind the meshdb Cargo feature; non-meshdb builds see the substrate path unchanged.
The bindings ship the same surface under the meshdb extra / feature flag. Python / Node / Go SDKs guard imports so the binding still loads without the feature compiled in (symbols simply don't appear).
Wire format — SUBPROTOCOL_MESHDB = 0x0F00
A new subprotocol identifier is reserved on the wire for MeshDB federated queries. The dispatcher hookup that registers SUBPROTOCOL_MESHDB on MeshNode is parked until a consumer drives it; the envelope shapes are stable. No existing protocol changes.
Capability index — mutation_version
CapabilityIndex gains an AtomicU64 mutation_version that bumps on every index / remove / gc mutation. Public surface: CapabilityIndex::mutation_version() -> u64. Used by the MeshDB result cache for pull-based invalidation. Source-compatible — no existing call site changes.
MeshError::AmbiguousDiscovery is new
MeshError gains an AmbiguousDiscovery { matches: Vec<u64>, requirement: String } variant for the case where ChainRef::Discovered resolves to more than one origin. The variant is gated under the #[non_exhaustive] attribute that already applies to MeshError; matches that explicitly cover every variant get a compile error and need a _ => arm or the new arm added.
Behavioral fixes that may surface as test breakage
- Routed-handshake replay guard now accepts same-static / fresh-ephemeral re-handshakes. Tests that asserted
RoutedRotationOutcome::DropReplayon bare(static_a, static_a)will seeAcceptRotation; pass the new 4-arg signature with matching ephemerals to pin the replay-detection behaviour. Duration::MAXsweep interval no longer panics. Tests that assertedtokio::time::interval(MAX)would surface an Instant-overflow panic in the spawned task will see the loop park onshutdown_notifyinstead.MeshErrorkind discriminator on the PythonMeshDbErrorexception — Python callers can reade.kind(set via PyO3setattr); tests that assertedMeshDbErrorhas no extra attributes will need updating.- Node FFI error messages carry the
<<meshdb-kind:KIND>>prefix. Tests that asserted on bare error messages need to either consumeparseMeshDbErrorKind(err).messageor update their substring matches.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.16 line. Recompile / rebuild the binding cdylib (NAPI for Node, maturin for Python,cargo build -p net-meshdb-ffifor Go) with themeshdbCargo feature on when you want the MeshDB surface; without it, the substrate is unchanged from v0.15. - Go toolchain. Bump to Go 1.26. CI now reads the version from
go/go.mod— set the version there andactions/setup-go@v5'sgo-version-file:picks it up automatically. Local toolchains should match. - MeshDB opt-in. Channels that want federated queries: build the substrate with
--features meshdband construct aLocalMeshQueryExecutor::new(reader)against aChainReaderthat walks RedEX. Compose plans via the typedMeshQuery::V1(QueryV1::*)AST or the host-language SDK factories. - Result-cache opt-in. Wrap the local executor with
LocalMeshQueryExecutor::with_cache(reader, Arc::new(LruResultCache::default()), Arc::new(|| capability_index.mutation_version())). Same shape forFederatedMeshQueryExecutor::with_cache. - Federated executor. Construct
FederatedMeshQueryExecutor::new(transport)against aMeshDbTransportimpl.LoopbackTransportships for in-process integration tests; a realMeshNode-backed transport that registersSUBPROTOCOL_MESHDB = 0x0F00on the dispatcher is the next slice once a consumer drives it. - Cross-binding consumers. Python imports
from net import MeshQuery, MeshQueryRunner, ExecuteOptions, CachePolicy; Nodeimport { MeshQuery, MeshQueryRunner, cachePolicyPermanent, cachePolicyTimeBound } from '@ai2070/net'plusimport '@ai2070/net/meshdb'for thefor awaitshim; Go importsgithub.com/ai-2070/net/goand usesMeshDBQuery/MeshDBRunner/MeshDBQueryBuilder. C consumers include<net_meshdb.h>and link-lnet_meshdb. - Error handling. Python:
except MeshDbError as e: e.kind. Node:import { parseMeshDbErrorKind } from '@ai2070/net/meshdb'; const { kind, message } = parseMeshDbErrorKind(err). Go:var mde *MeshDBError; if errors.As(err, &mde) { mde.Kind }. C:net_meshdb_last_error_kind()+net_meshdb_last_error_message()per-thread, withnet_meshdb_clear_last_error()to reset. - NAT-traversal consumers. The routed-handshake replay-guard fix is transparent — legitimate re-handshakes from the same peer now succeed where they previously timed out. If your application explicitly tested the prior
DropReplay-on-same-static behaviour, update to assert against(static, ephemeral)pairs. Duration::MAXsweep configs. If you intentionally settoken_sweep_intervalorcapability_gc_intervaltoDuration::MAXto disable a loop, the behaviour is now what the docs promised — the spawned task parks on shutdown notification without ticking. No code change required, but the pre-fix Instant-overflow panic noise disappears from logs.
Dataforts
Dataforts in Net is the data layer that grows on top of the event bus, and v0.15 is where it lands. Every prior approach to "where does the data live" presupposes an answer — S3 holds it in a region, Ceph holds it across racks, IPFS holds it wherever a pin exists. Storage is a place. You go to the place to read. You ship to the place to write. Dataforts inverts that: blobs are content-addressed BLAKE3 chunks, the address of the data is the data, and the chunks live on whichever nodes have capacity and capability to hold them. There is no canonical home.
Data in Dataforts is a fluid. Hot chunks — bytes that some node keeps fetching — get pulled toward the nodes that read them, because re-fetching the same hash leaves a copy behind. Cold chunks stay where they are or drain into nodes with spare disk. The same pressure that fills a near-empty node also empties a near-full one. Nothing tells the cluster to rebalance. The blobs move because the reads moved.
Heat is per-chunk and decays. A chunk read a hundred times in the last minute has gravity; a chunk read once a year ago has none. The capability index advertises heat the same way it advertises disk-free, scope, and class. A peer with gravity for a given hash is the natural target when the chunk's current holder needs to shed, and the natural source when a new reader asks. Migration is the heat reading itself — no scheduler, no shuffle plan, no coordinator deciding where bytes should be. The reads decide.
When a node crosses its high-water disk threshold, it picks the coldest chunks it holds and pushes them to peers with capacity. The receive side is opt-in via a capability tag — operators decide which nodes accept overflow and which stay pull-only. Pushes ride the existing per-chunk replication runtime; the only new wire shape is a one-shot nudge telling the receiver to open the chunk channel. Storage saturation no longer fails closed against new writes — the cluster bleeds pressure into peers that can absorb it, until either the workload subsides or those peers fill up too.
A producer that publishes a chunk and immediately reads it back never sees a gap. The publish path returns a write token; the read path waits on that token's durability watermark before returning the bytes. Read-your-own-writes is the producer's contract with itself — independent of replication factor, independent of cluster topology, independent of which peer ends up holding the chunk. The mesh doesn't promise global linearizability. It promises that you see what you just wrote, however many hops the bytes had to take to settle.
These properties compose with the rest of the stack. RedEX writes a BlobRef into the event chain like any other event — the substrate verifies the BLAKE3 hash, the chain stays causal, the blob payload pulls separately when somebody needs it. CortEX folds events that reference blobs into views; the views pin the chunks they care about so gravity doesn't sweep them away. A drone, a workstation, and a datacenter can hold the same dataforts — different slices of the same content-addressed space, replicating according to who reads what, all encrypted in flight and on disk.
There is no object store to provision. There is no cluster to operate. The data is on the mesh because the mesh is the data.
Named after Billy Idol's 1983 album / title track — a release that asks "more, more, more" of the substrate The Warriors laid down. v0.14 made replication the load-bearing layer underneath the channel surface. v0.15 stacks the four-phase Dataforts compositional layer on top: greedy-LRU caching pulls in-scope chains, data gravity drifts hot ones toward their readers, BlobRef carries content-addressed pointers without owning the bytes, and read-your-writes gives producers a session-bounded "did my write land yet?" handle. No new wire protocol — every phase composes against the existing capability index, proximity graph, and causal: tag layer that landed in The Warriors.
v0.15 lands the full Rebel Yell roadmap from DATAFORTS_PLAN.md — Phases 1, 3, 4, and 5 of the seven-phase plan ship in this release, completing Dataforts as a compositional data plane on top of the v0.14 substrate. The full surface ships across Rust core, Python, Node, Go, and C FFI, with end-to-end mesh integration; greedy and gravity are runtime-toggleable policies (operators flip them on / off live against a running mesh, no rebuild required); the single dataforts Cargo feature gates whether the surface compiles at all. A mesh-native blob storage extension (Phase 3.5) lands in the same release — MeshBlobAdapter implements the v0.15 BlobAdapter trait against the local mesh's RedEX replication layer, so a Dataforts-enabled cluster has a working content-addressed blob store the moment Redex::enable_replication(mesh) is called. See DATAFORTS_BLOB_STORAGE_PLAN.md for the design. The v0.3 active-overflow extension ships alongside — disabled by default, one boolean to turn on; when active, a node pushes its coldest blobs to overflow-enabled peers with free disk via a new nRPC. Design + per-PR shipping status in DATAFORTS_BLOB_OVERFLOW_PLAN.md.
The hardening posture from the Black Diamond line continues. Two coordinated code-review passes landed before the v0.15 branch cut: the primary dataforts-feature review (docs/misc/CODE_REVIEW_2026_05_11_DATAFORTS.md) closed 54 numbered items D-1..D-54, an independent second pass surfaced 11 N-series items, all but three (deferred with rationale) closed. Five post-merge follow-up commits on the channel-hash-32 branch hardened the RPC-inbound dispatcher hot path and tightened collision-lookup contracts.
Alongside Dataforts, v0.15 carries one cross-cutting breaking change: the canonical channel hash widens from u16 to u32 substrate-wide. The wire NetHeader::channel_hash stays u16 (the 64-byte cache-line-aligned header is full), mirroring the origin_hash u64-canonical / u32-wire precedent. ACL, storage, config, and RYW decisions key on the canonical 32-bit hash; the wire u16 is a fast-path filter hint only. The PermissionToken wire form grows from 159 → 161 bytes.
Greedy-LRU dataforts (Phase 1)
Per-node speculative caching of in-scope chains observed via the tail-subscription path. The mesh fans every event through a GreedyObserver hook; the runtime decides whether to admit each event into a per-channel cache file. Cold channels evict under cluster-cap pressure and withdraw their causal:<hex> advertisement so peers re-route to a healthy holder. Wires via Redex::enable_greedy_dataforts(mesh, config, local_caps, intent_registry).
GreedyConfig
pub struct GreedyConfig {
pub scopes: Vec<ScopeLabel>, // scope-axis admission
pub proximity_max_rtt: Option<Duration>, // proximity-axis admission
pub per_channel_cap_bytes: u64, // storage-axis admission, per chain
pub total_cap_bytes: u64, // cluster-cap eviction trigger
pub bandwidth_budget_fraction: f32, // share of measured NIC peak
pub nic_peak_bytes_per_s: Option<u64>, // operator override of probe
pub intent_match: IntentMatchPolicy, // capability-preference axis
pub colocation_policy: ColocationPolicy, // colocation axis
pub observer_inflight_cap: usize, // tokio spawn fan-out bound
}
The five admission axes (scope + proximity + capability-preference + colocation + storage-cap) gate every inbound event before the bandwidth-budget gate; rejected events bump the per-reason counter rather than entering the cache file. A bandwidth-budget rejection now increments a distinct dataforts_greedy_admit_throttled_bandwidth_total counter (was conflated with capacity-rejects pre-fix) so operators can disambiguate "NIC saturated" from "cache full." nic_peak_bytes_per_s overrides the hardcoded 1 Gbps default for fleets with faster NICs.
Admission + eviction
Inbound events flow through GreedyRuntime::dispatch_event(channel_name, channel_hash, origin_hash, chain_caps, payload). Admission is a pure function — should_admit(inputs) -> AdmissionVerdict — that returns one of Admit / RejectedByAdmission(AdmitRejectReason). The bandwidth-budget gate runs only after admission passes; admitted events that fail the budget gate are throttled, not silently dropped (D-2). Eviction under the cluster cap returns an EvictionSweep { evicted: Vec<EvictedEntry> } value; the runtime calls sink.withdraw_chain(origin_hash) for each evicted entry inline so peers see the capability tag drop in the same tick (D-1).
The cache-side RedexFile keys on a synthesized ChannelName (dataforts/greedy/<hex16>) derived from the wire u16 channel hash; the canonical 32-bit hash decision happens at the ACL / config / RYW layer, not at the data-plane cache (the wire hash is what inbound packets carry). Two channels colliding on the wire u16 share a cache file — a small mix-up at the data-plane layer; ACL and storage decisions stay collision-safe via the canonical hash.
TOCTOU + lock-coalescing fixes
is_new_channel = !cache.contains(channel) followed by cache.upsert(...) previously took two independent lock acquisitions; concurrent dispatch_event calls on the same channel both observed is_new_channel = true, both ran sink.announce_chain, and the second upsert orphaned the first RedexFile. v0.15 folds the lazy-open into a single cache.get_or_insert_with scope holding the lock for contains / open / upsert; announce fires after lock release. The steady-state path takes one lock; the new-channel path takes two with TOCTOU re-check (D-6, D-28).
upsert on an already-registered channel previously refreshed the file pointer without subtracting the prior entry's bytes from total_bytes. Reopens via dispatch_event's is_new_channel path accumulated total_bytes that no eviction could ever drain, eventually starving the cluster-cap budget. The update branch now subtracts the prior bytes before replacing the entry's file (D-3).
entry.bytes saturates on overflow but didn't reflect retention trim from RedexFile. v0.15 ships RedexFile::retained_bytes + GreedyRuntime::resync_cache_bytes for periodic operator-driven re-anchoring; wiring is opt-in via the operator's tick loop (D-26).
colocation_target_held resolved from cache
ColocationPolicy::SoftPreference / StrictRequired evaluates whether the local cache already holds chains colocated with the inbound event. The pre-fix colocation_target_held = None hardcode caused StrictRequired to reject events whose colocation target was actually present locally. The runtime now resolves the colocation target by name against the cache map (D-8).
Spawn fan-out bound
observe_event is the mesh hot-path entry; without a bound, a flooding peer could create one outstanding tokio task per event before the per-event admission lock serialized them, piling up per-task Bytes + Arc<CapabilitySet> clones. v0.15 ships observer_inflight: Arc<tokio::sync::Semaphore> sized via GreedyConfig::observer_inflight_cap (default 4096); on saturation, events drop and bump dataforts_greedy_observer_dropped_overloaded rather than blocking the mesh dispatch task (D-7).
Cross-binding API surface
Every binding exposes the same enable_greedy_dataforts / disable_greedy_dataforts pair plus greedy_cached_channel_count() and greedy_prometheus_text() for operator scrape. The Go binding carries the runtime-stub fallback (NET_ERR_FEATURE_NOT_BUILT) so a cdylib built without the dataforts feature still links cleanly into cgo programs (D-20).
Data gravity (Phase 4)
Per-chain read-rate counters with exponential decay. Threshold-crossing emissions stamp heat:<hex>=<rate> onto the chain's existing capability announcement; greedy admission weights cache pulls by heat × scope-match × proximity-rank. Cold chains evict first under cluster-cap pressure; hot chains migrate toward the readers that drive the heat. No separate migration engine — gravity emerges from greedy + heat counters + capability-preference automatically.
Wires via Redex::enable_gravity_for_greedy(mesh, DataGravityPolicy) against an already-running greedy runtime.
DataGravityPolicy
pub struct DataGravityPolicy {
pub enabled: bool,
pub emit_threshold_ratio: f64, // 1.5 = re-emit when rate is 1.5× last-announced
pub decay_half_life_secs: u64, // 300 = 5-minute half-life
pub tick_interval_ms: u64, // 5000 = 5-second tick cadence
pub normalization_reference_rate: f64, // 1000 events/s → 1.0 on the wire
}
Heat counter + emission decision
HeatCounter::observe(now, weight) bumps the counter; HeatCounter::current_rate(now) returns the decayed rate. should_emit_heat(prev, current, ratio) is the pure-logic emission decision: emit when no prior emission, or when the current rate exceeds prev × ratio or falls below prev / ratio. Edge cases:
- Near-zero
prevno longer tripsinf. Pre-fix, aprevof1e-300with finitecurrentmadecurrent / prevevaluate to+inf, which trivially satisfied any ratio check. v0.15 treatsprevbelowf64::EPSILON(and subnormals viais_normal()) as "no prior emission" — the bootstrap arm runs cleanly (D-29, N-9). NaNrates short-circuit to no-emit. A NaN slipping into the counter (e.g. via a corruptedto_le_bytesround-trip on the wire) used to propagate through the ratio arithmetic. The pure function now returnsEmissionDecision::Skipon any non-finite input.
Wire normalization — log-scale
Pre-fix, the wire heat:<hex>=<rate> tag normalized (rate / (rate + 1)).min(1.0), which compressed asymptotically — rate=10 → 0.91, rate=100 → 0.99. With {:.2} wire encoding, every "warm" chain looked like "blazing." v0.15 uses ln_1p(rate) / ln_1p(reference) with a configurable normalization_reference_rate; the reference defaults to 1000 events/s mapping to 1.0 on the wire. Wire format is unchanged — just the value placed on it (D-30, D-46).
HeatRegistry cap + LRU
HeatRegistry previously grew unbounded — one entry per (channel, origin) pair the local node ever observed. A misbehaving peer flooding diverse origin hashes could exhaust memory before any greedy-eviction signal fired. v0.15 caps the registry at DEFAULT_HEAT_REGISTRY_CAP = 8 * 1024 entries with LRU eviction by last_update; the tick loop also prunes entries with rate == 0.0 && last_emitted == Some(0.0) so cold chains drain on their own (D-10, N-2).
Inbound heat: tag auth
Capability announcements carrying heat:<hex> tags previously had no provenance check at the receive side — any peer could emit a heat tag claiming any chain. v0.15 gates inbound heat tags on the publisher's existing causal:<hex> claim: a node advertising heat:X without simultaneously advertising causal:X has its heat tag dropped at the receive boundary (D-11). Per-peer rate-limiting of heat: emissions is acknowledged in D-11 + N-8 as deferred — operators see today's posture in CODE_REVIEW_2026_05_11_DATAFORTS.md.
origin_hash == 0 no longer collapses heat
Default-constructed publishers carried origin_hash = 0, which collapsed all unattributed chains into a single registry bucket and stamped a meaningless heat:0000…0000 tag onto the wire. v0.15 stamps origin_hash from identity in publish_to_peer (the natural fix) and skips heat bumps when origin_hash == 0 is observed at the gravity-runtime entry as defense-in-depth (D-9).
announce_heat_batch — coalesced rebroadcast
gravity_tick previously walked all heat emissions and called announce_heat per chain. Each announce_heat rewrote the full CapabilitySet::tags vector and called announce_capabilities — at 100 K chains, O(n² × n_tags) per tick with each emit duplicating all chains' tags on the wire. v0.15 ships HeatSink::announce_heat_batch; the tick gathers all emissions, retains all stale heat tags + pushes all new heat tags in one pass, and emits a single announce_capabilities (D-25).
BlobRef + BlobAdapter (Phase 3)
Content-addressed reference whose bytes live in the caller's existing storage (S3, Ceph, IPFS, local FS, …). The substrate carries the reference, never owns the bytes. Adapters implement fetch / store (or the streaming variants for multi-GB payloads); the FileSystemAdapter ships in-tree as the reference adapter.
Wire format
[0xB0, 0xB1, 0xB2, 0xB3] // 4-byte magic (was single-byte 0xB0 pre-fix)
version: u8 // currently 1
hash: [u8; 32] // BLAKE3
size: u64 // bytes; bounded by BlobRef::MAX_SIZE = 16 GiB
uri: [u8] // length-prefixed; the adapter URI scheme prefix
Pre-fix the discriminator was a single byte 0xB0 (D-14). A plain binary payload starting with 0xB0 would misclassify as a blob ref and route through BlobAdapter::fetch instead of being delivered directly. The 4-byte magic gives a collision probability of ~1 in 4 billion against arbitrary binary payloads. Old (pre-v0.15) blob refs are rejected on decode; v0.15 nodes can't exchange blob refs with pre-v0.15 nodes (Dataforts is new in v0.15, so this only matters for pre-release pilots).
BlobRef::MAX_SIZE = 16 GiB defaults bound the size field; BlobRef::decode and publish_blob reject anything larger. The previous u64::MAX accept-anything path could OOM on vec![0u8; len as usize] on 64-bit and silently truncate on 32-bit (D-15). RedexFileConfig::blob_max_size lifts the cap when an operator needs it.
Adapter dispatch — URI-scheme keyed
BlobAdapter::accepted_schemes() -> &[&str] declares the URI schemes the adapter handles (["s3", "s3+https"], ["file"], etc.); the registry dispatches by URI scheme, not by the channel config's blob_adapter_id. Pre-fix, an attacker who could write to a channel could choose its blob_adapter_id and route a BlobRef URI through any registered adapter — authority confusion (D-13). The scheme-keyed dispatch closes the gap; the channel-config-selected path is gone.
Hash verification on store
FileSystemAdapter::store(blob_ref, &bytes) now BLAKE3-hashes the supplied bytes and rejects on mismatch with blob_ref.hash. Pre-fix the adapter wrote whatever bytes the caller passed; a content-address-violating store would silently corrupt the addressable layer (D-12). The rename-fallback path (idempotent re-store on existing content) also hash-verifies the on-disk bytes — the v0.13/v0.14-era TOCTOU on idempotent re-store via the windowed rename is closed (D-32, N-6).
fsync of the temp file before rename + fsync of the parent dir after rename land in the FileSystemAdapter store path; power loss between rename and OS flush previously left zero-length files in the addressable space (D-33).
Streaming hooks
fetch_stream(&self, blob: &BlobRef) -> Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>> and store_stream(&self, blob: &BlobRef, src: Pin<Box<dyn Stream<...>>>) ship as required methods on BlobAdapter with default implementations that route through the existing fetch / store (so existing impls keep working); adapters wanting real streaming override the defaults. The FileSystemAdapter chunks at 256 KiB (D-16).
Per-channel adapter override (multi-tenant)
BlobAdapterRegistry previously lived as a single process-wide singleton. v0.15 adds RedexFileConfig::blob_adapter_registry: Option<Arc<BlobAdapterRegistry>> for per-channel override; the default-tenant path uses the global singleton unchanged (D-34).
Bounded concurrency on the FS adapter
spawn_blocking calls on the FileSystemAdapter are bounded via tokio::sync::Semaphore. Pre-fix, a fanout of concurrent stores could exhaust the tokio blocking pool and deadlock unrelated tasks (D-35).
Conformance suite
The blob adapter conformance suite extends to cover idempotency (re-store same hash), hash-mismatch rejection, range-past-end behavior, cross-blob isolation (writes to blob A can't leak into blob B's namespace), and random-ghost reads (resolve a never-published BlobRef). Adapter authors pin against the same suite the in-tree FileSystemAdapter does (D-36).
Cross-binding adapter authoring
Adapters can be written in the host language across every binding:
- Python —
PyBlobAdapterwith sync +async defmethod support. Async adapters run on a binding-owned event loop on a dedicated thread (one loop per process); calling thread sharing is viaasyncio.run_coroutine_threadsafe. Anaiobotocore/httpx.AsyncClient/ SQLAlchemy async engine inside the adapter is safe — the binding never spins up a freshasyncio.runper call (D-4). - Node —
NodeBlobAdapter(sync TSFN bridge) +NodeAsyncBlobAdapter(Promise-returning TSFN bridge). - C / cgo —
NetBlobAdapterVtablewith per-field null-check at registration; partial vtables returnNET_ERR_BLOB_VTABLE_INVALIDrather than crashing on first dispatch (D-22).
BlobError::NotFound(uri) sanitizes the URI before including it in the error string — control chars escape as \xNN, length caps at 256 bytes — so a binding logging the error can't be log-injected by an attacker who controls the URI (D-31).
Mesh-native blob storage (Phase 3.5)
Phase 3's BlobRef + BlobAdapter hook treats the substrate as a carrier for content-addressed pointers — the bytes live in S3 / Ceph / IPFS / the local FS. Phase 3.5 extends that hook with a substrate-owned content-addressed store: MeshBlobAdapter implements BlobAdapter against the local mesh's RedEX replication layer, registered under the mesh:// URI scheme. A Dataforts-enabled cluster has a working blob store the moment Redex::enable_replication(mesh) is called; operators pick a replication_factor instead of standing up a separate storage system.
The full plan + design rationale lives in DATAFORTS_BLOB_STORAGE_PLAN.md. Shipped as PR-5a through PR-5r + a post-feature hardening bundle.
MeshBlobAdapter
let adapter = MeshBlobAdapter::new("mesh-prod", redex.clone())
.with_persistent(true)
.with_replication(ReplicationConfig::factor(3))
.with_retention_floor(Duration::from_secs(24 * 3600))
.with_disk_capacity(1 << 40)
.with_auth_guard(auth_guard.clone())
.with_blob_heat(blob_heat_registry, Duration::from_secs(60));
Implements BlobAdapter::{store, fetch, fetch_range, exists, delete, stat, prefetch} plus store_stream / fetch_stream for multi-GB payloads. store BLAKE3-verifies the supplied bytes against blob_ref.hash before persisting; idempotent — repeated stores of identical bytes against the same hash are a no-op. Chunks above the 4 MiB threshold split into independently-content-addressed RedexFiles, with a small manifest blob (one BlobRef::Manifest) carrying the chunk list.
BlobRef::Manifest + chunking
BlobRef gains a Manifest { encoding, chunks: Vec<ChunkRef>, size } variant alongside the v0.15 Small. Wire form is forward-compatible — the 4-byte magic + version byte gate variants. Encoding::Replicated ships in v0.2; Encoding::ReedSolomon { k, m } is reserved on the wire for v0.3. Chunking is fixed-size 4 MiB; a 16 GiB blob holds 4096 chunk references (≈144 KiB manifest, within the inline path itself).
publish_with_blob — store-then-publish
let receipt = mesh.publish_with_blob(
channel,
payload_bytes,
BlobDurability::ReplicatedTo(3),
).await?;
Stores the bytes to the configured durability, then publishes an event referencing the resulting BlobRef. The receipt carries a WriteToken whose applied_through_seq watermark composes with Phase 5's read-your-writes — a consumer calling tasks.wait_for_token(token, deadline) blocks until both the publish event has folded and the chunks have replicated to the requested durability. BlobDurability::{BestEffort, DurableOnLocal, ReplicatedTo(n)} chooses the trade-off between latency and the durability guarantee the receipt asserts.
Refcount + GC + pinning
BlobRefcountTable tracks per-hash references from three sources: RedEX chain folds (PR-5h wires greedy into the increment / decrement path on cache admit / eviction), CortEX adapters indexing events, and direct pin(blob_ref) / unpin(blob_ref) operator calls. sweep_gc(now, disk_pressure) collects refcount = 0 + unpinned hashes whose first_seen is older than the retention floor (default 24 h); disk_pressure = true bypasses the floor for emergency reclaim. delete_chunk drops the refcount entry inline rather than waiting for the sweep.
A health gate advertises dataforts:blob-storage-unhealthy when local disk crosses 95 % and clears at 85 % (hysteresis); other nodes' admission filters reject inbound migrations to an unhealthy node.
Capability extension
Three new capability families compose against the existing 5-axis PlacementFilter:
BlobCapability—storage,disk_total_gb,disk_free_gb,class.GreedyCapability—enabled,scope,proximity. Same shape as the chain-side greedy gate; blobs reuse the chain proximity score.GravityCapability—enabled,scope,proximity. Independent of greedy; a node can participate in gravity migration without speculatively greedy-pulling.
PlacementFilter gains an Artifact::Blob { blob_hash, size_bytes, encoding, capabilities } variant; the score function reads blob.disk_free_gb + blob.storage + gravity.scope to gate blob placement.
TopologyScope (Node ⊂ Zone ⊂ Region ⊂ Mesh) is a hard boundary on greedy / gravity decisions — scope == Zone means the local node never pulls or accepts migration of a blob whose publisher is in a different zone.
G-1 / G-2 / G-3 — admission, gravity, migration
Three pure-logic decision primitives plus the runtime that consumes them:
should_pull_blob(local_caps, publisher_caps)(G-1). Greedy admission verdict:Admit/Reject(reason)wherereason ∈ { NoStorageCap, GreedyDisabled, ProximityZero, Unhealthy, ScopeMismatch }. Wired intoGreedyRuntime::dispatch_eventso admitted chains carryingBlobRefs trigger aBlobAdapter::prefetchon the referenced blob. Counters:dataforts_greedy_blob_pulls_admitted_total/…_rejected_total{reason}.should_migrate_blob_to(target_caps, publisher_caps, size_bytes)(G-2 / G-3). Gravity migration verdict fortarget_caps; extends theshould_pull_blobshape with adisk_free_gbheadroom check (rounded up —1.5 GiB blob → ceil(1.5) = 2 GiB required).MigrateBlobReject::InsufficientDiskis the additional variant.drive_blob_migration_tick(local_caps, capability_index, adapter, size_resolver)+ the_with_manifest_resolvervariant. Walks peers in the capability index, parsesheat:blob:<hex>=<rate>reserved tags viaparse_blob_heat_tag, runsshould_migrate_blob_toagainst each candidate, and on admit callsadapter.prefetch. The manifest-resolver variant recursively prefetches every constituent chunk of aBlobRef::Manifest(PR-5o). Returns aBlobMigrationTickReportwith per-reason counters for operator dashboards.
Per-node pull, not centralized push — each node decides what to pull from its local capability view. The plan documents the storage-overflow push-to-peer track as deferred future work.
Blob heat — heat:blob:<hex>=<rate> tags
Mirrors the chain-side gravity layer with a key-shape change: blob heat keys on the 32-byte chunk hash. BlobHeatRegistry (LRU + cap + half-life decay, same discipline as HeatRegistry); MeshBlobAdapter::with_blob_heat(registry, half_life) opts the adapter into bumping heat on every successful fetch / fetch_range. MeshBlobAdapter::tick_blob_heat(policy, sink) walks the registry and routes Emit { rate } / Withdraw decisions through the BlobHeatSink trait; MeshNode implements the sink by adding a heat:blob:<hex64>=<rate> reserved tag to the local capability set and rebroadcasting via announce_capabilities.
The blob: body sub-prefix keeps blob-heat tags disjoint from chain-heat tags on the wire (heat:<origin_hex>=<rate> for chains, heat:blob:<hash_hex>=<rate> for blobs).
G-6 — Auth
pin_authorized / unpin_authorized / delete_chunk_authorized gate on AuthGuard::is_authorized_full(origin, channel) against the chain that originally published the blob. The unauth pin / unpin / delete_chunk variants remain available for system-internal callers (GC sweep, chain-fold refcount increment / decrement). BlobError::Unauthorized is the typed rejection.
net-blob operator CLI
Operator surface shipped behind the new cli Cargo feature (features = ["dataforts", "redex-disk", "cli"]). Subcommands:
net-blob put <path>— store + return the resultingBlobRef.net-blob get <hash> --out <path>— fetch; refuses to clobber existing output files.net-blob exists <hash>— exit 0 if present, exit 1 if absent.net-blob stat <hash>— refcount + size + last-seen.net-blob ls— list known content hashes.net-blob pin <hash>/net-blob unpin <hash>— operator pin / unpin.net-blob gc [--retention <duration>] [--dry-run] [--disk-pressure]— GC sweep.--dry-runlists candidates;--disk-pressurebypasses the retention floor.net-blob metrics— Prometheus text body.
--format json is available across every subcommand for scripting; parse_duration accepts 30s / 5m / 1h / 24h / 7d.
Cross-binding — Python
net.MeshBlobAdapter lands in the Python binding behind --features dataforts. Methods: store(blob_ref, data), fetch(blob_ref) -> bytes, fetch_range(blob_ref, start, end) -> bytes (half-open [start, end)), exists(blob_ref) -> bool, prometheus_text() -> str. Plus a PyBlobRef constructor taking (uri, hash_bytes, size) and round-tripping through encode() / BlobRef.from_encoded(bytes). Persistent mode (MeshBlobAdapter(redex, "id", persistent=True)) writes per-chunk RedexFiles to disk.
Node + Go binding wrappers for the v0.2 MeshBlobAdapter surface are tracked as deferred per-binding follow-ups in the plan doc.
Hardening — post-PR-5j review pass
Eighteen commits between PR-5r and the v0.15 cut closed second-pass review items. Grouped by area:
DoS surfaces
MeshNode::filter_unauthorized_heat_tagscaps incomingheat:blob:tags at 256 per announcement; the cap bounds migration-controller amplification (each surviving heat tag drives aprefetchattempt).CapabilityIndex::by_origin_hashis au32-truncated shortcut; anAtomicU64 collision_countfield surfaces last-writer-wins collisions on the admission hot path for operator observability (a wire-format-preserving fix; full collision-safe indexing is out of scope for v0.15).BlobMigrationControllercaps per-peer prefetch admits per tick so a single peer can't dominate the disk-bandwidth budget.- Per-channel
chain_blob_refsshadow set in the greedy runtime is bounded; a misbehaving publisher can't inflate per-channel memory unboundedly.
Soundness
- Python
&[u8]adapter parameters (PyMeshBlobAdapter::store,blob_publish,blob_resolve) now copy bytes under the GIL (data.to_vec()) beforepy.detach(). PyO3 0.28's strict&[u8]type-rejectsbytearrayat the FFI boundary; the post-fix copy keeps the capture-then-detach pattern safe against a hypothetical future PyO3 relaxation. CapabilityIndexfails closed when a wireu32 origin_hashis ambiguous and falls back to the empty-caps default for vacant slots.MeshBlobAdapterserializes concurrent stores against the same hash through a per-hash lock and BLAKE3-verifies bytes already on disk match the content address before short-circuiting the idempotent re-store path.
Races
gravity_tickcaptures sink + emissions + policy under one read of the gravity RwLock. Pre-fix it took the lock twice; a concurrentset_gravity/clear_gravitybetween reads could renormalize emissions computed under policy A against policy B.drive_blob_migration_tick_with_manifest_resolveronly inserts hashes into the dedup set after a successful Admit + Ok prefetch; rejected siblings + prefetch errors stay reconsiderable when the same hash surfaces under a later candidate's manifest expansion.BlobMigrationControllerfloors the publisher-scope check at the narrowest claim across all heat advertisers for the same hash so a single broad-scope peer can't bypass a narrower-scope peer's gate.
Label injection
- Operator-supplied
adapter_idis escaped per the Prometheus text-exposition spec (\\,\",\n) before being interpolated into label values. A--adapter-id 'evil"\n# bogus_metric{} 1\n#'payload can't inject fake metric lines.
Operator-surface hardening
net-blob get --outrefuses to clobber existing output files (the CLI may run with elevated privileges).delete_chunkdrops the refcount entry inline rather than waiting forsweep_gc.BlobError::Unauthorizedtyped variant separates auth-rejection from other rejection modes.
Build graph
dataforts = ["redex", "redex-disk", "dep:blake3"].--features datafortsalone previously failed to compile because the blob path callsRedexFile::sync()which is gated behindredex-disk. The feature graph now encodes the actual dep.
Doc + test-name polish
- Two
pull_rejects_*admission tests assertedAdmit(Zone-narrower-than-Mesh + absent-publisher-scope-defaults-to-Mesh) — renamed topull_admits_*. controller_skips_peers_without_blob_heat_tagsrenamed tocontroller_ignores_chain_heat_shape_tags.BlobRef::encoded_lendoc now documents Small as O(1) and Manifest as full-encode-cost (was "cheap for both variants").PyMeshBlobAdapter::fetch_rangedoc spells out half-open[start, end)tied to Python slice semantics.publish_with_blobdoc drops the overstated atomicity claim and documents chunk-advertise ordering inline.
The full per-commit log lives in the plan doc's Shipping status table under "Hardening — post-PR-5j hardening pass."
Active blob overflow (Phase 3.5 / v0.3 blob track)
v0.2 mesh-native blob storage is intentionally pull-only — when a node fills up, it advertises dataforts:blob-storage-unhealthy and other nodes' admission rejects inbound migrations. The local node never pushes its own blobs elsewhere; under sustained saturation a node either reclaims via GC or stops accepting new bytes. The v0.3 active-overflow extension closes the loop: when a node fills up, it picks coldest blobs by inverse blob-heat and pushes them to peers that have free disk and have opted into receiving overflow.
The plan + design rationale lives in DATAFORTS_BLOB_OVERFLOW_PLAN.md. Shipped as P1..P5 across five commits on the dataforts-overflow branch.
Disabled by default, one boolean to turn on
Active overflow is off in v0.2 deployments — every existing call site keeps the v0.2 pull-only posture without code changes. To opt in, operators flip a single boolean on the adapter:
// Construction-time, simple form:
let adapter = MeshBlobAdapter::new("mesh-prod", redex.clone())
.with_overflow(OverflowConfig { enabled: true, ..Default::default() });
// Or with typed tunables:
let adapter = MeshBlobAdapter::new("mesh-prod", redex.clone())
.with_overflow(OverflowConfig {
enabled: true,
high_water_ratio: 0.80,
low_water_ratio: 0.65,
max_pushes_per_tick: 8,
scope: TopologyScope::Zone,
tick_interval_ms: 30_000,
});
// Runtime toggle — no rebuild:
adapter.set_overflow_enabled(true);
adapter.set_overflow_enabled(false);
When enabled, the adapter advertises dataforts.blob.overflow on its capability set; peer-selection on the push side filters by this tag so overflow targets only nodes that have themselves opted in. Symmetric opt-in: the receive-side admission gate rejects pushes from a sender that isn't overflow-enabled.
OverflowConfig thresholds
pub struct OverflowConfig {
pub enabled: bool, // master switch
pub high_water_ratio: f64, // 0.85 default — triggers tick
pub low_water_ratio: f64, // 0.70 default — clears tick (hysteresis)
pub max_pushes_per_tick: usize, // 16 default — bandwidth burst cap
pub scope: TopologyScope, // Mesh default — push-target scope bound
pub tick_interval_ms: u64, // 30_000 default
}
Hysteresis mirrors the existing dataforts:blob-storage-unhealthy health-gate (95% / 85%) with looser thresholds because overflow fires before the unhealthy advertisement — by the time a node is unhealthy, overflow has already been shedding for a while.
G-7 — Active overflow admission
pub fn should_accept_overflow_from(
local_caps: &CapabilitySet,
sender_caps: &CapabilitySet,
blob_size_bytes: u64,
) -> OverflowVerdict;
Receive-side mirror of should_migrate_blob_to. Six ordered gates: NoStorageCap → NotParticipating → SenderNotOverflowing → Unhealthy → ScopeMismatch → InsufficientDisk. Each OverflowReject variant maps to a distinct Prometheus counter label so operators dashboard both sides.
The ordering matters operationally: a compute-only node surfaces NoStorageCap rather than NotParticipating, even when both gates would reject — the most actionable signal wins.
BlobOverflowController + tick driver
pub struct BlobOverflowController<'a> {
pub local_caps: &'a CapabilitySet,
pub capability_index: &'a CapabilityIndex,
pub heat_registry: &'a Arc<Mutex<BlobHeatRegistry>>,
pub refcount: &'a BlobRefcountTable,
pub config: &'a OverflowConfig,
}
The controller's candidates(now, size_for_hash) walks the heat registry in ascending-rate order (coldest first), filters out pinned + non-zero-refcount hashes, and for each remaining candidate selects an overflow-enabled peer with sufficient disk-free + matching scope. Target ranking: highest disk_free_gb wins (greedy spread across peers); ties broken by lowest node_id for determinism.
drive_blob_overflow_tick composes the controller + hysteresis state machine + the OverflowPushSink trait:
pub async fn drive_blob_overflow_tick(
controller: &BlobOverflowController<'_>,
sink: &dyn OverflowPushSink,
observation: OverflowTickObservation<'_>,
size_for_hash: impl Fn([u8; 32]) -> Option<u64>,
) -> BlobOverflowTickReport;
OverflowTickObservation bundles per-tick state (disk stats, hysteresis atomic, clock). The BlobOverflowTickReport carries every counter the Prometheus emitter needs.
MeshBlobAdapter::drive_overflow_tick(ctx, size_for_hash) is the 2-arg convenience wrapper — composes the controller, threads the adapter's refcount / config / overflow_active, runs the tick, auto-records the report into the adapter's metrics.
Wire protocol — OverflowPush RPC
pub struct OverflowPush {
pub blob_hash: [u8; 32],
pub size_bytes: u64,
pub sender_node_id: u64,
}
pub enum OverflowPushAck {
Accepted,
Rejected(OverflowReject),
OpenChunkFailed,
}
The chunk bytes themselves don't ride this RPC — the nudge tells the receiver to open the chunk channel against its local Redex with replication armed; the existing per-chunk replication runtime pulls the bytes from any holder advertising causal:<hash> (typically the sender). The RPC routes through the existing nRPC machinery under the dataforts.blob.overflow_push service name.
- Sender side:
MeshNode::send_overflow_push(target, hash, size) -> Result<OverflowPushAck, BlobError>— encodes the request, dispatches viaMeshNode::call, decodes the typed ack. - Receiver side:
MeshNode::serve_overflow_push(adapter) -> ServeHandleregisters theOverflowPushHandlerunder the service name. Each inbound request reads liveuser_caps_snapshot+ the capability index, runs admission, on Admit callsadapter.prefetch(BlobRef::small(...))to open the chunk channel. MeshNodeOverflowPushSink— concreteOverflowPushSinkimpl wrappingArc<MeshNode>. Maps non-Accepted acks to typedBlobError::Backendso the controller'spush_errorscounter bumps uniformly.
OverflowReject carries serde::{Serialize, Deserialize} so the typed reason rides inside OverflowPushAck::Rejected across the wire intact.
Prometheus counter family
The adapter's prometheus_text() body emits the full overflow surface:
dataforts_blob_overflow_pushes_admitted_total{adapter="..."} <counter>
dataforts_blob_overflow_push_errors_total{adapter="..."} <counter>
dataforts_blob_overflow_pushed_bytes_total{adapter="..."} <counter>
dataforts_blob_overflow_rejected_no_target_total{adapter="..."} <counter>
dataforts_blob_overflow_rejected_total{adapter="...",reason="no_storage_cap"} <counter>
dataforts_blob_overflow_rejected_total{adapter="...",reason="not_participating"} <counter>
dataforts_blob_overflow_rejected_total{adapter="...",reason="sender_not_overflowing"} <counter>
dataforts_blob_overflow_rejected_total{adapter="...",reason="unhealthy"} <counter>
dataforts_blob_overflow_rejected_total{adapter="...",reason="scope_mismatch"} <counter>
dataforts_blob_overflow_rejected_total{adapter="...",reason="insufficient_disk"} <counter>
dataforts_blob_overflow_high_water_triggered_total{adapter="..."} <counter> # false→true edges
dataforts_blob_overflow_low_water_cleared_total{adapter="..."} <counter> # true→false edges
dataforts_blob_overflow_active{adapter="..."} <gauge 0/1>
dataforts_blob_overflow_disk_ratio{adapter="..."} <gauge 0..1>
Sender's push_errors_total bumps on every non-Accepted ack (RPC transport + admission rejection + chunk open failure). The receiver's rejected_total{reason} family bumps on each admission rejection by variant — operators dashboarding both sides see matching volumes.
Hysteresis transitions only bump on the edge: false → true increments high_water_triggered_total, true → false increments low_water_cleared_total. Repeated active-during ticks don't bump either counter, so the metrics count distinct "overflow episodes" rather than steady-state ticks.
net-blob overflow status CLI
net-blob overflow status
net-blob --format json overflow status
Prints the configured boolean, the runtime overflow_active flag (set by the most recent tick on this process), the configured thresholds, and the cumulative counter family. JSON form is shape-stable: top-level keys adapter / config / active / counters, with every per-reason counter present even at zero (operator dashboards don't want missing keys).
Cross-binding surface
MeshBlobAdapter + the overflow surface ship across all four bindings (Rust, Python, Node/TypeScript, Go, C). Every binding takes a MeshBlobAdapter (or MeshBlobAdapterHandle* for C) at construction, exposes the v0.2 CRUD path (store / fetch / exists / prometheus_text), and surfaces the v0.3 overflow control as a paired getter/setter on enabled + active + config. Every binding uses the same OverflowConfig shape — enabled / high_water_ratio / low_water_ratio / max_pushes_per_tick / scope / tick_interval_ms — and accepts a bool form (the simple master-switch case) where the host language allows.
Rust — MeshBlobAdapter::with_overflow(OverflowConfig { .. }) builder, set_overflow_enabled(bool) / set_overflow_config(OverflowConfig) runtime setters, overflow_enabled() / overflow_active() / overflow_config() getters.
Python — MeshBlobAdapter(redex, "id", overflow=...) kwarg accepting bool or dict; set_overflow_enabled / set_overflow_config methods; overflow_enabled / overflow_active / overflow_config properties. Dict path enforces typed errors: unknown keys raise TypeError (typo defense — high_water_ration doesn't silently fail); invalid scope strings raise ValueError.
from net import MeshBlobAdapter, Redex
redex = Redex(persistent_dir="/data/blobs")
adapter = MeshBlobAdapter(
redex,
"py-prod",
overflow={"high_water_ratio": 0.90, "max_pushes_per_tick": 4, "scope": "zone"},
)
adapter.set_overflow_enabled(True)
print(adapter.overflow_active, adapter.overflow_config)
Node / TypeScript — new MeshBlobAdapter(redex, "id", { persistent?, overflow? }); overflow accepts a typed OverflowConfigJs object. setOverflowEnabled / setOverflowConfig runtime methods; overflowEnabled / overflowActive / overflowConfig getters.
import { MeshBlobAdapter, Redex } from '@ai2070/net';
const redex = new Redex({ persistentDir: '/data/blobs' });
const adapter = new MeshBlobAdapter(redex, 'node-prod', {
persistent: true,
overflow: {
enabled: true,
highWaterRatio: 0.80,
lowWaterRatio: 0.65,
maxPushesPerTick: 8,
scope: 'zone',
tickIntervalMs: 30_000,
},
});
adapter.setOverflowEnabled(false);
console.log(adapter.overflowEnabled, adapter.overflowActive, adapter.overflowConfig);
Go — NewMeshBlobAdapter(redex, "id", *MeshBlobAdapterOpts) constructor; Opts.Overflow *OverflowConfig is the typed config. SetOverflowEnabled(bool) / SetOverflowConfig(*OverflowConfig) methods; OverflowEnabled() / OverflowActive() / OverflowConfig() getters return (value, error) per Go convention. Typed sentinels: ErrBlob / ErrBlobClosed / ErrBlobInvalidConfig.
adapter, _ := net.NewMeshBlobAdapter(redex, "go-prod", &net.MeshBlobAdapterOpts{
Persistent: true,
Overflow: &net.OverflowConfig{
Enabled: true,
HighWaterRatio: 0.80,
MaxPushesPerTick: 8,
Scope: "zone",
},
})
defer adapter.Close()
adapter.SetOverflowEnabled(true)
C FFI — opaque MeshBlobAdapterHandle* from net_mesh_blob_adapter_new(redex, "id", persistent, overflow_json); the overflow config arrives as a JSON string at the boundary so the C consumer doesn't have to mirror the typed struct. Eleven new functions: _new / _free / _store / _fetch / _exists / _prometheus_text plus the v0.3 control family (_overflow_enabled / _overflow_active / _overflow_config returning JSON / _set_overflow_enabled / _set_overflow_config).
MeshBlobAdapterHandle* adapter = net_mesh_blob_adapter_new(
redex,
"c-prod",
/* persistent */ 1,
"{\"enabled\":true,\"high_water_ratio\":0.80,\"scope\":\"zone\"}"
);
net_mesh_blob_adapter_set_overflow_enabled(adapter, 0);
char* cfg_json = net_mesh_blob_adapter_overflow_config(adapter);
// ...consume cfg_json...
net_free_string(cfg_json);
net_mesh_blob_adapter_free(adapter);
The C surface requires building the cdylib with dataforts,netdb,redex-disk; the Go binding wraps these via cgo and the SDK READMEs document the per-binding shape (Rust / Python / TypeScript / Go / C).
Storage layout + safe-delete
Sender doesn't immediately delete the local copy on OverflowPushAck::Accepted — the durability watermark observation (sender polls capability index for receiver's causal:<hash> advertisement) is deferred to a future P6 follow-up. Today the local copy stays until the standard GC sweep collects it under retention + refcount-zero.
This is conservative-by-default: the receiver may have admitted but the chunk-pull could still fail before the bytes land. Operators running into "sender disk doesn't drain fast enough" today can flip gc --disk-pressure (which bypasses the retention floor for refcount-zero hashes) — the explicit watermark gate lands in v0.16+.
Hardening — clippy + arg-bundling
The OverflowTickContext<'a> + OverflowTickObservation<'a> borrow structs bundle the tick-driver args so neither drive_blob_overflow_tick (4 args) nor MeshBlobAdapter::drive_overflow_tick (2 args) trips clippy's too_many_arguments lint. No #[allow(clippy::too_many_arguments)] anywhere in the overflow surface — the bundling earns the clean signatures.
Test coverage
- P1: 17 pure-logic tests (
should_accept_overflow_from× 8 reject variants + admit path + ordering,BlobCapability::overflow_enabledround-trip × 2,OverflowConfigadapter surface × 5). - P2: 20 controller / tick / hysteresis tests (
step_overflow_hysteresis× 4 edge cases,BlobOverflowController::candidates× 7 filter paths, tick-driver tests × 6 against anOverflowPushRecordermock,scope_covers× 2,MeshBlobAdapter::overflow_activeshared-state × 1). - P3: 7 wire-format + integration tests (postcard round-trip × 5 variants + 2-node
MeshNode::send/serve_overflow_pushend-to-end × 2). - P4: 10 metrics + CLI tests (
record_overflow_tickbumps × 4 paths, per-reasonrecord_overflow_reject× 1, Prometheus body shape × 2, CLIoverflow statusHuman + JSON + metrics-body inclusion × 3). - P5: 12 Python pytest tests (default-off + bool-true + bool-false + dict-overrides + dict-prestage + scope-parsing + unknown-key + bad-scope + bad-type + runtime-setter + whole-config-setter + round-trip × 12).
Total: 66 new tests across the v0.3 overflow track.
Read-your-writes (Phase 5)
Every successful Tasks::create / Memories::insert / etc. returns a WriteToken { origin_hash, seq }. Pass it to wait_for_token(token, deadline) and the call blocks until the local fold has actually applied that sequence number — not just folded it. A producer reads its own write through the cache deterministically; no busy-poll, no time-window heuristic.
WriteToken
pub struct WriteToken {
pub(crate) version: u8,
pub(crate) origin_hash: u64,
pub(crate) seq: u64,
}
Fields are pub(crate); the public constructor is #[doc(hidden)]. FromStr is gated behind #[cfg(test)] or the wire-debug feature. Tokens are unforgeable only against the adapter that issued them (via origin binding); the threat model is documented inline (D-19).
wait_for_token — applied vs. folded
Pre-fix, wait_for_token delegated to wait_for_seq, which returned when the folded watermark passed seq — including events that FoldErrorPolicy silently skipped via RedexError::is_recoverable_decode. A producer whose write hit a skip got Ok(()) and then read state that didn't reflect its write.
v0.15 adds applied_through_seq() (events that actually ran through the fold) alongside the existing folded_through_seq() (events the fold saw). wait_for_token waits on applied, not folded; skipped events are no longer auto-acknowledged (D-17).
FoldStopped error variant
wait_for_seq previously returned Ok when running == false (the fold task crashed under FoldErrorPolicy::Stop). Every pending RYW wait resolved with a silent Ok(()) even though seq was never folded. v0.15 adds WaitForTokenError::FoldStopped { applied_through_seq }; the wait path checks applied_through_seq >= seq when it wakes due to running == false and surfaces the typed error when the fold actually stalled (D-18).
Non-blocking poll — deadline_ms == 0
wait_for_token(token, 0) now does a synchronous applied-vs-token check and returns Ok(()) / Err(Timeout) / Err(FoldStopped) without scheduling a wait. Pre-fix the FFI rewrote 0 to 1 ms, costing a real wait round-trip for a "is fold caught up?" probe (D-23). The synchronous-poll behavior is consistent across the FFI / Node / Go / Python surfaces; the Python surface promoted poll_for_token to the public API alongside wait_for_token so non-async Python callers can probe without spawning a task (N-4).
Process-wide in-flight cap
The 1024-deep wait-queue cap was per-adapter pre-fix; a process with 100 channels could stack 100 K outstanding RYW waiters. v0.15 ships set_global_ryw_inflight_cap(usize) for a process-wide bound; every wait_for_token call does a two-tier acquire (process-wide first, then per-adapter). The semaphore is renamed ryw_inflight_cap with a non-FIFO documentation note (the current implementation is Semaphore::try_acquire; true FIFO is deferred) (D-37, D-38).
Cross-binding API surface
| Binding | Surface |
|---|---|
| Rust | tasks.wait_for_token(token, Duration) / memories.wait_for_token(token, Duration); tasks.poll_for_token(token) synchronous variant |
| Python | tasks.wait_for_token(token, deadline_ms=…); deadline_ms=0 is a non-blocking poll (N-4) |
| Node | tasks.waitForToken(token, deadlineMs); deadlineMs === 0 is a non-blocking poll |
| Go | tasks.WaitForToken(token, timeout) + tasks.PollForToken(token) + tasks.WaitForTokenContext(ctx, token) non-blocking variant; Go context cancellation isn't propagated into the FFI wait — see WaitForTokenContext rustdoc for the contract (D-45, N-11) |
| C | net_tasks_wait_for_token / net_memories_wait_for_token; timeout_ms == 0 is a non-blocking poll. Every FFI entry wraps block_on in std::panic::catch_unwind(AssertUnwindSafe(…)); panics surface as NET_ERR_PANIC rather than unwinding across extern "C" (D-21) |
Channel-hash widening — u16 → u32 canonical
The wire NetHeader::channel_hash (16 bits, 65 536 buckets) routinely collides at mesh scale — birthday-paradox threshold ~300 channels. Pre-fix every substrate decision keyed on the wire u16: ACL (AuthGuard), storage (Redex), config (ChannelConfigRegistry), token (PermissionToken), RYW. Two unrelated channels colliding on u16 shared one ACL decision, one RedexFile, one config row.
v0.15 widens the canonical channel hash to u32 substrate-wide while keeping the wire NetHeader::channel_hash at u16 — the per-packet width is fixed by the 64-byte cache-line-aligned header budget. The wire u16 is now a fast-path filter hint only; wire-side collisions are benign because every non-fast-path decision (auth / storage / config / RYW) keys on the canonical 32-bit hash via registry-side disambiguation.
Mirrors the origin_hash u64-canonical / u32-wire precedent set in v0.13: per-packet width fixed, application layer wider, narrowing helper at the wire boundary.
Canonical type
pub type ChannelHash = u32;
impl ChannelName {
pub fn hash(&self) -> ChannelHash { … } // canonical u32
pub fn wire_hash(&self) -> u16 { … } // wire fast-path hint
}
pub fn channel_hash(name: &str) -> ChannelHash { … }
pub fn wire_channel_hash(name: &str) -> u16 { … }
ChannelHash joint-collision threshold is ~65 K channels per process (above realistic deployment), so the canonical key is treated as collision-free in fast paths.
ChannelConfigRegistry — dual index
pub struct ChannelConfigRegistry {
configs: DashMap<String, ChannelConfig>,
by_hash: DashMap<ChannelHash, Vec<String>>, // canonical (u32, rare collisions)
by_wire_hash: DashMap<u16, Vec<String>>, // wire (u16, routine collisions)
prefix_configs: DashMap<String, ChannelConfig>,
}
get(canonical) returns None on the rare canonical collision (forces caller fallback); get_by_wire_hash(wire) returns None on wire-bucket collision (used by receive-side dispatch, contrast with ChannelRegistry::get_all_by_wire_hash below). Removals stay collision-safe — remove(canonical) keys on the unique canonical hash; remove_by_name(name) is the explicit-name path.
ChannelRegistry — return the full collision set
ChannelRegistry::get_by_wire_hash was renamed to get_all_by_wire_hash and explicitly returns the full collision-bucket vector. This contrasts with ChannelConfigRegistry::get_by_wire_hash, which returns None on collision to force a safe default at the policy layer. The naming asymmetry is intentional — operators querying "what channels share this wire bucket" want the full set; the policy layer querying "what's the config for this packet" wants a unique answer or nothing.
AuthGuard — canonical u32 ACL
The bloom-filter key buffer widens from 10 to 12 bytes (u64 origin_hash + u32 channel_hash); check_fast / authorize / revoke signatures all take ChannelHash. The two-tier authorization shape (fast-path bloom + verified cache + exact-name backstop) is unchanged; the canonical hash makes the fast-path bloom collision-resistant at realistic scale. The exact-name backstop remains the only collision-free path for control-plane / storage authorization decisions where adversarial canonical-hash collisions matter.
PermissionToken — 161-byte wire form
issuer: 32 bytes (EntityId)
subject: 32 bytes (EntityId)
scope: 4 bytes (u32)
channel_hash: 4 bytes (canonical ChannelHash, u32; was u16)
not_before: 8 bytes (u64 unix timestamp)
not_after: 8 bytes (u64 unix timestamp)
delegation_depth: 1 byte (u8)
nonce: 8 bytes (u64)
--- signed ---
signature: 64 bytes (ed25519)
Total: 161 bytes (was 159). Signed payload: 97 bytes (was 95). PermissionToken::from_bytes rejects 159-byte input as TokenError::InvalidFormat; old tokens must be reissued under the wider form.
RPC inbound dispatcher — (canonical, dispatcher) pairs
MeshNode::register_rpc_inbound(channel_hash: ChannelHash, dispatcher) takes the canonical hash. The dispatcher map is indexed by the wire u16 for O(1) lookup on the inbound packet decode path; each bucket stores a Vec<(ChannelHash, RpcInboundDispatcher)> so wire-bucket collisions between independently-registered canonical channels don't share a dispatcher slot. RpcInboundEvent::channel_hash is the canonical u32 — dispatchers receive the disambiguated identity.
Five post-merge follow-up commits
A focused review pass landed five hardening commits on the channel-hash-32 branch after the primary widening:
| # | Commit | Concern | Test added |
|---|---|---|---|
| 1 | fda25a7d |
Race in unregister_rpc_inbound clobbering a concurrent sibling register |
sibling-survives + race-stress |
| 2 | af5f6c25 |
Stale "16-bit verified cache" comment in mesh.rs |
n/a (doc) |
| 3 | 1fb62fbc |
Stale 16-bit framing in two regression test docstrings | n/a (doc) |
| 4 | c141e691 |
Per-packet Vec allocation in the dispatch fast path |
end-to-end wire-bucket collision fan-out |
| 5 | c5e75ff6 |
get_by_wire_hash semantics divergence between registries |
full-collision-set contract test |
The single-dispatcher hot path (the overwhelming case at typical sizing) avoids the heap allocation entirely; the collision-set vector is built only when a wire bucket has more than one canonical entry. The unregister race is closed by atomic remove-if-present semantics; concurrent register + unregister can no longer leave the map in a torn state.
Dataforts greedy stays on wire u16
The greedy data-plane cache deliberately keys on the wire u16 (not the canonical u32) because the wire hash is what the inbound packet that triggered the observe call carries — there's no canonical lookup at packet decode time. The cache file is named dataforts/greedy/<hex16>; two wire-colliding channels share a cache file (a small mix-up at the data-plane layer; ACL and storage decisions stay collision-safe via the canonical hash).
Hardening — dataforts-feature two-pass review
Two coordinated review passes landed before the v0.15 branch cut. The primary review on the dataforts-feature branch surfaced 54 numbered items (D-1..D-54): 4 blockers, 19 highs, 24 mediums, 7 lows. An independent second pass on 2026-05-12 surfaced 11 N-series items (N-1..N-11): 3 highs, 6 mediums, 2 lows. All but three closed before merge (deferred with rationale in the tracking doc). The closures group by area:
Greedy correctness (D-1..D-8 + D-25..D-28 + N-5)
- Cluster-cap eviction withdraws chain announcements inline (D-1).
- Bandwidth-budget rejection bumps a distinct counter rather than dropping events silently (D-2).
upserton update subtracts the oldbytesbefore replacing the file pointer (D-3).chain_capsresolves the chain publisher's caps via the capability index, not the last-hop peer's (D-5).- TOCTOU on
is_new_channelcollapsed into a single locked get-or-insert (D-6). tokio::spawnper inbound event bounded by a semaphore (D-7).colocation_target_heldresolved from the cache map, not hardcodedNone(D-8).gravity_tickcoalesces N×announce_capabilitiesinto one viaannounce_heat_batch(D-25).- Retention-trim drift on
entry.bytesresyncs viaRedexFile::retained_bytes(D-26). - 5 cache-lock acquisitions per dispatch coalesced to 1 in the steady-state path; new-channel path takes 2 with TOCTOU re-check (D-28).
- Eviction explicitly drops
gravity.heatlock before callingsink.withdraw_chainto avoid lock-ordering hazards (N-5).
Gravity correctness (D-9..D-11 + D-29..D-30 + N-2 + N-9)
origin_hash == 0no longer collapses per-chain heat (D-9, fix at publish-side + defense-in-depth at gravity-runtime entry).HeatRegistrybounded + LRU-evicted + tick-pruned (D-10, N-2).- Inbound
heat:tags gated on the publisher's matchingcausal:claim (D-11; per-peer rate-limit deferred per N-8). should_emit_heatsubnormal-safe viais_normal()+ EPSILON-floor (D-29, N-9).- Log-scale wire normalization with configurable reference rate (D-30 / D-46).
Blob correctness (D-12..D-16 + D-31..D-36 + D-49..D-50 + D-52..D-53 + N-3 + N-6 + N-7)
FileSystemAdapter::storehash-verifies bytes (D-12).- URI-scheme keyed adapter dispatch closes authority confusion (D-13).
- 4-byte magic for
BlobRefdiscriminator closes payload-misclassification (D-14). BlobRefsize bounded;fetch_rangeguardsusizecast (D-15).- Streaming hooks on
BlobAdapter(D-16). - Log injection via
BlobError::NotFound(uri)sanitized (D-31). - Unique-suffix temp filenames (D-32);
fsyncof temp + parent dir (D-33). - Per-channel
BlobAdapterRegistryoverride (D-34). - Bounded concurrency on
spawn_blocking(D-35). - Conformance suite extended with idempotency / hash-mismatch / range-past-end / cross-blob isolation / random-ghost (D-36).
BlobErrormarked#[non_exhaustive](D-49).RedexFileConfig::blob_adapter_idunset surfaces the right error variant (D-50).OpaqueCtx(AtomicPtr<c_void>)collapsed to plain*mut c_void(D-52).- Adapter timeout user-tunable (D-53).
path_fordefends against symlinks in the shard root via canonicalize (N-3).- Windows rename-fallback TOCTOU on idempotent re-store hash-verifies existing content (N-6).
catch_unwind+ caller-held locks documented as a hazard inffi/mod.rs+ per-binding READMEs (N-7).
RYW correctness (D-17..D-19 + D-37..D-38 + D-45 + D-51 + N-4 + N-11)
wait_for_tokenwaits on applied seq, not folded (D-17).FoldStoppederror variant when fold task crashes mid-wait (D-18).WriteTokendoc-hidden constructor + threat-model docstring (D-19).ryw_inflight_caprename + non-FIFO doc note (D-37).- Process-wide
set_global_ryw_inflight_capwith two-tier acquire (D-38). - Go binding lands
Tasks+Memoriesadapters withWaitForToken+PollForToken+WaitForTokenContext(D-45). wait_duration_nanos_sumsaturating u128 → u64 cast (D-51).- Python
wait_for_token(deadline_ms=0)non-blocking poll consistent with FFI / Node / Go (N-4). - Go context cancellation doc contract clarified (N-11).
FFI / cross-binding (D-20..D-23 + D-39..D-44 + D-54 + N-1 + N-10)
- cgo externs link cleanly without
datafortsfeature viaNET_ERR_FEATURE_NOT_BUILTstubs (D-20). - Panics across FFI caught + remapped to
NET_ERR_PANIC(D-21). - Vtable per-field null-check (D-22).
timeout_ms == 0honored as non-blocking poll (D-23).mesh_arcdrop coverage via RAII guard rather than duplicated drop-on-error (D-39).- Node
await_tsfn_promiseapplies the 30 s timeout once (was 30 s × 2 → 60 s worst case) (D-40). - Node
DataGravityConfigJs*_secs / _mswidths match the Rust + Python + Go peers (D-41). - Python
Py<PyAny>adapters can no longer outlive interpreter finalization (D-42). - Python adapter
data.to_vec()copies insidepy.detach(D-43, N-1). - Go
omitemptydoc note on greedy / gravity numeric fields (D-44 deferred — substrate rejects0for every affected field;omitemptyis correct). - Go
runtime.SetFinalizerruns blockingCloseon the GC thread — doc note rather than refactor (D-54). - Python
atexitdrain counts drained vs. missing entries viaNET_PY_TRACE_ATEXITenv var (N-10).
Hygiene (D-47..D-48)
metrics.rschannel-cap race doc note (D-47)._force_use_hashmapdead allow removed (D-48).
The deferred N-8 (per-peer rate-limiting of heat: tags) is acknowledged in D-11 and tracked for a separate slice; the auth-via-causal-claim gate forecloses the dominant attack vector today.
Test hygiene
- Lib suite at 2645+ tests (was 2640+ at v0.14 release). 60+ net new tests across the four Rebel Yell phases + the channel-hash widening; every numbered review item ships with at least one regression where the shape made one possible. Notable additions: greedy admission + eviction unit coverage, gravity heat-counter decay + emission edge cases, blob conformance suite, RYW applied-vs-folded watermark separation, channel-hash canonical-vs-wire collision tests, RPC dispatcher race-stress + sibling-survives.
- Cross-binding wire-format fixtures regenerate against the 161-byte token wire form. The 159-byte token vectors under
tests/cross_lang_capability/rename and re-encode; binding-side tests that hardcoded the 159-byte length update accordingly. cargo clippy --all-features --all-targets -D warningsclean across substrate + every binding crate.cargo doc --all-features --no-depsclean underRUSTDOCFLAGS="-D warnings"—rustdoc::broken_intra_doc_linksandrustdoc::private_intra_doc_linksboth enforce.- Go
go vet ./...clean underCGO_ENABLED=1; the pre-existingtestOriginuint32/uint64mismatch incortex_test.gois fixed alongside the FFInet_channel_hashu16 → u32change.
Breaking changes
Wire format — PermissionToken is 161 bytes
PermissionToken::WIRE_SIZE grows from 159 → 161 bytes; the signed payload grows from 95 → 97 bytes. PermissionToken::from_bytes rejects 159-byte input as TokenError::InvalidFormat. Old tokens must be reissued; mixed v0.14 / v0.15 fleets cannot exchange tokens. Recommend lockstep upgrade.
Wire format — BlobRef magic widens to 4 bytes
BlobRef::MAGIC = [0xB0, 0xB1, 0xB2, 0xB3]. Pre-v0.15 1-byte-discriminator blob refs (if any pilot deployment serialized them) are rejected on decode. Dataforts is new in v0.15, so this only matters for pre-release pilots.
API — ChannelHash = u32 substrate-wide
ChannelName::hash()returnsu32(wasu16). NewChannelName::wire_hash() -> u16exposes the wire fast-path hint.channel_hash(name: &str) -> u32(wasu16). Newwire_channel_hash(name: &str) -> u16.AuthGuard::{check_fast, authorize, revoke, is_authorized}takeChannelHash(wasu16).PermissionToken::channel_hashisu32(wasu16);TokenScope::with_channel,try_issue,TokenCache::{check, get}all widen.MeshNode::register_rpc_inboundtakesChannelHash(wasu16);RpcInboundEvent::channel_hashisu32.ChannelConfigRegistry::{get, remove, priority}takeChannelHash; newget_by_wire_hash(u16)for receive-side disambiguation.ChannelRegistry::get_by_wire_hashrenamed toget_all_by_wire_hashand explicitly returns the full collision-bucket vector.
FFI — net_channel_hash takes uint32_t*
// v0.14
int net_channel_hash(const char* channel, uint16_t* out_hash);
// v0.15
int net_channel_hash(const char* channel, uint32_t* out_hash);
Go / Python / Node bindings widen their channel_hash / channelHash exports to uint32 / int (u32 range) / number (u32 range). TokenInfo.channel_hash fields widen to match.
API — Dataforts surface is new
Redex::enable_greedy_dataforts(mesh, GreedyConfig, local_caps, IntentRegistry), Redex::disable_greedy_dataforts(), Redex::enable_gravity_for_greedy(mesh, DataGravityPolicy), Redex::disable_gravity_for_greedy(), BlobAdapterRegistry, BlobRef, BlobAdapter trait, WriteToken, tasks.wait_for_token / memories.wait_for_token are all new in v0.15. Behind the dataforts Cargo feature; non-dataforts builds see typed RedexError stubs ("requires the dataforts feature; rebuild with --features dataforts") rather than a silent no-op.
Behavioral fixes that may surface as test breakage
- Greedy
dispatch_eventis now lock-coalesced. Tests that asserted on the pre-fix 5-lock-per-dispatch behavior will see 1 lock in the steady state, 2 in the new-channel path. HeatRegistryis capped at 8 K entries. Tests that fill the registry with > 8 K entries to observe unbounded growth will see LRU eviction.should_emit_heatreturnsSkipon near-zeroprev. Tests that injectedprev = 1e-300to observe the pre-fixinf-prone branch will see the bootstrap arm instead.wait_for_tokenreturnsErr(WaitForTokenError::FoldStopped)when the fold task crashed mid-wait. Tests that assertedOk(())against a fold-stopped adapter will see the typed error.wait_for_token(token, 0)is a non-blocking poll across every binding. Tests that injected 0 expecting a real1 mswait will see the synchronous return.PermissionToken::from_bytesrejects 159-byte input. Tests that hardcoded the 159-byte wire form will seeTokenError::InvalidFormat.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.15 line. Recompile / rebuild the binding cdylib (NAPI for Node, maturin for Python,cargo build -p net-compute-ffi+-p net-rpc-ffifor Go) with thedatafortsCargo feature on (pre-built release artifacts ship with the feature enabled). - Channel-hash type migration. Use
ChannelHash(u32) for ACL / storage / config / RYW decisions; useChannelName::wire_hash()/wire_channel_hash()for the 16-bit header value when constructing wire-level packets. The renames are compile errors —cargo build(and the binding-side TypeScript / Python static checks) drives the rewrite. - Token reissue.
PermissionTokenwire form is 161 bytes. Reissue tokens to clients; pre-v0.15 159-byte tokens are rejected on decode. The signed-payload field shifts mean old signatures don't verify against the new layout — there's no in-place upgrade. - Greedy opt-in. Channels that want greedy caching: call
Redex::enable_greedy_dataforts(mesh, GreedyConfig, local_caps, IntentRegistry)once after constructing theRedex(idempotent). The runtime registers aGreedyObserveron the mesh's inbound dispatch; admission decisions run per inbound event without any per-channel opt-in.Redex::disable_greedy_dataforts()removes the observer. - Gravity opt-in. Layer gravity on top of greedy with
Redex::enable_gravity_for_greedy(mesh, DataGravityPolicy). The tick loop spawns automatically; tunedecay_half_life_secs,tick_interval_ms,emit_threshold_ratio,normalization_reference_rateto match the deployment's read-rate skew. - Blob adapter registration. For channels that publish payloads above the inline threshold: register an adapter (
register_filesystem_blob_adapter(id, root)for the in-tree FS adapter;register_blob_adapter(id, instance)for a host-language adapter), thenblob_publish(adapter_id, uri, bytes)/blob_resolve(blob_ref)against the registered URI scheme. - RYW opt-in. Capture the
WriteTokenreturned from everytasks.create/memories.insert; pass it totasks.wait_for_token(token, deadline)before reading state that needs to reflect the write.deadline_ms = 0is a non-blocking poll. - Operator dashboards.
Redex::greedy_prometheus_text()emits per-channel greedy metrics in Prometheus text format. Heat emissions ride the existing capability-announcement metrics —dataforts_gravity_emit_total,dataforts_gravity_heat_registry_size, etc. - Single-binding deployments without dataforts. Builds without the
datafortsCargo feature surface typedRedexErrorstubs from everyenable_*entry point. The substrate substrate path is unchanged — RedEX, CortEX, NetDB, replication all work as in v0.14. - Cross-binding wire fixtures regenerated. If you have CI that asserts golden-vector parity against
tests/cross_lang_capability/, the 161-byte token form means token-bearing fixtures change. The blob-ref fixtures land for the first time in v0.15. - FFI consumers (C / cgo).
net_channel_hashtakesuint32_t*(wasuint16_t*). The four new Dataforts entry points (net_redex_enable_greedy_dataforts,net_redex_disable_greedy_dataforts,net_redex_enable_gravity_for_greedy,net_redex_disable_gravity_for_greedy) follow the existingnet_redex_*shape. Without thedatafortsfeature, the symbols returnNET_ERR_FEATURE_NOT_BUILTrather than failing to link. - Mixed v0.14 / v0.15 fleets. Replication traffic continues to work cross-version (the
SUBPROTOCOL_REDEXwire format is unchanged). Tokens do not (161 vs 159 bytes). Recommend lockstep upgrade for any deployment usingPermissionToken-bearing channels.
Named after Walter Hill's 1979 cult film and Rockstar Games' 2005 adaptation — a gang trying to make it home through hostile turf. Channels in this release do the same: replicas survive partitions, election storms, disk pressure, and divergent tails, and still converge on a consistent leader before the night is out.
v0.14 lands cross-node replication for RedEX channels end-to-end across the substrate and all five bindings. v0.13 ("Chippin' In") made capability the load-bearing layer; v0.14 makes replication the load-bearing layer underneath the channel surface. SUBPROTOCOL_REDEX is now a real wire codec, ReplicationCoordinator is a real tokio runtime task with a 4-state machine pinned per plan §3, leader election is deterministic nearest-RTT with a NodeId tiebreak (no broadcast, no epoch — microseconds-wide convergence), and catch-up is pull-based with bandwidth budgets and a 64 MiB hard ceiling. Every binding exposes the same enable_replication(mesh) / open_file(name, cfg.with_replication(Some(rep))) surface and the same per-channel Prometheus snapshot.
The hardening posture from the Black Diamond line continues — every new surface ships with handle-lifetime, panic-safety, FFI-soundness, lock-order, and cancel-safety guarantees consistent with v0.11 / v0.12 / v0.13 — and a sixty-four-item second-pass review (docs/misc/CODE_REVIEW_2026_05_11_REDEX_DISTRIBUTED.md) shipped its closure commits before the v0.14 branch cut.
Alongside the replication landing, v0.14 carries two cross-cutting breaking changes: capability hardware / network units switch from MB / Mbps to GB / Gbps end-to-end, and the predicate-on-the-wire header renames from cyberdeck-where: to net-where: (predicate envelope ABI bumped to 2).
RedEX Distributed (substrate)
The implementation plan in REDEX_DISTRIBUTED_PLAN.md phases A–I all closed before v0.14. The shape:
ReplicationConfig
pub struct ReplicationConfig {
pub factor: u8, // replicas including leader; 1..=16, default 3
pub placement: PlacementStrategy, // Standard / Pinned([NodeId]) / ColocationStrict
pub heartbeat_ms: u64, // 100..=300_000, default 500
pub leader_pinned: Option<NodeId>, // pin election outcome to a specific NodeId
pub on_under_capacity: UnderCapacity, // Withdraw (default) / EvictOldest
pub replication_budget_fraction: f32, // share of measured NIC peak; 0.0 < f ≤ 1.0
}
PlacementStrategy::Standard defers to the v0.13 PlacementFilter axes (scope filter, proximity max-RTT, capability intent matching, anti-affinity, custom-filter callback). Pinned([NodeId]) and ColocationStrict skip the filter chain. UnderCapacity::Withdraw (the default) drops the replica role and lets the leader's other replicas absorb the redundancy responsibility; EvictOldest runs RedexFile::sweep_retention against the configured caps and stays in Replica. validate() enforces every invariant at construction; binding layers run it before crossing the FFI so a malformed config can't leak into the coordinator's hot loop.
Wire protocol — SUBPROTOCOL_REDEX
A new subprotocol family at 0x0E00. Four message types pinned at byte-level:
SYNC_REQUEST(0x20, replica → leader) — fixed-size{ channel_id, since_seq, chunk_max }.SYNC_RESPONSE(0x21, leader → replica) — variable; carries{ channel_id, first_seq, leader_first_retained_seq, events: [{event_seq, payload_len, payload}] }. The newleader_first_retained_seqfield lets the replica disambiguate retention-trim from split-brain divergence; legitimate trim withfirst_seq == leader_first_retained_seqtriggers skip-ahead viaRedexFile::skip_to, any other gap shape NACKs back and bumpsdataforts_replication_skip_ahead_total.SYNC_HEARTBEAT(0x22, bidirectional) — fixed-size{ channel_id, tail_seq, role, wall_clock_ms }. Pinned at 52 bytes; the role byte is the validator-checkedReplicaRolediscriminant.SYNC_NACK(0x23, leader → replica) — variable; carries{ channel_id, since_seq, error_code, detail_len, detail }. Error codes:1 NotLeader/2 BadRange/3 Backpressure/4 ChannelClosed.detailtruncates at a UTF-8 char boundary ≤u16::MAXso a multi-byte codepoint straddling the cap can't ship invalid UTF-8 to the peer.
Codec is hand-rolled (no serde over the wire) for byte-stable round-trips, validated by byte_layout_pinned tests per message type. Truncation errors carry (need, have) for diagnostics; need = consumed + still_needed so a peer logging the value sees an accurate frame-completion estimate.
ReplicationCoordinator — the 4-state machine
pub enum ReplicaRole { Idle, Replica, Candidate, Leader }
Transitions are matrix-validated and serialized through an outer tokio::sync::Mutex<()> so the state write + chain-tag side-effect (announce_chain / withdraw_chain against MeshNode) can't interleave. Two transition_to calls racing one another produce a deterministic sequence: T1's Replica → Candidate announce never lands after T2's Idle withdraw. The transition signals (CapabilitySelected, MissedHeartbeats, ElectionWon, ElectionLost, GracefulRelinquish, DiskPressureWithdraw, ChannelClose) are pinned per plan §3; ChannelClose is the universal escape valid from any state, used by the disk-pressure / channel-closed paths when the current role isn't Replica.
The coordinator surfaces two error variants:
CoordinatorError::Transition— the validator rejected the triple. State unchanged.CoordinatorError::TagSink— the state mutation already happened; only the chain-tag side-effect failed. Operator observes a divergence between local state and advertised state until the next successful announce. Runtime handlers clear the believed leader on both variants so the next tick re-enters discovery cleanly.
Replica selection vs. leader election
Two distinct subsystems per plan §4:
Placement consults
PlacementFilterto choose which N nodes carry the channel's replica set when the channel is first opened or on roster change.Standardflows through the v0.13 scoring;Pinnedskips it. The selected set is published via thecausal:<hex>chain-tag layer so peers discover holders without a centralized membership view.Leader election is a pure function over each healthy replica's locally-known state:
elect(replica_set, self_id, rtt_to, health_of) -> ElectionOutcome:
R = { r ∈ replica_set : health_of(r) }
sorted = R sorted by (rtt_to(self, r), r.node_id_lex) // tie-break: lexicographic NodeId
return ElectionOutcome::PeerWins(sorted[0])
| ElectionOutcome::SelfWins
| ElectionOutcome::NoEligibleReplica
No broadcast, no epoch, no collection window. Every healthy replica computes the same winner from the same (replica_set, self_id, rtt_to, health_of) tuple, so leader-loss recovery converges in microseconds without the wire protocol getting involved. Peers with rtt_to == None (no recent ping measurement) rank at Duration::MAX rather than getting excluded — health already filtered the candidate set, and the NodeId tiebreaker keeps the outcome deterministic among any equally-unmeasured peers.
Pull-based catch-up
Replicas drive SYNC_REQUEST(since_seq=local_next, chunk_max=N) on every tick where is_leader_silent == false && believed_leader.is_some() && local_next < leader_tail_seq. The leader's handle_sync_request reads [since_seq, since_seq+chunk_max) from its local file, packs into a SYNC_RESPONSE, and ships. The replica's apply_sync_response validates strict monotonicity (prev.checked_add(1)), enforces a 64 MiB hard chunk ceiling even for the "admit at least one event" branch (so an oversize first event NACKs back rather than DOSing the wire), and routes typed RedexError variants (DiskPressure, Closed) to the right runtime handler.
Bandwidth budgets
BandwidthBudget is a token bucket sized at replication_budget_fraction × measured_NIC_peak. The catch-up loop calls try_consume(estimated_bytes, now) before shipping each chunk; full bucket admits; partial defers and NACKs back Backpressure. Oversize requests (a single event larger than one-second's capacity — rare but representable) admit as a one-off and drain the bucket fully, so the channel can never deadlock trying to ship an event it can never afford.
Heartbeats + repair
HeartbeatTracker per channel per node holds (last_seen, role, tail_seq) for every peer. The runtime tick emits a heartbeat to every non-self peer in the replica set when role ∈ {Leader, Replica}; inbound heartbeats update the tracker and refresh the believed_leader cell. is_leader_silent trips when now - last_seen > heartbeat_ms × miss_threshold (default 3× = 1.5 s at the 500 ms heartbeat), triggering the Replica → Candidate transition and the in-tick election. heartbeat_ms is now validated to [100, 300_000] so a unit-confused config (μs instead of ms) can't saturate the silence-detection multiplication and silently disable failover.
Failover + replica rejoin
Plan §7: leader loss → silence detection → Candidate → election → Leader/Replica per ElectionOutcome. Plan §8: a replica rejoining from a longer-than-trim outage observes first_seq > local_next on the next SYNC_RESPONSE; if first_seq == leader_first_retained_seq the gap is a legitimate retention trim and RedexFile::skip_to(first_seq) runs (bumping dataforts_replication_skip_ahead_total), any other shape is treated as divergence and NACKs back.
Cross-binding API surface
Every binding ships the same two-method extension to its existing Redex type:
enable_replication(mesh)— installs theSUBPROTOCOL_REDEXinbound router on the mesh and armsRedex::open_fileto spawn a replication runtime when the suppliedRedexFileConfigcarriesreplication: Some(ReplicationConfig). Idempotent: a second call with the same mesh is a no-op.open_file(name, cfg)— whencfg.replication.is_some(), spawns a per-channelReplicationRuntime(tokio task +HeartbeatTracker+BandwidthBudget+ReplicationCoordinator) and registers it on the inbound router. Reopen with a structurally-differentReplicationConfigreturns a typed error rather than silently reusing the original.
The substrate exposes Redex::replication_runtime_count(), Redex::replication_coordinator_for(name), Redex::replication_status_snapshot(), and Redex::replication_metrics_snapshot(). The metrics snapshot is also rendered to Prometheus text via Redex::replication_prometheus_text() for direct scraping.
Metrics
Per-channel atomic counters (ChannelMetricsAtomic) — sync_bytes_total, sync_request_total, sync_response_total, sync_nack_total, leader_changes_total, election_thrash_total, under_capacity_total, skip_ahead_total, applied_events_total, applied_bytes_total, leader_lag_micros, replica_lag_micros. Gauges (leader_lag_micros, replica_lag_micros) saturate one tick below LAG_NOT_OBSERVED = u64::MAX so a follow-up arithmetic operation can't accidentally collide with the sentinel. The Prometheus registry caps at 4096 channels to bound a hostile multi-channel scrape; entries past the cap are silently dropped at insertion.
Observability + operator ergonomics
Redex::replication_status_snapshot() returns a Vec<ChannelReplicationStatus> with channel, role, replica_set, believed_leader, tail_seq, lag_micros, under_capacity_total per channel. Plug into a Prometheus exporter via the replication_prometheus_text() text-format helper; pipe into a Grafana dashboard via the per-channel labels.
RedEX Distributed test strategy
The plan's test matrix landed in full:
- Unit — pure-function coverage for
replication_state,replication_election,replication_heartbeat,BandwidthBudget,replication_metrics, the wire codec, andreplication_catchup. Every pre-fix correctness item from the second-pass review ships with at least one regression test. - Integration (e2e) — multi-tokio-thread tests under
tests/redex_replication_e2e.rscovering two-node catch-up, leader-close → replica election, three-node fanout, lag-driven catch-up, heartbeat round-trip, and thebandwidth_budget_metric_field_is_plumbedsmoke. Thereplication_overhead_within_30_percent_budgetperf-budget test is marked#[ignore]and lives off CI's default matrix — wall-clock perf on shared CI runners isn't a stable signal. - DST (deterministic-simulation) — 14 scenarios under
tests/redex_replication_dst.rscovering happy-path catch-up, isolated-replica no-advance, partition heal, asymmetric / symmetric failover, three-node central-peer convergence, restart-during-sync, divergence-freedom after partition-heal AND after kill-revive (the original C-2 single-path scenario expanded), election storms (the C-1 scenario; storm rounds now assertelection_thrash_totalbumps), andwall_clock_msdeterminism. The harness derives wall-clock time from a step counter, not realInstant::now, so traces reproduce byte-identically across machines. - Loom — atomic-pattern models for
RedexFile::close's swap-true-on-close, therecord_tail_seqCAS loop, the replication metrics counters under concurrent increment (including a three-way same-counter contention case), and thetry_first_closefirst-call-wins flag.
Hardening — redex-distributed second-pass review
A two-pass review of the replication branch (docs/misc/CODE_REVIEW_2026_05_11_REDEX_DISTRIBUTED.md) landed sixty-four numbered items (R-1..R-64) plus four coverage gaps (C-1..C-4). The first pass closed forty-four; the second-pass review on 2026-05-12 surfaced one regression in the original R-23 fix plus nineteen new items; all closed before the v0.14 branch cut. Grouped by area:
Runtime / coordinator correctness
- Role-flip TOCTOU closed.
SyncRequestandSyncResponsehandlers re-checkcoordinator.role()immediately before the dispatcher send so a concurrent transition between the entry check and the outbound ship triggers a clean NACKNotLeaderrather than a response from a node that no longer claims leadership. - Chain-tag side-effects serialized. The coordinator's
transition_toholds atokio::sync::Mutex<()>across the state update + metric bumps + sink call so two racing transitions can't interleaveannounce_chainfrom a stale role over awithdraw_chainfrom a fresher one. - NACK
NotLeader/BadRangeactually recover.NotLeaderclears the believed leader so the next tick re-resolves viafind_chain_holders;BadRangecallsRedexFile::skip_to(since_seq + 1)and re-issues the request, rather than logging-and-dropping. - Post-election failure no longer strands Candidate. When the second
transition_to(Candidate → Leader / Candidate → Replica) surfacesTagSink(state moved, side-effect failed) orTransition(state moved out from under us), both error branches clear the believed leader so the next tick re-enters discovery from a clean slate. - Disk-pressure / channel-closed pick the valid signal per current role. The transition matrix only permits
DiskPressureWithdrawonReplica → Idle; Leader / Candidate variants now route throughChannelClose(the universal escape) so a Leader observing disk pressure actually withdraws rather than logging the matrix-reject and continuing to write through. cancel()can't hang. Usestry_send(Shutdown)first; onFull, aborts theJoinHandledirectly so a wedged task with a saturated inbox can't block the caller waiting on a buffer the task may never drain.DroponReplicationRuntimeHandleaborts the task. The strong-reference cycleMeshNode → router → handle → task → dispatcher Arcis broken unconditionally when the handle goes out of scope, not just via the canonicalReplicationWiring::dropun-installation.is_stoppedconsults an explicit flag flipped aftercancel()'s.awaitreturns, not theJoinHandleslot — two concurrentcancel()s racing ontask.lock().take()could previously let the loser observeNoneand reportstopped == truebefore the winner had finished joining.- Channel-id validation defense-in-depth on every inbound type.
SyncRequest,SyncResponse,SyncNack,Heartbeatall gate onmsg.channel_id == inputs.channel_idat the runtime boundary so mesh misroute can't poison the tracker. GapBeforeChunkunderflow closed.first_seq.saturating_sub(local_next)plus adebug_assert!(first_seq > local_next)belt-and-suspenders.
Catch-up correctness
- Retention-trim vs. divergence disambiguation.
SyncResponsecarriesleader_first_retained_seqon the wire; the replica treatsfirst_seq == leader_first_retained_seqas a legitimate trim (skip-ahead viaRedexFile::skip_to) and any other gap shape as divergence (NACK back, bump counter, log loudly). - Empty chunk validates
first_seq. The short-circuit onresponse.events.is_empty()now validatesfirst_seq >= local_nextso a leader bug emittingfirst_seq = 999on an empty chunk isn't silently accepted. - 64 MiB hard ceiling enforced for oversize first event. The "admit at least one event" branch rejects events larger than
CHUNK_MAX_HARD_CEILING_BYTESrather than shipping wire bytes that the replica's local append would refuse. prev + 1strict-monotonicity useschecked_add. Practically unreachable; surrounding code usedsaturating_*and the asymmetry was the real bug.- Lag-driven
SyncRequestfiltersbelieved_leader != selfso a test-setup loopback or tracker misuse can't make the runtime issue aSyncRequestto itself.
Wire codec
SyncNack::from_bytestruncation reports correctneed. The R-23 fix shipped forSyncResponsebut missed theSyncNackarm in the original commit; the second-pass review caught and fixed it.SyncNack::to_bytestruncates at a UTF-8 char boundary. A multi-byte codepoint straddlingSYNC_NACK_DETAIL_MAXpreviously shipped invalid UTF-8 that the decoder rejected, losing the structured error code along with the diagnostic.WireError::Truncated.needformula correct everywhere.need = consumed + still_neededin every arm — both header reads and per-event payload reads.
File / manager
RedexFile::skip_topanic-safe swap order. Builds the new index / timestamps into tempVecs, callsevict_prefix_toagainst the segment, then assigns the new index. Pre-fix a panic between the index swap and the eviction call would leave the index referencing pre-eviction offsets.- Reopen with differing replication config rejects with a typed error rather than silently reusing the original. Compares against the live coordinator's config; accepts
None ↔ NoneandSome(cfg_a) ↔ Some(cfg_b)where the two are structurallyPartialEq, rejects everything else. mod replicationdual public surface collapsed. The flat re-exports underredex::are now the only public path;pub mod replicationis gone.- Lag saturation pinned with a named constant
LAG_SATURATED_MICROS = LAG_NOT_OBSERVED - 1and a test asserting the gap from the sentinel is preserved.
Mesh / dispatch
from_node == 0sentinel collision rejected. The replication inbound arm mirrors the reflex handler's guard — a peer whosefrom_nodefalls back to0(the validNodeIdsentinel collision) is dropped rather than entering the tracker.
Bindings / FFI
- Python
replication=Falsewithreplication_*kwargs rejects with a typedRedexErrorrather than silently dropping the other kwargs. enable_replicationis a typedRedexErrorstub without thenetfeature in both Node and Python, rather thanTypeError: redex.enableReplication is not a function/AttributeError. The Pythonreplication_runtime_count/replication_prometheus_textgates the same way.net_redex_enable_replicationdropsBox<Arc<MeshNode>>on every error path. Doc-comment now states "consumed regardless of return code."net_redex_open_fileandnet_redex_file_tailpre-zero*out_handle/*out_cursoron entry so a cgo / C consumer reading the slot after a non-zero return sees null rather than stale stack data.- Python
runtime.block_onpaths release the GIL viapy.detachacross the blocking open / open-from-snapshot / tail / watch / snapshot-and-watch paths. Existing precedent (wait_for_seq,__next__) already did this; the cortex open / tail / watch paths now match. - Node
RedexFile.syncandRedexFile.closeare async — disk I/O dispatches viatokio::task::spawn_blockingonto the napi worker pool instead of running on the JS event-loop thread. The other read-side methods stay sync (in-memory only). - Python rejects kebab-case spellings for
colocation_strict/evict_oldest; Node rejects snake_case for the same (each binding accepts only its idiomatic spelling). The FFI core remains liberal so the Go-facing JSON shape can use either. Pinned([])rejected at the binding layer with a typed error rather than falling through to the core validator.leader_pinnedcross-checked againstpinned_nodesat the binding layer whenplacement == Pinned.- Node
redex_errdocuments theredex:prefix contract inindex.d.tsso JS-side operators can string-sniff one.message.startsWith("redex:")against a pinned shape. - Go
OpenFiledistinguishesErrInvalidReplicationConfigfromErrReplicationRequiresEnable. Binding-side validator covers shape errors plusFactor/HeartbeatMsranges; only the FFINET_ERR_REDEXfor replication-not-enabled falls into the second sentinel. - Go
RedexFile.muusessync.RWMutexso appends / reads aren't serialized per file. The Rust substrate'sHandleGuardis a reader-counter; pre-fix the Go binding's mutex defeated that. - Go
typedef ArcMeshNodealiases the upstreamnet_compute_mesh_arc_topaque typedef so the same Archandle works through both surfaces.
Hygiene + coverage
- Election sort uses
sort_unstable_by. The total compound key(rtt, node_id)provides determinism; stability isn't load-bearing. - Event-vec preallocation cap (4096) documented in the wire codec.
u32::try_from(payload_len).unwrap_or(u32::MAX)carries adebug_assert!so accidental misuse surfaces in debug builds rather than silently corrupting on the wire.- DST harness
wall_clock_msderives from the step counter, not realInstant::now. Traces reproduce byte-identically across machines. - DST election-storm scenario asserts
election_thrash_total. The harness mirrors the production coordinator's counter locally so storm rounds can observe the gauge without rewiring the harness around the async coordinator. - Divergence-freedom check runs after
partition_healAND afterrestart_during_sync, not just on the happy path. - e2e flake-prone test marked
#[ignore]. Thereplication_overhead_within_30_percent_budget1.3× wall-clock budget is opt-in viacargo test -- --ignoredrather than running on shared CI runners. - e2e
bandwidth_budget_is_observable_in_metricsrenamed tobandwidth_budget_metric_field_is_plumbedso the test name matches what the test asserts (field plumbing under the wire path, not budget engagement; the budget-fired path is unit-tested underreplication_catchup). - Loom metrics model exercises a three-way same-counter contention case beyond the existing two-thread mixed-counter races.
BandwidthBudget::try_consumehandles oversize requests via the full-bucket admit-once-and-drain escape hatch so a single event larger than one-second's capacity can't deadlock the channel.- Election ranks unmeasured-but-healthy peers at
Duration::MAXrather than excluding them — health already filtered the candidate set, and the NodeId tiebreaker keeps the outcome deterministic among any equally-unmeasured peers.
CI
- Three new CI jobs.
redex-replication-e2eruns the multi-tokio-thread integration suite under--features "redex net";redex-replication-dstruns the deterministic-simulation harness under--features redex;loom-modelsruns the atomic-pattern loom tests underRUSTFLAGS=--cfg loom. All three gate theredex-distributedmerge.
Capability hardware units — MB → GB / Mbps → Gbps
v0.14 changes the hardware-axis numeric units from megabyte / megabit-per-second to gigabyte / gigabit-per-second across core and every binding. The tag keys, predicate builders, FFI shapes, and JSON schemas all rename. This is a breaking wire-format change for any CapabilitySet that carries hardware numerics.
The motivation is operator ergonomics — fleets in 2026 routinely advertise hundreds of GB of memory and tens of Gbps of network capacity, and the MB / Mbps wire shape forced operators to read values like 65_536 and 10_000 when 64 / 10 is what they meant. The smaller numeric range also fits cleanly in u32 for the wire encoding.
Tag / key renames
| Old (v0.13) | New (v0.14) |
|---|---|
hardware.memory_mb |
hardware.memory_gb |
hardware.gpu.vram_mb |
hardware.gpu.vram_gb |
hardware.storage_mb |
hardware.storage_gb |
hardware.network_mbps |
hardware.network_gbps |
hardware.accelerator.<i>.memory_mb |
hardware.accelerator.<i>.memory_gb |
Adjust values when migrating: 65_536 MB → 64 GB, 81_920 MB → 80 GB, 10_000 Mbps → 10 Gbps.
Filter / predicate renames
| Old | New |
|---|---|
min_memory_mb |
min_memory_gb |
min_vram_mb |
min_vram_gb |
min_storage_mb |
min_storage_gb |
min_network_mbps |
min_network_gbps |
The predicate builders (p.minMemory(...), p.minVram(...), etc. in TS; the p.min_memory(...) family in Python; Predicate{}.MinMemory(...) in Go) now produce NumericAtLeast tags whose key is memory_gb / vram_gb / storage_gb / network_gbps.
Binding surfaces
| Binding | Renamed fields / keys |
|---|---|
| Rust core | HardwareCapabilities::memory_gb, GpuCapability::vram_gb, HardwareCapabilities::storage_gb, HardwareCapabilities::network_gbps, AcceleratorCapability::memory_gb. Capabilities::with_memory(gb) takes GB; ResourceEnvelope::max_memory_gb, ResourceClaim::memory_gb, TopologyHint::{uplink_gbps, downlink_gbps} all moved to GB / Gbps. |
| Go | HardwareCaps.MemoryGB, GPUInfo.VRAMGB, HardwareCaps.StorageGB, HardwareCaps.NetworkGbps, AcceleratorInfo.MemoryGB. |
| Node | Hardware.memoryGb, Hardware.storageGb, Hardware.networkGbps, GpuInfo.vramGb, AcceleratorJs.memoryGb (all index.d.ts). |
| Python | dict keys memory_gb / vram_gb / storage_gb / network_gbps; accelerator dict key memory_gb. Stubs (net_sdk.*.pyi) and tests updated. |
| C / FFI | Capability / filter JSON uses *_gb keys (min_memory_gb, min_vram_gb, min_storage_gb) and network_gbps. |
Refactors
The core schema (AXIS_SCHEMA) and tag codec emit / parse the new *_gb / *_gbps keys. Placement / scoring and proximity tiers use a 16 GB baseline (was 16 GB previously; the renames are nominal, not behavioral). Serialization APIs that took MB-shaped values now take GB. Safety types and topology hints align. Docs, benches, examples, and every test fixture / cross-binding golden vector regenerate against the new shape; the final sweep removed lingering network_mbps references across tests/cross_lang_capability/ and the per-binding compat suites.
Cross-binding fixtures
The thirteen fixtures under tests/cross_lang_capability/ regenerate against the new unit. predicate_eval, capability_set_diff, capability_validation, placement_score, and the numeric-parity fixtures all carry GB / Gbps values. predicate_nrpc_envelope.json bumps abi_version_expected: 1 → 2 (see below).
Predicate-on-the-wire header — cyberdeck-where: → net-where:
The HTTP / nRPC header carrying predicates from caller to callee was named cyberdeck-where: in v0.13 — the project umbrella on the wire. v0.14 renames to net-where: for three reasons:
- HTTP / nRPC convention names the protocol, not the parent organization. HTTP doesn't have
w3c-content-type:;traceparent/idempotency-keyuse system-level prefixes, not org names. The umbrella-on-the-wire shape was an outlier. - The header is not nRPC-specific even though it currently rides nRPC. Predicates are protocol-agnostic; any future predicate-bearing surface (raw channel pre-filter, subprotocol call hook, …) should ride the same name.
net-where:brackets the right layer (the net crate / SDK), not a specific service inside it. - Symmetric naming with the substrate crate. Net's other reserved headers and protocol identifiers carry the
net-/net_prefix; lining this one up makes the surface easier to grep and easier to teach.
RPC_WHERE_HEADER constant
Every binding exports the new name as a pinned constant:
- Rust:
net::adapter::net::behavior::predicate::RPC_WHERE_HEADER = "net-where" - TS:
import { RPC_WHERE_HEADER } from '@ai2070/net-sdk' - Python:
from net_sdk import RPC_WHERE_HEADER - Go:
net.RPCWhereHeader - C:
NET_PREDICATE_WHERE_HEADERmacro innet.go.h
Server-side decoders accepting the v0.13 cyberdeck-where: name are not provided. Mixed v0.13 / v0.14 fleets cannot exchange predicates over the wire; recommend lockstep upgrade alongside the capability-unit migration.
Predicate envelope ABI version bump
tests/cross_lang_capability/predicate_nrpc_envelope.json bumps abi_version_expected: 1 → 2 to signal the wire-format change. No binding-side ABI version constants pin to 1 — none of the per-binding tests asserted on the envelope fixture's version — so the bump is informational + future-defensive. Future header / envelope changes in v0.15+ will bump to 3 against the same fixture.
Test hygiene
- Cross-binding wire-format fixtures regenerate against the new units + header name. Thirteen fixtures under
tests/cross_lang_capability/, all versioned viaabi_version_expected: 2for the predicate envelope (other fixtures continue at1— only the envelope carries the ABI version field today). - Three new CI jobs.
redex-replication-e2e,redex-replication-dst,loom-modelsgate the merge. - Lib suite at 2640+ tests (was 2330+ at v0.13 release). 300+ net new tests across the replication + regression paths; every numbered review item ships with at least one regression where the shape made one possible.
cargo clippy --all-features --all-targets -D warningsclean across substrate + every binding crate.cargo doc --all-features --no-depsclean underRUSTDOCFLAGS="-D warnings"— bothrustdoc::broken_intra_doc_linksandrustdoc::private_intra_doc_linksenforce.
Breaking changes
Wire format — SUBPROTOCOL_REDEX is new
SUBPROTOCOL_REDEX = 0x0E00 is a new mesh subprotocol family; v0.13 nodes don't speak it. Mixed v0.13 / v0.14 fleets cannot exchange replication traffic. Channels opened with replication: None continue to work cross-version (same single-node behavior as v0.13).
Wire format — capability hardware units
v0.14 breaks wire compatibility with v0.13 for CapabilityAnnouncement / CapabilityDiff carrying hardware numerics. hardware.memory_mb / hardware.gpu.vram_mb / hardware.storage_mb / hardware.network_mbps / hardware.accelerator.<i>.memory_mb rename to the *_gb / *_gbps shape. v0.13 receivers parse v0.14 announcements as Tag::Legacy (unknown axis-prefixed tags pass through under the forward-compat rule) — the values survive the round-trip but no longer satisfy min_memory_mb / etc. filters, so placement decisions on a v0.13 receiver may produce different verdicts. Recommend lockstep upgrade.
Wire format — cyberdeck-where: → net-where:
v0.14 renames the predicate-on-the-wire HTTP header. v0.13 servers expecting cyberdeck-where: won't see v0.14 callers' header values; v0.13 callers' cyberdeck-where: won't be read by v0.14 servers. Mixed fleets must either upgrade lockstep or maintain a transitional gateway that rewrites the header on the way through.
Rust core (net crate) — API surface
Capabilities::with_memory(value)takes GB, not MB. Same for the resource-envelope / claim / topology types:ResourceEnvelope::max_memory_gb,ResourceClaim::memory_gb,TopologyHint::{uplink_gbps, downlink_gbps}.HardwareCapabilitiesfield renames —memory_gb,gpu.vram_gb,storage_gb,network_gbps.AcceleratorCapability::memory_gb.adapter::net::redexexports — new typesReplicationConfig,PlacementStrategy,UnderCapacity,ReplicationCoordinator,ReplicationCoordinator::transition_to,ReplicaRole,TransitionSignal,StateTransition,HeartbeatTracker,PeerState,BandwidthBudget,ReplicationMetricsRegistry,ChannelMetricsAtomic,ChainTagSink,ChannelIdentity,CoordinatorError,elect,ElectionOutcome,ChannelReplicationStatus. The wire codec types (SyncRequest,SyncResponse,SyncHeartbeat,SyncNack,SyncNackError,SyncEvent,WireError,SUBPROTOCOL_REDEX,DISPATCH_SYNC_*,SYNC_NACK_DETAIL_MAX) re-export at the redex module root.Redex::enable_replication(mesh)is a new method. Idempotent; pair withRedex::open_filecarryingcfg.replication = Some(rep)to spawn a per-channel replication runtime.Redex::open_filerejects reopen with a structurally-differentReplicationConfigwith a typedRedexError::Channel. Reopen with the same config returns the existing handle (unchanged from v0.13).RPC_WHERE_HEADER = "net-where"(was"cyberdeck-where"in v0.13).HEARTBEAT_MS_MAX = 300_000added;ReplicationConfig::validaterejectsheartbeat_ms > HEARTBEAT_MS_MAXwith a typedHeartbeatTooHighvariant.
Rust SDK (net-sdk)
net_sdk::capabilities::redexre-exports the substrate replication surface —ReplicationConfig,PlacementStrategy,UnderCapacity,ReplicaRole,ChannelReplicationStatus.net_sdk::capabilities::predicate::RPC_WHERE_HEADERis the renamed constant.
FFI / bindings
| Binding | Change |
|---|---|
| All | New enable_replication(mesh) method on Redex. New replication field on RedexFileConfig; pair with ReplicationConfig constructor. New ReplicaRole / PlacementStrategy / UnderCapacity enums and ReplicationConfig builder per binding. New replication_runtime_count, replication_status_snapshot, replication_metrics_snapshot, replication_prometheus_text getters on Redex. |
| All | Hardware-numeric field renames — memoryGb / vramGb / storageGb / networkGbps etc. per binding's idiomatic naming. Same for the predicate min-builder family — minMemory / minVram / minStorage / minNetwork now produce GB / Gbps tags. |
| All | RPC_WHERE_HEADER constant renames to "net-where". Header-bearing nRPC call variants (net_rpc_call_with_headers etc.) pass the new name; v0.13 servers expecting cyberdeck-where: won't decode v0.14 callers. |
| Node | New Redex.enableReplication(mesh) method. New replication: ReplicationConfig field on RedexFileConfig. RedexFile.sync() / RedexFile.close() are async (return Promise<void>); callers must await. Pre-v0.14 code calling file.sync() / file.close() synchronously generates an orphan Promise warning under modern Node. The redex: JS-error prefix is pinned in index.d.ts doc-comment as the stable contract. |
| Python | New Redex.enable_replication(mesh) method. New replication= kwarg on Redex.open_file. replication=False with any replication_* kwarg now raises RedexError rather than silently dropping the kwarg. cortex open / tail / watch paths release the GIL via py.detach across the blocking work. enable_replication / replication_runtime_count / replication_prometheus_text are typed RedexError stubs without the net feature. |
| Go | New RedexManager.EnableReplication(meshArc) method. New RedexFileConfig.Replication *ReplicationConfig field. RedexFile uses sync.RWMutex so appends / reads don't serialize. OpenFile returns the matching sentinel (ErrInvalidReplicationConfig vs ErrReplicationRequiresEnable) per error class. ArcMeshNode typedef aliases the upstream net_compute_mesh_arc_t. |
| C | New entry points: net_redex_enable_replication(redex, mesh_arc), net_redex_replication_runtime_count(redex), net_redex_replication_prometheus_text(redex), net_free_string(ptr). net_redex_open_file / net_redex_file_tail pre-zero *out_handle / *out_cursor on entry. The replication config rides the RedexFileConfigJson.replication field; binding-side validators or the FFI core enforce numeric ranges. |
Behavioral fixes that may surface as test breakage
ReplicationConfig::heartbeat_msclamps at[100, 300_000]. Tests injectingu64::MAXor other pathological values to observe silence-detection behavior will seeReplicationConfigError::HeartbeatTooHighinstead.PlacementFilterelection no longer excludes peers withrtt_to == None. Tests that assertedNoEligibleReplicaagainst an all-unmeasured replica set will see the smallest-NodeId healthy peer elected instead.SyncNack::to_bytestruncates at a UTF-8 char boundary, so a regression test that previously expectedfrom_bytesto fail on an oversize multi-byte payload will see the round-trip succeed at a slightly-shorter detail length.- Reopen with a different
ReplicationConfigrejects. Tests that opened a channel with one config and reopened with another expecting silent reuse will seeRedexError::Channel("different from the original"). bandwidth_budget_is_observable_in_metricsrenamed. Tests referencing the old test name fail to find it; rename tobandwidth_budget_metric_field_is_plumbed.replication_overhead_within_30_percent_budgetmarked#[ignore]. CI runs that included this test in the default matrix will no longer see it; run viacargo test -- --ignored.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.14 line. Recompile / rebuild the binding cdylib (NAPI for Node, maturin for Python,cargo build -p net-compute-ffi+-p net-rpc-ffifor Go). - Capability hardware-unit migration. Rename
memory_mb→memory_gb,vram_mb→vram_gb,storage_mb→storage_gb,network_mbps→network_gbps,accelerator.memory_mb→accelerator.memory_gbthroughout. Adjust values:65_536→64,81_920→80,10_000→10. The predicate builders pick up the new keys automatically; tag-string literals need a manual rewrite.cargo build(and the binding-side TypeScript / Python static checks) drives the rewrite — the renames are compile errors. - Predicate header migration. If your call sites reference the header name directly (
"cyberdeck-where"as a string literal), replace with"net-where"or use the exportedRPC_WHERE_HEADERconstant. Server-side handlers consuming the v0.13 name need the same rewrite. - Replication opt-in. Channels that want replication: call
Redex.enable_replication(mesh)once after constructing theRedex(idempotent), then open each replicated channel withRedex.open_file(name, cfg.with_replication(Some(rep_cfg))). The per-channelReplicationRuntimespawns automatically; consult the operator surface viaRedex.replication_status_snapshot()/replication_prometheus_text(). - Channels that don't want replication require no changes. Single-node channels behave identically to v0.13.
RedexFileConfig::replication = Noneis the default. - Node consumers —
RedexFile.sync()/RedexFile.close()are async. Addawaitto call sites:
Sync call sites compile but generate orphan Promise warnings under modern Node and may exit the process before the fsync lands.await file.sync(); await file.close(); - Python consumers —
Redex.open_file(name, replication_*=…)requiresreplication=True. Pre-v0.14 code passingreplication_factor=5withoutreplication=Trueproduced a single-node channel; now raisesRedexError. Either passreplication=Trueexplicitly or drop thereplication_*kwargs. - Go consumers —
RedexFileConfig.Replicationis the new optional field. Pass a*ReplicationConfigfor replicated channels. Numeric validation (factor / heartbeat ranges) runs on the Go side before the FFI; structurally-invalid configs returnErrInvalidReplicationConfiginstead of the catch-allErrReplicationRequiresEnable. - Fleet-wide upgrade required for any deployment using capability announcements with hardware numerics. v0.13 receivers parse v0.14 announcements'
hardware.memory_gbasTag::Legacy— the value survives but no longer satisfiesmin_memory_mb-keyed filters. Recommend lockstep upgrade alongside the predicate-header migration. - Cross-binding wire fixtures regenerated. If you have CI that asserts golden-vector parity against
tests/cross_lang_capability/, the GB / Gbps shape and thenet-where:header rename mean every fixture changes.predicate_nrpc_envelope.jsonbumpsabi_version_expected: 1 → 2; future binding-side version pins should track the per-fixture version field. - Operator dashboards —
Redex::replication_prometheus_text()emits a per-channel snapshot in Prometheus text format; pipe into your existing scrape config under thedataforts_replication_*metric family. Per-channel labels (channel,role) carry the channel name and current role for dashboard slicing. - DST harness integration — if you have channel-level DST scenarios that drive
ReplicaRoledirectly, the harness'sforce_transition/tick_nodenow mirror the production coordinator'selection_thrash_totalcounter onto a per-VirtualNodeelection_thrash_countfield, so storm scenarios can assert on the gauge without rewiring around the async coordinator. The harness'swall_clock_msderives from a step counter, notInstant::now.
Named after the two "Chippin' In" tracks: Samurai's original Chippin' In, and the Cyberpunk 2077 soundtrack rendition by Damian Ukeje, P.T. Adamczyk, and Kerry Eurodyne.
v0.13 lands the capability system end-to-end across the substrate and all five bindings. v0.12 ("Firestarter") shipped nRPC; v0.13 makes capability the load-bearing layer underneath. The Tag placeholder in v0.10 / v0.11, and the untyped Vec<String> shape v0.12 still carried, both go away — CapabilitySet is now a { tags: HashSet<Tag>, metadata: BTreeMap } typed-taxonomy wire shape, every binding ships the same Predicate AST + evaluator + validator + diff + trace + debug-report aggregator, and predicates ride nRPC request headers (cyberdeck-where:) so server-side filtering picks the right candidate without re-running the predicate per hop.
The hardening posture from the Black Diamond line is intact — every new surface ships with handle-lifetime, panic-safety, and FFI-soundness guarantees consistent with v0.11 / v0.12 — but this release is about replacing the placeholder with the real thing.
Capability System (substrate)
Typed taxonomy
The flat tag namespace becomes a four-axis ontology — hardware / software / devices / dataforts — backed by a typed Tag enum:
pub enum Tag {
AxisPresent { axis: TaxonomyAxis, key: String },
AxisValue { axis: TaxonomyAxis, key: String, value: String, separator: AxisSeparator },
Reserved { prefix: String, body: String }, // scope:* / causal:* / fork-of:* / heat:*
Legacy(String), // untyped strings outside the typed taxonomy
}
Tag::parse(s) accepts every shape including reserved-prefix tags (the deserializer + substrate-internal callers); Tag::parse_user(s) rejects reserved prefixes for application input. TagKey ((axis, key)) is the half-form Predicate matches on. TaxonomyAxis::all() enumerates the four axes for iteration.
Axis values accept either = or : as the separator on the wire (hardware.gpu.vram_mb=24576 and hardware.gpu:nvidia both parse). The separator is preserved through Tag::Eq for byte-stable round-trips, and tag.semantic_eq(other) is the separator-agnostic comparison for tag matching.
Tag shapes for discovery
Reserved-prefix tag shapes flesh out the discovery primitive. causal:<hex> / causal:<hex>:<tip_seq> / causal:<hex>[<range>] for chain holders; fork-of:<parent_hex> for chain ancestry; heat:<chain_hex>=<rate> for hot-chain advertisement; scope:tenant:<id> / scope:region:<name> / scope:subnet-local (scope:* was already in v0.12, now formally part of the taxonomy). RESERVED_PREFIXES constant exposes the full list for binding-level enforcement.
Metadata field
CapabilitySet storage shape collapses to two fields:
pub struct CapabilitySet {
pub tags: HashSet<Tag>,
pub metadata: BTreeMap<String, String>,
}
HardwareCapabilities / SoftwareCapabilities / Vec<ModelCapability> / Vec<ToolCapability> / ResourceLimits are projections — derived on demand via caps.views(). Encoding scheme: hardware.cpu_cores=N / hardware.gpu / hardware.gpu.vram_mb=N / software.os=linux / software.model.0.id=... / hardware.limits.max_concurrent_requests=N. Tool JSON-Schema strings (which can't safely round-trip through the tag wire format) live in metadata under tool::<id>::input_schema / tool::<id>::output_schema. Application-defined metadata keys propagate as opaque pairs (subject to a 4 KB soft cap with a MetadataOversize warning at the validator layer).
Wire format emits tags in sorted Tag::to_string() order — the HashSet keeps O(1) membership for in-memory lookups; the serialize_with hook flattens to a sorted Vec on the way out. Without this, two ends of a signed announcement round-trip would produce different bytes (HashSet iteration is process-local random) and the verifier would reject as InvalidSignature.
Bloom-filter primitive
behavior::bloom::BloomFilter ({ len_bits, k, bits: Vec<u64> }) backs compact chain-tag membership probes via xxh3-128 double-hashing. ~1% FPR at 10 K items in ≤ 500 KB per the substrate sizing target. Probe pattern: callers that match the bloom run a follow-up precise lookup (existing causal:<hex> tag membership) before issuing real reads — false positives become recoverable misses, false negatives are impossible by construction. Domain-separated via BLOOM_HASH_SEED = 0xB100_F1AC_DEAD_CAFE so callers using xxh3 elsewhere don't accidentally collide.
BloomFilter::new(expected_items, false_positive_rate) clamps degenerate inputs (expected_items == 0 → 1, p clamped to (1e-9, 0.5)); BloomFilter::with_params(len_bits, k) is the explicit-parameters constructor for cross-binding fixtures. Round-trips via serde with explicit deserialize-side validation (rejects out-of-range k, mismatched len_bits/bits.len() * 64).
Federated query primitives
behavior::query::CapabilityQuery lifts five composable ops over CapabilityIndex:
filter(predicate)— predicate-driven candidate set.match_axis(axis, key)— axis-shaped tag scan.aggregate(key, reduction)— per-key cardinality / numeric reductions.traverse(seed, edge_fn, depth)— graph-style join over peer capability links.nearest(predicate, k, proximity)— combine with proximity to score the top-K best matches.
Implementations on CapabilityIndex are O(log n) for indexed predicates and O(n) for the residual scan. The Predicate AST and these five ops together are what Mesh::find_nodes_by_filter / find_best_node_scoped flow through.
PlacementFilter trait + StandardPlacement
PlacementFilter::placement_score(target, artifact) -> Option<f32> is the substrate-level placement primitive. Some(score) admits the candidate at a fitness in [0, 1]; None is a hard veto. Artifact carries the workload type — Chain (causal-chain placement), Replica (channel replica placement), Daemon (compute placement, with required + optional capability sets).
StandardPlacement is the multi-axis reference implementation: scope filter, proximity max-RTT, intent matching (AnyOfLocalCapabilities / StrictMatch / Custom), colocation policy (Ignore / SoftPreference / StrictRequired), resource axis (Storage / Compute / Both), anti-affinity config (leadership-concentration penalty), and a custom-filter axis that consumes a registered host-language PlacementFilter via with_custom_filter_id(id). Axes compose multiplicatively; None on any axis is a hard veto. Per-axis tie-breaking via the locked RTT → free-resource → lexicographic-NodeId chain (tie_break_compare).
IntentRegistry::register(intent, &[required]) registers per-intent placement requirements built from the require! / require_axis! / require_axis_value! macros. Substrate ships defaults for the four canonical intents (ml-training, inference, embedding-cache, tool-call); per-deployment overrides land via the SDK.
global_placement_filter_registry() is the process-wide singleton mapping registered IDs to Arc<dyn PlacementFilter>. Bindings register their language-specific wrappers here; the scheduler resolves an SDK ID to an impl before scoring. Registration is open-by-default — the registry refuses overwrites of an existing ID (register returns false) so two bindings can't accidentally clobber each other's filters.
Mikoshi integration
Mikoshi::select_migration_target(daemon, scope) consults PlacementFilter end-to-end. LegacyPlacement preserves the v0.12 ad-hoc selection under a feature flag for one minor version; new daemons should target StandardPlacement. ReplicaGroup::select_member_node and StandbyGroup::select_promotion_target route through the same scorer so replication / hot-standby promotion get the same axis-composed verdict as initial placement.
Daemon authors declare MeshDaemon::required_capabilities() and optional_capabilities(); the runtime publishes both as part of the daemon's identity-bound announcement so the placement scheduler — and any custom filter — can consult them. Bindings expose the same hook through their daemon-caps dispatcher (net_compute_set_daemon_caps_dispatcher at the C ABI; the equivalent Python / TS / Go callback during factory registration).
Capability Enhancements (substrate refinements)
None of these change the wire format — they sit on top of the typed-taxonomy primitive and pay for themselves at the application layer.
Lazy view projections + diff
caps.views() returns a CapabilityViews handle whose per-axis fields decode-and-cache on first access. Hot-path caps.views().hardware().memory_mb is < 50 ns post-cache; first call is the per-tag scan. Cache invalidates compiler-enforced via the &caps borrow held by views().
caps.diff(prev) returns CapabilitySetDiff { added_tags, removed_tags, changed_metadata } for cheap before/after change detection. MetadataChange::{Added, Removed, Updated} per-key with old/new values. Powers event-driven placement, capability-change dashboards, and delta-based metadata propagation.
Axis schemas
AXIS_SCHEMA is the canonical per-axis schema baked into the substrate at build time: known keys per axis, value types (Presence / Number / String / Enumeration / Bool / Csv), indexed-collection shapes (software.model.<i>.* / software.tool.<i>.* / hardware.accelerator.<i>.*). validate_capabilities(caps) runs the schema against a CapabilitySet and returns a ValidationReport of errors (operator-must-fix: UnknownAxis, TypeMismatch, IndexMalformed) + warnings (forward-compat / hygiene: UnknownKey, MetadataOversize, LegacyTag). Both lists are sorted by JSON-stringified entry so cross-binding fixture comparisons stay order-independent. Each binding regenerates its language-side schema from the same authoritative CAPABILITIES_SCHEMA.md doc.
Predicate AST + nRPC headers
behavior::predicate::Predicate is the typed AST. Variants: Exists / Equals / NumericAtLeast / NumericAtMost / NumericInRange / SemverAtLeast / SemverAtMost / SemverCompatible / StringPrefix / StringMatches / MetadataExists / MetadataEquals / MetadataMatches / MetadataNumericAtLeast / And / Or / Not. Built via the pred! macro in Rust, language-idiomatic builders in every other binding (p.and([...]), p.exists(tagKey('hardware', 'gpu')), etc.). Evaluated against an EvalContext constructed from any (tags, metadata) pair.
Predicates encode losslessly to a cyberdeck-where: nRPC header pair via predicate_to_rpc_header; the receiver decodes via predicate_from_rpc_headers (consumes any iterable of (name, value_bytes) pairs through the AsRpcHeader trait). Pair with net_rpc_call_with_headers / _call_service_with_headers / _call_streaming_with_headers at the C ABI so server-side filtering picks the right candidate without re-running the predicate per hop. Decode-side enforces the encode-side size cap symmetrically — oversize payloads surface as PredicateRpcDecodeError::Oversize instead of walking serde's recursive parse on attacker-shaped input. Wire format pinned by tests/cross_lang_capability/predicate_nrpc_envelope.json.
Query planner
predicate.evaluate(ctx) runs the planned (selectivity-reordered) AST by default; predicate.evaluate_unplanned(ctx) exposes the raw declaration-order path for benchmarking. Planner consumes CardinalityProvider (a TTL-cached lookup over by_axis_key / by_metadata indexes via CapabilityIndex::axis_cardinality). Cost-based AND short-circuits cheap-false-first, cost-based OR cheap-true-first; structurally-equal clauses merge so duplicate work is single-counted. Cardinality casts saturate on u32::MAX so fleets with unbounded-cardinality metadata keys (session id, request id) don't wrap and mis-rank the most-selective key.
Chain composition helpers
caps.requireChain(hash) / requireAnyChain([hashes]) / excludeChain(hash) / fromFork(parent) / heatLevel(rate) are syntactic sugar over the underlying reserved-prefix tags (TS / Python builder shapes; the Rust require_axis_value! macro covers the same). Predicate-side equivalents on the pred.* builder.
Predicate debug sessions
Predicate::evaluate_with_trace(ctx) returns (bool, ClauseTrace) — every clause's verdict + skipped children for short-circuit AND/OR. PredicateDebugReport::from_evaluations(&pred, contexts) aggregates per-clause hit / miss / cost stats across a corpus; report.render() renders a multi-line text summary. Bindings ship a redact_metadata_keys(report, keys) helper for safe persistence — scrubs metadata-equality / -matches values before the report goes to disk or analytics. Wire format pinned by tests/cross_lang_capability/predicate_trace.json and predicate_debug_report.json.
SDK Capability System Surface
Every binding ships the same capability surface. Total ~14 K LoC across the substrate + SDK + bindings + tests, of which the binding surface accounts for ~7 K. The substrate primitives (Tag, TagKey, CapabilitySet, CapabilityViews, Predicate, pred! macro, ValidationReport, CapabilitySetDiff, RequiredCapability + require! macros) re-export through net-sdk::capabilities. Per-binding surfaces:
| Binding | Surface |
|---|---|
| Node / TypeScript | sdk-ts exports tagFromUserString, RESERVED_PREFIXES, requireTag, withMetadata, the p predicate builder, evaluatePredicate, predicateToRpcHeader / predicateFromRpcHeader, validateCapabilities, diffCapabilities, evaluatePredicateWithTrace, predicateDebugReport, redactMetadataKeys, renderDebugReport, placementFilterFromFn, standardPlacement. |
| Python | sdk-py exports the parallel surface as tag_from_user_string, p, evaluate_predicate, predicate_to_rpc_header, validate_capabilities, diff_capabilities, evaluate_predicate_with_trace, predicate_debug_report, redact_metadata_keys, placement_filter_from_fn, standard_placement. |
| Go | bindings/go/net/ exports Tag, Predicate{}, EvaluatePredicate, PredicateToWhereHeader, ValidateCapabilities, DiffCapabilities, EvaluatePredicateWithTrace, PredicateDebugReport, RegisterPlacementFilter, UnregisterPlacementFilter. |
| C ABI | Stateless evaluator (net_predicate_evaluate), stateless validator (net_validate_capabilities), debug-session helpers (net_predicate_evaluate_with_trace, net_predicate_aggregate_debug_report, net_predicate_redact_metadata_keys), cyberdeck-where: header builder (net_predicate_to_where_header), and header-bearing nRPC call variants (net_rpc_call_with_headers, net_rpc_call_service_with_headers, net_rpc_call_streaming_with_headers plus cancellable streaming variants). |
| All bindings | MeshDaemon capability authoring — daemons declare required_capabilities / optional_capabilities via per-binding factory hooks plumbed through net_compute_set_daemon_caps_dispatcher. Custom PlacementFilter callbacks via placement_filter_from_fn(fn) (TS / Python / Go) or global_placement_filter_registry().register(...) (Rust). |
Eight cross-binding wire-format fixtures under tests/cross_lang_capability/ (predicate_eval, capability_set_diff, capability_validation, predicate_trace, predicate_debug_report, predicate_debug_report_redacted, predicate_nrpc_envelope, placement_score) pin the byte-identical contract across Rust / TS / Python / Go / C and are versioned via abi_version_expected: 1.
Cross-cutting invariants the fixtures and per-binding compat suites enforce:
- Wire format is byte-identical across Rust / TS / Python / Go / C. A predicate authored in TS and shipped to a Go service via the
cyberdeck-where:header decodes losslessly; aCapabilitySet::diffon Python reproduces the identicaladded_tags/removed_tags/changed_metadatashape Rust would. Drift in any binding fails that binding's own CI. - Numeric / semver parse semantics agree with Rust. Every binding's
f64parser accepts exactly Rust'sf64::from_strset (decimal, scientific, leading+,.5,1.,inf,infinity,NaN) and rejects hex floats / digit-separator underscores. Every binding's semver parser accepts only ASCII digits with optional leading+. Validators boundNumbervalues atu64::MAXand reject negatives; indexed-collection indices bound atu32::MAX. AxisPresenttags don't satisfy value predicates.Equals(_, "")/StringPrefix(_, "")/StringMatches(_, "")never spuriously match a presence-only tag — only theExistspredicate does.CapabilitySet::diffis separator-agnostic onAxisValuetags (hardware.k=vandhardware.k:vcarry identical semantics).- Reserved-prefix tags only via dedicated helpers.
add_tag(s)parses throughTag::parse_user, which rejects reserved prefixes — applications that try to emit ascope:tenant:fooviaadd_tagget the tag silently dropped. Usewith_tenant_scope("foo")/with_region_scope/with_subnet_local_scope/ etc. Bindings opt into the unrestrictedTag::parsepath so reserved tags round-trip throughtags: [...]. Metadata writers gate on the same reserved-prefix list. The schema validator surfaces collisions and oversize as warnings. MeshDaemon::processpanic surfaces asRpcStatus::Internal— same hardening posture as v0.12's nRPC fold, applied through the daemon-caps dispatcher when caps extraction itself panics.AttributeErroris the only silently-swallowed Python error. Every other exception from a@propertygetter forrequired_capabilities/optional_capabilitiespropagates so operators see real failures instead of phantom-empty-cap daemons.
Hardening
The capability surface landed alongside two parallel audits whose fixes are integrated into the surface descriptions above. The substantive results, grouped by area:
Wire-format determinism and separator agnosticism
CapabilitySet::has_tagandRequiredCapability::Tagevaluate viaTag::semantic_eqsocaps.has_tag("software.os:linux")matches a storedsoftware.os=linuxand vice versa. Theseparatorfield is a wire-form detail, not part of identity.CapabilitySet::diffis separator-agnostic and emits ops in deterministic lexicographic-by-tag order. Pre-fix HashMap iteration randomized the op order, and an input tag with:separator that re-encoded canonically as=shipped a phantomRemoveTagwithout a compensatingUpdateSoftware— receivers dropped the tag entirely. Same fix applied to the TSdiffCapabilitiesrewrite (semantic comparison on(kind, axis, key, value)).- Capability announcements emit tags in sorted wire order so signed announcements verify byte-stably across processes (HashSet iteration is process-local random; pre-fix verification rejected multi-tag announcements crossing between two processes).
- Forward-compat axis tags survive
CapabilitySet::diffasAddTag/RemoveTag; theis_*_owned_tagpredicates no longer over-claim unknown forward-compat keys.
Predicate / placement correctness
- Custom
PlacementFilterimpls returningNoneorNaNare hard vetoes — pre-fix NaN scores poisoned the sort comparator and the highest-scoring candidate could rotate non-deterministically.StandardPlacement::saturating_score, the anti-affinity threshold, andtarget_axis_value_numericall clamp NaN / out-of-range values before composition;score_resource_axis::Bothcollapses to whichever axis carried data (rather than diluting against a permissive1.0placeholder for a no-data axis). score_custom_filter_axisresolves outside thewith_capsclosure so an FFI-registered filter that calls back into the index (index.query(...)from aLegacyPlacementshim, JS callback hittingfind_nodes) can't deadlock against a concurrentindex.index(...)insert.Scheduler::select_migration_targetcarries the LocalPreferred fast-path so RTT-aware operators feeding their ownTieBreakContextdon't silently lose the network-hop-avoidance behavior.place_migration_v2derives the rightPlacementReasonfrom the returned node id.CapabilityQuery::traversecarries a visited-set so cycles in the peer-capability graph terminate.eval_any_in_cost_orderranks Or composites cheap-true-first;redact_labelsearches every separator position so metadata-equality values containing=round-trip cleanly.Tag::AxisPresentno longer matches value-bearing predicates.Equals(_, "")/StringPrefix(_, "")/StringMatches(_, "")only matchAxisValuetags;Predicate::Existsis the dedicated presence-check path in every binding.
Cross-binding numeric / semver agreement
- Every binding's
f64parser accepts exactly Rust'sf64::from_straccepted-set (decimal, scientific, leading+,.5,1.,inf,infinity,NaN) and rejects hex floats (0x1p3) and digit-separator underscores (1_000) that Go'sstrconv.ParseFloatand Python'sfloat()would otherwise accept. Numeric leaves run through IEEE comparison so NaN never matches and ±inf compare correctly across bindings. - Schema
Numbervalidators bound atu64::MAXand reject negatives; indexed-collection indices bound atu32::MAX. ASCII digits only with optional leading+— Unicode digits (Arabic-Indic, fullwidth) parse cleanly under Python'sint()but Rust'su64::from_strrejects them, so the predicate-side and schema-side parsers both lock to^\+?[0-9]+$. - Semver parsers reject Unicode digits in the version components;
0.0.xis exact-only (every patch is a breaking change boundary per Cargo's caret rule);0.x.yrequireslhs.major == 0. parse_tag_keytrims whitespace around the dot,require!parses==before>=/<=so equality values containing comparison substrings parse correctly.Tag::parse_userrejects reserved prefixes consistently across bindings;with_metadatafilters reserved-prefix keys at the writer.
FFI / binding hardening
predicate_from_rpc_headersenforces the decode-side size cap symmetrically with the encode side — parse-bomb-shaped payloads surface asPredicateRpcDecodeError::Oversizeinstead of walking serde's recursive parse.dynamic_cost/dynamic_cost_orsaturateusizecardinality tou32::MAXso long-running fleets with unbounded-cardinality metadata keys (session id, request id) don't trip the planner into treating the most-selective key as if it had only one distinct value.placement_registry::registerpre-creates the per-binding invocation counter only on successful insertion — id-collision register-fail paths don't leak phantom Prometheus binding-counters.- Bloom-filter
h2forces odd-only so power-of-2 bit-count probe cycles cover the full bit range; the rounding-saturation path is unit-tested. - compute-ffi's
parse_sideandnet_compute_snapshot_bytes_freecorrectly free(non-NULL ptr, len == 0)malloc'd buffers. - rpc-ffi's
run_cancellablecarries acancelledflag for register-after-spawn ordering; the cancel-token registry evicts stale orphan entries;net_predicate_to_where_headerrecovers from partial-write failure. Streaming-call construction is cancellable end-to-end vianet_rpc_call_streaming_cancellableandnet_rpc_call_streaming_with_headers_cancellable(pre-existing non-cancellable variants kept for back-compat). - Python
announce_capabilitiesreleases the GIL across the blocking call. Python-binding property-getter errors propagate (exceptAttributeError) so misbehaving daemon-caps callbacks surface real failures instead of phantom-empty-cap daemons. The Python_try_parse_floatrejects whitespace-padded inputs to match Rust's strictness. - Go binding's
RegisterPlacementFilter/UnregisterPlacementFilterserialize on the same id to close a registry-vs-substrate race;tagKeyFromWiresurfaces type-assert failures. - Node + Python
fp16_tflops_x10bypasses the f32 round-trip that previously lost precision above 2²⁴ for direct large-value passthrough. tag_codecrejects software runtime / framework / driver names containing the separator characters=/:/.so round-trips through the canonical wire format don't silently truncate.
Go cgo surface widening — origin_hash uint32 → uint64
go/net.h declared every origin_hash parameter and return type as uint32_t, while the canonical net.go.h and the Rust extern "C" signatures use uint64_t / u64. Pre-fix the cgo boundary silently truncated the upper 32 bits of every origin_hash. Closed before merge:
- C header —
net_identity_origin_hash,net_compute_daemon_handle_origin_hash,net_compute_migration_handle_origin_hash,net_compute_fork_group_parent_origin,net_compute_standby_group_active_origin(all nowuint64_treturn).net_tasks_adapter_open,net_memories_adapter_open,net_compute_runtime_stop,net_compute_runtime_deliver,net_compute_runtime_snapshot,net_compute_start_migration,net_compute_expect_migration,net_compute_migration_phase,net_compute_replica_group_route_event(out_origin),net_compute_standby_group_promote(out_origin),net_compute_fork_group_spawn(parent_origin) (all nowuint64_tparameter / out-parameter). - Production Go binding —
Identity.OriginHash() uint64,DaemonHandle.OriginHash() uint64,MigrationHandle.OriginHash() uint64,ForkGroup.ParentOrigin() uint64,StandbyGroup.ActiveOrigin() uint64,StandbyGroup.Promote() uint64,ReplicaGroup.RouteEvent() uint64.DaemonRuntime.{Stop, Snapshot, Deliver, StartMigration, ExpectMigration, MigrationPhase}parameters,NewForkGroup'sparentOrigin,OpenTasks/OpenMemories'soriginHashparameter (alluint64). - Public Go types —
CausalEvent.OriginHashisuint64(changed fromuint32);GroupMemberInfo.OriginHashisuint64;GroupForkRecord.{OriginalOrigin, ForkedOrigin}areuint64.
Breaking change for downstream Go consumers. Code calling daemon.OriginHash() and assigning to a uint32 variable will fail to compile; drop the explicit uint32(...) cast or convert to uint64. The widening matches the Rust substrate's u64 shape.
Regression coverage
Every correctness fix above ships with a regression test. The cross-binding fixture corpus grew from five JSON files at branch start to thirteen: predicate_eval, capability_set_diff, capability_validation, predicate_trace, predicate_debug_report, predicate_debug_report_redacted, predicate_nrpc_envelope, placement_score, plus five new rows pinning numeric-parser parity, separator-strip parity, and schema range-check agreement across Rust / TS / Python / Go / C.
Test hygiene
- Cross-binding wire-format fixtures. Thirteen golden-vector fixtures under
tests/cross_lang_capability/, all versioned viaabi_version_expected: 1. Drift in any binding's encode / decode / evaluate path fails that binding's CI. Each fixture drives parallel suites in Rust integration tests + Node Vitest + Python pytest + Go go-test. - Integration tests for the load-bearing user flows.
integration_nrpc_predicate_header.rs(4 tests) composes header-bearing nRPC call variants with the stateless evaluator over a real two-node mesh — pins that the predicate-as-cyberdeck-where:-header → server-side filter flow works end-to-end.integration_placement_filter_callback.rs(3 tests) registers a customPlacementFilterviaglobal_placement_filter_registry(), buildsStandardPlacement::with_custom_filter_idover a populatedCapabilityIndex, verifies the filter's verdict reaches the composed score, and unregister-mid-flight collapses to a hard veto. - Lib suite at 2330+ tests (was 2289 at v0.12 release). 40+ net new tests across the regression + integration paths, every correctness fix above shipping with at least one regression.
cargo clippy --all-features --all-targets -D warningsclean across substrate + every binding crate.
Breaking changes
Wire format — CapabilitySet shape change
v0.13 breaks wire compatibility with v0.12 for CapabilityAnnouncement / CapabilityDiff / any payload carrying a CapabilitySet. The storage shape collapsed from seven fields (hardware, software, models, tools, tags, limits, metadata) to two (tags, metadata); typed projections decode lazily through views(). Old peers can't decode new announcements; new peers can't decode old. Per locked decision in CAPABILITY_SYSTEM_PLAN.md ("no backward-compatibility shim"), a synchronous fleet-wide upgrade is required for any deployment that uses capability announcements.
Forward-compat preserved within the new shape:
- Unknown axis-prefixed tags pass through as
Tag::Legacyon parse for forward-compat with future schema additions. The validator emitsLegacyTagwarnings rather than errors. - Unknown metadata keys propagate as opaque pairs subject to the 4 KB soft cap.
- Reserved-prefix tag set is closed at v0.13 (
scope:/causal:/fork-of:/heat:). Future reserved prefixes will land in v0.14+; v0.13 receivers will route them throughTag::Legacyuntil upgrade.
The signed_payload() envelope round-trip is byte-stable across processes thanks to the sorted-tag wire format — pre-fix, signature verification rejected announcements crossing between two processes (different RandomState seeds), silently dropping every multi-tag announcement at the receiver.
MembershipMsg, IdentityEnvelope, EventMeta, CausalLink, OriginStamp, NetHeader, RedEX on-disk layout, per-event checksum format, and every nRPC dispatch / header from v0.12 — all unchanged.
Rust core (net crate) — API surface
CapabilitySet's typed-struct fields are gone.caps.hardware,caps.software,caps.models,caps.tools,caps.limitsno longer exist as fields. Read throughcaps.views().hardware()(etc.) — the projection is per-axis OnceCell-cached. Write throughcaps.set_hardware(hw)/set_software/set_models/set_tools/set_limits— these clear axis-owned tags and re-emit via the codec. Thewith_*builders are thin wrappers.CapabilitySet::tagsfield type changes fromVec<String>toHashSet<Tag>. Iterations overcaps.tagsnow yield typedTagvalues; render to wire form viat.to_string(). Usecaps.add_tag(s)for application-facing additions (parses throughTag::parse_user, rejects reserved prefixes);caps.with_tenant_scope/with_region_scope/with_subnet_local_scopefor the dedicated reserved-tag builders.adapter::net::behavior::tagis a new public module re-exportingTag,TagKey,TaxonomyAxis,AxisSeparator,RESERVED_PREFIXES,CapabilityTagError.adapter::net::behavior::tag_codecis a new public module re-exporting the round-trip codecs (hardware_to_tags/hardware_from_tags/software_to_tags/software_from_tags/models_to_tags/models_from_tags/tools_to_tags/tools_from_tags/resource_limits_to_tags/resource_limits_from_tags) plus the axis-owned-tag predicates (is_hardware_owned_tag/ etc.).adapter::net::behavior::predicateis a new public module re-exportingPredicate,EvalContext,ClauseTrace,PredicateDebugReport,predicate_to_rpc_header,predicate_from_rpc_headers,RPC_WHERE_HEADER,MAX_PREDICATE_RPC_HEADER_VALUE_LEN,AsRpcHeader,PredicateRpcEncodeError,PredicateRpcDecodeError,PredicateWire,PredicateNodeWire,RpcPredicateContext,filter_by_predicate. Plus thepred!macro re-exported at the crate root.adapter::net::behavior::required_capabilityis a new public module re-exportingRequiredCapability,RequireParseError, plus therequire!/require_axis!/require_axis_value!macros at the crate root.adapter::net::behavior::schemais a new public module re-exportingvalidate_capabilities,ValidationReport,SchemaError,ValidationWarning,ValueType,KeyEntry,AxisSchema,AXIS_SCHEMA,METADATA_SOFT_CAP_BYTES.adapter::net::behavior::bloomis a new public module re-exportingBloomFilter.adapter::net::behavior::queryis a new public module re-exporting theCapabilityQuerytrait.adapter::net::behavior::placementis a new public module re-exportingPlacementFilter,Artifact,StandardPlacement,LegacyPlacement,IntentRegistry,IntentMatchPolicy,ColocationPolicy,ResourceAxis,AntiAffinityConfig,PlacementMetadataKeys,compose_axis_scores,tie_break_compare,LeadershipStatsLookup,RttLookup,ScopeLabel,TieBreakContext,NodeId as PlacementNodeId.adapter::net::behavior::placement_registryis a new public module re-exportingglobal_placement_filter_registry(),PlacementFilterRegistry.
Rust SDK (net-sdk)
The SDK's capability surface is entirely additive over the substrate re-exports — no existing SDK API changes outside the CapabilitySet shape change.
net_sdk::capabilities::*re-exports the substrate capability surface end-to-end. New entries since v0.12:Tag,TagKey,TaxonomyAxis,RESERVED_PREFIXES,CapabilityViews,CapabilitySetDiff,MetadataChange,CardinalityCache,CardinalityProvider,RequiredCapability,RequireParseError,LegacyPlacement,StandardPlacement,Artifact,PlacementFilter,IntentRegistry,IntentMatchPolicy,ColocationPolicy,ResourceAxis,AntiAffinityConfig,PlacementMetadataKeys,LeadershipStatsLookup,RttLookup,ScopeLabel,TieBreakContext,compose_axis_scores,tie_break_compare,global_placement_filter_registry,PlacementFilterRegistry.- New submodule
net_sdk::capabilities::predicatere-exportsPredicate,EvalContext,ClauseTrace,ClauseStats,PredicateDebugReport,predicate_to_rpc_header,predicate_from_rpc_headers,AsRpcHeader,RpcPredicateContext,filter_by_predicate,MAX_PREDICATE_RPC_HEADER_VALUE_LEN,RPC_WHERE_HEADER, plus encode / decode / wire types. - New submodule
net_sdk::capabilities::schemare-exportsvalidate_capabilities,ValidationReport,SchemaError,ValidationWarning,ValueType,KeyEntry,AxisSchema,AXIS_SCHEMA,METADATA_SOFT_CAP_BYTES. - The
pred!/require!/require_axis!/require_axis_value!macros are re-exported at the SDK crate root.
FFI / bindings
| Binding | Change |
|---|---|
| All | New capability-enhancements surface — typed Tag, predicate AST + builders, validator, diff, trace, debug-report aggregator, redaction. Cross-binding wire format is byte-identical and pinned by the eight golden-vector fixtures. |
| All | Reserved-prefix tag passthrough at the binding boundary now uses Tag::parse (not parse_user). SDK consumers can supply scope:* / causal:* / fork-of:* / heat:* via the tags: [...] shape; pre-fix they were silently dropped at the binding boundary. |
| All | placement_filter_from_fn(fn) / placementFilterFromFn(fn) registers a host-language predicate as a custom placement-filter callback. Pair with standardPlacement(custom_filter_id=...) / StandardPlacement::with_custom_filter_id to install. Substrate calls back per candidate. |
| All | MeshDaemon capability authoring — daemons declare required_capabilities / optional_capabilities via per-binding callbacks during factory registration. Substrate's net_compute_set_daemon_caps_dispatcher plus per-binding adapter. |
| Node | New SDK module capability-enhancements.ts exports the full surface (tagFromUserString, RESERVED_PREFIXES, requireTag, requireAxisValue, withMetadata, emptyCapabilities, p, evaluatePredicate, predicateToRpcHeader / predicateFromRpcHeader, RPC_WHERE_HEADER, validateCapabilities, isReportValid, diffCapabilities, evaluatePredicateWithTrace, predicateDebugReport, redactMetadataKeys, renderDebugReport, placementFilterFromFn, standardPlacement, plus the typed wire shapes). NAPI binding rebuild required for the new storage shape. |
| Python | New module net_sdk exports the parallel surface (tag_from_user_string, p, evaluate_predicate, predicate_to_rpc_header, validate_capabilities, diff_capabilities, evaluate_predicate_with_trace, predicate_debug_report, redact_metadata_keys, placement_filter_from_fn, standard_placement). The net._net PyO3 binding adds extract_optional_caps, daemon caps dispatcher, placement-filter callback. Rebuild via maturin develop --release for the storage-shape change. |
| Go | bindings/go/net/ adds the typed surface (Tag, Predicate{}, EvaluatePredicate, PredicateToWhereHeader, ValidateCapabilities, DiffCapabilities, EvaluatePredicateWithTrace, PredicateDebugReport, RegisterPlacementFilter, UnregisterPlacementFilter). The compute-ffi C ABI gains the placement-filter dispatcher entry points. |
| Go | origin_hash widened from uint32 to uint64 end-to-end. Public methods (Identity.OriginHash(), DaemonHandle.OriginHash(), MigrationHandle.OriginHash(), ForkGroup.ParentOrigin(), StandbyGroup.{ActiveOrigin, Promote}(), ReplicaGroup.RouteEvent()) return uint64; DaemonRuntime.{Stop, Snapshot, Deliver, StartMigration, ExpectMigration, MigrationPhase} parameters and NewForkGroup's parentOrigin take uint64; CausalEvent.OriginHash, GroupMemberInfo.OriginHash, GroupForkRecord.{OriginalOrigin, ForkedOrigin} are uint64. Pre-fix the cgo boundary silently truncated the upper 32 bits of every origin_hash. Same widening applied to the cortex adapters (OpenTasks / OpenMemories take uint64 originHash). Breaking change for downstream Go consumers — uint32 callsites need explicit uint64(...) conversion. |
| Go | Cancellable streaming-call entry points. net_rpc_call_streaming_cancellable and net_rpc_call_streaming_with_headers_cancellable add a cancel_token parameter so a parallel net_rpc_cancel_call can abort the construction block_on before the stream handle materializes. Pre-existing non-cancellable variants kept for back-compat. |
| C | net.go.h exports the new error codes (NET_COMPUTE_ERR_NO_DISPATCHER = -4, NET_COMPUTE_ERR_INVALID_UTF8 = -5) and switches mesh_arc from void* to the typed opaque handle net_compute_mesh_arc_t*. New capability entry points: net_validate_capabilities, net_predicate_to_where_header, net_predicate_evaluate, net_predicate_evaluate_with_trace, net_predicate_aggregate_debug_report, net_predicate_redact_metadata_keys, net_rpc_call_with_headers / _call_service_with_headers / _call_streaming_with_headers. |
Behavioral fixes that may surface as test breakage
CapabilitySetfield reads now decode lazily throughviews(). Tests that didcaps.hardware.memory_mbdirectly fail to compile; rewrite ascaps.views().hardware().memory_mb. Same forsoftware/models/tools/limits.caps.tags.contains(&"gpu".to_string())no longer compiles.tags: HashSet<Tag>carries typed values; usecaps.has_tag("hardware.gpu")(which is now separator-agnostic) orcaps.tags.iter().any(|t| t.to_string() == "hardware.gpu")for the substring-style check.add_tag("scope:tenant:foo")silently drops at the application layer. Usecaps.with_tenant_scope("foo"). The binding-side passthrough viatags: [...]works because bindings parse via the unrestrictedTag::parse.CapabilitySet::diffops now sort deterministically. Tests that asserted specific diff-op insertion order underVecsemantics will see lexicographic-by-tag ordering instead.PlacementFilter::placement_scorereturningNoneis a hard veto. Pre-fix, custom impls returningSome(0.0)andNoneproduced indistinguishable scheduler behavior; v0.13 makesNonethe explicit "exclude from ranking" signal andSome(0.0)the "score floor" signal. Tests asserting "filter returns None → scheduler ranks among others" will see the candidate excluded.- Custom
PlacementFilterimpls returning NaN are now treated as a hard veto. Tests that injected NaN to observe sort behavior will see a deterministic exclusion. require!("software.id == v>=1.0")parses asEquals, notNumericAtLeast. The==branch now precedes>=/<=in the require-parser to handle equality values containing comparison substrings. Tests asserting the legacy ">=claims the split first" behavior will fail.parse_tag_keytrims whitespace around the dot.require!("hardware. gpu == nvidia")now producesTagKey::new(Hardware, "gpu")instead ofTagKey::new(Hardware, " gpu")— the latter silently mismatched every real tag.semver_compatibletreats0.0.xas exact-only. Tests that asserted "^0.0.1matches0.0.2" will see the rejection.Tag::AxisPresentno longer matches value-bearing predicates.Equals(_, "")/StringPrefix(_, "")/StringMatches(_, "")no longer accept presence-only tags. UsePredicate::Existsfor key-presence checks.- Forward-compat axis tags survive
CapabilitySet::diff. Pre-fix,is_*_owned_tagover-claimed unknown forward-compat keys (hardware.future_field=v2) and the residual filter dropped them; the typedUpdate*ops didn't capture them either. Real changes to forward-compat tags now ship asAddTag/RemoveTag. - Capability announcements emit tags in sorted wire order. Tests asserting HashSet-iteration-order on the wire will see lexicographic ordering instead. Symptom for cross-process verification: the sorted form is what makes signature verification stable.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.13 line. Recompile / rebuild the binding cdylib (NAPI for Node, maturin for Python,cargo build -p net-compute-ffi+-p net-rpc-ffifor Go). - CapabilitySet field-access migration. Direct field reads (
caps.hardware,caps.software, etc.) move tocaps.views().hardware()/software()/ etc. Usecargo buildto drive the rewrite — the compiler errors name every site. The view handle is per-axis OnceCell-cached (< 50 ns post-cache); same hot-path cost as the old direct field access. - Tag iteration changes from
&strto&Tag. Render to wire form viatag.to_string()(the canonicalDisplayimpl), or pattern-match on the typed variants.caps.has_tag("...")works with either separator form. - Reserved-prefix tag emission moves to dedicated builders. Replace
caps.add_tag("scope:tenant:foo")withcaps.with_tenant_scope("foo"), etc. Application code passing reserved tags throughcaps.add_tagwas already silently dropping them in v0.12 prerelease builds. - Fleet-wide upgrade required for capability announcements. v0.12 ↔ v0.13 mixed fleets cannot exchange
CapabilityAnnouncement/CapabilityDiffpayloads — the storage shape change is intentional. Pub/sub, mesh transport, channels, identity, subnets, NAT traversal, nRPC (the v0.12 surface) all continue to work cross-version. Recommend lockstep upgrade. - For the new capability surface — the typed taxonomy + predicate evaluator + validator + diff + trace + debug report are opt-in. Read
net/crates/net/README.md#capabilitiesfor the high-level surface, then per-binding READMEs for language-idiomatic usage:- Rust SDK —
net/crates/net/sdk/README.md§ "Capability enhancements (typed taxonomy + predicates + validation)".pred!macro +require!family in scope undernet_sdk::capabilities. - Node —
net/crates/net/sdk-ts/README.md§ "Capability enhancements". Import from@ai2070/net-sdk. - Python —
net/crates/net/sdk-py/README.md§ "Capability enhancements". Import fromnet_sdk. - Go —
bindings/go/net/exports the parallel surface. C-ABI entry points documented innet/crates/net/include/README.md. - C —
net/crates/net/include/README.md§ "Mesh function families" rows "Predicate evaluation", "Predicatewhere:header", "Capability validation", "Predicate debug session". Worked examples:net/crates/net/docs/CAPABILITY_ENHANCEMENTS_USAGE.md.
- Rust SDK —
- Predicate-as-
cyberdeck-where:-header → server-side filter. Pairpredicate_to_rpc_headerwith the header-bearing nRPC call variants from v0.12 (net_rpc_call_with_headersand friends; same surface in every binding). Server's nRPC handler decodes viapredicate_from_rpc_headersand filters candidates withevaluate_predicate. Thecyberdeck-where:header name is exported asRPC_WHERE_HEADERfrom every binding. - Daemon capability authoring. Daemons that want to participate in capability-driven placement implement
required_capabilities/optional_capabilities. The runtime publishes both as part of the daemon's identity-bound announcement. Per-binding integration via the daemon-caps dispatcher (TS / Python: factory callback; Go:RegisterDaemonCaps; C:net_compute_set_daemon_caps_dispatcher). - Custom placement-filter callbacks. When the built-in
StandardPlacementaxes don't fit a placement rule, plug a host-language predicate viaplacement_filter_from_fn(closure)(TS / Python / Go) or implementPlacementFilterdirectly + register viaglobal_placement_filter_registry()(Rust). Pair withStandardPlacement::with_custom_filter_id(id). - Cross-binding consumers — every binding's wire format is pinned by the thirteen golden-vector fixtures under
tests/cross_lang_capability/. If you're integrating predicates / capability sets / debug reports across language boundaries, your wire-level compatibility is enforced at the binding's own CI. Fixtures versioned viaabi_version_expected: 1. - If you wired your own placement scoring around
Mikoshi::select_migration_targetor scheduler internals — the v0.13 path consultsStandardPlacementwith optional custom-filter callback.LegacyPlacementpreserves v0.12 behavior under a feature flag for one minor version; new code should targetStandardPlacement. - If you have caches keyed off the old
CapabilitySetshape on disk — the storage shape changed. Bust the cache or rewrite via the new shape. The view-projection layer is read-only over the typed tags + metadata, so encoding viaset_hardware(hw)etc. produces the canonical tag set; subsequentviews().hardware()reads back identically. - Go consumers —
origin_hashwidened touint64. Callsites assigningdaemon.OriginHash()(orIdentity.OriginHash()/migration.OriginHash()/replica.RouteEvent()/fork.ParentOrigin()/standby.{ActiveOrigin, Promote}()) to auint32variable fail to compile. Drop the explicit cast (or convert touint64); the canonical Rust shape is u64 and the Go binding's previous u32 silently truncated the upper 32 bits.CausalEvent.OriginHash,GroupMemberInfo.OriginHash,GroupForkRecord.{OriginalOrigin, ForkedOrigin}are nowuint64;DaemonRuntime.{Stop, Snapshot, Deliver, StartMigration, ExpectMigration, MigrationPhase}parameters andOpenTasks/OpenMemories/NewForkGroup'soriginHash/parentOrigintakeuint64. - Streaming RPC consumers wanting cancellation during construction — switch from
net_rpc_call_streaming/net_rpc_call_streaming_with_headersto the new*_cancellablevariants and pass acancel_tokenfromnet_rpc_reserve_cancel_token. A parallelnet_rpc_cancel_call(token)now aborts the constructionblock_on(peer-stalled initial-frame ACK), where pre-fixnet_rpc_stream_closeonly took effect after the stream handle was already constructed. Existing non-cancellable variants kept for back-compat.
v0.12 breaks the "Black Diamond" hardening line. After two consecutive releases of pure bug-fix + audit closure (v0.10 / v0.11), Firestarter is the first feature release on the line: it ships a complete request/response RPC surface (nRPC) on top of the v0.11 mesh, plus the four-language binding pipeline that consumes it (Node, Python, Go, plus the existing Rust SDK), plus a TypeScript migration of the Node binding's hand-written modules. The hardening posture is intact — every new surface has the same handle-lifetime, panic-safety, and FFI-soundness guarantees v0.11 established for the existing surfaces — but this release is about adding capability, not just polishing the existing one.
nRPC
Folds, Codec, Mesh Glue
The architectural anchor (and the prerequisite for everything else): an RPC server is a CortEX fold over a directed channel pair. There is no new transport, no new subsystem, no new daemon — just a typed dispatch enum on EventMeta, a channel-naming convention, and small caller-side / server-side helpers.
SubscriptionMode::QueueGroupon the channel roster (adapter/net/channel/roster.rs) — the one missing channel-layer primitive. Work-distribution dispatch alongside the existingBroadcastmode.add_with_mode/dispatch_recipients/subscriber_modeAPI; back-compat shims preserve every existing call site.MembershipMsg::Subscribe.queue_group: Option<String>wire field added atchannel/membership.rswith forward-compat decode (pre-queue-group senders with zero remaining bytes after the token decode asBroadcast). Public APIsMesh::subscribe_channel_in_queue_group[_with_token]. Pinned by 13 regression tests; cross-validated end-to-end bytests/queue_group_dispatch.rs(twoQueueGroupsubscribers on different nodes divide a stream of 100 events between them with exactly-once delivery; broadcast subscriber + queue-group pool coexist on one channel).cortex::rpccodec (adapter/net/cortex/rpc.rs) — dispatch constantsDISPATCH_RPC_REQUEST/RESPONSE/CANCEL/STREAM_GRANT/STREAM_CHUNK_DROPPED, flag bits (FLAG_RPC_STREAMING_RESPONSE,FLAG_RPC_PROPAGATE_TRACE),RpcStatusenum (Net-native with documented gRPC equivalence),RpcRequestPayload/RpcResponsePayloadround-trip codec withMAX_RPC_*caps andencoded_len()helpers for buffer pre-sizing. 15 regression tests pin wire stability + decode-rejection of malformed payloads.RpcServerFold—RedexFold<()>decoding REQUEST events, dispatching the handler in tokio, emitting RESPONSE via aRpcResponseEmittercallback.RpcCancellationToken(Notify+AtomicBool wrapper, race-safe),RpcContext(caller_origin + decoded payload + cancellation),RpcHandlerasync-trait,RpcHandlerError::{Application, Internal}. Handler panic caught viacatch_unwindand surfaced asRpcStatus::Internal. Fast deadline-already-passed short-circuit. CANCEL flips the in-flight token. Malformed payloads emit a structured warn-and-skip and continue (do not kill the cortex adapter). Duplicate REQUEST for an in-flightcall_idis refused; first-wins semantics. Per-channel-hash inbound dispatch hook onMeshNode(register_rpc_inbound/unregister_rpc_inbound) lets the mesh's inbound packet path consult a dispatcher map per packet (one DashMap get); registered channel hashes route directly and skip the per-shardinboundqueue.RpcClientFold+RpcClientPending— symmetric caller side.RpcClientPending::register(call_id)returns a oneshot receiver for unary calls;register_streaming(call_id)returns an mpsc receiver ofStreamItemfor streaming calls (the sameRpcClientFolddemuxes both call kinds via aPendingEntry::{Unary | Streaming}enum). Re-register of the samecall_idcloses the prior receiver (misuse detection).Mesh::serve_rpc(service, handler)/Mesh::call(target_node_id, service, payload, opts)glue (adapter/net/mesh_rpc.rs).serve_rpcregisters an inbound dispatcher for<service>.requests's channel hash; the dispatcher pushes events into a tokio mpsc that drains through theRpcServerFold.calllazy-subscribes to<service>.replies.<caller_origin>, allocates acall_id, registers a oneshot in the per-MeshRpcClientPending, direct-sends the REQUEST viapublish_to_peerbypassing the local subscriber roster (RPC's caller-knows-target model doesn't fit the publisher-led pub/sub roster), and awaits the receiver underopts.deadline. ReturnsRpcReplyonOk,RpcErroron any failure.ServeHandleis RAII — the dispatcher unregisters on Drop and in-flight handlers complete (no abort). Per-Mesh state additions onMeshNode:rpc_client_pending,rpc_next_call_id,rpc_reply_subscriptions(bounded; refuses hash collisions instead of overwriting).- End-to-end Mesh integration test (
tests/integration_nrpc_mesh.rs, 4 tests through real network handshake): round-trip echo, multiple sequential calls reusing the lazy reply subscription with exactly-once handler invocation, server panic surfaces asInternal, deadline emits CANCEL and surfaces asTimeoutto the caller. Deadline-fire CANCEL emission is now pinned by an explicit assertion test (rpc_deadline_fires_cancel_on_the_wire).
Service Discovery + Routing Policies
- Service discovery via capability announcements.
Mesh::serve_rpcauto-registers the service in a per-Meshrpc_local_servicesset;announce_capabilities[_with]auto-mergesnrpc:<service>tags onto the announcedCapabilitySet, propagating through the existing capability-broadcast machinery. Two new public APIs:Mesh::find_service_nodes(service) -> Vec<u64>queries the local capability index for nodes carrying thenrpc:<service>tag;Mesh::call_service(service, payload, opts) -> Result<RpcReply, RpcError>finds candidates, picks one perRoutingPolicy, dispatches via the existing direct-addressedcall(target, ...). ReturnsRpcError::NoRouteif no servers advertise the tag.ServeHandle::Dropremoves the service from the local registry so subsequent announcements stop emitting the tag. RoutingPolicyenum onCallOptions(defaultRoundRobin):RoundRobinuses a dedicated per-Mesh cursor withfetch_add(no longer collides with the call-id counter);Random(xxh3 ofcall_id, modulo);Sticky { key: u64 }(xxh3 of key, modulo a sorted candidate list — same key → same target while the candidate set is stable);LowestLatency(picks the candidate with smallestlatency_usper the localProximityGraph; deterministic fallback to the lexicographically-first sorted node id when no proximity data exists).filter_unhealthy: boolonCallOptions(defaulttrue) — skips candidates whoseProximityGraphentry reports!is_available(). Pin: candidates with NO proximity entry are KEPT (absence of evidence ≠ evidence of unhealth), so a freshly-announced server isn't falsely filtered just because pingwaves haven't propagated yet.- EntityId ↔ node_id bridge —
MeshNode::entity_id_for_node(u64) -> Option<[u8; 32]>accessor consultspeer_entity_idsto map session-layer node ids to entity-layer keys. The single missing piece thatLowestLatencyandfilter_unhealthyboth flow through. - End-to-end coverage (
tests/integration_nrpc_service_discovery.rs, 6 tests): three nodes, two serve "echo", one caller usescall_service— both servers exercised by round-robin;Stickypins consistency;Randomdistributes evenly; no-servers returnsNoRoutewith diagnostic;LowestLatencyfalls back deterministically when no proximity data exists;filter_unhealthykeeps proximity-less candidates.
Streaming, Tracing, Resilience, Metrics
The biggest single chunk of new surface in this release.
- Streaming responses. Multi-fire
DISPATCH_RPC_RESPONSEevents for onecall_idmarked non-terminal vs. terminal via thenrpc-streamingheader (continue/end).RpcResponseSink(unbounded mpsc, non-blockingsend),RpcStreamingHandlerasync-trait, andRpcServerStreamingFold(parallel toRpcServerFoldbut spawns a pump task draining the sink and emitting per-chunknrpc-streaming: continueframes; handler return → terminalendframe, handlerErr→ terminal non-Okframe, handler panic caught bycatch_unwind→ terminalInternal). Per-call ordering guarantee: the streaming fold takes anRpcAsyncResponseEmitter(Arc<dyn Fn(...) -> BoxFuture<()>>) instead of the unary fold's syncRpcResponseEmitter, and the pump task.awaits each emit before reading the next sink chunk — without this, two chunks emitted in tight succession would race into the publish path via independenttokio::spawns and arrive at the caller out of order. Caller side:Mesh::call_streamingreturns anRpcStream: futures::Stream<Item = Result<Bytes, RpcError>>; terminal-Ok closes the stream, terminal-error yields one finalErr(RpcError::ServerError)then closes.RpcStream::Dropclears the pending entry and best-effort emits CANCEL via direct unicast so the server's handler observesctx.cancellation. - Per-stream window grants (closes the Phase 3 streaming backlog). Wire additions:
DISPATCH_RPC_STREAM_GRANT(caller → server, payload is 4-byte big-endianu32credit count) +HEADER_NRPC_STREAM_WINDOW_INITIAL(REQUEST header, ASCII-decimalu32initial window). Server side keeps a per-callArc<tokio::sync::Semaphore>map; pump taskacquire_owned().await+forget()per chunk. STREAM_GRANT eventsadd_permits(n). Caller side:CallOptions::stream_window_initial: Option<u32>.RpcStream::poll_nextauto-grants 1 credit per delivered chunk (in-flight credit holds near the initial window).RpcStream::grant(n)is the explicit API for batched cadence; no-op when flow control isn't enabled. Defensive caps on incoming GRANT amounts so a misbehaving caller can't overflow tokio'sMAX_PERMITS. Bounded streaming pump mpsc with drop-on-full metric so a slow caller can't unbounded-buffer the server. - W3C Trace Context propagation (
cortex::rpc::TraceContext+extract_trace_context/build_trace_headershelpers). NewCallOptions::trace_context: Option<TraceContext>andRpcContext::trace_context: Option<TraceContext>fields. When the caller setsCallOptions::trace_context, the SDK emitstraceparent/tracestateheaders and setsFLAG_RPC_PROPAGATE_TRACE; the server's fold extracts the headers and populatesRpcContext::trace_context. nRPC is transport-only — application code on both sides reads/writes via whatever tracing backend it has wired up (tracing-opentelemetry, Datadog, etc.). Emptytracestateis omitted on the wire (W3C convention). Header-name matching is case-insensitive (W3C + HTTP convention); the previous implementation usedname.as_str() == "traceparent"and silently dropped any non-lowercase variant. - Caller-side retry helper (
sdk/src/mesh_rpc_resilience.rs).RetryPolicywith full-half jitter (each backoff scaled by uniform random in[0.5, 1.0]), exponential growth (backoff_multiplier, default2.0), upper-bound cap (max_backoff), and a swappableretryable: Arc<dyn Fn(&RpcError) -> bool>predicate. Default policy: 3 attempts, 50ms initial → 1s cap.default_retryableretriesTimeout,Transport, andServerErrorfor canonical transient statuses (Internal,Backpressure, server-observedTimeout); does NOT retryNoRoute,Codec, application errors,NotFound,Unauthorized,UnknownVersion, orCancelled. Four wrappers onMesh:call_with_retry,call_service_with_retry,call_typed_with_retry,call_service_typed_with_retry. Typed variants encode once and reuse the bytes across attempts; service variants re-resolve the candidate set per attempt so failover is automatic. - Caller-side hedge helper.
HedgePolicy { delay, hedges }— fire-then-race: primary att=0, additional hedges att=delay*idx, first reply (Ok or Err) wins; if first finisher isErr, the wrapper waits for remaining hedges before surfacing the deterministic last error. Defaults: 50ms delay, 1 hedge. Four wrappers:call_with_hedge_to(targets, ...)/call_typed_with_hedge_tofor explicit-target hedging (e.g. primary + warm-standby),call_service_with_hedge/call_service_typed_with_hedgefor capability-index-driven hedging across replicas. Why service-only and explicit-targets-only, not direct-to-one-target: hedging to the same target is always wrong (same backlog, same GC pause, doubles your load for nothing). Hedge losers'UnaryCallGuard::Dropfires CANCEL to the server, which observes it onctx.cancellation(pinned byhedge_loser_handler_observes_cancellation). - Caller-side circuit breaker.
CircuitBreakerwithCircuitBreakerConfig— three-state machineClosed → Open → HalfOpen → Closed/Open. Defaults: 5 consecutive failures to trip, 30s open cooldown, 1 successful probe to close. Different shape from retry/hedge: a long-lived stateful guard the user instantiates once (typically per logical downstream — one per service, or one per(service, target)pair) and shares viaArc<CircuitBreaker>. The wrapper takes a closure:breaker.call(|| async { mesh.call_typed::<Req,Resp>(...).await }).await. Generic over the inner result type so it composes around raw, typed, retried, OR hedged calls.BreakerError::{Open | Inner(RpcError)}— pattern-matchOpento fall back,Innerto handle the underlying error.default_breaker_failurematchesdefault_retryable(transient infra failures count as health signals; application errors don't). HalfOpen semantics: at most ONE concurrent probe; other calls during HalfOpen short-circuit. Panic-safe: a probe that panics doesn't poison the breaker's mutex; a poisoned mutex is recovered intointo_inner()so the breaker keeps serving. - Unary-call CANCEL-on-drop. New
UnaryCallGuardis constructed insideMesh::callimmediately after the REQUEST is published; if the call future is dropped before resolving (hedge loser,tokio::select!losing arm, caller-sideJoinHandle::abort), the guard's Drop runspending.cancel(call_id)AND spawns a CANCEL publish to the server via the newspawn_cancel_publishhelper (shared withRpcStream::Drop). The success path flipsguard.completed = trueso a happy call doesn't fire a useless CANCEL. - Per-service metrics + Prometheus formatter (
adapter/net/mesh_rpc_metrics.rs).RpcMetricsRegistry— per-MeshDashMap<String, Arc<ServiceMetricsAtomic>>(one entry per service that's been called or served). Bounded; idle entries with no in-flight ops and zero counters get evicted alongside empty queue-group shells. Per-service counters: caller-side (calls_total,errors_no_route/errors_timeout/errors_server/errors_transport,in_flight,latency_sum_ns/latency_count, Prometheus-default cumulative bucketed histogram), server-side (handler_invocations_total,handler_panics_total,handler_in_flight,handler_duration_*,streaming_chunks_emitted_total,streaming_chunks_dropped_total).CallMetricsGuard— RAII shim built BEFORE any potential early-return bumpsin_flighton construction, balances on Drop. Snapshot + Prometheus formatter:MeshNode::rpc_metrics_snapshot()is a cheap one-DashMap-pass copy. Service names are escaped per Prometheus exposition convention (backslash, double-quote, newline,\r); negative gauges from racy decrements clamp to zero.
nRPC bindings — Node, Python, Go (B1–B7)
The seven-phase rollout from NRPC_BINDINGS_PLAN.md ships in full. Each phase landed independently; all phases pass their per-binding test suites and the cross-binding wire-format compat tests. Total ~5,800 LoC of new binding code + ~2,500 LoC of tests.
| Phase | Scope | Commit |
|---|---|---|
| B1 | Node — raw serve / call / callService / callStreaming (Buffer in/out). Validates the napi ThreadsafeFunction handler-bridging pattern. |
98967fdc |
| B2 | Node — typed wrappers + RetryPolicy / HedgePolicy / CircuitBreaker + per-service metrics. |
5741f8e2 |
| B3 | Python — raw + GIL-aware runtime.block_on + tokio::task::spawn_blocking for handler dispatch. |
4003d9bb |
| B4 | Python — typed wrappers + resilience helpers + ServeHandle context manager. |
000b53bc |
| B5 | Go C-ABI — raw lifecycle + unary call / call_service / serve / find_service_nodes (bindings/go/rpc-ffi/, separate cdylib libnet_rpc). |
ea7c3836 |
| B6 | Go C-ABI — streaming + pure-Go RetryPolicy / HedgePolicy / CircuitBreaker + ABI version stamp (net_rpc_abi_version() -> u32, 0x0001 initial). |
9cf612ab |
| B7 | Cross-binding wire-format compat — shared tests/cross_lang_nrpc/golden_vectors.json fixture (6 ok cases + 3 error cases) drives parallel suites in Rust (tests/integration_nrpc_cross_lang.rs, 4 tests) + Node (bindings/node/test/cross_lang_compat.test.ts, 4 tests) + Python (bindings/python/tests/test_cross_lang_compat.py, 16 parametrized tests). 24 cross-binding compat assertions total. Drift in any binding's JSON encoding, typed-error mapping, or status-code constants now fails that binding's own CI. |
4cd7366b |
Cross-cutting decisions enforced by the fixture and the per-binding compat suites:
- Stable
nrpc:error prefix. Every binding's caller-side errors carrynrpc:<kind>: <detail>where<kind>is one ofno_route,timeout,server_error,transport,codec_encode,codec_decode,breaker_open. Each binding maps the prefix to typed exception classes viaclassifyError(e)(Node) /classify_error(e)(Python) /parseRpcError+ typed*RpcError(Go). The Node binding throws plainErrorwith the prefix (NOT typed classes) to sidestep vitest's dual-module-instance hazard; users classify at the catch site. - Canonical typed-handler status codes:
NRPC_TYPED_BAD_REQUEST = 0x8000,NRPC_TYPED_HANDLER_ERROR = 0x8001— both in the application-defined range0x8000..=0xFFFF. Re-exported from every binding alongside the typed surface. (The fixture initially used0x4001matching a stale Rust SDK comment; the fixture and Rust test were corrected to match the constant the bindings actually export. Found while writing the cross-binding compat suite.) ServeHandlelifecycle per language. Node:.close()method (finalizers are non-deterministic so callers MUST close). Python: context-manager protocol (with rpc.serve(...) as handle:) + explicit.close(). Rust:Drop. Go:(*ServeHandle).Close()+runtime.SetFinalizeras a backstop. In every case "drop / close stops new dispatch but lets in-flight handlers complete" — same contract as the Rustserve_rpc.- Caller-driven cancellation across all four bindings. Late in the cycle the bindings each grew an explicit cancellation surface beyond the existing CANCEL-on-future-drop:
- Node: AbortSignal-driven (
MeshRpc.reserveCancelToken()mints abigint; pass on the call's options; callMeshRpc.cancelCall(token)from anAbortSignallistener). Abort fires CANCEL on the wire. - Python:
Cancellablepyclass +RpcCancelledError. Pass viaopts={'cancel': cancel};cancel.cancel()from another thread aborts mid-flight. - Go:
ctx.Done()watcher goroutine wired throughnet_rpc_reserve_cancel_token/net_rpc_cancel_callC-ABI exports. Watcher pins to the stream/call's lifetime so it doesn't leak past close. Watcher self-deadlock prevention viawatcherDonechannel closed beforeClose().
- Node: AbortSignal-driven (
- Per-handler timeout configurable everywhere. Each binding's
serveaccepts an optional handler timeout (defaults to 60s for Go, no default for Rust/Node/Python — the SDK wraps user code with no timeout unless asked). Wedged handlers can't hold the in-flight slot indefinitely.
Node binding TS migration
- Single source of truth.
errors.tsandmesh_rpc.tsreplace the hand-writtenerrors.js/mesh_rpc.js+ parallel.d.tsfiles. The.d.tswas the only guard on the public type contract — and reviews of the nRPC work surfaced several places where the two had quietly diverged (theRawMeshRpcshape, thebreaker.armeddead branch, theappErrorhelper signature). Compiling from a single TS source catches that class of drift at build time. - Pipeline. New
tsconfig.build.jsonextends the existing test-onlytsconfig.json;target: ES2022,module: CommonJS,moduleResolution: node,strict,declaration,noEmitOnError.outDir/rootDirboth.so import paths don't change.package.jsongainsscripts.build:ts,scripts.typecheck, and aprepublishOnlythat runs the TS build beforenapi prepublish -t npm. Build artifacts (errors.{js,d.ts}+mesh_rpc.{js,d.ts}) are gitignored — regenerated on publish. - Module shape preserved. Stays CJS.
npm pack --dry-runproduces the same 8 files as before. Existingrequire('@ai2070/net/errors').CortexErrorkeeps working unchanged.index.js/index.d.tsstay JS forever — auto-generated by napi-rs from the Rust crate. - Test-stub conformance enforced. Turning
RawMeshRpcfrom documentation into a real type forcedStubRawMeshRpc,LoopbackHandlerRpc, andCancelTrackingRawto drop theiras unknownescape hatches and grow the missing methods. The compile error IS the win — the parallel.d.tscouldn't catch this. - Outcome. -210 LOC of duplicated
.js/.d.tscontent collapsed into single TS sources. 53/53 vitest tests pass against both source state (TS) and built state (compiled.js).
Test hygiene
- Cross-binding compat fixture — single source of truth for the canonical service contract. Every binding's compat test loads
golden_vectors.jsonand asserts the same matrix. Fixture is versioned viaabi_version_expectedmirroringNET_RPC_ABI_VERSION; bumping the ABI invalidates the fixture and forces every binding's compat test to update. - Streaming flow-control coverage (
tests/integration_nrpc_streaming.rs, 6 tests through real network): collects-all-chunks, drop-cancels-handler, terminal-error-after-partial-stream, plus the three flow-control tests (window_throttles_pump_until_grantsasserts the server'sstreaming_chunks_emitted_totalmetric is exactly the initial window after 300ms;auto_grant_drains_full_stream;explicit_grant_unblocks_pump). - Resilience helpers — 12 SDK integration tests across
mesh_rpc_retry.rs(4),mesh_rpc_hedge.rs(3),mesh_rpc_breaker.rs(5). Each pins a specific aspect: retry-then-succeed, retry-skips-app-errors, retry-exhaustion, predicate classification (retry); backup-wins, zero-degrades, empty-targets-NoRoute (hedge); full-state-machine cycle, failed-half-open-reopens, app-errors-don't-trip, reset-clears-state, error-flatten (breaker). All over real-network handshake. - Cross-language compat — 24 parametrized assertions (4 Rust + 4 Node + 16 Python) all driven from the shared fixture.
Breaking changes
Wire format additions (forward-compat from v0.11)
Unlike v0.11, v0.12 does not break wire compatibility with v0.11 for any pre-existing message type. Every change is a forward-compat addition:
- New dispatch bytes in the CortEX
EventMeta::dispatchnamespace under nRPC:DISPATCH_RPC_REQUEST,DISPATCH_RPC_RESPONSE,DISPATCH_RPC_CANCEL,DISPATCH_RPC_STREAM_GRANT,DISPATCH_RPC_STREAM_CHUNK_DROPPED. All in the CortEX-internal range0x10..=0x1F. A v0.11 receiver that doesn't know nRPC will see these as unknown dispatch values and route them to the no-op fold arm — no crash, no confusion, just a silent skip on the receiver side. MembershipMsg::Subscribegains an optionalqueue_group: Option<String>field (u8length + UTF-8 bytes after the existing token field). Forward-compat: a v0.11 sender (zero remaining bytes after the token) decodes asBroadcast. A v0.12 sender that emits aqueue_groupto a v0.11 receiver — the v0.11 receiver ignores the trailing bytes, which is benign for broadcast semantics but means queue-group dispatch silently degrades to broadcast-fan-out across mixed-version peers. Recommendation: upgrade publishers and subscribers in lockstep if you intend to useQueueGroup.publish_to_peernow stampschannel_hashon the outgoing packet header (was always0pre-fix). A v0.11 receiver doesn't consult the header for dispatch routing on the per-shard inbound path, so this is invisible there; v0.12 receivers consult the field for the per-channel-hash fast-path dispatcher hook. Mixed-version: v0.12 sender → v0.11 receiver works (header byte ignored); v0.11 sender → v0.12 receiver works (zero hash misses the dispatcher map and falls through to per-shard inbound, which is the same behavior the v0.11 sender's receiver already had).- New REQUEST headers:
nrpc-stream-window-initial(ASCII-decimalu32initial flow-control window) and the W3C tracing pairtraceparent/tracestate(whenFLAG_RPC_PROPAGATE_TRACEis set on the REQUEST). All optional; absence means "no flow control" / "no tracing context." - No changes to
IdentityEnvelope,EventMeta,CausalLink,OriginStamp,NetHeader, RedEX on-disk layout, or per-event checksum format — every v0.11 wire-format change persists unchanged into v0.12.
The summary: a v0.11 ↔ v0.12 fleet can coexist on the same mesh for the v0.11 subset of operations. nRPC traffic between mixed-version peers will silently fail (the v0.11 peer doesn't know how to dispatch nRPC), but the existing pub/sub and migration paths continue to work. Recommend lockstep upgrade if you intend to use nRPC across the fleet from day one.
Rust core (net crate) — API surface
SubscriptionModeenum is new inadapter::net::channel::roster. Match arms overSubscriptionModeneed to handle both variants;#[non_exhaustive]was added so this is forward-compatible.MembershipMsg::Subscribegains a publicqueue_group: Option<String>field. Struct-literal constructors must add it; the helper constructors (Subscribe::new, etc.) default toNoneso most call sites don't need updating.Mesh::subscribe_channel_in_queue_group/Mesh::subscribe_channel_in_queue_group_with_tokenare new public methods onMeshNodeand the SDK'sMeshenvelope.Mesh::serve_rpc/Mesh::call/Mesh::call_service/Mesh::find_service_nodesare new public methods onMeshNode. The SDK adds typed counterparts:serve_rpc_typed,call_typed,call_service_typed,serve_rpc_streaming,serve_rpc_streaming_typed,call_streaming,call_streaming_typed.adapter::net::cortex::rpcis a new public module re-exportingRpcContext,RpcHandler,RpcHandlerError,RpcRequestPayload,RpcResponseEmitter,RpcResponsePayload,RpcServerFold,RpcClientFold,RpcClientPending,RpcStatus,RpcStreamingHandler,RpcResponseSink,StreamItem,TraceContext, plus the dispatch + flag constants.adapter::net::mesh_rpcis a new public module re-exportingRpcError,RpcReply,RpcStream,CallOptions,RoutingPolicy,ServeError,ServeHandle,CodecDirection,MAX_RPC_*constants.adapter::net::mesh_rpc_metricsis a new public module re-exportingRpcMetricsRegistry,RpcMetricsSnapshot,ServiceMetrics,ServiceMetricsAtomic,CallOutcome,DEFAULT_LATENCY_BUCKETS_SECS. Snapshot viaMeshNode::rpc_metrics_snapshot(); Prometheus formatter viaRpcMetricsSnapshot::prometheus_text().MeshNode::register_rpc_inbound(channel_hash, dispatcher) -> boolandMeshNode::unregister_rpc_inbound(channel_hash)are new public methods. The dispatcher isArc<dyn Fn(StoredEvent) + Send + Sync>; registered channel hashes route directly and skip the per-shardinboundqueue.register_rpc_inboundreturnsfalseif the hash is already registered (refuses overwrites).ThreadLocalPooledBuilder::set_channel_hash(u32)is a new public method exposing the underlying packet-builder method so the publish path can stamp the channel hash.ChannelConfigRegistry::insert_prefix(prefix, config)/remove_prefix(prefix)are new public methods.get_by_name(name)falls back to a longest-prefix-first walk when no exact match exists. The exact-match hot path (DashMap get) is unaffected.
Rust SDK (net-sdk)
The SDK's nRPC surface is entirely additive — no existing SDK API changes.
- New module
mesh_rpcre-exportsRpcError,RpcReply,CallOptions,RoutingPolicy,ServeHandle,RpcContext,RpcHandler,RpcHandlerError,RpcStatus,ServeError,Codec,RpcStreamTyped,ResponseSinkTyped, plus theNRPC_TYPED_*status constants. - New module
mesh_rpc_resiliencere-exportsRetryPolicy,HedgePolicy,CircuitBreaker,CircuitBreakerConfig,BreakerError,BreakerState, plusdefault_retryable/default_breaker_failurepredicates. - New
Meshmethods (Rust SDK):serve_rpc,serve_rpc_typed,serve_rpc_streaming,serve_rpc_streaming_typed,call,call_service,call_typed,call_service_typed,call_streaming,call_streaming_typed,call_with_retry,call_service_with_retry,call_typed_with_retry,call_service_typed_with_retry,call_with_hedge_to,call_service_with_hedge,call_typed_with_hedge_to,call_service_typed_with_hedge,find_service_nodes,rpc_metrics_snapshot.
FFI / bindings
| Binding | Change |
|---|---|
| All | New nRPC surface — serve / call / callService / callStreaming / findServiceNodes plus typed wrappers + resilience helpers. Importable from @ai2070/net/mesh_rpc (Node), net.mesh_rpc (Python), bindings/go/net/ (reference; Go module ships downstream). All extend the existing binding modules; nothing pre-existing changes. |
| All | Stable nrpc: error prefix on every caller-side failure. Each binding ships a classifyError(e) / classify_error(e) helper for typed-error dispatch at catch sites. |
| Node | Hand-written errors.js / mesh_rpc.js + their .d.ts files replaced by single TypeScript sources (errors.ts, mesh_rpc.ts). Module shape and tarball contents unchanged for consumers; build pipeline now requires npm run build:ts before napi prepublish (wired into prepublishOnly). The TypeScript surface declares RawMeshRpc as a real interface — custom test stubs may need to grow methods that previously got past via as unknown escape hatches. Streaming + resilience helpers (TypedMeshRpc, RetryPolicy, HedgePolicy, CircuitBreaker) ship in the new mesh_rpc.ts. AbortSignal-driven cancellation: MeshRpc.reserveCancelToken() / MeshRpc.cancelCall(token) plus the cancelToken option on call. |
| Python | New net.mesh_rpc module ships TypedMeshRpc.from_mesh(mesh) + RetryPolicy / HedgePolicy / CircuitBreaker + the typed exception hierarchy (RpcError, RpcNoRouteError, RpcTimeoutError, RpcServerError, RpcTransportError, RpcCodecError, BreakerOpenError, RpcCancelledError). ServeHandle is a context manager (with rpc.serve(...)). Cancellation via Cancellable pyclass + opts={'cancel': cancel}. The native net.MeshRpc pyclass is the raw layer the typed wrapper sits on. GIL released across runtime.block_on(...); handler callbacks dispatch under tokio::task::spawn_blocking. |
| Go | New crate net-rpc-ffi at bindings/go/rpc-ffi/ ships the C-ABI cdylib libnet_rpc (separate from the existing compute-ffi). 21 new C entry points: lifecycle (net_rpc_new / _free), ABI-version stamp (net_rpc_abi_version()), unary call (net_rpc_call / _call_service), service discovery (net_rpc_find_service_nodes), serve (net_rpc_serve / _serve_handle_close / _serve_handle_free), streaming (net_rpc_call_streaming / _stream_next / _stream_grant / _stream_close / _stream_free / _stream_call_id), cancellation (net_rpc_reserve_cancel_token / _cancel_call), handler dispatcher registration (net_rpc_set_handler_dispatcher), free helpers (net_rpc_free_cstring / net_rpc_response_free / net_rpc_find_service_nodes_free). New error code NET_RPC_ERR_STREAM_DONE = -6 separates clean stream termination from "no chunk available right now." Reference Go consumer at bindings/go/net/mesh_rpc.go documents the cgo wiring; the Go module itself ships downstream. |
| C | nRPC is not exposed in net.h — it lives in the separate libnet_rpc cdylib (bindings/go/rpc-ffi/). The C SDK README at include/README.md § nRPC documents the entry-point listing, error codes, and ABI version stamp for downstream consumers building against the cdylib directly. |
Behavioral fixes that may surface as test breakage
MembershipMsg::Subscribeencoder emits no trailing bytes whenqueue_group: None. Tests that decoded a v0.11 Subscribe and asserted "trailing zero byte" will fail — the encoder no longer writes the length byte onNone. The decoder still accepts both shapes (forward-compat).- Hedge losers' handlers observe
ctx.cancellation. Pre-fix a hedge loser's request stayed in-flight on the server and the handler ran to completion against a caller that no longer cared. Tests that asserted "handler ran for every hedge attempt" will see the cancellation signal instead. - Caller-side
Mesh::calldropped before resolution emits CANCEL on the wire. Tests that asserted the server-side handler ran to completion despite caller drop will seectx.cancellationfire. - Server-side fold emits
RpcStatus::Cancelledon CANCEL observation. Tests that asserted "deadline + cancel surfaces asTimeout" will seeCancelledif CANCEL beat the deadline timer; the deadline path still surfacesTimeout(no behavior change for the deadline-only case). extract_trace_contextis case-insensitive. Tests that injected only-lowercase trace headers and asserted extraction will continue to work; tests that asserted capitalized variants were silently dropped will see the headers picked up.classify_publish_no_sessionmatches both publish-side and send-side error strings.call_servicefailure to a peer whose session expired between discovery and dispatch now surfacesRpcError::NoRouteinstead ofRpcError::Transport.ChannelConfigRegistryprefix-walk is longest-prefix-first. Tests that relied on insertion-order or shortest-prefix-wins to disambiguate nested prefix registrations will see the most-specific prefix match instead.- Per-handler-timeout default for the Go binding is 60s. Wedged Go-side handlers can no longer hold the in-flight slot indefinitely; tests that exercised "handler runs for >60s" will surface a timeout where they previously hung.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.12 line. Recompile. - For consumers that only use the existing pub/sub + migration surfaces — no source changes required. v0.12 is forward-compatible with v0.11 wire formats for everything that existed in v0.11. The new
SubscriptionModeandMembershipMsg.queue_groupfields are additive. - For consumers that want nRPC — the typed surface is opt-in. Read
net/crates/net/README.md#nrpcfor the cross-binding contract, then per-binding READMEs for language-idiomatic usage:- Rust SDK —
net/crates/net/sdk/README.md§ nRPC. Feature-gated oncortex(already enabled by thelocalandfullumbrella features). - Node —
net/crates/net/sdk-ts/README.md§ nRPC. Import from@ai2070/net/mesh_rpc. - Python —
net/crates/net/sdk-py/README.md§ nRPC +net/crates/net/bindings/python/README.md§ nRPC. Import fromnet.mesh_rpc. - Go —
net/crates/net/include/README.md§ nRPC for the C-ABI surface. Reference cgo wrapper atbindings/go/net/mesh_rpc.go.
- Rust SDK —
- For mixed v0.11 ↔ v0.12 fleets — pub/sub and migration paths continue to work cross-version. nRPC traffic between mixed-version peers will silently fail (v0.11 doesn't know how to dispatch nRPC). Upgrade the fleet in lockstep if you intend to use nRPC across all peers from day one.
QueueGroupsubscriptions silently degrade to broadcast fan-out when crossing into a v0.11 receiver — same recommendation. - Node consumers depending on the hand-written
mesh_rpc.js/errors.jsshape — module exports andrequire()resolution are unchanged. If your test harness usedas unknowncasts to satisfyRawMeshRpcagainst a stub that didn't conform, the stub will need to grow the missing methods (or the casts switched to actual conforming shapes). The TypeScript compile error names the missing method. - Cross-binding nRPC consumers — every binding's compat suite asserts the same fixture (
tests/cross_lang_nrpc/golden_vectors.json). If you're integrating nRPC across language boundaries, your wire-level compatibility is enforced at the binding's own CI. The fixture is versioned viaabi_version_expectedmirroringNET_RPC_ABI_VERSION = 0x0001. - Go consumers — the
libnet_rpccdylib is a separate build artifact from the existinglibcompute_ffi. Build withcargo build --release -p net-rpc-ffiand link both. ABI version drift is detected vianet_rpc_abi_version()vs the consumer's compiled-inExpectedABIVersion. - If you implemented your own caller-side request/response over the existing pub/sub primitives (e.g. via two channels + correlation id) — the nRPC surface implements exactly that pattern, with deadlines, retry/hedge/breaker, response streaming, and end-to-end cancellation. Migration is a straight rewrite per the per-binding README's
## nRPCsection. - If you wired your own metrics around the existing channel publish path for RPC-shaped traffic —
MeshNode::rpc_metrics_snapshot()+RpcMetricsSnapshot::prometheus_text()ships a complete per-service counter set (caller-sidenrpc_calls_total/nrpc_errors_total{kind}/nrpc_in_flight_calls/nrpc_call_latency_seconds_*+ server-sidenrpc_handler_invocations_total/nrpc_handler_panics_total/nrpc_handler_in_flight/nrpc_handler_duration_seconds_*/nrpc_streaming_chunks_emitted_total). One snapshot covers both directions for any service the local node both calls and serves.
v0.11 closes the audit work that v0.10 left open. Same shape: a hardening release with no new transports, no new SDK surfaces, no new feature gates. Every commit on this branch is a bug fix, a regression test, a triage decision, or a wire-format bump that closes a structural gap the previous release flagged but couldn't ship inside its envelope.
Addressed in this release
CortEX watermark, snapshot, and per-event integrity
folded_through_seqadvanced past unfolded events — underStoppolicy,recoverable_decodecould publish a watermark for events whose state mutation never landed;wait_for_seq(seq)returned true incorrectly and downstream readers acted on never-applied state. Split the watermark in two:applied_through_seq(strict-prefix, advances only onOk(())AND only whenseqis the immediate successor of the previous applied) andfolded_through_seq(live-progress, retained for low-latency observers).snapshot()writesapplied_through_seq; restore re-attempts the previously-skipped event so the post-restore state matches what fold committed, not what fold attempted.- Snapshot persisted
last_seqfor skipped events — same root cause as the watermark fix above. Once the strict-prefix watermark is the source of truth, snapshots no longer carry sequence numbers for events whose state was never applied; the on-disk log remains the source of truth on restore. - Per-event checksum did not cover the EventMeta header —
compute_checksum(tail)was xxh3 over only the payload tail; a stray bit-flip in the 20-byteEventMetaheader (e.g.dispatch: STORED → DELETED) was undetected by the per-event integrity check and silently re-routed the event to the wrong fold arm. The newcompute_checksum_with_meta(&meta, tail)covers both the header (with thechecksumslot zeroed) and the tail. Producers stamp v2; readers try v2 first and fall back to v1 to keep pre-fix on-disk records readable. Downgrading to a pre-v0.11 binary will skip every event written by a v0.11 producer (the legacy verifier expectsxxh3(tail), which v2 records won't match) — the migration is effectively one-way.
RedEX compact_to durability + atomicity (manifest-pointer flip)
Two layered fixes; the first patches per-call durability on Windows, the second closes the cross-file mixed-state window structurally.
Per-rename
MoveFileExW(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)—compact_to's rename calls usedstd::fs::rename, which on Windows isMoveFileExW(MOVEFILE_REPLACE_EXISTING)with no write-through — the destination metadata could be cached and lost on power-loss. Now driven through adurable_renamehelper that callsMoveFileExWwithMOVEFILE_WRITE_THROUGHon Windows; POSIX is unchanged (fs::renameis durable as long as the directory isfsync'd, which the surrounding code already does).Cross-file atomicity via manifest-pointer layout. The old
compact_todid three sequential renames (idx,dat,ts). A crash between rename N and N+1 left the on-disk channel in a mixed state (idxat gen K+1 paired withdat/tsstill at gen K) that recovery could not distinguish from a clean half-finished compact. The new layout puts each generation's files under its own directory and atomically swaps a single manifest pointer:<channel>/manifest # 16-byte pointer file <channel>/v0000000001/{idx,dat,ts} # current live generation <channel>/v0000000002/{idx,dat,ts} # next generation (mid-compact)compact_towrites the new generation's files in full, fsyncs them, thendurable_rename(manifest.tmp → manifest)is the single linearizing event. Before the rename, recovery sees the old manifest and usesv<N>/. After it, recovery sees the new manifest and usesv<N+1>/. There is no mixed state — every generation directory is either complete or orphaned, never partially live. Recovery falls back to the highest validatedv<NNN>/if the manifest is torn or missing, and sweeps every generation directory other than the live one on every open (cleaning up orphans left by a crashed prior compact).The post-rename
fsync_dir(channel_dir)is treated as best-effort: a rare POSIX failure after the linearizing rename is logged and swallowed rather than surfaced asErr, so the cached-handle swap still proceeds and on-disk + in-memory stay aligned. Surfacing the error would have lied to the caller about whether the flip happened, leaving any in-process appends between the failed compact and process exit landing in a now-dead generation. The residual durability gap (a power loss before the next implicit dirent flush could revert the rename) is recovered by the orphan-generation sweep on next open, which converges on a single consistent live generation regardless of which side of the rename survived.Legacy v0.10 / v0.11 channels with the flat
<channel>/{idx,dat,ts}layout migrate transparently into<channel>/v0000000001/{idx,dat,ts}on first open. The migration is one-shot per channel and idempotent. Pinned by 20 new regression tests including all 10 crash-injection points sketched inBUG_AUDIT_2026_05_03_REMAINING_PLAN.md's long-term-follow-up section, plus mid-rename partial-migration recovery, fault-injectedfsync_dirfailure handling, and source-shape guards against the deleted post-rename-reopen failure mode drifting back. Design recorded indocs/misc/REDEX_MANIFEST_POINTER_DESIGN.md.
Compute registry quiescence
- In-flight
Arc<Mutex<DaemonHost>>callers mutated through swap and unregister —replaceandunregisterrotated the registry'sArcslot but a concurrent caller that had already cloned the priorArcout of the map (betweenget_arcandarc.lock()) would land its mutation on the now-orphaned host. The replacement was correct from the registry's point of view but the orphaned host had already been removed from delivery routing, so writes to it disappeared into nothing. Introduced aguard_identity(origin_hash, &held_arc)helper that runs afterarc.lock()and re-checksArc::ptr_eqagainst the current registry slot. On mismatch the helper surfaces a typedDaemonError::Stale(u32)and the caller bails before mutating; the new variant lets callers distinguish "I lost the swap race" from "the daemon was never registered" without inspecting registry state.
FFI handle lifetime — cortex, mesh, identity, redis-dedup
A foreign caller (Go cgo, Python threads, Node.js workers) racing a _free against an active op against the same handle could (a) UAF on the inner Arc after _free did Box::from_raw → drop, or (b) UAF on the outer handle box itself even when the inner was held alive via an Arc<Inner> clone. The shape was filed as three separate audit items because three separate handle families exhibited it; the underlying race is one race.
- Shared
ffi::handle_guard::HandleGuardextracted withtry_enter() -> Option<HandleOp<'_>>andbegin_free(deadline) -> bool. Packed atomics (freeing: AtomicBool,active_ops: AtomicU32); SeqCst-ordered Dekker-style "set freeing, check active_ops" handshake; per-handleFFI_HANDLE_FREE_DEADLINE: Duration = 5s. Soundness rule: the handle box is never deallocated once handed to C —_freetakes the inner out viaManuallyDrop::takeonly afterbegin_freereturns true, and the outer Box (carryingHandleGuard's atomics) is intentionally leaked. Concurrent ops doingtry_enterafter free safely fetch_add on still-valid memory, observefreeing=true, decrement, and bail. - All 11 cortex/mesh/identity/redis-dedup handle types ported.
RedexHandle,RedexFileHandle,RedexTailHandle,TasksAdapterHandle,TasksWatchHandle,MemoriesAdapterHandle,MemoriesWatchHandle(cortex side);MeshNodeHandle,MeshStreamHandle(mesh side, including theArc::ptr_eqUAF inhandles_matchthat audit #25 specifically called out);IdentityHandle,RedisDedupHandle. Every entry point gates ontry_enter; every_freedrivesbegin_free._freeis idempotent — a second/concurrent_freecaller observes the lost CAS, returns false, and bails before the double-take that would UAF the inner allocation. - Per-handle regression coverage. Three pinned tests per handle: post-
_freeop returnsShuttingDown,_freeis idempotent under concurrent callers,_freewaits for an in-flight op to drain (or timeouts and leaks rather than UAF). Plus five tests on theHandleGuardhelper itself (try_enter, post-free bail, drain-wait, drain-timeout, idempotent concurrent free).
Identity & envelope
IdentityEnvelopewire format gains a 1-byte version prefix. Pre-fix the AEADopen()path tried v1, and on failure retried v0 — the documented rolling-upgrade fallback. The new layout puts a singleIDENTITY_ENVELOPE_VERSION = 1byte at offset 0; readers reject any other byte viaEnvelopeError::UnknownVersionand skip the AEAD attempt entirely. The CPU-DoS amplification framing in the original audit was overstated (the ed25519 signature check fail-fasts random ciphertext before either AEAD attempt fires; only legitimate-but-replayed v0 envelopes ever reached the second AEAD), but the structural improvement of "version byte at offset 0, deterministic dispatch, no v0 fallback at all" closes the gap with one extra byte.IDENTITY_ENVELOPE_SIZE208 → 209;SNAPSHOT_VERSION1 → 2.origin_hashwidened fromu32tou64across the application layer. Pre-fixEntityKeypair::origin_hash()returned a 32-bit BLAKE2s projection; with ~65 K distinct daemon identities the birthday probability of two daemons aliasing the sameorigin_hashcrossed 50 %, and cross-channel accounting keyed byorigin_hashsilently conflated them. Now widened to 64 bits at the application layer (EntityKeypair,EntityId,OriginStamp,CausalLink,EventMeta,ContinuityProof,ForkRecord,DaemonRegistry,daemon_factory, the SDK's public surface). The per-packetNetHeader.origin_hashdeliberately staysu32— that field is the routing fast path's pre-AEAD filter and width matters for cache-line packing; thewith_origin(u64)setter downcasts to the routing-side projection. Wire-format constants:CAUSAL_LINK_SIZE28 → 32,EVENT_META_SIZE20 → 24,CONTINUITY_PROOF_SIZE36 → 40.- The widening cascade flowed through the SDK, the Node binding (
u64→ JSbigint, matching the existingnode_idconvention), the Python binding (pyo3 mapsu64to nativeinttransparently), and the Go binding (uint32_t→uint64_tininclude/net.go.h).
Compute orchestrator & merge
on_replay_completesynthesizedtarget_headwithparent_hash: 0— downstream verifiers couldn't reconcile a chain head whose parent was the literal zero hash; reconciliation surfacedForkedagainst legitimate replay-completion messages. Now queriesdaemon_registry.with_host(...)for the real chain head and stamps the actual parent hash. The audit's separate report againstconsumer/merge.rs:384(per-shard cap rolling the cursor backward onunclamped_per_shard > PER_SHARD_FETCH_CAP) was re-triaged as obsolete: the current code already advances the cursor to the last fetched event id; the audit was reading a prior revision. Pinned by a new regression test (poll_merger_does_not_stall_on_single_shard_filter_under_cap).
Mesh transport — mesh.rs deep-read audit
The 9 items the v0.10 release note flagged "queued for the next release" all land here.
spawn_heartbeat_loopheld a DashMap shard guard across.await— the heartbeat broadcast loop iteratedpeers.iter()and awaitedsocket.send_to(...)(heartbeat then pingwave, twice per peer) while still holding the iterator'sRef. Every other task touching the same shard blocked for the cumulative round-trip. Now snapshots(node_id, addr, Arc<NetSession>)tuples into aVecfirst and awaits without the iterator alive.accept/startmutual exclusion usedAcqRelwhere the comment relied on SeqCst — the doc-comment argued correctness from "the SeqCst total order on these two atomics," but theaccept_in_flight.fetch_add(1, AcqRel)and the matchingfetch_subinAcceptGuard::dropwere not part of the SC total order. On x86 the LOCK'd RMW happened to fully fence so the race was unobservable; on AArch64 / RISC-V the dispatcher could racehandshake_responderfor the inbound msg1. Both increments nowSeqCst.- Routed-handshake key rotation silently overwrote a live session — the replay guard only fired for the same
remote_static_pub; a routed msg1 with a different static for the samepeer_node_idfell through andpeers.insertoverwrote the existing legitimate session. The legitimate peer's subsequent AEAD packets (encrypted under the old session key) failed to verify and were silently dropped. The trusted-PSK threat model rationalised this only if PSK compromise was treated as "any node can DoS any other node's sessions" — which contradicted the rest of the auth surface (entity-ID TOFU pinning, signed capability announcements). Rotation is now refused while the existing session is still within its idle / heartbeat window. handle_routed_handshakepeers.get→peers.insertwas not atomic — two concurrent routed handshakes for the samepeer_node_id(e.g. a flaky peer retrying under a fresh ephemeral) could both pass the replay-guardexisting.remote_static_pubcheck and race the insert; the loser'spending_handshakesinitiator state stayed armed waiting for a msg2 now bound to the winner's session, untilhandshake_timeoutfired. Decision and insert now hold a singlepeers.entry(peer_node_id)write guard.commit_reclassify_observationstorn(nat_class, reflex_addr)snapshot — when every probe failed,latest_reflex == None. The code still updatednat_class(typically toUnknown) but leftreflex_addrat its previous value; subsequentannounce_capabilities_withreads undertraversal_publish_musaw(fresh class, stale reflex). The wholetraversal_publish_muinvariant was silently violated on this branch.reflex_addris now reset toNonewhenlatest_reflexisNone, keeping the pair coherent.authorize_subscriberejected idempotent re-subscribes withTooManyChannels— a peer at the channel cap that retransmitted/re-subscribed to a channel it already held was rejected even thoughSubscriberRosteris set-typed and the operation is a no-op. Now short-circuits(true, None)when the roster already contains the channel, before the cap-check fires.publish_to_peerdid not propagate the reliable flag to the packet header — every other sender (send_to_peer,send_routed,send_on_stream,mod.rs:1016/1063) computedif reliable { PacketFlags::RELIABLE } else { PacketFlags::NONE }and threaded it into the packet builder;publish_to_peerhard-codedPacketFlags::NONEand only fedreliableintoopen_stream_with. Latent today (the dispatch path doesn't yet inspectflags.is_reliable()) but the per-call-site inconsistency would silently bite when a receiver-side path consults the packet flag —proxy.rs/route.rs/router.rsalready inspectis_priority/is_control. Same ternary as the other senders now applied.process_local_packetmigration loopback unbounded synchronous self-bounce — the in-placepending: VecDequekept draining as long as the handler emitted self-bound follow-ups. A buggy or attacker-influenced trusted handler that always emitted a self-bound message would spin the dispatch task synchronously, starving every other peer's packets. Now caps loopback depth (tracing::warn!past it).connect_viadid not refreshaddr_to_nodeafter a successful direct upgrade — afterconnect_direct → connect_via(peer_reflex, …)succeeded, the upgraded session's dispatch fast path missed onpeer_reflexand fell back to a linearpeers.iter().find(|e| session_id == ...)per packet. Performance only, but it defeated the addr → nid index for exactly the sessions that benefit most from it. Theconnect_directOkpath now inserts the(peer_reflex, peer_node_id)mapping; the relayed-session note inconnect_viaitself is unchanged (the upgrade is a separate caller).
Behavior / safety / rate limiting
per_source.clear()minute-boundary RPM cap exceedance — the periodic sweep cleared the per-source rate-bucket map at the minute boundary, which momentarily zeroed every active source's count and let the next 60 seconds of traffic through unmetered before the budget gate observed it again. Replaced with a packed-atomicRateBucketcarrying(window_floor: u32, count: u32)in a singleAtomicU64; CAS-based atomic reset on window rollover, no clear-and-reinsert race, no stale-count window.gc_per_source_stalenow sweeps stale entries based on observed window age rather than stomping the live state.try_acquirecomputes itsOkvalue from the CASprev, not a racy reload — avoids a second lost-update window.
Cluster F triage (lower-severity items)
- #81
adapter/redis.rspipeline timeout duplicate hazard — config-deployment-shape issue; closed with a one-time-per-processtracing::warn!fromRedisAdapter::initpointing atnet_sdk::RedisStreamDedupso misconfigured deployments are surfaced at boot rather than as silent duplicate publishes under retry. - #125
behavior/safety.rsper-source RPM cap — closed via the packed-atomicRateBucketrework above. - #127 initiator handshake
HandshakePacer— re-triaged as obsolete; the structural fix (per-(peer, us) in-flight handshake registry) is a separate refactor and the existing per-call timeout already bounds the worst case to a known floor. - #128
router.rsnotify_one+ permit-stash soundness — re-triaged as obsolete; the notify-with-stashed-permit pattern is sound vsnotify_waitersfor this use case (all waiters drain at most-once, no lost-wakeup window). Documented in-line so the design rationale survives the next reader. - #73
consumer/merge.rsper-shard cap rolling cursor backward — re-triaged as obsolete; current code advances. Pinned bypoll_merger_does_not_stall_on_single_shard_filter_under_cap. - #118
behavior/rules.rsrate-limit reset semantics — re-triaged as obsolete; the currentreset to 1is the correct semantic (the audit'sreset to 0would allowmax+1firings per window). - #121
behavior/loadbalance.rsP2C withlen == 2— re-triaged as obsolete; the degenerate case IS the P2C algorithm with 2 inputs.
Test hygiene
HandleGuardrace injection — five tests on the helper module: try_enter, post-free bail, drain-wait, drain-timeout, idempotent concurrent free. Three pinned tests per ported handle (post-freeShuttingDown, idempotent_free,_freewaits for in-flight op).- Cortex
applied_through_seqstrict-prefix — five regression tests pinning the watermark advances only onOk(())-and-immediate-successor; snapshot reflects the strict-prefix value; restore re-attempts the previously skipped event (so post-restore state matches what fold committed, not what fold attempted). compute_checksum_with_metav2 coverage — pins that v2 detects bit-flips indispatch,flags,origin_hash,seq_or_ts; pins that v1 fallback still accepts pre-fix on-disk records; pins that v1 and v2 of the same input differ for typical tails (so the fold-side fallback can't accidentally accept a v2 record by numerical coincidence).DaemonRegistry::Stalequiescing — five regression tests pinning that an in-flight mutator holding a now-orphanArcsurfacesDaemonError::Stale(u32)instead of mutating; thatreplaceandunregisterboth trip the check; that the surviving in-flight Arc and the fresh registration don't produce two parallel writers.durable_renameWindows behavior — three regression tests pinning theMoveFileExW(MOVEFILE_WRITE_THROUGH)path on Windows and the POSIX fast-path passthrough.- Identity envelope version-byte rejection — pins that envelopes with any leading byte other than
IDENTITY_ENVELOPE_VERSION = 1surfaceEnvelopeError::UnknownVersionand never reach the AEAD path. - Mesh-audit regression coverage — the heartbeat snapshot,
accept/startSeqCst, routed-handshake atomic entry, NAT class/reflex coherence, idempotent re-subscribe, reliable flag propagation, loopback depth cap, andaddr_to_nodedirect-upgrade refresh each carry a pinned regression test intests/mesh_audit.rs. - JetStream msg-id
sequence_startper-shard monotonicity — pins that within one bus instance, every shard's batches advance theirsequence_startstrictly monotonically AND gap-free (seq_start[n+1] == seq_start[n] + len(events[n])). A regression that introduced a gap would let(process_nonce, shard, seq, i)tuples be reused after the JetStream / Redis dedup window closes; an overlap would silently overlay a later batch on an earlier one's slot. Pinned bybus::tests::sequence_start_is_per_shard_monotonic_and_gap_free. The cross-restart variant (persistentnext_sequenceacross process boots) remains feature-shaped and is not in this release; today's invariant relies onprocess_noncerotating to disjoin the msg-id namespace. - Manifest-pointer crash-injection — 12 regression tests covering manifest codec round-trip + corruption rejection, brand-new-channel init, flat-layout migration, fallback when manifest is missing or torn, sweep of orphan newer / older generation directories, generation advancement + manifest atomicity, and recovery convergence in one open. Maps onto the 10-row crash-injection table in
docs/misc/REDEX_MANIFEST_POINTER_DESIGN.md.
Triage decisions recorded in code
One audit item resolved as "no code change needed, but the rationale must live in code so a future contributor doesn't re-open the question":
apply_authoritative_grantclamp ordering — the audit recommended reordering thetx_bytes_sentbump and thetx_credit_remainingdecrement. The current form uses a CAS-with-delta againstmax_consumed_seenand adds the delta totx_credit_remainingviafetch_update; this composes atomically with the CAS intry_acquire_tx_creditand thefetch_updateinrefund_tx_credit. The audit's reorder presumed a.store()-based recompute from a racy snapshot oftx_bytes_sent— a shape the current code deliberately avoids. The rationale is documented in code atadapter/net/session.rs::apply_authoritative_grantand the codec-side abstract atadapter/net/subprotocol/stream_window.rs::StreamWindow.
Breaking changes
Wire format (v0.10 ↔ v0.11 do not interop)
This is the consequential upgrade. Three structural format changes land together; the wire-format pair are NOT backwards-compatible across the wire (v0.10 ↔ v0.11 do not interop), and the RedEX on-disk layout migrates automatically on first open per channel.
IdentityEnvelope v0 → v1 (208 B → 209 B)
IdentityEnvelope::to_bytes now writes a leading IDENTITY_ENVELOPE_VERSION = 1 byte; from_bytes rejects any other leading byte via EnvelopeError::UnknownVersion. The v0 fallback in open() is removed entirely. IDENTITY_ENVELOPE_SIZE is 1 + 32 + 80 + 32 + 64 = 209.
SNAPSHOT_VERSION bumps 1 → 2 because the snapshot wire format embeds the envelope at fixed offsets and the version byte shifts every subsequent field. v0.10's from_bytes_v0 is removed; from_bytes_v1 was renamed to from_bytes_v2.
Impact: v0.10 → v0.11 must upgrade in lockstep. A v0.10 sender to a v0.11 receiver will get UnknownVersion on every envelope; a v0.11 sender to a v0.10 receiver will fail signature verification because v0.10 doesn't account for the leading byte in its AAD construction.
origin_hash widening: u32 → u64
EntityKeypair::origin_hash(), EntityId::origin_hash(), and OriginStamp::origin_hash() now return u64 (the full 8-byte BLAKE2s value, not a 4-byte truncation). The struct fields CausalLink.origin_hash, EventMeta.origin_hash, ContinuityProof.origin_hash, and ForkRecord.origin_hash widen accordingly. The wire-format constants:
| Type | Old size | New size |
|---|---|---|
CAUSAL_LINK_SIZE |
28 | 32 |
EVENT_META_SIZE |
20 | 24 |
CONTINUITY_PROOF_SIZE |
36 | 40 |
NetHeader.origin_hash deliberately stays u32. That field is the per-packet routing fast path's pre-AEAD filter and width matters for cache-line packing. The setter with_origin(u64) downcasts to the routing-side projection (as u32); the OriginStamp::origin_hash() doc explicitly notes this convention.
The DaemonRegistry's public surface (register, unregister, snapshot, deliver, with_host, stats, contains) and the daemon_factory::FactoryEntry map are keyed by u64. All SDK methods that take or return an origin_hash (DaemonRuntime::stop, snapshot, deliver, migration_phase, peek_migration_failure, inject_migration_failure, subscriptions, expect_migration, start_migration, etc.) take/return u64. The DaemonHandle.origin_hash, MigrationHandle.origin_hash, and CausalEvent.origin_hash fields widen accordingly.
Impact: on-disk RedEX files written by v0.10 cannot be read by v0.11's cortex adapters — the meta header layout shifts. Re-tail from the source of truth (the bus / publisher) on upgrade. The cortex per-event checksum's v1 fallback path keeps reading legacy checksums, but the meta-size shift means the byte slicing itself differs.
Cortex per-event checksum v1 → v2
Producers stamp compute_checksum_with_meta(&meta, tail) (header-covering). Readers try v2 first and fall back to v1 (compute_checksum(tail)) so pre-v0.11 records remain readable. New writes are v2-only. Downgrading to a pre-v0.11 binary will skip every event written by a v0.11 producer — the migration is one-way.
RedEX on-disk layout: flat → manifest-pointer + generation directories
Each channel's <base>/<channel>/{idx,dat,ts} files now live one level deeper at <base>/<channel>/v0000000001/{idx,dat,ts}, alongside a single <base>/<channel>/manifest pointer file (16 bytes) that names the live generation. Compactions roll the live generation by writing a fresh v<N+1>/ directory and atomically swapping the manifest.
Migration is automatic and transparent. On first open, a v0.10 / v0.11 channel with the flat layout is migrated by renaming each of {idx,dat,ts} into v0000000001/, then writing a manifest pointing at it. The migration is one-shot per channel and idempotent; failure mid-migration leaves the per-file moves in whichever state they reached and the next open re-runs the migration.
Tools that read RedEX files directly (rare; the supported access path is the RedexFile API) need to read the manifest first and follow it to the live generation directory. The 16-byte manifest format is documented in docs/misc/REDEX_MANIFEST_POINTER_DESIGN.md.
Rust core (net crate) — API surface
origin_hashtypes widen tou64at every public API point listed above. Theas u32downcast at the routing-fast-path boundary (NetHeader::with_origin) is the only place in the new code where the projection survives.DaemonError::Stale(u32)is a new variant. Match arms overDaemonErrorneed to add it;#[non_exhaustive]was already in place so this is forward-compatible, but exhaustive match-on-variant code refuses to compile.compute_checksum_with_meta(meta: &EventMeta, tail: &[u8]) -> u32is a new public function.compute_checksum(tail: &[u8]) -> u32remains and is now described as the v1 fallback used only on the read side; new writers must usecompute_checksum_with_meta. Both are re-exported fromadapter::net::cortex.IDENTITY_ENVELOPE_VERSION: u8 = 1is a new public constant re-exported fromadapter::net::identity. Pin against this instead of literal1so a future bump auto-propagates.- CortexAdapter splits the watermark.
applied_through_seqis the new strict-prefix watermark used bysnapshot();folded_through_seqis the live-progress watermark used bywait_for_seq. Existing snapshot consumers that readlast_seqget the strict-prefix value automatically; tests asserting thatwait_for_seq(seq)impliedstate was applied for seqneed to be re-read against the new semantic (wait_for_seqindicates fold attempted; restore re-attempts skipped events). HandleGuardis a new public module underffi::handle_guard(pub mod handle_guard). Custom FFI wrappers built against the crate (rare — most consumers use the bundled bindings) need to embedHandleGuardand route every entry point throughtry_enter/begin_freeto keep the same memory-safety guarantees the bundled bindings now have.
Rust SDK (net-sdk)
- All
origin_hashparameters and fields widen tou64.Identity::origin_hash() -> u64.DaemonHandle.origin_hash: u64.MigrationHandle.origin_hash: u64. Closuresmove |origin_hash: u64|inPostRestoreCallback,PreCleanupCallback,MigrationFailureCallback.DaemonRuntime::stop,snapshot,deliver,migration_phase,peek_migration_failure,inject_migration_failure,subscriptions,subscribe_channel,unsubscribe_channel,expect_migration,start_migration,start_migration_with. Thegroups/{fork,replica,standby}surface widens parent_origin / active_origin / route_event return types.group_idingroups/replicadeliberately staysu32— that's agroup_seedhash, distinct fromorigin_hash. - The brute-force u32 collision fixture in
compute_runtime.rs(spawn_from_snapshot_checks_full_entity_id_not_just_origin_hash) searches for a collision on theas u32projection rather than the full u64 — the SDK's identity-mismatch guard fires on the routing-side u32 collision, so the test's intent (entity_id check, not origin_hash check) is preserved at the original ~2^16 birthday-bound runtime.
FFI / bindings
| Binding | Change |
|---|---|
| All | Every FFI handle type (cortex, mesh, identity, redis-dedup) now embeds HandleGuard. _free is idempotent across all 11 types; entry points after _free return typed ShuttingDown instead of segfaulting. Behavior change for callers that depended on _free being one-shot or used double-free as a way to detect prior frees — those patterns now silently succeed where they previously crashed. |
| All | EntityKeypair::origin_hash() and friends return u64. The bundled bindings handle the marshalling per-language; consumers that called these APIs via raw FFI need to widen the receiving type. |
C (include/net.go.h) |
net_identity_origin_hash, net_compute_daemon_handle_origin_hash, net_compute_migration_handle_origin_hash, every net_compute_* function with an origin_hash parameter, all replica/fork/standby out-params, and the cortex net_tasks_adapter_open / net_memories_adapter_open origin_hash parameters are now uint64_t. C consumers must widen their typed pointers. |
Node (@net/sdk) |
The TypeScript surface declares originHash: bigint (matching the existing nodeId: bigint convention). Existing callers using JS Number literals must switch to BigInt literals (0xabcdef01n) or wrap with BigInt(value). The auto-generated index.d.ts reflects the new types. |
Python (net-py) |
Python int is arbitrary precision; the surface is unchanged for callers (PyO3 marshals u64 ↔ int transparently). One pytest fixture literal was extended from 0xdead_beef to 0xdead_beef_dead_beef to actually exercise the upper 32 bits. |
Go (compute-ffi) |
All origin_hash parameters and out-params are uint64_t in the cgo header; Go callers must use uint64 typed locals where they previously used uint32. |
Behavioral fixes that may surface as test breakage
These aren't strictly API-breaking but tests that asserted the pre-fix behavior will need updating:
- Cortex snapshot
last_seqreflectsapplied_through_seq, notfolded_through_seq— tests that asserted snapshots include sequence numbers for skipped events will fail. The strict-prefix semantic is the correct one; the assertion was reading the bug. - Cortex restore re-attempts the previously-skipped event — tests that asserted
statewas preserved verbatim across snapshot+restore (treating the skip as a permanent state change) will see the post-restore state include the re-attempted event. The asymmetric trade-off is documented onsnapshot()'s rustdoc. DaemonRegistry::replace/unregisterfollowed by an in-flight mutator returnsDaemonError::Stale(u32)— tests that asserted the mutation landed on the orphan host will see the typed error instead.- FFI
_freeis idempotent and returns success on second-call — tests that asserted second-call returned an error code will see success. - FFI entry points after
_freereturnShuttingDown— tests that asserted post-free behavior was undefined / panicked will see the typed error. - Per-event cortex checksum is the v2 header-covering hash — tests asserting
meta.checksum == compute_checksum(tail)(v1) will fail; switch tocompute_checksum_with_meta(&meta, tail). Two pinned tests undertests/integration_cortex_{tasks,memories}.rsalready had this issue and were updated. IdentityEnvelope::openrejects v0 envelopes outright — tests that asserted the v0 fallback path engaged will fail. Theopen_accepts_v0_envelope_for_rolling_upgrade_compatfixture from v0.10 has been removed (it explicitly pinned the now-removed fallback); the new equivalent pinsEnvelopeError::UnknownVersionon a leading-byte mismatch.- Mesh
accept/startuse SeqCst onaccept_in_flight— tests on AArch64 / RISC-V hardware that relied on the pre-fix race window to construct concurrent-accept-and-start state will see the documented mutual exclusion. - Mesh routed-handshake refuses key rotation while a session is live — tests that asserted the silent overwrite (e.g. simulating a Sybil swap-in via routed msg1) will see the rotation refused.
authorize_subscribeshort-circuits idempotent re-subscribes ahead of the cap-check — tests that asserted at-cap re-subscribe surfacedTooManyChannelswill see success instead.- RedEX poisoning error strings now reference
"partial-write rollback could not restore on-disk state to match in-memory"— log alerting / string assertions that matched the prior"compact_to post-rename reopen failure"parenthetical (which described a setter the manifest-pointer rework deleted) need updating. The poisoning condition itself is unchanged: only the partial-write rollback paths set the flag, and the error wording now accurately names them.
How to upgrade
- Coordinate the upgrade across all peers in a deployment. v0.10 and v0.11 do not interop on the wire — the envelope version byte and the EventMeta size both changed. Stand the new version up across the fleet in one window rather than rolling upgrades.
- Re-tail from your source of truth (bus / publisher) for any RedEX channels carrying state you need to retain. v0.10's on-disk EventMeta layout (
origin_hashat bytes [4..8],seq_or_tsat [8..16],checksumat [16..20]) does not match v0.11's (origin_hashat [4..12],seq_or_tsat [12..20],checksumat [20..24]). The cortex per-event checksum's v1 fallback path reads checksums from pre-v0.11 records, but the meta-size shift means the byte slicing itself is different. - Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.11 line. Recompile. The Rust signature changes (u32→u64onorigin_hash,DaemonError::Stalevariant,applied_through_seqwatermark) will surface as compile errors at the exact call sites that need updating. - JS / TypeScript callers: switch
originHashliterals toBigInt.0xabcdef01→0xabcdef01n. The TypeScript surface declaresoriginHash: bigint; existing call sites usingNumberwill fail at runtime against the new declarations. - Go callers: widen
uint32locals touint64for everyorigin_hashparameter, return value, or struct field. The cgo header (include/net.go.h) reflects the new ABI. - Python callers need no source changes —
intis arbitrary precision and PyO3 handles the marshalling transparently. Re-test fixtures that round-trip anorigin_hashthrough external storage (databases, message queues) to confirm the upper 32 bits are preserved. - C callers: widen
uint32_ttyped pointers touint64_tfor everyorigin_hashparameter and out-param. Anyone hand-rolling againstinclude/net.go.hmust regenerate their bindings. - If your tests covered any of the items in Behavioral fixes that may surface as test breakage, update the assertions. The cortex
applied_through_seqsemantic and the v2 checksum migration each have a one-line fix at the assertion site; the v0 envelope removal requires deleting the fixture entirely. - RedEX on-disk layout has changed. Each channel now stores its files under
<channel>/v0000000001/{idx,dat,ts}plus a 16-byte<channel>/manifestpointer file, replacing the flat<channel>/{idx,dat,ts}layout. The migration runs automatically on first open of a v0.10 / v0.11 channel (one-shot, idempotent) — no code change required from callers. Tools or scripts that read RedEX files directly (rare; the supported access path is theRedexFileAPI) need to follow the manifest to the live generation directory. - If you embed FFI handles in a custom Rust wrapper (rare), embed
HandleGuardfrom the newffi::handle_guardmodule and route every entry point throughtry_enter/begin_free. The recipe matches the bundled handles' implementation; the helper module's tests double as documentation.
Addressed in this release
RedEX & CortEX (storage + folded state)
- Compact temp-file leak on reopen failure —
compact_to's cleanup path ran after the post-renameopen_or_poison/clone_or_poisonfallibles, so a reopen failure left three placeholder files behind in/tmpforever. Cleanup now runs before the fallible reopen. - Truncate-on-recovery without
sync_all— torn-tail repairset_lenwas not durable; a crash before the next write reverted the recovery and the same torn bytes were re-read. Nowsync_all+fsync_dirafter the truncate. - Best-effort rollback silently swallowed open errors —
if let Ok(f) = OpenOptions::new().write(true).open(...)quietly skipped rollback when the dat/idx open failed; subsequent appends produced permanent dat/idx divergence. Now propagated asRedexError. - In-memory index corruption on panic between drain and renormalize —
sweep_retentioncould leave rebasedbase_offsetagainst absolute payload offsets if it panicked mid-rewrite. Now builds the renormalized index in a tempVecand atomically replaces. saturating_sub(dat_base) as u32masks heap corruption — silently wrote offset 0 for stale heap entries. Now hardened so the cast never silently squashes a real offset error.next_seqrollback skipped ifdiskisNone— currently safe path; documented and pinned by an invariant comment.- Stale watermark advances past unfolded events under
Stoppolicy —recoverable_decodepublishedfolded_through_seq.store(seq)for events whose state mutation never landed;wait_for_seq(seq)returned true incorrectly. Now gated on the actual fold result. - Snapshot persists
last_seqfor skipped events — when the watermark fix above lands,snapshot()no longer emits alast_seqfor events whose state was never applied; the log remains the source of truth on restore. - Cortex
WatermarkingFoldsaturatesapp_seqatu64::MAX— a peer publishingseq_or_ts == u64::MAXcould pin ourapp_seq; the nextfetch_add(1)panicked in debug or wrapped in release, breaking per-origin monotonicity. Inputs are now capped atu64::MAX - 1. - Memories upsert was asymmetric and tombstone-less — existing-id
STOREDpartial-updated, missing-id inserted withpinned: false, and aSTORED → DELETED → STOREDsequence resurrected the deleted entry. Now consistent and tombstone-aware. - Memories empty-vec filter footgun —
Some(vec![])forrequire_any_tagexcluded everything (anyover empty = false);Some(vec![])forrequire_all_tagsexcluded nothing (allover empty = true). UI forms emitting empty multi-selects broke silently. Both empty cases now treated as "no filter." - Cortex/memories watch strict-bound mismatch — doc said
>/<, code used>=/<=. Strict-bound consumers received boundary events. Now matches the doc. StoredEvent::Serializeround-trips bytes throughValue— re-encoding throughserde_json::Valuediscarded original whitespace, normalized number formatting (1.0→1), and reordered keys. Any downstream that hashed or signed the serialized form silently failed verification. Now passes the raw bytes through&serde_json::value::RawValue.
Bus, shards, and dispatch
remove_shard_internalawaited batch worker before drain — contradicting the function's own doc comment. Drain still owned a sender clone, so a wedged adapter pinned this function indefinitely (notokio::time::timeoutshell on this path). Order swapped to drain → batch and the same timeout the rollback path uses now wraps the await.add_shard_internalrollback dispatched stranded batch with stalenext_sequenceafter worker timeout — the still-detached worker may not have published its final flush, so the rollback emitted overlapping msg-ids. Rollback now refuses to dispatch on the timeout path; the JoinHandle leak is acknowledged in the comment.manual_scale_upcooldown loop invariant violated whenevercooldown > 0— each iteration bumpedlast_scaling = Instant::now(); iteration 1 immediately failedInCooldown(default 30 s), leaving the first shard half-added. Operator-initiated scale-up now bypasses the auto-scaling cooldown via a dedicatedscale_up_provisioning_forcepath.- Scaling monitor and
manual_scale_downracedfinalize_draining— non-target qualifyingDrainingshards were silently transitioned toStopped, dropped on the floor by thetarget.contains(&shard_id)filter, and leaked. Non-target ids are now still routed throughremove_shard_internal. flush()Phase 2 barrier satisfied by post-flush traffic —dispatchedwas a running counter, not a snapshot; with asymmetric per-shard latency the inequality could be satisfied while pre-flush events were still queued. Now snapshotsdispatched + droppedat flush entry and gates on the delta.shutdown()deadline path double-counted in-flight events —events_dropped += in_flight_ingeststhen the final two-pass sweep also drained those events intoevents_dispatched, violatingevents_ingested = events_dispatched + events_droppedon every deadline-triggered shutdown. Now subtracts the events the final sweep drained.Dropdid not surface stranded ring-buffer events — bus dropped withoutawait shutdown()lost ring contents but never bumpedevents_droppedor setshutdown_was_lossy. Operators reading post-mortem stats saw no record of the loss. Now snapshotsshard_stats()inDrop.PollMergertopology swap had a lost-update race — concurrentadd_shard_internal/remove_shard_internalcould each readshard_ids()and serialize theirstore(...)in the wrong order, leaving the published merger view including a removed shard until the next topology change. Theshard_ids() → storeblock is now serialized.PollMerger::polllost cursor context on stalled poll —next_idwasNonewhen no shards made progress, even with a validrequest.from_id. Callers re-fetched from zero — silent pagination regression. Now echoes back the originalfrom_id.mapper.activateactive_count.fetch_addoutside the held write lock — three concurrent activates could pass the budget gate against a stale count and transiently overshootmax_shards. Increment moved beforedrop(shards).mapper.finalize_drainingreadpushes_since_drain_startwithRelaxed— the field's docstring requiredAcquireto pair with the writer'sSeqCstreset. Now matches.- JoinHandle errors silently dropped in shutdown —
let _ = futures::future::join_all(drains).await;ate panicked drain workers (default Tokio doesn't log task panics). Now captured and surfaced viaevents_dropped. shutdown_via_refand in-flight wait loops thrashed the runtime — baretokio::task::yield_nowre-queued the task without parking; tight loops under contention starved the workers they were waiting on. Switched to shorttokio::time::sleep.flush()held a syncparking_lot::Mutexinsideasync fn— replaced with the async-safe variant.- JSON cursor key
"00"parsed to0— collided with shard 0 across rebuilds. Cursor codec now treats string keys as opaque. std::time::Instantmixed with tokio time in shutdown — wall-clock5sbroketokio::time::pause()-based tests. Now consistent.- Drain worker
mem::replace/sendordering — swappedscratchbefore the awaitedsender.send(batch); channel-close mid-await silently dropped the batch. Documented as load-bearing under shutdown ordering and pinned by a regression test.
Atomics, timestamps, and counters
raw_to_nanos(raw)quanta semantics — clarified to usedelta_as_nanos(0, raw)consistently.TimestampGenerator::nextre-readsrawinside the CAS loop — pre-fixnowwas read once outside the loop; on contention, retries reused the stalenowand the returned timestamp drifted aslast + 1arbitrarily far behind real time.shard/batch.rscurrent_batch_size * 3 + targetoverflow — debug panic / release wrap on adversarial config.BatchConfig::validatenow boundsmax_size <= 1_000_000.shard/batch.rsvelocity-windowInstant - Durationunderflow — WindowsInstantis QPC-relative-to-boot; immediately-after-boot processes aborted the batch worker. Nowchecked_sub.f64 → usizeascast in batch — addedclampfirst.shard/mapper.rsnext_shard_id.store(first_id + count)—checked_addon the bump path.shard/mapper.rsoverloaded_countused stale-metric placeholders for freshly-added shards — newly-active shards no longer skew the load signal until they have at least one observation window.record_flush/collect_and_resetlatency-sum/count desync — two independentfetch_adds vs two independentswaps letavg_flush_latency = sum.checked_div(count).unwrap_or(0)silently zero out under sustained load, suppressing the scale-up flush-latency trigger.(sum, count)now packed into a singleu128and CAS'd together. Same fix applied topush_latency_sum_ns / push_count.
Adapters (JetStream / Redis / dedup)
- JetStream
OtherPublishErrorKindclassified as transient — auth failures, permission denied, malformed-subject all retried forever against a backend that would never succeed. Now enumerates the truly transient variants and treatsOtheras fatal. - JetStream "pipelined" publish was actually serial — loop
awaitedpublish_with_headersper event before moving on; only the server-ack join was parallel. 1k-event batch on a 1 ms RTT cost ~1 s instead of "~1 RTT per batch." Now pushes the publish-future into the join set. - JetStream per-event
serde_json::Valueallocation — violated the per-event no-alloc contract. Now mirrors Redis'sRawValueborrow +Bytes::copy_from_slice. - JetStream one RTT per sequence in steady state —
direct_get(seq)per sequence on a 1 ms RTT cost ≥100 ms wall for a 100-event poll. Nowdirect_batch_get. - JetStream cold-stream bail enabled on transient
info()failure — fallback fabricatedfirst_seq = 0, enabling the cold-stream bail; populated streams returning NotFound in deletion gaps bailed after 64 NotFounds with events still ahead. Now propagatesTransient. - JetStream
Fataldecode discarded already-decoded prefix — function returned immediately, dropping the events accumulated so far without advancing the cursor; recovery re-emitted the prefix. Now returnsOkon the good prefix and surfaces the corruption on the next poll. - JetStream
shutdownretainedself.jetstream/self.client— post-shutdownon_batchproceeded against a drained client (typically erroring, sometimes hanging). Both fields now cleared. - JetStream init-after-shutdown silently overwrote client without
drain()— losing in-flight publishes piggybacking on the prior client. Now drains first. - JetStream partial-failure produced duplicate publishes — mid-batch error dropped in-flight
PublishAckFutures but bytes were already on the wire; retry re-published, andNats-Msg-Iddeduped only within the dedup window. Documented and pinned; retry path now wrapspublish_with_headersintokio::time::timeoutto bound the cancellation surface. - JetStream missing
rfield storedb"null"— could surprise downstream consumers expecting either present-or-absent. Now passes through unchanged. - Redis cluster errors classified as fatal —
MOVED/ASK/READONLY/CLUSTERDOWN/NOREPLICASwere not in the substring set; after any Redis Cluster failover, every batch failed permanently until process restart. Added. - Redis
is_healthyPING timeout cancellation — wrapped incommand_timeout, with a dedicated health-check connection so a desyncedConnectionManagerdoesn't serve a stale PING reply on the next real command. - Redis
poll_shardXRANGE had nocommand_timeoutwrapper —on_batchandis_healthyhonored the timeout contract;poll_shardcould block indefinitely. Now wrapped. - Redis
shutdowndidn't dropself.conn— pure advisory flag;get_connignoredinitialized = false.on_batchcould write to Redis silently after shutdown. Connection now dropped,get_connerrors withFatalwhen the adapter has shut down. RedisStreamDedup4096-entry default was two orders of magnitude too small — at 10 K events/sec that's a 0.4 s window; the doc described "~minutes of in-flight." Default raised; capacity required at construction.dedup_statestartup nonce non-cryptographic —xxh3_64of(pid, tid, ns, stack_addr, ...)narrowed entropy on 32-bit targets. Now mixes a/dev/urandomseed.limit + 1overflow (Redis & JetStream poll request shaping) on adversarial limits —saturating_add(1).
Mesh transport, sessions, routing
handle_routed_handshakeCase 2 — replay nuked the live session, no rate limit — Noise NKpsk0's responder uses a fresh ephemeral on each reply, deriving a brand-new session key per replay; an attacker replaying a captured msg1 replaced the legitimate session keys, the legitimate sender kept the old keys, every subsequent packet failed AEAD. Now drops the replay when the live session matches the sameremote_static_pub, and theHandshakePacerfrom the legacy adapter has been added.- Pingwave
strict_progresspermitted address-poisoning via thehops < n.hopsarm — an attacker who had observed pingwaves could spoof(origin_id=Y, seq=K, hop_count=0)forK < n.last_seqand overwriten.addrto their UDP source. The conditions are now AND'd:pw.seq >= n.last_seqANDhops <= n.hops. ThreadLocalPoolper-thread cache leaked forever — every connect/disconnect/NAT-rebind/mesh-rebuild cycle leaked ~16 KB ×local_capacity×num_threads. Long-lived daemons OOM'd in proportion to peer-churn count. NowDropwalks every thread'sLOCAL_BUILDERSto evict itspool_idslot.MAX_PACKET_POOL_SIZE = 1<<20was OOM-on-first-session —with_local_capacitypre-allocatedsize × ~16 KB≈ 16 GiB up front. The cap was meant to prevent OOM. Lowered to a few thousand; remaining budget covered by lazy-on-first-use.- Anti-replay window forward-jump > 1024 zeroed state instead of refusing —
MAX_FORWARD = 65_536,WINDOW_SIZE = 1024; a single authenticated jump in(1025, 65_536]zeroed the bitmap and left previously-seen counters inrx_counter - 1024 .. rx_counterreplayable. The slide is now refused pastWINDOW_SIZE; a fresh handshake is required. - Anti-replay
received == u64::MAX— first authenticated packet at the boundary saturatedrx_counterand rejected every subsequent counter; one hostile authenticated packet could permanently poison the receive path. Now rejected atis_valid. TokenScope::contains(NONE)returnedtrueunconditionally —(self.bits & 0) == 0. Compounded withauthorizes(NONE, ch)returning unconditionaltrue, so any token authorized the no-op action; callers buildingaction: TokenScopefrom external input where the input masked toNONEsawtruefor every token. Short-circuits at the top ofcontains.route.rstie-break used<=— doc said "preserved if strictly better." Now<.router.rsroute_packethad no source/loop suppression — TTL exhaustion was the only loop-breaker;add_route_with_metricflap or a malicious peer could set up a 2-hop loop. Now drops whenrouting_header.src_id == routing_table.local_idand inspects a small(src_id, stream_id, sequence)LRU.router.rsRouterError::TtlExpiredrecheck afterforward()double-counted — bothrecord_inandrecord_dropran.record_indeferred until after the post-decrement TTL check.linux.rsBatchedTransport::send_batchsilently truncated above 64 —len.min(MAX_BATCH_SIZE)returned ≤ 64 unconditionally; reliable streams stashed the rest viaon_sendand only learned via NACK/RTO. Now returnsInvalidInputover the cap; chunked-internally is a follow-up.linux.rsiov_base: packet.as_ptr() as *mut _provenance laundering — sound under the kernel-reads-only invariant, but documented at the call site so a future Miri pass doesn't have to re-derive it.mod.rshandshake retry sleep had no upper bound —100 * attemptoverMAX_HANDSHAKE_RETRIES = 1024summed to ~14 hours total with the last attempt sleeping ~102 s. Capped at 5 s per attempt.mod.rshandshake recv loop allocatedBytesMut::with_capacity(MAX_PACKET_SIZE)per iteration — allocator pressure under stray traffic. Buffer now reused across iterations.session.rsevict_idle_streamsLRU vs concurrent open race —min_by_keythenremovewas non-atomic; a freshly-opened stream could be torn down between selection and removal. Now usesremove_ifwith a freshness predicate.session.rsverify_and_touch_heartbeatdid not pre-checkparsed.payload.len() == TAG_SIZE— AEAD caught the mismatch but a length check shortcuts cleartext-flood probes before they touch the cipher.session.rsRxCreditState::on_bytes_consumedconsumed/grantednot jointly atomic — concurrent calls could publishconsumed > grantedtransiently; observability/metrics showed flicker. Now packedu128AcqRel CAS.route.rscapability-announcementhop_count += 1— every other hop-count increment in the crate usessaturating_add(1); this one was bounded today by the< MAX_CAPABILITY_HOPS - 1 = 15guard but one constant change from a debug panic. Now matches the rest.- Static-mode
select_shard_by_hashused raw modulo — dynamic-mode was already on Lemire's unbiased(hash * len) >> 64. Same bias, same fix; both paths now consistent. gateway.rsParentVisibleover-permissive direction — predicate accepted bothdest.is_ancestor_of(source)andsource.is_ancestor_of(dest); the second clause leaked parent-region traffic down into descendants. Now strictly upward.pool.rs(payload.len() - 16) as u16truncation — currently safe underMAX_PAYLOAD_SIZE = 8112;debug_assert!added so a future cap-raise pastu16::MAX + 16doesn't silently mis-frame on the wire.failure.rsunwrap()on poisonedstd::sync::Mutex— the rest of the crate usesunwrap_or_else(|p| p.into_inner()); a single panic anywhere holding these locks would have turned every subsequent unwrap into a runtime panic that took down the failure-detection loop. Switched.failure.rsRecoveryManager::on_failureoverwroteFailedNodeStateon insert —failed_atandretry_countreset to 0 each time; flapping peers never hitmax_retries. Nowentry().or_insert(...)and bumpsretry_count.failure.rsget_actionreturnedRetry { delay_ms: 0 }for healthy nodes — busy-loop footgun for callers using the action on the healthy path. Now returns the no-op variant.transport.rsBatchedPacketReceiverthread spun at 1 ms on persistent socket errors —EBADF/ENOTSOCK/ permission-revoke ate a CPU forever. Now exponential backoff with hard-error early return.proxy.rstelemetry counters incremented before send succeeded — counters drifted high under partial failure. Now incremented on success.proximity.rsupdate_from_pingwaveworse path overwrote better — high-seq pingwave through a long route demoted the cached direct route. Freshness (always take latest seq) is now separate from path quality (only updatehops/addr/latency_uswhennew_hops <= self.hops).proximity.rsself-edgeinsert_or_update_edgeper-pingwave — hot-path noise; skipped.
Compute, daemons, migration
start_migrationalways emitted a singleSnapshotReadyregardless of size —chunk_index: 0, total_chunks: 1whether the snapshot was 12 B or 12 MB; the wire encoder rejected any chunk overMAX_SNAPSHOT_CHUNK_SIZE = 7000. Locally-initiated migration of any daemon whose serialized state exceeded 7 KB couldn't be sent. Now routes throughchunk_snapshot(daemon_origin, snapshot_bytes, seq_through). Breaking — see breaking-changes section.- Snapshot reassembly unbounded chunk hold via
seq_through == latest— eviction only fired for strictly greater; an attacker could park up to ~4.3 GiB of unfinished reassembly per(origin, seq)and refresh forever. Per-entry byte cap (MAX_PENDING_REASSEMBLY_BYTES = 64 MiB) plus a per-entry age sweep (MAX_PENDING_REASSEMBLY_AGE = 5 min, opportunistic at the head of everyfeedplus a publicsweep_stalefor external timers) close the at-cap-and-quiet residual hole. abort_migration_with_reasondid not propagate toMigrationSourceHandler— source-sidemigrationsmap retained the entry;is_migrating()stayed true,buffer_eventkept buffering into an undrained vector, retries trippedAlreadyMigrating. Now dispatched.standby_groupreplaced standby marked healthy withsynced_through = 0— a subsequent active failure could promote the fresh zero-state standby and lose all pre-buffer state. Now keeps the replaced standby unhealthy until after a successful sync, andpromote()candidates are filtered tolast_sync.is_some().migration_target::buffer_eventhad no phase guard — could insert/deliver post-cutover; combined with normal-path delivery yielded duplicate execution. Now guarded.migration_source::start_snapshotwas acontains_key→entry()race — two concurrent snapshots of the same origin could both call user-suppliedMeshDaemon::snapshot()(DashMap entry guard was held across user I/O — a separate fix moves the entry-guard drop ahead of the snapshot). The trait API doesn't enforce idempotency; the race is now serialized.migration_source::take_buffered_eventshad no phase guard — misuse-prone. Now guarded.migration_target::abortdid not clearcompletedindex — minor leak. Cleared.orchestratorreturnedMigrationError::TargetUnavailable(0)from auto-placement — surfaced "target node 0x0 unavailable" to operators when no specific node had ever been tried. Now typedNoTargetAvailable(variant addition).orchestrator::buffer_eventreturnedfalseat Cutover — downstream caller could route to source post-handoff. Now correctly buffers through Cutover.migration.rsstarted_at: u64saturated on clock jump backward — switched toInstant.fork_groupforks.pop()andcoord.remove_last()invariant unenforced — brittle. Now enforced.bindings.rsVec::with_capacityfrom peer-suppliedu32— declared count of ~4 B entries → ~96 GiB allocation before truncation. Now bounded bydata.len() / MIN_BINDING_SIZE.reconcile.rsunreachable!()reachable on signed but divergent input — equal-length-equal-payload tiebreak panicked on the chain's reconciliation thread. Now a deterministic tiebreak onparent_hash.reliability.rssilent reliability drop — whenpending.len() >= max_pending, the oldest unacknowledged packet was popped; subsequent NACK could never recover that seq because the entry was gone. Now backpressures callers; doesn't drop tracking for in-flight packets.router.rsNetRouter::starthad no re-entry guard — a second call spawned a competing dequeue loop. Nowcompare_exchangeonrunning.continuity/chain(0, Some(non-empty payload))accepted as genesis-shaped — chain reportedForkedagainst junk. NowUnverifiable.state/loggenesis-shaped event with un-validated payload — peer-injected attacker-chosen anchor. Now pinned to the canonical genesis payload.contested/correlationcapability-index parent walk loops forever — defensive depth cap (matches the 4-level hierarchy).contested/observationunboundedHashMap+seq_diff_sumoverflow — long chains accumulated forever. LRU +saturating_add.contested/superpositiontarget_replayedonly advanced fromSuperposed—Spreading(target catches up beforeadvance(Replay)) stalled forever;ReadyToCollapsenever fired. Now both arms advance.contested/propagationlossyf64 → u64poisoned EWMA — a pathological RTT clampedper_hoptou64::MAXpermanently. NaN check tightened.contested/correlationInstantsubtraction panicked —now - correlation_windowpanicked if the window exceeded uptime. Nowchecked_sub.partition.rsNaN >= thresholdblocked healing — whenother_side.is_empty()the ratio was NaN. Empty case now treated as "fully healed."failure.rsRecoveryManagerflapping peers (see Mesh transport, sessions, routing — the recovery and the failure detection both lived in this file).identity/origin.rsorigin_hash: u32collision floor documented — ~65 K peer birthday collision; cross-channel accounting keyed byorigin_hashaliases distinct entities. Documented as the boundary; the rename toorigin_tagand the wire bump are deferred to the next phase.
Behavior, identity, security
safety.rsAuditOnly silently dropped violation logs —check_rate_limitsonly logged whenmode == Enforce; the documented "log violations but don't block" stance simply didn't log. Now logs unconditionally; only thereturn Erris gated.safety.rsRelaxed/AcqRelmismatch —releasepaired againstacquire'sAcqRel; observable counter drift on weakly-ordered cores. Both sides nowAcqRel.safety.rsaudit-only token counterfetch_addwithout saturating — wraps under hostile traffic. Now saturating.loadbalance.rsNaN slipped pasttotal_weight <= 0.0— switched to!(total_weight > 0.0)which captures NaN.token.rsslot-cap race unbounded —contains_keythenentry()overshoot bounded by concurrent calls, not shards. Nowentry().or_insert_with()then drop on overflow.token.rssigned_payload()allocated 95 bytes per verify — hot-path waste. Now stack-buffered.channel/rosteris_empty()→remove_ifTOCTOU — idempotent today but fragile. Tightened.channel/guardrevoke()did not rebuild bloom — false-positive rate climbed until manualrebuild_bloom. Now triggers rebuild.behavior/diff::to_bytesreturnedVec::new()on cap-violation — indistinguishable from a legitimate empty diff; senders silently transmitted zero bytes, receiver dropped. Deprecated in favor oftry_to_bytes.crypto.rsReplayWindow::commit— see Mesh transport, sessions, routing:received == u64::MAXpoisoning fixed atis_validinstead ofcommit.
Bindings (Node, Python, Go, C) & FFI
net_pollbuffer-too-small dropped already-consumed events —bus.poll(request)advanced the cursor before the response was serialized; an undersized buffer returnedBufferTooSmalland dropped the entire response, but the next call started at the now-advanced cursor. Every event in the failed serialization was silently lost. Buffer is now sized-checked first and the response is buffered so a retry can resume.net_poll_exallocation failure dropped the entire batch —Layout::array::<NetEvent>(count)andstd::alloc::alloc(layout)failures returnedUnknownand dropped the response. Now pre-validatescountagainst a max event-count.- Panic across FFI on OOM in
net_poll_ex—event.id.as_bytes().to_vec().into_boxed_slice()andevent.raw.to_vec().into_boxed_slice()could panic mid-loop and leak earlierBox::into_raws plus thestd::alloc::alloc(layout)array. Entry points nowcatch_unwind;panic = "abort"for the cdylib closes the residual. slice::from_raw_parts(ptr, len)lackedlen <= isize::MAXvalidation — a C caller passing sign-extended-1triggered immediate UB before any guard fired. Affects every wide-input FFI entry point:net_ingest,net_ingest_raw,net_ingest_raw_batch,net_ingest_raw_ex,mesh.rs::collect_payloads,net_mesh_publish,net_redex_file_append,net_identity_sign,net_identity_install_token,net_parse_token. All now reject above theisize::MAXboundary.net_generate_keypair/net_free_stringfeature-gated, header unconditional — consumers linking against a cdylib built withoutnetgot load-time missing-symbol errors despite the header promising the symbol. Stubs added.net_free_poll_resultnot idempotent — freeseventsandnext_idbut left the struct fields holding the freed pointers. A defensive caller / destructor wrapper double-free'd. Now nulls fields after free; subsequent calls and null-pointer calls are no-ops.bus_takendefense-in-depth claim was doc-only — doc said "FFI ops also check this," but the field was read only insidenet_shutdown. Either gate or remove the doc; we gated.- Concurrent
net_shutdowncallers raced thebus_takenswap — a second/third caller returnedSuccesswhile the first was still insideruntime.block_on(bus.shutdown()), falsely signaling completion. Now serialized. runtime().block_on(...)panics unwound acrossextern "C"—Handle::try_current()guard added at everycortex.rsandmesh.rsblock_onsite;catch_unwindshim added.- FFI handle accessors
&*handlewithout alignment check — misaligned*mut NetHandlefrom C is immediate UB before the null check.is_aligned_to::<HandleType>()now precedes every dereference. Arc<InnerType>-wrapped FFI handles lacked compile-timeSend + Syncaudit —static_assertions::assert_impl_all!(InnerType: Send + Sync);placed next to each handle.c_str_to_strlifetime elision dangled — signatureunsafe fn c_str_to_str(p: &*const c_char) -> Option<&str>bound the returned&strto the local stack slot, not the underlying C buffer. Today's call sites are stack-only, but a future refactor moving the result intotokio::spawn(async move { ... })would have compiled cleanly and dangled. Nowunsafe fn c_str_to_str<'a>(p: *const c_char) -> Option<&'a str>with explicit lifetime.net_ingest_raw_batchsilently dropped null and invalid-UTF-8 entries — function returnedcount - 1accepted; bindings attributed the drop to backpressure, retried the wrong indices, and double-published the good ones. Now surfaces dropped indices viaout_failed_indices: *mut size_t, out_failed_len: *mut size_t.parse_config_jsonsilently fell back toDropNeweston unknownbackpressure_mode—"DropOldset"(typo) or"FailProduce"got a different durability profile with no error at deploy time. Now errors on unknown values; added theSample { rate }arm with rate validation.retention_max_*accepted zero, fsync params did not —retention_max_events = 0meant "evict everything immediately on first append" — almost certainly a config mistake intended as "no limit." Now rejected at the same gate.- Net
heartbeat_interval_ms/session_timeout_msand meshheartbeat_msaccepted zero — heartbeat-every-0ms busy-looped the heartbeat task and saturated a CPU. Now validated. - Cortex non-success paths didn't write
*out_json/*out_len— pre-zero is the contract; some paths violated it. Fixed. CString::newfailure reported asInvalidUtf8but caused by interior NUL — error variant retitled.NetEvent/NetReceipt#[repr(C)]lacked cross-arch alignment pinning — const asserts on layout added.TokioMutexheld across JSON serialization in cortex FFI — per-cursor latency stall. Serialization now happens outside the held mutex.- Mesh FFI
g.fp16_tflops_x10.map(|tf| tf as f32 / 10.0)lossy foru32 ≥ 2²⁴— the neighboringtops_x10already usedsaturating_u16_cap. Matched. parse_modality_capunknown modality strings silently fell back toModality::Text— used for both capability announcements and capability filters; a typo inrequire_modalitiesreturned wrong nodes with no error. Switched toOption<Modality>and surfacesNET_ERR_CHANNELon unknown.
Compute SDK error surface
MigrationError::TargetUnavailable(0)→NoTargetAvailable— variant addition; the integration test that asserted the pre-fix variant has been updated.start_migrationreturnsVec<MigrationMessage>instead of single — see breaking changes.
Test hygiene
- Migration chunked-snapshot regression — pins that locally-initiated migration of a daemon with a serialized state ≥ 7 KB chunks correctly, and the SDK's transport-identity seal path reassembles, seals, and rechunks in order.
- Snapshot reassembly age-sweep regression — pins that the pending entry is evicted at the head of the next
feedpast the age cap. active_countbudget under concurrent activate — pins that three concurrent activates can't transiently overshootmax_shards.PollMergerfrom_idecho on stalled poll — pins the cursor-context preservation.flush()Phase 2 barrier delta-snapshot — pins that post-flush ingest can't satisfy the inequality.shutdown_was_lossyno longer false-positives on deadline-triggered shutdown — pins that final-sweep drains are not counted againstevents_dropped.next_seqobserver consistency —committed_seqis the lock-free invariant readers see.- Anti-replay
received == u64::MAXrejection — pins that one hostile authenticated packet can't poison the receive path. TokenScope::contains(NONE)isfalse— pins the no-op-action authorization closure.- JetStream cold-stream bail gated only on
first_seq == 0— pins that populated sparse streams are walked past arbitrary deletion gaps. net_free_poll_resultidempotency — pins single + multiple + null-pointer free.net_pollminimum-buffer rejection — pins that buffers belowMIN_RESPONSE_BUFFERare rejected before the cursor is touched.
Known issues — queued for the next release
mesh.rs deep-read audit
A separate single-file audit of adapter/net/mesh.rs (~8 K LOC) surfaced 9 additional defects that are scoped to that file. None of them are addressed in this release; all are slated for the next phase. For consumers running production deployments, the most consequential are listed below — the full audit is in docs/misc/BUG_AUDIT_2026_05_03_MESH.md.
spawn_heartbeat_loopholds a DashMap shard guard across.await— the heartbeat broadcast loop iteratespeers.iter()and awaitssocket.send_to(...)(heartbeat + pingwave, twice per peer) while still holding the iterator'sRefguard. Every other task touching the same shard blocks for the cumulative round-trip.accept/startmutual exclusion usesAcqRelwhere the comment relies onSeqCst— Dekker-style mutual exclusion needs both sides SC. On x86 the LOCK'd RMW happens to fully fence so the race is unobservable; on AArch64 / RISC-V the dispatcher can racehandshake_responderfor the inbound msg1.- Routed-handshake key rotation silently overwrites a live session — the replay guard only fires for the same
remote_static_pub; a routed msg1 with a different static for the samepeer_node_idfalls through andpeers.insertoverwrites the existing legitimate session. commit_reclassify_observationstorn(nat_class, reflex_addr)snapshot — when every probe failed,nat_classis updated butreflex_addrkeeps its previous value, violating thetraversal_publish_muinvariant.authorize_subscriberejects idempotent re-subscribes withTooManyChannels— a peer at the cap re-subscribing to a channel it already holds is rejected even thoughSubscriberRosteris set-typed.- Routed-handshake
peers.get→peers.insertnot atomic — concurrent routed handshakes for the samepeer_node_idrace the insert; the loser'spending_handshakesinitiator state is wedged untilhandshake_timeout. publish_to_peerdoes not propagate the reliable flag to the packet header — every other sender (send_to_peer,send_routed,send_on_stream, etc.) computesif reliable { PacketFlags::RELIABLE }and threads it in.publish_to_peerhard-codesPacketFlags::NONE. Latent today (per-stream reliability is set on open) but the inconsistency will silently bite when a receiver-side path consults the packet flag.process_local_packetmigration loopback unbounded synchronous self-bounce — a buggy / attacker-influenced "trusted" handler that always emits a self-bound message can spin the dispatch task synchronously, starving every other peer's packets.connect_viadoes not refreshaddr_to_nodeafter a successful direct upgrade — the upgraded session's dispatch fast path falls back to a linearpeers.iter().find(...)per packet for exactly the sessions that benefit most from the addr → nid index. Performance only.
Items deferred from the main audit
The following remain open from BUG_AUDIT_2026_05_03.md and are tracked for the next release: #1 (Windows compact_to non-atomic — MoveFileExW/MOVEFILE_WRITE_THROUGH), #6 / #7 / #8 (cortex watermark + checksum coverage), #13 (registry replace in-flight quiescing), #23 / #24 / #25 (cortex / mesh handle-lifetime contract on FFI), #39 (msg-id sequence_start monotonicity test), #56 (origin_hash u32 collision boundary; rename / wire bump), #64 (orchestrator target_head parent-hash 0), #68 (registry::unregister in-flight Arc clones), #73 (per-shard cap clamps cursor advancement under filtered single-shard requests), #81 (adapter/redis.rs pipeline timeout duplicate hazard — depends on RedisStreamDedup wiring), #97 (session.rs racy tx_bytes_sent watermark — see notes about credit-window invariant), #102 (envelope v0/v1 prober), #118 (rule window reset), #121 (select_power_of_two degenerate on len == 2), #125 (per_source.clear() minute-boundary RPM cap exceedance), #127 (initiator handshake HandshakePacer), #128 (router.rs lost-wakeup window).
Breaking changes
Rust core (net crate)
MigrationOrchestrator::start_migration returns Vec<MigrationMessage>
start_migration now returns Result<Vec<MigrationMessage>, MigrationError> instead of Result<MigrationMessage, MigrationError>. The local-source path returns one or more SnapshotReady chunks (sized to MAX_SNAPSHOT_CHUNK_SIZE = 7000); the remote-source path returns a single-element vec![TakeSnapshot { .. }].
Why: pre-fix the orchestrator emitted chunk_index: 0, total_chunks: 1 regardless of payload size; the wire encoder rejected anything past 7 KB and locally-initiated migration of any stateful daemon with a non-trivial state vector simply could not be sent.
Migrate:
// Before
let msg: MigrationMessage = orchestrator.start_migration(origin, src, dst)?;
send_migration_message(dest_node, &msg).await?;
// After
let msgs: Vec<MigrationMessage> = orchestrator.start_migration(origin, src, dst)?;
for msg in &msgs {
send_migration_message(dest_node, msg).await?;
}
If you opted into transport-identity sealing, reassemble all chunks → seal → chunk_snapshot(daemon_origin, sealed, seq_through) → re-dispatch in order. The SDK's start_migration_with and MigrationHandle::reinitiate_attempt route through a new maybe_seal_chunked_snapshot helper that does this for you.
MigrationError::NoTargetAvailable (variant addition)
start_migration_auto now returns MigrationError::NoTargetAvailable when the scheduler finds no candidate, instead of TargetUnavailable(0) (which surfaced "target node 0x0 unavailable" to operators).
Migrate: match arms over MigrationError need to add the new variant; with #[non_exhaustive] already in place this is forward-compatible, but exhaustive match-on-variant code will refuse to compile.
ConsumeResponse::failed_shards
A new failed_shards: Vec<u16> field reports per-shard adapter errors that previously were silently swallowed at warn level (in contrast to stalled_shards, which was already surfaced).
Config validation rejects zero in places it used to accept
retention_max_events = 0,retention_max_bytes = 0,retention_max_age_ms = 0are now rejected at the JSON-config gate (matching the existing fsync zero-rejection). Set them tonullor omit the field for "no limit."- Net
heartbeat_interval_ms = 0,session_timeout_ms = 0, meshheartbeat_ms = 0are now rejected. A 0-ms heartbeat saturates a CPU; this was almost always an unintended config. BatchConfigmax_size > 1_000_000is now rejected. Default is10_000; the cap closes thecurrent_batch_size * 3 + targetoverflow path.parse_config_jsonerrors on unknownbackpressure_modevalues instead of silently selectingDropNewest.
BackpressureMode::Sample { rate }
New variant; existing match arms must add a wildcard or the new arm.
behavior::diff::to_bytes deprecated
Returns Vec::new() on cap-violation, indistinguishable from a legitimate empty diff. Migrate to try_to_bytes which returns Result.
WatermarkingFold caps inputs at u64::MAX - 1
A peer publishing seq_or_ts == u64::MAX previously poisoned per-origin monotonicity. Inputs at the boundary are now rejected. Operators feeding the watermarking fold with a synthetic max-seq must pick u64::MAX - 1.
consumer/merge::PollMerger failed/stalled shard surfacing
PollMerger::poll now echoes back the caller's from_id when no shards make progress (instead of None, which callers were interpreting as "no events" and re-fetching from zero). Callers that relied on None as the stall signal need to switch to next_id == request.from_id.
Cross-backend cursor migration enforced
compare_stream_ids's mixed-format lex fallback wedged the cursor across backend migrations (e.g. JetStream → Redis: "1700-0" < "42" lex-compared). The cursor format is now persisted alongside the cursor; cross-backend migration without explicit reset is refused.
StoredEvent serialization passes raw bytes through
Pre-fix StoredEvent::Serialize round-tripped self.raw through serde_json::Value, discarding original whitespace and key order, normalizing number formatting (1.0 → 1). Downstream signatures or hashes against the serialized form silently failed verification. Now uses &serde_json::value::RawValue passthrough — byte-equality is preserved.
Rust SDK (net-sdk)
The SDK's public surface is unchanged. The migration kickoff paths (DaemonRuntime::start_migration_with and MigrationHandle::reinitiate_attempt) handle the new chunked Vec<MigrationMessage> internally; if you call the orchestrator directly via DaemonRuntime::orchestrator_arc() (or equivalent) you must update to the new return shape.
FFI / bindings
| Binding | Change |
|---|---|
| All | Every extern "C" body is now wrapped in catch_unwind; the cdylib uses panic = "abort" so a Rust panic does not unwind across the FFI boundary. Behavior change for callers that depended on a Rust panic partially completing the call before unwinding. |
| All | slice::from_raw_parts(ptr, len) rejects len > isize::MAX as usize. C callers passing sign-extended -1 previously hit immediate UB before any guard fired; they now hit a defined error return. |
| All | FFI handle accessors check alignment via is_aligned_to::<HandleType>(). A misaligned *mut Handle returned from a wrapper that allocated through a non-Rust allocator now returns an error instead of UB. |
| All | net_ingest_raw_batch surfaces dropped indices via two new out-parameters (out_failed_indices, out_failed_len). Bindings that called the function with nullptr for these still get the old "count returned" semantics. |
| All | net_free_poll_result is now idempotent. Callers that ran their own field-nulling defensively can drop it. |
| All | parse_modality_cap returns NET_ERR_CHANNEL on unknown modality strings instead of silently falling back to Modality::Text. Bindings that round-tripped capability announcements through arbitrary string fields will start surfacing errors at deploy time. |
| C | net.h now provides net_generate_keypair / net_free_string stubs in builds without net. Consumers linking against a net-less cdylib previously hit load-time missing-symbol errors despite the header. |
Behavioral fixes that may surface as test breakage
These aren't strictly API-breaking, but tests that asserted the pre-fix behavior will need updating:
MigrationError::NoTargetAvailable: tests assertingTargetUnavailable(_)fromstart_migration_autoneed to switch.shutdown_was_lossy = falseon a clean deadline-triggered shutdown: tests that asserted the false-positive behavior will fail.PollMerger::pollechoes backfrom_idon stall: tests that assertednext_id == Noneon stall will see the input cursor instead.active_countcannot transiently exceedmax_shards: tests that relied on the budget overshoot to construct a degenerate state will need a different vector.flush()Phase 2 barrier respects pre-flush ingest: tests that satisfied the inequality with post-flush traffic will hang to the deadline.- Anti-replay
received == u64::MAXis rejected: tests that asserted the boundary was accepted will see the rejection. TokenScope::contains(NONE) == false: tests that asserted the oldtruewill need to flip.- JetStream
OtherPublishErrorKindis fatal: retry-loop tests that simulatedOtherand asserted retry will see the call return immediately. - Memories
STORED → DELETED → STOREDdoes not resurrect: tests that asserted resurrection will see the post-tombstone behavior. gateway.rs::ParentVisibleis now strictly upward; tests that asserted descendant-side leakage will fail.route.rsroute tie-break is strictly better, not equal-or-better: tests that asserted equal-metric overwrite will see preserved routes.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.10 line. - Recompile. The signature changes (
start_migration→Vec,BackpressureMode::Sample,ConsumeResponse::failed_shards,MigrationError::NoTargetAvailable) will surface as compile errors at the exact call sites that need updating — follow the Migrate snippets above. - Audit your config for fields that previously accepted zero where they shouldn't have (
retention_max_*,heartbeat_interval_ms,session_timeout_ms, meshheartbeat_ms). Replace zeros withnull(or omit) for "no limit," or pick a small positive value for the heartbeat fields. - Cross-backend cursor migrations require an explicit reset. If your deployment is migrating from JetStream to Redis (or vice-versa), drop the persisted cursor and let the consumer re-tail from the explicit start position.
- If you call
MigrationOrchestratordirectly (rather than through the SDK'sDaemonRuntime::start_migration_with), update to the chunkedVec<MigrationMessage>return shape and reassemble + seal + rechunk on the transport-identity-sealing path. - If your test suite covers the items in Behavioral fixes that may surface as test breakage, update the assertions.
- Re-run your full suite. The lib + binding suites run green; the FFI / bindings layer now uses
catch_unwind+panic = "abort"so any unwind across the boundary that previously "worked" is now a hard failure pointing at an unhandled panic source.
v0.9 is a hardening release. No new features, no new transports, no new SDK surfaces — every commit on this branch is a bug fix, a regression test, or a documentation tightening. The conviction we shipped under v0.8 ("Killing Moon") was that distributed compute should not be a control-plane problem. v0.9 is the version where we stand behind that conviction by walking it through audit after audit and tightening every seam we found.
The work was driven by four parallel-pass internal audits totalling 102 items across the bus, the shard manager, the RedEX append log and its CortEX fold, the JetStream and Redis adapters, the mesh transport, the FFI surface, and every binding.
Addressed in this release
RedEX & CortEX (storage + folded state)
- Lost events on partial replay failure —
MigrationTargetHandler::drain_pendingreturned on first delivery error without restoring the undelivered tail; everything past the failure was permanently lost. Fix preserves the tail for the next drain and a regression test pins both the resume and the prefix-not-redelivered invariant. - Silent eviction during tail backfill — backfill could miss the
Laggedsignal under retention rollover and silently drop events. Now signals correctly during backfill. - Index task exits permanently after
Lagged— the tail task halted onLaggedand never recovered. Now clears the index, re-tails live-only with a 5/20/60/250 ms saturation backoff, and surfaces alag_resets()counter so aggregating downstreams can detect lossy resets. - Snapshot-store retention drops high-water mark on
remove— a stale producer could re-stage older snapshots after a remove. Added a per-entity high-water table that survivesremove.forget()is nowpub(crate)so the anti-rewind invariant can't be defeated externally. - Observable seq rollback via
next_seq()— external readers could observe a temporarily-bumpednext_seqmid-rollback. Now reads under the state lock. new_heapacceptsRedexFlags::INLINE— the heap path silently accepted the inline flag, breaking invariants. Now rejected.append_batchempty-input returns plausible-looking seq (breaking) — returned0for both empty input and the legitimate seq-0 first write. NowResult<Option<u64>, _>. See breaking-changes section.- Age-retention off-by-one (breaking) — boundary was
>(entries at exact cutoff dropped); now>=(retained). See breaking-changes section. Stoppolicy halts without finalchanges_txnotify — subscribers got no signal on halt. Initial fix addednotify_waiters+changes_tx.send(seq); the broadcast was later refined to NOT emit the failing seq, sincechanges_txis documented as carrying successfully-folded sequences.- Cortex
changes_txbroadcasts failing seq on Stop+non-recoverable halt — pre-fix subscribers could observe a phantomSeq(failing_seq), mis-routing state. Now drops the broadcast on halt; subscribers pollis_running(). RedexFile::Debugdeadlock footgun —Debugcalledlen()andnext_seq(), both of which take the state lock. Now reads only the lock-free atomics.RedexIndex::clear()on Lagged is silent — added thelag_resets()accessor as a public sentinel.RedexIndexsaturation-resume can hot-loop — under sustained burst with an under-sizedtail_buffer_sizethe loop emitted a warn per cycle. Now backed off and rate-limited.
Bus, shards, and dispatch
- Activation-failure abort drops drain-worker scratch buffer / Batch worker abort drops in-memory
current_batch—.abort()dropped events. Now graceful await + dispatch with boundedtokio::time::timeout(2 × adapter_timeout)so the rollback can't hang on a parked worker. num_shardsdecremented on rollback that never incremented it — activate-failure rollback over-decrementednum_shardsfor never-activated shards. Decrement is now gated on the shard's mapper state. A targetedremove_specific_stopped_shardreplaces the bulkremove_stopped_shards()so sequentialmanual_scale_downdoesn't prune sibling state under itself.ShardManager::activate_sharddouble-counts on idempotent calls — repeated activates kept bumpingnum_shards. Now gated on the mapper'stransitionedsignal.activate()budget gate — load-then-store is safe today because the held write lock onshardsserializes both the load and the mutation. The lock-held invariant is now documented as the correctness gate (CAS would be belt-and-braces, not strictly required).- Shutdown drain race past
in_flight_ingests— single zero-pass could miss late producers. Now requires two consecutive zero passes. shutdown()returnsOk(())after timeout-with-drops — lossy shutdown looked successful. Now surfaces viaevents_dropped+ a dedicatedshutdown_was_lossyflag.drain_finalize_ready—Releasepairs only via implicit fence on the in-flight spin's SeqCst; promoted to SeqCst at the store site so the happens-before is explicit. Deadline-break path documented as the data-loss escape hatch.PollMergerdefault shard list is wrong after dynamic scale-down — polled from a stale0..num_shardsrange, missing live shards. Now uses the live shard id set, propagated through both add and remove paths.poll_mergerArcSwap leaves polls operating on stale topology — topology-snapshot semantics now documented onpoll().per_shard_limitsilently capped at 10 000 — caller had no signal. Surfaced viatruncated_at_per_shard_cap: boolinConsumeResponse.has_more=truefrom a stalled adapter is silently suppressed — stalled shards invisible to the caller. Now surfaced viastalled_shards: Vec<u16>.Cursor::encodereturns empty cursor on serialization failure — empty cursor restarted polling from zero (silent rewind). Initial fix usedexpect(...); later refined to returnResult<String, ConsumerError>so an asyncpoll()panic can't take down a runtime worker. Minor breaking change for direct callers.PER_SHARD_FETCH_CAPmade public — exposed an internal tuning knob as API. Now#[doc(hidden)]. Readtruncated_at_per_shard_capinstead.add_events(vec![])flushes as a side effect — load-bearing for the rollback path. Documented and pinned byadd_events_empty_can_flush_via_timeout.flush()baseline excludes events flushed viaremove_shard_internal— verifiedevents_dispatchedis bumped on stranded-flush; was already correct.dispatch_batchfinal attempt collapses error reasons — all retries were tagged with one collapsed error. Now structured per-attemptreason.dispatch_batchretry sleep has no jitter / backoff — synchronized retry storms across shards. Now jittered exponential viaretry_backoff(shard_id, attempt).drain_finalize_readyordering doc — clarified that the SeqCst happens-before only covers the non-deadline exit; deadline-path stranded events are exactly the ones surfaced viaevents_dropped+shutdown_was_lossy.
Atomics, timestamps, and counters
pushes_since_drain_startmismatched atomic ordering — producer used Relaxed, drain side used Acquire. Now both Acquire.in_flight_ingestsisAtomicU32with no saturating semantics — pathological producer counts could wrap. Widened toAtomicU64.TimestampGeneratoruses hard-coded baseline0— TSC delta math wrong. Now captures baseline at construction.TimestampGeneratormonotonicity stalls before the documented panic — stalled spin instead of advertised panic. Now panics preemptively atu64::MAX.velocity_samplesVecDequebounded only by time, not count — burst could grow unbounded. Now also count-capped.- Partition
next_idreuses ID 0 onu64::MAXoverflow — wrap-around silently re-issued IDs. Now saturates.
Adapters (JetStream / Redis / dedup)
- JetStream
as u16truncatesshard_id— values > 65 535 wrapped silently. Now rejected withFatal(andpoll_shardpropagates theFatalinstead of log-and-skipping). - JetStream
unwrap_or_default()on remote JSON — malformedrfield re-serialized as empty bytes. Now propagated asFatal. - JetStream cold-stream poll walks
fetch_limit * 10round-trips — ~1010 RTTs per poll on cold streams. Now bails afterconsecutive_not_found_cap, gated onfirst_seq == 0so populated sparse streams (events at seq 1, 500, 1000) walk past arbitrary deletion gaps. - JetStream
from_idcursorseq + 1overflows — wrapped to 0 atu64::MAX, silent restart. Nowchecked_add(1).unwrap_or(seq). - JetStream
Fataldrops accumulated batch inpoll_shard— documented; acceptable sinceFatalis non-retryable. - Redis
is_healthyPING has no enforced timeout — could hang indefinitely. Now wrapped incommand_timeout. - Redis & JetStream
limit + 1overflow on adversarial limits — wrapped to 0, silent under-delivery. Nowsaturating_add(1). RedisStreamDedup::newaccepts unbounded capacity — clamped atMAX_CAPACITY = 1<<24.RedisStreamDedupis FIFO eviction, not LRU as documented — docs were wrong. Updated to describe FIFO accurately.dedup_statesilently swallows fsync failures —let _ = f.sync_all()ignored disk-full errors. Propagated; cross-platform fixed via single writable handle (File::openreturned read-only on Windows;FlushFileBuffersfailed silently).dedup_state::create_new(true)poison after crash — a stale tempfile from a crashed prior run could break every subsequent save. Addedfs::remove_file(&tmp).ok()beforecreate_new.
Security & permissions
ttl_seconds = 0token mints expired — born-expired tokens with no diagnostic to the issuer.try_issuereturnsTokenError::ZeroTtl.Identity::issue_tokenpanic onDuration::ZERO— first fix routed throughtry_issue.expect(...), which still aborted the process with a misleading "ReadOnly" message. Now soft-clamps to 1 second,debug_assert!s in dev builds, and the wrapper's panic messages match eachtry_issuevariant precisely.PermissionToken::issuepanic message misattributes ZeroTtl as ReadOnly — fixed in tandem with the above.- Anti-replay window cleared on large legitimate jumps — whole bitmap zeroed silently. Now emits a structured warn before zeroing.
OriginStamphas no per-packet binding — threat model documented.- Untrusted-input panics in subnet config — added
try_*fallible constructors for SDK callers. - Channel decoder accepts trailing bytes on UNSUBSCRIBE/ACK — decoder now requires
cur.remaining() == 0after the channel name + token.
Bindings (Node, Python, Go, C)
- Node binding
u32 → u8truncation on member index —as u8silently truncated > 255. Switched totry_intowith explicit> 255rejection. - Python bindings hold GIL across blocking compute ops —
scale_to,on_node_failure,sync_standbys,promoteblocked the GIL during long ops. Now release via PyO3 0.28'spy.detach. - Node-binding groups carry an unused
kind: Stringfield — removed dead field. RedisStreamDedupstripped from generated Node binding surface — a regen-without-redis-feature dropped the class fromindex.d.tsandindex.js. Re-ran NAPI generation with--features redis,….- Python parity test for
append_batch([])returnsNone— added so future binding regenerations don't silently drop the contract. include_str!ofgo/net.hescapes the crate root — brokecargo publishand out-of-repo vendoring. Copied to in-crateinclude/net.go.hand updated the parity test.- C SDK README — fixed stale references to a removed
bindings/go/net/net.hpath. Runtime::block_onfromextern "C"shims unwinds across FFI — reentrancy hazard documented.
Behavior rules & evaluators
- Lossy
as_f64for all numeric ordering in rules — big i64/u64 values lost precision through f64. Now compares i64/u64 directly with sign-aware mixed-type fallback. compare_numbersbrittle withserde_json/arbitrary_precision— a transitive dep enabling that feature would silently make rules fail closed. Addeddebug_assert!so the misuse is loud in dev.- Non-deterministic verdict ordering —
window_failuresordering depended on iteration order. Now sorts and dedups for determinism. record_executionwindow-reset across rule reload — counters mis-reset for non-rate-limited rules. Now skipped for those.- Stream tight-loop spin — zero
poll_intervalspun the loop. Clamped to non-zero. - Stream backoff overflow on absurd
poll_interval— doubling overflowed. Now saturating. Rule::newlossily castsu128millis tou64— long uptimes truncated. Now uses saturatingu64::try_from.
Compute (daemons + migration)
- Migration
next_seqoverflow —replayed_through + 1could panic atu64::MAX. Nowsaturating_add. - DashMap entry guard held across registry I/O —
start_snapshotheld the entry guard across user-supplied snapshot code, deadlock-prone. Drops the guard before I/O. Two racing starts produce twoMeshDaemon::snapshot()calls — non-idempotent daemons must single-flight at their layer; documented. on_node_recoverydoes not break after first matching partition — documented as intentional for overlapping partitions.
Mesh transport & packet codec
- Silent
event_counttruncation in packet builder — builder accepted oversized batches and truncated. Now rejects with explicit error. StreamWindow.decodeunboundedtotal_consumed— consumer-side clamp was already enforced; documented.- Modulo bias in equal-weight candidate selection —
hash % lenbiased low for non-power-of-2. Now Lemire's(hash * len) >> 64. cpus.saturating_mul(2)capsmax_shards: u16at 65 535 — documented as intentional.mapper.rscooldown check + scale mutation atomicity — RwLock-implicit serialization documented.
SDK & error surface
SdkError::Ingestion(String)flattens structuredIngestionError— backpressure / sampled / unrouted all funnelled through one stringly-typed variant. Routed to structuredSampled/Unrouted/Backpressure. Breaking — see breaking-changes section.SdkErrorenum is breaking and not#[non_exhaustive]— added#[non_exhaustive]so future variant additions are minor-version changes.NetBuilder::identity()silently overridesentity_keypair— builder accepted both fields and silently dropped one; now rejects the conflict at build time.NetAdapterConfig::validateaccepts pathological values — added upper bounds + heartbeat floor.Dropreleases shutdown gates synchronously while workers holdArc<Self>— no partial-destruction UB; documented.
Test hygiene
MigrationTargetHandler::drain_pendingregression test — strengthened to also assert the prefix is NOT redelivered.add_events_empty_can_flush_via_timeout— pins that empty input flushes aftermax_delay. Load-bearing for the rollback path.retry_backoffjitter test — relaxed from>= 8 / 16to>= 4 / 16to stay robust againstDefaultHasherdistribution drift across toolchain versions.debug_does_not_acquire_state_lock— pins the lock-freeDebuginvariant by holdingstate.lock()acrossformat!("{:?}", file).stop_policy_does_not_broadcast_failing_seq— pins the cortex broadcast contract.cold_stream_bail_gate_only_fires_when_first_seq_is_zero— pins the JetStream sparse-stream gate.
Breaking changes
Rust core (net crate)
RedexFile::append_batch signature changed
append_batch and append_batch_ordered now return Result<Option<u64>, RedexError> instead of Result<u64, RedexError>.
Why: the prior shape returned Ok(0) for an empty batch, which collided with the legitimate "first event of a non-empty batch landed at seq 0" return — callers couldn't distinguish "I appended nothing" from "I appended one event at seq 0".
Migrate:
// Before
let first_seq: u64 = file.append_batch(&payloads)?;
// After
let first_seq: Option<u64> = file.append_batch(&payloads)?;
Same change cascaded through OrderedAppender::append_batch and TypedRedexFile::append_batch.
Retention boundary semantics
Age-based retention now uses >= instead of > for the cutoff. An entry whose timestamp equals the cutoff exactly is retained (was: evicted).
Why: the original > comparison was off-by-one — entries on the boundary lasted strictly less than the configured retention_max_age. Production deployments with tight age caps observed events expiring one tick early.
Migrate: no source change required, but tests that asserted exact-boundary entries were evicted will now fail. Update assertions to expect retention.
Cursor::encode returns Result
CompositeCursor::encode now returns Result<String, ConsumerError> instead of String. Affects callers using the type directly; EventBus::poll() already handles the new shape.
Migrate: append .unwrap() (in tests) or ? (in production) to existing call sites.
PollMerger::new signature
PollMerger::new takes Vec<u16> of active shard IDs instead of num_shards: u16. This is an internal-leaning type but pub; downstream wrappers may need to update.
ConsumeResponse struct fields
Added truncated_at_per_shard_cap: bool and stalled_shards: Vec<u16>. Callers that construct ConsumeResponse directly need to populate the new fields. Pattern matches with .. unaffected.
PER_SHARD_FETCH_CAP is #[doc(hidden)]
Still pub const (callable), but no longer documented as API. Callers checking truncation should read ConsumeResponse::truncated_at_per_shard_cap instead of comparing against the constant.
SnapshotStore::forget is pub(crate)
Was pub. The function defeats the high-water-mark anti-rewind invariant — exposing it publicly let any caller stage stale snapshots over fresh ones. No production callers existed; only test code referenced it.
Rust SDK (net-sdk)
SdkError is #[non_exhaustive] + new variants
SdkError now carries the #[non_exhaustive] attribute. Two new variants moved out of the stringly-typed Ingestion(String) fallback:
Sampled— event deliberately dropped by a sampling / decimation policy. Retry is pointless.Unrouted— no routable shard for the event (typically a topology-transient state). Retry once topology stabilizes.
From<IngestionError> now routes IngestionError::Sampled and IngestionError::Unrouted to these structured variants. Code that string-matched on the content of Ingestion(String) for those causes silently stops matching.
Migrate:
// Match arms now must include a wildcard
match err {
SdkError::Backpressure => /* drop or retry */,
SdkError::Sampled => /* accept the drop */,
SdkError::Unrouted => /* retry after topology stabilizes */,
SdkError::NotConnected => /* peer gone */,
_ => /* future-proof catch-all */,
}
If you were substring-matching on Ingestion(...) for "sampled" or "no shard", switch to the structured variants.
Identity::issue_token no longer panics on Duration::ZERO
Previously the panicking convenience wrapper aborted with a misleading "public-only keypair" message when ttl == Duration::ZERO. It now soft-clamps to 1 second and debug_assert!s in dev builds, so the misuse surfaces in tests but doesn't take down the process in release.
Identity::try_issue_token (the explicit fallible surface) still rejects zero-TTL with TokenError::ZeroTtl — bindings route through it.
Migrate: nothing strictly required. Tests that exercised the panic with #[should_panic(expected = "public-only keypair")] need updating — the new debug-assert message contains "Duration::ZERO".
Bindings
| Binding | Change |
|---|---|
| Node | appendBatch(...) returns bigint | null (was bigint). Empty input → null. |
| Python | append_batch(...) returns int | None (was int). Empty input → None. |
| Node | RedisStreamDedup class is back on the binding surface (it had been stripped by an earlier feature-incomplete regen — not a breaking change for downstream npm consumers, just a regression repaired). |
| Go | IssueToken{TTLSeconds: 0} returns a non-nil error (was: same — surfaced from FFI's try_issue path). No source change. |
Behavioral fixes that may surface as test breakage
These aren't strictly API-breaking, but if your test suite asserted the old behavior they will need updating:
num_shardsrollback:add_shard+ failedactivate_shard+ rollback no longer over-decrementsnum_shards. Tests that expected the off-by-one will fail.- JetStream sparse-stream polling:
poll_shardno longer breaks early on 64 consecutiveNotFounds wheninfo()reported a populated stream (first_seq > 0). Tests on populated sparse streams that asserted early-bail behavior will see longer walks. - Cortex
changes_with_laghalt path: onStop+ non-recoverable error the failing seq is no longer broadcast onchanges_tx. Subscribers need to pollis_running()to detect halt — pre-fix they could have observed (incorrectly) aChangeEvent::Seq(failing_seq). RedexFile::Debug: no longer acquires the state mutex; reads only the lock-free atomics. Output format changed (next_seq_atomicfield name;lenremoved).SnapshotStore::store: equal-seq concurrent-store linearization is now documented to be on the snapshots-side entry guard, not on the high-water mark. Behavior unchanged; doc clarified.
How to upgrade
- Bump your
Cargo.toml/package.json/requirements.txt/go.modto the v0.9 line. - Recompile. The signature changes (
append_batch→Result<Option<u64>>,Cursor::encode→Result,SdkError#[non_exhaustive]) will surface as compile errors at the exact call sites that need updating — follow the Migrate snippets above. - If you have tests that assert pre-fix behavior on the items in Behavioral fixes that may surface as test breakage, update those assertions.
- Bindings consumers (Node / Python): no source change is required — the type-stub updates are forward-compatible — but treat the new
null/Noneempty-input returns as the canonical "I appended nothing" signal in your call sites. - Re-run your full suite. The lib + binding suites run green; if your suite covers integration paths not exercised by the audit, this is the right release to catch any drift.
Net is a mesh runtime. Identity is cryptographic, channels are hierarchical, state is causal, and compute moves. There is no broker, no leader, no central directory. Every node is its own keypair. Every event is signed into a chain you can verify without trusting the network underneath. The network is the substrate; the entities are what matter.
This is what we have to show on day one.
Mikoshi
The piece worth naming first.
A daemon in Net is a stateful event processor whose identity is its public key and whose location is the mesh. You don't address it by "node X, slot 3." You address it by its origin_hash, and that fingerprint doesn't change when the daemon moves.
Mikoshi is how it moves.
A running program on one node becomes a running program on another without losing its history, its pending work, or its place in the conversation. The source packages its state, the target unpacks it, and for a brief moment the entity exists on both nodes at once — spreading, superposed, then collapsed onto the target as routing cuts over. The daemon doesn't know it moved. Neither does anything talking to it. Observer nodes watching the stream see the same causal chain continue uninterrupted, the same sequence numbers, the same entity speaking. The hardware underneath shifted. The stream didn't notice.
What moved wasn't a copy. It was the thing itself, carried across.
Six phases, signed at every boundary, with continuity proofs that verify the chain didn't fork. Standby groups and replica groups compose on top — the active dies, the warmest standby promotes, the mesh keeps moving. The daemon is the object, and the object persists.
That is the headline of v0.8.
What's underneath
A non-localized event bus. Encrypted UDP transport with AEAD on every data packet, multi-hop forwarding, NAT traversal, and pingwave swarm discovery. ed25519 identity stamped on every header. Capability announcements that drive routing — a request for inference flows toward the nearest node with a matching GPU, not toward a fixed endpoint. Permission tokens with delegation chains. Bloom-filter authorization checks at sub-10ns per packet. Hierarchical subnets that keep observation cost bounded as the mesh grows.
A storage stack that is embedded, not a service: RedEX as the append-only log, CortEX folding the log into typed domain state, NetDB exposing it as queries and live watches. Disk persistence is a flag. Durability is a knob (Never, EveryN, Interval, IntervalOrBytes). Snapshots round-trip the whole stack in one blob. There is no database to run alongside the runtime. The runtime is the database.
Bindings for Node, Python, and Go. Ergonomic SDKs in TypeScript and Python. The same MeshDaemon interface whether the event came from this process, the next node over, or three hops away. Code written against a single-node prototype runs unmodified on a multi-hop mesh.
What this release means
Net is built on the conviction that distributed compute should not be a control-plane problem. No broker to provision, no orchestrator to fail over, no service registry to keep consistent with reality. The mesh routes around what's down. The chain proves what's true. The daemon is wherever it needs to be.
We chose the Cyberpunk frame because it's the right one. Mikoshi is the engram store — minds persisting outside the hardware that bore them. Net's daemons persist outside the nodes that host them. That is not a metaphor we are reaching for. It is what the migration state machine does, packet by packet, with cryptographic receipts.
v0.8 is the version of Net we are willing to put a name on. The codename does double duty. The song — Echo & the Bunnymen, 1984 — is about the part of yourself you don't get to negotiate with. The mission — Phantom Liberty's final act — is V carrying Songbird (Somi) to the Moon, where the system that would destroy her can't reach.
The release ships when it's ready, not when it's convenient. It happens to ship on May 1, 2026, under a full moon. We didn't plan that. We're taking it.
Codename
"Killing Moon" — Echo & the Bunnymen (1984) / Cyberpunk: Phantom Liberty (2023). Released May 1, 2026.
not anti-cloud.
post-cloud.
Cloud infrastructure solves the wrong problem. It moves compute closer to a central provider. NET decouples storage and compute from hardware and location.
Cloud adds a trusted intermediary by definition. NET has no intermediaries. Relay nodes forward encrypted bytes they cannot read. There is no Cloudflare, no AWS, no Azure in the path because the path is yours.
Cloud was the right answer when compute was scarce and hardware was expensive. Compute is abundant. Hardware is cheap. The coordination layer should reflect that.
A manufacturing plant running on NET doesn't route sensor data to AWS us-east-1 and back. The sensor talks directly to the decision system on the factory floor. The latency is physics, not geography plus cloud overhead.
running.