diff --git a/02-DECISIONS/0122-a-seat-is-data-a-rename-is-a-database-update.md b/02-DECISIONS/0122-a-seat-is-data-a-rename-is-a-database-update.md new file mode 100644 index 0000000..4d3eeb7 --- /dev/null +++ b/02-DECISIONS/0122-a-seat-is-data-a-rename-is-a-database-update.md @@ -0,0 +1,107 @@ +--- +topic: what runs on it +status: accepted +date: 2026-09-27 +deciders: jochen +reconstructed: false +supersedes-in-part: + - 0110-a-seat-is-a-module-assignment-from-a-closed-set.md + - 0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md +--- + +# 122. A seat is data the controller owns, and a rename is a database update + +## Context + +[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made the seats a closed set the +control plane defines, and [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) +named them by scope. Both were right about *what* a seat is. Both left it defined the wrong *way*: +**the set is a hardcoded Go slice compiled into the controller, and everything references a seat by +its name as a string literal.** Renaming `the-packet-filter` to `node-packet-filter` this session +took, in one pass: + +- an edit to the Go slice in `internal/catalogue/seats.go`, recompiled into a new controller image; +- an edit to a `const gitSeat = "git"` in *production* control-plane code (`source.go`), because a + seat's name was hardcoded where a repository's home is resolved; +- edits to every claiming manifest in the catalogue, each re-registered; +- a controller **rebuild and redeploy**, which — because the running controller then refused the + still-old-named claims in stored manifests — **froze composition** for the affected nodes until + each manifest was re-registered under its new name; +- the same coupling in the **build machine**, which embeds the same seat set and refused to build + anything claiming a name it did not yet know; +- a **deadlock** when the build machine's own seat was renamed, since the old builder could not + build the new builder whose manifest claimed a name it rejected. + +None of that is what a rename should cost. A rename is the operator changing a label. It should be a +single write, and nothing should have to be rebuilt, refused, or unfrozen. The set being *closed* +(0110) and *named by scope* (0121) are good rules; **the set being code is the mistake.** When +adhering to the design means twenty steps and a `const` in the resolver, the design is what to fix. + +## Decision + +**The seat set is data the control plane owns, not code it is compiled from.** The seats live in a +table in the controller's store — one row per seat: a **stable id**, a `name`, a `scope`, what it +`delivers` (a provision, or nothing), and the record that decided it. The rows are seeded by a +migration (the closed set 0110 defines still ships with the mesh), and thereafter they are ordinary +data the control plane reads and writes. + +**A seat is referenced by its stable id, never by its name.** A claim, a held-seat record, and any +control-plane code that must name a seat (the git-seat resolver, the artifact-store guard) hold the +**id**. The `name` is a label for people and for what a manifest writes; it is resolved to an id +once, when a claim is registered. So: + +- **A rename is one `UPDATE seats set name = … where id = …`.** Nothing is recompiled, nothing is + re-registered, nothing is refused, nothing freezes. Held records and claims already point at the + id, so they follow the rename for free. The build machine is not involved, because the build + machine validates a claim against the set it reads from the mesh, not one baked into its image. +- **Adding or removing a seat is an `INSERT`/`DELETE`** (within the closed-set discipline: a change + to the set is still a decision with a record — the record is now a row's `decided` column and an + ADR, not a line of Go). No controller release is needed to change the roster of roles. +- **Production code stops hardcoding names.** `const gitSeat = "git"` becomes a lookup of the seat + that delivers the `git` provision (or a well-known id), so renaming its label cannot break the + code that finds a repository's forge. + +**What does not change** (0110 and 0121 still hold): a seat is still a module assignment from a +closed set; there is still one holder per scope; a delivering seat is still the single answer for +its provision; system seats are still `mesh-*`/`node-*` and a module may still define its own. Only +their *storage and reference* change — from a compiled slice keyed by name to a table keyed by id. + +**A manifest still claims by name, and that is fine.** A manifest is written by a person and names +the seat in words; the mesh resolves the name to an id at registration and stores the id. If a +seat's name changes, manifests written against the old name are updated in the catalogue like any +other edit (and the mesh can keep the old name as an alias row during a transition so nothing breaks +in the window) — but the *control plane* never has to change or redeploy for it, which is the whole +point. The heavy, mesh-wide, freeze-prone half of a rename disappears; only the ordinary catalogue +edit remains. + +## Consequences + +- **A rename, and a set change, become operations, not releases.** The pain this session paid — + three freezes, a builder deadlock, hand-resolved manifests — is designed out. The seat migrations + still outstanding (the delivering registry seats, and the private network's scope change) should + wait for this: done as data, each is a write, not a coupled multi-repo deploy. +- **The controller gains a small table and a seed migration**, and its seat lookups change from + slice scans to id-keyed reads. `SeatNamed`, `SeatDelivering`, `claimProblems` read the table. +- **The build machine reads the set from the mesh** (it already talks to the control plane), rather + than embedding it — which removes the controller/builder seat coupling that made every breaking + seat change a two-sided deadlock (see [to-be 30](../03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md)). +- **The closed set is still closed.** Data being editable is not the set being open: changing it is + still a decision, still recorded. What changes is that recording it no longer means shipping a + binary. +- **This is a real refactor**, touching the store schema, the seat lookups, claim registration + (name→id resolution), and the held-seat records. It is worth its own build; until it lands, the + current compiled set stands and further renames are held rather than forced through the heavy path. +- **Config on a seat is still the module's** (the question that surfaced this): a seat row carries + the seat's own metadata (scope, delivers, protocol), not a module's configuration — that stays in + the holding module's manifest ([ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md)). Making + seats data does not make them a config store. + +## References + +- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md), + [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the seat + rules this keeps, whose *storage* it changes +- [to-be 30](../03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md) — the controller/builder + seat coupling and the breaking-change freeze this removes for seat changes +- mesh-controller `internal/catalogue/seats.go` (the compiled slice this replaces), + `cmd/mesh-controller/source.go` (`const gitSeat`, the hardcoded name this removes)