The control plane's seats grew a second naming style (the-*) beside mesh-*, and the closed set was the only place any seat could be defined. This settles both: system seats are mesh-* (one, mesh-wide) or node-* (one per node), named for scope; a module may define its own seat outside the closed set. Folds in the seat review: mesh-build-machine (scope fix), mesh-private-network (one server + client modules, dropping per-node VPN choice), showcase becomes the first module-defined seat, node-uplink, and the node-* renames — plus the registry consolidation onto gitea, which reshapes the registry seats and gates retiring distribution/verdaccio. Records why the renames are a coordinated migration and why distribution cannot be removed until gitea serves images.
9.0 KiB
topic, status, date, deciders, reconstructed, extends
| topic | status | date | deciders | reconstructed | extends |
|---|---|---|---|---|---|
| what runs on it | accepted | 2026-09-27 | jochen | false | 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 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-brokerare named for the mesh; beside them sitthe-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, …). Amesh-*seat is always held by a module on a named node —mesh-gitis gitea on novox, not "gitea"; another node running gitea does not holdmesh-gitunless 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 fromthe-resolver-configuration→node-resolver-config(what writesresolv.conf). Two roles, two seats; the rename must not blur them.the-packet-filter→node-packet-filterandthe-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). Unheld, so it renames with no migration.- The registry seats —
the-artifact-store,npm-package-registry(→mesh-artifact-store,mesh-npm-package-registry) — andgit(→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-deliveringnode-*seats andmesh-build-machinemigrate as one tested controller+catalogue change;node-uplinkis free (unheld); the delivering registry seats wait for the gitea consolidation. - Retiring
distributionis 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 — isartifact-store://…@sha256served bydistribution. gitea today provides onlynpm-package-registryandgit; it has no OCI registry. Removingdistributionbefore 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 → giteaprovides artifact-storeand holds the seat → repoint the builder to push there → migrate or re-push existing images → only then retiredistributionandverdaccio. 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 — the closed set this refines
- ADR 0117 —
the-uplink, renamed here tonode-uplink - ADR 0079 — the original
mesh-*seats whose naming this generalises - to-be 26 — 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)