IFM Station Discovery & Relay Protocol
Version
v0.1 Draft — this specification is a work in progress. Sections marked future development are design intent, not yet implemented.
§1 Scope
This document defines how IFM stations and relays are discovered and how relays are selected, without any central server:
- Stations announce signed metadata so listeners can find them — zero-configuration on a local network, verifiable globally (§4–§14).
- Relays announce signed capabilities and are scored per listener preferences; clients fall back to direct peer transmission when no relay is suitable (§15–§24).
- The discovery plane is a control channel, separate from the audio/chat mesh. Announcements ride the same transport (libp2p gossip topics / mDNS) as ordinary packets, but carry discovery records (§28).
The exact metadata format may evolve independently from the discovery transport (§7).
Status
- Implemented in
crates/discovery(records, identity, presence, scoring),crates/transport(control-plane topics),crates/core(NodeAPI), and theifm station/ifm relayCLI commands. - Future development: §8 (local discovery UX), §26 (desktop behavior), §25 (relay market pricing auctions).
§2 Terminology
| Term | Meaning |
|---|---|
| Station | A node that broadcasts content (audio, chat, files) on frequencies. |
| Relay | Infrastructure node that forwards traffic between peers; frequency-agnostic, in a shared pool. |
| Discovery plane | The control channel carrying station/relay records and presence. |
| Announcement | A signed record a station/relay publishes about itself. |
| Record | A station's signed, durable metadata (§12). |
| Presence | LIVE / STALE / OFFLINE state derived from heartbeat cadence (§10). |
§3 Design Goals
- Zero central server. Discovery is peer-to-peer; the protocol must work offline on a LAN.
- Verifiable. Announcements and records are signed by the identity they describe; nothing is trusted automatically (§14).
- No single mandatory relay. Relays are discovered and scored; when none is suitable, peers transmit directly.
- Stable identity. Station/relay IDs derive from the operator's Ed25519 keypair, not from IP addresses — identity survives network changes (§4).
§4 Station Identity
A station ID is derived from the station operator's Ed25519 public key:
station_id = "ifm1_" || base64url( BLAKE3( public_key ) )- Prefix
ifm1_disambiguates station IDs from relay IDs and node IDs. - The 43-character base64url suffix is the BLAKE3 hash of the public key (URL-safe, no padding).
- Stable across network changes: an operator's station keeps its ID when their IP, NAT, or transport address changes.
- Self-verifying: anyone can check that an ID matches the key that signed a record (§14). A station cannot be impersonated without its keypair.
Relay identities use the same derivation with prefix relay1_ (§17).
§5 Local Discovery
Zero-configuration discovery for stations on the same LAN:
- The transport discovers local peers via mDNS (libp2p).
- Station records are exchanged over the station control topic
/ifm/stations/v1(gossip), carrying serde-taggedDiscoveryOpmessages (§28). - A node ingests records it hears and exposes them through the discovery registry — stations automatically appear without configuration.
Discovery is best-effort and asynchronous: a station's record may arrive before or after the peer is reachable; consumers reconcile via the registry presence model (§10).
§6 Discovery Plane
The discovery plane is a control topic on the same transport as user traffic, so it inherits the mesh's encryption, dedup, and TTL rules:
| Topic | Payload | Purpose |
|---|---|---|
/ifm/stations/v1 | DiscoveryOp | Station announce/update/expire, search |
/ifm/relays/v1 | relay Hello / Bye / Tuned, RelayMsg::Announce | Relay presence + signed announcements |
Records are re-broadcast on a 30-second heartbeat tick (matching the relay Hello cadence), which doubles as liveness signaling (§10).
§7 Station Metadata (Local Announcement)
A lightweight, transport-independent JSON announcement accompanies a station's record on the local plane:
{
"station_id": "ifm1_…",
"name": "IFM News",
"protocol": "ifm/1",
"version": "1",
"status": "live",
"channels": [{ "id": "main", "name": "Main" }],
"public_key": "base64url(ed25519 pk)",
"capabilities": ["chat", "file"]
}The exact metadata format may evolve independently from the discovery transport; consumers MUST ignore unknown fields.
§8 Local Discovery UX (future development)
Listener-facing UI for browsing nearby stations (map/radar view, one-tap tune). Not implemented in v0.1 — the headless core exposes the registry API; a UI layer is out of scope.
§9 Presence Model
A discovery client tracks each known station/relay through three states:
- LIVE — a fresh announcement was heard recently (within
stale_after). - STALE — the announcement lapsed; the entity may still be reachable.
- OFFLINE — no announcement within
offline_after; excluded from selection and eventually pruned.
Only LIVE entities are candidates for relay selection (§19).
§10 Presence Timings
| Parameter | Default | Meaning |
|---|---|---|
| Heartbeat | 30 s | Re-announcement / liveness tick |
stale_after | 90 s | LIVE → STALE |
offline_after | 300 s | STALE → OFFLINE |
Timestamps are Unix seconds. A record with a newer timestamp replaces an older one for the same station; a stale record never replaces a fresh one.
§11 Global Discovery
Beyond the LAN, discovery rides the mesh (DHT-backed in a real deployment):
- A station publishes its signed record to the mesh.
- Nodes cache records and answer lookups/searches from their cache.
- Records carry their own signature and timestamp, so caching is safe — nodes cannot forge or backdate another station's record (§14).
The headless core keeps the registry in-process; the transport wires the control plane (station_records() / relay_announcements() ingestion).
§12 Station Record
A station's durable, signed record:
{
"station_id": "ifm1_…",
"name": "IFM News",
"protocol": "ifm/1",
"status": "live",
"channels": [{ "id": "main", "name": "Main" }],
"endpoints": ["ifm://ifm1_…"],
"public_key": "base64url(ed25519 pk)",
"meta": { "location": "Berlin", "genre": "news" },
"timestamp": 1754760000,
"signature": "base64url(ed25519 sig)"
}endpoints— reachable addresses/URLs.meta— free-form searchable metadata (§13); unknown keys are ignored.- The signature covers the canonical JSON of every other field (§14).
§13 Station Search
A search is a set of ANDed filters; null = no filter:
| Field | Matches |
|---|---|
query | station name, station ID, channel names |
location, language, category, genre, country, region | exact meta keys |
live | only LIVE stations |
channel | channel id/name |
limit | maximum results |
§14 Verification
Discovery information is never trusted automatically:
- The station ID must match the embedded public key:
station_id == "ifm1_" + base64url(BLAKE3(public_key)). - The Ed25519 signature must verify (strict) over the canonical JSON of the record with the signature field emptied (deterministic: fixed field order,
BTreeMap-sorted metadata).
A record failing either check is rejected and never cached or relayed. Because the signature covers the timestamp, records cannot be backdated, post-dated, or re-keyed without the identity's keypair.
§15 Relay Role
Relays are frequency-agnostic infrastructure in a shared pool:
- Any node may serve as a relay (
serve_relay); serving is independent of discovery announcement (§18). - Peers route through any available relay and fall back to direct connections when no relay is reachable (§19).
- Relays are measured (RTT) and scored per-listener (§19–§20).
§16 Relay Pool
The transport maintains a shared pool of relays known to the node, with measured round-trip times (relay_pool(), nearest-first). Discovery joins relay announcements to pool entries via node id (BLAKE3(public_key)), so scoring can use measured RTT rather than advertised values (§20).
§17 Relay Identity
Relay IDs use the same key derivation as stations with the relay1_ prefix:
relay_id = "relay1_" || base64url( BLAKE3( public_key ) )Relay announcements MUST be signed by that key (§14 rules apply).
§18 Relay Announcement
A signed relay announcement:
{
"relay_id": "relay1_…",
"region": "eu",
"protocols": ["quic", "webrtc"],
"capacity": 100,
"bandwidth": "1 Gbps",
"latency_ms": 5,
"price": 0,
"public_key": "base64url(ed25519 pk)",
"timestamp": 1754760000,
"signature": "base64url(ed25519 sig)"
}region— ISO region or tag, used for proximity scoring.protocols— transports the relay speaks (listener requires at least one).capacity— max concurrent connections.bandwidth/latency_ms— advertised capabilities (advertising is cheap; measured RTT wins in scoring, §20).price— per-unit traffic price (0 = free).
Heartbeats (RelayHeartbeat) carry the relay id + fresh timestamp, signed, proving liveness to the key holder.
§19 Relay Selection Scoring
A client ranks LIVE relays against its preferences; the top score wins:
| Weight | Factor | Note |
|---|---|---|
| 0.35 | RTT | measured, via transport pool; lowest wins |
| 0.15 | Capacity | highest wins |
| 0.10 | Bandwidth | advertised |
| 0.10 | Price | lowest wins |
| 0.10 | Region | matches preferred_region (exact or ISO prefix) |
| 0.10 | Protocol | listener's protocol supported |
| 0.10 | Advertised latency | tie-break among equal RTTs |
Preferences (RelayPrefs): preferred_region, protocols, max_price, min_capacity, exclude.
Fallback: when no relay is LIVE/reachable, selection returns none and the peer transmits directly — no single mandatory relay (§3).
§19a Path-Level Selection — P2P-first, quality-driven routing
Relay selection is one piece of a larger rule. The relay is another path in the routing system, not a separate mode: the Path Manager (crates/core/src/path.rs) scores every candidate path — direct peers, alternate peers, and relays in the shared pool — continuously, from measured telemetry, and the best-scoring path carries the media stream:
The decision is quality-driven, not failure-driven:
Path score (measured only — nothing advertised or assumed):
| Weight | Factor | Source |
|---|---|---|
| 0.55 | Latency | measured RTT, normalized over 250 ms (radio scale) |
| 0.15 | Jitter | live media stream arrival jitter |
| 0.10 | Loss | concealed/lost inbound media frames |
| 0.10 | Bandwidth | measured outbound throughput (Mbps) |
| 0.05 | Hops | direct = 1, relay = 2 |
| 0.05 | Stability | reachable/live, capacity-aware (relays) |
Because latency dominates, a relay with a clearly better round-trip beats a degraded P2P path (Station → A → B → Listener at 80 ms vs Station → Relay → Listener at 45 ms moves to the relay), while a healthy 24 ms P2P path stays P2P over a 55 ms relay. The scoring is stateless: Node::best_path() re-scores on every read, so the best path is always the best path right now, from the freshest measurements.
Seamless migration: the alternate path is established while the current path still works, then switched at a known media timestamp — good audio → good audio → good audio, never silence → reconnect → buffer.
Exposed surfaces: Node::path_ranked() / Node::best_path(), and the observability snapshot's p2p block + paths list (the dashboard renders them per node).
§20 Measured RTT
RelayAnnouncement.node_id() derives the transport node id (BLAKE3(public_key)), which is joined to the transport relay pool's measured RTT. Advertised latency is only a tie-break.
§21–§23 Relay Connectivity (future refinement)
Connection establishment via relays (ConnectRelay, §28), capacity-aware admission control, and pool churn handling are iterative refinements layered on §15–§20.
§24 Automatic Failover
When a selected relay fails, the client re-selects with the failed relay excluded (prefs.exclude), no restart required:
§25 Relay Market (future development)
Auctions / bids for relay capacity (price discovery beyond the flat price field). Not implemented in v0.1.
§26 Desktop Behavior (future development)
Desktop-app discovery UX (persistent station lists, tray presence). Not implemented in v0.1.
§27 Implementation Mapping
| Component | Artifact |
|---|---|
Identity (ifm1_ / relay1_) | crates/discovery/src/identity.rs |
| Records, announcements, search, prefs, ops | crates/discovery/src/record.rs |
| Registries, presence, scoring | crates/discovery/src/registry.rs |
| Discovery facade (peers + registries) | crates/discovery/src/lib.rs |
| Control plane (topics, gossip dispatch) | crates/transport/src/libp2p_quic.rs |
| Transport API | crates/transport/src/lib.rs |
| Node API | crates/core/src/node.rs |
| CLI | crates/cli — `ifm station announce |
§28 Protocol Operations
The initial discovery vocabulary. Serialized as kebab-case tagged JSON ({"op": "station-announce", …}) so operations ride any discovery transport:
| Op | Payload | Direction |
|---|---|---|
station-announce | StationRecord | station → plane |
station-update | StationRecord | station → plane |
station-lookup | station_id | client → plane |
station-search | StationSearch | client → plane |
station-expire | station_id | registry → plane |
relay-announce | RelayAnnouncement | relay → plane |
relay-lookup | relay_id | client → plane |
relay-select | RelayPrefs | client → plane |
relay-heartbeat | RelayHeartbeat | relay → plane |
relay-expire | relay_id | registry → plane |
connect-direct | station_id, endpoint | client → station |
connect-relay | station_id, relay_id | client → relay |