Files
hq/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
T
jschoubben 8a6ee9177c ADR 0121: a system seat is named for its scope; a module may define its own
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.
2026-09-27 14:15:05 +02:00

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-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). 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 — the closed set this refines
  • ADR 0117 — the-uplink, renamed here to node-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)