Files
hq/02-DECISIONS/0049-connectivity.md
T
jschoubben 77f3a4cea7 Consolidate: 65 decision records to 23
Every remaining cluster merged. Each was one design that had been split across
several records because it was worked out over days rather than at once.

  the node host          8 -> 1    applies not decides, depends on nothing,
                                   per operating system, root service, the
                                   launcher, episodic, what a declaration is,
                                   actions from the bundle only
  a node and how it joins 4 -> 1   what a node is, joining, the link as
                                   security boundary, the enrolment token
  modules and the graph   7 -> 1   everything is a module, no domain modules,
                                   three edges, provisioning, the core library
  substrate and control   6 -> 1   the test, seven contexts, one control plane,
    plane                          the authority is not a database, the named
                                   products, the pinned bundle
  connectivity            3 -> 1   a route is a grant, reachability declared,
                                   filter rules
  delivery                5 -> 1   reconciliation not a pipeline, artifacts,
                                   the three silos, a failed step, the verdict
  the lab                 5 -> 1   (earlier)
  how this repository     10 -> 1  (earlier)
    works

Nothing was dropped. Each consolidated record carries the reasoning of the ones
it absorbs -- the measurements, the incidents, the alternatives rejected --
because that reasoning is the only reason to keep a record at all. What is gone
is the fragmentation: eight files to read to understand tier 0, when tier 0 is
one component.

The four superseded records went too. They existed to point at their
successors, and the successors now contain what they said.

The checker made this safe. Each merge left dangling links -- 38 files after
the host merge alone -- and it named every one. Nothing was found by reading,
and a manual pass would certainly have missed some, including references inside
AGENTS.md which every session loads.
2026-08-28 20:03:24 +02:00

6.2 KiB

status, date, deciders, reconstructed, consolidates
status date deciders reconstructed consolidates
accepted 2026-08-28 jochen false
0050
0052

49. Connectivity

Consolidated 2026-08-28 from three records. Overlay, resolution, exposure, filtering and certificates are one design.

Why it is control-plane work

Apply the test — everything that needs to know about more than one node — and not one of the five can be answered by a machine on its own:

needs to know
overlay — who peers with whom every node, and which can be dialled
resolution — which name is which node every node
exposure — which public name reaches which container which node is publicly reachable
filtering — which port is open, to whom what is assigned here, and the overlay's shape
certificates — who may present which name which name belongs to which node

That is exactly what the current arrangement gets wrong, by computing all five on the node from a direct database connection. Two modules do this, and they are the only two left holding a credential to the control plane's database.

The shape of the fix, once for all five: the connectivity context computes the configuration; it arrives over the link as file resources; the service reads files and knows nothing about the mesh. This costs no new host vocabulary.

A route is a grant

Ingress is not substrate. The control plane does not need a route to start — it listens locally — and no node needs one to reach it, because the node dials out and has no listening control surface. It grants itself a route afterwards, the way it grants itself a bucket.

The strongest objection deserves stating: the api is the one interface every surface speaks to, so eventually it does want a public name. But wanting one later is not needing one to start, and that distinction is the entire substrate test.

A module that must be reachable declares it needs a route; the proxy provides one. Ordinary instantiation, with the direction mirrored — the consumer supplies a target and receives a name.

Exposure is three facts at two scopes, which is why it cannot live on the node:

the fact scope
the public name resolves to an address mesh — which node is publicly reachable
a certificate valid for that name exists mesh — issued once, used on one node
the proxy maps that name to that container node

A node without a public address is proxied by one that has, across the overlay. Most nodes sit behind a connection with no forwarded port, so exposure cannot assume the workload's node is reachable.

Reachability is declared, not inferred

The overlay's peer graph is computed from whether a node can be dialled, and that was inferred from a regular expression over the address. The address is evidence of reachability; it is not the fact, and the gap has already cost:

address the regex says actually
100.64.0.0/10 — carrier-grade NAT public not reachable. An endpoint is written to an address nothing can reach
any IPv6 address public the test is v4 shapes only
a routable address behind a closed firewall public not reachable
a documentation range standing in for a public segment private reachable — this is the lab bug

A test environment having to choose its addresses to satisfy a regex is the regex telling us it is not a fact.

So: an endpoint, or none — declared. And the hub is declared, never derived from an address prefix, because an election decided by the first four characters of an address fails silently, cannot be queried, and makes a renumbering an outage.

The address remains evidence and stops being the fact. Where an observed endpoint disagrees with a declared one, the disagreement is a reportable condition, not a silent correction.

What does not change is the lesson underneath: role does not imply reachability — a home-hosted node is a server that cannot be dialled. This keeps that and stops encoding it as a pattern match.

A filter rule names its source

scope: public is declared in five manifests, is part of no rule type, and is referenced by no code. So five manifests appear to restrict a port and restrict nothing — on the modules most worth restricting.

A rule names its source. from: is the only way to scope one, and a rule without one is open — which it must say plainly rather than appear to deny.

scope: is removed rather than implemented, because giving it meaning would leave two ways to express one thing. And the general fix is that an unknown key is refused: the host's declaration parser already works this way, and manifests are the layer where that discipline is missing. scope: survived because nothing rejected it, and it spread by copying to five manifests.

Order, and what it costs

The link runs on the underlay and never on the overlay. The overlay is configured by the mesh, so a link requiring it could never be established on a new node.

The first declaration is the overlay and nothing else — because a node's address and peers are assigned so it cannot come earlier, and because it is the way back in. A node reachable over the overlay can be fixed by hand if a later declaration breaks the machine; a large first declaration risks a node that is broken and unreachable at once.

Reachable is not the same as having a control surface. Every node reaches every other over the overlay — SSH, services, ordinary traffic — and every node consumes from the broker. What is forbidden is a listening thing that accepts instructions and changes the machine.

Consequences

  • The last two direct database connections leave the nodes, and with them the database credential every node carries.
  • The /etc/hosts floor goes, along with the bootstrap circularity it patched.
  • Two certificate authorities stay separate on purpose: a public one for public names, the mesh's own for internal ones. A single-CA lab would hide any bug living in the split.
  • What happens when the hub is down: nothing takes over. Non-co-located paths stop; co-located peers and every assigned workload keep running.