logs.gokuls.in

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.

UPGRADING §19.

The FFI never threaded any of it, so no binding, no configuration key and no

behaviour changes for app teams.

Verification

SuiteResult
cargo test --workspacepass
cargo clippy --workspace -- -D warningsclean
cargo clippy -p offline-protocol --no-default-featuresclean
Bare-metal no_std (core, sealed, leaf)clean
RUSTDOCFLAGS="-D warnings" cargo docclean
Spec vector provenance (generate.py --check)7 files match
swift test (iOS harness)286 pass, 36 new
iOS bridge typecheck (iphoneos SDK)clean
Android testDebugUnitTest476 pass, 33 new
React Native build + JS testspass
Bindings regenerate with no driftconfirmed

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.