diff --git a/01-RESEARCH/006-mesh-from-scratch/00-overview.md b/01-RESEARCH/006-mesh-from-scratch/00-overview.md index 3b84f73..0577ea1 100644 --- a/01-RESEARCH/006-mesh-from-scratch/00-overview.md +++ b/01-RESEARCH/006-mesh-from-scratch/00-overview.md @@ -88,7 +88,7 @@ the catalogue where modules genuinely change together under one intent. The skel |---|---| | Does the record — the event log contexts integrate through — belong to the substrate or the control plane? | It is infrastructure by shape and domain by content. Placing it wrong reintroduces a circularity. | | One repository per tier, or per context? | Already open from ADR 0015 as "catalogue destination — one repository or many". The skeleton assumes per tier and does not settle it. | -| ~~Does an unprivileged node earn a place in the inventory, or only a presence?~~ | **Answered 2026-08-25** by the operator: a node is a *managed machine inside the mesh*, not an unprivileged something — and a disconnected node is still a node, in a different situation. The question posed a class distinction; the answer is that there is none, and what varies is **state**. Awaiting a decision record. | -| ~~Does absorbing overlay, filtering, packages, supervision and the container runtime make the host too large?~~ | **Answered 2026-08-25** — [`host-size.md`](host-size.md). Measured: the absorption is smaller than the machinery that already applies state, and eight of ten adapters already carry no dependency. The risk is not size but direction, and it is two modules wide. The claim survives with its scope corrected — the host carries one concern, *apply declared state on this machine*, of which the six are instances. | +| ~~Does an unprivileged node earn a place in the inventory, or only a presence?~~ | **Answered 2026-08-25** by the operator: a node is a *managed machine inside the mesh*, not an unprivileged something — and a disconnected node is still a node, in a different situation. The question posed a class distinction; the answer is that there is none, and what varies is **state**. Recorded as [ADR 0036](../../02-DECISIONS/0036-a-node-is-a-managed-machine.md). | +| ~~Does absorbing overlay, filtering, packages, supervision and the container runtime make the host too large?~~ | **Answered 2026-08-25** — [`host-size.md`](host-size.md). Measured: the absorption is smaller than the machinery that already applies state, and eight of ten adapters already carry no dependency. The risk is not size but direction, and it is two modules wide. The claim survives with its scope corrected — the host carries one concern, *apply declared state on this machine*, of which the six are instances. Recorded as [ADR 0037](../../02-DECISIONS/0037-the-host-applies-it-does-not-decide.md). | | Four substrate services or five? | The identity provider passes the tier test only if the control plane delegates authentication rather than doing it natively. | | Does `feature` survive? | The skeleton splits it in two and argues the conflation is what makes the delivery pipeline hard to reason about. Unproven. | diff --git a/02-DECISIONS/0036-a-node-is-a-managed-machine.md b/02-DECISIONS/0036-a-node-is-a-managed-machine.md new file mode 100644 index 0000000..0cf74b3 --- /dev/null +++ b/02-DECISIONS/0036-a-node-is-a-managed-machine.md @@ -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. diff --git a/02-DECISIONS/0037-the-host-applies-it-does-not-decide.md b/02-DECISIONS/0037-the-host-applies-it-does-not-decide.md new file mode 100644 index 0000000..301a318 --- /dev/null +++ b/02-DECISIONS/0037-the-host-applies-it-does-not-decide.md @@ -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. diff --git a/02-DECISIONS/0038-a-node-joins-by-linking-first.md b/02-DECISIONS/0038-a-node-joins-by-linking-first.md new file mode 100644 index 0000000..e06721c --- /dev/null +++ b/02-DECISIONS/0038-a-node-joins-by-linking-first.md @@ -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.