Jochen: a normal application has 3-5 ADRs, maybe 10 for a large one, and we are at 65. Fair, and the cause is mine -- I recorded every FINDING as a decision rather than every fork in the road. Two merges, both cases where one decision had been split across many records because it was taken over several days rather than at once. 0019 absorbs ten records about how this repository works: what it is and that it is public, the folder flow, the two design layers, the issue front door, status in frontmatter, playbooks, the naming rule, the product name. Those were never ten decisions -- they were one, seen from ten angles as the repository took shape. 0016 absorbs the five about the lab: a node is a virtual machine, a router is scenery, a scenario declares the underlay, a scenario is a closed address space, and the two scenario classes. Same pattern -- one design, split by the order it was worked out in. The consolidated 0019 also raises the bar for what earns a record, since that is what produced 65: a record is warranted when there is a genuine fork -- a direction reversed, an alternative that will be proposed again, something contested. A finding is not a decision, and a bug is certainly not. Everything else belongs in the design document where the reasoning is actually read. The checker earned its place here. Deleting nine records left 13 dangling links across the repository and it named every one, including in AGENTS.md. Nothing was found by reading. Remaining clusters worth the same treatment: the host (8 records), delivery (5), modules (6), connectivity (4), substrate and control plane (4). That would be 52 down to roughly 30.
6.6 KiB
status, date, deciders, reconstructed, extends
| status | date | deciders | reconstructed | extends |
|---|---|---|---|---|
| accepted | 2026-08-27 | jochen | false | 0016-the-lab.md |
50. Reachability is declared, not inferred from an address
Context
The overlay's peer graph is computed on each node by the wireguard module, which decides —
per pair — whether to write an Endpoint for a peer. The rule
(research 004) is a regular expression:
const isPrivate = (a) => /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a);
Not private ⇒ assumed reachable ⇒ an Endpoint is written. Private ⇒ no endpoint, and the peer
must initiate.
The module's own comments record what this cost to arrive at: testing profile === "server"
was tried and was wrong, because a home-hosted node is a server and is not publicly
reachable — "role does not imply reachability; the address does."
That lesson is right and the implementation of it is not. The address is evidence of reachability; it is not the fact itself, and the gap between the two has already caused failures and will cause more:
| Address | The regex says | Actually |
|---|---|---|
100.64.0.0/10 — carrier-grade NAT (RFC 6598) |
public | not reachable. An endpoint is written to an address nothing can reach. |
any IPv6 address, including fd00::/8 unique-local |
public | unique-local is not reachable; the regex tests v4 shapes only |
| a routable address behind a closed firewall | public | not reachable |
10.200.0.0/24 standing in for a public segment |
private | reachable — this is the lab bug |
The CGNAT row is the serious one. A node on a carrier-grade NAT address presents exactly the failure already recorded for the hairpin case: 1.77 MiB sent, 0 B received, no handshake. The mesh silently never forms, and it presents as a WireGuard fault rather than an addressing one.
The lab row is the same bug seen from the other side. Research 004 calls the required substitution "the single most important fact in this document" — a simulated public segment must use TEST-NET-3, or nothing can ever initiate. A test environment having to choose its addresses to satisfy a regex is the regex telling us it is not a fact.
There is a second inference in the same code, and it is worse because nothing records it:
Hub election is by convention. The hub is the node whose
profile='server'and whose overlay address begins10.10.0.1. A lab must assign that address to the node it intends as hub or there will be no hub — and nothing says so.
An election decided by the first four characters of an address is not an election. It fails silently, it cannot be queried, and it makes a renumbering into an outage.
Considered options
- Fix the regex. Add CGNAT, add IPv6, add the ranges as they are discovered. Rejected: the list is unbounded, and each addition is written after the outage that revealed it. The firewall case cannot be fixed at all — no address shape encodes it.
- Probe for reachability and cache the answer. Attractive, and wrong as the primary source: at the moment the graph is computed a node may be legitimately down, and a probe cannot distinguish unreachable from asleep (ADR 0036). Deriving topology from a liveness check makes the overlay flap with the network.
- Declare it, and let observation contradict it. Chosen.
Decision
A node's reachability is a declared fact on its record, not an inference from its address.
Two facts, and the mesh stores both:
- an endpoint, or none — where peers may reach this node, if anywhere. Absent means this node initiates and is never dialled, which is the safe default and the common case.
- its role in the overlay — whether it is a hub. Declared, never derived from an address.
The address remains evidence and stops being the fact. When a node's observed endpoint disagrees with its declared one, that is a reportable condition, not a silent correction — the same discipline as ADR 0035: a picture is read from the system, and where the reading disagrees with the intent, the disagreement is the finding.
The peer graph is computed by the control plane, from these declared facts, and delivered to
each node as configuration. It is not computed on the node, which is
ADR 0037 and is what removes wireguard's direct
database connection (ADR 0049 does the same for the proxy).
What does not change is the rule the comment was defending. Role still does not imply reachability — a home-hosted node is still a server that cannot be dialled. This record keeps that lesson and stops encoding it as a pattern match.
Consequences
- CGNAT and IPv6 nodes become expressible, which today they are not. Neither needs a code change to support; they need a field that says what is true.
- The lab stops needing its substitution. TEST-NET-3 remains the right choice for a documentation range, but the scenario now says this segment is reachable rather than relying on an address shape to imply it. The constraint research 004 calls its most important fact becomes an ordinary declaration, and the validator's enforcement of it becomes unnecessary rather than load-bearing.
- Hub election becomes queryable and renumbering becomes safe. Both follow from the same change and neither is possible today.
- Two facts can now disagree, and something must say so. Declared-versus-observed is a new reportable condition and a new way to be wrong — a node declared reachable that is not will fail exactly as it does today until somebody looks. The gain is that looking is now possible; the regex offered nothing to compare against.
- Somebody must set these when a node joins. ADR 0038 has the mesh finish the job after the link, so this is one more thing it finishes — and the honest default (no endpoint, not a hub) is correct for every node except the ones somebody deliberately publishes.
References
- Research 004 — the regex, the hub convention, and the hairpin failure.
- ADR 0037 — why the graph is computed centrally.
- ADR 0035 — declared versus observed.
08-connectivity.md— the design this serves.