ADRs 0036, 0037, 0038 — what a node is, what the host does, how one joins
0036 (accepted): a node is a managed machine, and disconnection is a situation. The open question posed a class distinction — full nodes and lesser presences. There is none. Reachability is state, not kind, which promotes the host's local store from a component to a requirement: it is what makes disconnection ordinary rather than exceptional. The reduced contract the question reached for is real but it is capability, and that belongs in the profile. 0037 (accepted): the host applies, it does not decide. Measured rather than argued — the absorption is smaller than the machinery that already applies state, and eight of ten adapters carry no dependency to move. The two that do open a Postgres connection to the control plane, which inside tier 0 is the one thing the tier rule exists to forbid. So each concern splits: deciding needs every other node and stays in tier 2; applying needs root and locality and goes to tier 0. The host carries ONE concern, of which the six are instances. 0038 (proposed): a node joins by linking first. The operator's two-modes proposal, adopted as intent and corrected as structure. Two modes is two code paths where the first runs once per mesh and rots — and the mesh already has that fault in its worst form, as three hand-run shell scripts. Instead: one behaviour, two sources of declaration. The first node is not a different kind of node, it is a node whose mesh is not up yet, and its specialness is temporary and self-erasing. 0038 also shrinks the migration 0037 called expensive: a joining node never needs mesh-wide state, because the hard part of the overlay is only needed to compute the WHOLE mesh. It needs one peer. The rest arrives. Left open and said so: what may be pushed over the link and how a joining node proves it is entitled to join, and whether one host can raise the substrate alone.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
---
|
||||
status: accepted
|
||||
date: 2026-08-25
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
---
|
||||
|
||||
# 36. A node is a managed machine, and disconnection is a situation
|
||||
|
||||
## Context
|
||||
|
||||
[Research 006](../01-RESEARCH/006-mesh-from-scratch/00-overview.md) left open: *"does an
|
||||
unprivileged node earn a place in the inventory, or only a presence? Decides whether 'node'
|
||||
means one thing or two."*
|
||||
|
||||
The question came from requirement 6 — *Arch Linux only for now; ideally any device, including
|
||||
phones, on lighter terms* — and from the observation that some machines cannot be fully
|
||||
managed. A phone will not run the host. A laptop is absent for days.
|
||||
|
||||
The question assumed the answer was a **class**: full nodes and lesser ones, with the
|
||||
inventory recording the first and merely acknowledging the second.
|
||||
|
||||
## Considered options
|
||||
|
||||
1. **Two classes — nodes and presences.** An unprivileged device gets a lighter record and a
|
||||
reduced contract. Rejected: it makes "node" mean two things, so every context that reasons
|
||||
about nodes acquires a branch, and the branch is invisible in the type. The mesh already has
|
||||
one instance of this shape and it is the one this repository keeps writing issues about —
|
||||
a declared thing that is only sometimes honoured.
|
||||
2. **One class, membership by capability.** Everything is a node; what it can do is a property.
|
||||
Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A node is a managed machine inside the mesh.** Not a device that is merely known about, not
|
||||
an unprivileged something. If the mesh does not manage it, it is not a node — it is a client, a
|
||||
peer, or a thing on the network, and those want their own names rather than a weakened version
|
||||
of this one.
|
||||
|
||||
**A disconnected node is still a node, in a different situation.** Reachability is state, not
|
||||
class. A node that is switched off, roaming, or behind a connection that has dropped has not
|
||||
become a lesser kind of thing; it has a last-known state and a pending set of declarations.
|
||||
|
||||
The distinction the original question reached for is real, but it is **capability**, not kind —
|
||||
what this machine can be asked to do — and that belongs in the host's profile, not in the
|
||||
definition of a node.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The inventory has one shape.** No branch, no second record type, no context that must ask
|
||||
which kind it is holding.
|
||||
- **Local state is structural, not a convenience.** If disconnection is an ordinary situation
|
||||
rather than an exception, the host's store is authoritative while disconnected by design —
|
||||
it is what makes the situation ordinary. This promotes `store/` from a component to a
|
||||
requirement.
|
||||
- **Absence is not failure.** A node that has not been seen is in a state, and the mesh must be
|
||||
able to say which. Anything that treats unreachable as broken will be wrong most of the time
|
||||
about a laptop.
|
||||
- **Devices that cannot be managed do not become nodes by being lenient about the word.** A
|
||||
phone that cannot run the host is not a node under this record. Whether the mesh should reach
|
||||
such devices at all, and as what, is not decided here and needs its own record if it is
|
||||
wanted.
|
||||
- **The reduced-contract idea is not lost, it is relocated.** What a given node can be asked to
|
||||
do is its profile — the host's capability detection — and varies per machine without varying
|
||||
what a node is.
|
||||
|
||||
## References
|
||||
|
||||
- [Research 006](../01-RESEARCH/006-mesh-from-scratch/00-overview.md) — the open question, and
|
||||
requirement 6 that raised it.
|
||||
- [ADR 0015](0015-mesh-brokers-nodes-host-agents-think.md) — *nodes host*; this says what a
|
||||
node is.
|
||||
- [Issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md) —
|
||||
capability as something detected rather than assumed, which is where the reduced contract
|
||||
now lives.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
status: accepted
|
||||
date: 2026-08-25
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
---
|
||||
|
||||
# 37. The host applies; it does not decide
|
||||
|
||||
## Context
|
||||
|
||||
The skeleton absorbs overlay membership, packet filtering, package management, service
|
||||
supervision, the container runtime and filesystem management into tier 0, and
|
||||
[research 006](../01-RESEARCH/006-mesh-from-scratch/00-overview.md) called this *"the
|
||||
skeleton's biggest unproven claim. A binary whose whole argument is that it has no dependencies
|
||||
now carries six concerns."*
|
||||
|
||||
That claim has now been measured against the monorepo's `main`:
|
||||
[`host-size.md`](../01-RESEARCH/006-mesh-from-scratch/host-size.md).
|
||||
|
||||
The measurement says the question asked about the wrong axis.
|
||||
|
||||
## Considered options
|
||||
|
||||
1. **Absorb the six concerns as they are.** What the skeleton literally proposes. Rejected on
|
||||
evidence: two of the ten modules implementing them open a direct connection to the control
|
||||
plane's database and compute their own configuration. Absorbing those unchanged puts a
|
||||
Postgres client and knowledge of the mesh schema inside tier 0 — an upward dependency, and
|
||||
the tier rule is the whole of the bootstrap argument.
|
||||
2. **Leave them as modules.** Keeps the tier rule trivially, and keeps the fault that prompted
|
||||
the skeleton: four modules constituting *how a node is reachable* with no relationship the
|
||||
mesh can see, so one intent is expressed four times
|
||||
([research 005](../01-RESEARCH/005-domain-grouping/analysis.md) finding 4 measures this and
|
||||
finds it is the only place in the catalogue where the shape genuinely occurs).
|
||||
3. **Split each concern: decide centrally, apply locally.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The host has one concern: apply declared state on this machine.** The six are not six
|
||||
concerns it carries; they are instances of the one.
|
||||
|
||||
Each divides:
|
||||
|
||||
- **Deciding** — what this node's overlay, names, exposure, filtering, packages and services
|
||||
*should be*. This needs every other node, and belongs to the control plane.
|
||||
- **Applying** — putting that on the machine. This needs root and locality, and belongs to the
|
||||
host.
|
||||
|
||||
**The host never queries the mesh database.** A host that reads the control plane's schema is
|
||||
tier 0 depending on tier 2, and the tiers stop being a bootstrap answer the moment that is
|
||||
permitted once.
|
||||
|
||||
## Why the evidence supports it
|
||||
|
||||
**Size was the wrong worry.** The ten modules total 2 755 lines. The machinery that already
|
||||
applies state on a node — `meshware`, `env-sync`, `config-sync` — is 3 059. Everything being
|
||||
absorbed is smaller than what already exists to apply it. The host is not a new large thing; it
|
||||
already exists, spread across three core modules and unnamed.
|
||||
|
||||
**Eight of the ten are already pure appliers.** They receive derived state and put it on the
|
||||
machine. Absorbing them moves code that has no dependency to move.
|
||||
|
||||
**The split has already been happening, unnamed.** `dnsmasq-app` needs the same mesh-wide data
|
||||
as `wireguard` and does not query for it. Its own comments record why: the values were
|
||||
*"duplicated by hand on all four nodes"* until someone derived them centrally, after a rename
|
||||
meant editing four override rows nobody knew about. That is this decision, reached once by
|
||||
fixing a bug.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Two modules must be split before they can be absorbed**, and they are the two hardest.
|
||||
`wireguard` needs every node's key, address, site and endpoint reachability; `traefik` needs
|
||||
certificates and every node's exposed names. The measurement says the design is right; it
|
||||
does not say the migration is cheap, and this record does not claim it is.
|
||||
- **The overlay and firewall modules stop existing** as the skeleton says — but the reason is
|
||||
now sharper than "they are host concerns". The host holds membership and applies filtering;
|
||||
the control plane decides policy; swappable backends stay modules.
|
||||
- **Six vocabularies remain.** Zero dependencies, but the host must still know what a WireGuard
|
||||
peer, an nftables rule, a package, a unit, a container and a dataset *are*. That surface is
|
||||
the residue of the original worry and is not measured by anything here.
|
||||
- **A dependency-direction lint is now load-bearing**, not a nicety. This record is a rule
|
||||
about direction, and per this repository's own standard a rule states how it is checked: an
|
||||
upward import fails the build. A tier rule enforced by intention is the same as no tier rule.
|
||||
- **What the host carries versus what it finds is still open.**
|
||||
[Issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md) — the
|
||||
host manages `wg`, `nft`, `pacman`, `docker`; it does not contain them, and *installed* is
|
||||
not the same as *usable*.
|
||||
|
||||
## References
|
||||
|
||||
- [`host-size.md`](../01-RESEARCH/006-mesh-from-scratch/host-size.md) — the measurement.
|
||||
- [Research 005](../01-RESEARCH/005-domain-grouping/analysis.md) — reachability as the only
|
||||
measured co-change cluster in the catalogue.
|
||||
- [ADR 0003](0003-the-mesh-database-is-the-source-of-truth.md) — what the control plane decides
|
||||
from.
|
||||
- [ADR 0030](0030-the-repository-structure.md) — `mesh-host` as tier 0.
|
||||
- [ADR 0008](0008-a-failed-step-fails-the-job.md) — the standard the direction lint is held to.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
status: proposed
|
||||
date: 2026-08-25
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 0037-the-host-applies-it-does-not-decide.md
|
||||
---
|
||||
|
||||
# 38. A node joins by linking first, and the mesh finishes the job
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0037](0037-the-host-applies-it-does-not-decide.md) settles that the host applies and the
|
||||
control plane decides. That leaves the case where there is no control plane to decide: the
|
||||
first node, which must raise a mesh from nothing, and the second, which must join one.
|
||||
|
||||
Raised by the operator: *"shouldn't the host have two modes — one for the initial node, setting
|
||||
up the mesh, so we know the full state; then when adopting a second node, we enter the mesh
|
||||
early and let our first node take over the mesh-related work? The host should only set up the
|
||||
bare minimum for the other nodes in the mesh to complete adoption."*
|
||||
|
||||
The instinct is right and it is the resolution of the gap ADR 0037 leaves open. The framing
|
||||
needs one correction, and the correction comes from what the mesh already does.
|
||||
|
||||
**Today there are three hand-run shell paths**: `install.d/adopt.sh` (183 lines),
|
||||
a separate first-node bootstrap, and `install.d/rescue.sh`. The skeleton names the cause —
|
||||
*"the first node is raised by a special script that exists only because of the circularity"* —
|
||||
and Move 1 exists to remove it. Three paths that do nearly the same thing, maintained
|
||||
separately, run by hand, outside anything that checks them.
|
||||
|
||||
**That is the two-mode problem, already at its worst.** A decision that gives the host two
|
||||
modes risks rebuilding `adopt.sh` and `bootstrap.sh` inside the binary, where they will drift
|
||||
in exactly the same way and be harder to see.
|
||||
|
||||
## Considered options
|
||||
|
||||
1. **Two modes — genesis and join.** What was proposed. Rejected as a *structure* while adopted
|
||||
as an *intent*: two modes is two code paths, the first is exercised once per mesh and the
|
||||
second constantly, so the rarely-run one rots. The current three scripts are the evidence.
|
||||
2. **One path, and the first node is special-cased inside it.** The conditional moves rather
|
||||
than disappearing, and now it is scattered instead of named.
|
||||
3. **One behaviour, two sources of declaration.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The host has one behaviour: apply the declaration it has.** What differs between the first
|
||||
node and the fiftieth is not what the host *does* but **where the declaration comes from** —
|
||||
and, exactly as in [ADR 0036](0036-a-node-is-a-managed-machine.md), that is a situation rather
|
||||
than a class.
|
||||
|
||||
| Situation | Declaration comes from |
|
||||
|---|---|
|
||||
| no mesh reachable | the pinned bundle the host carries (`substrate.lock`) |
|
||||
| mesh reachable | the control plane, over the link |
|
||||
|
||||
**The first node is not a different kind of node.** It is a node whose mesh is not up *yet*. It
|
||||
applies the bundle it carries, the control plane comes up on top of it, and from that moment it
|
||||
takes declarations like everything else. Its specialness is temporary and self-erasing, which
|
||||
is the property `adopt.sh` and the bootstrap script do not have.
|
||||
|
||||
**A joining node does the minimum to be reachable, and nothing else.** It establishes identity
|
||||
and a route to the control plane — the `link` — and then stops deciding. Everything after that
|
||||
arrives as declarations.
|
||||
|
||||
**The minimum is deliberately small:** an identity, an address, and one peer to reach. A
|
||||
joining node does **not** compute the overlay. It needs a single peer to reach the mesh; the
|
||||
full peer set is derived centrally and pushed down, like everything else.
|
||||
|
||||
## Why this resolves what 0037 left open
|
||||
|
||||
ADR 0037 records that `wireguard` and `traefik` are the two modules that must be split before
|
||||
they can be absorbed, and that they are the hardest because they need mesh-wide state.
|
||||
|
||||
**A joining node never needs that state.** The hard part of the overlay — every node's key,
|
||||
address, site and endpoint reachability — is only needed to compute the *whole* mesh, which is
|
||||
the control plane's job. The node needs one peer. The rest arrives.
|
||||
|
||||
So the migration ADR 0037 calls expensive is smaller than it looked, and this record is what
|
||||
makes it smaller.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Adoption stops being a script.** The three hand-run paths collapse into the host: joining
|
||||
is establishing a link, and rescue is a node whose local state is discarded so the mesh can
|
||||
re-derive it. Whether rescue is fully covered by this is not decided here.
|
||||
- **The bundle is a fallback, not a mode.** It is what a host applies when nothing better is
|
||||
available, which also covers a node that has been disconnected for a long time — ADR 0036's
|
||||
ordinary situation.
|
||||
- **The rarely-run path is now the common one.** The first node exercises the same code every
|
||||
other node exercises constantly. That is the whole reason for choosing this over two modes.
|
||||
- **The link becomes the security boundary.** Everything a node applies arrives through it, so
|
||||
what may be pushed, and how a joining node proves it is entitled to join, is now a question
|
||||
worth its own record. **Not decided here, and it is a gap.**
|
||||
- **The bundle must be able to raise the substrate alone.** Whether one host can bring up the
|
||||
four pinned services with no mesh present is Move 1 of the skeleton and remains unproven.
|
||||
This record depends on it and does not establish it.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0037](0037-the-host-applies-it-does-not-decide.md) — the split this completes.
|
||||
- [ADR 0036](0036-a-node-is-a-managed-machine.md) — situation rather than class, applied here
|
||||
to the first node.
|
||||
- [Research 006, Move 1](../01-RESEARCH/006-mesh-from-scratch/skeleton.md) — the pinned bundle,
|
||||
and the special script it exists to remove.
|
||||
- [`00-as-is/05-runtime-and-installation.md`](../03-DESIGN/00-as-is/05-runtime-and-installation.md)
|
||||
— how a node comes into being today.
|
||||
Reference in New Issue
Block a user