logs.gokuls.in

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 OK rejection 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 Rust ReticulumConfig fields as live behaviour when reconnection is entirely owned by the native managers (1s doubling to 30s, not configurable); RETICULUM_MAX_PAYLOAD_SIZE is documented as a limit but has no reader anywhere.
  • docs/transport-architecture.md aligned.

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 MessageReceived and 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 as Deliver, deliberately not as a sixth verb (delivery is what any carrier does, not what makes one a gateway), with sender marked 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 address is the address derived from public_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-v1 now sits in the non-prefixing and distinctness guards in protocol::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.md mixed-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 --workspace2241 passed, 0 failed
  • Negative-controlled: with the mesh-preference seam stubbed out, an_online_sender_prefers_a_neighbor_it_can_see_over_the_relay fails and the two vacuity guards still pass. The tests target preferOnline, 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-existing an_online_sender_reaches_a_recipient_only_the_mesh_can_see passes unchanged.
  • clippy -D warnings, fmt --check, RUSTDOCFLAGS="-D warnings" cargo doc all 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. Plus ProtocolConfig.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.
  • RelayManager and RelayInfo, whose only remaining call sites were the two PathSelector::with_config constructions being deleted. RelayConfig / RelayPriority / RelayRole stay: 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 warnings clean
  • cargo test --workspace2223 passed, 0 failed (includes the uniffi source-guard tests that string-match the bridge files edited here)
  • cargo fmt --all -- --check clean
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps clean
  • RN tsc build clean; npm run test:js 18 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].version and all seven internal-dependency

versions, plus Cargo.lock

  • bindings/python/pyproject.toml
  • bindings/react-native/package.json + package-lock.json
  • THIRD-PARTY-NOTICES.md x3 (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-18 with a

release header naming all six breaks and the getDorsConfig() cache fix. No

empty [Unreleased] left behind.

  • SECURITY.md: supported table to 0.22.x current, <= 0.21.x unsupported.
  • docs/UPGRADING.md: current-line reference, an intro paragraph for the two

compile-fine-behave-differently items, and a new

§11.3

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.md had 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 -- --check clean
  • cargo clippy --workspace -- -D warnings exit 0, zero warnings
  • cargo test --workspace --lib 2253 passed, 0 failed
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps exit 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_state pins 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 through resolveUsername's fallback code, InvalidArgument — telling them the *name* was unusable and that retrying could never help, when start() 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:

MutationTest that catches it
Remove the Running gatetest_resolving_while_stopped_is_an_error_not_a_promise
Remove the stop() flushtest_stopping_answers_a_lookup_it_would_otherwise_strand
Move the gate ahead of the config checktest_a_disabled_lookup_reports_configuration_before_state
Restore the default-fallback gettertest_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 new protocol_with_discovery helper enables discovery in the config, because setting the flag on the transport by hand does not survive start() — 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::Always excusing the *forwarding* battery floor had no gate-level test (only Never did). Added both directions: eager at 20% carries, eager at 14% does not.
  • mesh_relay.fanout == 0 silently 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.