Proposal: Transport-Class Phase 2 — Core Extraction & the Android Edge Node¶
Status: proposed, v0.4 · Scope: restructuring — a reusable core crate, a platform-transport seam, and the first non-daemon RelayFabric node · Date: 2026-08-18
Regenerated 2026-08-18: the original Phase-2 design section (written 2026-08-17 alongside the Phase-1 spec) lived in an untracked working directory and was lost when that worktree was removed. This document reconstructs it from the project's design notes and the shipped Phase-1 code, and lives in
docs/proposals/so it is versioned with the repo.
Context: what Phase 1 shipped¶
Phase 1 (merged to main 2026-08-17) made the link a plugin rides a first-class model, separate from the plugin's protocol:
relay-core:TransportClass(terrestrial_internet, satellite_internet, reticulum, meshtastic, mesh_core, bluetooth, local_network) andTransportPolicy { max_payload_bytes, allow_images, allow_video, compress, batch_telemetry }with built-in per-class defaults (TransportPolicy::for_class). Deserialize-only — never embedded inEnvelope, never on the relay-ipc wire.- config: the
transports:block maps a plugin to a class plus optional per-field overrides;Config::transport_policy(plugin)resolves config-class+overrides → per-protocol default (default_transport_class_for) → the terrestrial anchor. Live-reloadable, validated liketransport_budgets. - engine egress: effective payload cap = min(plugin
Capabilities::max_payload, transportmax_payload_bytes); image/video attachments demote to a note when the transport disallows media (relayfabric_transport_demoted_total). SEALED routes are exempt (SPEC §113.4) — structural, not a shared call site. - Key invariant (must survive Phase 2): backward-compat is provably
non-constraining.
TERRESTRIAL_MAX_PAYLOAD_BYTES == relay_ipc::MAX_FRAME(16 MiB), somin()is a no-op for every existing internet route, and a config with notransports:block behaves byte-identically to v0.3. Locked by a SHA-verified regression e2e.
Phase 1 keys everything statically: one class per plugin, chosen by config
at load time. compress and batch_telemetry are parsed and carried but
not yet acted on — they are Phase-2/Phase-3 consumers.
Motivation¶
Two operator realities drove the transport-class model, and both point at the same next step:
- T-Satellite is a transport, not a protocol. A phone that drifts from Wi-Fi to cellular to satellite is the same node running the same protocols over links whose class changes at runtime. Static plugin→class mapping can't express that; the policy engine needs to re-derive when the link changes.
- The Android edge node. The most useful place for a
transport-adaptive RelayFabric node is a handset: BLE and Wi-Fi Aware
nearby, cellular normally, satellite when off-grid.
switchyarddis a bin-only crate — nothing today is linkable from an app.
Priority call (2026-08-17): Android edge node ranks above further WebUI work. This proposal is the Android prerequisite.
Design¶
1. Extract relayfabric-core¶
Pull the protocol-independent heart of switchyardd into a reusable
library crate consumed by both the daemon and the Android node:
Moves to relayfabric-core |
Stays in switchyardd |
|---|---|
| routing decision + fan-out logic (engine's pure parts) | tokio runtime, supervision, admin API/socket |
policy evaluation (policy.rs) |
plugin process management (plugins.rs) |
transform/truncation (transform.rs) |
SQLite storage backend (storage.rs) |
envelope + transport model (already relay-core — folds in or is re-exported) |
federation listener/dialer (fed/conn.rs) |
| fed crypto primitives (sign/seal/keyfile) | metrics endpoint, config file loading |
Constraints:
relay-ipcgolden frames stay exactly where they are; the wire format is the compatibility contract and does not move or change.- The daemon's behavior is byte-identical after extraction — this is a
cargore-layering, not a rewrite. The existing e2e suite is the gate. - No
tokio(or any runtime) dependency in the core crate's API surface: the Android side drives it from its own executor/bindings.
2. The PlatformTransport trait¶
The seam that makes transport class dynamic:
pub trait PlatformTransport {
/// The class of the link currently carrying this transport's traffic.
fn current_class(&self) -> TransportClass;
/// Fires when the platform detects a link transition (Wi-Fi -> cell ->
/// satellite, BLE joins/leaves). The core re-derives TransportPolicy
/// and re-applies egress caps/demotion on the next dispatch.
fn subscribe_transitions(&self, cb: Box<dyn Fn(TransportClass) + Send>);
}
- Daemon implementation: Python-plugins-over-IPC, class fixed by the
existing
transports:config — Phase 1 behavior, now expressed as onePlatformTransportimpl whosecurrent_classnever changes. Zero operator-visible difference. - Android implementation: native transports (BLE, Wi-Fi Aware/LAN,
cellular, satellite) with
current_classderived fromConnectivityManager/NetworkCapabilities(satellite detection is API 35+). A transition re-derives the policy: the same message that carries an image over Wi-Fi demotes it over satellite, with no restart and no config change. - Policy re-derivation reuses
Config::transport_policy's precedence (overrides > class default > terrestrial anchor); only the class input becomes live instead of load-time. This is where Phase 1's carried-but- unreadcompress/batch_telemetryflags gain their first consumer (satellite/mesh classes want both).
3. The Android edge node¶
- Rust core via UniFFI, Compose UI. The app links
relayfabric-core; no daemon, no Unix sockets, no Python. - Reticulum via Prns (Rust, transport-only, permissive license) — Python RNS won't run in-app, and the richer Rust alternatives are license-excluded (LXMF-rs is EPL-2.0; project policy is permissive-only, clean-room if needed). Scope Reticulum support to what Prns provides; LXMF message semantics stay daemon-side until a permissive path exists.
- Reach expectations: mesh/BLE/Wi-Fi features are broadly available; satellite is API 35+/US-carrier near-term. The app must degrade to "no satellite class detectable" cleanly on older devices.
Non-goals¶
- No new wire protocol, no relay-ipc changes, no Envelope changes.
- No sealed-routing changes: the §113.4 exemption structure carries over.
- Not a daemon rewrite —
switchyarddkeeps its exact behavior and tests. - iOS is out of scope for this phase.
Open questions¶
- T-Mobile SatelliteApps program outreach (async, in flight as of 2026-08-17) may shape what satellite-class detection/entitlement looks like in practice.
- Phase 1's characteristics table was folded into
for_class(2026-08-18 simplification pass) since policy was its only consumer. If dynamic transitions need richer link inputs (measured bandwidth, metering signals fromNetworkCapabilities), reintroduce a characteristics type at thePlatformTransportboundary — fed by the platform, not by a static table. - Storage on Android:
storage.rsstays daemon-side this phase; the app needs a minimal queue/dedup story (likely the same SQLite schema behind a platform path) — to be specced with the app's first delivery task.
Phasing¶
- Core extraction — move the table above into
relayfabric-core; daemon e2e suite green, byte-identical behavior. PlatformTransportseam — daemon impl (static class), engine egress reads the class through the trait; regression e2e still SHA-identical.- Android skeleton — UniFFI bindings over the core, Compose shell, BLE/Wi-Fi classes with live transitions.
- Satellite + Prns — API-35 satellite detection, Reticulum transport via Prns, field test against the live RNS backbone.