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.

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.

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
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.
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.

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.

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.