September 2, 2026
1 pull request merged across 1 repo
Offline-Protocol/offline-protocol-sdk
Closes the device half of the gateway contract: the Reticulum transport now
speaks it, instead of speaking a subset that no conforming daemon could work
with.
What was broken
The two native managers opened a TCP socket, sent Identify carrying
config.profile (a local storage-namespace selector, not an identity in any
namespace a gateway knows), announced the carrier the moment the socket came
up, and confirmed every send the instant the write returned. They handled two
inbound frame types and ignored the rest.
Against a daemon answering
contract v1, that
device is verdict-only: the session is never bound, so every submission
draws attach_required and nothing addressed to the device is ever delivered
to it. Meanwhile the core had been told each frame was sent, so it settled
messages that went nowhere — and never saw recipient_unreachable, the one
verdict that parks a direct message and offers it to the mesh.
What changed
The attach is the handshake. Identify with a version, a challenge signed
under offline-gateway-addr-v1, DeclareAddress, the echo checked against this
device's own address, capabilities, and only then the carrier announced. A
refused declaration closes the connection rather than offering a transport that
can only refuse — which is where this deliberately differs from the relay, whose
undeclared connections keep working in account-name space. A gateway has no such
space.
Sends settle on the verdict. MessageSent confirms; DeliveryError fails
with the gateway's reason verbatim, so recipient_unreachable reaches the
classifier that parks and offers to the mesh. This closes the Reticulum half of
the mixed-neighbourhood residual docs/mesh.md has carried. The Nostr half
stays permanent, for the reason ADR 0017 gives.
The proof is built in the core. The relay's has to be bridge-side because it
commits the relay-resolved account name, which only a bridge knows, and it is
now hand-mirrored in three languages. This one commits only this device's own
address, so it exists once, pinned by a conformance vector CI executes rather
than by three copies of a byte layout.
offline-gateway-addr-v1 goes live, with published vectors.
The bug the vectors caught
Writing them found the mistake I would otherwise have shipped. The contract's
layout — domain ‖ u32be(len(address)) ‖ address ‖ challenge — reads naturally
as two length-prefixed fields, which is what the generic canonical_payload
builds. It is wrong: only the address is a field and the challenge is appended
raw. The wrong version is four bytes longer and verifies against nothing, while
a round-trip test written against the wrong builder agrees with itself and
reports success. That failure looks like a key problem, not an encoding problem.
Guards that fired, and were right to
- The podspec guard caught the two new Swift files. All four registration
points needed them.
- The Kotlin generation-count guard caught my new frame handlers sampling
connectGeneration themselves. A handler that does that always finds its own
value and so always passes handleConnectionClosed's check — including when
the frame arrived on a connection that has since been replaced, in which case
tearing down "the" connection tears down the one that replaced it. The receive
loop threads its own generation in now.
The new attach-ordering guard deliberately does not compare source offsets,
which the relay's equivalent does. That works there because the relay's calls
sit in one function in file order; here the four steps live in four functions
driven by four frames, so file order says nothing about run order, and a guard
asserting on it would fail the first time somebody moved a function. It pins the
causal chain instead.
Breaking
Rust-library consumers only: ReticulumConfig and its four inert fields, the
builder, and two constants nothing read are deleted.
The FFI never threaded any of it, so no binding, no configuration key and no
behaviour changes for app teams.
Verification
| Suite | Result |
|---|---|
cargo test --workspace | pass |
cargo clippy --workspace -- -D warnings | clean |
cargo clippy -p offline-protocol --no-default-features | clean |
| Bare-metal no_std (core, sealed, leaf) | clean |
RUSTDOCFLAGS="-D warnings" cargo doc | clean |
Spec vector provenance (generate.py --check) | 7 files match |
swift test (iOS harness) | 286 pass, 36 new |
| iOS bridge typecheck (iphoneos SDK) | clean |
Android testDebugUnitTest | 476 pass, 33 new |
| React Native build + JS tests | pass |
| Bindings regenerate with no drift | confirmed |
Not covered, and worth saying plainly: no test drives either manager against
a socket, on either platform. The pure parts are unit-tested and the call
ordering is pinned by source guards, but the end-to-end path needs a device run
against a real daemon. That is still owed.
Review notes
The eight commits are meant to be read in order; each is independently
reviewable. The Rust cleanup, the signing domain, and the engine work land
before anything touches a bridge.