Merge pull request 'ADR 0220: what a machine asks needs its uplink held, and the retired resolver pieces go' (#110) from decision/0220-resolver-config-needs-the-uplink into main

This commit was merged in pull request #110.
This commit is contained in:
2026-10-05 20:02:59 +00:00
4 changed files with 210 additions and 13 deletions
@@ -0,0 +1,157 @@
---
topic: what runs on it
status: accepted
date: 2026-10-05
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md
---
# 220. What a machine asks needs its uplink held, and the retired resolver pieces go
## Context
**Three things about a machine's resolver were left half done when the mesh moved to one resolver.**
On the production mesh on 2026-10-05, read from the controller's `seats` verb:
- **`node-dns-resolver` has no holder on any node, and no module in the catalogue claims it.**
[ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md) retired it
and the controller kept its row deliberately, *"deleted once nothing claims it"*, because removing a
seat a machine still holds makes that machine unresolvable. That condition now holds. The row still
stands in the controller's compiled set and in the store's seat table, and the overview still lists
it, unheld, beside the seats a mesh actually has.
- **The rule that keeps `/etc/resolv.conf` the mesh's is checked by nothing.**
[ADR 0117](0117-a-machines-uplink-is-a-seat.md) found that a network manager rewrites the resolver
file on every connectivity change unless it is told not to, and gave that telling to the module
holding `node-uplink`. It said the condition *"only if NetworkManager runs"* is expressed by
assigning the manager's module. Nothing makes anybody do so: `resolv-conf` can be assigned to a
machine with no uplink holder, and the file is then replaced the first time a laptop changes
network while every surface of the mesh reads green. Today every node holding
`node-resolver-config` also holds `node-uplink` — NetworkManager on the home server, the
workstation and the laptop, systemd-networkd on the anchor — by care, not by check.
- **The catalogue still carries a systemd-resolved split-DNS module**, `resolved-split-dns`, claiming
`node-resolver-config`. [ADR 0196](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md)
chose against a stub on every node and says *"There is no `systemd-resolved` module."* It is
assigned nowhere. `resolv-conf`'s own resolver file still tells its reader that systemd-resolved or
NetworkManager may be assigned *instead* — the opposite of how the roles now divide.
**A dependency mechanism already exists.** [ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)
made a module depend on the node seats that apply its resources, derived rather than stated, judged
over the node's whole set of assignments, refused at `assign` naming the seat and its possible holders,
and refused at composition. [ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)
derived a second kind from contributions. What is missing is a dependency that belongs to a *role*
rather than to what a module declares.
## Considered Options
**For the retired seat:**
1. **Keep the row until the build seat's retired row goes too, and delete both together.** Rejected:
the two have nothing in common but having been retired; one is unclaimed now and the other is not
yet known to be.
2. **Remove it from the compiled set only.** Rejected: seeding adds a seat a release ships and never
removes one ([ADR 0122](0122-a-seat-is-data-a-rename-is-a-database-update.md)), so the store's
row, which is the live set, would stay.
3. **Remove it from the compiled set and delete the store's row in a numbered migration**, as the
artifact store's rename did for its old row. Chosen.
**For the resolver file and the uplink:**
1. **Leave it to the operator.** Rejected: it is the failure ADR 0117 describes — the file silently
replaced — with the one difference that the operator was told.
2. **`resolv-conf` declares the manager's settings itself.** Rejected by ADR 0117 already: which
setting depends on which manager runs, and a resolver module that knew about network managers would
be the wrong module knowing the wrong thing.
3. **A manifest field in `resolv-conf` naming `node-uplink`.** Rejected for the reason ADR 0207
rejected its own option 2: a second module claiming the same seat would have to restate it, and
one that forgot would pass.
4. **The seat carries what its holder needs beside it.** `node-resolver-config` names `node-uplink`;
any module claiming the former depends on the latter, derived from the claim and judged exactly as
ADR 0207 judges a resource's dependency. Chosen.
**For the split-DNS module:** keep it for a machine that wants systemd-resolved in charge, or remove it.
Kept, it is a second answer to a question ADR 0196 settled, and a claimant the catalogue offers
without a record allowing it. Removed.
## Decision
**1. `node-dns-resolver` is deleted from the mesh's set.** The controller's compiled set no longer
carries it, and a numbered migration of the controller's store deletes its row and any alias naming
it. No alias is kept: nothing was renamed, and a manifest still claiming it should be refused at
registration, naming the seat. This completes ADR 0194's retirement; nothing it decided changes.
**2. A seat may name the node seats its holder needs held on the same node.** A module claiming such a
seat depends on each of them. The dependency is a third source beside ADR 0207's resources and ADR
0210's contributions, and everything ADR 0207 §3 and §4 say of those applies unchanged: met by any
module assigned to the node, the claimant included; judged over the node's whole set; refused at
`assign` naming the seat and the catalogue's possible holders; refused at composition; and only said,
never refused, when no module in the catalogue could hold the needed seat. Unassigning the needed
seat's last holder beneath a dependent is refused, naming the dependent. What a seat needs is part of
the mesh's definition of the role: compiled with the set, never stored, as ADR 0212 keeps what a seat
receives. Adding a need to a seat is a decision, recorded.
**3. `node-resolver-config` needs `node-uplink`.** The holder that writes the resolver file is right
only while the network manager is told to leave it alone, and that telling is the uplink holder's
(ADR 0117). Every manager the catalogue knows — NetworkManager, systemd-networkd, dhcpcd — holds
`node-uplink`, so the refusal always has a remedy to name.
**4. `resolved-split-dns` leaves the catalogue.** `resolv-conf` is the only module claiming
`node-resolver-config`. Its resolver file's comment says the uplink's holder is required beside it,
rather than naming alternatives to assign instead.
## Consequences
- **The set reads as the mesh is.** Thirty-seven seats in the compiled set; the overview no longer
lists a role nothing can fill.
- **A machine cannot be given the mesh's resolver file without its network manager being told to keep
off it.** A machine with no manager at all — a static configuration — needs the smallest holder,
`dhcpcd`, or a new module holding `node-uplink` for its way of configuring the link. That is the
point: such a machine has to say what manages its link before the mesh writes a file the manager
could overwrite.
- **Order of assignment on a new machine**: the uplink holder before or with `resolv-conf`, in one
`assign` when together. On the production mesh nothing changes: every node already holds both.
- **The uplink becomes harder to take away.** Unassigning a machine's manager module while
`resolv-conf` stays is refused; replacing one manager with another is one act assigning the new and
unassigning the old, or the dependent goes first.
- **A machine wanting systemd-resolved has no module for it.** A future need for one is a new record,
not a revival of the removed module.
- **Changing `resolv-conf`'s comment rewrites `/etc/resolv.conf` on every node once**, with the same two
nameserver lines and options; only the comment differs.
- **The merge order matters.** The controller's tests read the catalogue beside them, and the two
changes are judged together: the controller's change and the catalogue's removal merge together,
the catalogue's first or in the same window, and the controller rolls out only once its test suite
passes against the merged catalogue.
## How it is checked
| Rule | Checked by |
|---|---|
| `node-dns-resolver` is not in the set, and the set has thirty-seven seats | mesh-controller's closed-set unit test on the compiled seats |
| The store's row goes with it | the migration, and after rollout the controller's `seats` verb listing no `node-dns-resolver` |
| `node-resolver-config` needs `node-uplink`, and a module claiming it depends on the uplink with nothing in its manifest | mesh-controller's seat-dependency tests on the seat definition and on a synthetic claimant |
| What a seat needs survives loading the set from the store | a unit test loading the store's rows, which carry no such column |
| `resolv-conf` without an uplink holder is refused at `assign`, naming `node-uplink` and its possible holders; beside one, or with one in the same act, it passes; a composition without one is refused | the same tests, and `assign` live |
| Unassigning the uplink's last holder beneath `resolv-conf` is refused | an unassign test |
| In the catalogue, `resolv-conf` depends on the uplink, dhcpcd, NetworkManager and systemd-networkd each hold it, and `resolv-conf` is the only claimant of `node-resolver-config` | a mesh-controller test reading the catalogue beside it |
| Two modules deciding what a machine asks are still refused on one node | the resolver test, now with a synthetic second claimant |
| Every node of the live mesh holding `node-resolver-config` also holds `node-uplink` | the controller's `seats` verb, read before this was decided and after it rolls out; `status` reports no unheld dependency |
## References
- [ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md) — retired
`node-dns-resolver`; this record deletes it.
- [ADR 0196](0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md) — no
stub, and so no systemd-resolved module.
- [ADR 0117](0117-a-machines-uplink-is-a-seat.md) — the uplink's holder keeps the manager off the
resolver file; this record makes that a checked dependency.
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — serving and
asking as two seats.
- [ADR 0207](0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md),
[ADR 0210](0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md) — the dependency mechanism
this extends.
- [ADR 0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) — the set as data, which is why a
deletion is a migration.
- [The seats](../03-DESIGN/01-to-be/26-the-seats.md) and
[connectivity §2](../03-DESIGN/01-to-be/08-connectivity.md), amended alongside.
- mesh-controller `internal/catalogue/seats.go`, `internal/catalogue/seat_dependencies.go`, and the
store migration deleting the row; mesh-catalog `modules/resolv-conf`.
+1
View File
@@ -318,6 +318,7 @@ python3 00-META/checks/index.py fail if stale
- **0214** — [Backups guard against mistakes, stay on the machine, and are declared by the module that owns the data](0214-backups-guard-against-mistakes-and-stay-on-the-machine.md)
- **0215** — [The machine's message bus is a node seat, and it is never restarted live](0215-the-machines-message-bus-is-a-node-seat-and-is-never-restarted-live.md)
- **0216** — [The agent's configuration is registered through its module, at three scopes, and served as one plugin](0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
- **0220** — [What a machine asks needs its uplink held, and the retired resolver pieces go](0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)
### How it is built
+25 -8
View File
@@ -7,14 +7,16 @@ code:
- mesh-controller internal/identity/authority.go
- mesh-controller cmd/mesh-controller/plan.go (the names the roster publishes)
- mesh-controller internal/catalogue/zones.go (the zones a module answers, ADR 0199)
- mesh-controller internal/catalogue/seats.go (mesh-dns-resolver, node-hosts-file)
- mesh-controller internal/catalogue/seats.go (mesh-dns-resolver, node-hosts-file, node-resolver-config needing node-uplink)
- mesh-controller internal/catalogue/seat_dependencies.go (a seat's need checked at assignment, ADR 0220)
- mesh-catalog modules/dnsmasq (the mesh's one resolver)
- mesh-catalog modules/resolv-conf (what a node asks)
- mesh-catalog modules/hosts (a node's /etc/hosts)
- mesh-host internal/identity/serving.go
- mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-10-03
updated: 2026-10-05
decisions:
- 02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md
- 02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md
- 02-DECISIONS/0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md
- 02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
@@ -374,10 +376,21 @@ public names keep resolving then, and `.internal` is never asked of a public res
answers. Containers take the same two from their machine, the runtime copying non-loopback resolvers
into every container, so the runtime is given no `dns` of its own
([ADR 0196](../../02-DECISIONS/0196-a-node-asks-the-meshs-resolver-first-and-a-public-one-only-when-it-is-silent.md),
replacing ADR 0194's per-node `systemd-resolved` stub).
replacing ADR 0194's per-node `systemd-resolved` stub). `resolv-conf` is the one module the catalogue
offers for it; the systemd-resolved split-DNS module that once claimed the same seat is gone
([ADR 0220](../../02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)).
**That file stays the mesh's only beside the uplink.** A network manager rewrites `/etc/resolv.conf`
on every connectivity change unless it is told not to, and the module holding `node-uplink` is what
tells it ([ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)). So
`node-resolver-config` needs `node-uplink` held on the same node: assigning the resolver file to a
machine with no uplink holder is refused, naming the managers' modules that could hold it, and taking
the last uplink holder from beneath it is refused too
([ADR 0220](../../02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)). *Checked by the controller's seat-dependency tests and by `assign` live.*
**No node holds a copy.** The per-node resolver, its zones file and the mesh's region of `/etc/hosts`
go: every resolution fault found on 2026-10-03 was a copy disagreeing with the truth — a hosts file
go, and the per-node resolver's seat with them, deleted from the set once nothing claimed it
([ADR 0220](../../02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)): every resolution fault found on 2026-10-03 was a copy disagreeing with the truth — a hosts file
read once at start, an operator's old line beside the mesh's, a node's resolver lent to a LAN. No
member's resolver answers a LAN; a router pointing at one is moved first. *Checked by each node's
`/etc/resolv.conf` naming `mesh-resolver` then a public resolver, by no node but the holder answering
@@ -1035,10 +1048,14 @@ The list is worth having in one place, because it is most of the argument:
## Open
- **One resolver ([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)).** Not built: every node still runs
`node-dns-resolver`. The migration's four steps are in the record, in order.
Nor are zones or the hosts file's holder ([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)): the
workstation moves to the one resolver only once both exist, its lab and operator names depending on them.
- **One resolver ([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)).** *Correction of fact,
2026-10-05:* no node holds `node-dns-resolver` any more, and
[ADR 0220](../../02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md) deletes the seat. What stood here before: *"Not built: every node still runs
`node-dns-resolver`. The migration's four steps are in the record, in order."* Nor did it
stay true that *"the workstation moves to the one resolver only once"* zones and the hosts file's
holder ([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md))
exist: every node, the workstation included, asks the one resolver, and every node holds
`node-hosts-file`.
- ~~**What happens when the hub is down.**~~ **Resolved** by
[ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), together with `06`'s
+27 -5
View File
@@ -4,14 +4,16 @@ status: in-progress
code:
- mesh-controller internal/catalogue/seats.go
- mesh-controller internal/catalogue/resolve.go
- mesh-controller internal/catalogue/seat_dependencies.go
- mesh-controller internal/inventory/seats.go
- mesh-controller internal/inventory/migrations/0039-a-seat-is-held-by-one-assignment-on-record.sql
- mesh-controller cmd/mesh-controller/seats.go
- mesh-controller cmd/mesh-controller/source.go
- mesh-controller internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql
- mesh-catalog modules/gitea/module.json
updated: 2026-10-03
updated: 2026-10-05
decisions:
- 02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md
- 02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md
- 02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md
- 02-DECISIONS/0161-what-deserves-a-seat.md
@@ -129,12 +131,12 @@ convention, which later seats departed from.
| `mesh-git` | `git` | mesh | `git` | the forge |
| `mesh-build-machine` | `the-build-machine` | node | — | a builder |
| `mesh-resolver` | — | mesh | — | the mesh's one resolver, holding every node's internal domain ([ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)) |
| ~~`mesh-dns-port`~~ | `the-dns-port` | node | — | retired by [ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md): the local resolver became the mesh's one |
| ~~`mesh-dns-port`~~ | `the-dns-port` | node | — | retired by [ADR 0194](../../02-DECISIONS/0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md): the local resolver became the mesh's one; deleted from the set, and from the store's table, once nothing claimed it ([ADR 0220](../../02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)) |
| `node-hosts-file` | — | node | — | owns `/etc/hosts`: the machine's own lines and the operator's kept region, changed through its verbs `entries`, `add`, `remove` ([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)) |
| `mesh-intrusion-prevention` | `the-intrusion-prevention` | node | — | an intrusion-prevention service |
| `mesh-packet-filter` | `the-packet-filter` | node | — | the packet filter |
| `mesh-private-network` | `the-private-network` | node | — | the private network the mesh runs over |
| `mesh-resolver-configuration` | `the-resolver-configuration` | node | — | whichever of the alternative resolver configurations is chosen |
| `mesh-resolver-configuration` | `the-resolver-configuration` | node | — | the module writing `/etc/resolv.conf`; its holder needs the uplink's held on the same node ([ADR 0220](../../02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)) |
| `mesh-showcase` | `the-showcase` | node | — | the showcase module |
The controller holds **the mesh's own** entries in code, and a test asserts their size and that
@@ -202,10 +204,28 @@ Nothing reaches that state by accident: an unknown manifest field is refused out
protocol was written as one. Checked by a registration test accepting a node seat with no protocol
and by the showcase manifest, which declares one.
Most node seats deliver nothing. They say which module is this machine's packet filter, or which of
two alternative resolver configurations it runs, and a second holder is refused. That is the whole of
Most node seats deliver nothing. They say which module is this machine's packet filter, or which
module writes its resolver file, and a second holder is refused. That is the whole of
their job, and it is a real one: it is the mesh saying what a machine is, in words a person can read.
## A seat that needs another beside it
*2026-10-05* ([ADR 0220](../../02-DECISIONS/0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)). **A seat may name the node seats its holder needs held on the
same node**, because some roles are right only while another is filled beside them. The holder of the
resolver configuration writes `/etc/resolv.conf`, and that file stays the mesh's only while the
machine's network manager is told to leave it alone — which is what the uplink's holder does
([ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)). So the resolver configuration
needs the uplink.
**A module claiming such a seat depends on each seat it needs**, exactly as a module declaring a
service depends on the service manager
([ADR 0207](../../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)):
derived from the claim and never written in a manifest, met by any module assigned to the node, judged
over the node's whole set, refused at assignment naming the seat and the modules that could hold it,
and refused at composition. Taking the needed seat's last holder from beneath a dependent is refused
too. What a seat needs is the mesh's definition of the role, compiled with the set and never stored,
and adding a need is a decision.
## The overview
The controller lists every seat in the set with its scope, what it delivers, and its holder as a node
@@ -259,5 +279,7 @@ checked as their tables say:
| `secret` has one provider, the holder of `mesh-vault` | 0161: a second claimant of the seat is refused by name (`CanHold`); *correction of fact, 2026-10-01: no parser rule ever reserved the word, the seat does the work*. |
| A singular fact about machines is a placement of capacity one, refused by name | 0161: the overlay command's test for a second hub; the store's unique index. |
| A holder of `node-uplink` is the dialect the machine runs | 0161: the host reports `uplink-<manager>` in its profile with every report; a resolution test refuses the other holder naming the capability. |
| A seat's holder has the seats it needs beside it: the resolver configuration is refused at `assign` without the uplink held on its node, and the uplink's last holder cannot be taken from beneath it | 0220: seat-dependency tests on the definition, on a synthetic claimant, at assign, at unassign and at composition, and one reading the catalogue for the uplink's possible holders. |
| A retired seat leaves the set once nothing claims it, from the compiled set and the store's table both | 0220: the closed-set test's count, and the store migration that deletes the row. |
| Holdings are derived, and the overview lists every seat | 0118: the `seats` command test, including an unheld seat. |
| A build source on the seat records no address; an unheld seat refuses only self-hosted builds | 0111's tests. |