August 18, 2026
7 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
Workstream D1.2 (spec half), D1.4 and D1.6 from the gateway design record. Documents plus one test guard, independent of #375 and #376, mergeable in any order.
Why
Layer 2 of the program architecture existed only as a concept. "Gateway" and "backbone" appeared in this repo solely as Reticulum integration prose, and docs/reticulum.md documented a daemon TCP protocol as though a counterpart shipped — it never has, in this or any companion repository, and rnsd's actual IPC is a different protocol entirely (which the same document describes three sections earlier, contradicting itself).
What is specified
docs/spec/gateway-contract.md — four invariants, five verbs, the daemon wire protocol, the backbone.
The key move is that this is a reframing, not an invention: the internet relay already implements all five verbs. So the SDK's existing machinery around relay answers (parking, escalating probes, mesh offers, presence-driven flush) turns out to be gateway-verdict machinery that predates the name, and a Reticulum gateway plugs into it rather than needing its own path. Likewise the daemon protocol promotes the one both mobile bridges already speak, rather than designing a third — the client side is implemented twice already, and since no daemon exists, extending it breaks nobody.
Verdict is the load-bearing verb, and the spec is explicit that verdicts are unauthenticated claims: they MAY open a path, MUST NOT close one, and settle nothing. That single rule is the entire answer to a hostile gateway, and it is why the rest of the contract can be as permissive as it is.
New signing domain offline-gateway-addr-v1, registered in the spec's signing-domain table as reserved (specified, not yet emitted). It is registered in advance because mutual non-prefixing is a property of the whole set, so a domain cannot be chosen in isolation — and it must stay distinct from offline-relay-addr-v1 so a proof harvested by one gateway cannot be replayed against the other.
Decisions recorded
- ADR 0016 — gateways are provisioned, never emergent. Self-promotion is the obvious design and this codebase already does something that looks like it (role-blind forwarding with a capability bias). That shape does not transfer: it works in-zone because *every* device can forward and suppression makes redundancy cheap. Most devices structurally cannot bridge, and no in-zone redundancy covers a bridge that leaves, so an emergent gateway produces a silent partition — from inside the zone, an absent gateway and one that decided it wasn't needed look identical.
- ADR 0017 — Nostr is a carrier, never a gateway. A broadcast relay reports no per-recipient delivery, so there is no verdict to be had. Recorded as permanent so it stops being rediscovered as a bug, and specifically so nobody "fixes" it by inferring verdicts from a relay
OKrejection or a send timeout — both are evidence about the *relay*, and inferring recipient state from them yields confident wrong claims, which is worse than honest silence.
Threat model
Adversary A7 (hostile gateway) and residual R10: blackholing, verdicts lying in both directions, zone-membership exposure to the operator, backbone exhaustion — each with why it is bounded to latency rather than loss, and an honest "nothing closes the lying-gateway case, because the lie is about someone else's state."
Corrections (D1.4)
docs/reticulum.md: the phantom daemon section now points at the contract and says plainly that no counterpart exists; "fourth transport" predated Nostr; the reconnection table presented inert RustReticulumConfigfields as live behaviour when reconnection is entirely owned by the native managers (1s doubling to 30s, not configurable);RETICULUM_MAX_PAYLOAD_SIZEis documented as a limit but has no reader anywhere.docs/transport-architecture.mdaligned.
Review round 1
Five gaps found in review, all fixed in the follow-up commits on this branch:
- Contract v1 specified no inbound delivery. Both shipped Reticulum managers handle
MessageReceivedand it is the transport's whole inbound path, but the contract, which now calls itself the document to implement against, never mentioned it: a daemon built to the five verbs alone would attach devices, accept their frames, answer their verdicts and deliver nothing to any of them. Added asDeliver, deliberately not as a sixth verb (delivery is what any carrier does, not what makes one a gateway), withsendermarked as a gateway claim rather than authentication. - Attach had verification obligations on one side only. Every MUST pointed at the device. The gateway is now required to verify the signature, that
addressis the address derived frompublic_key, and that the challenge is its own and single-use. Without those a conforming daemon attaches any device under anybody's address, which draws a victim's inbound traffic to a hostile device and poisons the presence answers about them. - R10 listed unimplemented mechanisms as "in place": gateway token buckets, attach under a domain that is reserved and unemitted, and the recipient-aware decay that lives in #376. Split into what holds today and what is only specified.
- The reserved signing domain was prose only.
offline-gateway-addr-v1now sits in the non-prefixing and distinctness guards inprotocol::types::signing_domain_tests, since non-prefixing is a property of the whole set and a guard watching only live domains would accept a future live domain that prefixes this one. Negative-controlled: pointing the constant at a prefixing value fails the guard. - The MDU contradicted itself across
docs/reticulum.md(464) and the new spec (465). 465 is correct (MTU 500 minus 35 header).
Also recorded, because it is a silence rather than a message and a daemon author will otherwise miss it: today's bridges confirm a send on the socket write and ignore MessageSent and DeliveryError entirely, so a daemon that answers contract v1 correctly gets no verdict handling until the transport work lands.
Rebased onto main after #375 and #376 merged, resolving one CHANGELOG.md conflict (both sides opened an [Unreleased] section; the gateway entries now sit inside the existing ### Documentation subsection, per the house ordering that puts it last).
The rebase invalidated one of the fixes above, which is worth stating rather than quietly correcting: R10's "specified but not yet implemented" list named the recipient-aware decay, and #376 is the branch that shipped it. The decay is now in the tree (ten-minute TTL for a verdict, five for a presence answer) and has moved into the mitigations that hold today. The Presence section's claim that presence answers "decay the same way" as verdicts is likewise now checkable and was not quite right; they decay on a shorter TTL, and the spec says so with the reason.
Verified locally on the rebased tree: cargo fmt --all --check, CI-parity cargo clippy --workspace -D warnings, cargo test --workspace --lib (2,226 pass), RUSTDOCFLAGS=-D warnings cargo doc, and a scripted relative-link and anchor check across every touched document (0 broken).
Verification
All relative links resolve (scripted check across every touched file). No em dashes in the new documents, per house style.
Workstream D1.1, the first implementation step of the gateway design record (.plans/gateways-and-inter-zone.md, decision G4).
> Stacked on #375 (B3 deletion) — the design record sequences this after it because both touch send.rs seams. Base will retarget to main once #375 merges. Review the second commit only.
The problem
Reachability is modelled as a property of *the sender's carriers*, never of the recipient. can_reach_without_carrying answers yes for every recipient while any infrastructure carrier is up — the code says so itself at transport_manager.rs:1078-1088 ("deliberately peer-blind on its first arm… a real limit rather than an oversight"). The one per-peer fact the system ever received, the relay's recipient_unreachable verdict, was acted on and then discarded (send.rs:3458-3460: "the only per-peer reachability fact this device ever receives").
Consequence: an online sender standing next to the recipient routes to the relay, waits for it to answer that the recipient is not there, and only then offers the frame to the neighbour who was addressable all along.
What this adds
A per-recipient fact table — (recipient, carrier, claim, source, recorded_at) — written by producers that already existed (a carrier's unreachable verdict, a relay presence answer) and read at two seams:
1. The outbound ladder. A recipient with a live mesh link is sent to over that link, whatever scoring says. Queried live, never remembered, so there is no stale-link failure to introduce. A refusal falls through to ordinary selection, so a marginal radio cannot strand a message another carrier could take.
2. The carrying predicate. A carrier that said it cannot reach this recipient stops counting as a way to reach them.
DORS is untouched. Pushing a hard per-recipient fact into a seven-factor weighted sum would make it tunable, and a mis-tuned weight would override it silently — the prefer_online sentinel arithmetic is the standing demonstration of how delicate that plane is.
Three properties that bound the change
- Absent facts mean today's behaviour, byte for byte. Two of the new tests are vacuity guards asserting exactly this, and both pass with the seam disabled.
- Facts decay — 600s for a verdict (the probe escalation cap), 300s for presence. A remembered "unreachable" that never expired would keep a path shut long after the recipient came back. Since nothing settles on a claim (only the end-to-end ACK or terminal outbox expiry), the worst a stale or lying claim costs is latency.
- A carrier producing no facts keeps its blanket claim. Nostr cannot report per-recipient delivery, so the
docs/mesh.mdmixed-neighbourhood residual stays open there *by design* rather than closing by accident.
Parking now generalises to any carrier
The machinery was always keyed to the recipient_unreachable token rather than to the relay, and all bridge failure paths already shared one boundary. What was missing was *which carrier spoke*. Failure reports now carry it, so a Reticulum gateway reporting the verdict parks, offers to the mesh and probes exactly as the relay does — this is what a gateway's Verdict verb plugs into, at no further cost.
Latent bug fixed on the way
send_via_transport never stamped the negotiated binary wire codec while the selection path did, so a binary-capable peer silently fell back to JSON whenever a send bypassed selection. Both paths now share one stamping helper. Visible only as larger frames on the wire — no error, no event — which is why it survived.
Verification
cargo test --workspace— 2241 passed, 0 failed- Negative-controlled: with the mesh-preference seam stubbed out,
an_online_sender_prefers_a_neighbor_it_can_see_over_the_relayfails and the two vacuity guards still pass. The tests targetpreferOnline, since without it DORS already demotes Internet below every mesh transport and the ordering would hide the gap. - New: 9 fact-table unit tests (decay, per-carrier isolation, supersession, LRU bound, prune), 9 seam tests, 1 end-to-end test in
mesh_forwarding.rs. The pre-existingan_online_sender_reaches_a_recipient_only_the_mesh_can_seepasses unchanged. - clippy
-D warnings,fmt --check,RUSTDOCFLAGS="-D warnings" cargo docall clean.
No FFI change, no new config section, no new events, no wire change.
What was dead
A learned-route table, a path scorer and an adaptive-TTL calculator sat between DORS and the mesh. Nothing read them. Forwarding has always chosen among the neighbors a device can address at that moment, and the one production write into the table (learn_route_from_message, on every inbound third-party frame) fed a structure no delivery decision ever consulted.
The todo entry scoped this to four files in the router crate. The inventory pass found it was considerably wider:
- Public FFI surface. Eight methods (
learn_route,get_best_route,get_all_routes,has_route,remove_neighbor_routes,cleanup_expired_routes,get_routing_stats,update_routing_config) and four dictionaries crossing UniFFI into the RN TypeScript wrapper, both native modules, and Python. PlusProtocolConfig.path, parsed and carried to an engine that never read it. - Live bridge code feeding it. Both BLE managers learned a route on every completed inbound message and seeded one on every peer connect; both ran a 30-second timer calling
cleanupExpiredRoutes. RelayManagerandRelayInfo, whose only remaining call sites were the twoPathSelector::with_configconstructions being deleted.RelayConfig/RelayPriority/RelayRolestay: they are the vocabulary for whether this device forwards, and the standing itself is decided by the forwarding governor from traffic actually carried (B2).
The one thing that was not dead
On Android the routing-cleanup runnable did two jobs: cleanupExpiredRoutes() *and* evicting stale inbound pending fragments. The fragment sweep is load-bearing (without it a peer that connects, queues fragments while its device ID resolves, then goes silent leaks them until another peer's fragment triggers the sweep). The runnable survives as fragmentSweepRunnable on the same 30-second cadence; only the routing call is gone.
Documentation
docs/mesh.md presented this layer as the delivery path: "Path Scoring", route entries, the routing-table config table, the UniFFI method table, and Swift/Kotlin route-learning examples. Replaced with how a forwarding device actually chooses, and why remembering routes is the wrong shape for the environments this targets (links appear and vanish in seconds, so a remembered route is usually stale while a fresh choice never is).
Stale references also corrected in docs/architecture.md, the router README, docs/api-reference.md, docs/configuration.md, docs/react-native-integration.md, and the RN README. Two code comments cited RelayManager::current_role(), a method that stopped existing when relay standing moved to the forwarding governor. The DORS tie-break comments claimed an order ending at Reticulum when Nostr is actually lowest (dors.rs:148).
Breaking
Every binding. See docs/UPGRADING.md §15. Outright deletion rather than a deprecation cycle because there are no deployed peers and no consumer calls it: verified against fernweh, fernweh_v2, mesh-sdk-example-app and id-sdk-rn-test-app — zero hits. Same precedent as the getMetrics deletion in #304.
Verification
cargo clippy --workspace -- -D warningscleancargo test --workspace— 2223 passed, 0 failed (includes the uniffi source-guard tests that string-match the bridge files edited here)cargo fmt --all -- --checkcleanRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depsclean- RN
tscbuild clean;npm run test:js18 passed - All bindings regenerated together via
./scripts/generate-bindings.sh
cargo clippy --all-targets fails on pre-existing unwrap() lints in test code on main; the CI command (without --all-targets) is clean.
Cuts v0.22.0. Minor rather than patch: the range carries six distinct
breaking changes across three ! commits, plus new public API.
Version bump
All seven surfaces move together, which release.yml's version gate checks
before either publish job runs:
Cargo.toml[workspace.package].versionand all seven internal-dependency
versions, plus Cargo.lock
bindings/python/pyproject.tomlbindings/react-native/package.json+package-lock.jsonTHIRD-PARTY-NOTICES.mdx3 (they list our own crates by version, so the
bump makes all three stale and the CI drift gate fails on them)
What the CHANGELOG audit found
The recurring gap at every cut is that [Unreleased] is missing whole
clusters. This time it was worse than missing.
**Four entries had been written into the already-shipped ## [0.21.0]
section.** ead66a6 and e70dec3 landed on 2026-08-14, a day after the
v0.21.0 tag, and inserted their ### Changed entries at the file offset that
had by then become 0.21.0's ### Changed heading. Two consequences: the
published 0.21.0 notes falsely claimed four changes that never shipped in it,
and [Unreleased] had **zero coverage of the two hardest compile breaks in
the range** (the RelayPriority respelling and set_battery_level becoming
fallible). A release header built from [Unreleased] would have shipped
without them. All four are moved here.
One entry was actively wrong. The isRelay() note said it "stays false
while no battery level has been reported", which 671c849 inverted three
weeks later by deriving relay standing from observed forwarding instead of
battery. That sentence and the [Unreleased] text 250 lines away said
opposite things. Same class for the two "the relay role was never evaluated"
claims, now corrected to say the role is no longer battery-derived at all.
Four changes had no entry at all: key-package lookups no longer ending on
the fastest relay's answer (a live cold-contact defect at v0.21.0, framed in
the file as a username-discovery property only), the iOS query deadline moving
off the wall clock, setRelayPriority no longer clobbering a concurrent
config update, and the eight new crate READMEs.
One shipped doc comment was stale: the UDL's resolve_username block still
named three rejection codes after fbe2a97 added NotStarted. Corrected;
bindings regenerated (no diff, plain // UDL comments do not propagate).
Prose
CHANGELOG.md:[Unreleased]becomes## [0.22.0] — 2026-08-18with a
release header naming all six breaks and the getDorsConfig() cache fix. No
empty [Unreleased] left behind.
SECURITY.md: supported table to0.22.xcurrent,<= 0.21.xunsupported.docs/UPGRADING.md: current-line reference, an intro paragraph for the two
compile-fine-behave-differently items, and a new
for the battery feed going live.
- Archive: 0.21.0 moves to
docs/changelog/0.21.md, both archive tables
updated, relative links rewritten for the new depth.
CONTRIBUTING.md: the archive step was missing from the canonical release
recipe (it lived only in docs/changelog/README.md), which is how a cut
forgets it. Added, including the relative-link rewrite that breaks silently.
Verification
cargo clippy --workspace --locked -- -D warnings, `cargo test --workspace
--lib (131 passed), cargo fmt --all -- --check`,
scripts/check-license-consistency.sh, scripts/check-crate-readmes.sh,
npx tsc --noEmit, and a notices regeneration that reproduces byte-identically.
The tag is deliberately not pushed. Pushing v0.22.0 is what publishes to
npm and crates.io, and crates.io versions are immutable; this branch merges
first.
The bug
handle_group_mls_msg_via marks the envelope identifier in message_dedup
*before* attempting the decrypt, deliberately, to bound replay amplification to
one MLS crypto operation per identifier. The SecurityRejected arm then
returned without releasing that mark.
The relay path has released it since #337 (group_mesh.rs:5789) and the drain
releases it too (group_mesh.rs:2697). The mesh arm predates that discipline
and was never retrofitted.
The leak chain, each link verified against the code:
1. SecurityRejected withholds the ACK and unmarks the transport
deduplicator (protocol/receive.rs:232-244), so a replay is not absorbed
there.
2. The replay reaches the group handler again and hits the duplicate branch
(group_mesh.rs:1064-1094).
3. The identifier is marked but not pending, so that branch classifies it as
already delivered and returns Consumed.
4. The receive loop ACKs Consumed.
Replaying a frame we refused on identity grounds therefore earns an
acknowledgement, which is exactly the liveness confirmation the silent-refusal
rule exists to withhold (invariant I4 in docs/state-machines/delivery-and-acks.md,
and ADR 0005).
The fix
Release the envelope identifier on the SecurityRejected arm of the mesh
handler. All three group paths now discharge the obligation: mesh, relay, and
the drain.
The transport-level unmark stays with the receive loop's SecurityRejected
arm, which already owns that layer for every rejected frame. That is why this
is a direct message_dedup.remove rather than a call to
release_replay_protection: the helper clears both layers, and its documented
scope is drain-time drops where no receive-loop pass exists to do it. Using it
here would double-unmark and make that doc comment stale.
Cost is one crypto operation per replayed copy, the trade the relay path
already accepts and the spec already endorses.
What the replay does now
The refused decrypt has already spent the ciphertext's ratchet generation
(OpenMLS persists message secrets through the storage provider before the
identity check runs), so the replay cannot decrypt. It classifies Retriable,
buffers, and returns Deferred: still no ACK, custody stays with the sender,
the failure stays visible. This mirrors what the relay path's equivalent
regression test already pins.
Test
test_mesh_copy_sender_mismatch_rejected_releases_dedup_entry replays a
refused frame and asserts no ACK is produced, as the issue asked for.
It was checked against the unfixed code in both directions:
- Remove the release: fails on the dedup assertion.
- Remove the release *and* bypass that assertion so execution reaches the
replay: fails with got Consumed, which is the result the receive loop
turns into a delivery ACK. That is the reported bug reproduced directly.
Docs
The issue named one stale document; there were two.
docs/spec/group-protocol.md(written in #365) called the mesh behaviour "a
defect against this specification". It now states the uniform rule and keeps
the MUST and the replay-amplification rationale.
docs/state-machines/group-message-lifecycle.mdhad the old behaviour baked
into a diagram edge as no ack, unmark transport dedup only, and its G4
invariant scoped the unmark obligation to the relay path alone. Both
corrected, plus "Difference 1" is now explicitly scoped to the buffered case
so it does not read as covering the refusal arm.
The comment at group_mesh.rs:1277-1278 that the issue flagged as stale
("it must withhold the ACK and unmark the id, which is what SecurityRejected
already carries") needed no edit: the fix makes it true.
Blast radius
No FFI, UDL, wire-format, or config change, so no binding regeneration. The
only caller-visible behaviour change is that a replayed refused frame now
classifies Deferred instead of Consumed, and the receive loop already
handles Deferred correctly. Revert is a single-commit revert, safe: the state
involved is in-memory only.
Verification
cargo fmt --all -- --checkcleancargo clippy --workspace -- -D warningsexit 0, zero warningscargo test --workspace --lib2253 passed, 0 failedRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depsexit 0
Two defects found by auditing the unreleased range (v0.21.0..35c2f94) against the claims its own CHANGELOG and docs make. Both were invisible to the test suite because each lives in the seam *between* two features that were reviewed individually.
1. resolveUsername() promised an event the engine could not send
resolve_username gated only on the discovery switches, which survive stop(). The event it promises is emitted from process(), which early-returns unless the protocol is Running.
So on a stopped or paused instance the call returned Ok(true) — "an answer is coming" — with nothing able to deliver one. Worse, it left its registration behind, so every later attempt at that name returned Ok(false), which says an answer is coming *too*. The caller was told twice that a resolution was in flight and had no way to learn otherwise. That directly contradicts the documented contract: *"Every case where no event will ever arrive rejects instead, so awaiting the resolution can never hang."*
- It now refuses with
NotStarted, placed after the configuration checks. Both errors are true of a stopped instance with discovery off, and reporting the transient one first would send an app round a start-and-retry loop to arrive at the permanent problem it could have been told about immediately.test_a_disabled_lookup_reports_configuration_before_statepins that ordering. stop()now answers the lookups it is about to strand rather than dropping them: a resolution that heard from some relays emits what it has, one that never reached a relay emits the empty set — both on the terms the deadline sweep already uses. Discarding them silently would leave the promise broken *and* the registrations standing.- Neither bridge mapped
NotStarted, so it would have reached apps throughresolveUsername's fallback code,InvalidArgument— telling them the *name* was unusable and that retrying could never help, whenstart()was all that was missing. Added to both bridges with tests.
2. getDorsConfig() silently turned off internet-first routing
It answered from an FFI-local cache that nothing populated until the first updateDorsConfig, falling back to CoreDorsConfig::default() until then — and prefer_online is precisely the field whose default (false) disagrees with what construction seeds from ProtocolConfig.
An app created with preferOnline: true that then performed the documented read-modify-write to change any other field read false, wrote it back, and silently demoted internet-first routing. React Native was shielded only by accident (its TypeScript layer sources the flag from the dors section, whose create-time application populated the cache), so this bit direct FFI callers: Swift, Kotlin and Python.
The getter now reads the live selector, the way getMeshRelayTunables() reads the governor. The cache is deleted rather than seeded — a second copy of a value is a thing that can disagree with it. The single remaining core-to-FFI mapping destructures exhaustively, so a new DORS field fails the build rather than going missing from what every binding reads back.
Verification
Every new test was checked against the unfixed code first. Four mutations, each failing the test that pins it:
| Mutation | Test that catches it |
|---|---|
Remove the Running gate | test_resolving_while_stopped_is_an_error_not_a_promise |
Remove the stop() flush | test_stopping_answers_a_lookup_it_would_otherwise_strand |
| Move the gate ahead of the config check | test_a_disabled_lookup_reports_configuration_before_state |
| Restore the default-fallback getter | test_get_dors_config_reads_the_engine_not_an_ffi_copy |
The Kotlin bridge arm was negative-controlled by removing it in the standalone harness (ProtocolErrorBridgeTest fails).
Green locally: cargo fmt, clippy -D warnings, cargo test --workspace (2262 tests), the rustdoc gate, TypeScript typecheck + 18 JS harness tests, 252 iOS Swift tests, 438 Android JVM tests, and generate-bindings.sh reports no drift (the UDL is untouched — NotStarted is a pre-existing variant, so the append-only error enum is unchanged).
Two notes for the reviewer:
- Two pre-existing tests now call
start(). A newprotocol_with_discoveryhelper enables discovery in the config, because setting the flag on the transport by hand does not survivestart()— it re-applies the whole Nostr section from config. - Refusing a pre-
start()lookup is a deliberate behaviour change beyond the stopped case. Such a lookup could not reach a relay anyway and would resolve to the empty set after the 30s deadline, which reads as "nobody claims this name" — a false negative is worse than an error.
Both defects, and the rest of the audit findings not addressed here, are catalogued in the audit that produced this PR.
Closes TODO item 10 (workstream B4), and the three review follow-ups #364 merged without.
Why
Multi-hop forwarding (#324) shipped with its governor tunables and its counters as Rust-core surfaces. From an RN app the feature is unconfigurable *and* unobservable: with no counters to read, even a correctly staged three-device test could do no better than infer forwarding from the fact that a message arrived. docs/mesh.md documented the gap in as many words.
What
ProtocolConfig.meshRelay now carries the sixteen governor tunables; getMeshRelayStats() reports what the device has carried; getMeshRelayTunables() reports what is actually in force.
The optional/required split is the load-bearing design choice. Input is all-optional so an omitted field reaches the core absent and no binding ever restates a default, which is the DORS silent-reset class where a payload naming one field arrived with defaults for company. Output is all-required so no caller writes ?? 3, the same duplicated-default drift arriving from the other direction. getMeshRelayTunables() reads the governor (via a new core mesh_relay_config()), never an FFI-side copy, so it cannot be vacuous the way get_dors_config() is.
No runtime setter, deliberately. The governor snapshots its config at construction and has no set_config; re-pointing it mid-flight would have to rebuild the token buckets and suppression cache underneath in-flight forwards. Suppression-cache sizing (seen) stays core-only, being memory sizing rather than a policy dial.
The three inherited #364 follow-ups (first commit)
- The bias window-separation guard asserted the 400ms ceiling clears the 180ms span, but no device pays the ceiling: one at the floor scales to
bias_min_scale, so the reachable maximum is 300ms. Raise the min scale past 0.55 and the windows overlap with the guard still green. Now asserts the reachable bound. RelayPriority::Alwaysexcusing the *forwarding* battery floor had no gate-level test (onlyNeverdid). Added both directions: eager at 20% carries, eager at 14% does not.mesh_relay.fanout == 0silently became a fan-out of one via.max(1). Now refused at construction, making the clamps unreachable defensive floors.
Two real defects the Rust suite could not see
Both found by running the platform suites, both in the class this repo keeps hitting:
1. The Kotlin parser built the section and never passed it to the ProtocolConfig(...) constructor, so it was silently inert. Exactly the T6 failure mode.
2. A missing uniffi.offline_protocol.MeshRelayConfig import failed the Kotlin compile.
iOS parsing went into a Foundation-only MeshRelayConfigReader.swift so the SwiftPM suite covers it, closing the "no iOS parser test for a new config flag" gap that no item owned. Both bridges clamp before the unsigned conversion: a fan-out of -1 would otherwise arrive as ~1.8e19, which the core cannot recognise as wrong.
Guarding
react_native_bridges_read_every_mesh_relay_tunable derives the field list from the UDL dictionary itself and asserts all four hand-written layers read every field in both spellings, plus that config and tunables stay field-for-field aligned. Negative-controlled four ways (dropped snake_case spelling, missing TS field, UDL field absent from the guard's own list, cross-wired conversion field); each fails only its own assertion. The FFI mapping tests are mutation-verified too.
Verification
clippy -D warnings, cargo test --workspace --lib (1370 core + 129 uniffi + rest), fmt, RUSTDOCFLAGS=-D warnings doc, all three binding sets regenerated together and verified idempotent under the CI delete-and-regenerate drift gate, 438 Android JVM tests, 252 SwiftPM tests, 18 JS harness tests, the full hand-written iOS bridge swiftc -typecheck (negative-controlled), and examples/react-native-app tsc which no CI job covers.