Per-class transport

One tunnel, six lanes.

A bonded tunnel is still one pipe — and one misbehaving stream can ruin it for the traffic that matters. The transport-class system gives every message a delivery contract: priority, a deadline, persistence, its own link policy, or parity. Opt-in, wire-compatible, and byte-identical when off.

Shipped · opt-in ([qos] enabled)No protocol version bump
6service classes, strictly ordered
0.0 msC2 max queue wait under a 20,000 pkt/s video flood — measured in the scripted run
GF(256)systematic Reed–Solomon FEC, implemented from scratch
80–1000 msdeadline budgets, derived from live worst-link RTT
UDPstill the only thing on the wire
0application changes required
The six classes

Six delivery contracts, stated as a table.

Classification is DSCP-first (CS6/CS7 → Control, EF → Voice, AF41 → Video, CS1 → Bulk), then port heuristics (MAVLink 14550/14551 → Control, RTP ranges → Video), then a safe fallback. iperf lands in Bulk while a MAVLink heartbeat rides Control — with zero app changes.

ClassReliability contractDefault multipathBudget / floor
Control / C2ReliableBounded — duplicated rather than retried, never starvedDuplicate ×280 ms · 32 kbps floor
VoiceBestEffort, latest-frame semanticsDuplicate ×2180 ms · 16 kbps floor
PositionLatestValue — a newer fix always beats retransmitting an old oneSingleBest—
TelemetryBestEffortAggregate250 ms
Video / ISRDeadlineBounded — too-late frames are dropped, not resentAggregate200 ms
BulkStoreCarryForward — waits out blackouts; survives reboots on the file backendAggregateno deadline

This is the shipping tactical_default policy table — used whenever the policy file is missing or corrupt, and the floor every adaptive behaviour returns to. Class + reliability pack into a previously unused header byte: old nodes simply ignore it.

What each kind of data needs

The lane is the contract; the primitive is how the contract is kept.

DataThe failure that mattersThe primitive that fitsLane
Live videoa late framedrop it once it is too late for its latency budget, never queue itVideo
Voice and push-to-talka lost frame, a delayed frameduplication across links. When Voice has to shed, it drops its oldest queued frame, the one closest to its deadline, never the newest, so the frames that still have time are the ones that cross.Voice
Position, attitude, batterya stale valuekeep only the latest, on the single best linkPosition
Commandsa lost commandduplication across links; time-diversity repeats on a single bearerControl
Primary vs secondary camerathe wrong stream survivingprecedence inside the laneVideo
Telemetry logs, still imagesnothing urgent, nothing loststore-carry-forward, erasure codingBulkTelemetry
Chat and reportslosing onestore-carry-forward with a short lifetimeBulk

Time-diversity repeats. When a node is down to its last link, duplication across links can no longer protect commands, so each Control packet is sent again in time instead: up to three copies, a third of a round trip apart, all inside the packet’s deadline. The receiver keeps the first copy and its replay window discards the rest; repeats stop as soon as a second link is alive and stay within a fifth of the link’s capacity.

Within a lane Shipped · opt-in

Two cameras, one lane, and a clear order of loss.

Two cameras in the Video lane share one queue. When the bond narrows, the lane has to shed, and on class alone it sheds blind: the thermal camera and the primary camera lose packets in equal measure, so the feed that matters freezes as often as the one that does not.

ONE VIDEO LANE, THREE CAMERAS THE BOND NARROWS → primarylevel 0 · AF41 secondarylevel 1 · AF42 tertiarylevel 2 · AF43 tertiary shed first then secondary the primary camera is the last to lose a frame
PRIMARYserved first and shed last — carried whole while the bond narrows
SECONDARYgives way once the tertiary stream is already gone
TERTIARYthe first to thin and the first to stop
Three levels

Served lowest first, shed highest first

Inside a lane each packet carries one of three precedence levels — primary, secondary or tertiary — in two header bits that older releases ignore. The class queue serves the lowest level first and sheds the highest first. Under saturation the lower levels also give way where drops actually happen: a link’s queue refuses them while it still has room for the primary, and the deadline check gives them a shorter share of the time left, so a growing backlog sheds the secondary stream long before the primary is touched. Each level can carry its own latency budget.

Marks or rules

The camera’s own mark, or a rule for a camera that cannot mark

A camera that sets DSCP chooses its level itself: AF41, AF42 and AF43 put video at primary, secondary and tertiary, and AF31 to AF33 do the same for telemetry. A device that cannot mark gets a flow rule — a source address, a destination port or both, with a class and a level — and a rule wins over a mark. Per-level counters show what each level sent and lost.

Governor

The encoder steps down in the same order

Run the video governor once per camera with that camera’s level. It holds a camera’s bitrate while only lower levels are being shed, and steps down the camera whose level is being shed, so encoders and the transport give way in the same order.

Lineage

Assured Forwarding’s drop precedence, inside a bonded lane

The shape is the drop precedence of RFC 2597: within one class, packets marked with a higher precedence are discarded first. Atlas applies it per lane, at the class queue and at every link queue below it.

config.toml — a secondary camera that cannot mark
[[qos.flow]]
src = "10.0.9.21/32"   # the thermal camera
dst_port = 8554
class = "Video"
level = 1              # 0 primary · 1 secondary · 2 tertiary
Priority lanes K1 · Triage

Video can’t starve your control link.

Strict priority with per-class reserved-rate floors (measured over a 1-second sliding window) and a tail-drop AQM on the shared byte budget. A class sitting below its reserved floor is served before strict priority is applied at all — and the floors are on the two highest classes, 32 kbps for Control and 16 kbps for Voice, which is precisely what guarantees the command channel gets through. Classes without a floor, Video and Bulk among them, are the ones a flood is allowed to squeeze. When the shared backlog budget is full, a newcomer is admitted first and the queue sheds from the lowest-priority lane that has anything queued — so a command packet arriving into a backlog of video evicts video, and is never itself the packet refused.

Flood the tunnel — the command channel doesn’t careSimulation
0.0 msC2 max queue wait
0video offered
0video shed by AQM
The measured original: in the scripted run, C2 max-wait held 0.0 ms at 200 / 1,000 / 5,000 / 20,000 pkt/s offered video, while AQM-dropped video climbed 0 → 1,046 → 9,042 → 39,027. Measured

Every drop has a reason

A dropped packet is a decision, and each decision is counted where it was taken: in the class queue, per class; in each link’s own send queue, per link and per class.

ReasonWhat it meansCountedWhat an operator does
Late at the class queueThe packet outlived its class budget while it waited behind higher lanes, and was dropped at dequeue instead of being sent late.Per classExpected for Video under a flood. If it never stops, the bond is short of capacity for the lane, or the budget is tighter than the paths allow.
Late at the linkThe packet left the class queue in time, then ran out of budget in a link’s queue and was dropped there.Per link and classThe link is slower than it claims or is saturated. Check its queue delay and its declared capacity.
Queue fullA link’s send queue refused the packet.Per link and classThe link is offered more than it drains. Keep the heavy classes off it, or let the scheduler see its real capacity.
Abandoned before sendA fragmented packet lost a piece at a full link queue before any piece was accepted; none of it went out.Per linkNothing on its own: it is the queue-full figure, seen as whole packets.
Abandoned after sendPart of the packet had been accepted when a later piece was refused; the rest was withdrawn from the link.Per link and classThe waste figure: airtime spent on a packet the far end cannot use. Move the class off that bearer.

The backlog shedding above is counted as its own reason, and so is a packet that no link could deliver before its deadline, refused at planning. Every figure is in the per-second snapshot, in GET /api/qos per class, on the dashboard, and in the Prometheus export by class and reason. Counting changes nothing about what is dropped. Why packets are abandoned whole.

Deadline delivery K2 · Tempo

Bends, not breaks.

Every packet carries a latency budget — 3.5× the worst alive link’s RTT, clamped to 80–1000 ms — and expired packets are dropped at dequeue. A frame that sat behind a burst is discarded instead of arriving uselessly late and delaying fresher ones.

The same 10 fps feed through three transports, under a periodic loss burstSimulation
0.0 sTCP time frozen
0UDP frames torn
0Tempo frames shed late
Reorder smoothing (opt-in): an optional receive-side release buffer holds Video-class packets briefly (default 120 ms) and releases them in sequence order, late-dropping past the watermark — multipath reorder and jitter smoothing so the decoder sees a steady stream. Off by default; video-only by design.
Store-carry-forward K3 · Portage

Survive the blackout.

Bulk traffic to an unreachable peer is held — in memory, or in a write-ahead-log file backend that survives a reboot — and drained priority-then-FIFO the instant any link returns. Caps, lifetimes and an expiry janitor keep the store honest (16 MB / 10,000 bundles / 10 min by default).

Kill every link and keep sending — the data mule patternSimulation
0bundles queued
0delivered
0expired by the janitor
Field-proven as the data-mule demo: all links cut, CS1-marked messages kept flowing into the store, one link restored — and the burst arrives. The reboot button is the point of the WAL backend.
Complete footage Shipped

The live view bends. The full footage still arrives.

Live video drops what arrives too late: right for the pilot, wrong for the archive a search or an incident review needs. Mark a class for retention and the sender keeps every packet of it; the receiver asks for exactly what it missed, and only those packets cross, on the lowest lane, during the flight or after it.

Dronekeeps a journalof the class Ground stationreads the gapskeeps the capture LIVE STREAM · VIDEO LANE 101 102 103 104 105 106 107 108 109 110 GAP REQUEST · AFTER 2 S missing: 104, 108 BACKFILL · BULK LANE, BELOW EVERY LIVE CLASS 104 108 CAPTURE FILE · EVERY PACKET ONCE, IN ORDER 101 102 103 104 105 106 107 108 109 110 only the missing packets cross; the capture holds every packet of the stream once, in sequence order the live view never waits: a backfilled packet goes to the capture, not to the application
LIVE STREAM101 to 110 sent, 104 and 108 missing at the ground station
GAP REQUEST · BACKFILLthe ground station asks for 104 and 108; only those two cross, on the Bulk lane
CAPTURE FILE101 to 110, every packet once, in order
Missing

Two kinds of missing

The sender’s own deadline drops a frame that would arrive too late, or the air loses a packet the sender did send. Neither end needs to know which: every packet of a retained class is numbered where it enters the tunnel, before the class queue, so a hole in the sequence is a hole whatever made it.

Retention

A journal at the sender

With retain = true on a class, the sender journals a copy of every packet to disk per destination, bounded by size and age ([qos.retain] max_bytes, max_secs: 2 GiB and 24 hours by default). With the node’s disk encryption on, each record is sealed under its at-rest key. If the disk falls behind, the journal copy is dropped and counted, never the live packet.

Gap requests

The receiver asks for ranges

The receiver writes every packet of a retained stream once to a capture and tracks the holes per origin, class and stream. After two seconds, so reordering and FEC get their chance, it asks the origin for the missing ranges. The request travels on the end-to-end control channel, routed, relayed and sealed like data. The origin answers with the stream’s highest sequence, so a lost tail is requested too, and marks what its journal no longer holds as unrecoverable.

Bulk lane

Only the missing packets cross, below everything else

Requested packets go out on the Bulk lane only when no live class has anything queued, and only over links with a short send queue and no standing queue; a rate cap is optional. They go to the capture, never to the application, so the live view is never held back. With no path, the journal holds them and the receiver asks again when a path returns, during the flight or after landing. store-carry-forward ↗

Capture file

Packet-complete, in order

The capture holds every packet once, in sequence order, with its original send time. atlasd capture status and the dashboard’s archive-completeness figure show how complete each stream is and what cannot be recovered. A converter outside the daemon turns an export into pcapng with the original timestamps, or replays it paced so a standard muxer writes a playable file. Five minutes of video at 5 % loss with periodic blackouts was complete about 14 seconds after the loss stopped, byte for byte the sender’s copy; after a two-minute cut the archive completed within about a minute of the link’s return. Measured

The other way

Record on the vehicle, send the files

When the archive should be a better encode than the live stream, record whole chunks on the vehicle with its own recorder and send the files over the tunnel marked Bulk, the lowest lane. The uplink carries the footage twice, once live and once as files, and in exchange the archive can be any quality the vehicle records.

Boundaries

Packets, not frames

Atlas completes packets; frames are what a decoder makes of them, and the converter is a tool beside the daemon. A frame lost on the vehicle before it reaches the tunnel cannot be backfilled. A capture at its size cap stops growing until it is cleared; it never deletes footage by itself. Retention covers unicast streams, and a store-carry-forward class, already reliable, is not retained.

Per-class bonding K4 · Loom

The right links for each job.

Each class carries its own multipath policy — SingleBest, Duplicate{copies}, Aggregate, or Fec{overhead} — overriding the global scheduler. Cost is (RTT/2 + jitter) × 1/(1−loss) from live probes; dead links are never selected.

Three classes, three links — jam one and watch each class re-planSimulation
LTE + RFD900Control duplicates on
3 linksVideo aggregates across
LTEPosition rides
Erasure recovery Aegis

Repair loss without asking for it again.

Systematic Reed–Solomon over GF(256), implemented from scratch: K data packets gain M parity shards, and any K of the K+M rebuild the block — no retransmission, no return-channel dependency, nothing asked for twice. On the send path, shards are striped across the alive links weighted by effective goodput, and parity is floored against the largest per-link share — so a whole link going dark stays a recoverable erasure while that link carries at most half the stripe (the stats report single_link_loss_recoverable honestly either way). One honest cost note: on a two-link bond the parity floor equals plain duplication — prefer Duplicate there. Triple-gated: [qos] enabled, the global fec_enabled switch, and a class whose policy is Fec.

K=8 data + M=2 parity, one shard per link — the striped send path, simulatedSimulation
0blocks delivered
0rebuilt from parity
0blocks lost
Cost model: 1-in-8 loss on a degraded link becomes invisible at 12.5% overhead (M=1) — against ARQ’s answer of a full round-trip per loss, on links where the round-trip is the problem. Streaming encoder flushes partial blocks after 60 ms idle; incomplete blocks expire after 200 ms.
Adaptive escalation Loom-Adapt

Each class defends itself.

New in 2026-08. With QoS on, classified traffic takes its links from the class policy — so the global adaptive strategies govern unclassified traffic only. The per-class adapt block closes that composition gap: a class escalates its own redundancy when the links it is using measurably degrade, and steps back down on recovery.

The ladder

Escalate within the class’s own vocabulary

Duplicate{n} steps n → n+1 up to a cap (default 3). Aggregate steps to Fec{20%}, then +10 points per level to a 60% ceiling — and only when the global fec_enabled allows FEC; escalation never flips that switch itself. SingleBest classes never escalate: they chose freshness over redundancy, and duplicating LatestValue traffic only adds stale copies. The configured policy is always the floor it returns to.

Triggers

Enter at 20% loss, exit at 14%, dwell 5 s — both directions

Per-(peer, class) state with hysteresis matching the global loss_adaptive defaults, plus optional RTT thresholds. Unlike the global strategies, the dwell gates escalation too — deliberately, because escalation spends shared budget.

Guards

Two non-negotiable checks before every step

An escalation_guard bounds the estimated extra bandwidth of all escalated classes to 30% of the peer’s alive-link capacity, and nothing escalates while queues sit deep — loss caused by congestion must never trigger load amplification. Skips are recorded (budget_skip, congestion_skip), every transition is logged, and the QoS panel shows base → effective policy with a live budget meter.

Measured

Proven in the namespace suite

The escalation suite injects 30% loss with kernel-level impairment and asserts the enter / exit / budget-refuse transitions live against two real daemons. Under that loss, Control escalates Duplicate ×2 → ×3 within seconds, and returns to ×2 after the dwell once the interference stops.

Lineage

Where the ideas come from.

None of the delivery contracts above was invented here. Each is an idea with a standards-track history, applied to a bonded multi-link tunnel instead of to a single connection. An advisor who knows the documents will recognise the shapes; the differences are the points where a bond and a connection part ways.

RFC 3758

Deadline delivery is timed reliability, with a live budget.

Partial-reliability SCTP lets a sender attach a lifetime to a message and abandon it unsent when the lifetime expires, telling the receiver to skip ahead rather than wait. Tempo is that contract applied per packet: a latency budget stamped at admission, expiry checked at dequeue, the packet discarded instead of transmitted late. The difference is where the budget comes from. A connection takes it from the application; a bond takes it from its class policy, either fixed per class or derived from the bond itself, 3.5× the worst alive link’s round trip, clamped, so a derived budget tightens when the paths are good and loosens when they are not.

RFC 7496

Backlog shedding is the priority policy.

The second PR-SCTP document adds a priority policy: when a new message does not fit in the send buffer, the sender may abandon buffered messages of lower priority to make room. The class queue does exactly that. A packet is admitted first; then, while the shared backlog exceeds its budget, the queue sheds from the lowest-priority lane that has anything queued. A Control packet arriving into a backlog of Video evicts video; it is never the one refused. The eviction is counted per class and reported as dropped_aqm in the class statistics.

RFC 8655

Disjoint delivery with dedup is replication and elimination.

Deterministic Networking protects a flow by replicating packets over disjoint paths, eliminating the duplicates where the paths merge, and reordering after that. Atlas does the first two in the packet lifecycle: a class whose policy duplicates sends one copy per path, the receiver keeps the first arrival and discards the rest, and the mesh’s dual-path routing keeps the copies on different first hops. DetNet reaches its loss figure by reserving capacity along the path as well; Atlas reaches its figure with path diversity and per-class floors, and says so plainly on the design page.

DetNet explicit routes

Pinned paths are explicit routes.

The same architecture pins a flow to a route so it does not move mid-stream, because a reroute is itself a jitter event. Operator-pinned static paths are Atlas’s version: a named sequence of links and hops that the scheduler honours without adaptation, for rehearsed operations where predictability outranks reaction. The rest of the traffic keeps the link-state routing; the pin applies to the flows the operator names.

The full list, with the relationship stated for each standard, is on the Standards page.

How it fits

The QoS pipeline, in order.

All of it sits between classification and the scheduler — and with [qos] enabled = false, none of it exists: the data path is byte-identical to the pre-QoS engine. One rule below it holds with QoS on or off: each link’s send queue carries packets whole, and a packet whose fragment a link refuses is withdrawn from that link entirely, pieces already queued included.

ClassifyDSCP → heuristics → fallback; class byte set. Queue (K1)Strict priority + reserved floors + AQM. Deadline (K2)Budget stamped; expired packets shed at dequeue. Select (K4)Per-class link set from live costs. EscalateLoom-Adapt steps redundancy, guards permitting. Parity (Aegis)K+M shards; any K rebuild the block. Store (K3)No path? Bulk waits in the bundle store.
config.toml — the [qos] gate
[qos]
enabled = true          # default false — off means byte-identical
fec_enabled = false     # the global Aegis switch (second gate)
fec_block_packets = 8   # K
fec_flush_ms = 60
fec_block_timeout_ms = 200
bundle_backend = "file" # Portage survives reboots
rx_release_buffer = false
GET/api/qos?interface=atlas0

The live policy + per-class counters (enqueued, sent, dropped-late, dropped-AQM, escalation state).

PUT/api/qos

Merge-update the policy — bump Video’s budget mid-demo, no restart. Absent blocks are preserved.

Runnable crate examples ship with the source: k1_no_starve, k2_bend_not_break, k4_bonding, recover, k3_blackout.

Request an evaluation

Six contracts. One tunnel. Zero app changes.

Mark the traffic (or let the classifier do it) and every message gets the delivery contract its job deserves — on the links you already bonded.