568×: the phone call, the batching rule and the bug that would have dialled
Adding a dial-up telephone carrier to a LoRa mesh exposed a routing bug that no test caught — it would have been discovered as a phone bill.
One of the stranger carriers in Dictyon-net is an analog telephone line. The PSTN32 design is a field node that bridges LoRa, Wi-Fi, Ethernet and a live telephone line, and keeps the phone asleep until traffic has earned a call — think of a village node that syncs over copper once a day. It sounds like a museum piece. It turned out to be the best test the routing code ever had. (Honesty note: the modem driver is written from the V.250 standard and hasn't met a real modem yet; everything below is arithmetic and code, not a phone bill.)
A phone call is priced per session, not per byte
A call's time splits into dead time — dial, ring, modem training, teardown — and payload. Dead time doesn't shrink with the message:
| profile | dead time | 200-byte message alone | appended to a live call | ratio |
|---|---|---|---|---|
| V.34 cold | 27.0 s | 27.0 s | 47.6 ms | 568× |
| V.92 warm | 16.0 s | 16.0 s | 47.6 ms | 337× |
| V.32bis, poor line | 33.0 s | 33.1 s | 111.1 ms | 298× |
The design note said "don't place one call per message; batch." That's not a preference. It's two orders of magnitude. A stack that dials per message isn't slow, it's broken.
The less obvious result: break-even sits around 300–570 queued messages. A LoRa-fed village node might take days to collect that many. So "wait until the queue justifies a call" means never calling. The dial trigger has to be a deadline — the oldest message's age, an urgent flag or a scheduled window. Queue depth is an upper bound, not a lower one.
The bug
Route selection ranked paths by (hops, -quality, -last_seen). Hop count first, and nothing about the carrier.
That's correct as long as every carrier is roughly free. The moment a carrier costs a phone call, it isn't: a one-hop phone route beats a two-hop LoRa route on hops and loses on every other axis. A node with a phone line would have dialled rather than use a free relay.
Nothing caught it, because nothing fails. The network works; it just makes calls it didn't need to make. It would have been discovered as a phone bill.
The fix: rank the kind of path before its length
Each carrier now declares a route class, ranked ahead of hop count:
| class | carriers |
|---|---|
| 0 | a direct wire or radio to the peer |
| 1 | local mesh: LoRa, HaLow, Wi-Fi, Ethernet |
| 2 | IP backhaul |
| 3 | demand-dial: telephone, cellular |
| 4 | long-haul: HF, satellite pass |
| 5 | store-and-wait: sneakernet, custody |
The ordering lives in exactly one function, so it can't drift between "update" and "best". The default class is local mesh, so every carrier written before the change behaves exactly as before. And there are now regression tests for the case that was never tested: a cheap two-hop path must beat an expensive one-hop path.
The lesson
The bug wasn't new; it was latent until a carrier with a real cost existed. Writing down the preference ladder in plain English — "never dial if a relay will do" — is what exposed it, because the code couldn't express that sentence.
I've seen the same pattern in enterprise networks: routing metrics that were fine until a metered link joined the topology. The fix is the same too. Make cost a first-class property of the path, not something you hope the operator remembers.