Tier 0: the questions answered, the decisions taken, and the design #9

Merged
jschoubben merged 16 commits from design/close-the-record into main 2026-08-25 22:56:07 +00:00
23 changed files with 1647 additions and 16 deletions
+95 -1
View File
@@ -39,7 +39,7 @@ incident behind it is not written down, and the fix is to write it down, not to
| **Never write to a production database directly** | No insert, update, delete or schema statement executed against production by hand. Schema changes go through numbered migrations; data changes go through application code or the module's own capabilities. Raw statements skip every side effect the proper path has — events, audit, cache invalidation, fan-out. |
| **Every schema change is a migration** | Numbered, in the module's own language, compiled with it. Both a baseline for a fresh installation *and* an incremental migration for installations that already exist. If code references a column, the migration creating it must exist. [ADR 0006](../02-DECISIONS/0006-schema-changes-are-numbered-migrations.md) |
| **Never bypass the pipeline** | No manual database edit, no manual restart as a workaround. Fix the cause and deploy. A workaround that works is a workaround that is never removed, and the next person cannot tell the node from its declaration. |
| **Never create a symlink** | A hand-made link caused production data loss through container volume resolution, and the judgement needed to make a safe exception is exactly the judgement unavailable at the moment it matters. Today the installer owns and reconciles the links the mesh still uses [ADR 0011](../02-DECISIONS/0011-the-installer-owns-linking.md); the intent is that the mesh creates none at all [ADR 0018](../02-DECISIONS/0018-the-mesh-creates-no-symlinks.md). Neither reading permits you to make one. |
| **Never create a symlink** | A hand-made link caused production data loss through container volume resolution, and the judgement needed to make a safe exception is exactly the judgement unavailable at the moment it matters. **The mesh creates none at all** ([ADR 0018](../02-DECISIONS/0018-the-mesh-creates-no-symlinks.md), which supersedes [ADR 0011](../02-DECISIONS/0011-the-installer-owns-linking.md)). The links the installer still reconciles are a migration, not a permission. |
| **Never push directly to the main branch** | Branch, push, review, merge. Every merge is a human checkpoint, without exception — **including in this repository**. A documentation repository is not a lower tier of care; a decision record lands the same way a service does. |
| **One change per pull request, and never merge your own** | Unrelated improvements bundled together cannot be reviewed or reverted separately. Self-merging removes the checkpoint that is the entire point. |
| **Never open a pull request unprompted** | A permissions list saying it is allowed is not a request. |
@@ -152,6 +152,47 @@ finds one names the evidence required and returns the work.
The reason is the mesh's most consistent failure shape: a green result proves transport, not
effect. Absence reads as success unless something looked.
### A test defends a decision
The rule above applies to prose. It applies to **decisions** too: a decision record states
something that must be true, and a test asserts it. A decision with no test is one that will
quietly stop being true, and nobody will learn that from a document.
- Structure and logic — what is accepted, what is refused, how a value is derived — is tested
**first**, because the behaviour is knowable before the code.
- Behaviour against a real system is tested **alongside**, because it is discovered rather than
known.
- **Mocking the boundary is forbidden.** A test that fakes the system under integration asserts
that the fake behaves as expected.
- **The gate is blocking.** Green is the definition of done; a change that has not run its tests
is not finished, whatever the diff looks like.
Not test-driven development as a blanket rule — a test written first against undiscovered
behaviour asserts a guess. The obligation is that every decision has a defender.
### A report is read from the system, never from what asked for it
The same rule as the two above, pointed at reporting rather than at verification. **Anything
that describes the state of the mesh — a status view, an inventory, a diagram, a health
check — is assembled from the running system.** Assembling it from the intended state produces
a report that always agrees with itself and can never disagree with reality, which is not a
report.
Where the system does not natively hold a fact the report needs, **the thing that applied the
fact records it** — and:
> **A record of behaviour is written after the behaviour works, never when the resource is
> created.**
Written up front it restates the request in a new place and inherits none of the authority of
having happened. A failed run leaves its wreckage standing, and a report of that wreckage must
not describe what the wreckage was supposed to be.
This is the production form of the mesh's most expensive fault: a firewall key declared in five
manifests and read by no code
([04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md)). The
declaration was never wrong. Nothing ever asked the system.
### Search the record before forming a hypothesis
The first action on any error message, failing service or unexpected behaviour is to search the
@@ -179,6 +220,12 @@ This document is governed. It does not change by commit message or unilateral de
No drive-by edits. Every change traces to a recorded decision.
**Review means a person who is not the proposer.** The enforced page carried a stricter bar —
a design meeting with at least two node operators — which has never been met and cannot be, as
there is one operator. A rule that cannot be satisfied is not a high standard; it is a rule
everything silently violates. Recorded here as resolved in favour of what is achievable, and
what has in fact been practised ([ADR 0040](../02-DECISIONS/0040-the-constitution-absorbs-what-is-enforced.md)).
---
## 7. Overrides
@@ -188,3 +235,50 @@ They may never relax them.
An override says which rule it tightens, or which gap it fills, and follows the same amendment
process. Absence of an override means these rules apply unmodified.
---
## 8. Code quality
*Absorbed 2026-08-26 from the enforced page, which carried these rules while this document did
not — [ADR 0040](../02-DECISIONS/0040-the-constitution-absorbs-what-is-enforced.md).*
**These rules are recorded because they are enforced, not because this repository earned them.**
Every other rule here states the incident or measurement behind it. These state nothing,
because nothing is written down. That is a gap, not a style choice, and it is marked rather
than dressed up: a rule whose reasoning nobody recorded is one nobody can argue with correctly,
which is the condition §2 exists to avoid.
### Structure
- **One reason to change** per module, class or function.
- **Extend by composition**, not by editing what already works.
- **Depend on abstractions**, and inject the concrete thing rather than reaching for it.
- **Small, focused interfaces** over one large one.
- A substitute for a type must not break the behaviour its users rely on.
### Layering
Data access, business logic and the interface layer are separate.
- No queries in route handlers, tool definitions or daemon loops — those belong in repositories.
- No orchestration or validation in repositories — that belongs in services.
- Shared logic belongs in the SDK. Duplicating it into a surface is how two answers to one
question start to exist.
### Types
*Scope: the mesh's services and surfaces. Tier 0 is a statically linked binary that must depend
on nothing installed first, and is written in Go —
[ADR 0041](../02-DECISIONS/0041-the-host-depends-on-nothing.md).*
- TypeScript throughout; no new untyped JavaScript.
- Strict, with no implicit `any` and no unchecked index access.
- Public functions state their return type. Prefer `unknown` with a guard over `any`. Errors
are typed, never thrown as strings.
### Restraint
- **Do not over-abstract.** Three similar lines beat a premature abstraction.
- **Build what is needed now.** A hypothetical future is not a requirement.
- Names reveal intent, functions fit on a screen, and boundaries fail fast.
+2 -2
View File
@@ -21,13 +21,13 @@ and a forge address is an operational detail (see [`README`](../README.md)).
## What the mesh becomes
[ADR 0030](../02-DECISIONS/0030-the-repository-structure.md) records the repositories the
monorepo decomposes into. **Only `mesh-lab` exists so far** — it is built first
monorepo decomposes into. **`mesh-lab` and `mesh-host` exist so far** — the lab is built first
([ADR 0029](../02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md)); the rest are the
target, not the present.
| Repository | Tier | Holds |
|---|---|---|
| `mesh-host` | 0 | the node host — the one binary installed by hand |
| `mesh-host` | 0 | **exists.** The node host — one statically linked binary, requiring nothing present ([ADR 0041](../02-DECISIONS/0041-the-host-depends-on-nothing.md)) |
| `mesh-substrate` | 1 | the four pinned services, as declarations |
| `mesh-control` | 2 | the control plane and its contexts |
| `mesh-surfaces` | 3 | tools, web, cli |
+21 -4
View File
@@ -1,8 +1,13 @@
---
status: active
status: graduated
initiated: 2026-08-22
touches: [03-DESIGN/00-as-is/01-mesh-and-transport.md, 03-DESIGN/01-to-be/01-end-to-end-testing.md]
became: [02-DECISIONS/0016-a-lab-node-is-a-virtual-machine.md, 02-DECISIONS/0031-the-lab-provides-the-underlay.md, 03-DESIGN/01-to-be/02-scenario-declaration.md]
became:
- 02-DECISIONS/0016-a-lab-node-is-a-virtual-machine.md
- 02-DECISIONS/0031-the-lab-provides-the-underlay.md
- 02-DECISIONS/0033-a-router-is-scenery-not-a-node.md
- 03-DESIGN/01-to-be/02-scenario-declaration.md
- 03-DESIGN/01-to-be/01-end-to-end-testing.md
---
# 004 — Reproducing the mesh network in a lab
@@ -46,5 +51,17 @@ production so real nodes are unaffected.
## Open
- Not yet stood up. `incus` is declared in `modules/hal/developer/module.yml` and merged
(PR #944); the lab itself is unbuilt.
*Closed 2026-08-25.* The lab is stood up. The topology this effort described raises, and the
substitution it turned on — a simulated public segment addressed from documentation space
rather than RFC1918 — is enforced by the declaration validator before anything is raised
rather than left as a thing to remember.
The certificate conclusion above is carried by
[`01-end-to-end-testing.md`](../../03-DESIGN/01-to-be/01-end-to-end-testing.md), which
specifies the lab's own ACME issuer on the public segment. It is **designed and not built** —
implementation state is a third axis, and the effort graduates on its conclusions, not on
their delivery.
One item leaves this effort without a home and is recorded here so it is not lost: the reverse
proxy does not set `caServer`, so it defaults to the public authority's production endpoint.
That is a fact about what runs today, not about the lab.
@@ -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? | Decides whether "node" means one thing or two. |
| Does absorbing overlay, filtering, packages, supervision and the container runtime make the host too large? | It is the skeleton's biggest unproven claim. A binary whose whole argument is that it has no dependencies now carries six concerns. |
| ~~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), designed in [`05-the-node-host.md`](../../03-DESIGN/01-to-be/05-the-node-host.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. |
@@ -0,0 +1,126 @@
# Is the host too large?
The skeleton absorbs overlay membership, packet filtering, package management, service
supervision, the container runtime and filesystem management into tier 0, and
[`00-overview.md`](00-overview.md) calls this *"the skeleton's biggest unproven claim. A binary
whose whole argument is that it has no dependencies now carries six concerns."*
This is that claim, measured.
## Method
Against `origin/main` of the monorepo at 2026-08-25 — read through git refs rather than a
checkout, which sits on a feature branch 1031 commits behind with uncommitted work.
For each module that implements one of the six concerns: how much code it is, and **what it
depends on to do its job**. The second question turned out to be the one that matters.
## Finding 1 — by size, the concern is misplaced
| Absorbed into the host | Lines |
|---|---|
| `dnsmasq-app` | 706 |
| `traefik` | 426 |
| `ufw` | 385 |
| `mesh-ca` | 353 |
| `wireguard` | 313 |
| `incus` | 285 |
| `package-manager` | 118 |
| `zfs` | 67 |
| `fail2ban` | 60 |
| `docker-app` | 42 |
| **total** | **2 755** |
| Machinery that already applies state on a node | Lines |
|---|---|
| `hal/meshware` | 1 829 |
| `hal/env-sync` | 626 |
| `hal/config-sync` | 604 |
| **total** | **3 059** |
**Everything being absorbed is smaller than the machinery that already exists to apply it.**
The six concerns are not six subsystems; they are ten thin adapters averaging 275 lines, most
of which is rendering a config file and running a command.
The host is not a new large thing. It already exists, spread across three core modules, and
the absorption adds less code than those three already contain.
Size is therefore the wrong axis, and the open question asked about the wrong thing.
## Finding 2 — the real risk is direction, and it is narrow
What each adapter needs in order to decide what to write:
| Module | Gets its inputs from | Applier only? |
|---|---|---|
| `ufw` | `~/.hal/modules/*/module.yml` on the local disk | yes |
| `dnsmasq-app` | `process.env.DNSMASQ_*`, derived centrally by `env-sync` | yes |
| `fail2ban`, `mesh-ca`, `package-manager`, `docker-app`, `incus`, `zfs` | local state and declared env | yes |
| `wireguard` | **direct `pg` connection** — `nodes`, `node_accessors`, `node_wg_keys`, `module_env` | **no** |
| `traefik` | **direct `pg` connection** — `nodes`, `mesh_ca` | **no** |
**Eight of ten are already pure appliers.** They receive derived state and put it on the
machine. Absorbing those into tier 0 moves no dependency at all — it moves code that already
has none.
**Two reach upward.** `wireguard` and `traefik` open a connection to the control plane's
database and compute their own configuration from it. Absorbing them *as they are* would put a
Postgres client and knowledge of the mesh schema inside tier 0 — an upward dependency, which
is precisely what
[the tier rule](skeleton.md) forbids and what the entire bootstrap argument rests on.
So the danger in the absorption is real, and it is two modules wide rather than six concerns
wide.
## Finding 3 — the split has already been happening, unnamed
`dnsmasq-app` is the same *kind* of module as `wireguard`: it needs every node's addresses and
names. It does not query for them. Its own comments record why:
> *"the mesh DB already holds [this] in `node_accessors` and the WireGuard address, duplicated
> by hand on all four nodes. Renaming the namespace then meant editing four override rows
> nobody [knew about]."*
and
> *"now derived from `node_accessors`"*
That is one module having already made the move the skeleton proposes — deciding centrally,
applying locally — for the ordinary reason that hand-duplicated state went wrong. Nobody named
it as an architectural direction; it was reached by fixing a bug.
Eight of ten adapters are on the far side of that migration. Two are not.
## What this means for the open question
**The absorption is not a move. It is a split, and it is mostly already done.**
The question "does the host become too large" assumed six concerns would arrive whole. They do
not. Each divides:
- **deciding** — what this node's overlay, names, exposure and filtering should be, which needs
every other node and therefore belongs in tier 2;
- **applying** — putting that on the machine, which needs root and locality and therefore
belongs in tier 0.
Tier 0 absorbs the applying. That is ~2 755 lines today, most of it already dependency-free,
against 3 059 lines of apply machinery the host needs regardless.
**The claim survives, with its scope corrected.** The host does not carry six concerns; it
carries one — *apply declared state on this machine* — of which the six are instances. That is
the skeleton's own tier-0 test (*"does it apply state on a machine?"*) applied to itself.
## What remains open
- **The two unsplit modules are the two hardest.** Overlay needs every node's key, address,
site and endpoint reachability; the proxy needs certificates and every node's exposed names.
These are where "derive centrally, apply locally" is most work, and neither has been done.
The measurement says the design is right; it does not say the migration is cheap.
- **Six concerns is still six vocabularies.** Absorbing them adds no dependencies but does add
surface: the host must know what a WireGuard peer, an nftables rule, a package, a unit, a
container and a dataset *are*. Nothing here measures that cost, and it is the residue of the
original worry.
- **What the host must carry versus what it must find.** The host manages `wg`, `nft`,
`pacman`, `docker`; it does not contain them.
[Issue 007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md) is
exactly this question and is unresolved.
@@ -0,0 +1,117 @@
---
status: active
initiated: 2026-08-25
touches:
- 02-DECISIONS/0002-everything-is-a-module.md
- 02-DECISIONS/0017-modules-outside-the-core-are-grouped-by-domain.md
- 02-DECISIONS/0036-a-node-is-a-managed-machine.md
- 03-DESIGN/00-as-is/02-modules-and-manifests.md
- 03-DESIGN/00-as-is/10-module-catalogue.md
- 04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md
---
# 011 — The module graph
## What is being investigated
Whether the catalogue's missing structure is a **graph** — modules declaring what they need,
what they offer, and what they exclude — and what that replaces.
[ADR 0017](../../02-DECISIONS/0017-modules-outside-the-core-are-grouped-by-domain.md) proposes
grouping modules by domain. [Research 005](../005-domain-grouping/analysis.md) measured that
proposal and found its evidence holds in exactly one place — reachability — which
[ADR 0037](../../02-DECISIONS/0037-the-host-applies-it-does-not-decide.md) has since absorbed
into the host. The measured case for domain grouping has therefore been consumed by a decision
taken for unrelated reasons, and what remains is fifty modules that co-change with nothing.
That leaves the original complaint unanswered: the catalogue records **what was installed**
rather than **what anything is for**, and nothing in the system can see a relationship between
two modules. Grouping asserts relationships. A graph records them.
## Why now
A proposal to split modules into *provisioning services* and *applications* was worked through
and abandoned in favour of one concept with facets, for a reason worth keeping:
- The split cannot be filed consistently. A git forge is consumed as a service *and* operated
through a web interface. An analytics service grants tracking identity *and* is a dashboard
somebody reads. An identity provider grants authentication *and* has an admin console.
- The operator's correction is the sharper form: **what runs on the machine is a supervised
container, not something a user started.** That is a fact about *how a thing runs*, not about
what kind of thing it is — so it is a facet, not a taxonomy.
Filing decisions that follow from nothing are the disease research 005 measured. A second
taxonomy would reproduce it.
So [ADR 0002](../../02-DECISIONS/0002-everything-is-a-module.md) survives, and the question
becomes what a module must be able to **declare**.
## The shape being investigated
Five declarations, of which two exist today.
| Declaration | Today | Notes |
|---|---|---|
| **requires a resource** — a database, a bucket | yes | provisioning, [ADR 0005](../../02-DECISIONS/0005-capabilities-are-provisioned-on-declaration.md) |
| **provides a resource** | yes | as above |
| **requires another module** | **no** | the dependency edge — the graph's substance |
| **excludes another module** | **no** | installing A makes B unavailable |
| **requires a node capability** | **no** | a graphical session, a container runtime, an architecture |
And one structural idea on top: **an interface module carries no implementation.** Adapters
provide it. An assistant interface with several model-provider adapters; a terminal interface
with several terminal adapters. A dependent names the interface and never an implementation.
**Prior art to measure against, not invent past.** This is a package manager's model, and the
platform's own package manager already has all of it: `depends` is the dependency edge,
`conflicts` is exclusion, and `provides` is the interface — several packages provide one
virtual name, and a dependent names the virtual one. That the design arrived at the same shape
independently is evidence for it. It is also a warning: dependency resolution, version
constraints, conflict handling and rollback are a long-solved and easily-botched problem, and
the effort should establish what to **delegate** rather than reimplement.
## Capabilities, and what may be installed
The operator's formulation: *system specs are capabilities, and capabilities unlock installable
modules — you cannot install a graphical application on a node with no display server.*
The question that follows is whether the mesh may install a capability. The effort's working
position, to be tested:
- **Intrinsic capabilities** — hardware, architecture, network position — are facts about a
machine. They are detected, never installed, and a module requiring one it does not have is
not unresolved but **impossible** on that node.
- **Provided capabilities** — a display server, a container runtime — are not a separate kind
of thing at all. They are modules that provide a capability, and requiring one is an ordinary
edge the graph resolves by installing it.
If that holds, "may the mesh install a capability" is not a policy question. It is dependency
resolution, and the only genuinely new thing is detection.
Which is where [issue 007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md)
bears directly: *an installed package is not a capability*. A provided capability is not real
because a package is present — it is real when it is present, running and working, and the
difference is exactly the class of fault this repository keeps recording.
## What it touches beyond the catalogue
The operator's assessment is that the machinery around a module is wanted and its integration
is not: **scheduled tasks, hooks, migrations, configuration and environment settings are worth
keeping; seeds are not; and the current integration is wrong enough to need a major refactor.**
That is a claim to test rather than adopt. Research 005 already found supporting evidence from
a different direction — that the densest apparent coupling in the catalogue is manifest
boilerplate churn, cross-cutting changes to the machinery applied N times — which is what an
integration being wrong looks like from the outside.
## Open questions
| Question | Why it is open |
|---|---|
| What does the graph **delete**? | If modules gain declarations and lose nothing, this is motion rather than progress. The effort has not finished until it names what stops existing. |
| Where does resolution happen — mesh or platform package manager? | The mesh must model mesh-level edges. Whether it also resolves operating-system packages, or delegates, decides whether a solver has to be written. |
| Is an interface a module, or a name? | Arch makes it a name that packages claim. Making it a module gives it a manifest, an owner and a place to document the contract — and a thing with no implementation to install. |
| What does an exclusion mean for something already installed? | Refuse the install, or make the conflict visible and let it be decided. The second is a policy surface; the first is a package manager. |
| Does node adoption scan for capabilities, applications, or both? | The operator proposes scanning an adopted node and enabling what it finds. Under the working position above, the scan is for capabilities — but a machine with a terminal already installed is also a module already satisfied, and whether that is adoption or drift is undecided. |
| One installation image, or several? | Proposed: pre-built images carrying different capability sets, so a machine is adopted quickly. Several images bake capability sets at image time, which is the filing problem in a new form and reintroduces what detection exists to avoid. One image carrying the host and nothing else is [ADR 0038](../../02-DECISIONS/0038-a-node-joins-by-linking-first.md)'s *one binary installed by hand*, automated. The effort should settle which. |
| What happens to domain grouping? | [ADR 0017](../../02-DECISIONS/0017-modules-outside-the-core-are-grouped-by-domain.md) is still `proposed`. If the graph is the answer, 0017 is superseded rather than narrowed — its text is never edited. |
@@ -1,5 +1,6 @@
---
status: accepted
status: superseded
superseded-by: 02-DECISIONS/0018-the-mesh-creates-no-symlinks.md
date: 2026-07-10
deciders: jochen
reconstructed: true
@@ -1,5 +1,5 @@
---
status: proposed
status: accepted
date: 2026-08-23
deciders: jochen
reconstructed: false
@@ -50,7 +50,7 @@ State is derived onto nodes; it does not reach back.
## Decision
*Proposed — the position is settled; the migration is not designed. See "Open" below.*
*Accepted 2026-08-25. The position is settled; the migration is not designed. See "Open" below.*
**The mesh creates no symlinks.** A file a node needs is placed on that node as a real file,
derived from the mesh and reconciled by the installer like every other managed file
@@ -0,0 +1,81 @@
---
status: accepted
date: 2026-08-24
deciders: jochen
reconstructed: false
extends: 0016-a-lab-node-is-a-virtual-machine.md
---
# 33. A router is scenery, not a node — so it is a container
## Context
[ADR 0016](0016-a-lab-node-is-a-virtual-machine.md) settles that **a lab node is a virtual
machine**, and its reasoning is fidelity: a node boots a stock image and runs the real install,
so it has to be a real machine or the thing under test is not the thing that ships.
A scenario also needs routers. NAT, port forwarding, policy between segments and mapping
expiry are all things a router does, and until one is materialised a multi-segment scenario
raises isolated islands
([03-DESIGN/01-to-be/02-scenario-declaration.md](../03-DESIGN/01-to-be/02-scenario-declaration.md)).
The declaration already implies them: a gateway is *the one implicit machine in an otherwise
explicit declaration*.
The question is whether ADR 0016 binds those too.
## Considered options
1. **A router is a node, so it is a virtual machine.** Consistent, and pays for a consistency
nobody needs. A router boots in roughly ten seconds against a container's one; a
six-segment scenario wanting three routers spends thirty seconds per raise on scenery.
2. **The hypervisor provides NAT** — bridges with translation switched on, and its own
forwarding primitives. Rejected on a stronger ground than speed: it makes the *lab* provide
what the declaration is supposed to own, and it cannot express a mapping that expires, a
gateway that refuses to forward, or policy between siblings. The model would shrink to fit
the tool.
3. **A router is scenery, and scenery is a container.** Chosen.
## Decision
**ADR 0016 binds nodes. A router is not a node.**
Nothing under test runs on a router. It is not a participant, it holds no identity, the mesh
never installs anything on it, and no assertion is ever made about its internals. It exists so
that packets between machines behave the way they behave in the world — which is the definition
of scenery.
So a router is a **system container**, and the fidelity argument does not reach it: what a
router must reproduce is kernel behaviour — translation, connection tracking, filtering,
forwarding — and a container has the same kernel.
**Verified before deciding, not assumed.** In a plain unprivileged container:
| Needed for | Works |
|---|---|
| routing at all | `net.ipv4.ip_forward`, `net.ipv6.conf.all.forwarding` |
| `nat:` | nftables masquerade, rules accepted and listed back |
| `mapping_ttl:` | `nf_conntrack_udp_timeout`, `nf_conntrack_tcp_timeout_established` |
No privileged mode, no nesting, no capability grants.
## Consequences
- A raise stops paying a boot per router. Scenery costs about a second where a node costs ten,
and a scenario's cost tracks the machines actually under test.
- **The distinction is now load-bearing and has to stay legible.** *Node* means something under
test; *scenery* means something that makes the test real. If anything is ever installed on a
router by the mesh, it has become a node and this decision no longer covers it.
- Routers and nodes are different kinds of thing in the lab's own model, which is a small extra
concept — justified by it being true, rather than by the saving.
- A container shares the host kernel, so a scenario cannot reproduce a router running a
*different* kernel from the workstation. Nothing currently wants that; if something does, that
router becomes a virtual machine and this record needs revisiting rather than bending.
- The gateway stays implicit in the declaration. A scenario declares `gateway:` on a segment and
never names the machine that serves it — which is right, because it is not a machine the
scenario has anything to say about.
## References
- [ADR 0016](0016-a-lab-node-is-a-virtual-machine.md) — what a lab *node* is, unchanged.
- [Research 004](../01-RESEARCH/004-lab-network/analysis.md) — the topology needing a router,
and why *published but behind NAT* only exists in production today.
@@ -0,0 +1,91 @@
---
status: accepted
date: 2026-08-24
deciders: jochen
reconstructed: false
---
# 34. A test defends a decision
## Context
[`how-we-build.md`](../00-META/how-we-build.md) §5 already says that **if a document states a
rule about the mesh, it says how the rule is verified**, on the grounds that an unenforced rule
is indistinguishable from a wrong one and costs more, because people believe it.
That rule is applied to prose and to acceptance criteria. It has never been applied to
**decisions**, and it should be — a decision record states something that must be true, which
is the same kind of claim.
The gap was found by review. The lab reached 2,128 lines with 1,072 of them untested, and
**no stated rule was broken.** There is no testing posture in `how-we-build.md` at all: no
expectation, no gate, no definition of done. Every decision the lab embodies — the underlay
boundary, the closed address space, routers as scenery, waiting for usable rather than for a
call to return — was verified by hand, by running scenarios and reading output, and none of
that survives the terminal it was run in.
Which is the fault the mesh already has catalogued at scale: an end-to-end harness that has
not built since 2026-06-04, and nothing said so
([`04-ISSUES/005`](../04-ISSUES/005-pipeline-test-harness-unbuildable/00-report.md)). Coverage
assumed rather than checked.
## Considered options
1. **A coverage percentage.** Rejected. It measures how much code a test touched, not whether
anything important is defended, and it is satisfied by tests that assert nothing. A number
would have been met by testing the parser harder while the hypervisor integration stayed
unasserted.
2. **Test-driven development as a hard rule.** Rejected, and not because it is wrong in general.
Half the lab's implementation was discovery: that the hypervisor CLI reads a definition from
stdin and hangs, that it assigns a MAC without recording it, that a stock image's boot-time
networking flushes a static address. A test written first against undiscovered behaviour
asserts a guess.
3. **A test defends a decision.** Chosen.
## Decision
**Every decision record states something that must be true. A test asserts it.**
A decision with no test is a decision that will quietly stop being true, and nobody will find
out from a document. Concretely:
- Where a decision is about **structure or logic** — what a declaration may say, what is
refused, how a name is derived — the test is a unit test, and it is **written first**, because
the behaviour is knowable before the code.
- Where a decision is about **behaviour against a real system** — a hypervisor, a broker, a
daemon — the test runs against the real thing, and is written **alongside**, because the
behaviour is discovered rather than known.
- **Mocking the boundary is forbidden.** A test that fakes a hypervisor asserts that the fake
behaves as expected, which is the shape of test this whole effort exists to stop shipping.
- **The gate is blocking, and green is the definition of done.** A change that has not run its
tests is not finished, whatever its diff looks like.
A test names the decision it defends. Not as ceremony: it is what makes the pairing checkable,
so a decision without one can be *found* rather than noticed.
## Consequences
- The question *"which tests matter"* has an answer that is not a number. The decisions are the
list, and they are already written down.
- **A new decision costs a test.** That is the intended friction — a decision nobody will assert
is one worth reconsidering.
- Integration tests need real infrastructure and are slow. That cost is accepted: a fast test
suite that mocks the boundary would tell us nothing about the boundary, which is where every
interesting fault in this session actually was.
- Some decisions are not mechanically assertable — *the mesh brokers capabilities; nodes host;
agents think* is a shape, not a predicate. Those should say so in the record rather than being
quietly exempt, so the exemption is visible.
- Records 0001–0033 were made before this rule. They are not retroactively invalid, but each
should acquire a test or an explicit note that it cannot have one, and until then this rule
is aspirational for them — which is exactly the state §5 warns about, recorded rather than
hidden.
## References
- [`how-we-build.md`](../00-META/how-we-build.md) §5 — the rule this extends from prose to
decisions.
- [`04-ISSUES/005`](../04-ISSUES/005-pipeline-test-harness-unbuildable/00-report.md) — coverage
assumed rather than checked, for two and a half months.
- The sibling HQ repository for the PAPA platform states the same boundary rule — the contract
is tested against the real system, mocking the client is forbidden, and a blocking gate is the
definition of done. This record adopts that posture and adds the decision pairing.
@@ -0,0 +1,98 @@
---
status: accepted
date: 2026-08-24
deciders: jochen
reconstructed: false
extends: 0034-a-test-defends-a-decision.md
---
# 35. A picture of a system is read from the system, never from what asked for it
## Context
A scenario declaration is a file. A raised scenario is a set of machines, links and rulesets.
The two are supposed to correspond, and the entire value of the lab rests on noticing when
they do not — [ADR 0034](0034-a-test-defends-a-decision.md) says a claim nothing checks is a
claim that will quietly stop being true.
Drawing a scenario makes that concrete, and forces a choice that looks cosmetic and is not.
A diagram of a running system can be produced two ways: parse the declaration and lay it out,
or interrogate the system and lay *that* out. The first is far easier — the declaration is
already parsed, already validated, already in memory.
It is also worthless for the only question worth asking of such a picture: *is what is running
what I asked for?* A drawing built from the request and captioned **as raised** answers that
question with the request, which always agrees with itself.
This is the same fault as
[04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) — a firewall key declared in
five manifests and read by no code, so a manifest appears to restrict a port and restricts
nothing. The declaration was never wrong. Nothing ever asked the system.
## Considered options
1. **Draw the declaration, and label it honestly.** Cheap, and useful for review before
anything is raised. Insufficient alone: it can never disagree with itself.
2. **Draw the system, inferring the rest from the declaration where the system is silent.**
The tempting middle. Rejected — a picture where some facts are observed and some are
assumed has no honest caption, and the assumed ones are exactly the interesting ones.
3. **Two pictures, one layout, neither borrowing from the other.** Chosen.
## Decision
**A picture captioned *as raised* reads only the running system.** It never opens the
declaration, not even for a fact the system happens not to record.
Where the hypervisor does not natively hold a fact the picture needs — whether a segment is
public, what a gateway translates, whether a machine refuses inbound — **the raise records it
on the resource** as metadata, and the picture reads it back from there.
That recording carries its own rule, which is the substance of this decision rather than an
implementation note:
> **A behavioural tag is written after the behaviour works, never when the resource is
> created.**
Written at creation, a tag restates the request in a new location and inherits none of the
authority of having happened. A failed raise deliberately leaves its wreckage standing, so a
tag written up front would let a picture of that wreckage badge translation the gateway was
never configured to do — reproducing, inside the tool built to catch the fault, exactly the
fault.
So: the gateway is tagged after its ruleset applies; the machine after the read-back proves
its firewall loaded.
Both pictures render through **one layout**, so they can be put side by side and the
difference read off directly.
## Consequences
- **It earned itself on the first comparison.** Drawn side by side, every virtual machine in
the live picture held no addresses at all. A container's interface carries the name of the
device it was configured as; a virtual machine names its own — so joining addresses to
devices by name attached every address to a container and none to a VM. Nothing failed;
a whole class of machine silently lost its addresses. The two pictures disagreed, so it was
visible in seconds. It is now joined on MAC.
- Raise does more work, and writes metadata it does not itself consume. Accepted: the cost is
a few config keys, and it is what makes a raised instance self-describing.
- A resource raised before a tag existed is missing it. The reader says so rather than filling
the gap from the declaration — an untagged link draws as unknown, not as what the file said
it should be.
- **The rule generalises past diagrams.** Anything reporting on the mesh — a status view, an
inventory, a health check — is subject to it. A report assembled from the intended state is
not a report.
- The declared picture stays, and stays useful: it is review before raising, and it is one half
of the comparison. It carries no runtime status, because it cannot know any.
- **The constitution sync is now owed.** Accepted 2026-08-25, so §5 carries the rule
unqualified and playbook [05](../00-META/process/05-constitution-sync.md) is due. Until it
runs, the mesh does not enforce this — an unsynced rule is a rule the mesh does not enforce,
whatever this document says.
## References
- [ADR 0034](0034-a-test-defends-a-decision.md) — a claim nothing checks stops being true.
- [ADR 0031](0031-the-lab-provides-the-underlay.md) — why the lab must not supply what the
mesh is responsible for; the same instinct, applied to facts rather than to configuration.
- [04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) — the fault in production
form.
@@ -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: 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.
@@ -0,0 +1,190 @@
---
status: accepted
date: 2026-08-25
deciders: jochen
reconstructed: false
extends: 0038-a-node-joins-by-linking-first.md
---
# 39. The link is the security boundary
## Context
[ADR 0038](0038-a-node-joins-by-linking-first.md) makes the link the one channel a node takes
declarations from, and names the gap it leaves: *"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."*
This is that record. It is a design decision about a boundary that does not exist yet — but
what it replaces is measured, and that is the argument.
Settled as: **a node owns no password. It owns an identity, and that identity is what it
presents to the broker.**
### What adoption does today
`install.d/adopt.sh` asks the operator to paste credentials in by hand:
```
The meshware module needs registry database and minio credentials.
REGISTRY_DB_PASSWORD=<postgres password from novox>
REGISTRY_MINIO_PASSWORD=<minio password from novox>
```
plus an `NPM_TOKEN` for the private registry. These are not adoption-time credentials that are
then discarded: `wireguard` and `traefik` open a `pg` connection on every reconcile
([ADR 0037](0037-the-host-applies-it-does-not-decide.md)).
**So every node permanently holds a credential to the control plane's database, and to the
object store.** They are the same credentials on every node. There is no rotation —
[`00-as-is/06`](../03-DESIGN/00-as-is/06-configuration-and-secrets.md) records that *"there is
no mechanism that rotates one and informs everything holding it. Where a rotation has been
done, it has been done by hand, and doing it wrong has taken services down."*
Compromise of any node is therefore compromise of the mesh's database, and there is no
mechanism to recover from it.
### The link is not new
Written first as though the link were a thing to build. It is not.
[ADR 0001](0001-nodes-communicate-over-a-broker.md) already has it: *every node connects
outbound to a single broker; nothing ever connects to a node*, each node declaring an exchange
named for itself and consuming from its own queue
([`00-as-is/01`](../03-DESIGN/00-as-is/01-mesh-and-transport.md)).
That is already outbound-only, already per-node addressed, and already the one channel
everything arrives through. **This record is not proposing a channel. It is proposing that the
channel carry per-node identity instead of one shared credential.**
The same as-is records the fault, for the broker rather than the database: *"the broker is a
single point of failure and a single point of trust. Its credential is mesh-wide, so rotating
it is a mesh-wide operation, and doing it wrong has taken the broker down."*
## Considered options
1. **Keep shared credentials, scope them per node.** Least change: give each node its own
database role. Rejected — it makes the blast radius smaller without changing its shape, and
it keeps tier 0 speaking the control plane's schema, which ADR 0037 forbids for reasons that
are not about security at all.
2. **Accept the exposure as the cost of simplicity.** A shared credential is one thing to
understand and nothing to build, and the objection to replacing it is real: mutual
authentication fails opaquely, and a node that cannot link is harder to debug than a node
with a wrong password. Rejected on the ground that the simplicity is what makes it
unrotatable — the credential cannot be changed *because* everything holds the same one, so
the arrangement's convenience and its unfixability are the same property.
3. **Mutual authority on a node-initiated link, with the node holding nothing but its own
identity.** Chosen.
## Decision
**The link is the only way anything reaches a node**, and four properties make it a boundary
rather than a pipe.
### It is outbound and node-initiated
The node dials the control plane. Nothing dials a node. This is not only defensive — it is what
the topology already requires: most nodes sit behind a household connection with no forwarded
port ([research 004](../01-RESEARCH/004-lab-network/00-overview.md)), so an inbound control
channel would work for the hosted node and not for the rest, and the difference would be
invisible until it mattered.
A node therefore has **no listening control surface at all**.
### A node holds its own identity and nothing else
No shared secret, no credential to anything it does not own. A node's identity authenticates it
to the control plane and grants access to nothing else.
**Compromise of a node is compromise of that node.** That is the property today's arrangement
does not have, and it is the main reason for this record.
### Authority is mutual
The node proves it may join, and **the control plane proves it is the mesh**. One-way is not
enough here: the host applies whatever the link delivers, so a node that cannot tell the mesh
from something impersonating it will apply that something's declarations. Given ADR 0038, an
attacker who can answer a joining node's first call owns the machine.
### What may be pushed is bounded by form, not by trust
The control plane may push **declarations of known shape** and nothing else. It may not push a
command to run. The host's vocabulary is finite, versioned and auditable, and anything outside
it is refused rather than best-effort interpreted.
**Stated honestly: this bounds form, not impact.** A compromised control plane can declare
harmful state — a malicious package, an open firewall — and the host will apply it faithfully,
because that is what it is for. What the property buys is that the blast radius is describable:
it is exactly what the declaration language can express, which can be reviewed. An arbitrary
command channel has no such bound. This is a real limit and not a defence-in-depth story.
### Joining is a deliberate, bounded act
A joining node presents a **one-time, short-lived enrolment token** issued by the mesh for that
purpose, and exchanges it for its own durable identity. The token grants exactly one thing:
the right to become a node. It is not a credential to any service, it does not persist after
exchange, and it expires whether used or not.
This replaces hand-carried shared secrets with a thing that is useless once used and useless
after a while.
## What this actually costs
The objection to weigh is overhead, and it is smaller than it looks because most of it is
already running.
| Property | Where it comes from |
|---|---|
| outbound, node-initiated | already true — ADR 0001 |
| per-node addressing | already true — per-node exchange and queue |
| per-node credential | a broker user per node; the broker already has users, virtual hosts and per-queue permissions |
| mutual authority | transport-level certificates on a connection that already exists |
| bounded by form | already true — three message shapes and only three |
| **enrolment** | **the one genuinely new mechanism** |
And ADR 0037 subtracts rather than adds: under it a node holds **no** database credential at
all, so this record replaces three hand-carried shared secrets with one per-node identity that
grants only identity.
**It must fail legibly.** A boundary that refuses a node without saying why is worse than the
credential it replaced, because a wrong password at least announces itself. A node that cannot
link must report which side rejected it and on what grounds, in terms someone can act on. This
is `how-we-build` §5 applied to a security mechanism: a refusal that proves only that something
went wrong is transport reported as effect.
## Consequences
- **ADR 0037 removes a standing exposure as a side effect.** Its rule — the host never queries
the mesh database — was chosen for tier discipline. It also removes the reason every node
holds the database password. Worth recording because the two arguments are independent and
both hold.
- **Rotation becomes possible and is still not designed.** Per-node identities can be revoked
individually, which is what makes rotation tractable at all. The mechanism —
what rotates, on what trigger, and how holders learn — is **not decided here** and remains
the open weakness `00-as-is/06` records.
- **The enrolment token has to come from somewhere.** Issuing it is a control-plane operation
and the first node has no control plane, so the first node's identity is self-issued and
becomes the root of trust when the mesh comes up. **That is a real asymmetry** — the one
place ADR 0038's "no special first node" does not fully hold — and it is named here rather
than hidden.
- **A declaration vocabulary is now a security artefact, not only a design one.** Every
addition widens what a compromised control plane can express. That is a reason to keep it
small and a reason for additions to be reviewed as such.
- **Offline nodes need identities that survive disconnection.** Per
[ADR 0036](0036-a-node-is-a-managed-machine.md) disconnection is ordinary, so an identity
that must be refreshed to remain valid would make a laptop fail for being a laptop. What
expires and what does not is **not decided here**.
- **This is a boundary that does not exist yet.** Nothing in the current mesh implements any of
it, and the migration from shared credentials to per-node identity touches every node and the
substrate. No estimate is offered.
## References
- [ADR 0038](0038-a-node-joins-by-linking-first.md) — the link, and the gap this fills.
- [ADR 0037](0037-the-host-applies-it-does-not-decide.md) — why the host stops holding database
credentials at all.
- [ADR 0036](0036-a-node-is-a-managed-machine.md) — disconnection as ordinary, which constrains
what may expire.
- [`00-as-is/06-configuration-and-secrets.md`](../03-DESIGN/00-as-is/06-configuration-and-secrets.md)
— secrets today, and the absence of rotation.
- [Research 004](../01-RESEARCH/004-lab-network/00-overview.md) — why most nodes cannot accept
an inbound connection.
@@ -0,0 +1,104 @@
---
status: accepted
date: 2026-08-26
deciders: jochen
reconstructed: false
---
# 40. The constitution absorbs what is already enforced
## Context
[ADR 0025](0025-hq-is-the-source-of-the-constitution.md) makes this repository the source
and the knowledge base a derived copy, and playbook
[05](../00-META/process/05-constitution-sync.md) publishes the copy whenever a rule changes.
Three rules were accepted on 2026-08-25 and the sync came due. Reading the target before
overwriting it — as [§2](../00-META/how-we-build.md) requires — found the two documents had
diverged in a way nobody had recorded, and that a literal republish would have **deleted rules
the mesh currently enforces**.
| | source (`how-we-build.md`) | enforced page |
|---|---|---|
| §4 | naming and boundaries | **code quality** — structure, layering, types, restraint |
| §5 | four evidence rules | one runtime-evidence rule |
| §6 | review by a non-proposer | a design meeting with **two node operators** |
The enforced page was last written 2026-07-10, is agent-authored, and its code-quality section
appears in no decision record anywhere. It has been checked against for six weeks regardless.
Two facts made this urgent rather than tidy:
**Removal is not the safe option.** The orchestrator reads *"when absent, no constitution is
injected (backward-compatible)"* — so deleting the page would not fail, it would silently
inject nothing, and every design meeting would run unchecked with no error anywhere. Three
unenforced rules would become all of them.
**The stricter review bar has never been met.** The enforced page requires two node operators,
neither the proposer. There is one operator. Every amendment ever made violated it.
## Considered options
1. **Republish wholesale.** What the playbook literally says. Rejected — it deletes the
code-quality rules, which exist nowhere else.
2. **Merge surgically** — update §2, §3, §5 in the copy and leave §4 alone. Nothing is lost and
it is quick, but the source becomes authoritative for some sections and the copy for others,
which is the drift being fixed.
3. **Absorb, then republish.** Chosen.
## Decision
**What the mesh enforces and cannot cite is written down here first, then republished.**
The code-quality rules become §8 rather than §4, because appending renumbers nothing and every
existing citation stays valid.
**They are recorded as inherited, and marked as such.** Every other rule in the document states
the incident or measurement that earned it. These state nothing, because nothing was ever
written down. Importing them silently would have made the document claim a provenance it does
not have, and this repository's whole argument is that the reasoning is the expensive half.
**The review bar is resolved in favour of what is achievable.** A rule requiring two operators
where there is one is not a high standard, it is a rule everything silently violates — the same
shape as a firewall key declared in five manifests and read by no code
([04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md)). Review means
a person who is not the proposer, which is both achievable and what has been practised.
## Consequences
- **The copy can now be a faithful projection**, which is what makes "the source is the source"
true rather than aspirational.
- **§8 has no reasoning behind it and says so.** Anyone may propose replacing an inherited rule
with an earned one; until then the marking is the honest state. It is also a standing
invitation to delete any of them that turn out to serve nothing.
- **Six weeks of drift went unnoticed** because nothing compares the two. The sync is manual and
runs only when someone remembers a rule changed. **Nothing detects divergence**, and this
record does not fix that — it is worth an issue.
- **The operator's instinct to remove the copy was right about the direction and wrong about the
method.** The end state where the mesh reads this repository directly — rendered and operable
through the board — removes the second copy rather than blanking it. That is not designed and
is not decided here.
- **Section numbering is now append-only in practice.** §8 sits after the overrides section
purely because renumbering would break citations, which is a cost of the copy existing at all.
## Sync
Run 2026-08-26, and the read-back earned its place in the playbook.
**The first publish reported success and changed nothing.** It created a new revision and
updated the title, and the body did not apply — a malformed argument was dropped silently. Had
the sync been marked done on the strength of the tool saying *"updated; new revision created"*,
three accepted rules would have gone unenforced while every record said otherwise, and nothing
would ever have contradicted it.
That is §5 demonstrating itself during its own publication: a green result proves transport,
not effect. The republish was verified by reading the rule back out of the live page, not by
trusting the second success message either.
## References
- [ADR 0025](0025-hq-is-the-source-of-the-constitution.md) — source and copy.
- [ADR 0009](0009-the-mesh-is-governed-by-a-constitution.md) — why the copy is injected at all.
- [Playbook 05](../00-META/process/05-constitution-sync.md) — the sync this record interrupts.
- [ADR 0034](0034-a-test-defends-a-decision.md), [ADR 0035](0035-a-picture-is-read-from-what-runs.md),
[ADR 0018](0018-the-mesh-creates-no-symlinks.md) — the three rules whose sync surfaced this.
@@ -0,0 +1,78 @@
---
status: accepted
date: 2026-08-26
deciders: jochen
reconstructed: false
extends: 0037-the-host-applies-it-does-not-decide.md
---
# 41. The host depends on nothing that must be installed first
## Context
[ADR 0030](0030-the-repository-structure.md) calls tier 0 *"the one binary installed by hand"*,
and [research 006](../01-RESEARCH/006-mesh-from-scratch/00-overview.md) states the property the
whole tier rests on: *"a binary whose whole argument is that it has no dependencies"*.
Building it forced the question that phrase had been carrying unexamined. Everything else in
the mesh is TypeScript, and `how-we-build` §8 says so. A TypeScript host needs a runtime present
before it can run — so the thing installed by hand becomes **two** things, and the second must
be installed by the means the host exists to replace.
## Considered options
1. **TypeScript, with a runtime installed first.** Simplest, and matches every other
repository. Rejected: it breaks the property the tier is built on. A host that cannot run
until something else has been installed by hand is not the bottom of the stack.
2. **TypeScript, bundled as a single executable.** Preserves the language and produces one
file. Rejected on two grounds: it carries roughly ninety megabytes of runtime to preserve a
language choice, and single-executable bundling is a young feature — tier 0 is the worst
place in the system to discover its edges.
3. **A statically linked binary in a language built for it.** Chosen; Go.
## Decision
**The host is a single statically linked binary that requires nothing to be present.** Copy it
onto a machine and run it. That is the whole installation.
**It is written in Go.** The job is system-level — run commands, write files, speak to the
firewall, the overlay, the service manager and the package manager — which is what Go's
ecosystem is for, and it cross-compiles to every architecture the mesh might reach, including
the lighter devices requirement 6 anticipates.
**The second language costs less here than anywhere else it could appear**, and the reason is
architectural rather than convenient. [ADR 0037](0037-the-host-applies-it-does-not-decide.md)
means the host never queries the mesh database.
[ADR 0039](0039-the-link-is-the-security-boundary.md) means it only ever receives declarations.
So the host shares **no code** with any other tier — not a client, not a schema, not the SDK.
It is joined to the mesh by a message contract and nothing else.
The language boundary therefore falls exactly on an architectural boundary that already exists.
A second language usually costs duplicated logic; here there is none to duplicate.
## Consequences
- **`how-we-build` §8 needs a scope.** It reads *"TypeScript throughout"*, which was true when
everything was a service or a surface. It is now scoped to those, with tier 0 named as the
exception and this record as the reason. That is a constitution change, and the sync it owes
is part of it.
- **Agents must write Go to work on the host.** A real cost, and the one genuine argument
against this. It is bounded by the host being the only thing in tier 0 — nothing else in the
mesh acquires a second language because of this.
- **The dependency-direction lint the design calls for gets easier, not harder.** A Go module
cannot accidentally import a TypeScript control-plane client; the boundary is enforced by
there being no path across it.
- **Cross-compilation replaces per-node builds.** The host is built once per architecture and
copied, rather than built on the machine it runs on — which is what makes *"copy it and run
it"* true rather than nearly true.
- **Two toolchains in the lab.** Scenarios that place a host need a Go build available, and the
lab is TypeScript. The binary is built before the scenario runs, not inside it.
- **This is reversible at a cost that will only grow.** It is being taken at the moment the
first line is written, which is the cheapest point it will ever be taken.
## References
- [ADR 0030](0030-the-repository-structure.md) — *the one binary installed by hand*.
- [ADR 0037](0037-the-host-applies-it-does-not-decide.md) — why the host shares no code.
- [ADR 0039](0039-the-link-is-the-security-boundary.md) — why it receives declarations only.
- [`05-the-node-host.md`](../03-DESIGN/01-to-be/05-the-node-host.md) — the design this serves.
+145
View File
@@ -0,0 +1,145 @@
---
layer: as-is
status: implemented
code: [mesh-lab]
updated: 2026-08-25
decisions:
- 02-DECISIONS/0016-a-lab-node-is-a-virtual-machine.md
- 02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md
- 02-DECISIONS/0031-the-lab-provides-the-underlay.md
- 02-DECISIONS/0032-a-scenario-is-an-isolated-address-space.md
- 02-DECISIONS/0033-a-router-is-scenery-not-a-node.md
---
# The lab, as it stands
The first piece of the new shape that exists. It is the only repository outside the monorepo
so far, and unlike everything else planned it ships to nobody: it runs on a workstation,
raises virtual machines, and throws them away.
Written from the implementation. Where intent and implementation disagree, the implementation
is what is recorded here and the disagreement is stated.
## What it does
A scenario is a YAML file declaring an **underlay** — segments, the gateways between them, and
machines placed on them. `raise` materialises it on `incus`; `destroy` removes it. In between,
`exec` runs a command inside a machine, and `snapshot` / `restore` capture and return the whole
scenario as one state.
| | |
|---|---|
| segments as isolated links | works |
| machines, multi-homed or detached | works |
| declared addresses, both families | works |
| segment MTU | works |
| gateways, NAT, masquerade | works |
| `published:` ports, as DNAT through the gateway's address | works |
| `mapping_ttl:` as a conntrack timeout, read back after setting | works |
| `forwardable: false` — outbound only | works |
| `policy:` between segments, asymmetric | works |
| `inbound: deny` as a host firewall, read back after applying | works |
| several public networks, routed through a transit router | works |
| `diagram` — the scenario drawn, from the declaration or from the hypervisor | works |
| `place:` | **refused at raise** |
| the lab's own certificate authority | **not built** |
## What it does not do, and why that matters
**`place:` is refused.** A scenario can declare that a node host is placed on a machine; the
lab names the gap and refuses rather than raising a scenario that silently lacks what it
declared. Nothing can be placed because tier 0 does not exist yet.
The consequence is worth stating plainly rather than leaving to be inferred: **the lab raises
empty machines.** It reproduces a network faithfully and puts nothing on it. It is
infrastructure whose consumer has not been built, and it stays that way until tier 0 does.
**The certificate story is designed and absent.**
[`01-end-to-end-testing.md`](../01-to-be/01-end-to-end-testing.md) specifies the lab running
its own ACME issuer on the public segment, preserving production's two-CA split. None of that
is built.
## What shipped differently from the design
**The drawing was never designed.** `diagram` renders a scenario as draw.io, from the
declaration or from the running instance, and it exists because it was asked for during the
build. It has tests and a decision record ([ADR 0035](../../02-DECISIONS/0035-a-picture-is-read-from-what-runs.md),
proposed) but no document in the to-be layer. It is recorded here because it runs, not because
it was planned.
**A router is tagged as a machine as well as a router.** The design speaks of routers and
machines as distinct. In the implementation a router carries `user.mesh-lab.machine` too,
because `destroy` finds an instance's resources with one query and a router that carried only
`router=` was left behind — holding its networks open, so `destroy` reported removing zero
segments.
**The router image is built once and cached.** A scenario is a closed address space, so a
router has no route to a package repository and cannot install `nftables` at raise time. The
image is prepared once, with temporary connectivity. That is the only step in the whole lab
that needs the workstation to be online.
## The rules that turned out to be load-bearing
**Public segments must use documentation ranges** (RFC 5737, RFC 3849), refused by the
validator before anything is raised. Research 004 found why: the mesh decides
public-versus-private by matching the address, so a private range on a segment meant to be
routable makes the mesh silently never form.
**A scenario is a closed address space.** The workstation has no route in, so two instances
raised from one declaration hold the same addresses and never meet. Reachability is therefore
asked from *inside* — `exec` on one machine, testing another. The workstation's opinion would
be a different question with a misleadingly similar answer.
**One public address is one gateway.** Two gateway declarations sharing an address are one
box, and their address lists union. Before this, gateways were grouped on their exact address
list, and a household declaring a v6 address on one of its two segments became two router
containers holding one address on one segment — which resolved to whichever answered ARP last.
## What it costs
Measured on a workstation, not asserted:
| | one machine | two machines | two machines and a router |
|---|---|---|---|
| raise, to usable | 12.5 s | 14.6 s | 32 s |
| snapshot | 0.14 s | 0.28 s | — |
| restore, to usable again | 10.5 s | 11.6 s | — |
Machines boot concurrently, so a second machine costs seconds rather than doubling the wait.
Nearly all the remaining time is boot.
These numbers depend entirely on a copy-on-write storage pool. On `dir` the same snapshot takes
9.9 s and a full copy of the disk, and a second did not finish in two minutes — so the lab's
`check` **refuses** rather than warns. A machine without copy-on-write runs scenarios correctly
and snapshots roughly 76× slower, which does not make the lab slow, it makes it unused.
## How it is checked
`npm run check` — typecheck over source *and* tests, then the offline suite, then integration
against a real hypervisor. Mocking the hypervisor is forbidden
([ADR 0034](../../02-DECISIONS/0034-a-test-defends-a-decision.md), proposed): a test that fakes
the system under integration asserts that the fake behaves as expected.
Integration tests **skip with a reason** on a machine that cannot raise scenarios, rather than
passing green having checked nothing.
Two things the suite does not yet do, recorded because their absence is invisible:
- **It raises two of the five scenarios.** Both faults found so far — two gateways holding one
address, and a gateway drawn across an unrelated network — lived in scenarios nothing ever
built. They were found by looking at pictures, not by running tests.
- **Nothing opens the generated draw.io file.** The tests assert on the XML and check the
stencil names against draw.io's own library, but no test has ever opened one.
## References
- [`01-to-be/02-scenario-declaration.md`](../01-to-be/02-scenario-declaration.md) — what a
scenario declares.
- [`01-to-be/03-scenario-lifecycle.md`](../01-to-be/03-scenario-lifecycle.md) — what happens
to one.
- [`01-to-be/04-lab-installation.md`](../01-to-be/04-lab-installation.md) — what the
workstation needs.
- [Research 004](../../01-RESEARCH/004-lab-network/00-overview.md) — the topology, and the
address-range constraint the whole thing turns on.
- [Research 010](../../01-RESEARCH/010-lab-inner-loop-cost/00-overview.md) — where the measured
costs come from.
+1
View File
@@ -19,6 +19,7 @@ Where the two disagree, the implementation wins and the disagreement is stated.
| [`08-agents-and-work.md`](08-agents-and-work.md) | Agents as employees, tasks, workflows, and the meeting model |
| [`09-interfaces-and-observability.md`](09-interfaces-and-observability.md) | How the mesh is reached and watched — tools, board, proxy, health, thoughts |
| [`10-module-catalogue.md`](10-module-catalogue.md) | The catalogue's shape, and what its shape says |
| [`11-the-lab.md`](11-the-lab.md) | The lab — the first piece of the new shape that exists, and what it does not yet do |
## What these documents are not
@@ -1,8 +1,8 @@
---
layer: to-be
status: designed
status: in-progress
code: [mesh-lab]
updated: 2026-08-23
updated: 2026-08-25
decisions:
- 02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md
- 02-DECISIONS/0031-the-lab-provides-the-underlay.md
@@ -256,6 +256,11 @@ reachability, and it does not.
The lab materialises a machine to be the gateway. That is the one implicit machine in an
otherwise explicit declaration, and it exists because NAT has to run somewhere.
It is a **container, not a virtual machine** — a router is scenery rather than something under
test, so the fidelity argument that makes a node a virtual machine does not reach it
([ADR 0033](../../02-DECISIONS/0033-a-router-is-scenery-not-a-node.md)). What a router must
reproduce is kernel behaviour, and a container has the same kernel.
**`machines[].at`** — segment and addresses, or a **list** of them for a machine on several
segments at once. Multi-homing is not exotic: it is what a border machine is, and what any node
with both a LAN and a WAN interface is. Each entry carries the addresses that machine holds on
+8 -2
View File
@@ -1,8 +1,8 @@
---
layer: to-be
status: designed
status: in-progress
code: [mesh-lab]
updated: 2026-08-23
updated: 2026-08-25
decisions:
- 02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md
- 02-DECISIONS/0031-the-lab-provides-the-underlay.md
@@ -133,6 +133,12 @@ decision rather than a second implementation.
[research 010](../../01-RESEARCH/010-lab-inner-loop-cost/measurements.md).
- **Instance naming.** A declaration is a kind and instances are many; how they are named
decides whether a person can find the one they left standing yesterday.
- ~~**Does a scenario snapshot need the machines stopped?**~~ **Answered by the integration
test on its first run: no, but they must be flushed.** A snapshot captures disk and not
memory, so a write still in the guest's page cache is absent from it — not stale, absent. A
file written seconds before a snapshot did not survive the restore. Flushing first buys
write-durability; it does not buy application-consistency, and anything mid-transaction is
still captured mid-transaction.
- **What survives `destroy`.** Logs and captures are the output of a failed run, so destroying
the instance must not destroy them.
- **Placement before the mesh is self-hosting.** `place:` needs artifacts from somewhere, and
+198
View File
@@ -0,0 +1,198 @@
---
layer: to-be
status: in-progress
code: [mesh-host]
updated: 2026-08-26
decisions:
- 02-DECISIONS/0030-the-repository-structure.md
- 02-DECISIONS/0036-a-node-is-a-managed-machine.md
- 02-DECISIONS/0037-the-host-applies-it-does-not-decide.md
- 02-DECISIONS/0038-a-node-joins-by-linking-first.md
- 02-DECISIONS/0039-the-link-is-the-security-boundary.md
- 02-DECISIONS/0008-a-failed-step-fails-the-job.md
- 02-DECISIONS/0041-the-host-depends-on-nothing.md
---
# The node host
Tier 0. The one thing ever installed by hand, and the only thing that changes a machine.
## What it is
A **statically linked binary that requires nothing to be present** — copy it onto a machine and
run it, and that is the whole installation
([ADR 0041](../../02-DECISIONS/0041-the-host-depends-on-nothing.md)). Written in Go, because the
job is system-level and because the host shares no code with any other tier.
A single binary with one job: **apply declared state on this machine**
([ADR 0037](../../02-DECISIONS/0037-the-host-applies-it-does-not-decide.md)). Overlay
membership, packet filtering, packages, services, containers and filesystems are not six
concerns it carries; they are six instances of the one.
It replaces three things that exist today
([`00-as-is/05`](../00-as-is/05-runtime-and-installation.md)): the three hand-run bootstrap
scripts — first node, joining, rescue — and the synchronisers that rewrite managed files
([`00-as-is/06`](../00-as-is/06-configuration-and-secrets.md)).
**What it is not:** it does not decide anything that needs another node, it never queries the
mesh database, and it has no listening surface.
## The parts
| Part | Owns |
|---|---|
| `apply` | reconciling declared state on this machine |
| `store` | local state, authoritative while disconnected |
| `link` | the single outbound connection to the control plane |
| `profile` | what this machine can be asked to do |
| `inventory` | what this machine is and has |
| `substrate.lock` | the pinned tier-1 descriptor, appliable with no mesh present |
### apply
Takes a **declaration** and makes the machine match it. Idempotent: applying the same
declaration twice changes nothing the second time, and applying it to a drifted machine
returns it.
Three properties, each following a recorded decision:
**A failed step fails the apply.** Not "logs and continues"
([ADR 0008](../../02-DECISIONS/0008-a-failed-step-fails-the-job.md)). A partial apply that
reports success is the mesh's most expensive shape.
**Each applier reads back.** Setting a value is not evidence the value took. The firewall is
asked whether the rule loaded; conntrack is asked what timeout it holds. This is `how-we-build`
§5 as a component requirement rather than a review habit.
**What was applied is recorded after it works, never before**
([ADR 0035](../../02-DECISIONS/0035-a-picture-is-read-from-what-runs.md)). A failed apply leaves
the machine in whatever state it reached, and nothing must claim otherwise.
### store
Local, and **authoritative while disconnected**. Not a cache of the control plane — the record
of what this node has applied and what it currently holds.
This is structural rather than convenient: if disconnection is an ordinary situation rather
than an exception ([ADR 0036](../../02-DECISIONS/0036-a-node-is-a-managed-machine.md)), the
store is what makes it ordinary. A laptop shut for a week comes back and reconciles; it does
not come back and ask what it is.
### link
The node's one connection to the control plane, and its security boundary
([ADR 0039](../../02-DECISIONS/0039-the-link-is-the-security-boundary.md)).
It is the broker connection that already exists
([ADR 0001](../../02-DECISIONS/0001-nodes-communicate-over-a-broker.md)) — outbound,
node-initiated, per-node addressed — carrying **per-node identity instead of a shared
credential**. The node owns no password. It owns an identity, and that identity is what it
presents.
What arrives is bounded by form: **declarations of known shape, never a command to run.**
**It must fail legibly.** A node that cannot link says which side refused and on what grounds.
A boundary that refuses without saying why is worse than the password it replaced.
### profile
What this machine can be asked to do — a graphical session, a container runtime, an
architecture, a network position.
**Detected, never assumed.** A package being installed does not mean a capability is present
([04-ISSUES/007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md)): a
capability is real when it is present, running and working, and the difference is the whole
point of detecting it.
The profile is what makes [ADR 0036](../../02-DECISIONS/0036-a-node-is-a-managed-machine.md)
work: a node is a node, and what varies between them is here rather than in the definition.
### inventory
What this machine *is* — its identity, what it holds, what it has applied. Reported upward over
the link; never asked downward.
## Where a declaration comes from
One behaviour, two sources
([ADR 0038](../../02-DECISIONS/0038-a-node-joins-by-linking-first.md)):
| Situation | Source |
|---|---|
| no mesh reachable | `substrate.lock` — the pinned bundle the host carries |
| 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 every other node. Its specialness is temporary and self-erasing.
**A joining node does the minimum to be reachable and nothing else** — an identity, an address,
one peer — and then stops deciding. It does not compute the overlay; it needs one peer to reach
the mesh, and the full peer set arrives derived.
## What a declaration is
**Open, and the first thing to settle in build.** The shape is constrained but not chosen:
- It is data, not instructions — the host's vocabulary is finite, versioned and auditable, and
anything outside it is refused rather than best-effort interpreted.
- It is per-node and complete: what this machine should be, not a delta against what it was.
A delta requires the sender to know what the receiver holds, which is the coupling the store
exists to remove.
- Every addition to the vocabulary widens what a compromised control plane can express, so it
is a security artefact and additions are reviewed as such.
## Build order
Staged so each stage is verifiable in the lab before the next exists.
**1 — profile and inventory.** The host runs on a machine, detects what it can do, and reports
what it is. No control plane, no declarations, no network. Verifiable immediately: the lab's
`place:` gains its first implementation, and a raised scenario finally contains something.
**2 — apply, from the bundle.** The host applies `substrate.lock` with no mesh present. This is
the first node's path, and it is the claim the skeleton's Move 1 rests on and has never proved:
that one host can raise the substrate alone.
**3 — link and store.** The node connects, receives declarations, and holds what it applied.
**4 — enrolment.** The one genuinely new mechanism in
[ADR 0039](../../02-DECISIONS/0039-the-link-is-the-security-boundary.md); everything else there
is configuration of what already runs.
Stage 1 is deliberately the smallest useful thing. The lab currently raises **empty machines**
([`00-as-is/11`](../00-as-is/11-the-lab.md)) because `place:` has nothing to place; stage 1 ends
that, and every later stage is tested by a lab that already works.
## How it is verified
**The lab is the harness.** A scenario places a host on a machine and asserts what it did —
against a real hypervisor, with the boundary never mocked
([ADR 0034](../../02-DECISIONS/0034-a-test-defends-a-decision.md)).
Each decision above owes a test:
| Decision | What asserts it |
|---|---|
| 0037 — the host never queries the mesh database | no database client in the dependency tree; a dependency-direction lint failing on an upward import |
| 0038 — one behaviour, two sources | the same code path raises a first node and joins a second |
| 0039 — a node holds no shared credential | a raised node's store contains no credential to any service |
| 0036 — disconnection is a situation | a node cut off and returned reconciles without being re-adopted |
| 0008 — a failed step fails the apply | an apply with a failing step reports failure |
## Open
- **What a declaration is.** Above; the first thing to settle.
- **Whether one host can raise the substrate alone.** Move 1 assumes it. Stage 2 tests it, and
if it is false the tier boundary moves.
- **What the host carries versus what it finds.** It manages `wg`, `nft`, `pacman`, `docker`;
it does not contain them, and how it obtains one it lacks is undecided —
[04-ISSUES/007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md).
- **Six vocabularies.** Zero dependencies, but the host must still know what a peer, a rule, a
package, a unit, a container and a dataset *are*. Nothing has measured that surface, and it is
the residue of the question [`host-size.md`](../../01-RESEARCH/006-mesh-from-scratch/host-size.md)
answered.
- **Rescue.** [ADR 0038](../../02-DECISIONS/0038-a-node-joins-by-linking-first.md) suggests it is
a node whose local state is discarded so the mesh re-derives it, and does not decide it.
- **What may expire.** An identity needing refresh to stay valid would make a laptop fail for
being a laptop ([ADR 0036](../../02-DECISIONS/0036-a-node-is-a-managed-machine.md)).
+1
View File
@@ -14,6 +14,7 @@ document is written and this one's status becomes `implemented`.
| [`02-scenario-declaration.md`](02-scenario-declaration.md) | What a scenario declares — the underlay, and what to place on it | [ADR 0031](../../02-DECISIONS/0031-the-lab-provides-the-underlay.md) |
| [`03-scenario-lifecycle.md`](03-scenario-lifecycle.md) | What happens to a scenario — raise, snapshot, restore, move, destroy | [ADR 0032](../../02-DECISIONS/0032-a-scenario-is-an-isolated-address-space.md) |
| [`04-lab-installation.md`](04-lab-installation.md) | Getting the lab onto a clean machine, and why it verifies capability rather than installation | [ADR 0008](../../02-DECISIONS/0008-a-failed-step-fails-the-job.md) |
| [`05-the-node-host.md`](05-the-node-host.md) | Tier 0 — the one thing installed by hand, and the only thing that changes a machine | [ADR 0037](../../02-DECISIONS/0037-the-host-applies-it-does-not-decide.md) |
## Not yet written