Interface Isolation
The single most important architectural rule in Leviculum:
Only the interface knows the quirks of its carrier medium. The core, the transport, and the daemon are media-agnostic.
A packet is a packet. At the boundary where the core hands bytes to an interface, there is no distinction between an announce, a link request, a data packet, or a resource chunk. They are all just bytes.
What "media-agnostic core" means
leviculum-core decides what to send, to which interface, and —
on an interface that carries several peers — for which peer (the
peer hint on Action::SendPacket, Codeberg #376: an identity, never
a link, a handle or an address, so the interface still owns the map
from peer to link). It never decides when to put a frame on the
wire, never spaces transmissions, and never reasons about
contention. The core processes
every packet with zero delay and emits an Action::SendPacket or
Action::Broadcast immediately (see Architecture).
Because the core is the same code on a Linux daemon, an Android app,
and an nRF52 firmware image, it cannot afford to know whether the
medium underneath is a fibre-fast TCP socket or a half-duplex LoRa
radio whose airtime budget is measured in minutes. Medium awareness
lives entirely on the far side of the
Interface trait.
What an interface is allowed to know
A LoRa interface knows it cannot transmit and receive at the same
time. It knows its RadioSettings (bandwidth, spreading factor,
coding rate) and therefore the airtime cost of any given frame. It
holds packets back, applies its own randomised pre-TX jitter on top of
the RNode firmware's CSMA, and refuses new frames when its airtime
budget is exhausted. Concretely:
- Send-side jitter — packets are queued, not sent immediately; a
frame that acquires an idle channel first serves a randomised wait,
DIFS plus a contention window sized from the radio parameters, so two
nodes do not re-collide (
leviculum-std/src/interfaces/rnode.rs:2742-2773, where the TX loop arms it; the ceiling it reports iscompute_jitter_max_ms,leviculum-std/src/interfaces/rnode.rs:293-303). - CSMA — radio-level carrier sensing is handled by the RNode
firmware on top; the interface hands the modem one frame at a time so
that CSMA runs for every frame, and none of this reaches the core
(
leviculum-std/src/interfaces/rnode.rs:2264-2273). - Airtime backpressure — a per-interface credit bucket charges
every send by its airtime cost and signals
BufferFullrather than flooding the serial queue (leviculum-std/src/interfaces/airtime.rs:1). This explicitly "never leaks intoleviculum-core, so theno_stdcore stays free of host-side backpressure concerns" (same file).
A TCP interface has none of this. It just writes bytes
(leviculum-std/src/interfaces/tcp.rs).
Why the rule is hard, not advisory
The rule binds anyone writing a fix. If a proposed fix for a
collision, contention, or duplex problem introduces an awareness flag
or counter in transport.rs, the node/ modules, or the daemon
("is a link in flight?", "am I forwarding a link request?"), it is at
the wrong layer. Such a fix must be redirected into the interface.
Interface implementations are therefore free to diverge from Python-Reticulum's thin serial-writer style — that divergence is exactly where medium-specific intelligence belongs, and it satisfies the project's deviation rule as long as wire and semantic compatibility are preserved.
Consequences
- The same routing logic runs unchanged over LoRa, TCP, UDP, serial, and the in-process local socket. That includes relaying a packet back out of the interface it arrived on — the same-interface relay decision is taken in the media-agnostic core; the interface is not involved.
- New media are added by implementing one trait, not by threading medium-specific cases through the protocol core.
- Collision-avoidance bugs are debugged in one place — the interface — instead of being smeared across six stack layers.
The one place the medium is named
InterfaceKind (leviculum-core/src/traits.rs) names the carrier —
Tcp, Rnode, Serial, and so on — and that looks like an exception
to the rule. It is not: the kind is reported, never acted on. It
exists so rnstatus can print the Python-RNS interface class name and
so a status consumer can group interfaces by transport instead of by
their peer label.
A match on it outside traits.rs may produce a string, a number or a
status field, and nothing else. As of 2026-07-30 there are exactly two
consumers: transport.rs's sparse-map bookkeeping (where Unknown
means "no entry") and rpc/handlers.rs::interface_type. A third that
decides what the stack does — a longer timeout for LoRa, a skipped
step on serial — is the wrong-layer fix this page describes; widen the
Interface trait so the interface answers the question itself.
This rule is deliberately not machine-checked. A guard could only see
a syntactic comparison against a variant, which is not the shape the
violation takes: an exhaustive match kind returning a timeout reads
identically to one returning a label. Both existing consumers would
need an exemption, so on today's three call sites the exemption list
would be longer than the finding, and it would grow with every
legitimate status field. The rule is stated here and on the enum
instead.
See also: Storage and Embedding for the parallel isolation of persistence and time, and the RNode protocol page for the LoRa carrier details an interface must handle.