Merge pull request 'ADR 0287: a seat names the paths its holder owns, and a claim without them is refused (issue 412, research 023)' (#301) from feat/grad-023-seat-paths into main

This commit was merged in pull request #301.
This commit is contained in:
2026-10-10 19:59:46 +00:00
6 changed files with 245 additions and 2 deletions
+2
View File
@@ -55,6 +55,8 @@ is listed under [Homonyms](#homonyms), with the qualified form each domain uses.
seat, is the overview of what a mesh has ([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)).
A seat carries **verbs** — the tools every holder must serve. A verb added to a held seat is first
promised as optional, then served, then required ([ADR 0246](../02-DECISIONS/0246-a-seats-new-verb-is-promised-before-it-is-required.md)).
A node seat may also name **the paths its holder owns**, such as `/etc/hosts`: a claim whose module does
not declare them is refused, and only a claimant declares them ([ADR 0287](../02-DECISIONS/0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md)).
- **operator** — the person who runs the mesh, and the one the mesh talks to.
*Not:* ~~master~~ (hq)
- **person** — any human, as against the mesh acting unattended: *a person's word* releases a held
@@ -1,7 +1,8 @@
---
status: active
status: graduated
initiated: 2026-10-03
touches: [the seats, the seat protocol, the controller's ownership check, 03-DESIGN/01-to-be/26-the-seats.md]
became: [03-DESIGN/01-to-be/26-the-seats.md, 02-DECISIONS/0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md]
---
# 023 — A seat protocol that defines what its holder owns
@@ -89,6 +89,12 @@ controller holds none of it:** an operator's line is the machine's, not a record
**4. No other module writes `/etc/hosts`.** The private network's region goes, as ADR 0194 already has
it; a module that once wrote a line there asks the mesh's resolver instead.
> **The mechanism changed — 2026-10-10, by [ADR 0287](0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md).**
> Decision 3 and 4 stand: `/etc/hosts` is the hosts seat's holder's, and no other module writes it. What moved
> is where that is stated and checked. The seat's definition names `/etc/hosts` and `/etc/hostname` (built under issue 412), a
> claim without them is refused, and a module that does not claim the seat and declares either is refused at
> registration, for every seat that names a path, rather than by this record for one file.
## Consequences
- **The lab's names follow its scenarios.** A scenario raised is resolvable from every node at once; a
@@ -94,6 +94,11 @@ leaves the unit as it is. What the record going had found is not the machine's t
another resource holds the unit, and restoring it could stop the runtime only for the remaining
resource to start it again in the same apply.
> **The mechanism changed — 2026-10-10, by [ADR 0287](0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md).**
> Decision 3 stands. For a path, which file a seat's holder owns is now named by the seat itself, and a
> resource the controller generates on such a path for a module that does not claim the seat is refused at
> composition by ADR 0287's rule 4, beside the collision check of decision 4.
## Consequences
- **Rollout has a fixed order, in three steps.**
@@ -0,0 +1,182 @@
---
topic: what runs on it
status: accepted
date: 2026-10-10
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
---
# 287. A seat names the paths its holder owns, and a claim without them is refused
Graduates [research 023](../01-RESEARCH/023-a-seat-protocol-that-defines-what-its-holder-owns/00-overview.md).
The operator left the choices to the agent that graduated it ("I trust your judgement"); they are argued here.
## Context
**A seat is a protocol, and a module holds it by implementing the protocol**
([ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md),
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)). Today the protocol is what the holder
accepts, emits and serves. A seat's definition in the controller carries a name, a scope, what it delivers,
those three, what it receives, whether it is replicated, and the record that made it a seat. The store's seat table carries a name, a scope, what it delivers and the record. **Neither
names a path.**
**Who owns a machine-wide file has been decided one record at a time:**
- [ADR 0199](0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)
§3 and §4: `/etc/hosts` is held by the holder of the hosts seat (now `node-hostname`), and no other module
writes it.
- [ADR 0222](0222-a-module-is-told-where-a-mesh-seats-holder-is-reached-and-the-controller-writes-no-file-a-seats-holder-owns.md)
§3: the controller writes no file a seat's holder owns. §4 holds what the controller generates to the
same collision check as what a module declares.
- [ADR 0223](0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md): `/etc/resolv.conf` is
written by the holder of `node-uplink`, rendered by the mesh as a file the holder asks for.
- [ADR 0241](0241-a-machine-says-how-its-network-is-and-an-outside-writer-of-a-mesh-file-is-a-finding.md)
§4 and §5: the machine's network statement names "the module whose file it is", and a rewrite is raised
under that module.
- [ADR 0247](0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md):
where `node-resolver` is held, its holder writes `/etc/resolv.conf` and the uplink's holder steps back.
**The only enforcement is after the fact, and per machine.** The controller's `checkResources`
(mesh-controller `internal/catalogue/resolve.go`, main at `c06a2cd`) refuses two modules on one machine
declaring one path, unit, name or package, at composition. It applies the step-back of ADR 0247 by hand
before comparing. Nothing refuses a module that holds no seat declaring `/etc/hosts` on a machine where the
hosts seat's holder is not assigned: there is no second owner there to collide with. And the question asked
on 2026-10-03, *which module owns `/etc/resolv.conf`?*, had no place to be looked up but every manifest.
**What the catalogue declares, measured on 2026-10-10** (mesh-catalog main at `61dcdf0`, 125 manifests):
- 29 modules claim one of the mesh's own node seats, and two more claim a node seat they declare themselves.
- Three paths that the holders declare are the role's on any machine, whatever program fills the role:
`/etc/hosts` and `/etc/hostname` (declared by `hostname`, holder of `node-hostname`), and
`/etc/resolv.conf` (rendered for `networkmanager` and `systemd-networkd`, holders of `node-uplink`, and for
`systemd-resolved`, holder of `node-resolver`).
- No other module declares any of the three. Two read them (the agent's guard and the container runtime's
tools), which is access, not ownership ([ADR 0051](0051-shared-data-is-the-operators.md)).
- Every other path a node seat's holder declares belongs to its program, not to the role:
`/etc/fail2ban/jail.local`, `/etc/docker/daemon.json`, `/etc/pacman.conf`, `/etc/lemurs/config.toml`,
`/etc/NetworkManager/conf.d/50-mesh.conf`, or a path under the operator's home. Two are neither: the
holder of `node-secret-service` declares `/etc/pam.d/login` and `/etc/pam.d/passwd`, which are shared
files of other programs. They are not the role's on any machine either, so no seat names them.
## Considered Options
1. **Keep deciding case by case.** Rejected. It takes one record per file, the answer lives in prose, and it
is enforced only on a machine where two owners happen to be assigned together. That is the state the
research was opened about.
2. **The holder names the paths it owns, in its claim.** Rejected. That is the direction the research
turned around: the answer would depend on which module is registered, and a second implementation could
name a different set. The seat is the definition, so the seat says it, before any module is written.
3. **A seat names every path its holder writes, its program's own included** (the research's example of the
intrusion prevention owning its jail file). Rejected. A seat is a protocol any program may implement
(ADR 0132). Naming `/etc/fail2ban/jail.local` would refuse every intrusion prevention that is not fail2ban,
which turns the seat into one module. A program's own file is already one module's, by its path and by
`checkResources`.
4. **No two seats name one path, with no exception.** Rejected. It would refuse the arrangement ADR 0247
made and the laptop runs. If `node-uplink` lost `/etc/resolv.conf`, a machine without a resolver of its
own would have no owner of the file (against ADR 0223). If `node-resolver` lost it, ADR 0247 would be
undone.
5. **A seat's paths as a column of the store's seat table.** Rejected. Whether a seat is replicated and what
it receives are compiled with the set and never stored, as the seats a seat needed were while that field
existed ([ADR 0220](0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)),
because each is the mesh's definition of the role and changes what every holder must satisfy. A path is
the same kind of fact. A row edited without a release would make claims that were valid refused, with
nothing in review.
6. **Check it only at the merge gate.** Rejected as the only place. The rule is a judgement over the whole
catalogue, which is what registration judges (`CatalogueProblems`). The merge gate already runs
`CatalogueProblems` on the catalogue with the change (mesh-controller `cmd/mesh-controller/merge_gate.go`),
so a refusal at registration is also a red `mesh/merge-gate` before merge, and a manifest registered by
another way (the lab, the catalogue command) is held to it as well.
## Decision
**1. A seat's definition may name paths its holder owns.** Each is an absolute path on the machine where
the holder runs, written literally, with no placeholder. Only a node seat names paths: a path is a
machine's, and a mesh seat's holder runs on one machine, so its path would have no owner on every other.
A seat names a path only when the path is the role's on any machine, whatever program fills the role: a
path the operating system's conventions give, not one program's. A seat a module declares for itself
([ADR 0126](0126-a-module-declares-its-own-seats.md)) names paths in its declaration, under the same rules.
**2. The set this record starts with:**
| seat | names |
|---|---|
| `node-hostname` | `/etc/hosts`, `/etc/hostname` |
| `node-uplink` | `/etc/resolv.conf`, stepping back for `node-resolver` |
| `node-resolver` | `/etc/resolv.conf` |
Every other seat names none. Adding a path to a seat, or taking one away, is a decision, recorded, as adding
a seat or a seat's need is.
**3. A module holds the seat only if it declares every path the seat names.** It declares it as a resource
or as a file the mesh renders for it, which is how the uplink and the resolver hold `/etc/resolv.conf`. A
claim without them is refused by `CanHold`, the one judgement of whether a module can hold a seat (ADR 0131),
at registration and at handover, naming the seat and the missing paths. A seat a module declares for itself
is held to the same rule by the check that already judges what such a seat's holder serves
(`CatalogueProblems`). All four modules that hold one of
these seats today declare what the table names, so the rule refuses nothing registered on 2026-10-10.
**4. Only a claimant declares a seat's path.** A module that claims no seat naming a path, and declares that
path, is refused at registration, naming the seats that name it. A module claiming any one of them may declare
it, which is how both the uplink and the resolver declare `/etc/resolv.conf`. The same refusal applies at composition to what the
controller generates (ADR 0222 §4), so the controller cannot write a seat's path either. Reading a seat's
path is access, and stays allowed.
**5. No two seats name one path, unless one steps back for the other by definition.** A seat may name a path
*stepping back for* another node seat: where that other seat is held on the machine, its holder owns the path,
and this seat's holder still declares it, but its declaration is not composed on that machine. Every path has
exactly one seat naming it without stepping back, and the stepping back does not cycle. This makes the
existing rule of ADR 0247 part of the definition, in the place `checkResources` applies it today. Only the
mesh's own seats step back, each by a record. A module declaring a seat that names a path another seat
already names, stepping back or not, is refused at registration, and the second declaration loses, as for a
seat's name.
**6. The seat listing shows each seat's paths.** *Which module owns `/etc/resolv.conf` on a machine?* is
answered by the controller's `seats` verb: the seat that names the path, and that seat's holder on the
machine, with the step-back applied.
Building rules 1 to 6 in the controller is tracked by issue 412. The design it builds is
[26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md), *A seat that names the paths its holder owns*.
## Consequences
- **The question of 2026-10-03 has a place to be answered**, before any module is written, from the seat's
definition rather than from every manifest.
- **Implementing the seat becomes checkable for files**, as it already is for verbs (ADR 0132): a module that
does not own what the role owns cannot claim the role.
- **ADR 0199 §4 becomes the general rule rather than a case**, and ADR 0222 §3 becomes checked for paths
rather than stated. Both records get a note pointing here.
- **The step-back moves from code into the definition.** `checkResources` keeps comparing what the machine
composes, and reads which seat steps back from the seat instead of from a special case.
- **What got harder.** A new holder of `node-hostname` must own both files, so a program that keeps a
machine's names elsewhere cannot hold the seat until the seat changes by a record. A seat that gains a path
makes every holder that does not declare it refused; the change to the seat and to its holders' manifests
must land together, and the merge gate shows which holders fail.
- **The finding of ADR 0241 is unchanged.** It still names the module whose file was rewritten. For a seat's
path that is now the seat's holder by definition, not by coincidence.
## How it is checked
All in mesh-controller, built under issue 412:
| Rule | Checked by |
|---|---|
| Only a node seat names paths, absolute and without a placeholder | a unit test over the compiled set; a registration test refusing a module's mesh seat that names a path and a path with `${` |
| Every path has one seat naming it without stepping back, and stepping back does not cycle | a unit test over the compiled set; a registration test refusing a module's seat that names `/etc/hosts` |
| A claim without the seat's paths is refused, naming them | `CanHold` tests at registration and at handover, with a holder declaring the path as a resource, as a rendered file, and not at all |
| Only a claimant of a seat naming the path declares it | a registration test refusing a module that declares `/etc/hosts` without claiming `node-hostname`, and one accepting the uplink and the resolver both declaring `/etc/resolv.conf`; a composition test refusing a generated resource on a seat's path |
| The step-back comes from the seat | the existing composition tests for the uplink and the resolver on one machine pass with the special case removed |
| The listing shows the paths | the `seats` command test |
| The catalogue as it is passes | `CatalogueProblems` over the catalogue's main gives no new problem; the merge gate runs it on every catalogue pull request |
## References
- [Research 023](../01-RESEARCH/023-a-seat-protocol-that-defines-what-its-holder-owns/00-overview.md), the
effort this graduates.
- [26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md), the design amended.
- mesh-controller main at `c06a2cd`: `internal/catalogue/seats.go` (`Seat`, `CanHold`),
`internal/catalogue/resolve.go` (`checkResources`), `internal/catalogue/node_resolver.go` (`steppedBack`),
`internal/catalogue/seats_declared.go` (`CatalogueProblems`), `cmd/mesh-controller/merge_gate.go`.
- mesh-catalog main at `61dcdf0`: the manifests of `hostname`, `networkmanager`, `systemd-networkd` and
`systemd-resolved`.
- Issue 412, the build.
+48 -1
View File
@@ -15,8 +15,9 @@ code:
- mesh-catalog modules/gitea/module.json
- mesh-controller internal/inventory/migrations/0063-a-machines-names-are-one-seats.sql
- mesh-controller internal/inventory/migrations/0064-the-resolver-file-is-the-uplinks.sql
updated: 2026-10-05
updated: 2026-10-10
decisions:
- 02-DECISIONS/0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md
- 02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md
- 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
@@ -242,6 +243,49 @@ and refused at composition. Taking the needed seat's last holder from beneath a
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.
## A seat that names the paths its holder owns
*2026-10-10* ([ADR 0287](../../02-DECISIONS/0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md)).
**A node seat may name paths its holder owns**, so that holding the role means owning its files, and *which
module owns this file on that machine?* is read from the seat and its holder rather than from every manifest.
A seat names a path only when the path is the role's on any machine, whatever program fills the role: a
path the operating system's conventions give, absolute and written literally. A path one program keeps,
such as an intrusion prevention's jail file or a container runtime's daemon file, is not the role's, and a
seat naming it would refuse every other program that could fill the role. Only a node seat names paths; a
mesh seat's holder runs on one machine, and its path would have no owner on the others. What a seat names
is compiled with the set and never stored, like what it needs, and changing it is a decision.
| seat | names |
|---|---|
| `node-hostname` | `/etc/hosts`, `/etc/hostname` |
| `node-uplink` | `/etc/resolv.conf`, stepping back for `node-resolver` |
| `node-resolver` | `/etc/resolv.conf` |
**Holding the seat means declaring its paths.** A module that claims the seat declares every path it names,
as a resource or as a file the mesh renders for it. `CanHold` refuses a claim without them, at registration
and at handover, naming the missing paths, beside the verbs it already requires. A seat a module declares
for itself is held to the same rule by the registration check that judges what its holder serves.
**Only a claimant declares a seat's path.** A module that claims no seat naming the path, and declares it, is
refused at registration, naming the seats that name it, and
what the controller generates is refused at composition by the same rule, which is how the controller is
held to writing no file a seat's holder owns. Reading the path is access and stays open to every module.
**One seat per path, with a declared step-back.** Every path has exactly one seat naming it outright. A
second seat may name it only *stepping back for* that seat: where the other seat is held on the machine, its
holder owns the path, and the stepping-back seat's holder still declares it, but its declaration is not
composed on that machine. The uplink steps back
for a machine's own resolver this way ([ADR 0247](../../02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md)),
and the collision check reads that from the seat rather than from a case of its own. A module declaring a
seat that names a path another seat already names is refused at registration: only the mesh's own seats
step back, each by a record.
**The listing shows each seat's paths**, so the controller's `seats` verb answers who owns a path on a
machine: the seat that names it, and that seat's holder there.
Building this is tracked by issue 412.
## The overview
The controller lists every seat in the set with its scope, what it delivers, and its holder as a node
@@ -298,5 +342,8 @@ checked as their tables say:
| 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 replicated seat has every holder on record and composes on each; a seat held once still refuses a second holder, on record or not; `--add` is refused for it | 0223: resolution and composition tests with two holders on record and a third machine, with two claimants and nothing on record, and on `mesh-store`; a unit test that only `mesh-dns-resolver` is replicated, surviving the store's rows; store tests keeping several holders once each, a handover leaving one, and unassigning one taking only its row. |
| 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. |
| A node seat names only literal absolute paths; every path has one seat naming it outright, and stepping back does not cycle | 0287: a unit test over the compiled set; registration tests refusing a module's seat naming a path at mesh scope, with `${`, or already named by another seat. |
| A holder declares every path its seat names, and only a claimant declares one | 0287: `CanHold` tests at registration and handover (declared as a resource, as a rendered file, not at all); a registration test refusing `/etc/hosts` from a module not claiming `node-hostname`, and one accepting the uplink and the resolver both declaring `/etc/resolv.conf`; a composition test refusing a generated resource on a seat's path. |
| The uplink's step-back comes from the seat | 0287: the composition tests for the uplink and the resolver on one machine pass with the case in the collision check removed. |
| 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. |