Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,21 @@ archived by series under [docs/changelog/](docs/changelog/); see the
`ProtocolManager` already passes it, so only code that builds an
`InternetManager` directly needs `app_id=`.

- **The backbone is a gateway property.** The gateway contract's backbone
section is restated as what any backbone owes a gateway (gateway-to-gateway
presence, arbitrary-size framing, a provisioned peer list, a declared rate
class), with Reticulum as the reference backbone in a subsection of its
own. A gateway advertises each backbone as a `backbone_<kind>_v1`
capability token; a client ignores a kind it does not know, a gateway may
advertise none, and the token is advisory and never a routing input. A
device attaches to one gateway daemon at a time, because every gateway
answer is recorded against the one daemon carrier. No frame, verb or
token spelling changes: `backbone_reticulum_v1` is the family's first
member. The `reticulum_*` entry points keep their names and their doc
comments now say what they name, the gateway daemon carrier after its
reference backbone. [ADR 0026](docs/adr/0026-the-backbone-is-a-gateway-property.md)
records the decision.

## [0.27.0] — 2026-09-25

> **Replicated documents can be removed, narrowed, and carry their bytes
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/offline_protocol_sdk/local_api/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from __future__ import annotations

UDL_SHA256 = "d2b3a4e23be560b35388bfe45c5164ce4b7c002f5e11022700618c7e102823cb"
UDL_SHA256 = "cff2c11d29e6149e3d9c04919c03992fd2c1f760ecf20af1a6054e796851661e"

TABLE = {'callbacks': ('MlsStorageProvider',
'ProtocolStateStorageProvider',
Expand Down
23 changes: 13 additions & 10 deletions crates/offline-protocol-transport/src/reticulum.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
//! Reticulum mesh transport queue engine.
//! The gateway daemon transport's queue engine.
//!
//! Long-range, low-bandwidth, resilient mesh networking via the Reticulum
//! network stack (LoRa, TCP, UDP, serial, I2P, and other mediums). No
//! Reticulum link is opened here: the platform side bridges to a running
//! Reticulum daemon (sidecar, TCP gateway, or embedded Python); the Rust
//! side manages queues, metrics, and the confirmation loop.
//! Named after Reticulum, its reference backbone: long-range, low-bandwidth,
//! resilient mesh networking over LoRa, TCP, UDP, serial, I2P and other
//! mediums. The name is the slot's, not a requirement on what stands
//! behind the daemon. No backbone link is opened here: the platform side
//! bridges to a gateway daemon over local IP, the daemon owns whatever
//! backbone it has, and the Rust side manages queues, metrics, and the
//! confirmation loop.
//!
//! The bridge contract: the platform reports daemon connectivity via
//! [`ReticulumTransport::on_status_changed`], drains outbound wire bytes
Expand All @@ -23,11 +25,12 @@ use std::time::{Duration, Instant};

use crate::common::recalculate_delivery_ratios;

/// Reticulum mesh transport implementation.
/// The gateway daemon transport, named after its reference backbone.
///
/// Provides connectivity via the Reticulum network for long-range,
/// low-bandwidth, resilient mesh networking. The platform bridges to a
/// gateway daemon speaking the contract in `docs/spec/gateway-contract.md`.
/// The platform bridges to a gateway daemon speaking the contract in
/// `docs/spec/gateway-contract.md`. The backbone behind that daemon,
/// Reticulum or another, is the gateway's property and reaches this crate
/// as nothing more than a capability token (ADR 0026).
///
/// Reconnection, the attach handshake and the verdict loop are the platform
/// bridge's, not this type's: it never opens a socket, so a timeout or a
Expand Down
21 changes: 16 additions & 5 deletions crates/offline-protocol-uniffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5023,8 +5023,16 @@ impl OfflineProtocol {
// ========================================================================
// RETICULUM TRANSPORT
// ========================================================================

/// Called by the platform when the Reticulum daemon connection status changes.
//
// The `reticulum_*` names are the transport slot's, after its reference
// backbone, and they are the generated API of three bindings. The slot
// is the gateway daemon carrier: a bridge speaks the daemon contract
// over local IP, and what stands behind the daemon is the gateway's
// property, learned here only as a `backbone_<kind>_v1` token that
// nothing reads (ADR 0026).

/// Called by the platform when the gateway daemon connection status
/// changes.
pub fn reticulum_status_changed(&self, is_connected: bool) -> Result<(), ProtocolError> {
// Atomically read previous state and update in a single lock scope
let was_connected = {
Expand Down Expand Up @@ -5266,7 +5274,10 @@ impl OfflineProtocol {
}

/// Reticulum: capability tokens from the gateway's `Capabilities` answer
/// (e.g. `gateway_v1`, `backbone_reticulum_v1`).
/// (e.g. `gateway_v1`, `backbone_reticulum_v1`). A `backbone_<kind>_v1`
/// token says what stands behind the daemon; the SDK stores it and
/// reads nothing from it, and a kind it does not know is kept like any
/// other token.
///
/// The bridge MUST call this before `reticulum_status_changed(true)` on
/// each attach — the contract's own ordering, so a device never drains
Expand All @@ -5287,8 +5298,8 @@ impl OfflineProtocol {

/// Reticulum: gateway-sourced presence for a peer (`PresenceStatus`).
///
/// `online=true` records the fact against Reticulum and re-drives that
/// peer's parked messages **over Reticulum**, because that is the carrier
/// `online=true` records the fact against this carrier and re-drives that
/// peer's parked messages **over this carrier**, because it is the one
/// that just proved it can reach them; `online=false` parks pending
/// welcomes without burning retry budget. Emits `presence_updated` with
/// `source: reticulum` — except for self, blocked, or empty peer ids,
Expand Down
15 changes: 9 additions & 6 deletions crates/offline-protocol-uniffi/src/offline_protocol.udl
Original file line number Diff line number Diff line change
Expand Up @@ -1479,19 +1479,22 @@ interface OfflineProtocol {
void reticulum_address_declaration_refused(string reason);

// Reticulum: capability tokens from the gateway's Capabilities answer
// (e.g. "gateway_v1", "backbone_reticulum_v1"). MUST be called BEFORE
// reticulum_status_changed(true) on each attach — the contract's own
// ordering, so a device never drains into a gateway whose features it has
// not been told. Wholesale replace; the SDK clears the set itself when
// (e.g. "gateway_v1", "backbone_reticulum_v1"). A backbone_<kind>_v1
// token says what stands behind the daemon; the SDK stores it and reads
// nothing from it, and a kind it does not know is kept like any other
// token. MUST be called BEFORE reticulum_status_changed(true) on each
// attach: the contract's own ordering, so a device never drains into a
// gateway whose features it has not been told. Wholesale replace; the
// SDK clears the set itself when
// the transport drops. Bounded like the relay's (64 tokens of 128 bytes)
// and kept in its own set, so a token from whatever box is on the venue
// Wi-Fi cannot open the relay's group-broadcast gate.
[Throws=ProtocolError]
void reticulum_gateway_capabilities(sequence<string> capabilities);

// Reticulum: gateway-sourced presence for a peer (PresenceStatus).
// online=true records the fact against Reticulum and re-drives that
// peer's parked messages OVER RETICULUM, because that is the carrier that
// online=true records the fact against this carrier and re-drives that
// peer's parked messages OVER THIS CARRIER, because it is the one that
// just proved it can reach them; online=false parks pending welcomes
// without burning retry budget. Emits presence_updated with
// source: reticulum, except for self, blocked, or empty peer ids.
Expand Down
10 changes: 8 additions & 2 deletions crates/offline-protocol/src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,18 @@ pub struct OfflineProtocol {
/// seams. In-memory only, and absent facts mean today's behaviour.
pub(crate) reachability: reachability::ReachabilityFacts,

/// Capability tokens the attached Reticulum gateway advertised.
/// Capability tokens the attached gateway daemon advertised.
///
/// Delivered at attach, before the bridge reports the carrier available,
/// and cleared when the carrier drops: a stale advertisement outlives the
/// gateway that made it, and a reconnect may land on a different one.
///
/// Stored and never read on a decision. The backbone behind the daemon
/// is the gateway's own property and reaches this device only as a
/// `backbone_<kind>_v1` token in this set; the set is for an application
/// to show, and the first production reader would turn a string set by
/// whoever holds the socket into a routing input (ADR 0026).
///
/// Kept apart from `group_mesh.relay_capabilities` deliberately, though
/// both are capability sets from a gateway. That one gates the relay
/// group-broadcast path, and a token advertised by whatever box is
Expand Down Expand Up @@ -2620,7 +2626,7 @@ impl OfflineProtocol {
}
}

/// Records what the attached Reticulum gateway says it can do.
/// Records what the attached gateway daemon says it can do.
///
/// Wholesale replace, like the relay's: each attach describes the gateway
/// actually connected now. The bridge calls this **before** it reports the
Expand Down
3 changes: 2 additions & 1 deletion crates/offline-protocol/src/protocol/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5727,7 +5727,8 @@ impl OfflineProtocol {
}
}

// Reticulum excluded: LoRa bandwidth (~0.7 KB/s typical) is unsuitable for media transfer.
// The daemon carrier is left out of the fallback order: its reference
// backbone is scarce-class (gateway-contract.md, rate class).
for preferred in [
TransportType::Internet,
TransportType::WiFiDirect,
Expand Down
7 changes: 5 additions & 2 deletions crates/offline-protocol/src/protocol/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ pub enum GatewayCarrier {
/// The internet relay, which implemented every verb before the contract
/// named them.
Internet,
/// A gateway daemon reached over the Reticulum transport's local IP
/// contract.
/// A gateway daemon reached over local IP, through the transport slot
/// named `Reticulum` after its reference backbone. The variant names
/// the carrier, not the backbone: what stands behind the daemon is the
/// gateway's property, and this device learns of it only as a
/// capability token it never reads (ADR 0026).
Reticulum,
}

Expand Down
129 changes: 129 additions & 0 deletions docs/adr/0026-the-backbone-is-a-gateway-property.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# 0026. The backbone is a gateway property

**Status:** Accepted

## Context

The [gateway contract](../spec/gateway-contract.md) was written when one
backbone existed. Its backbone section opened with "between gateways, the
wide-area carrier is Reticulum", the Verdict rule was stated for "a Reticulum
gateway", and the one capability token a gateway advertised for its backbone
was `backbone_reticulum_v1`, with no rule for a second. The SDK's own names
followed: the transport slot, the `reticulum_*` FFI entry points and the two
mobile managers are all named after that backbone, and their doc comments
said "the attached Reticulum gateway" for what is, on the wire, a daemon
reached over local IP.

None of that is what the device does. Four facts, verified in the tree:

**The device never touches a backbone.** The transport opens no backbone
link, holds no backbone identity and sees no backbone frame. The bundled
managers speak newline-delimited JSON to a daemon at a configurable local
address, and everything past the daemon is the daemon's.

**The engine stores the gateway's tokens and reads none of them.** The
capability set a gateway advertises at attach is kept, bounded and cleared on
drop, and the only readers of the set are tests. No send path, no parking
decision and no transport choice consults a token. The `mailbox_v1`
behaviour that looks like a token-driven feature is driven by the `stored`
and `pushed` flags on each verdict, and the token is the gateway's promise to
honour them, not the switch.

**Every gateway answer is recorded against a transport type.** The
reachability facts a verdict or a presence answer produce are keyed by
recipient and by `TransportType`, and the newest answer for a carrier
replaces the last. The engine holds one transport per type, and the FFI
holds one daemon connection state. Two daemons attached through the one
slot would overwrite each other's facts recipient by recipient, and neither
would be wrong on its own terms.

**Forty-odd guards pin the names.** Source guards in the FFI crate read the
mobile managers by path and assert their shape, and the `reticulum_*` entry
points are the generated API of three bindings. Renaming the slot is a
three-language break for a property the device does not have.

## Decision

1. **The backbone is the gateway's.** The contract states what a backbone
owes a gateway (gateway-to-gateway presence, arbitrary-size framing, a
provisioned peer list, a declared rate class) as obligations on the
gateway, with Reticulum as the reference backbone in a subsection of its
own. Nothing a device does depends on the section.
2. **The device learns the backbone only as a token.** A gateway advertises
each backbone it reaches as one token of the family `backbone_<kind>_v1`.
A client ignores a kind it does not know and keeps the session. A
gateway may advertise no backbone token, and is a conforming gateway.
`backbone_reticulum_v1` is the first member, unchanged in spelling.
3. **The token is advisory and never a routing input.** The engine keeps
storing the set and keeps reading no token from it. A device does not
choose a carrier, park a message or size a frame because of a backbone
token. A token is set by whoever holds the socket, so a routing choice
that trusts it is a routing choice the gateway makes for the device,
and invariant 3 of the contract (no facts, no change) forbids that.
4. **One attached gateway daemon at a time.** The daemon link is one carrier
however many backbones stand behind it, and the facts it produces are
keyed by that carrier. A second daemon on the same device needs a second
transport type, which is a change to the transport set (a sixth variant,
every exhaustive match, the selector's ranking, the bridges' managers)
and is deferred until a deployment needs two daemons on one device. A
device that wants a second gateway's reach gets it through the first: the
gateways are peers over the backbone.
5. **The names stay.** The `reticulum_*` FFI entry points, the transport
variant and the manager file names keep their spelling. They name the
transport slot, after its reference backbone. Their doc comments say
what the slot is: the gateway daemon carrier, whose backbone is the
gateway's property.

## Consequences

- A gateway on another backbone, or on none, attaches the same devices with
the same managers and no SDK change. What it advertises differs by one
token, which nothing reads.
- The threat model's abuse-budget argument for gateways is per rate class:
the budgets a gateway applies are sized to what its backbone can carry,
and a device cannot verify either the class or the budgets. That was
already true for one backbone and is now stated for any.
- The `reticulum_*` names remain a small standing confusion for a reader
who meets them first. The doc comments carry the correction, and a rename
is future debt that would cost a three-binding API break to pay.
- Two daemons on one device is not supported, and a bridge that opens a
second connection to a second daemon while the first is attached produces
facts that overwrite each other. The contract says so. Nothing enforces
it in the engine, because the engine sees one carrier.

## Alternatives considered

**A transport variant per backbone kind.** Honest about what a second daemon
would need, and wrong about what a device does: the device speaks one
protocol to a daemon whatever stands behind it, so a variant per kind would
put a property the device cannot observe into an enum the device branches
on, and every exhaustive match would grow for a distinction with no
behaviour behind it.

**Rename the slot to something neutral now.** The right name, at the cost of
a three-binding API break, forty guard edits and two manager renames, for a
change with no behaviour in it. The doc comments carry the correction
instead, and the rename waits for an API break that is happening anyway.

**Carry the backbone in a wire field rather than a token.** A field is a
promise that something reads it. Nothing should, for the reason in decision
3, and a token in a set that is already bounded, cleared on drop and
documented as never driving a security decision is the shape that makes that
hard to forget.

## What would undo this

A send path, a parking decision or a transport ranking that reads a
`backbone_*` token. The getter exists for tests and for an application that
wants to show the token; the first production reader turns an advisory
string set by the socket's holder into a routing input, and does so without
anything failing.

A second daemon attached through the one slot. It works, in the sense that
frames flow, and the two gateways' verdicts overwrite each other from then
on.

A backbone obligation restated in device terms, such as a device-side frame
ceiling sized to a backbone's MDU. The device does not know what the
backbone is, and the day it is told, every device is coupled to one
deployment's link.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ silently undo it. Decisions that follow from the obvious default do not need one
| [0023](0023-a-control-frame-states-when-it-was-made.md) | A control frame states when it was made | Accepted |
| [0024](0024-the-sdk-ships-its-own-tls-stack.md) | The SDK ships its own TLS stack for telemetry | Accepted |
| [0025](0025-native-packages-are-assembled-in-place.md) | Native packages are assembled from the bridge sources in place | Accepted |
| [0026](0026-the-backbone-is-a-gateway-property.md) | The backbone is a gateway property | Accepted |

## Format

Expand Down
2 changes: 1 addition & 1 deletion docs/reticulum.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The Reticulum transport provides long-range, resilient mesh networking via the [

Reticulum is one of five transports in the Offline Protocol SDK, alongside BLE, Wi-Fi Direct, Internet and Nostr. It is disabled by default because it requires external infrastructure (a running Reticulum instance, an RNode radio, or a gateway).

> **This repository ships the device half.** The Rust transport opens no Reticulum link of its own: it manages queues, metrics and the confirmation loop, and expects the platform to bridge to a real Reticulum stack. Both mobile managers now speak [the gateway daemon contract](spec/gateway-contract.md) to a configurable address: they attach with a signed address declaration, settle each send on the gateway's verdict, and watch presence. What answers on the other end is a gateway daemon built to that contract, which is a deployment rather than something this SDK ships. With nothing listening at `daemonAddress`, enabling Reticulum gives you a transport that never becomes available.
> **This repository ships the device half.** The Rust transport opens no Reticulum link of its own: it manages queues, metrics and the confirmation loop, and expects the platform to bridge to a real Reticulum stack. Both mobile managers now speak [the gateway daemon contract](spec/gateway-contract.md) to a configurable address: they attach with a signed address declaration, settle each send on the gateway's verdict, and watch presence. What answers on the other end is a gateway daemon built to that contract, which is a deployment rather than something this SDK ships. With nothing listening at `daemonAddress`, enabling Reticulum gives you a transport that never becomes available. The transport is named after its reference backbone, and that is all the name means: the daemon contract does not require Reticulum behind the daemon, the backbone is [the gateway's property](spec/gateway-contract.md#the-backbone), and this device learns of it only as a `backbone_<kind>_v1` capability token that nothing in the SDK reads ([ADR 0026](adr/0026-the-backbone-is-a-gateway-property.md)).

## When to Use Reticulum

Expand Down
Loading
Loading