From 6b49ed613c86be2992f9e46c1b46bd3daa76f6d9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 21:49:57 +0200 Subject: [PATCH] ADR 0287: a seat names the paths its holder owns, so a file's owner is read from the seat (issue 412, research 023) --- 00-META/glossary.md | 2 + .../00-overview.md | 3 +- ...e-and-a-nodes-hosts-file-is-one-modules.md | 6 + ...ller-writes-no-file-a-seats-holder-owns.md | 5 + ...wns-and-a-claim-without-them-is-refused.md | 176 ++++++++++++++++++ 03-DESIGN/01-to-be/26-the-seats.md | 45 ++++- 6 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 02-DECISIONS/0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md diff --git a/00-META/glossary.md b/00-META/glossary.md index ec656d0b..b3eac862 100644 --- a/00-META/glossary.md +++ b/00-META/glossary.md @@ -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 diff --git a/01-RESEARCH/023-a-seat-protocol-that-defines-what-its-holder-owns/00-overview.md b/01-RESEARCH/023-a-seat-protocol-that-defines-what-its-holder-owns/00-overview.md index 7bc80c12..2b96d156 100644 --- a/01-RESEARCH/023-a-seat-protocol-that-defines-what-its-holder-owns/00-overview.md +++ b/01-RESEARCH/023-a-seat-protocol-that-defines-what-its-holder-owns/00-overview.md @@ -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 diff --git a/02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md b/02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md index 2ef3f69e..845f397a 100644 --- a/02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md +++ b/02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md @@ -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 now names `/etc/hosts` and `/etc/hostname`, 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 diff --git a/02-DECISIONS/0222-a-module-is-told-where-a-mesh-seats-holder-is-reached-and-the-controller-writes-no-file-a-seats-holder-owns.md b/02-DECISIONS/0222-a-module-is-told-where-a-mesh-seats-holder-is-reached-and-the-controller-writes-no-file-a-seats-holder-owns.md index af65c3dd..034cb170 100644 --- a/02-DECISIONS/0222-a-module-is-told-where-a-mesh-seats-holder-is-reached-and-the-controller-writes-no-file-a-seats-holder-owns.md +++ b/02-DECISIONS/0222-a-module-is-told-where-a-mesh-seats-holder-is-reached-and-the-controller-writes-no-file-a-seats-holder-owns.md @@ -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.** diff --git a/02-DECISIONS/0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md b/02-DECISIONS/0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md new file mode 100644 index 00000000..cb750fc5 --- /dev/null +++ b/02-DECISIONS/0287-a-seat-names-the-paths-its-holder-owns-and-a-claim-without-them-is-refused.md @@ -0,0 +1,176 @@ +--- +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, the seats it needs beside it, 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 a node seat. +- 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. + +## 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, what it + receives and which seats it needs are compiled with the set and never stored, because each is the mesh's + definition of the role and changes what every holder must satisfy + ([ADR 0220](0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)). 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. 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 does not claim the seat and declares a path the +seat names is refused at registration, naming the seat. 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 does not declare it there. 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. A module declaring a seat that names a path another seat +already names 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 declares a seat's path | a registration test refusing a module that declares `/etc/hosts` without claiming `node-hostname`; 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`, `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. diff --git a/03-DESIGN/01-to-be/26-the-seats.md b/03-DESIGN/01-to-be/26-the-seats.md index 626aba48..06543a12 100644 --- a/03-DESIGN/01-to-be/26-the-seats.md +++ b/03-DESIGN/01-to-be/26-the-seats.md @@ -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,45 @@ 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. + +**Only a claimant declares a seat's path.** Any other module declaring it is refused at registration, 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 does not declare it there. 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. + +**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 +338,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`; 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. |