ADR 0038 accepted; ADR 0039 (proposed) — the link is the security boundary

0038 accepted: no two modes. One behaviour, two sources of declaration.

0039 fills the gap 0038 named. Proposed rather than measured — it designs a
boundary that does not exist — but what it replaces IS measured, and that is the
argument.

Today adopt.sh asks the operator to paste in the postgres password and the
object store password, the same ones on every node, and they are not discarded
after adoption: wireguard and traefik open a pg connection on every reconcile.
So every node permanently holds a credential to the control plane's database,
and 00-as-is/06 records that nothing rotates it. Compromise of any node is
compromise of the mesh's store, with no way back.

Four properties make the link a boundary rather than a pipe: it is outbound and
node-initiated, so a node has no listening control surface — which the topology
already requires, since most nodes have no forwarded port. A node holds its own
identity and nothing else, so compromise of a node is compromise of that node.
Authority is mutual, because a host that applies whatever the link delivers must
know the mesh from something impersonating it. And what may be pushed is bounded
by FORM — declarations of known shape, never a command to run.

That last property is stated with its limit rather than oversold: it bounds
form, not impact. A compromised control plane can declare harmful state and the
host will apply it faithfully. What it buys is a describable blast radius.

Joining uses a one-time short-lived enrolment token, useless once used and
useless after a while, in place of hand-carried shared secrets.

Named rather than hidden: the first node's identity is self-issued and becomes
the root of trust, which is the one place 0038's "no special first node" does
not fully hold. Rotation becomes possible and is still not designed. And what
may expire is constrained by 0036 — an identity needing refresh would make a
laptop fail for being a laptop.
This commit is contained in:
2026-08-25 22:27:31 +02:00
parent 72b22830f3
commit 4866007c04
2 changed files with 144 additions and 3 deletions
@@ -1,5 +1,5 @@
--- ---
status: proposed status: accepted
date: 2026-08-25 date: 2026-08-25
deciders: jochen deciders: jochen
reconstructed: false reconstructed: false
@@ -89,8 +89,8 @@ makes it smaller.
- **The rarely-run path is now the common one.** The first node exercises the same code every - **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. 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 - **The link becomes the security boundary.** Everything a node applies arrives through it, so
what may be pushed, and how a joining node proves it is entitled to join, is now a question what may be pushed, and how a joining node proves it is entitled to join, is its own
worth its own record. **Not decided here, and it is a gap.** 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 - **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. 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. This record depends on it and does not establish it.
@@ -0,0 +1,141 @@
---
status: proposed
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, so it is
proposed rather than measured — but what it replaces is measured, and that is the argument.
### 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.
## 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. **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.
## 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.