diff --git a/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md b/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md new file mode 100644 index 0000000..555db40 --- /dev/null +++ b/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md @@ -0,0 +1,128 @@ +--- +topic: what runs on it +status: accepted +date: 2026-09-27 +deciders: jochen +reconstructed: false +extends: 0110-a-seat-is-a-module-assignment-from-a-closed-set.md +--- + +# 121. A system seat is named for its scope, and a module may define its own + +## Context + +[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made seats a closed set the +control plane defines: a well-formed name no longer becomes a seat by being claimed, so a person can +read what a mesh can have and who fills each role. It left two things unsettled that the growing set +now exposes: + +- **The names carry no rule.** `mesh-controller`, `mesh-store`, `mesh-broker` are named for the mesh; + beside them sit `the-artifact-store`, `the-build-machine`, `the-dns-port`, `the-showcase`, + `the-uplink` — a second naming style with no principle behind it. A reader cannot tell a seat's + scope from its name, and the mesh's own roles do not look like the mesh's. +- **The set is the *only* place a seat may be defined.** A module claiming any name not in the + control plane's set is refused. That is right for *system* roles — one broker, one packet filter + per node — but it means a module can never define a role of its own: a demo module's + `the-showcase`, a future application's coordination role, must be smuggled into the control plane's + set or not exist. The control plane ends up holding roles that are not the mesh's to define. + +Reviewing the set against these also found seats whose *scope* or *membership* is wrong, not just +their name — the review is the occasion to fix those too. + +## Decision + +**A system seat — one the control plane defines — is named for its scope:** + +- **`mesh-*`** for a mesh-scoped seat: one holder in the whole mesh, a role the mesh has once + (`mesh-controller`, `mesh-store`, `mesh-broker`, `mesh-git`, …). A `mesh-*` seat is always held by + a module **on a named node** — `mesh-git` is gitea *on novox*, not "gitea"; another node running + gitea does not hold `mesh-git` unless it is the holder. The seat is the mesh's single answer for + the role, and which node answers is part of what the seat records. +- **`node-*`** for a node-scoped seat: one holder per node, a role each machine has at most once + (`node-packet-filter`, `node-intrusion-prevention`, `node-uplink`, …). + +The three already-`mesh-*` seats keep their names; the rest are renamed by this rule. The scope a +name declares must match the seat's actual scope — a `mesh-*` seat at node scope, or the reverse, is +a contradiction the reader is entitled to trust is impossible. + +**The control plane defines only system seats. A module may define its own.** A seat named `mesh-*` +or `node-*` is the control plane's, and claiming one the control plane does not define is refused as +before. Any *other* name is a **module-defined seat**: valid when the module declaring the claim also +declares the seat (its name, scope, and — if any — the protocol its holder speaks). The control plane +enforces one-holder-per-scope for it exactly as for its own, but does not otherwise know what it +means. So an application can coordinate its own instances through a seat of its own, and the mesh's +closed set stays what its name says it is: the *system's* roles, not everyone's. + +**Specific seats this settles:** + +- **`the-build-machine` → `mesh-build-machine`, and its scope becomes mesh.** There is one build + machine in the mesh (the builder on novox), not one per node. Node scope said the opposite. It + delivers no provision; it is the mesh's single build machine. +- **`the-private-network` → `mesh-private-network`, held by the network *server* on one node.** Today + it is node-scoped and held on every node, with a stated (untested) story that a different VPN could + hold it per machine — which would force every provider module to independently implement receiving + and applying the controller-composed configuration. The mesh does not work that way and should not + pretend to: **one mesh decides one private network.** The seat is mesh-scoped, held by the server + module (WireGuard on the hub, novox). A machine that joins is given a **client module** that + receives the composed configuration and applies it; when a node joins, the mesh emits each node's + configuration so all of them know each other at once. This drops per-node VPN choice deliberately — + the private network is nox-mesh's own, and it defines the nodes' configuration rather than being + assembled from each node's opinion. (Implementation: the overlay generator's per-node computation + is unchanged; what changes is the seat's scope and the server/client split of the module.) +- **`the-showcase` → removed from the set; it becomes a module-defined seat.** It is a demo module's + own coordination role, claimed by nothing else and held nowhere. It is the first module-defined + seat, and the reason the rule above is needed rather than hypothetical. +- **`the-dns-port` → `node-dns-resolver`** (the daemon that binds `:53`), kept distinct from + **`the-resolver-configuration` → `node-resolver-config`** (what writes `resolv.conf`). Two roles, + two seats; the rename must not blur them. +- **`the-packet-filter` → `node-packet-filter`** and **`the-intrusion-prevention` → + `node-intrusion-prevention`** — names kept as-is but for the prefix. "Packet filter" stays distinct + from "firewall", which would swallow intrusion-prevention too. +- **`the-uplink` → `node-uplink`** ([ADR 0117](0117-a-machines-uplink-is-a-seat.md)). Unheld, so it + renames with no migration. +- **The registry seats — `the-artifact-store`, `npm-package-registry` (→ `mesh-artifact-store`, + `mesh-npm-package-registry`) — and `git` (→ `mesh-git`) — are decided but gated.** They are + entangled with consolidating every registry onto gitea (below), so their final shape is settled + when that lands, not renamed in isolation first. + +**The registry consolidates onto gitea.** The mesh should have **one** registry: gitea serving the +container/OCI images, the npm packages, crates, and trivial-tarball artifacts. `distribution` (the +standalone OCI registry) and `verdaccio` (a second npm registry) are retired once gitea serves what +each did. This is recorded here because it reshapes the registry seats; it is **not** a rename and +**not** surgical — see Consequences. + +## Consequences + +- **A reader learns a seat's scope from its name.** `mesh-*` is mesh-wide and one; `node-*` is + per-machine. The mesh's own roles finally look like the mesh's. +- **Applications get their own seats** without the control plane learning their meaning. The closed + set shrinks to what it should be — the system's roles — and stops being where unrelated roles hide. +- **The renames are a coordinated migration, not a rename.** A held seat's name lives in three places + that must move together: the control plane's set (`seats.go`), every claiming manifest, and what + each node reports it holds (re-derived by re-registering the manifest and re-pushing). A seat + renamed in one place and not the others stops resolving to its holder — and for a *delivering* seat + (`mesh-store`→postgres, `mesh-broker`→amqp, the registry seats) that is a mesh-wide provision + outage, the same failure mode as a schema change hitting an old manifest. So: the non-delivering + `node-*` seats and `mesh-build-machine` migrate as one tested controller+catalogue change; + `node-uplink` is free (unheld); the delivering registry seats wait for the gitea consolidation. +- **Retiring `distribution` is blocked until gitea serves images, and is the mesh's highest-risk + operation.** Every image — the control plane's own, the builder's, every module's — is + `artifact-store://…@sha256` served by `distribution`. gitea today provides only `npm-package-registry` + and `git`; it has no OCI registry. Removing `distribution` before gitea serves images strands every + image: nothing pulls, nothing reconciles, and the control plane cannot recover itself. The order is + fixed: stand up gitea's container registry → gitea `provides artifact-store` and holds the seat → + repoint the builder to push there → migrate or re-push existing images → **only then** retire + `distribution` and `verdaccio`. Recorded here so the sequence is not skipped. +- **The private network stops pretending to be swappable per node.** The gain is a coherent + server/client model matching how the controller already composes configuration; the cost is that + choosing a different VPN is now a mesh-wide change, not a per-node one — accepted. + +## References + +- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — the closed set this refines +- [ADR 0117](0117-a-machines-uplink-is-a-seat.md) — `the-uplink`, renamed here to `node-uplink` +- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — the original `mesh-*` seats + whose naming this generalises +- [to-be 26](../03-DESIGN/01-to-be/26-the-seats.md) — the seat table, updated by this +- mesh-controller `internal/catalogue/seats.go` (the set and claim validation), + `internal/overlay/generator.go` (the private network as server + client)