MESH ONLINECODENAME: Purple Rain
SEED ROUNDAI 2070 is raising seed funding to build post-cloud infrastructure.get in touch
RFC-NET-001PROTOCOL.0x4E45·54REV v0.27.0-beta.2 / Q2 2026

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.

§01 / why not best-effort

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.

§02 / topology classes

a new class of system.

Existing networking falls into two categories. NET is neither.

// net
// real-time
// best-effort
NET → latency-first
The internet runs in milliseconds. NET runs in nanoseconds. Commodity hardware, commodity networks, no central coordination. Drop, route around, observe, derive.
latency floor: nanoseconds
throughput: ~20M events/s · per core
CAN / EtherCAT / TSN
Specialized hardware, optimized for deterministic timing. Fixed topologies. Dedicated hardware. Time-slotted access. Guarantees only because you own the wire.
latency floor: microseconds†
throughput: ~100K updates/s · dedicated bus
TCP / IP / HTTP / gRPC
Optimized for delivery. Queues absorb bursts. Backpressure negotiated. Connections stateful. Trust assumed. Sender slows down when receiver can't keep up.
latency floor: milliseconds
throughput: ~10K req/s · per connection
§03 / protocol properties

nine axioms.
one runtime.

P.01

Latency-first

Sub-nanosecond header serialization. Nanosecond heartbeats, hops, recovery. Packet scheduling at timescales reserved for local function calls.

0.20 nsfwd
sub-ns floor
P.02

Streaming-first

Data is continuous flow, not documents. Sharded ring buffers, adaptive batching. No requests and responses — everything is a stream.


░░░░░░░░░░░░░░
P.03

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
P.04

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 ░░░
P.05

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 assumption
P.06

Schema-agnostic

Transport moves bytes, not structures. Raw event = payload + hash. Protocol never inspects content. Structure emerges where participants agree.

[hdr][hash][░▒▓█▓]
opaque payload
P.07

Optionally 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()
P.08

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 network
P.09

Native backpressure

Nodes drop without reply. Not a failure mode — the design. The proximity graph makes silence a signal. Automatic rerouting.

silentsuspect
suspect → reroute
§04 / measured numbers

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.

operationM1 Maxi9-14900K
▸ routing
routing header forward0.57 ns1.75G/s0.20 ns4.99G/s
header serialize2.19 ns456M/s1.21 ns829M/s
routing lookup (hit)40 ns25.2M/s38 ns26.3M/s
▸ multi-hop forwarding
1 hop57 ns17.4M/s53 ns18.7M/s
3 hops160 ns6.23M/s122 ns8.18M/s
5 hops257 ns3.90M/s196 ns5.09M/s
▸ failure detection & recovery
heartbeat29 ns34.7M/s36 ns28.0M/s
circuit breaker check9.55 ns105M/s11 ns90.3M/s
full fail + recover274 ns3.65M/s249 ns4.02M/s
▸ swarm / discovery
pingwave roundtrip0.93 ns1.07G/s0.69 ns1.46G/s
new peer discovery93 ns10.8M/s47 ns21.2M/s
▸ capability system
filter (require GPU)47 ns21.4M/s44 ns22.8M/s
GPU check40 ns25.3M/s41 ns24.7M/s

// scheduling floor

0.20ns

Routing header forward on i9-14900K. Per-packet overhead. Software is not the bottleneck — physics is.


// hot path

4.99G/s

Operations per second on a single core for the forward path. Five billion. Per second. Per core.


// SDK ingest

6.97M/s

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
§05 / mikoshi // engram transit

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.

§06 / daemon runtime // new

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.

CASE · trading agent · NYSE colo

// 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 total
01
snapshot
source serializes daemon into a portable bundle.
02
transfer
bundle moves source → target.
03
restore
target copies program data from source; maps daemon environment and identity.
04
replay
target plays back events in order. catches up to where the source left off.
05
cutover
source stops accepting work. routing flips atomically. next event goes to target.
06
complete
source daemon collapses. target becomes sole entity.
▸ GRP.01

replica

N interchangeable copies · load-balanced
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.

identitydeterministic from seedroutinground-robinstatestatelessrecoveryrespawn
▸ GRP.02

fork

independent siblings · documented lineage
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.

identitydivergent from sentinelroutingper-forkstateindependentrecoveryresnapshot from origin
▸ GRP.03

standby

1 active · N-1 warm · zero duplicate compute
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.

identitydeterministic from seedroutingactive onlystatestateful, syncedrecoverypromote + replay gap
// trait surface
5methods
name · requirements · process · snapshot · restore
// migration phases
6phases
snapshot → transfer → restore → replay → cutover → complete
// wire messages
10types
orchestrator + source + target over 0x0500
// cycle time
~280ns
full snapshot → activate, faster than a kernel context switch
§07 / storage // new

Dataforts:
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.

$net dataforts status --live --pool=meshlive
┌─ mesh storage pool5 nodes · 892 GB cap
pressure▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰░░░░░░░░░░░░░░░57%·STEADY
├─ nodes
├─node.0x7af3▰▰▰▰▰▰▰▰▰▰▰▰▰▰░░░░░░░░ 64%····
├─node.0x2c91▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰░░ 91%PUSH
├─node.0xeb29▰▰▰▰▰▰▰░░░░░░░░░░░░░░░ 31%····
├─node.0xfbb1▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰░░░░░ 78%····
└─node.0x9a3e▰▰▰▰▰░░░░░░░░░░░░░░░░░ 22%RECV
├─ recent events
└─ end of stream
▸ press ^C to detach · gravity recalc every 1.4s · watermark high ·85 / low ·30
mesh.storage.1NEW

Overflow

storage doesn't run out. when one disk fills up, the mesh catches the spillover.

mesh.storage.2

Data Gravity

the files aren't moved. files settle near nodes that use them.

mesh.storage.3

Content-Addressed

the hash is the handle. one address gets you the file — wherever it lives.

mesh.storage.4

Durability Tiers

pick your guarantee. fire-and-forget, fsync, or N-peer replicated. one call.

§08 / cluster os // new

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.

$net meshos autoform --live --mesh=local --autoform=truemonitoring
mesh.eventstail -f autoform.log
awaiting nodes...
devicecomputestoragereplica datafort · gravity welldaemon / mikoshi · in transit
mesh.os.1

Mikoshi Lifecycle

spawn, migrate, supervise. daemons hop between machines without losing state, history, or place in the conversation.

mesh.os.2

Gravity Placement

workloads pull toward their data. compute lands near the bytes it touches — gravity-based scoring, not central scheduling.

mesh.os.3

Daemon Supervision

start, drain, restart, gate. exponential backoff. backpressure signals. graceful shutdown or forced.

mesh.os.4

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.

§09 / components on the mesh

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.

▸ component.01

nRPC

// native rpc primitive

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.

TypedMeshRpc · paired streams · zero new wire
▸ component.02

RedEX

// stream as state

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.

28.7 M append/s · 161 ns tail
▸ component.03

CortEX

// folded RedEX streams

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.

9.11 ns find_unique · 4.07 M ingest/s
▸ component.04

NETDB

// unified query façade

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.

26.4 μs decode · 48 KB / 1K rows
§10 / install

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).

§11 / target applications

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.

▸ 0x01 ─ ai agents

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.

▸ 0x02 ─ vehicular mesh

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.

▸ 0x03 ─ factory floor

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.

▸ 0x04 ─ energy grids & extraction

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.

▸ 0x05 ─ remote surgery

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.

▸ 0x06 ─ drone swarms

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.

▸ 0x07 ─ live performance

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.

▸ 0x08 ─ medical nanorobotics

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.

§12 / the blackwall

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.

§13 / releases

net releases.

Every tagged release pulled directly from ai-2070/net.

v0.26.0Codename:Monkey Business
2026.05.28

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

  1. 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 TokenError exhaustively.

  2. 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_SECS is the ceiling to check against.

  3. Anyone matching TokenError — add the TtlTooLong arm. Exhaustive matches without a wildcard will not compile until you do.

  4. 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.

  5. 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.

  6. Wire format is unchanged; v0.25 and v0.26.0 peers handshake cleanly.

v0.25.1Codename:Shock To The System
2026.05.28

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
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.md covers the agent surface (eight locked decisions, phasing, per-binding status); docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md covers the capability perf pass (headline regressions, root causes with file:line pointers, ranked fixes with risk/touch columns, before/after numbers per fix).
  • T-1 cross-language tool-format byte-equality, ratcheted across all four bindings. The tests/cross_lang_tool_formats/golden_vectors.json fixture 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-2 ToolEvent envelope 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 in tests/cross_lang_tool_formats/tool_event_vectors.json. The synthesized Error { 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 warnings clean. The strict floor from v0.20.2 stays armed; the clippy::useless-vec lint that landed in Rust 1.95 caught one pre-existing vec![] in the capability test suite — fixed in deebf93e.
  • cargo doc --features meshos,deck,aggregator --no-deps clean under RUSTDOCFLAGS="-D warnings". All new ToolDescriptor / ToolEvent / tool::* intra-doc links resolve; the compact-codec docstrings inline 0x01 instead of linking to the private COMPACT_FORMAT_TAG constant (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

  1. 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 the tool feature), or (b) iterate a tag set through Tag::axis_key() in a hot path (switch to axis_key_ref() for the per-call allocation saving).

  2. 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: @tool decorator 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?) streams ToolListChange::{Added, Removed, NodeCountChanged}.

  3. Agent authors using OpenAI / Anthropic / Gemini / MCP — install the format package. Python: pip install net-mesh-tools; import from net_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 from ToolDescriptor → provider tool-array entry; the reverse lower_<provider>_tool_call(call) returns a CallSpec you pass into call_tool / call_tool_streaming. No transitive provider-SDK dep — wire the translator output into your existing OpenAI / Anthropic client.

  4. 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.

  5. Operators with binary-size budgets — tool is opt-in. Direct net-mesh consumers who don't want the agent surface keep their default feature list. Binding artifacts: the binding's tool feature flag is on by default in Node / Python / Go; downstream consumers who don't want it pass --no-default-features and enumerate the features they do want.

  6. Downstream consumers caching capability bytes — opt into to_bytes_compact when 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 decode 0x01-prefixed bytes.

  7. Operators on mixed-version fleets — wire format is unchanged. CapabilitySet::to_bytes is still JSON, CapabilityAnnouncement::to_bytes is still JSON, serialize_tags_sorted still produces the Tag::to_string() canonical order for JSON serializers, signed-announcement bytes are byte-stable across versions. v0.24 and v0.25 peers handshake cleanly.

  8. Downstream Go binding consumers — ABI version unchanged. NET_RPC_ABI_VERSION stays at 0x0004. The Go tool surface (net.RegisterTool, net.RegisterStreamingTool, net.CallToolStreaming, net.ListTools, net.WatchTools) is additive.

  9. CI — no config change required. Strict clippy floor stays armed (the new clippy::useless-vec in 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 in tests/cross_lang_tool_formats/golden_vectors.json must be done in lockstep across all four binding verifiers.

  10. Operators — bump the binary. Pre-built net-mesh, net-deck, net-aggregator-daemon archives land for every supported target (Linux x86_64 / aarch64, macOS x86_64 / aarch64, Windows x86_64). Wire format is unchanged from v0.24.

v0.24.0Codename:Money For Nothing
2026.05.26

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-MeshNode state: pending_stream_grants: Mutex<HashMap<(session_id, stream_id), PendingStreamGrant>> + a single Notify.
  • Receive path now does one lock + insert + notify_one per accepted packet — no spawn, no encrypt, no sendto.
  • A per-mesh drainer task (spawn_stream_grant_drainer_loop) wakes on the Notify or on a 1 ms safety-net interval, swaps the map out with std::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 inbound from_node at REQUEST receive time.
  • New publish_response_to_caller helper consults the cache, then falls back to the mesh's global origin-hash reverse index, then finally to mesh.publish — preserving correctness for loopback / test paths that emit with from_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-empty regex = [] alias becomes regex = ["dep:regex"].
  • capability_aggregation.rs: the wire-format TagMatcher::Regex variant stays in the enum unconditionally — peers exchanging serialized matchers must keep working regardless of the receiver's feature set. The CompiledMatcher::Regex arm and its matches_one branch gate on #[cfg(feature = "regex")].
  • safety.rs: the compiled_patterns field 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 regex or 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 skipped validate() 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.md lands 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_backpressure was 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 warnings clean. Strict floor from v0.20.2 stays armed across the feature-flagged regex split.
  • cargo doc --features meshos,deck,aggregator --no-deps clean under RUSTDOCFLAGS="-D warnings". Intra-doc links across the new TagMatcherError, validate(), and compile() 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)? get TagMatcherError::RegexNotBuiltIn { pattern }.
  • Callers who skip validation and pass the matcher straight to Fold::aggregate / Fold::capacity_ranking get 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

  1. Rust consumers — update the dependency to 0.24. No source changes required unless you (a) construct TagMatcher::Regex directly and don't enable the regex feature, or (b) match exhaustively on PublishErrorKind in your own code. The former: add validate() ahead of compile, or enable the feature. The latter: add MaxPayloadExceeded => ... to your match.

  2. Operators with TagMatcher::Regex in their query mix — pin the regex feature explicitly. Direct net-mesh consumers: cargo add net-mesh --features regex. Bindings: enable the binding's regex feature flag (Node + Python + Go default to on; downstream wrappers may differ). The structured TagMatcherError::RegexNotBuiltIn shows up in validate() results when the build is wrong; the panic at Fold::aggregate shows up at first use when validation is skipped.

  3. Operators with binary-size budgets — flip regex off explicitly. The napi win-x86_64 artifact drops 1.10 MiB. Downstream binding builds: pass --no-default-features and enumerate the features you do want (substitute the binding's own default list minus regex). Verify by grepping cargo tree -e features for regex — it should not appear.

  4. Operators watching nrpc_qps benchmarks — 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.

  5. Operators on async-nats 0.49 or later — the MaxPayloadExceeded classification fix is automatic. A producer hitting the variant gets a hard fatal in the logs (look for the existing JetStream publish error tracing); previously this same path would loop forever silently as part of the catch-all. Upstream fix: chunk the payload or raise the stream's max_msg_size.

  6. Downstream consumers who hook mesh.publish for response-leg telemetry — re-wire to the substrate observer. The four reply sites now bypass mesh.publish on 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.

  7. 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 PublishErrorKind variants will fail the exhaustive match at compile time, exactly as 0.49 did.

  8. Operators — bump the binary. Pre-built net-mesh, net-deck, net-aggregator-daemon archives 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.23 TypedMeshRpc.Regex variant transmitted from a regex-on peer to a regex-off peer surfaces as the structured error on receive instead of silent empty.

  9. Downstream Go binding consumers — ABI version unchanged. NET_RPC_ABI_VERSION stays at 0x0004. No symbol additions in this release.

v0.23.0Codename:Gimme Shelter
2026.05.25

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 of rpc.call<Req, Resp>(...) ergonomics with one extra positional argument (the *TypedMeshRpc itself), free-function shape because Go forbids type parameters on methods.
  • TypedCallStreaming[Req, Resp] + *TypedRpcStream[Resp] — server-streaming with Recv() returning (Resp, error) + ErrStreamDone sentinel 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 new net_rpc_set_observer + net_rpc_metrics_snapshot FFI 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() and NEXT_CANCEL_TOKEN: AtomicU64 are deleted. reserveCancelToken / cancelCall napi methods now delegate to Mesh::reserve_cancel_token / Mesh::cancel. callClientStream and callDuplex populate opts.cancel_token from the incoming CallOptions. The typed wrapper drops stripSignal for streaming entries and wires wireAbortSignal end-to-end.
  • pyo3. Cancellable.__init__ reserves a token from the mesh; Cancellable.cancel() calls mesh.cancel(token). call_client_stream / call_duplex extract opts['cancel'] and populate CallOptions::cancel_token. The Notify-based close_notify path on PyClientStreamCall / PyDuplexCall becomes an internal implementation detail; the substrate registers a Weak<Notify> against it.
  • Go FFI. The file-local cancel_registry is deleted. net_rpc_reserve_cancel_token / net_rpc_cancel_call become pass-throughs. New cancellable FFI variants — net_rpc_call_client_stream_cancellable + net_rpc_call_duplex_cancellable — populate opts.cancel_token and forward to the SDK. The Go typed wrapper's TypedCallClientStream / TypedCallDuplex now propagate ctx.Context through 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 NetMeshAsyncNetMesh(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_streamingAsyncRpcStream with __aiter__ + __anext__ (server-streaming).
  • call_client_streamAsyncClientStreamCall with awaitable send / finish (client-streaming).
  • call_duplexAsyncDuplexCall / AsyncDuplexSink / AsyncDuplexStream (duplex + split halves).
  • serve / serve_client_stream / serve_duplex — accept EITHER sync def or async def handlers, detected via inspect.iscoroutinefunction at register time. Sync handlers run on the substrate's spawn_blocking path; 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 every Async* class).
  • Cross-language wire contract test. Every binding now also pins the canonical observer event shape and metrics-snapshot envelope (observer_dropped_total field 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 are net_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 warnings clean. Strict floor from v0.20.2 stays armed.
  • cargo doc --features meshos,deck,aggregator --no-deps clean under RUSTDOCFLAGS="-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

  1. Rust consumers — update the dependency to 0.23. Direct struct-literal callers of CallOptions add cancel_token: None; everyone else recompiles unchanged.

  2. 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 / ObserverDroppedTotal in metricsSnapshot to see if your callback is too slow for production load; size the upstream queue or push events into an asyncio.Queue / Go channel / Node async iterator if drops are appearing.

  3. Cancellation users — migrate from binding-specific cancel surfaces to idiomatic per-language ones.

    • Node: new AbortController() + pass signal in CallOptions. Streaming entries now honor it end-to-end.
    • Python: Cancellable() + pass opts={'cancel': cancellable}. Streaming entries now honor it. Async callers use asyncio.wait_for(...) or task.cancel() for free — the bridge converts asyncio cancellation into a substrate Mesh::cancel.
    • Go: context.WithCancel(parent) + pass ctx to every call. Streaming entries now honor ctx.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.
  4. Node + Python typed users — adopt client-streaming + duplex. TypedMeshRpc.serveClientStream / serveDuplex (Node) and serve_client_stream / serve_duplex (Python) are the new entry points. Handler signatures match the Rust SDK's typed surface. JSON codec is unchanged.

  5. Go users — adopt TypedMeshRpc from 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.

  6. Python asyncio consumers — flip every blocking call to its async sibling. MeshRpc(mesh)AsyncMeshRpc(mesh); rpc.call(...)await arpc.call(...); for chunk in streamasync for chunk in astream. Sync API unchanged; both APIs coexist on the same NetMesh. The migration cookbook in bindings/python/README.md walks through a service-by-service move. asyncio cancellation works transparently via asyncio.wait_for / task.cancel().

  7. async def server handlers — Python only. Register an async def handler with AsyncMeshRpc.serve(...) or AsyncMeshRpc.serve_client_stream(...) or AsyncMeshRpc.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 on spawn_blocking; the choice is per-handler, not per-class.

  8. 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.

  9. Operators — bump the binary. Pre-built net-mesh, net-deck, net-aggregator-daemon archives 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 new RpcMetricsSnapshot.observer_dropped_total field and the CallOptions::cancel_token field are postcard-appended; pre-v0.23 readers ignore them.

  10. Downstream Go binding consumers — update or override the ABI pin. bindings/go/net/mesh_rpc.go::ExpectedABIVersion is now 0x0004. Consumers compiled against 0x0003 panic at init. NET_RPC_SKIP_ABI_CHECK=1 is the development override.

v0.22.0Codename:All Along the Watchtower
2026.05.24

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).

  • ListGroups(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 stored source_subnet + fold_kinds so a --template typo 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 --freshquery_summarize_now). Output renders the SummaryAnnouncements as JSON via the existing SummaryRow shape.
  • spawn --template <NAME> --name <NAME> --replica-count <N>RegistryClient::spawn. --source-subnet is gone from spawn — 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 --remoteRegistryClient::list. Renders source_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 under spawn_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 warnings clean. 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_of in the HealthMonitor backoff retry-gate).
  • cargo doc --features meshos,deck,aggregator --no-deps clean under RUSTDOCFLAGS="-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 aggregator feature 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

  1. Rust consumers — update the dependency to 0.22. Most consumers see only the additions. Direct consumers of the legacy CapabilityIndex / RoutingTable need to switch to the fold APIs (the compiler points at every site).

  2. Daemon authors with an async daemon — implement LifecycleDaemon, not MeshDaemon. If your daemon does tokio::interval work or await-blocking publish/subscribe, it's a LifecycleDaemon. Deploy via LifecycleGroup<L>::spawn or spawn_with_placement; auto-respawn via register_with_monitor. Existing MeshDaemon impls are unchanged.

  3. 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), run net-aggregator-daemon --config foo.toml. Spawn additional groups dynamically via net aggregator spawn --template … --name … --replica-count N --node-addr … --node-pubkey … --node-id …. The bootstrap triple comes from net-aggregator-daemon --config foo.toml --print-bootstrap (first stdout line, JSON).

  4. CLI users — adopt --node-addr / --node-pubkey / --node-id or a --remote <NAME> profile. net aggregator query / spawn / scale / ls --remote now round-trips against any daemon you can reach. Set [default].psk_hex + [default].node_addr / node_pubkey / node_id in your CLI config for a one-flag --remote default shortcut.

  5. SDK consumers (TypeScript / Python / Go / C) — RegistryClient + FoldQueryClient are first-class. Pull the surface from @net-mesh/sdk / net_mesh.aggregator / net_mesh Go package / the C cdylib's net_registry_client_* symbols. Same wire contract across every language — error kinds, group_seed as 64-char hex, u64 marshaling per the locked table.

  6. net aggregator spawn callers — switch to --template. The --source-subnet flag is gone. Templates live in the daemon's TOML; operators reference them by name. --name + --replica-count are unchanged.

  7. 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 aggregator feature on bindings — repo CI picks both up automatically.

  8. Operators — bump the binary. Pre-built net-mesh, net-deck, and net-aggregator-daemon binaries 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 and restart. Wire format is additive from v0.21; mixed-version fleets handshake cleanly, though the new fold envelopes and Scale RPC obviously won't reach pre-v0.22 peers.

  9. Deck users — three new tabs. SUBNETS, GATEWAYS, and AGGREGATORS appear 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.

v0.21.0Codename:Radar Love
2026.05.22

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 to Arc<DashMap> — sharded per call_id.
  • Cached approx_bytes on CachedResult. Walked every row per LRU bookkeeping call; v0.21 caches at construction in a private u64 field — single field load.
  • Pre-sized drain_rows. Switched from Vec::new() + grow-by-doubling to Vec::with_capacity(DRAIN_INITIAL_CAPACITY).
  • Lookup-table planner chain_hex. format!("{:016x}", origin_hash) replaced with a HEX_NIBBLES 16-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-auth SubnetId allow-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_slice reintroduction, ArcSwap selection 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 warnings clean. 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-deps clean under RUSTDOCFLAGS="-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

  1. 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.

  2. Adapter implementations — switch to Arc<Batch>. If your Adapter::on_batch mutates the batch, replace batch.clone() with Arc::try_unwrap(batch).unwrap_or_else(|b| (*b).clone()). If your on_batch is read-only, drop the leading clone entirely.

  3. nRPC callers / handlers — construct payloads with Bytes. Anywhere you built a RpcRequestPayload with a Vec<u8> body, swap to Bytes::from(vec) (no allocation, takes ownership) or Bytes::copy_from_slice(&slice) (one allocation for the new buffer). Handlers reading the body: payload.body.as_ref() for &[u8].

  4. Blob consumers — handle Bytes. BlobAdapter::fetch and friends now return Bytes. Most call sites change from fetch(...).await? (returning Vec<u8>) to fetch(...).await? (returning Bytes); downstream &[u8] access uses .as_ref(). Sites that genuinely need an owned Vec<u8> call .to_vec(), but this is rarely the right answer — Bytes is cheaper to pass around.

  5. CortEX consumers — Arc<Memory> handles. Query and watcher returns now carry Arc<Memory>. Reads work unchanged via deref coercion (memory.title, memory.content); mutation requires Arc::try_unwrap (succeeds when the Arc is sole-owned) or an explicit clone of the inner Memory.

  6. No CI config change required. The strict clippy floor from v0.20.2 is still the floor; the test-side allow-list is unchanged.

  7. Operators — bump the binary. Pre-built net-mesh and net-deck binaries 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.

  8. Operators tracking the scaling roadmap — read the new design docs. SCALING_SUBNET_SPEC.md and SCALING_MULTIFOLD_PLAN.md live under docs/. Neither is implemented in v0.21; both target future minor releases. Comments and pushback welcome before the implementation lands.

v0.20.2Codename:Smoke On The Water
2026.05.20

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.

v0.20.1Codename:Smoke On The Water
2026.05.19

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, a blake2s-of-name truncated to 16, or any operator-stable convention); the substrate doesn't interpret the bytes. A peer self-declares subnet membership via a subnet:<hex32> tag on its own announcement; the capability index parses the tag at fold time and stores NodeId → SubnetId on the peer view.
  • GroupId([u8; 32]) — 32-byte opaque identifier for an operator-defined named collection of peers. Same self-declared pattern via group:<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 random GroupId that'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 by find_service_nodes is filtered through may_execute before 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 returns RpcError::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 (direct call() instead of call_service, an out-of-date local index, or a buggy client) gets rejected at the receiver. The bridge emits an RpcStatus::CapabilityDenied (0x0008) response that the caller's MeshNode::call surfaces as the typed RpcError::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 CLI cap announce regression suite (signed-bytes round-trip, stdout-vs-file equivalence, --node-id mismatch 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 warnings clean across substrate + every binding crate + the deck demo + the deck TUI + the net-mesh CLI.
  • cargo doc --features meshos,deck --no-deps clean under RUSTDOCFLAGS="-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 CapabilityDenied status 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

  1. Operators issuing restrictive policies. The new net-mesh cap announce subcommand builds and signs a CapabilityAnnouncement carrying 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 new cap announce at a bumped --version with a tighter allow-list — there's no separate revoke verb.

  2. Callers handling RpcError. Add a RpcError::CapabilityDenied { target, capability } arm to exhaustive match expressions. The variant is non-retryable; surface it to the application layer rather than looping. Caller-side call_service filters the candidate set before target selection, so a CapabilityDenied return from call_service means every peer advertising the service refused this caller — distinct from NoRoute, which means no peer advertises the service at all.

  3. Servers using serve_rpc. No code change required. serve_rpc self-indexes a permissive baseline announcement immediately on registration and schedules a peer-side broadcast. Operators who previously called announce_capabilities after serve_rpc can remove the call (it's now redundant); operators who called it before serve_rpc no longer need to remember the order.

  4. Servers wanting to restrict access. Publish a policy announcement via net-mesh cap announce (offline build + ship) or by constructing a CapabilityAnnouncement with non-empty allow-lists and folding via your existing capability-broadcast path. The version bump rule still applies — restrictive policies use a version strictly greater than any prior announcement from the same node_id.

  5. Subnet and group membership. Peers self-declare via subnet:<hex32> and group:<hex64> tags on their own announcement. Use the CLI's --tag subnet:<value> and --tag group:<value> to add them, or CapabilitySet::add_tag(...) programmatically. Operators picking subnet/group identifiers can use random bytes (value-as-secret) or a blake2s-of-name (advisory routing).

  6. 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.

  7. 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 next check() — no per-token revocation entry, no CRL gossip, just the generation cross-check the cache already runs.

  8. Channel-name casing. Lowercase any registered channel names that contain uppercase characters. The new canonicalization treats foo.bar and FOO.BAR as the same channel rather than parallel namespaces. Subscribers calling subscribe(name) against a previously-uppercase name continue to work — the lowercase normalization is applied symmetrically on both sides.

  9. Clock-skew tuning. The default 60-second clock_skew_tolerance_secs covers typical NTP-synced nodes. Tighten via MeshNodeConfig::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.

  10. Operator dashboards. New per-node metrics: capability_denied_caller_side and capability_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.

v0.19.0Codename:Push It To The Limit
2026.05.19

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.3BlobRef::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 / Heartbeat against a channel they had no role in; the runtime trusted the wire-supplied from_node for both delivery and believed_leader tracking. v0.19 binds replica delivery to a replica_set registered at channel-open time and gates believed_leader updates against the from_node matching 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 → Replica transition — 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 its believed_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 forged ActivateTarget from 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: SnapshotReady checks against source_node, CleanupComplete against source_node, ActivateTarget against orchestrator_node, MigrationFailed against the union of recorded participants. The orchestrator-on-third-party-node topology gets its long-promised wire-shipped target_head in ReplayComplete so the orchestrator no longer falls back to a synthetic parent_hash: 0 that no downstream verifier could reconcile.

  • Migration phase-guard hardening. MigrationTargetHandler::replay_events no longer rewinds Cutover → Replay (which had been enabling double-delivery of post-cutover events). MigrationSourceHandler::cleanup gains a phase guard so a pre-cutover replayed CleanupComplete no longer destroys a live daemon. MigrationTargetHandler::pending_events is bounded (64 MiB / 1M events per origin) so a wire-driven OOM is no longer reachable. Source-side buffered_events gains 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: u64 field 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::PunchIntroduce and PunchAck previously 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::Ack correlated on a sequential-counter nonce — predictable nonces let any session peer spoof Subscribe / Unsubscribe responses. v0.19 binds each correlation arm to the wire-authenticated from_node (read from the inbound session, not the payload) and switches membership nonces to getrandom-sourced u64s. nRPC response delivery gets the same binding — a sequential call_id becomes a getrandom u64 and the reply-channel ACL gates response delivery to the originating peer.

  • Blob hardening. Sweep D-1 (the GC sweep_gc TOCTOU where a concurrent incr was silently dropped) closes via a remove_if guarded by should_sweep. D-11 adds manifest chunk-size validation + defensive get(..) so an untrusted publisher can't slice-panic the consumer with arbitrary per-chunk sizes. D-14 branches resolve_payload on is_chunked() so the top-level verify skip for manifests no longer unconditionally fails. D-15 makes GC delete_chunk actually unlink the persistent segment file. D-18 fixes a publisher-crafted UTF-8-boundary panic in the FS adapter's URI sanitizer.

  • Audit-chain durability ordering. O-4 (chain_append_failures counter + 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_audit chain append before ring push, ring/chain divergence regression test).

  • Substrate-wide ChannelHash widening. ChannelHash widens from u32 to u64. The targeted-collision cost rises from ~2^32 (feasible offline) to ~2^64. The wire NetHeader::channel_hash stays 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). The PermissionToken wire form grows to 169 bytes (issuer + subject + scope + 64-bit channel hash + issuer generation + not_before + not_after + delegation depth + nonce + ed25519 signature); the FFI net_channel_hash widens to *mut u64; every binding (Python / Node / Go / C) consumes the JSON channel_hash as int64 / BigInt / uint64 / uint64_t respectively.

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::cleanup unregisters daemon on lookup miss). Pre-fix the new Cutover phase guard ran inside if let Some(entry) = ..., but the migrations.get miss fell through to daemon_registry.unregister(daemon_origin) unconditionally — a spurious or replayed CleanupComplete for an origin we never migrated tore down a live local daemon. Fix: move unregister inside the Some branch; the miss path is now a no-op with a tracing::debug log.

  • C-2 (StandbyGroup::try_recover_inner clobbers the active). Pre-fix the unhealthy filter did not exclude self.active_index; if the active was briefly marked unhealthy (transient node heartbeat staleness), recovery constructed a fresh DaemonHost::new with empty state and registry.replaced the live active — silently dropping all committed state and the post-sync buffer. Fix: route active-side unhealthiness through promote, 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 on lower node_id only and was sticky. A real leader L1 (high tail, high id) could stay Leader while also recording L2 (low tail, low id) as believed_leader. v0.19 unifies on the runtime convergence rule across both layers and pins the regression test.

  • H-2 (MigrationTargetHandler::activate flips Cutover before drain_pending). Pre-fix a mid-drain Err left phase already at Cutover; replay_events no-oped, buffer_event rejected, and the undelivered tail was reinserted into pending_events with no future call able to drain it. v0.19 flips Cutover only on successful drain; activate's retry path drains on next call without the early-return.

  • H-3 (StandbyGroup try_recover_inner does not bump term). The X-1 fencing gap that ForkGroup and ReplicaGroup already guarded against was open on StandbyGroup. v0.19 bumps term in try_recover to match promote / promote_with_placement.

  • H-4 (PollMerger::poll discards set_checked bool). Both Step-1 (adapter next_id) and Step-2 (last-event override) writes routed through set_checked but ignored the bool return. On a format-mismatch refusal, fetched events were still returned in all_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 in failed_shards.

  • H-5 (SnapshotReady TOFU into orchestrator binding). For a daemon_origin with no prior record, restore_on_target ran and target_handler.orchestrator_node was recorded as from_node — any session peer that beat the legitimate orchestrator with a forged SnapshotReady became the bound orchestrator and could drive ActivateTarget / MigrationFailed past the new peer-auth gates. v0.19 closes the TOFU window with DaemonFactoryRegistry::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 before restore_on_target records 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, then refcount.remove only 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" whenever new_tail == pre_apply_tail && leader_tail > new_tail, but leader_tail was the cached value from the last received heartbeat. v0.19 keys backoff on the response's leader_first_retained_seq / leader-tip hint, only counting empties when the request explicitly asked above tail.

  • H-8 (record_tail_seq from on_tick advertises pre-quorum tail). The leader was bumping tail_provider the 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-replica pending_apply metric.

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 warnings clean across substrate + every binding crate + the deck demo + the deck TUI + the net-mesh CLI.
  • cargo doc --features meshos,deck --no-deps clean under RUSTDOCFLAGS="-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-streaming and dataforts-tree features in addition to the default set so the new surfaces compile on every PR. Python / Node / Go / C bindings pick up the ChannelHash u32 → u64 widening regression suite — every binding's channel_hash round-trip + token-parse path runs on every CI build.
  • Code-review regression suite. tests/review_2026_05_18_*.rs covers 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-netnet-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

netnet-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

  1. Rename your dependencies. Cargo.toml: ai2070-net = "0.18"net-mesh = "0.19". package.json: @ai2070/net@net-mesh/core. requirements.txt: ai2070-netnet-mesh. The old names continue to resolve in v0.19 with a deprecation warning; in v0.20 they hard-break.

  2. 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.

  3. Operators — install the binary. v0.19 ships pre-built net-mesh and net-deck binaries 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 run net-mesh --help. The Cargo install path (cargo install --path cli) still works from a workspace checkout. Generate an operator identity with net-mesh identity generate <name> and install the public key into the cluster's operator registry as before.

  4. nRPC client-streaming callers. let mut call = mesh_rpc.call_client_streaming::<Req, Resp>(target, channel).await?; returns a ClientStreamCall<Req, Resp>. Call call.send(req).await? per input frame, call.finish().await? to close the request side. The single response comes back from finish. Pass a tracing context via the existing RpcContext::with_trace builder if needed.

  5. nRPC duplex callers. let call = mesh_rpc.call_duplex::<Req, Resp>(target, channel).await?; returns a DuplexCall<Req, Resp> implementing Stream<Item = Result<Resp, RpcError>>. Call call.send(req).await? for input, call.finish_sending().await? to close the request side, poll the stream side via .next().await for responses. call.into_split() returns a (DuplexSender, DuplexReceiver) pair for tasks that need the halves separately.

  6. Streaming blob publishers. Long uploads benefit from the new staging API. let upload = adapter.begin_streaming_store(config).await?; → loop adapter.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. The StagingHandle::staging_id() accessor returns a stable id you can persist alongside your upload-tracking record.

  7. Tree blob consumers. No code change — MeshBlobAdapter::fetch_range(blob_ref, range, output) handles Small / Manifest / Tree transparently. Mixed-version fleets where some nodes are still v0.18 should gate Tree publishes behind a dataforts.blob_tree_v3 capability tag; v0.18 consumers will reject the variant.

  8. 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 to n-k chunks 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.

  9. Bandwidth classes. Tag a stream at open: BlobStoreConfig::default().with_bandwidth_class(BandwidthClass::Background) for cold-tier replication; Realtime for migration / live replay. The default is Foreground (operator-interactive). Operator dashboards surface current_bandwidth_class / bytes_in_class / paused_for_higher_class_ms per stream.

  10. Migration orchestrator on a third node. No code change for SDK consumers — the wire format change ships under the hood. Direct MigrationOrchestrator::on_replay_complete callers in test harnesses update their call sites to pass a third target_head: CausalLink argument; use the daemon's head_link() if registered locally, or CausalLink::genesis(origin, 0) for snapshot-only test cases.

  11. 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_changes see the additional flip count under partition-heal scenarios.

  12. Subprotocol peer binding. No code change for SDK consumers — the peer-auth gates ship under the hood. Operators monitoring the subprotocol_peer_auth_rejections counter see legitimate-zero in steady state and non-zero only under attack or misconfiguration; alarm on sustained non-zero.

v0.18.0Codename:Welcome to the Jungle
2026.05.17

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) returning MeshOsDaemonHandle with next_control() / try_next_control() / publish_log() / publish_capabilities() / graceful_shutdown().
  • Python (pyo3): bindings/python/python/net/ + sdk-py/src/net_sdk/meshos.pyMeshOsDaemon Protocol class for type-checker verification; MeshOsDaemonSdk.start() returns a handle with sync next_control + async anext_control + async for ev in handle iterator; context-manager dunders drive graceful shutdown on scope exit.
  • TypeScript (napi-rs): bindings/node/ + sdk-ts/src/meshos.tsregisterDaemon(daemon: DaemonObjectTsfns, identity) accepts a full daemon object with TSFN-bridged process / snapshot / restore / onControl / health / saturation; the napi MeshOsDaemonHandle exposes async control-event iteration through an AsyncIterable<DaemonControl>.
  • Go (cgo): bindings/go/net/meshos.goMeshOsDaemon interface with cgo trampolines (goMeshOsProcessTrampoline, goMeshOsSnapshotTrampoline, goMeshOsRestoreTrampoline, goMeshOsOnControlTrampoline, goMeshOsHealthTrampoline, goMeshOsSaturationTrampoline); MeshOsDaemonSdk.RegisterDaemon returns a handle with ControlEvents() <-chan DaemonControl (context.Context-cancellable) + TryNextControl().
  • C (raw FFI): include/net_meshos.h + libnet_meshos.{so,dylib,dll}NetMeshOsDaemonVtable carrying name / process / health / saturation / on_control / snapshot / restore function 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-thread net_meshos_last_error_message / net_meshos_last_error_kind discriminator 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.pyDeckClient(operator_seed, config) + DeckClient.from_seed(seed, **config_kwargs) wrapper; admin verbs return typed ChainCommit dataclasses; ICE break-glass typestate enforced through IceProposalSimulatedIceProposalcommit(signatures); context-manager dunders drive shutdown on scope exit.
  • TypeScript (napi-rs): bindings/node/ + sdk-ts/src/deck.tsDeckClient with readonly admin / readonly ice properties holding typed verb dispatchers; async-iterable SnapshotStream / StatusSummaryStream / LogStream / FailureStream; DeckSdkError carries the structured kind discriminator the substrate emits.
  • Go (cgo): bindings/go/net/deck.go + top-level go/deck.go companion — DeckClient.Admin().Drain(node, drainForMs) / DeckClient.ICE().FreezeCluster(reason, ttl).Simulate() / .Commit(signatures); the stream surfaces use buffered <-chan with context-aware shutdown; DeckError wraps the substrate's <<deck-sdk-kind:KIND>>MSG envelope. The binding-tier bindings/go/net/deck.go covers all three slices (admin + ICE + logs + audit + failures); the top-level go/deck.go ships 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; 9 net_deck_admin_* verbs; 7 net_deck_ice_* factories returning NetIceProposal* that consumes itself on _simulate (yielding NetSimulatedIceProposal* that consumes itself on _commit); snapshot / status-summary / log / failure / audit streams; per-thread net_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>fsyncrenamefsync 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 MeshOsSnapshot postcard / JSON forward-compat regression layer.
  • cargo clippy --features meshos,deck --all-targets -- -D warnings clean across substrate + every binding crate + the deck demo + the deck TUI + the net CLI.
  • cargo doc --features meshos,deck --no-deps clean under RUSTDOCFLAGS="-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-ffi alongside the existing net-compute-ffi, net-meshdb-ffi, net-meshos-ffi cdylibs so the new go/deck.go cgo block links. The Python CI step builds with meshdb enabled so test_meshdb.py and the new test_stub_drift.py MeshDB class coverage exercise on every run.
  • 86 help-text snapshot tests pin every Net CLI subcommand's --help output. 14 deck-pipeline integration tests cover the substrate-end-to-end behavior the TUI relies on (MeshOsSnapshot publish → MeshOsSnapshotFold consume → MeshDB Latest query → 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

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.18 line. Rust consumers who want the prior minimal default-feature set add default-features = false to their ai2070-net dependency and re-list the features they need; everyone else gets the full operator stack out of the box.
  2. Operators. Install the Net CLI via cargo install --path cli (workspace-relative) or your distro's release artifact. Generate an operator identity with net identity generate <name>, install the public key into the cluster's operator registry, and start running admin / ICE / audit commands. Run net --help for the full subcommand map.
  3. Deck. Build with cargo build --release -p deck (binary at target/release/deck). Configure the deck connection target via ~/.config/net/deck.toml or the --cluster flag. Run deck from the operator workstation — the TUI auto-discovers reachable maintenance nodes and renders the cluster live. Press ? in any tab for context-aware help.
  4. Daemon authors. Pick your language:
    • Rust: MeshOsDaemonSdk::start(...) returning MeshOsDaemonHandle — see sdk/src/meshos/.
    • Python: pip install ai2070-net-sdk (or build from source with --features meshos); implement the MeshOsDaemon Protocol and register via MeshOsDaemonSdk.start().register_daemon(daemon, identity).
    • TypeScript: npm install @ai2070/net-sdk; implement the daemon object shape (name, process, optional snapshot/restore/onControl/health/saturation) and pass it to registerDaemon.
    • Go: import "github.com/ai-2070/net/bindings/go/net"; implement the MeshOsDaemon interface and call meshos.RegisterDaemon(daemon, identity).
    • C: #include <net_meshos.h>; populate a NetMeshOsDaemonVtable and call net_meshos_register_daemon(...). Link against libnet_meshos.
  5. 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.
  6. ICE workflow. Every break-glass operator runs through simulate()commit(signatures). Build a proposal with client.ice().freeze_cluster(reason, ttl); call proposal.simulate().await to get a SimulatedIceProposal carrying the blast-radius projection; collect operator signatures over simulated.signing_payload(); call simulated.commit(signatures).await. The substrate enforces simulate-before-commit; commit without a fresh simulation returns consumed.
  7. MeshOS daemon trait additions. If you implement MeshDaemon and want supervision participation, override health() / saturation() / on_control(DaemonControl). Defaults preserve compatibility from v0.17.
  8. NetDB from Go. Go consumers who previously opened OpenTasksAdapter + OpenMemoriesAdapter separately can now use OpenNetDb(redex, NetDbConfig{...}) for the cross-adapter façade + snapshot bundle that round-trips across every binding. See bindings/go/net/netdb.go.
  9. C ABI consumers. Migrate #include "net.go.h" callsites to the per-surface headers (net_cortex.h for RedEX/CortEX/NetDb, net_rpc.h for RPC, net_meshdb.h for MeshDB, net_meshos.h for MeshOS, net_deck.h for Deck). The convenience net.go.h #includes net_cortex.h automatically; existing consumers compile unchanged.
v0.17.0Codename:Atomic Playboys
2026.05.13

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 LeaderLostReplicaLeaderUpdate { 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, the MaintenanceState round-trip tests including DrainFailed { reason }, the MaintenanceTransitionObserved gated-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.
  • cargo clippy --features meshos --all-targets -D warnings clean across substrate + every binding crate.
  • cargo doc --features meshos --no-deps clean under RUSTDOCFLAGS="-D warnings" — every public item in the meshos surface 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::Delay replaces Skip on the loop's heartbeat timer. Tests that asserted skipped ticks under load will see delayed ticks instead.
  • MeshOsRuntime::drop aborts both tasks. Tests that relied on the loop / executor running past a dropped runtime will see the tasks aborted.
  • MeshOsHandle::publish is still async-blocking on a full queue; tests that hung previously now have publish_timeout(event, Duration) available as a non-blocking-on-deadline alternative.
  • Probe panics no longer kill the loop. Tests that asserted JoinError::Panic propagation through the loop task will see the probe's panic surface in recent_failures instead.

How to upgrade

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.17 line. Recompile / rebuild the binding cdylib with the meshos Cargo feature on when you want the MeshOS surface; without it, the substrate is unchanged from v0.16.
  2. MeshOS opt-in. Channels that want the cluster-behavior engine: build the substrate with --features meshos and call MeshOsRuntime::start(MeshOsConfig::default(), dispatcher) where dispatcher wires MeshOsAction variants to the existing subsystems (DaemonRegistry for StartDaemon / StopDaemon, the migration orchestrator for PullReplica / DropReplica, the admin-chain commit path for CommitMaintenanceTransition).
  3. Source converters. Attach the daemon-registry sink via attach_to_daemon_registry(&registry, runtime.handle_clone()). Attach a per-coordinator replica sink via attach_to_replication_coordinator(&coord, runtime.handle_clone(), this_node). Install proximity probes via runtime.add_locality_probe(...) and runtime.add_health_probe(...) against a shared Arc<ProximityGraph>.
  4. Placement scorer. Install a PlacementScorer impl via runtime.install_placement_scorer(scorer). The substrate ships the trait + scheduler arm; the impl wires to PlacementFilter per consumer.
  5. Action chain. Install an ActionChainAppender on the executor (production: writes to a RedEX chain that the MeshOsSnapshotFold consumes on every node). The default NoOpActionChainAppender makes the chain optional; Deck integration drives the wiring.
  6. Daemon trait additions. If you implement MeshDaemon and want supervision participation: override health() / saturation() / on_control(DaemonControl). Defaults preserve compatibility; overrides opt into graceful shutdown, drain coordination, and cluster-wide backpressure.
  7. Shutdown. Always call runtime.shutdown().await rather than dropping the runtime. The new Drop impl aborts the tasks and warns, but an explicit shutdown is the contract for clean lifecycle.
  8. Snapshot consumers. Read the snapshot via runtime.snapshot() (cheap — one ArcSwap::load_full) or sample executor stats via runtime.executor_stats(). Deck queries arrive through MeshDB once the snapshot chain is wired.
v0.16.0Codename:Eye of the Tiger
2026.05.13

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_viewdataforts.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 → int payload conversion via unsafe.Slice + bytes.Clone — refuses payloads above math.MaxInt with ErrMeshDBRuntime rather than letting C.GoBytes's C.int cast silently truncate.
  • ExecuteContext / ExecuteWithContext run the FFI execute call inside the spawned goroutine; the caller is never blocked on cgo, and ctx.Done() races the executor concurrently with row pumping.
  • An ffi_guard! macro wraps every FFI entry point in catch_unwind; panics across the C ABI become null_mut() returns with kind runtime_panic populated on the thread-local last-error pair.
  • Every factory validation null-return populates net_meshdb_last_error_message / _kind with a descriptive invalid_arg message; Go-side wrapMeshDBError(sentinel) reads both into a MeshDBError that wraps ErrMeshDBInvalidArg / ErrMeshDBRuntime for errors.Is routing.
  • MeshDBQueryBuilder source-resets (.At / .Between / .Latest) preserve the accumulated b.err so Build still surfaces the first error in the chain; deterministically free the prior *MeshDBQuery handle 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_plan now returns Option<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 structured MeshError (display + kind) flows through a thread-local LAST_ERROR_* and three getters.
  • Go SDK ExecuteContext / ExecuteWithContext take context.Context. Pumping goroutine selects on ctx.Done() per send. Drop-the-channel-to-cancel was a documented lie.
  • MeshDBQueryBuilder source-resets free the prior *MeshDBQuery handle deterministically.
  • Go SDK pumpIterRowsContext no longer truncates size_t payloads to C.int. unsafe.Slice + bytes.Clone + a math.MaxInt guard surfaces ErrMeshDBRuntime on oversized payloads rather than letting C.GoBytes silently sign-flip.
  • ExecuteContext runs the FFI execute inside the spawned goroutine. Pre-fix it ran on the caller's stack before the pump goroutine spawned, so ctx.Done() was ignored until the executor returned.
  • Every FFI entry point (not just the two runner_execute* paths) wraps in catch_unwind via a new ffi_guard!($default, { ... }) macro. Panics become null_mut() / NET_MESHDB_RUNTIME_ERR with kind runtime_panic populated.

Majors (19 — 13 closed in code, 6 deferred with rationale)

Closed:

  • Planner non-determinism via HashSet<Tag> iteration. parent_of / children_of / collect_coverage collect every candidate, sort, and pick the smallest with a deterministic tie-break key.
  • Discovered resolution surfaces MeshError::AmbiguousDiscovery { matches } when multiple origins match, rather than silently truncating to the first.
  • call_id uniqueness — process-global FEDERATED_CALL_ID_COUNTER replaces 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 kind discriminator on MeshError; surfaced through every binding.
  • Node cache-policy factory validation brought to parity with Python / Go (reject non-finite / negative ttlSeconds at construction).
  • Watermark API parity on Python's MeshQuery.join(...) (already shipped; pinned with a regression test).
  • BFS in lineage walks uses VecDeque::pop_front and caches children_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 = 0 as "just-the-origin"; previously a present parent / child tripped LineageMaxDepthExceeded.
  • parent_of collects 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::insert of an oversized result refuses up-front instead of silently evicting itself.
  • JSON predicate parsing bounds depth at 64; Predicate::to_wire converts to an iterative heap-allocated work stack.
  • Every Go FFI factory's validation null-return populates last_error_* with a descriptive invalid_arg message.
  • Node AsyncIterable shim defines return() / throw() that release the backing Vec<ResultRow> via a new MeshQueryStream::release() napi method.
  • include/README.md error-reporting paragraph rewritten to match the actual net_meshdb_last_error_* contract; operator-families table gains the last-error row; quickstart migrated to <inttypes.h> PRIx64 / PRIu64.
  • MeshDBQueryBuilder source-resets preserve b.err; aliasing across source-resets documented explicitly.

Deferred with rationale:

  • Federated SDK tests. Need FederatedMeshQueryExecutor + LoopbackTransport exposed 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 MeshError variants the review listed (JoinMemoryExceeded, QueryBudgetExceeded, AmbiguousDiscovery, HistoricalRangeUnavailable) aren't currently triggerable from the SDK surfaces — they need configurable per-query budgets, ChainRef::Discovered exposure, and capability-index gating, none of which ship in v0.16. The kind discriminator plumbing is pinned with a Node-side parseMeshDbErrorKind test against synthetic errors.

Minors (20) and Nits (4)

Closed:

  • group_key_for defensive fallback for JoinKeyMode::Field replaced with unreachable!() and a descriptive message.
  • row_overhead: u64 = 64 magic constant replaced with std::mem::size_of::<ResultRow>() as u64.
  • translate_responses emits MeshError::ExecutorError on 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_table helper.
  • C header threading section documents move-safe / not-Sync semantics for MeshDbRunner and MeshDbIter.
  • meshdb.ts drops the typed-class re-export (the shim's job is just the AsyncIterable side-effect).
  • Shared OnceLock<Runtime> per FFI shim instead of Runtime::new() per runner.
  • MESHDB_PLAN.md and CORTEX_ADAPTER_PLAN.md reconciled with shipped reality.
  • JoinKeyMode::Field("origin"|"seq"|"origin,seq") canonicalizes to the matching row-intrinsic encoding.
  • parseMeshDbErrorKind regex 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.c uses <inttypes.h> PRIx64 / PRIu64.
  • Python lineage_emit doc-comment attached to the correct factory.
  • Go FFI ffi_cached_runner_round_trips actually asserts a cache hit (mutates the underlying store between calls and verifies the Permanent-policy fetch returns pre-mutation bytes).
  • translate_responses last-err rebuild uses the original error rather than re-constructing.
  • Node LineageEntry.depth is bigint (shape parity with originHash / tipSeq). The factory rejects values exceeding u32::MAX with a typed error. Breaking for any Node SDK caller that previously constructed entries with plain number literals: pass 0n, 1n, … instead of 0, 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>) -> Duration in behavior::meshdb::query, alongside DEFAULT_JOIN_WATERMARK_SECS = 5. All three SDK shims now route their f64 watermark 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 Python test_join_accepts_watermark_secs_kwarg could 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 full p ∈ [0, 1] range; empty-input group_by = origin aggregates 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_outcome only DropReplays when BOTH the static AND the initiator's ephemeral match.
  • Duration::MAX sentinel handled in periodic sweep loops. spawn_token_sweep_loop and spawn_capability_gc_loop both documented Duration::MAX as "disable the loop". The implementations forwarded that value to tokio::time::interval(MAX), which panics on Instant + MAX overflow. Both loops now short-circuit to shutdown_notify.notified().await when the interval is MAX.

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

  • ctor 0.11.1 → 1.0.5 (Rust constructor / destructor attributes; cleaner 1.x API for the static-init registration paths).
  • napi 3.8.6 → 3.9.0 (napi-rs runtime — Node binding surface).
  • napi-build 2.3.1 → 2.3.2 (napi-rs build script).
  • napi-derive 3.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, instrumented ffi_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.
  • cargo clippy --all-features --all-targets -D warnings clean across substrate + every binding crate. The MeshDB executor's hash-join probe-table type alias (HashJoinTable) lands to silence clippy::type_complexity on the shared helper.
  • cargo doc --features meshdb --no-deps clean under RUSTDOCFLAGS="-D warnings" — broken intra-doc links in cache.rs (DefaultHasher / PredicateWire) and redex/config.rs (the dataforts-gated BlobAdapter / RedexFile::resolve_one references 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::DropReplay on bare (static_a, static_a) will see AcceptRotation; pass the new 4-arg signature with matching ephemerals to pin the replay-detection behaviour.
  • Duration::MAX sweep interval no longer panics. Tests that asserted tokio::time::interval(MAX) would surface an Instant-overflow panic in the spawned task will see the loop park on shutdown_notify instead.
  • MeshError kind discriminator on the Python MeshDbError exception — Python callers can read e.kind (set via PyO3 setattr); tests that asserted MeshDbError has 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 consume parseMeshDbErrorKind(err).message or update their substring matches.

How to upgrade

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.16 line. Recompile / rebuild the binding cdylib (NAPI for Node, maturin for Python, cargo build -p net-meshdb-ffi for Go) with the meshdb Cargo feature on when you want the MeshDB surface; without it, the substrate is unchanged from v0.15.
  2. Go toolchain. Bump to Go 1.26. CI now reads the version from go/go.mod — set the version there and actions/setup-go@v5's go-version-file: picks it up automatically. Local toolchains should match.
  3. MeshDB opt-in. Channels that want federated queries: build the substrate with --features meshdb and construct a LocalMeshQueryExecutor::new(reader) against a ChainReader that walks RedEX. Compose plans via the typed MeshQuery::V1(QueryV1::*) AST or the host-language SDK factories.
  4. 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 for FederatedMeshQueryExecutor::with_cache.
  5. Federated executor. Construct FederatedMeshQueryExecutor::new(transport) against a MeshDbTransport impl. LoopbackTransport ships for in-process integration tests; a real MeshNode-backed transport that registers SUBPROTOCOL_MESHDB = 0x0F00 on the dispatcher is the next slice once a consumer drives it.
  6. Cross-binding consumers. Python imports from net import MeshQuery, MeshQueryRunner, ExecuteOptions, CachePolicy; Node import { MeshQuery, MeshQueryRunner, cachePolicyPermanent, cachePolicyTimeBound } from '@ai2070/net' plus import '@ai2070/net/meshdb' for the for await shim; Go imports github.com/ai-2070/net/go and uses MeshDBQuery / MeshDBRunner / MeshDBQueryBuilder. C consumers include <net_meshdb.h> and link -lnet_meshdb.
  7. 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, with net_meshdb_clear_last_error() to reset.
  8. 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.
  9. Duration::MAX sweep configs. If you intentionally set token_sweep_interval or capability_gc_interval to Duration::MAX to 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.
v0.15.0Codename:Rebel Yell
2026.05.12

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 prev no longer trips inf. Pre-fix, a prev of 1e-300 with finite current made current / prev evaluate to +inf, which trivially satisfied any ratio check. v0.15 treats prev below f64::EPSILON (and subnormals via is_normal()) as "no prior emission" — the bootstrap arm runs cleanly (D-29, N-9).
  • NaN rates short-circuit to no-emit. A NaN slipping into the counter (e.g. via a corrupted to_le_bytes round-trip on the wire) used to propagate through the ratio arithmetic. The pure function now returns EmissionDecision::Skip on 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:

  • PythonPyBlobAdapter with sync + async def method support. Async adapters run on a binding-owned event loop on a dedicated thread (one loop per process); calling thread sharing is via asyncio.run_coroutine_threadsafe. An aiobotocore / httpx.AsyncClient / SQLAlchemy async engine inside the adapter is safe — the binding never spins up a fresh asyncio.run per call (D-4).
  • NodeNodeBlobAdapter (sync TSFN bridge) + NodeAsyncBlobAdapter (Promise-returning TSFN bridge).
  • C / cgoNetBlobAdapterVtable with per-field null-check at registration; partial vtables return NET_ERR_BLOB_VTABLE_INVALID rather 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:

  • BlobCapabilitystorage, disk_total_gb, disk_free_gb, class.
  • GreedyCapabilityenabled, scope, proximity. Same shape as the chain-side greedy gate; blobs reuse the chain proximity score.
  • GravityCapabilityenabled, 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) where reason ∈ { NoStorageCap, GreedyDisabled, ProximityZero, Unhealthy, ScopeMismatch }. Wired into GreedyRuntime::dispatch_event so admitted chains carrying BlobRefs trigger a BlobAdapter::prefetch on 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 for target_caps; extends the should_pull_blob shape with a disk_free_gb headroom check (rounded up — 1.5 GiB blob → ceil(1.5) = 2 GiB required). MigrateBlobReject::InsufficientDisk is the additional variant.
  • drive_blob_migration_tick(local_caps, capability_index, adapter, size_resolver) + the _with_manifest_resolver variant. Walks peers in the capability index, parses heat:blob:<hex>=<rate> reserved tags via parse_blob_heat_tag, runs should_migrate_blob_to against each candidate, and on admit calls adapter.prefetch. The manifest-resolver variant recursively prefetches every constituent chunk of a BlobRef::Manifest (PR-5o). Returns a BlobMigrationTickReport with 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 resulting BlobRef.
  • 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-run lists candidates; --disk-pressure bypasses 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_tags caps incoming heat:blob: tags at 256 per announcement; the cap bounds migration-controller amplification (each surviving heat tag drives a prefetch attempt).
  • CapabilityIndex::by_origin_hash is a u32-truncated shortcut; an AtomicU64 collision_count field 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).
  • BlobMigrationController caps per-peer prefetch admits per tick so a single peer can't dominate the disk-bandwidth budget.
  • Per-channel chain_blob_refs shadow 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()) before py.detach(). PyO3 0.28's strict &[u8] type-rejects bytearray at the FFI boundary; the post-fix copy keeps the capture-then-detach pattern safe against a hypothetical future PyO3 relaxation.
  • CapabilityIndex fails closed when a wire u32 origin_hash is ambiguous and falls back to the empty-caps default for vacant slots.
  • MeshBlobAdapter serializes 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_tick captures sink + emissions + policy under one read of the gravity RwLock. Pre-fix it took the lock twice; a concurrent set_gravity / clear_gravity between reads could renormalize emissions computed under policy A against policy B.
  • drive_blob_migration_tick_with_manifest_resolver only 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.
  • BlobMigrationController floors 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_id is 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 --out refuses to clobber existing output files (the CLI may run with elevated privileges).
  • delete_chunk drops the refcount entry inline rather than waiting for sweep_gc.
  • BlobError::Unauthorized typed variant separates auth-rejection from other rejection modes.

Build graph

  • dataforts = ["redex", "redex-disk", "dep:blake3"]. --features dataforts alone previously failed to compile because the blob path calls RedexFile::sync() which is gated behind redex-disk. The feature graph now encodes the actual dep.

Doc + test-name polish

  • Two pull_rejects_* admission tests asserted Admit (Zone-narrower-than-Mesh + absent-publisher-scope-defaults-to-Mesh) — renamed to pull_admits_*.
  • controller_skips_peers_without_blob_heat_tags renamed to controller_ignores_chain_heat_shape_tags.
  • BlobRef::encoded_len doc now documents Small as O(1) and Manifest as full-encode-cost (was "cheap for both variants").
  • PyMeshBlobAdapter::fetch_range doc spells out half-open [start, end) tied to Python slice semantics.
  • publish_with_blob doc 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: NoStorageCapNotParticipatingSenderNotOverflowingUnhealthyScopeMismatchInsufficientDisk. 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 via MeshNode::call, decodes the typed ack.
  • Receiver side: MeshNode::serve_overflow_push(adapter) -> ServeHandle registers the OverflowPushHandler under the service name. Each inbound request reads live user_caps_snapshot + the capability index, runs admission, on Admit calls adapter.prefetch(BlobRef::small(...)) to open the chunk channel.
  • MeshNodeOverflowPushSink — concrete OverflowPushSink impl wrapping Arc<MeshNode>. Maps non-Accepted acks to typed BlobError::Backend so the controller's push_errors counter 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.

RustMeshBlobAdapter::with_overflow(OverflowConfig { .. }) builder, set_overflow_enabled(bool) / set_overflow_config(OverflowConfig) runtime setters, overflow_enabled() / overflow_active() / overflow_config() getters.

PythonMeshBlobAdapter(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 / TypeScriptnew 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);

GoNewMeshBlobAdapter(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_enabled round-trip × 2, OverflowConfig adapter surface × 5).
  • P2: 20 controller / tick / hysteresis tests (step_overflow_hysteresis × 4 edge cases, BlobOverflowController::candidates × 7 filter paths, tick-driver tests × 6 against an OverflowPushRecorder mock, scope_covers × 2, MeshBlobAdapter::overflow_active shared-state × 1).
  • P3: 7 wire-format + integration tests (postcard round-trip × 5 variants + 2-node MeshNode::send/serve_overflow_push end-to-end × 2).
  • P4: 10 metrics + CLI tests (record_overflow_tick bumps × 4 paths, per-reason record_overflow_reject × 1, Prometheus body shape × 2, CLI overflow status Human + 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 — u16u32 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).
  • upsert on update subtracts the old bytes before replacing the file pointer (D-3).
  • chain_caps resolves the chain publisher's caps via the capability index, not the last-hop peer's (D-5).
  • TOCTOU on is_new_channel collapsed into a single locked get-or-insert (D-6).
  • tokio::spawn per inbound event bounded by a semaphore (D-7).
  • colocation_target_held resolved from the cache map, not hardcoded None (D-8).
  • gravity_tick coalesces N×announce_capabilities into one via announce_heat_batch (D-25).
  • Retention-trim drift on entry.bytes resyncs via RedexFile::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.heat lock before calling sink.withdraw_chain to avoid lock-ordering hazards (N-5).

Gravity correctness (D-9..D-11 + D-29..D-30 + N-2 + N-9)

  • origin_hash == 0 no longer collapses per-chain heat (D-9, fix at publish-side + defense-in-depth at gravity-runtime entry).
  • HeatRegistry bounded + LRU-evicted + tick-pruned (D-10, N-2).
  • Inbound heat: tags gated on the publisher's matching causal: claim (D-11; per-peer rate-limit deferred per N-8).
  • should_emit_heat subnormal-safe via is_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::store hash-verifies bytes (D-12).
  • URI-scheme keyed adapter dispatch closes authority confusion (D-13).
  • 4-byte magic for BlobRef discriminator closes payload-misclassification (D-14).
  • BlobRef size bounded; fetch_range guards usize cast (D-15).
  • Streaming hooks on BlobAdapter (D-16).
  • Log injection via BlobError::NotFound(uri) sanitized (D-31).
  • Unique-suffix temp filenames (D-32); fsync of temp + parent dir (D-33).
  • Per-channel BlobAdapterRegistry override (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).
  • BlobError marked #[non_exhaustive] (D-49).
  • RedexFileConfig::blob_adapter_id unset 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_for defends 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 in ffi/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_token waits on applied seq, not folded (D-17).
  • FoldStopped error variant when fold task crashes mid-wait (D-18).
  • WriteToken doc-hidden constructor + threat-model docstring (D-19).
  • ryw_inflight_cap rename + non-FIFO doc note (D-37).
  • Process-wide set_global_ryw_inflight_cap with two-tier acquire (D-38).
  • Go binding lands Tasks + Memories adapters with WaitForToken + PollForToken + WaitForTokenContext (D-45).
  • wait_duration_nanos_sum saturating 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 dataforts feature via NET_ERR_FEATURE_NOT_BUILT stubs (D-20).
  • Panics across FFI caught + remapped to NET_ERR_PANIC (D-21).
  • Vtable per-field null-check (D-22).
  • timeout_ms == 0 honored as non-blocking poll (D-23).
  • mesh_arc drop coverage via RAII guard rather than duplicated drop-on-error (D-39).
  • Node await_tsfn_promise applies the 30 s timeout once (was 30 s × 2 → 60 s worst case) (D-40).
  • Node DataGravityConfigJs *_secs / _ms widths 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 inside py.detach (D-43, N-1).
  • Go omitempty doc note on greedy / gravity numeric fields (D-44 deferred — substrate rejects 0 for every affected field; omitempty is correct).
  • Go runtime.SetFinalizer runs blocking Close on the GC thread — doc note rather than refactor (D-54).
  • Python atexit drain counts drained vs. missing entries via NET_PY_TRACE_ATEXIT env var (N-10).

Hygiene (D-47..D-48)

  • metrics.rs channel-cap race doc note (D-47).
  • _force_use_hashmap dead 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 warnings clean across substrate + every binding crate.
  • cargo doc --all-features --no-deps clean under RUSTDOCFLAGS="-D warnings"rustdoc::broken_intra_doc_links and rustdoc::private_intra_doc_links both enforce.
  • Go go vet ./... clean under CGO_ENABLED=1; the pre-existing testOrigin uint32 / uint64 mismatch in cortex_test.go is fixed alongside the FFI net_channel_hash u16 → u32 change.

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() returns u32 (was u16). New ChannelName::wire_hash() -> u16 exposes the wire fast-path hint.
  • channel_hash(name: &str) -> u32 (was u16). New wire_channel_hash(name: &str) -> u16.
  • AuthGuard::{check_fast, authorize, revoke, is_authorized} take ChannelHash (was u16).
  • PermissionToken::channel_hash is u32 (was u16); TokenScope::with_channel, try_issue, TokenCache::{check, get} all widen.
  • MeshNode::register_rpc_inbound takes ChannelHash (was u16); RpcInboundEvent::channel_hash is u32.
  • ChannelConfigRegistry::{get, remove, priority} take ChannelHash; new get_by_wire_hash(u16) for receive-side disambiguation.
  • ChannelRegistry::get_by_wire_hash renamed to get_all_by_wire_hash and 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_event is 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.
  • HeatRegistry is capped at 8 K entries. Tests that fill the registry with > 8 K entries to observe unbounded growth will see LRU eviction.
  • should_emit_heat returns Skip on near-zero prev. Tests that injected prev = 1e-300 to observe the pre-fix inf-prone branch will see the bootstrap arm instead.
  • wait_for_token returns Err(WaitForTokenError::FoldStopped) when the fold task crashed mid-wait. Tests that asserted Ok(()) 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 real 1 ms wait will see the synchronous return.
  • PermissionToken::from_bytes rejects 159-byte input. Tests that hardcoded the 159-byte wire form will see TokenError::InvalidFormat.

How to upgrade

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.15 line. Recompile / rebuild the binding cdylib (NAPI for Node, maturin for Python, cargo build -p net-compute-ffi + -p net-rpc-ffi for Go) with the dataforts Cargo feature on (pre-built release artifacts ship with the feature enabled).
  2. Channel-hash type migration. Use ChannelHash (u32) for ACL / storage / config / RYW decisions; use ChannelName::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.
  3. Token reissue. PermissionToken wire 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.
  4. Greedy opt-in. Channels that want greedy caching: call Redex::enable_greedy_dataforts(mesh, GreedyConfig, local_caps, IntentRegistry) once after constructing the Redex (idempotent). The runtime registers a GreedyObserver on the mesh's inbound dispatch; admission decisions run per inbound event without any per-channel opt-in. Redex::disable_greedy_dataforts() removes the observer.
  5. Gravity opt-in. Layer gravity on top of greedy with Redex::enable_gravity_for_greedy(mesh, DataGravityPolicy). The tick loop spawns automatically; tune decay_half_life_secs, tick_interval_ms, emit_threshold_ratio, normalization_reference_rate to match the deployment's read-rate skew.
  6. 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), then blob_publish(adapter_id, uri, bytes) / blob_resolve(blob_ref) against the registered URI scheme.
  7. RYW opt-in. Capture the WriteToken returned from every tasks.create / memories.insert; pass it to tasks.wait_for_token(token, deadline) before reading state that needs to reflect the write. deadline_ms = 0 is a non-blocking poll.
  8. 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.
  9. Single-binding deployments without dataforts. Builds without the dataforts Cargo feature surface typed RedexError stubs from every enable_* entry point. The substrate substrate path is unchanged — RedEX, CortEX, NetDB, replication all work as in v0.14.
  10. 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.
  11. FFI consumers (C / cgo). net_channel_hash takes uint32_t* (was uint16_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 existing net_redex_* shape. Without the dataforts feature, the symbols return NET_ERR_FEATURE_NOT_BUILT rather than failing to link.
  12. Mixed v0.14 / v0.15 fleets. Replication traffic continues to work cross-version (the SUBPROTOCOL_REDEX wire format is unchanged). Tokens do not (161 vs 159 bytes). Recommend lockstep upgrade for any deployment using PermissionToken-bearing channels.
v0.14.0Codename:The Warriors
2026.05.11

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 new leader_first_retained_seq field lets the replica disambiguate retention-trim from split-brain divergence; legitimate trim with first_seq == leader_first_retained_seq triggers skip-ahead via RedexFile::skip_to, any other gap shape NACKs back and bumps dataforts_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-checked ReplicaRole discriminant.
  • 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. detail truncates at a UTF-8 char boundary ≤ u16::MAX so 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 PlacementFilter to choose which N nodes carry the channel's replica set when the channel is first opened or on roster change. Standard flows through the v0.13 scoring; Pinned skips it. The selected set is published via the causal:<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 the SUBPROTOCOL_REDEX inbound router on the mesh and arms Redex::open_file to spawn a replication runtime when the supplied RedexFileConfig carries replication: Some(ReplicationConfig). Idempotent: a second call with the same mesh is a no-op.
  • open_file(name, cfg) — when cfg.replication.is_some(), spawns a per-channel ReplicationRuntime (tokio task + HeartbeatTracker + BandwidthBudget + ReplicationCoordinator) and registers it on the inbound router. Reopen with a structurally-different ReplicationConfig returns 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, and replication_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.rs covering two-node catch-up, leader-close → replica election, three-node fanout, lag-driven catch-up, heartbeat round-trip, and the bandwidth_budget_metric_field_is_plumbed smoke. The replication_overhead_within_30_percent_budget perf-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.rs covering 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 assert election_thrash_total bumps), and wall_clock_ms determinism. The harness derives wall-clock time from a step counter, not real Instant::now, so traces reproduce byte-identically across machines.
  • Loom — atomic-pattern models for RedexFile::close's swap-true-on-close, the record_tail_seq CAS loop, the replication metrics counters under concurrent increment (including a three-way same-counter contention case), and the try_first_close first-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. SyncRequest and SyncResponse handlers re-check coordinator.role() immediately before the dispatcher send so a concurrent transition between the entry check and the outbound ship triggers a clean NACK NotLeader rather than a response from a node that no longer claims leadership.
  • Chain-tag side-effects serialized. The coordinator's transition_to holds a tokio::sync::Mutex<()> across the state update + metric bumps + sink call so two racing transitions can't interleave announce_chain from a stale role over a withdraw_chain from a fresher one.
  • NACK NotLeader / BadRange actually recover. NotLeader clears the believed leader so the next tick re-resolves via find_chain_holders; BadRange calls RedexFile::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) surfaces TagSink (state moved, side-effect failed) or Transition (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 DiskPressureWithdraw on Replica → Idle; Leader / Candidate variants now route through ChannelClose (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. Uses try_send(Shutdown) first; on Full, aborts the JoinHandle directly so a wedged task with a saturated inbox can't block the caller waiting on a buffer the task may never drain.
  • Drop on ReplicationRuntimeHandle aborts the task. The strong-reference cycle MeshNode → router → handle → task → dispatcher Arc is broken unconditionally when the handle goes out of scope, not just via the canonical ReplicationWiring::drop un-installation.
  • is_stopped consults an explicit flag flipped after cancel()'s .await returns, not the JoinHandle slot — two concurrent cancel()s racing on task.lock().take() could previously let the loser observe None and report stopped == true before the winner had finished joining.
  • Channel-id validation defense-in-depth on every inbound type. SyncRequest, SyncResponse, SyncNack, Heartbeat all gate on msg.channel_id == inputs.channel_id at the runtime boundary so mesh misroute can't poison the tracker.
  • GapBeforeChunk underflow closed. first_seq.saturating_sub(local_next) plus a debug_assert!(first_seq > local_next) belt-and-suspenders.

Catch-up correctness

  • Retention-trim vs. divergence disambiguation. SyncResponse carries leader_first_retained_seq on the wire; the replica treats first_seq == leader_first_retained_seq as a legitimate trim (skip-ahead via RedexFile::skip_to) and any other gap shape as divergence (NACK back, bump counter, log loudly).
  • Empty chunk validates first_seq. The short-circuit on response.events.is_empty() now validates first_seq >= local_next so a leader bug emitting first_seq = 999 on 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_BYTES rather than shipping wire bytes that the replica's local append would refuse.
  • prev + 1 strict-monotonicity uses checked_add. Practically unreachable; surrounding code used saturating_* and the asymmetry was the real bug.
  • Lag-driven SyncRequest filters believed_leader != self so a test-setup loopback or tracker misuse can't make the runtime issue a SyncRequest to itself.

Wire codec

  • SyncNack::from_bytes truncation reports correct need. The R-23 fix shipped for SyncResponse but missed the SyncNack arm in the original commit; the second-pass review caught and fixed it.
  • SyncNack::to_bytes truncates at a UTF-8 char boundary. A multi-byte codepoint straddling SYNC_NACK_DETAIL_MAX previously shipped invalid UTF-8 that the decoder rejected, losing the structured error code along with the diagnostic.
  • WireError::Truncated.need formula correct everywhere. need = consumed + still_needed in every arm — both header reads and per-event payload reads.

File / manager

  • RedexFile::skip_to panic-safe swap order. Builds the new index / timestamps into temp Vecs, calls evict_prefix_to against 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 ↔ None and Some(cfg_a) ↔ Some(cfg_b) where the two are structurally PartialEq, rejects everything else.
  • mod replication dual public surface collapsed. The flat re-exports under redex:: are now the only public path; pub mod replication is gone.
  • Lag saturation pinned with a named constant LAG_SATURATED_MICROS = LAG_NOT_OBSERVED - 1 and a test asserting the gap from the sentinel is preserved.

Mesh / dispatch

  • from_node == 0 sentinel collision rejected. The replication inbound arm mirrors the reflex handler's guard — a peer whose from_node falls back to 0 (the valid NodeId sentinel collision) is dropped rather than entering the tracker.

Bindings / FFI

  • Python replication=False with replication_* kwargs rejects with a typed RedexError rather than silently dropping the other kwargs.
  • enable_replication is a typed RedexError stub without the net feature in both Node and Python, rather than TypeError: redex.enableReplication is not a function / AttributeError. The Python replication_runtime_count / replication_prometheus_text gates the same way.
  • net_redex_enable_replication drops Box<Arc<MeshNode>> on every error path. Doc-comment now states "consumed regardless of return code."
  • net_redex_open_file and net_redex_file_tail pre-zero *out_handle / *out_cursor on entry so a cgo / C consumer reading the slot after a non-zero return sees null rather than stale stack data.
  • Python runtime.block_on paths release the GIL via py.detach across 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.sync and RedexFile.close are async — disk I/O dispatches via tokio::task::spawn_blocking onto 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_pinned cross-checked against pinned_nodes at the binding layer when placement == Pinned.
  • Node redex_err documents the redex: prefix contract in index.d.ts so JS-side operators can string-sniff on e.message.startsWith("redex:") against a pinned shape.
  • Go OpenFile distinguishes ErrInvalidReplicationConfig from ErrReplicationRequiresEnable. Binding-side validator covers shape errors plus Factor / HeartbeatMs ranges; only the FFI NET_ERR_REDEX for replication-not-enabled falls into the second sentinel.
  • Go RedexFile.mu uses sync.RWMutex so appends / reads aren't serialized per file. The Rust substrate's HandleGuard is a reader-counter; pre-fix the Go binding's mutex defeated that.
  • Go typedef ArcMeshNode aliases the upstream net_compute_mesh_arc_t opaque typedef so the same Arc handle 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 a debug_assert! so accidental misuse surfaces in debug builds rather than silently corrupting on the wire.
  • DST harness wall_clock_ms derives from the step counter, not real Instant::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_heal AND after restart_during_sync, not just on the happy path.
  • e2e flake-prone test marked #[ignore]. The replication_overhead_within_30_percent_budget 1.3× wall-clock budget is opt-in via cargo test -- --ignored rather than running on shared CI runners.
  • e2e bandwidth_budget_is_observable_in_metrics renamed to bandwidth_budget_metric_field_is_plumbed so the test name matches what the test asserts (field plumbing under the wire path, not budget engagement; the budget-fired path is unit-tested under replication_catchup).
  • Loom metrics model exercises a three-way same-counter contention case beyond the existing two-thread mixed-counter races.
  • BandwidthBudget::try_consume handles 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::MAX rather 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-e2e runs the multi-tokio-thread integration suite under --features "redex net"; redex-replication-dst runs the deterministic-simulation harness under --features redex; loom-models runs the atomic-pattern loom tests under RUSTFLAGS=--cfg loom. All three gate the redex-distributed merge.

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 MB64 GB, 81_920 MB80 GB, 10_000 Mbps10 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:

  1. HTTP / nRPC convention names the protocol, not the parent organization. HTTP doesn't have w3c-content-type:; traceparent / idempotency-key use system-level prefixes, not org names. The umbrella-on-the-wire shape was an outlier.
  2. 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.
  3. 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_HEADER macro in net.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 via abi_version_expected: 2 for the predicate envelope (other fixtures continue at 1 — only the envelope carries the ABI version field today).
  • Three new CI jobs. redex-replication-e2e, redex-replication-dst, loom-models gate 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 warnings clean across substrate + every binding crate.
  • cargo doc --all-features --no-deps clean under RUSTDOCFLAGS="-D warnings" — both rustdoc::broken_intra_doc_links and rustdoc::private_intra_doc_links enforce.

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}.
  • HardwareCapabilities field renamesmemory_gb, gpu.vram_gb, storage_gb, network_gbps. AcceleratorCapability::memory_gb.
  • adapter::net::redex exports — new types ReplicationConfig, 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 with Redex::open_file carrying cfg.replication = Some(rep) to spawn a per-channel replication runtime.
  • Redex::open_file rejects reopen with a structurally-different ReplicationConfig with a typed RedexError::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_000 added; ReplicationConfig::validate rejects heartbeat_ms > HEARTBEAT_MS_MAX with a typed HeartbeatTooHigh variant.

Rust SDK (net-sdk)

  • net_sdk::capabilities::redex re-exports the substrate replication surface — ReplicationConfig, PlacementStrategy, UnderCapacity, ReplicaRole, ChannelReplicationStatus.
  • net_sdk::capabilities::predicate::RPC_WHERE_HEADER is 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_ms clamps at [100, 300_000]. Tests injecting u64::MAX or other pathological values to observe silence-detection behavior will see ReplicationConfigError::HeartbeatTooHigh instead.
  • PlacementFilter election no longer excludes peers with rtt_to == None. Tests that asserted NoEligibleReplica against an all-unmeasured replica set will see the smallest-NodeId healthy peer elected instead.
  • SyncNack::to_bytes truncates at a UTF-8 char boundary, so a regression test that previously expected from_bytes to fail on an oversize multi-byte payload will see the round-trip succeed at a slightly-shorter detail length.
  • Reopen with a different ReplicationConfig rejects. Tests that opened a channel with one config and reopened with another expecting silent reuse will see RedexError::Channel("different from the original").
  • bandwidth_budget_is_observable_in_metrics renamed. Tests referencing the old test name fail to find it; rename to bandwidth_budget_metric_field_is_plumbed.
  • replication_overhead_within_30_percent_budget marked #[ignore]. CI runs that included this test in the default matrix will no longer see it; run via cargo test -- --ignored.

How to upgrade

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.14 line. Recompile / rebuild the binding cdylib (NAPI for Node, maturin for Python, cargo build -p net-compute-ffi + -p net-rpc-ffi for Go).
  2. Capability hardware-unit migration. Rename memory_mbmemory_gb, vram_mbvram_gb, storage_mbstorage_gb, network_mbpsnetwork_gbps, accelerator.memory_mbaccelerator.memory_gb throughout. Adjust values: 65_53664, 81_92080, 10_00010. 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.
  3. 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 exported RPC_WHERE_HEADER constant. Server-side handlers consuming the v0.13 name need the same rewrite.
  4. Replication opt-in. Channels that want replication: call Redex.enable_replication(mesh) once after constructing the Redex (idempotent), then open each replicated channel with Redex.open_file(name, cfg.with_replication(Some(rep_cfg))). The per-channel ReplicationRuntime spawns automatically; consult the operator surface via Redex.replication_status_snapshot() / replication_prometheus_text().
  5. Channels that don't want replication require no changes. Single-node channels behave identically to v0.13. RedexFileConfig::replication = None is the default.
  6. Node consumers — RedexFile.sync() / RedexFile.close() are async. Add await to call sites:
    await file.sync();
    await file.close();
    
    Sync call sites compile but generate orphan Promise warnings under modern Node and may exit the process before the fsync lands.
  7. Python consumers — Redex.open_file(name, replication_*=…) requires replication=True. Pre-v0.14 code passing replication_factor=5 without replication=True produced a single-node channel; now raises RedexError. Either pass replication=True explicitly or drop the replication_* kwargs.
  8. Go consumers — RedexFileConfig.Replication is the new optional field. Pass a *ReplicationConfig for replicated channels. Numeric validation (factor / heartbeat ranges) runs on the Go side before the FFI; structurally-invalid configs return ErrInvalidReplicationConfig instead of the catch-all ErrReplicationRequiresEnable.
  9. Fleet-wide upgrade required for any deployment using capability announcements with hardware numerics. v0.13 receivers parse v0.14 announcements' hardware.memory_gb as Tag::Legacy — the value survives but no longer satisfies min_memory_mb-keyed filters. Recommend lockstep upgrade alongside the predicate-header migration.
  10. Cross-binding wire fixtures regenerated. If you have CI that asserts golden-vector parity against tests/cross_lang_capability/, the GB / Gbps shape and the net-where: header rename mean every fixture changes. predicate_nrpc_envelope.json bumps abi_version_expected: 1 → 2; future binding-side version pins should track the per-fixture version field.
  11. Operator dashboardsRedex::replication_prometheus_text() emits a per-channel snapshot in Prometheus text format; pipe into your existing scrape config under the dataforts_replication_* metric family. Per-channel labels (channel, role) carry the channel name and current role for dashboard slicing.
  12. DST harness integration — if you have channel-level DST scenarios that drive ReplicaRole directly, the harness's force_transition / tick_node now mirror the production coordinator's election_thrash_total counter onto a per-VirtualNode election_thrash_count field, so storm scenarios can assert on the gauge without rewiring around the async coordinator. The harness's wall_clock_ms derives from a step counter, not Instant::now.
v0.13.0Codename:Chippin' In
2026.05.10

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; a CapabilitySet::diff on Python reproduces the identical added_tags / removed_tags / changed_metadata shape Rust would. Drift in any binding fails that binding's own CI.
  • Numeric / semver parse semantics agree with Rust. Every binding's f64 parser accepts exactly Rust's f64::from_str set (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 bound Number values at u64::MAX and reject negatives; indexed-collection indices bound at u32::MAX.
  • AxisPresent tags don't satisfy value predicates. Equals(_, "") / StringPrefix(_, "") / StringMatches(_, "") never spuriously match a presence-only tag — only the Exists predicate does. CapabilitySet::diff is separator-agnostic on AxisValue tags (hardware.k=v and hardware.k:v carry identical semantics).
  • Reserved-prefix tags only via dedicated helpers. add_tag(s) parses through Tag::parse_user, which rejects reserved prefixes — applications that try to emit a scope:tenant:foo via add_tag get the tag silently dropped. Use with_tenant_scope("foo") / with_region_scope / with_subnet_local_scope / etc. Bindings opt into the unrestricted Tag::parse path so reserved tags round-trip through tags: [...]. Metadata writers gate on the same reserved-prefix list. The schema validator surfaces collisions and oversize as warnings.
  • MeshDaemon::process panic surfaces as RpcStatus::Internal — same hardening posture as v0.12's nRPC fold, applied through the daemon-caps dispatcher when caps extraction itself panics.
  • AttributeError is the only silently-swallowed Python error. Every other exception from a @property getter for required_capabilities / optional_capabilities propagates 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_tag and RequiredCapability::Tag evaluate via Tag::semantic_eq so caps.has_tag("software.os:linux") matches a stored software.os=linux and vice versa. The separator field is a wire-form detail, not part of identity.
  • CapabilitySet::diff is 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 phantom RemoveTag without a compensating UpdateSoftware — receivers dropped the tag entirely. Same fix applied to the TS diffCapabilities rewrite (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::diff as AddTag / RemoveTag; the is_*_owned_tag predicates no longer over-claim unknown forward-compat keys.

Predicate / placement correctness

  • Custom PlacementFilter impls returning None or NaN are 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, and target_axis_value_numeric all clamp NaN / out-of-range values before composition; score_resource_axis::Both collapses to whichever axis carried data (rather than diluting against a permissive 1.0 placeholder for a no-data axis).
  • score_custom_filter_axis resolves outside the with_caps closure so an FFI-registered filter that calls back into the index (index.query(...) from a LegacyPlacement shim, JS callback hitting find_nodes) can't deadlock against a concurrent index.index(...) insert.
  • Scheduler::select_migration_target carries the LocalPreferred fast-path so RTT-aware operators feeding their own TieBreakContext don't silently lose the network-hop-avoidance behavior. place_migration_v2 derives the right PlacementReason from the returned node id.
  • CapabilityQuery::traverse carries a visited-set so cycles in the peer-capability graph terminate. eval_any_in_cost_order ranks Or composites cheap-true-first; redact_label searches every separator position so metadata-equality values containing = round-trip cleanly.
  • Tag::AxisPresent no longer matches value-bearing predicates. Equals(_, "") / StringPrefix(_, "") / StringMatches(_, "") only match AxisValue tags; Predicate::Exists is the dedicated presence-check path in every binding.

Cross-binding numeric / semver agreement

  • Every binding's f64 parser accepts exactly Rust's f64::from_str accepted-set (decimal, scientific, leading +, .5, 1., inf, infinity, NaN) and rejects hex floats (0x1p3) and digit-separator underscores (1_000) that Go's strconv.ParseFloat and Python's float() would otherwise accept. Numeric leaves run through IEEE comparison so NaN never matches and ±inf compare correctly across bindings.
  • Schema Number validators bound at u64::MAX and reject negatives; indexed-collection indices bound at u32::MAX. ASCII digits only with optional leading + — Unicode digits (Arabic-Indic, fullwidth) parse cleanly under Python's int() but Rust's u64::from_str rejects 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.x is exact-only (every patch is a breaking change boundary per Cargo's caret rule); 0.x.y requires lhs.major == 0.
  • parse_tag_key trims whitespace around the dot, require! parses == before >= / <= so equality values containing comparison substrings parse correctly. Tag::parse_user rejects reserved prefixes consistently across bindings; with_metadata filters reserved-prefix keys at the writer.

FFI / binding hardening

  • predicate_from_rpc_headers enforces the decode-side size cap symmetrically with the encode side — parse-bomb-shaped payloads surface as PredicateRpcDecodeError::Oversize instead of walking serde's recursive parse.
  • dynamic_cost / dynamic_cost_or saturate usize cardinality to u32::MAX so 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::register pre-creates the per-binding invocation counter only on successful insertion — id-collision register-fail paths don't leak phantom Prometheus binding-counters.
  • Bloom-filter h2 forces 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_side and net_compute_snapshot_bytes_free correctly free (non-NULL ptr, len == 0) malloc'd buffers.
  • rpc-ffi's run_cancellable carries a cancelled flag for register-after-spawn ordering; the cancel-token registry evicts stale orphan entries; net_predicate_to_where_header recovers from partial-write failure. Streaming-call construction is cancellable end-to-end via net_rpc_call_streaming_cancellable and net_rpc_call_streaming_with_headers_cancellable (pre-existing non-cancellable variants kept for back-compat).
  • Python announce_capabilities releases the GIL across the blocking call. Python-binding property-getter errors propagate (except AttributeError) so misbehaving daemon-caps callbacks surface real failures instead of phantom-empty-cap daemons. The Python _try_parse_float rejects whitespace-padded inputs to match Rust's strictness.
  • Go binding's RegisterPlacementFilter / UnregisterPlacementFilter serialize on the same id to close a registry-vs-substrate race; tagKeyFromWire surfaces type-assert failures.
  • Node + Python fp16_tflops_x10 bypasses the f32 round-trip that previously lost precision above 2²⁴ for direct large-value passthrough.
  • tag_codec rejects 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 headernet_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 now uint64_t return). 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 now uint64_t parameter / out-parameter).
  • Production Go bindingIdentity.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's parentOrigin, OpenTasks / OpenMemories's originHash parameter (all uint64).
  • Public Go typesCausalEvent.OriginHash is uint64 (changed from uint32); GroupMemberInfo.OriginHash is uint64; GroupForkRecord.{OriginalOrigin, ForkedOrigin} are uint64.

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 via abi_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 custom PlacementFilter via global_placement_filter_registry(), builds StandardPlacement::with_custom_filter_id over a populated CapabilityIndex, 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 warnings clean 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::Legacy on parse for forward-compat with future schema additions. The validator emits LegacyTag warnings 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 through Tag::Legacy until 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.limits no longer exist as fields. Read through caps.views().hardware() (etc.) — the projection is per-axis OnceCell-cached. Write through caps.set_hardware(hw) / set_software / set_models / set_tools / set_limits — these clear axis-owned tags and re-emit via the codec. The with_* builders are thin wrappers.
  • CapabilitySet::tags field type changes from Vec<String> to HashSet<Tag>. Iterations over caps.tags now yield typed Tag values; render to wire form via t.to_string(). Use caps.add_tag(s) for application-facing additions (parses through Tag::parse_user, rejects reserved prefixes); caps.with_tenant_scope / with_region_scope / with_subnet_local_scope for the dedicated reserved-tag builders.
  • adapter::net::behavior::tag is a new public module re-exporting Tag, TagKey, TaxonomyAxis, AxisSeparator, RESERVED_PREFIXES, CapabilityTagError.
  • adapter::net::behavior::tag_codec is 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::predicate is a new public module re-exporting Predicate, 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 the pred! macro re-exported at the crate root.
  • adapter::net::behavior::required_capability is a new public module re-exporting RequiredCapability, RequireParseError, plus the require! / require_axis! / require_axis_value! macros at the crate root.
  • adapter::net::behavior::schema is a new public module re-exporting validate_capabilities, ValidationReport, SchemaError, ValidationWarning, ValueType, KeyEntry, AxisSchema, AXIS_SCHEMA, METADATA_SOFT_CAP_BYTES.
  • adapter::net::behavior::bloom is a new public module re-exporting BloomFilter.
  • adapter::net::behavior::query is a new public module re-exporting the CapabilityQuery trait.
  • adapter::net::behavior::placement is a new public module re-exporting PlacementFilter, 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_registry is a new public module re-exporting global_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::predicate re-exports Predicate, 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::schema re-exports validate_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

  • CapabilitySet field reads now decode lazily through views(). Tests that did caps.hardware.memory_mb directly fail to compile; rewrite as caps.views().hardware().memory_mb. Same for software / models / tools / limits.
  • caps.tags.contains(&"gpu".to_string()) no longer compiles. tags: HashSet<Tag> carries typed values; use caps.has_tag("hardware.gpu") (which is now separator-agnostic) or caps.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. Use caps.with_tenant_scope("foo"). The binding-side passthrough via tags: [...] works because bindings parse via the unrestricted Tag::parse.
  • CapabilitySet::diff ops now sort deterministically. Tests that asserted specific diff-op insertion order under Vec semantics will see lexicographic-by-tag ordering instead.
  • PlacementFilter::placement_score returning None is a hard veto. Pre-fix, custom impls returning Some(0.0) and None produced indistinguishable scheduler behavior; v0.13 makes None the explicit "exclude from ranking" signal and Some(0.0) the "score floor" signal. Tests asserting "filter returns None → scheduler ranks among others" will see the candidate excluded.
  • Custom PlacementFilter impls 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 as Equals, not NumericAtLeast. 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_key trims whitespace around the dot. require!("hardware. gpu == nvidia") now produces TagKey::new(Hardware, "gpu") instead of TagKey::new(Hardware, " gpu") — the latter silently mismatched every real tag.
  • semver_compatible treats 0.0.x as exact-only. Tests that asserted "^0.0.1 matches 0.0.2" will see the rejection.
  • Tag::AxisPresent no longer matches value-bearing predicates. Equals(_, "") / StringPrefix(_, "") / StringMatches(_, "") no longer accept presence-only tags. Use Predicate::Exists for key-presence checks.
  • Forward-compat axis tags survive CapabilitySet::diff. Pre-fix, is_*_owned_tag over-claimed unknown forward-compat keys (hardware.future_field=v2) and the residual filter dropped them; the typed Update* ops didn't capture them either. Real changes to forward-compat tags now ship as AddTag / 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

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.13 line. Recompile / rebuild the binding cdylib (NAPI for Node, maturin for Python, cargo build -p net-compute-ffi + -p net-rpc-ffi for Go).
  2. CapabilitySet field-access migration. Direct field reads (caps.hardware, caps.software, etc.) move to caps.views().hardware() / software() / etc. Use cargo build to 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.
  3. Tag iteration changes from &str to &Tag. Render to wire form via tag.to_string() (the canonical Display impl), or pattern-match on the typed variants. caps.has_tag("...") works with either separator form.
  4. Reserved-prefix tag emission moves to dedicated builders. Replace caps.add_tag("scope:tenant:foo") with caps.with_tenant_scope("foo"), etc. Application code passing reserved tags through caps.add_tag was already silently dropping them in v0.12 prerelease builds.
  5. Fleet-wide upgrade required for capability announcements. v0.12 ↔ v0.13 mixed fleets cannot exchange CapabilityAnnouncement / CapabilityDiff payloads — 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.
  6. For the new capability surface — the typed taxonomy + predicate evaluator + validator + diff + trace + debug report are opt-in. Read net/crates/net/README.md#capabilities for the high-level surface, then per-binding READMEs for language-idiomatic usage:
    • Rust SDKnet/crates/net/sdk/README.md § "Capability enhancements (typed taxonomy + predicates + validation)". pred! macro + require! family in scope under net_sdk::capabilities.
    • Nodenet/crates/net/sdk-ts/README.md § "Capability enhancements". Import from @ai2070/net-sdk.
    • Pythonnet/crates/net/sdk-py/README.md § "Capability enhancements". Import from net_sdk.
    • Gobindings/go/net/ exports the parallel surface. C-ABI entry points documented in net/crates/net/include/README.md.
    • Cnet/crates/net/include/README.md § "Mesh function families" rows "Predicate evaluation", "Predicate where: header", "Capability validation", "Predicate debug session". Worked examples: net/crates/net/docs/CAPABILITY_ENHANCEMENTS_USAGE.md.
  7. Predicate-as-cyberdeck-where:-header → server-side filter. Pair predicate_to_rpc_header with the header-bearing nRPC call variants from v0.12 (net_rpc_call_with_headers and friends; same surface in every binding). Server's nRPC handler decodes via predicate_from_rpc_headers and filters candidates with evaluate_predicate. The cyberdeck-where: header name is exported as RPC_WHERE_HEADER from every binding.
  8. 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).
  9. Custom placement-filter callbacks. When the built-in StandardPlacement axes don't fit a placement rule, plug a host-language predicate via placement_filter_from_fn(closure) (TS / Python / Go) or implement PlacementFilter directly + register via global_placement_filter_registry() (Rust). Pair with StandardPlacement::with_custom_filter_id(id).
  10. 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 via abi_version_expected: 1.
  11. If you wired your own placement scoring around Mikoshi::select_migration_target or scheduler internals — the v0.13 path consults StandardPlacement with optional custom-filter callback. LegacyPlacement preserves v0.12 behavior under a feature flag for one minor version; new code should target StandardPlacement.
  12. If you have caches keyed off the old CapabilitySet shape 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 via set_hardware(hw) etc. produces the canonical tag set; subsequent views().hardware() reads back identically.
  13. Go consumers — origin_hash widened to uint64. Callsites assigning daemon.OriginHash() (or Identity.OriginHash() / migration.OriginHash() / replica.RouteEvent() / fork.ParentOrigin() / standby.{ActiveOrigin, Promote}()) to a uint32 variable fail to compile. Drop the explicit cast (or convert to uint64); 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 now uint64; DaemonRuntime.{Stop, Snapshot, Deliver, StartMigration, ExpectMigration, MigrationPhase} parameters and OpenTasks / OpenMemories / NewForkGroup's originHash / parentOrigin take uint64.
  14. Streaming RPC consumers wanting cancellation during construction — switch from net_rpc_call_streaming / net_rpc_call_streaming_with_headers to the new *_cancellable variants and pass a cancel_token from net_rpc_reserve_cancel_token. A parallel net_rpc_cancel_call(token) now aborts the construction block_on (peer-stalled initial-frame ACK), where pre-fix net_rpc_stream_close only took effect after the stream handle was already constructed. Existing non-cancellable variants kept for back-compat.
v0.12.0Codename:Firestarter
2026.05.06

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::QueueGroup on the channel roster (adapter/net/channel/roster.rs) — the one missing channel-layer primitive. Work-distribution dispatch alongside the existing Broadcast mode. add_with_mode / dispatch_recipients / subscriber_mode API; back-compat shims preserve every existing call site. MembershipMsg::Subscribe.queue_group: Option<String> wire field added at channel/membership.rs with forward-compat decode (pre-queue-group senders with zero remaining bytes after the token decode as Broadcast). Public APIs Mesh::subscribe_channel_in_queue_group[_with_token]. Pinned by 13 regression tests; cross-validated end-to-end by tests/queue_group_dispatch.rs (two QueueGroup subscribers 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::rpc codec (adapter/net/cortex/rpc.rs) — dispatch constants DISPATCH_RPC_REQUEST / RESPONSE / CANCEL / STREAM_GRANT / STREAM_CHUNK_DROPPED, flag bits (FLAG_RPC_STREAMING_RESPONSE, FLAG_RPC_PROPAGATE_TRACE), RpcStatus enum (Net-native with documented gRPC equivalence), RpcRequestPayload / RpcResponsePayload round-trip codec with MAX_RPC_* caps and encoded_len() helpers for buffer pre-sizing. 15 regression tests pin wire stability + decode-rejection of malformed payloads.
  • RpcServerFoldRedexFold<()> decoding REQUEST events, dispatching the handler in tokio, emitting RESPONSE via a RpcResponseEmitter callback. RpcCancellationToken (Notify+AtomicBool wrapper, race-safe), RpcContext (caller_origin + decoded payload + cancellation), RpcHandler async-trait, RpcHandlerError::{Application, Internal}. Handler panic caught via catch_unwind and surfaced as RpcStatus::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-flight call_id is refused; first-wins semantics. Per-channel-hash inbound dispatch hook on MeshNode (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-shard inbound queue.
  • RpcClientFold + RpcClientPending — symmetric caller side. RpcClientPending::register(call_id) returns a oneshot receiver for unary calls; register_streaming(call_id) returns an mpsc receiver of StreamItem for streaming calls (the same RpcClientFold demuxes both call kinds via a PendingEntry::{Unary | Streaming} enum). Re-register of the same call_id closes 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_rpc registers an inbound dispatcher for <service>.requests's channel hash; the dispatcher pushes events into a tokio mpsc that drains through the RpcServerFold. call lazy-subscribes to <service>.replies.<caller_origin>, allocates a call_id, registers a oneshot in the per-Mesh RpcClientPending, direct-sends the REQUEST via publish_to_peer bypassing the local subscriber roster (RPC's caller-knows-target model doesn't fit the publisher-led pub/sub roster), and awaits the receiver under opts.deadline. Returns RpcReply on Ok, RpcError on any failure. ServeHandle is RAII — the dispatcher unregisters on Drop and in-flight handlers complete (no abort). Per-Mesh state additions on MeshNode: 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 as Internal, deadline emits CANCEL and surfaces as Timeout to 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_rpc auto-registers the service in a per-Mesh rpc_local_services set; announce_capabilities[_with] auto-merges nrpc:<service> tags onto the announced CapabilitySet, 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 the nrpc:<service> tag; Mesh::call_service(service, payload, opts) -> Result<RpcReply, RpcError> finds candidates, picks one per RoutingPolicy, dispatches via the existing direct-addressed call(target, ...). Returns RpcError::NoRoute if no servers advertise the tag. ServeHandle::Drop removes the service from the local registry so subsequent announcements stop emitting the tag.
  • RoutingPolicy enum on CallOptions (default RoundRobin): RoundRobin uses a dedicated per-Mesh cursor with fetch_add (no longer collides with the call-id counter); Random (xxh3 of call_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 smallest latency_us per the local ProximityGraph; deterministic fallback to the lexicographically-first sorted node id when no proximity data exists).
  • filter_unhealthy: bool on CallOptions (default true) — skips candidates whose ProximityGraph entry 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 bridgeMeshNode::entity_id_for_node(u64) -> Option<[u8; 32]> accessor consults peer_entity_ids to map session-layer node ids to entity-layer keys. The single missing piece that LowestLatency and filter_unhealthy both flow through.
  • End-to-end coverage (tests/integration_nrpc_service_discovery.rs, 6 tests): three nodes, two serve "echo", one caller uses call_service — both servers exercised by round-robin; Sticky pins consistency; Random distributes evenly; no-servers returns NoRoute with diagnostic; LowestLatency falls back deterministically when no proximity data exists; filter_unhealthy keeps proximity-less candidates.

Streaming, Tracing, Resilience, Metrics

The biggest single chunk of new surface in this release.

  • Streaming responses. Multi-fire DISPATCH_RPC_RESPONSE events for one call_id marked non-terminal vs. terminal via the nrpc-streaming header (continue / end). RpcResponseSink (unbounded mpsc, non-blocking send), RpcStreamingHandler async-trait, and RpcServerStreamingFold (parallel to RpcServerFold but spawns a pump task draining the sink and emitting per-chunk nrpc-streaming: continue frames; handler return → terminal end frame, handler Err → terminal non-Ok frame, handler panic caught by catch_unwind → terminal Internal). Per-call ordering guarantee: the streaming fold takes an RpcAsyncResponseEmitter (Arc<dyn Fn(...) -> BoxFuture<()>>) instead of the unary fold's sync RpcResponseEmitter, 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 independent tokio::spawns and arrive at the caller out of order. Caller side: Mesh::call_streaming returns an RpcStream: futures::Stream<Item = Result<Bytes, RpcError>>; terminal-Ok closes the stream, terminal-error yields one final Err(RpcError::ServerError) then closes. RpcStream::Drop clears the pending entry and best-effort emits CANCEL via direct unicast so the server's handler observes ctx.cancellation.
  • Per-stream window grants (closes the Phase 3 streaming backlog). Wire additions: DISPATCH_RPC_STREAM_GRANT (caller → server, payload is 4-byte big-endian u32 credit count) + HEADER_NRPC_STREAM_WINDOW_INITIAL (REQUEST header, ASCII-decimal u32 initial window). Server side keeps a per-call Arc<tokio::sync::Semaphore> map; pump task acquire_owned().await + forget() per chunk. STREAM_GRANT events add_permits(n). Caller side: CallOptions::stream_window_initial: Option<u32>. RpcStream::poll_next auto-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's MAX_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_headers helpers). New CallOptions::trace_context: Option<TraceContext> and RpcContext::trace_context: Option<TraceContext> fields. When the caller sets CallOptions::trace_context, the SDK emits traceparent / tracestate headers and sets FLAG_RPC_PROPAGATE_TRACE; the server's fold extracts the headers and populates RpcContext::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.). Empty tracestate is omitted on the wire (W3C convention). Header-name matching is case-insensitive (W3C + HTTP convention); the previous implementation used name.as_str() == "traceparent" and silently dropped any non-lowercase variant.
  • Caller-side retry helper (sdk/src/mesh_rpc_resilience.rs). RetryPolicy with full-half jitter (each backoff scaled by uniform random in [0.5, 1.0]), exponential growth (backoff_multiplier, default 2.0), upper-bound cap (max_backoff), and a swappable retryable: Arc<dyn Fn(&RpcError) -> bool> predicate. Default policy: 3 attempts, 50ms initial → 1s cap. default_retryable retries Timeout, Transport, and ServerError for canonical transient statuses (Internal, Backpressure, server-observed Timeout); does NOT retry NoRoute, Codec, application errors, NotFound, Unauthorized, UnknownVersion, or Cancelled. Four wrappers on Mesh: 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 at t=0, additional hedges at t=delay*idx, first reply (Ok or Err) wins; if first finisher is Err, 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_to for explicit-target hedging (e.g. primary + warm-standby), call_service_with_hedge / call_service_typed_with_hedge for 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::Drop fires CANCEL to the server, which observes it on ctx.cancellation (pinned by hedge_loser_handler_observes_cancellation).
  • Caller-side circuit breaker. CircuitBreaker with CircuitBreakerConfig — three-state machine Closed → 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 via Arc<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-match Open to fall back, Inner to handle the underlying error. default_breaker_failure matches default_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 into into_inner() so the breaker keeps serving.
  • Unary-call CANCEL-on-drop. New UnaryCallGuard is constructed inside Mesh::call immediately after the REQUEST is published; if the call future is dropped before resolving (hedge loser, tokio::select! losing arm, caller-side JoinHandle::abort), the guard's Drop runs pending.cancel(call_id) AND spawns a CANCEL publish to the server via the new spawn_cancel_publish helper (shared with RpcStream::Drop). The success path flips guard.completed = true so a happy call doesn't fire a useless CANCEL.
  • Per-service metrics + Prometheus formatter (adapter/net/mesh_rpc_metrics.rs). RpcMetricsRegistry — per-Mesh DashMap<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 bumps in_flight on 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 carry nrpc:<kind>: <detail> where <kind> is one of no_route, timeout, server_error, transport, codec_encode, codec_decode, breaker_open. Each binding maps the prefix to typed exception classes via classifyError(e) (Node) / classify_error(e) (Python) / parseRpcError + typed *RpcError (Go). The Node binding throws plain Error with 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 range 0x8000..=0xFFFF. Re-exported from every binding alongside the typed surface. (The fixture initially used 0x4001 matching 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.)
  • ServeHandle lifecycle 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.SetFinalizer as a backstop. In every case "drop / close stops new dispatch but lets in-flight handlers complete" — same contract as the Rust serve_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 a bigint; pass on the call's options; call MeshRpc.cancelCall(token) from an AbortSignal listener). Abort fires CANCEL on the wire.
    • Python: Cancellable pyclass + RpcCancelledError. Pass via opts={'cancel': cancel}; cancel.cancel() from another thread aborts mid-flight.
    • Go: ctx.Done() watcher goroutine wired through net_rpc_reserve_cancel_token / net_rpc_cancel_call C-ABI exports. Watcher pins to the stream/call's lifetime so it doesn't leak past close. Watcher self-deadlock prevention via watcherDone channel closed before Close().
  • Per-handler timeout configurable everywhere. Each binding's serve accepts 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.ts and mesh_rpc.ts replace the hand-written errors.js / mesh_rpc.js + parallel .d.ts files. The .d.ts was the only guard on the public type contract — and reviews of the nRPC work surfaced several places where the two had quietly diverged (the RawMeshRpc shape, the breaker.armed dead branch, the appError helper signature). Compiling from a single TS source catches that class of drift at build time.
  • Pipeline. New tsconfig.build.json extends the existing test-only tsconfig.json; target: ES2022, module: CommonJS, moduleResolution: node, strict, declaration, noEmitOnError. outDir/rootDir both . so import paths don't change. package.json gains scripts.build:ts, scripts.typecheck, and a prepublishOnly that runs the TS build before napi 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-run produces the same 8 files as before. Existing require('@ai2070/net/errors').CortexError keeps working unchanged. index.js / index.d.ts stay JS forever — auto-generated by napi-rs from the Rust crate.
  • Test-stub conformance enforced. Turning RawMeshRpc from documentation into a real type forced StubRawMeshRpc, LoopbackHandlerRpc, and CancelTrackingRaw to drop their as unknown escape hatches and grow the missing methods. The compile error IS the win — the parallel .d.ts couldn't catch this.
  • Outcome. -210 LOC of duplicated .js/.d.ts content 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.json and asserts the same matrix. Fixture is versioned via abi_version_expected mirroring NET_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_grants asserts the server's streaming_chunks_emitted_total metric 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::dispatch namespace 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 range 0x10..=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::Subscribe gains an optional queue_group: Option<String> field (u8 length + UTF-8 bytes after the existing token field). Forward-compat: a v0.11 sender (zero remaining bytes after the token) decodes as Broadcast. A v0.12 sender that emits a queue_group to 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 use QueueGroup.
  • publish_to_peer now stamps channel_hash on the outgoing packet header (was always 0 pre-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-decimal u32 initial flow-control window) and the W3C tracing pair traceparent / tracestate (when FLAG_RPC_PROPAGATE_TRACE is 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

  • SubscriptionMode enum is new in adapter::net::channel::roster. Match arms over SubscriptionMode need to handle both variants; #[non_exhaustive] was added so this is forward-compatible.
  • MembershipMsg::Subscribe gains a public queue_group: Option<String> field. Struct-literal constructors must add it; the helper constructors (Subscribe::new, etc.) default to None so most call sites don't need updating.
  • Mesh::subscribe_channel_in_queue_group / Mesh::subscribe_channel_in_queue_group_with_token are new public methods on MeshNode and the SDK's Mesh envelope.
  • Mesh::serve_rpc / Mesh::call / Mesh::call_service / Mesh::find_service_nodes are new public methods on MeshNode. 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::rpc is a new public module re-exporting RpcContext, RpcHandler, RpcHandlerError, RpcRequestPayload, RpcResponseEmitter, RpcResponsePayload, RpcServerFold, RpcClientFold, RpcClientPending, RpcStatus, RpcStreamingHandler, RpcResponseSink, StreamItem, TraceContext, plus the dispatch + flag constants.
  • adapter::net::mesh_rpc is a new public module re-exporting RpcError, RpcReply, RpcStream, CallOptions, RoutingPolicy, ServeError, ServeHandle, CodecDirection, MAX_RPC_* constants.
  • adapter::net::mesh_rpc_metrics is a new public module re-exporting RpcMetricsRegistry, RpcMetricsSnapshot, ServiceMetrics, ServiceMetricsAtomic, CallOutcome, DEFAULT_LATENCY_BUCKETS_SECS. Snapshot via MeshNode::rpc_metrics_snapshot(); Prometheus formatter via RpcMetricsSnapshot::prometheus_text().
  • MeshNode::register_rpc_inbound(channel_hash, dispatcher) -> bool and MeshNode::unregister_rpc_inbound(channel_hash) are new public methods. The dispatcher is Arc<dyn Fn(StoredEvent) + Send + Sync>; registered channel hashes route directly and skip the per-shard inbound queue. register_rpc_inbound returns false if 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_rpc re-exports RpcError, RpcReply, CallOptions, RoutingPolicy, ServeHandle, RpcContext, RpcHandler, RpcHandlerError, RpcStatus, ServeError, Codec, RpcStreamTyped, ResponseSinkTyped, plus the NRPC_TYPED_* status constants.
  • New module mesh_rpc_resilience re-exports RetryPolicy, HedgePolicy, CircuitBreaker, CircuitBreakerConfig, BreakerError, BreakerState, plus default_retryable / default_breaker_failure predicates.
  • New Mesh methods (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::Subscribe encoder emits no trailing bytes when queue_group: None. Tests that decoded a v0.11 Subscribe and asserted "trailing zero byte" will fail — the encoder no longer writes the length byte on None. 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::call dropped before resolution emits CANCEL on the wire. Tests that asserted the server-side handler ran to completion despite caller drop will see ctx.cancellation fire.
  • Server-side fold emits RpcStatus::Cancelled on CANCEL observation. Tests that asserted "deadline + cancel surfaces as Timeout" will see Cancelled if CANCEL beat the deadline timer; the deadline path still surfaces Timeout (no behavior change for the deadline-only case).
  • extract_trace_context is 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_session matches both publish-side and send-side error strings. call_service failure to a peer whose session expired between discovery and dispatch now surfaces RpcError::NoRoute instead of RpcError::Transport.
  • ChannelConfigRegistry prefix-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

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.12 line. Recompile.
  2. 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 SubscriptionMode and MembershipMsg.queue_group fields are additive.
  3. For consumers that want nRPC — the typed surface is opt-in. Read net/crates/net/README.md#nrpc for the cross-binding contract, then per-binding READMEs for language-idiomatic usage:
    • Rust SDKnet/crates/net/sdk/README.md § nRPC. Feature-gated on cortex (already enabled by the local and full umbrella features).
    • Nodenet/crates/net/sdk-ts/README.md § nRPC. Import from @ai2070/net/mesh_rpc.
    • Pythonnet/crates/net/sdk-py/README.md § nRPC + net/crates/net/bindings/python/README.md § nRPC. Import from net.mesh_rpc.
    • Gonet/crates/net/include/README.md § nRPC for the C-ABI surface. Reference cgo wrapper at bindings/go/net/mesh_rpc.go.
  4. 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. QueueGroup subscriptions silently degrade to broadcast fan-out when crossing into a v0.11 receiver — same recommendation.
  5. Node consumers depending on the hand-written mesh_rpc.js / errors.js shape — module exports and require() resolution are unchanged. If your test harness used as unknown casts to satisfy RawMeshRpc against 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.
  6. 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 via abi_version_expected mirroring NET_RPC_ABI_VERSION = 0x0001.
  7. Go consumers — the libnet_rpc cdylib is a separate build artifact from the existing libcompute_ffi. Build with cargo build --release -p net-rpc-ffi and link both. ABI version drift is detected via net_rpc_abi_version() vs the consumer's compiled-in ExpectedABIVersion.
  8. 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 ## nRPC section.
  9. If you wired your own metrics around the existing channel publish path for RPC-shaped trafficMeshNode::rpc_metrics_snapshot() + RpcMetricsSnapshot::prometheus_text() ships a complete per-service counter set (caller-side nrpc_calls_total / nrpc_errors_total{kind} / nrpc_in_flight_calls / nrpc_call_latency_seconds_* + server-side nrpc_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.0Codename:Black Diamond
2026.05.05

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_seq advanced past unfolded events — under Stop policy, recoverable_decode could 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 on Ok(()) AND only when seq is the immediate successor of the previous applied) and folded_through_seq (live-progress, retained for low-latency observers). snapshot() writes applied_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_seq for 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 headercompute_checksum(tail) was xxh3 over only the payload tail; a stray bit-flip in the 20-byte EventMeta header (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 new compute_checksum_with_meta(&meta, tail) covers both the header (with the checksum slot 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 expects xxh3(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 used std::fs::rename, which on Windows is MoveFileExW(MOVEFILE_REPLACE_EXISTING) with no write-through — the destination metadata could be cached and lost on power-loss. Now driven through a durable_rename helper that calls MoveFileExW with MOVEFILE_WRITE_THROUGH on Windows; POSIX is unchanged (fs::rename is durable as long as the directory is fsync'd, which the surrounding code already does).

  • Cross-file atomicity via manifest-pointer layout. The old compact_to did three sequential renames (idx, dat, ts). A crash between rename N and N+1 left the on-disk channel in a mixed state (idx at gen K+1 paired with dat/ts still 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_to writes the new generation's files in full, fsyncs them, then durable_rename(manifest.tmp → manifest) is the single linearizing event. Before the rename, recovery sees the old manifest and uses v<N>/. After it, recovery sees the new manifest and uses v<N+1>/. There is no mixed state — every generation directory is either complete or orphaned, never partially live. Recovery falls back to the highest validated v<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 as Err, 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 in BUG_AUDIT_2026_05_03_REMAINING_PLAN.md's long-term-follow-up section, plus mid-rename partial-migration recovery, fault-injected fsync_dir failure handling, and source-shape guards against the deleted post-rename-reopen failure mode drifting back. Design recorded in docs/misc/REDEX_MANIFEST_POINTER_DESIGN.md.

Compute registry quiescence

  • In-flight Arc<Mutex<DaemonHost>> callers mutated through swap and unregisterreplace and unregister rotated the registry's Arc slot but a concurrent caller that had already cloned the prior Arc out of the map (between get_arc and arc.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 a guard_identity(origin_hash, &held_arc) helper that runs after arc.lock() and re-checks Arc::ptr_eq against the current registry slot. On mismatch the helper surfaces a typed DaemonError::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::HandleGuard extracted with try_enter() -> Option<HandleOp<'_>> and begin_free(deadline) -> bool. Packed atomics (freeing: AtomicBool, active_ops: AtomicU32); SeqCst-ordered Dekker-style "set freeing, check active_ops" handshake; per-handle FFI_HANDLE_FREE_DEADLINE: Duration = 5s. Soundness rule: the handle box is never deallocated once handed to C — _free takes the inner out via ManuallyDrop::take only after begin_free returns true, and the outer Box (carrying HandleGuard's atomics) is intentionally leaked. Concurrent ops doing try_enter after free safely fetch_add on still-valid memory, observe freeing=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 the Arc::ptr_eq UAF in handles_match that audit #25 specifically called out); IdentityHandle, RedisDedupHandle. Every entry point gates on try_enter; every _free drives begin_free. _free is idempotent — a second/concurrent _free caller 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-_free op returns ShuttingDown, _free is idempotent under concurrent callers, _free waits for an in-flight op to drain (or timeouts and leaks rather than UAF). Plus five tests on the HandleGuard helper itself (try_enter, post-free bail, drain-wait, drain-timeout, idempotent concurrent free).

Identity & envelope

  • IdentityEnvelope wire format gains a 1-byte version prefix. Pre-fix the AEAD open() path tried v1, and on failure retried v0 — the documented rolling-upgrade fallback. The new layout puts a single IDENTITY_ENVELOPE_VERSION = 1 byte at offset 0; readers reject any other byte via EnvelopeError::UnknownVersion and 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_SIZE 208 → 209; SNAPSHOT_VERSION 1 → 2.
  • origin_hash widened from u32 to u64 across the application layer. Pre-fix EntityKeypair::origin_hash() returned a 32-bit BLAKE2s projection; with ~65 K distinct daemon identities the birthday probability of two daemons aliasing the same origin_hash crossed 50 %, and cross-channel accounting keyed by origin_hash silently 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-packet NetHeader.origin_hash deliberately stays u32 — that field is the routing fast path's pre-AEAD filter and width matters for cache-line packing; the with_origin(u64) setter downcasts to the routing-side projection. Wire-format constants: CAUSAL_LINK_SIZE 28 → 32, EVENT_META_SIZE 20 → 24, CONTINUITY_PROOF_SIZE 36 → 40.
  • The widening cascade flowed through the SDK, the Node binding (u64 → JS bigint, matching the existing node_id convention), the Python binding (pyo3 maps u64 to native int transparently), and the Go binding (uint32_tuint64_t in include/net.go.h).

Compute orchestrator & merge

  • on_replay_complete synthesized target_head with parent_hash: 0 — downstream verifiers couldn't reconcile a chain head whose parent was the literal zero hash; reconciliation surfaced Forked against legitimate replay-completion messages. Now queries daemon_registry.with_host(...) for the real chain head and stamps the actual parent hash. The audit's separate report against consumer/merge.rs:384 (per-shard cap rolling the cursor backward on unclamped_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_loop held a DashMap shard guard across .await — the heartbeat broadcast loop iterated peers.iter() and awaited socket.send_to(...) (heartbeat then pingwave, twice per peer) while still holding the iterator's Ref. Every other task touching the same shard blocked for the cumulative round-trip. Now snapshots (node_id, addr, Arc<NetSession>) tuples into a Vec first and awaits without the iterator alive.
  • accept / start mutual exclusion used AcqRel where the comment relied on SeqCst — the doc-comment argued correctness from "the SeqCst total order on these two atomics," but the accept_in_flight.fetch_add(1, AcqRel) and the matching fetch_sub in AcceptGuard::drop were 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 race handshake_responder for the inbound msg1. Both increments now SeqCst.
  • 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 same peer_node_id fell through and peers.insert overwrote 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_handshake peers.getpeers.insert was not atomic — two concurrent routed handshakes for the same peer_node_id (e.g. a flaky peer retrying under a fresh ephemeral) could both pass the replay-guard existing.remote_static_pub check and race the insert; the loser's pending_handshakes initiator state stayed armed waiting for a msg2 now bound to the winner's session, until handshake_timeout fired. Decision and insert now hold a single peers.entry(peer_node_id) write guard.
  • commit_reclassify_observations torn (nat_class, reflex_addr) snapshot — when every probe failed, latest_reflex == None. The code still updated nat_class (typically to Unknown) but left reflex_addr at its previous value; subsequent announce_capabilities_with reads under traversal_publish_mu saw (fresh class, stale reflex). The whole traversal_publish_mu invariant was silently violated on this branch. reflex_addr is now reset to None when latest_reflex is None, keeping the pair coherent.
  • authorize_subscribe rejected idempotent re-subscribes with TooManyChannels — a peer at the channel cap that retransmitted/re-subscribed to a channel it already held was rejected even though SubscriberRoster is 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_peer did not propagate the reliable flag to the packet header — every other sender (send_to_peer, send_routed, send_on_stream, mod.rs:1016/1063) computed if reliable { PacketFlags::RELIABLE } else { PacketFlags::NONE } and threaded it into the packet builder; publish_to_peer hard-coded PacketFlags::NONE and only fed reliable into open_stream_with. Latent today (the dispatch path doesn't yet inspect flags.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.rs already inspect is_priority / is_control. Same ternary as the other senders now applied.
  • process_local_packet migration loopback unbounded synchronous self-bounce — the in-place pending: VecDeque kept 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_via did not refresh addr_to_node after a successful direct upgrade — after connect_direct → connect_via(peer_reflex, …) succeeded, the upgraded session's dispatch fast path missed on peer_reflex and fell back to a linear peers.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. The connect_direct Ok path now inserts the (peer_reflex, peer_node_id) mapping; the relayed-session note in connect_via itself 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-atomic RateBucket carrying (window_floor: u32, count: u32) in a single AtomicU64; CAS-based atomic reset on window rollover, no clear-and-reinsert race, no stale-count window. gc_per_source_stale now sweeps stale entries based on observed window age rather than stomping the live state. try_acquire computes its Ok value from the CAS prev, not a racy reload — avoids a second lost-update window.

Cluster F triage (lower-severity items)

  • #81 adapter/redis.rs pipeline timeout duplicate hazard — config-deployment-shape issue; closed with a one-time-per-process tracing::warn! from RedisAdapter::init pointing at net_sdk::RedisStreamDedup so misconfigured deployments are surfaced at boot rather than as silent duplicate publishes under retry.
  • #125 behavior/safety.rs per-source RPM cap — closed via the packed-atomic RateBucket rework 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.rs notify_one + permit-stash soundness — re-triaged as obsolete; the notify-with-stashed-permit pattern is sound vs notify_waiters for 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.rs per-shard cap rolling cursor backward — re-triaged as obsolete; current code advances. Pinned by poll_merger_does_not_stall_on_single_shard_filter_under_cap.
  • #118 behavior/rules.rs rate-limit reset semantics — re-triaged as obsolete; the current reset to 1 is the correct semantic (the audit's reset to 0 would allow max+1 firings per window).
  • #121 behavior/loadbalance.rs P2C with len == 2 — re-triaged as obsolete; the degenerate case IS the P2C algorithm with 2 inputs.

Test hygiene

  • HandleGuard race 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-free ShuttingDown, idempotent _free, _free waits for in-flight op).
  • Cortex applied_through_seq strict-prefix — five regression tests pinning the watermark advances only on Ok(())-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_meta v2 coverage — pins that v2 detects bit-flips in dispatch, 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::Stale quiescing — five regression tests pinning that an in-flight mutator holding a now-orphan Arc surfaces DaemonError::Stale(u32) instead of mutating; that replace and unregister both trip the check; that the surviving in-flight Arc and the fresh registration don't produce two parallel writers.
  • durable_rename Windows behavior — three regression tests pinning the MoveFileExW(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 = 1 surface EnvelopeError::UnknownVersion and never reach the AEAD path.
  • Mesh-audit regression coverage — the heartbeat snapshot, accept/start SeqCst, routed-handshake atomic entry, NAT class/reflex coherence, idempotent re-subscribe, reliable flag propagation, loopback depth cap, and addr_to_node direct-upgrade refresh each carry a pinned regression test in tests/mesh_audit.rs.
  • JetStream msg-id sequence_start per-shard monotonicity — pins that within one bus instance, every shard's batches advance their sequence_start strictly 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 by bus::tests::sequence_start_is_per_shard_monotonic_and_gap_free. The cross-restart variant (persistent next_sequence across process boots) remains feature-shaped and is not in this release; today's invariant relies on process_nonce rotating 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_grant clamp ordering — the audit recommended reordering the tx_bytes_sent bump and the tx_credit_remaining decrement. The current form uses a CAS-with-delta against max_consumed_seen and adds the delta to tx_credit_remaining via fetch_update; this composes atomically with the CAS in try_acquire_tx_credit and the fetch_update in refund_tx_credit. The audit's reorder presumed a .store()-based recompute from a racy snapshot of tx_bytes_sent — a shape the current code deliberately avoids. The rationale is documented in code at adapter/net/session.rs::apply_authoritative_grant and the codec-side abstract at adapter/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: u32u64

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_hash types widen to u64 at every public API point listed above. The as u32 downcast 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 over DaemonError need 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]) -> u32 is a new public function. compute_checksum(tail: &[u8]) -> u32 remains and is now described as the v1 fallback used only on the read side; new writers must use compute_checksum_with_meta. Both are re-exported from adapter::net::cortex.
  • IDENTITY_ENVELOPE_VERSION: u8 = 1 is a new public constant re-exported from adapter::net::identity. Pin against this instead of literal 1 so a future bump auto-propagates.
  • CortexAdapter splits the watermark. applied_through_seq is the new strict-prefix watermark used by snapshot(); folded_through_seq is the live-progress watermark used by wait_for_seq. Existing snapshot consumers that read last_seq get the strict-prefix value automatically; tests asserting that wait_for_seq(seq) implied state was applied for seq need to be re-read against the new semantic (wait_for_seq indicates fold attempted; restore re-attempts skipped events).
  • HandleGuard is a new public module under ffi::handle_guard (pub mod handle_guard). Custom FFI wrappers built against the crate (rare — most consumers use the bundled bindings) need to embed HandleGuard and route every entry point through try_enter / begin_free to keep the same memory-safety guarantees the bundled bindings now have.

Rust SDK (net-sdk)

  • All origin_hash parameters and fields widen to u64. Identity::origin_hash() -> u64. DaemonHandle.origin_hash: u64. MigrationHandle.origin_hash: u64. Closures move |origin_hash: u64| in PostRestoreCallback, 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. The groups/{fork,replica,standby} surface widens parent_origin / active_origin / route_event return types. group_id in groups/replica deliberately stays u32 — that's a group_seed hash, distinct from origin_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 the as u32 projection 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 u64int 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_seq reflects applied_through_seq, not folded_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 state was 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 on snapshot()'s rustdoc.
  • DaemonRegistry::replace / unregister followed by an in-flight mutator returns DaemonError::Stale(u32) — tests that asserted the mutation landed on the orphan host will see the typed error instead.
  • FFI _free is idempotent and returns success on second-call — tests that asserted second-call returned an error code will see success.
  • FFI entry points after _free return ShuttingDown — 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 to compute_checksum_with_meta(&meta, tail). Two pinned tests under tests/integration_cortex_{tasks,memories}.rs already had this issue and were updated.
  • IdentityEnvelope::open rejects v0 envelopes outright — tests that asserted the v0 fallback path engaged will fail. The open_accepts_v0_envelope_for_rolling_upgrade_compat fixture from v0.10 has been removed (it explicitly pinned the now-removed fallback); the new equivalent pins EnvelopeError::UnknownVersion on a leading-byte mismatch.
  • Mesh accept / start use SeqCst on accept_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_subscribe short-circuits idempotent re-subscribes ahead of the cap-check — tests that asserted at-cap re-subscribe surfaced TooManyChannels will 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

  1. 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.
  2. 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_hash at bytes [4..8], seq_or_ts at [8..16], checksum at [16..20]) does not match v0.11's (origin_hash at [4..12], seq_or_ts at [12..20], checksum at [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.
  3. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.11 line. Recompile. The Rust signature changes (u32u64 on origin_hash, DaemonError::Stale variant, applied_through_seq watermark) will surface as compile errors at the exact call sites that need updating.
  4. JS / TypeScript callers: switch originHash literals to BigInt. 0xabcdef010xabcdef01n. The TypeScript surface declares originHash: bigint; existing call sites using Number will fail at runtime against the new declarations.
  5. Go callers: widen uint32 locals to uint64 for every origin_hash parameter, return value, or struct field. The cgo header (include/net.go.h) reflects the new ABI.
  6. Python callers need no source changesint is arbitrary precision and PyO3 handles the marshalling transparently. Re-test fixtures that round-trip an origin_hash through external storage (databases, message queues) to confirm the upper 32 bits are preserved.
  7. C callers: widen uint32_t typed pointers to uint64_t for every origin_hash parameter and out-param. Anyone hand-rolling against include/net.go.h must regenerate their bindings.
  8. If your tests covered any of the items in Behavioral fixes that may surface as test breakage, update the assertions. The cortex applied_through_seq semantic and the v2 checksum migration each have a one-line fix at the assertion site; the v0 envelope removal requires deleting the fixture entirely.
  9. RedEX on-disk layout has changed. Each channel now stores its files under <channel>/v0000000001/{idx,dat,ts} plus a 16-byte <channel>/manifest pointer 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 the RedexFile API) need to follow the manifest to the live generation directory.
  10. If you embed FFI handles in a custom Rust wrapper (rare), embed HandleGuard from the new ffi::handle_guard module and route every entry point through try_enter / begin_free. The recipe matches the bundled handles' implementation; the helper module's tests double as documentation.
v0.10.0Codename:Hex
2026.05.03

Addressed in this release

RedEX & CortEX (storage + folded state)

  • Compact temp-file leak on reopen failurecompact_to's cleanup path ran after the post-rename open_or_poison / clone_or_poison fallibles, so a reopen failure left three placeholder files behind in /tmp forever. Cleanup now runs before the fallible reopen.
  • Truncate-on-recovery without sync_all — torn-tail repair set_len was not durable; a crash before the next write reverted the recovery and the same torn bytes were re-read. Now sync_all + fsync_dir after the truncate.
  • Best-effort rollback silently swallowed open errorsif 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 as RedexError.
  • In-memory index corruption on panic between drain and renormalizesweep_retention could leave rebased base_offset against absolute payload offsets if it panicked mid-rewrite. Now builds the renormalized index in a temp Vec and atomically replaces.
  • saturating_sub(dat_base) as u32 masks heap corruption — silently wrote offset 0 for stale heap entries. Now hardened so the cast never silently squashes a real offset error.
  • next_seq rollback skipped if disk is None — currently safe path; documented and pinned by an invariant comment.
  • Stale watermark advances past unfolded events under Stop policyrecoverable_decode published folded_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_seq for skipped events — when the watermark fix above lands, snapshot() no longer emits a last_seq for events whose state was never applied; the log remains the source of truth on restore.
  • Cortex WatermarkingFold saturates app_seq at u64::MAX — a peer publishing seq_or_ts == u64::MAX could pin our app_seq; the next fetch_add(1) panicked in debug or wrapped in release, breaking per-origin monotonicity. Inputs are now capped at u64::MAX - 1.
  • Memories upsert was asymmetric and tombstone-less — existing-id STORED partial-updated, missing-id inserted with pinned: false, and a STORED → DELETED → STORED sequence resurrected the deleted entry. Now consistent and tombstone-aware.
  • Memories empty-vec filter footgunSome(vec![]) for require_any_tag excluded everything (any over empty = false); Some(vec![]) for require_all_tags excluded nothing (all over 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::Serialize round-trips bytes through Value — re-encoding through serde_json::Value discarded original whitespace, normalized number formatting (1.01), 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_internal awaited 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 (no tokio::time::timeout shell on this path). Order swapped to drain → batch and the same timeout the rollback path uses now wraps the await.
  • add_shard_internal rollback dispatched stranded batch with stale next_sequence after 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_up cooldown loop invariant violated whenever cooldown > 0 — each iteration bumped last_scaling = Instant::now(); iteration 1 immediately failed InCooldown (default 30 s), leaving the first shard half-added. Operator-initiated scale-up now bypasses the auto-scaling cooldown via a dedicated scale_up_provisioning_force path.
  • Scaling monitor and manual_scale_down raced finalize_draining — non-target qualifying Draining shards were silently transitioned to Stopped, dropped on the floor by the target.contains(&shard_id) filter, and leaked. Non-target ids are now still routed through remove_shard_internal.
  • flush() Phase 2 barrier satisfied by post-flush trafficdispatched was a running counter, not a snapshot; with asymmetric per-shard latency the inequality could be satisfied while pre-flush events were still queued. Now snapshots dispatched + dropped at flush entry and gates on the delta.
  • shutdown() deadline path double-counted in-flight eventsevents_dropped += in_flight_ingests then the final two-pass sweep also drained those events into events_dispatched, violating events_ingested = events_dispatched + events_dropped on every deadline-triggered shutdown. Now subtracts the events the final sweep drained.
  • Drop did not surface stranded ring-buffer events — bus dropped without await shutdown() lost ring contents but never bumped events_dropped or set shutdown_was_lossy. Operators reading post-mortem stats saw no record of the loss. Now snapshots shard_stats() in Drop.
  • PollMerger topology swap had a lost-update race — concurrent add_shard_internal / remove_shard_internal could each read shard_ids() and serialize their store(...) in the wrong order, leaving the published merger view including a removed shard until the next topology change. The shard_ids() → store block is now serialized.
  • PollMerger::poll lost cursor context on stalled pollnext_id was None when no shards made progress, even with a valid request.from_id. Callers re-fetched from zero — silent pagination regression. Now echoes back the original from_id.
  • mapper.activate active_count.fetch_add outside the held write lock — three concurrent activates could pass the budget gate against a stale count and transiently overshoot max_shards. Increment moved before drop(shards).
  • mapper.finalize_draining read pushes_since_drain_start with Relaxed — the field's docstring required Acquire to pair with the writer's SeqCst reset. Now matches.
  • JoinHandle errors silently dropped in shutdownlet _ = futures::future::join_all(drains).await; ate panicked drain workers (default Tokio doesn't log task panics). Now captured and surfaced via events_dropped.
  • shutdown_via_ref and in-flight wait loops thrashed the runtime — bare tokio::task::yield_now re-queued the task without parking; tight loops under contention starved the workers they were waiting on. Switched to short tokio::time::sleep.
  • flush() held a sync parking_lot::Mutex inside async fn — replaced with the async-safe variant.
  • JSON cursor key "00" parsed to 0 — collided with shard 0 across rebuilds. Cursor codec now treats string keys as opaque.
  • std::time::Instant mixed with tokio time in shutdown — wall-clock 5s broke tokio::time::pause()-based tests. Now consistent.
  • Drain worker mem::replace/send ordering — swapped scratch before the awaited sender.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 use delta_as_nanos(0, raw) consistently.
  • TimestampGenerator::next re-reads raw inside the CAS loop — pre-fix now was read once outside the loop; on contention, retries reused the stale now and the returned timestamp drifted as last + 1 arbitrarily far behind real time.
  • shard/batch.rs current_batch_size * 3 + target overflow — debug panic / release wrap on adversarial config. BatchConfig::validate now bounds max_size <= 1_000_000.
  • shard/batch.rs velocity-window Instant - Duration underflow — Windows Instant is QPC-relative-to-boot; immediately-after-boot processes aborted the batch worker. Now checked_sub.
  • f64 → usize as cast in batch — added clamp first.
  • shard/mapper.rs next_shard_id.store(first_id + count)checked_add on the bump path.
  • shard/mapper.rs overloaded_count used 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_reset latency-sum/count desync — two independent fetch_adds vs two independent swaps let avg_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 single u128 and CAS'd together. Same fix applied to push_latency_sum_ns / push_count.

Adapters (JetStream / Redis / dedup)

  • JetStream Other PublishErrorKind classified 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 treats Other as fatal.
  • JetStream "pipelined" publish was actually serial — loop awaited publish_with_headers per 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::Value allocation — violated the per-event no-alloc contract. Now mirrors Redis's RawValue borrow + Bytes::copy_from_slice.
  • JetStream one RTT per sequence in steady statedirect_get(seq) per sequence on a 1 ms RTT cost ≥100 ms wall for a 100-event poll. Now direct_batch_get.
  • JetStream cold-stream bail enabled on transient info() failure — fallback fabricated first_seq = 0, enabling the cold-stream bail; populated streams returning NotFound in deletion gaps bailed after 64 NotFounds with events still ahead. Now propagates Transient.
  • JetStream Fatal decode discarded already-decoded prefix — function returned immediately, dropping the events accumulated so far without advancing the cursor; recovery re-emitted the prefix. Now returns Ok on the good prefix and surfaces the corruption on the next poll.
  • JetStream shutdown retained self.jetstream / self.client — post-shutdown on_batch proceeded 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, and Nats-Msg-Id deduped only within the dedup window. Documented and pinned; retry path now wraps publish_with_headers in tokio::time::timeout to bound the cancellation surface.
  • JetStream missing r field stored b"null" — could surprise downstream consumers expecting either present-or-absent. Now passes through unchanged.
  • Redis cluster errors classified as fatalMOVED / ASK / READONLY / CLUSTERDOWN / NOREPLICAS were not in the substring set; after any Redis Cluster failover, every batch failed permanently until process restart. Added.
  • Redis is_healthy PING timeout cancellation — wrapped in command_timeout, with a dedicated health-check connection so a desynced ConnectionManager doesn't serve a stale PING reply on the next real command.
  • Redis poll_shard XRANGE had no command_timeout wrapperon_batch and is_healthy honored the timeout contract; poll_shard could block indefinitely. Now wrapped.
  • Redis shutdown didn't drop self.conn — pure advisory flag; get_conn ignored initialized = false. on_batch could write to Redis silently after shutdown. Connection now dropped, get_conn errors with Fatal when the adapter has shut down.
  • RedisStreamDedup 4096-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_state startup nonce non-cryptographicxxh3_64 of (pid, tid, ns, stack_addr, ...) narrowed entropy on 32-bit targets. Now mixes a /dev/urandom seed.
  • limit + 1 overflow (Redis & JetStream poll request shaping) on adversarial limits — saturating_add(1).

Mesh transport, sessions, routing

  • handle_routed_handshake Case 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 same remote_static_pub, and the HandshakePacer from the legacy adapter has been added.
  • Pingwave strict_progress permitted address-poisoning via the hops < n.hops arm — an attacker who had observed pingwaves could spoof (origin_id=Y, seq=K, hop_count=0) for K < n.last_seq and overwrite n.addr to their UDP source. The conditions are now AND'd: pw.seq >= n.last_seq AND hops <= n.hops.
  • ThreadLocalPool per-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. Now Drop walks every thread's LOCAL_BUILDERS to evict its pool_id slot.
  • MAX_PACKET_POOL_SIZE = 1<<20 was OOM-on-first-sessionwith_local_capacity pre-allocated size × ~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 refusingMAX_FORWARD = 65_536, WINDOW_SIZE = 1024; a single authenticated jump in (1025, 65_536] zeroed the bitmap and left previously-seen counters in rx_counter - 1024 .. rx_counter replayable. The slide is now refused past WINDOW_SIZE; a fresh handshake is required.
  • Anti-replay received == u64::MAX — first authenticated packet at the boundary saturated rx_counter and rejected every subsequent counter; one hostile authenticated packet could permanently poison the receive path. Now rejected at is_valid.
  • TokenScope::contains(NONE) returned true unconditionally(self.bits & 0) == 0. Compounded with authorizes(NONE, ch) returning unconditional true, so any token authorized the no-op action; callers building action: TokenScope from external input where the input masked to NONE saw true for every token. Short-circuits at the top of contains.
  • route.rs tie-break used <= — doc said "preserved if strictly better." Now <.
  • router.rs route_packet had no source/loop suppression — TTL exhaustion was the only loop-breaker; add_route_with_metric flap or a malicious peer could set up a 2-hop loop. Now drops when routing_header.src_id == routing_table.local_id and inspects a small (src_id, stream_id, sequence) LRU.
  • router.rs RouterError::TtlExpired recheck after forward() double-counted — both record_in and record_drop ran. record_in deferred until after the post-decrement TTL check.
  • linux.rs BatchedTransport::send_batch silently truncated above 64len.min(MAX_BATCH_SIZE) returned ≤ 64 unconditionally; reliable streams stashed the rest via on_send and only learned via NACK/RTO. Now returns InvalidInput over the cap; chunked-internally is a follow-up.
  • linux.rs iov_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.rs handshake retry sleep had no upper bound100 * attempt over MAX_HANDSHAKE_RETRIES = 1024 summed to ~14 hours total with the last attempt sleeping ~102 s. Capped at 5 s per attempt.
  • mod.rs handshake recv loop allocated BytesMut::with_capacity(MAX_PACKET_SIZE) per iteration — allocator pressure under stray traffic. Buffer now reused across iterations.
  • session.rs evict_idle_streams LRU vs concurrent open racemin_by_key then remove was non-atomic; a freshly-opened stream could be torn down between selection and removal. Now uses remove_if with a freshness predicate.
  • session.rs verify_and_touch_heartbeat did not pre-check parsed.payload.len() == TAG_SIZE — AEAD caught the mismatch but a length check shortcuts cleartext-flood probes before they touch the cipher.
  • session.rs RxCreditState::on_bytes_consumed consumed/granted not jointly atomic — concurrent calls could publish consumed > granted transiently; observability/metrics showed flicker. Now packed u128 AcqRel CAS.
  • route.rs capability-announcement hop_count += 1 — every other hop-count increment in the crate uses saturating_add(1); this one was bounded today by the < MAX_CAPABILITY_HOPS - 1 = 15 guard but one constant change from a debug panic. Now matches the rest.
  • Static-mode select_shard_by_hash used raw modulo — dynamic-mode was already on Lemire's unbiased (hash * len) >> 64. Same bias, same fix; both paths now consistent.
  • gateway.rs ParentVisible over-permissive direction — predicate accepted both dest.is_ancestor_of(source) and source.is_ancestor_of(dest); the second clause leaked parent-region traffic down into descendants. Now strictly upward.
  • pool.rs (payload.len() - 16) as u16 truncation — currently safe under MAX_PAYLOAD_SIZE = 8112; debug_assert! added so a future cap-raise past u16::MAX + 16 doesn't silently mis-frame on the wire.
  • failure.rs unwrap() on poisoned std::sync::Mutex — the rest of the crate uses unwrap_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.rs RecoveryManager::on_failure overwrote FailedNodeState on insertfailed_at and retry_count reset to 0 each time; flapping peers never hit max_retries. Now entry().or_insert(...) and bumps retry_count.
  • failure.rs get_action returned Retry { 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.rs BatchedPacketReceiver thread spun at 1 ms on persistent socket errorsEBADF / ENOTSOCK / permission-revoke ate a CPU forever. Now exponential backoff with hard-error early return.
  • proxy.rs telemetry counters incremented before send succeeded — counters drifted high under partial failure. Now incremented on success.
  • proximity.rs update_from_pingwave worse 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 update hops/addr/latency_us when new_hops <= self.hops).
  • proximity.rs self-edge insert_or_update_edge per-pingwave — hot-path noise; skipped.

Compute, daemons, migration

  • start_migration always emitted a single SnapshotReady regardless of sizechunk_index: 0, total_chunks: 1 whether the snapshot was 12 B or 12 MB; the wire encoder rejected any chunk over MAX_SNAPSHOT_CHUNK_SIZE = 7000. Locally-initiated migration of any daemon whose serialized state exceeded 7 KB couldn't be sent. Now routes through chunk_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 every feed plus a public sweep_stale for external timers) close the at-cap-and-quiet residual hole.
  • abort_migration_with_reason did not propagate to MigrationSourceHandler — source-side migrations map retained the entry; is_migrating() stayed true, buffer_event kept buffering into an undrained vector, retries tripped AlreadyMigrating. Now dispatched.
  • standby_group replaced standby marked healthy with synced_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, and promote() candidates are filtered to last_sync.is_some().
  • migration_target::buffer_event had no phase guard — could insert/deliver post-cutover; combined with normal-path delivery yielded duplicate execution. Now guarded.
  • migration_source::start_snapshot was a contains_keyentry() race — two concurrent snapshots of the same origin could both call user-supplied MeshDaemon::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_events had no phase guard — misuse-prone. Now guarded.
  • migration_target::abort did not clear completed index — minor leak. Cleared.
  • orchestrator returned MigrationError::TargetUnavailable(0) from auto-placement — surfaced "target node 0x0 unavailable" to operators when no specific node had ever been tried. Now typed NoTargetAvailable (variant addition).
  • orchestrator::buffer_event returned false at Cutover — downstream caller could route to source post-handoff. Now correctly buffers through Cutover.
  • migration.rs started_at: u64 saturated on clock jump backward — switched to Instant.
  • fork_group forks.pop() and coord.remove_last() invariant unenforced — brittle. Now enforced.
  • bindings.rs Vec::with_capacity from peer-supplied u32 — declared count of ~4 B entries → ~96 GiB allocation before truncation. Now bounded by data.len() / MIN_BINDING_SIZE.
  • reconcile.rs unreachable!() reachable on signed but divergent input — equal-length-equal-payload tiebreak panicked on the chain's reconciliation thread. Now a deterministic tiebreak on parent_hash.
  • reliability.rs silent reliability drop — when pending.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.rs NetRouter::start had no re-entry guard — a second call spawned a competing dequeue loop. Now compare_exchange on running.
  • continuity/chain (0, Some(non-empty payload)) accepted as genesis-shaped — chain reported Forked against junk. Now Unverifiable.
  • state/log genesis-shaped event with un-validated payload — peer-injected attacker-chosen anchor. Now pinned to the canonical genesis payload.
  • contested/correlation capability-index parent walk loops forever — defensive depth cap (matches the 4-level hierarchy).
  • contested/observation unbounded HashMap + seq_diff_sum overflow — long chains accumulated forever. LRU + saturating_add.
  • contested/superposition target_replayed only advanced from SuperposedSpreading (target catches up before advance(Replay)) stalled forever; ReadyToCollapse never fired. Now both arms advance.
  • contested/propagation lossy f64 → u64 poisoned EWMA — a pathological RTT clamped per_hop to u64::MAX permanently. NaN check tightened.
  • contested/correlation Instant subtraction panickednow - correlation_window panicked if the window exceeded uptime. Now checked_sub.
  • partition.rs NaN >= threshold blocked healing — when other_side.is_empty() the ratio was NaN. Empty case now treated as "fully healed."
  • failure.rs RecoveryManager flapping peers (see Mesh transport, sessions, routing — the recovery and the failure detection both lived in this file).
  • identity/origin.rs origin_hash: u32 collision floor documented — ~65 K peer birthday collision; cross-channel accounting keyed by origin_hash aliases distinct entities. Documented as the boundary; the rename to origin_tag and the wire bump are deferred to the next phase.

Behavior, identity, security

  • safety.rs AuditOnly silently dropped violation logscheck_rate_limits only logged when mode == Enforce; the documented "log violations but don't block" stance simply didn't log. Now logs unconditionally; only the return Err is gated.
  • safety.rs Relaxed / AcqRel mismatchrelease paired against acquire's AcqRel; observable counter drift on weakly-ordered cores. Both sides now AcqRel.
  • safety.rs audit-only token counter fetch_add without saturating — wraps under hostile traffic. Now saturating.
  • loadbalance.rs NaN slipped past total_weight <= 0.0 — switched to !(total_weight > 0.0) which captures NaN.
  • token.rs slot-cap race unboundedcontains_key then entry() overshoot bounded by concurrent calls, not shards. Now entry().or_insert_with() then drop on overflow.
  • token.rs signed_payload() allocated 95 bytes per verify — hot-path waste. Now stack-buffered.
  • channel/roster is_empty()remove_if TOCTOU — idempotent today but fragile. Tightened.
  • channel/guard revoke() did not rebuild bloom — false-positive rate climbed until manual rebuild_bloom. Now triggers rebuild.
  • behavior/diff::to_bytes returned Vec::new() on cap-violation — indistinguishable from a legitimate empty diff; senders silently transmitted zero bytes, receiver dropped. Deprecated in favor of try_to_bytes.
  • crypto.rs ReplayWindow::commit — see Mesh transport, sessions, routing: received == u64::MAX poisoning fixed at is_valid instead of commit.

Bindings (Node, Python, Go, C) & FFI

  • net_poll buffer-too-small dropped already-consumed eventsbus.poll(request) advanced the cursor before the response was serialized; an undersized buffer returned BufferTooSmall and 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_ex allocation failure dropped the entire batchLayout::array::<NetEvent>(count) and std::alloc::alloc(layout) failures returned Unknown and dropped the response. Now pre-validates count against a max event-count.
  • Panic across FFI on OOM in net_poll_exevent.id.as_bytes().to_vec().into_boxed_slice() and event.raw.to_vec().into_boxed_slice() could panic mid-loop and leak earlier Box::into_raws plus the std::alloc::alloc(layout) array. Entry points now catch_unwind; panic = "abort" for the cdylib closes the residual.
  • slice::from_raw_parts(ptr, len) lacked len <= isize::MAX validation — a C caller passing sign-extended -1 triggered 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 the isize::MAX boundary.
  • net_generate_keypair / net_free_string feature-gated, header unconditional — consumers linking against a cdylib built without net got load-time missing-symbol errors despite the header promising the symbol. Stubs added.
  • net_free_poll_result not idempotent — frees events and next_id but 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_taken defense-in-depth claim was doc-only — doc said "FFI ops also check this," but the field was read only inside net_shutdown. Either gate or remove the doc; we gated.
  • Concurrent net_shutdown callers raced the bus_taken swap — a second/third caller returned Success while the first was still inside runtime.block_on(bus.shutdown()), falsely signaling completion. Now serialized.
  • runtime().block_on(...) panics unwound across extern "C"Handle::try_current() guard added at every cortex.rs and mesh.rs block_on site; catch_unwind shim added.
  • FFI handle accessors &*handle without alignment check — misaligned *mut NetHandle from C is immediate UB before the null check. is_aligned_to::<HandleType>() now precedes every dereference.
  • Arc<InnerType>-wrapped FFI handles lacked compile-time Send + Sync auditstatic_assertions::assert_impl_all!(InnerType: Send + Sync); placed next to each handle.
  • c_str_to_str lifetime elision dangled — signature unsafe fn c_str_to_str(p: &*const c_char) -> Option<&str> bound the returned &str to the local stack slot, not the underlying C buffer. Today's call sites are stack-only, but a future refactor moving the result into tokio::spawn(async move { ... }) would have compiled cleanly and dangled. Now unsafe fn c_str_to_str<'a>(p: *const c_char) -> Option<&'a str> with explicit lifetime.
  • net_ingest_raw_batch silently dropped null and invalid-UTF-8 entries — function returned count - 1 accepted; bindings attributed the drop to backpressure, retried the wrong indices, and double-published the good ones. Now surfaces dropped indices via out_failed_indices: *mut size_t, out_failed_len: *mut size_t.
  • parse_config_json silently fell back to DropNewest on unknown backpressure_mode"DropOldset" (typo) or "FailProduce" got a different durability profile with no error at deploy time. Now errors on unknown values; added the Sample { rate } arm with rate validation.
  • retention_max_* accepted zero, fsync params did notretention_max_events = 0 meant "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_ms and mesh heartbeat_ms accepted 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::new failure reported as InvalidUtf8 but caused by interior NUL — error variant retitled.
  • NetEvent / NetReceipt #[repr(C)] lacked cross-arch alignment pinning — const asserts on layout added.
  • TokioMutex held 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 for u32 ≥ 2²⁴ — the neighboring tops_x10 already used saturating_u16_cap. Matched.
  • parse_modality_cap unknown modality strings silently fell back to Modality::Text — used for both capability announcements and capability filters; a typo in require_modalities returned wrong nodes with no error. Switched to Option<Modality> and surfaces NET_ERR_CHANNEL on unknown.

Compute SDK error surface

  • MigrationError::TargetUnavailable(0)NoTargetAvailable — variant addition; the integration test that asserted the pre-fix variant has been updated.
  • start_migration returns Vec<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 feed past the age cap.
  • active_count budget under concurrent activate — pins that three concurrent activates can't transiently overshoot max_shards.
  • PollMerger from_id echo 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_lossy no longer false-positives on deadline-triggered shutdown — pins that final-sweep drains are not counted against events_dropped.
  • next_seq observer consistencycommitted_seq is the lock-free invariant readers see.
  • Anti-replay received == u64::MAX rejection — pins that one hostile authenticated packet can't poison the receive path.
  • TokenScope::contains(NONE) is false — 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_result idempotency — pins single + multiple + null-pointer free.
  • net_poll minimum-buffer rejection — pins that buffers below MIN_RESPONSE_BUFFER are 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_loop holds a DashMap shard guard across .await — the heartbeat broadcast loop iterates peers.iter() and awaits socket.send_to(...) (heartbeat + pingwave, twice per peer) while still holding the iterator's Ref guard. Every other task touching the same shard blocks for the cumulative round-trip.
  • accept / start mutual exclusion uses AcqRel where the comment relies on SeqCst — 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 race handshake_responder for 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 same peer_node_id falls through and peers.insert overwrites the existing legitimate session.
  • commit_reclassify_observations torn (nat_class, reflex_addr) snapshot — when every probe failed, nat_class is updated but reflex_addr keeps its previous value, violating the traversal_publish_mu invariant.
  • authorize_subscribe rejects idempotent re-subscribes with TooManyChannels — a peer at the cap re-subscribing to a channel it already holds is rejected even though SubscriberRoster is set-typed.
  • Routed-handshake peers.getpeers.insert not atomic — concurrent routed handshakes for the same peer_node_id race the insert; the loser's pending_handshakes initiator state is wedged until handshake_timeout.
  • publish_to_peer does not propagate the reliable flag to the packet header — every other sender (send_to_peer, send_routed, send_on_stream, etc.) computes if reliable { PacketFlags::RELIABLE } and threads it in. publish_to_peer hard-codes PacketFlags::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_packet migration 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_via does not refresh addr_to_node after a successful direct upgrade — the upgraded session's dispatch fast path falls back to a linear peers.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 = 0 are now rejected at the JSON-config gate (matching the existing fsync zero-rejection). Set them to null or omit the field for "no limit."
  • Net heartbeat_interval_ms = 0, session_timeout_ms = 0, mesh heartbeat_ms = 0 are now rejected. A 0-ms heartbeat saturates a CPU; this was almost always an unintended config.
  • BatchConfig max_size > 1_000_000 is now rejected. Default is 10_000; the cap closes the current_batch_size * 3 + target overflow path.
  • parse_config_json errors on unknown backpressure_mode values instead of silently selecting DropNewest.

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.01). 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 asserting TargetUnavailable(_) from start_migration_auto need to switch.
  • shutdown_was_lossy = false on a clean deadline-triggered shutdown: tests that asserted the false-positive behavior will fail.
  • PollMerger::poll echoes back from_id on stall: tests that asserted next_id == None on stall will see the input cursor instead.
  • active_count cannot transiently exceed max_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::MAX is rejected: tests that asserted the boundary was accepted will see the rejection.
  • TokenScope::contains(NONE) == false: tests that asserted the old true will need to flip.
  • JetStream Other PublishErrorKind is fatal: retry-loop tests that simulated Other and asserted retry will see the call return immediately.
  • Memories STORED → DELETED → STORED does not resurrect: tests that asserted resurrection will see the post-tombstone behavior.
  • gateway.rs::ParentVisible is now strictly upward; tests that asserted descendant-side leakage will fail.
  • route.rs route tie-break is strictly better, not equal-or-better: tests that asserted equal-metric overwrite will see preserved routes.

How to upgrade

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.10 line.
  2. Recompile. The signature changes (start_migrationVec, 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.
  3. Audit your config for fields that previously accepted zero where they shouldn't have (retention_max_*, heartbeat_interval_ms, session_timeout_ms, mesh heartbeat_ms). Replace zeros with null (or omit) for "no limit," or pick a small positive value for the heartbeat fields.
  4. 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.
  5. If you call MigrationOrchestrator directly (rather than through the SDK's DaemonRuntime::start_migration_with), update to the chunked Vec<MigrationMessage> return shape and reassemble + seal + rechunk on the transport-identity-sealing path.
  6. If your test suite covers the items in Behavioral fixes that may surface as test breakage, update the assertions.
  7. 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.0Codename:First Blood
2026.05.02

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 failureMigrationTargetHandler::drain_pending returned 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 Lagged signal under retention rollover and silently drop events. Now signals correctly during backfill.
  • Index task exits permanently after Lagged — the tail task halted on Lagged and never recovered. Now clears the index, re-tails live-only with a 5/20/60/250 ms saturation backoff, and surfaces a lag_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 survives remove. forget() is now pub(crate) so the anti-rewind invariant can't be defeated externally.
  • Observable seq rollback via next_seq() — external readers could observe a temporarily-bumped next_seq mid-rollback. Now reads under the state lock.
  • new_heap accepts RedexFlags::INLINE — the heap path silently accepted the inline flag, breaking invariants. Now rejected.
  • append_batch empty-input returns plausible-looking seq (breaking) — returned 0 for both empty input and the legitimate seq-0 first write. Now Result<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.
  • Stop policy halts without final changes_tx notify — subscribers got no signal on halt. Initial fix added notify_waiters + changes_tx.send(seq); the broadcast was later refined to NOT emit the failing seq, since changes_tx is documented as carrying successfully-folded sequences.
  • Cortex changes_tx broadcasts failing seq on Stop+non-recoverable halt — pre-fix subscribers could observe a phantom Seq(failing_seq), mis-routing state. Now drops the broadcast on halt; subscribers poll is_running().
  • RedexFile::Debug deadlock footgunDebug called len() and next_seq(), both of which take the state lock. Now reads only the lock-free atomics.
  • RedexIndex::clear() on Lagged is silent — added the lag_resets() accessor as a public sentinel.
  • RedexIndex saturation-resume can hot-loop — under sustained burst with an under-sized tail_buffer_size the 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 bounded tokio::time::timeout(2 × adapter_timeout) so the rollback can't hang on a parked worker.
  • num_shards decremented on rollback that never incremented it — activate-failure rollback over-decremented num_shards for never-activated shards. Decrement is now gated on the shard's mapper state. A targeted remove_specific_stopped_shard replaces the bulk remove_stopped_shards() so sequential manual_scale_down doesn't prune sibling state under itself.
  • ShardManager::activate_shard double-counts on idempotent calls — repeated activates kept bumping num_shards. Now gated on the mapper's transitioned signal.
  • activate() budget gate — load-then-store is safe today because the held write lock on shards serializes 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() returns Ok(()) after timeout-with-drops — lossy shutdown looked successful. Now surfaces via events_dropped + a dedicated shutdown_was_lossy flag.
  • drain_finalize_readyRelease pairs 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.
  • PollMerger default shard list is wrong after dynamic scale-down — polled from a stale 0..num_shards range, missing live shards. Now uses the live shard id set, propagated through both add and remove paths.
  • poll_merger ArcSwap leaves polls operating on stale topology — topology-snapshot semantics now documented on poll().
  • per_shard_limit silently capped at 10 000 — caller had no signal. Surfaced via truncated_at_per_shard_cap: bool in ConsumeResponse.
  • has_more=true from a stalled adapter is silently suppressed — stalled shards invisible to the caller. Now surfaced via stalled_shards: Vec<u16>.
  • Cursor::encode returns empty cursor on serialization failure — empty cursor restarted polling from zero (silent rewind). Initial fix used expect(...); later refined to return Result<String, ConsumerError> so an async poll() panic can't take down a runtime worker. Minor breaking change for direct callers.
  • PER_SHARD_FETCH_CAP made public — exposed an internal tuning knob as API. Now #[doc(hidden)]. Read truncated_at_per_shard_cap instead.
  • add_events(vec![]) flushes as a side effect — load-bearing for the rollback path. Documented and pinned by add_events_empty_can_flush_via_timeout.
  • flush() baseline excludes events flushed via remove_shard_internal — verified events_dispatched is bumped on stranded-flush; was already correct.
  • dispatch_batch final attempt collapses error reasons — all retries were tagged with one collapsed error. Now structured per-attempt reason.
  • dispatch_batch retry sleep has no jitter / backoff — synchronized retry storms across shards. Now jittered exponential via retry_backoff(shard_id, attempt).
  • drain_finalize_ready ordering doc — clarified that the SeqCst happens-before only covers the non-deadline exit; deadline-path stranded events are exactly the ones surfaced via events_dropped + shutdown_was_lossy.

Atomics, timestamps, and counters

  • pushes_since_drain_start mismatched atomic ordering — producer used Relaxed, drain side used Acquire. Now both Acquire.
  • in_flight_ingests is AtomicU32 with no saturating semantics — pathological producer counts could wrap. Widened to AtomicU64.
  • TimestampGenerator uses hard-coded baseline 0 — TSC delta math wrong. Now captures baseline at construction.
  • TimestampGenerator monotonicity stalls before the documented panic — stalled spin instead of advertised panic. Now panics preemptively at u64::MAX.
  • velocity_samples VecDeque bounded only by time, not count — burst could grow unbounded. Now also count-capped.
  • Partition next_id reuses ID 0 on u64::MAX overflow — wrap-around silently re-issued IDs. Now saturates.

Adapters (JetStream / Redis / dedup)

  • JetStream as u16 truncates shard_id — values > 65 535 wrapped silently. Now rejected with Fatal (and poll_shard propagates the Fatal instead of log-and-skipping).
  • JetStream unwrap_or_default() on remote JSON — malformed r field re-serialized as empty bytes. Now propagated as Fatal.
  • JetStream cold-stream poll walks fetch_limit * 10 round-trips — ~1010 RTTs per poll on cold streams. Now bails after consecutive_not_found_cap, gated on first_seq == 0 so populated sparse streams (events at seq 1, 500, 1000) walk past arbitrary deletion gaps.
  • JetStream from_id cursor seq + 1 overflows — wrapped to 0 at u64::MAX, silent restart. Now checked_add(1).unwrap_or(seq).
  • JetStream Fatal drops accumulated batch in poll_shard — documented; acceptable since Fatal is non-retryable.
  • Redis is_healthy PING has no enforced timeout — could hang indefinitely. Now wrapped in command_timeout.
  • Redis & JetStream limit + 1 overflow on adversarial limits — wrapped to 0, silent under-delivery. Now saturating_add(1).
  • RedisStreamDedup::new accepts unbounded capacity — clamped at MAX_CAPACITY = 1<<24.
  • RedisStreamDedup is FIFO eviction, not LRU as documented — docs were wrong. Updated to describe FIFO accurately.
  • dedup_state silently swallows fsync failureslet _ = f.sync_all() ignored disk-full errors. Propagated; cross-platform fixed via single writable handle (File::open returned read-only on Windows; FlushFileBuffers failed silently).
  • dedup_state::create_new(true) poison after crash — a stale tempfile from a crashed prior run could break every subsequent save. Added fs::remove_file(&tmp).ok() before create_new.

Security & permissions

  • ttl_seconds = 0 token mints expired — born-expired tokens with no diagnostic to the issuer. try_issue returns TokenError::ZeroTtl.
  • Identity::issue_token panic on Duration::ZERO — first fix routed through try_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 each try_issue variant precisely.
  • PermissionToken::issue panic 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.
  • OriginStamp has 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() == 0 after the channel name + token.

Bindings (Node, Python, Go, C)

  • Node binding u32 → u8 truncation on member indexas u8 silently truncated > 255. Switched to try_into with explicit > 255 rejection.
  • Python bindings hold GIL across blocking compute opsscale_to, on_node_failure, sync_standbys, promote blocked the GIL during long ops. Now release via PyO3 0.28's py.detach.
  • Node-binding groups carry an unused kind: String field — removed dead field.
  • RedisStreamDedup stripped from generated Node binding surface — a regen-without-redis-feature dropped the class from index.d.ts and index.js. Re-ran NAPI generation with --features redis,….
  • Python parity test for append_batch([]) returns None — added so future binding regenerations don't silently drop the contract.
  • include_str! of go/net.h escapes the crate root — broke cargo publish and out-of-repo vendoring. Copied to in-crate include/net.go.h and updated the parity test.
  • C SDK README — fixed stale references to a removed bindings/go/net/net.h path.
  • Runtime::block_on from extern "C" shims unwinds across FFI — reentrancy hazard documented.

Behavior rules & evaluators

  • Lossy as_f64 for 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_numbers brittle with serde_json/arbitrary_precision — a transitive dep enabling that feature would silently make rules fail closed. Added debug_assert! so the misuse is loud in dev.
  • Non-deterministic verdict orderingwindow_failures ordering depended on iteration order. Now sorts and dedups for determinism.
  • record_execution window-reset across rule reload — counters mis-reset for non-rate-limited rules. Now skipped for those.
  • Stream tight-loop spin — zero poll_interval spun the loop. Clamped to non-zero.
  • Stream backoff overflow on absurd poll_interval — doubling overflowed. Now saturating.
  • Rule::new lossily casts u128 millis to u64 — long uptimes truncated. Now uses saturating u64::try_from.

Compute (daemons + migration)

  • Migration next_seq overflowreplayed_through + 1 could panic at u64::MAX. Now saturating_add.
  • DashMap entry guard held across registry I/Ostart_snapshot held the entry guard across user-supplied snapshot code, deadlock-prone. Drops the guard before I/O. Two racing starts produce two MeshDaemon::snapshot() calls — non-idempotent daemons must single-flight at their layer; documented.
  • on_node_recovery does not break after first matching partition — documented as intentional for overlapping partitions.

Mesh transport & packet codec

  • Silent event_count truncation in packet builder — builder accepted oversized batches and truncated. Now rejects with explicit error.
  • StreamWindow.decode unbounded total_consumed — consumer-side clamp was already enforced; documented.
  • Modulo bias in equal-weight candidate selectionhash % len biased low for non-power-of-2. Now Lemire's (hash * len) >> 64.
  • cpus.saturating_mul(2) caps max_shards: u16 at 65 535 — documented as intentional.
  • mapper.rs cooldown check + scale mutation atomicity — RwLock-implicit serialization documented.

SDK & error surface

  • SdkError::Ingestion(String) flattens structured IngestionError — backpressure / sampled / unrouted all funnelled through one stringly-typed variant. Routed to structured Sampled / Unrouted / Backpressure. Breaking — see breaking-changes section.
  • SdkError enum is breaking and not #[non_exhaustive] — added #[non_exhaustive] so future variant additions are minor-version changes.
  • NetBuilder::identity() silently overrides entity_keypair — builder accepted both fields and silently dropped one; now rejects the conflict at build time.
  • NetAdapterConfig::validate accepts pathological values — added upper bounds + heartbeat floor.
  • Drop releases shutdown gates synchronously while workers hold Arc<Self> — no partial-destruction UB; documented.

Test hygiene

  • MigrationTargetHandler::drain_pending regression test — strengthened to also assert the prefix is NOT redelivered.
  • add_events_empty_can_flush_via_timeout — pins that empty input flushes after max_delay. Load-bearing for the rollback path.
  • retry_backoff jitter test — relaxed from >= 8 / 16 to >= 4 / 16 to stay robust against DefaultHasher distribution drift across toolchain versions.
  • debug_does_not_acquire_state_lock — pins the lock-free Debug invariant by holding state.lock() across format!("{:?}", 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_shards rollback: add_shard + failed activate_shard + rollback no longer over-decrements num_shards. Tests that expected the off-by-one will fail.
  • JetStream sparse-stream polling: poll_shard no longer breaks early on 64 consecutive NotFounds when info() reported a populated stream (first_seq > 0). Tests on populated sparse streams that asserted early-bail behavior will see longer walks.
  • Cortex changes_with_lag halt path: on Stop + non-recoverable error the failing seq is no longer broadcast on changes_tx. Subscribers need to poll is_running() to detect halt — pre-fix they could have observed (incorrectly) a ChangeEvent::Seq(failing_seq).
  • RedexFile::Debug: no longer acquires the state mutex; reads only the lock-free atomics. Output format changed (next_seq_atomic field name; len removed).
  • 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

  1. Bump your Cargo.toml / package.json / requirements.txt / go.mod to the v0.9 line.
  2. Recompile. The signature changes (append_batchResult<Option<u64>>, Cursor::encodeResult, SdkError #[non_exhaustive]) will surface as compile errors at the exact call sites that need updating — follow the Migrate snippets above.
  3. If you have tests that assert pre-fix behavior on the items in Behavioral fixes that may surface as test breakage, update those assertions.
  4. Bindings consumers (Node / Python): no source change is required — the type-stub updates are forward-compatible — but treat the new null / None empty-input returns as the canonical "I appended nothing" signal in your call sites.
  5. 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.
v0.8.0Codename:Killing Moon
2026.05.01

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.

§14 / post-cloud

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.

the mesh is already
running.
↓ Install NET
░░░░▒▒▒▒▓▓▓▓████████▓▓▓▓▒▒▒▒░░░░ ░░░░▒▒▒▒▓▓▓▓████████▓▓▓▓▒▒▒▒░░░░ ░░░░▒▒▒▒▓▓▓▓████████▓▓▓▓▒▒▒▒░░░░