Files
hq/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
jschoubben ce6ae943b7 Merge main: renumber this branch's records around the trunk's
Both lines of work numbered from the same point, so four decision records and one design
document existed twice with different content. The trunk keeps its numbers and this branch
yields — the only rule that scales, because the trunk's are already cited by what merged
before them.

  0117 the bus is the only broker        -> 0125
  0118 a module declares its own seats   -> 0126
  0119 amqp is a provision, not the bus  -> 0127
  0120 the mesh bus is required          -> 0128
  0123 a seat carries its role's protocol -> 0129
  0124 the predecessor is ending          -> 0130
  design 29, what a module declares       -> design 32

Applied to the code repositories too, because a stale reference is worse when numbers
collide than when they dangle: the reader lands on a real record that decided something
else.

Two reconciliations the merge forced, both real:

**0110 was marked wholly superseded and was not.** Its successor says in as many words that
everything 0110 decided about what a seat *is* stands untouched — and two records that
landed on the trunk rest on exactly that part. So it is accepted again, extended rather than
replaced, with a note saying which of its claims moved and where.

**A seat's protocol becomes columns, not fields.** The trunk moved the seat set out of
compiled code into a table the controller owns. This branch had added what a role accepts,
emits and serves to the Go slice. The decision is unaffected and the mechanism is better for
it: giving a role a protocol is now a write rather than a rebuild, which is the trunk's own
argument applied to what this branch added.

One check still fails and it fails on main too: a record resting on ADR 0112 while that is
still 'proposed'. Left alone — it is not this merge's to answer.
2026-09-27 18:23:41 +02:00

9.3 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 0125). 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 deferred. They each deliver a provision, so renaming them is a delivering-seat migration: a holder that stops resolving mid-flight takes a provision away from every consumer. That risk is not worth carrying in the same pass as the node-* renames, so they keep their names until done deliberately.

distribution stays the mesh's registry; only verdaccio is retired. An earlier draft of this record had the registry consolidating onto gitea and distribution retired — that was reversed: distribution is the standalone OCI registry serving every artifact-store://…@sha256 image (the control plane's own included), and the mesh keeps it. verdaccio was a second npm registry; gitea already provides npm-package-registry, so verdaccio is redundant and is removed. It is only in the catalogue (never registered in the running mesh), so removing it is deleting the module — no migration, nothing to strand.

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 are deferred to their own pass.
  • The node- migration was done as one controlled step, and it froze briefly.* Deploying the new controller made it reject the still-old-named claims in the stored manifests, so composition stopped for the affected nodes until each manifest was re-registered under its new name; running services were untouched, and the window was seconds. This is the coordinated-migration cost named above, paid once — and the reason the delivering registry seats, whose freeze would be a provision outage rather than a compose pause, are not folded into the same pass.
  • distribution is not retired. It stays as the registry; only verdaccio (a redundant second npm registry) is removed. The mesh keeps one OCI registry (distribution) and gitea for npm/git — the "one registry, on gitea" idea was considered and dropped.
  • 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 0125 — 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)