Files
hq/02-DECISIONS/0050-reachability-is-a-property-of-the-address.md
T
jschoubben 5e83ac2c22 Consolidate: 65 decision records to 52
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.
2026-08-28 18:53:19 +02:00

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

  1. 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.
  2. 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.
  3. 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