0038 accepted: no two modes. One behaviour, two sources of declaration. 0039 fills the gap 0038 named. Proposed rather than measured — it designs a boundary that does not exist — but what it replaces IS measured, and that is the argument. Today adopt.sh asks the operator to paste in the postgres password and the object store password, the same ones on every node, and they are not discarded after adoption: wireguard and traefik open a pg connection on every reconcile. So every node permanently holds a credential to the control plane's database, and 00-as-is/06 records that nothing rotates it. Compromise of any node is compromise of the mesh's store, with no way back. Four properties make the link a boundary rather than a pipe: it is outbound and node-initiated, so a node has no listening control surface — which the topology already requires, since most nodes have no forwarded port. A node holds its own identity and nothing else, so compromise of a node is compromise of that node. Authority is mutual, because a host that applies whatever the link delivers must know the mesh from something impersonating it. And what may be pushed is bounded by FORM — declarations of known shape, never a command to run. That last property is stated with its limit rather than oversold: it bounds form, not impact. A compromised control plane can declare harmful state and the host will apply it faithfully. What it buys is a describable blast radius. Joining uses a one-time short-lived enrolment token, useless once used and useless after a while, in place of hand-carried shared secrets. Named rather than hidden: the first node's identity is self-issued and becomes the root of trust, which is the one place 0038's "no special first node" does not fully hold. Rotation becomes possible and is still not designed. And what may expire is constrained by 0036 — an identity needing refresh would make a laptop fail for being a laptop.
107 lines
5.7 KiB
Markdown
107 lines
5.7 KiB
Markdown
---
|
|
status: accepted
|
|
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 its own
|
|
question — taken up by [ADR 0039](0039-the-link-is-the-security-boundary.md).
|
|
- **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.
|