Files
hq/02-DECISIONS/0122-a-seat-is-data-a-rename-is-a-database-update.md
jschoubben a8921fe737 ADR 0122: a seat is data the controller owns; a rename is a database update
Reviews 0110/0121 after a session where renaming seats cost three freezes, a
builder deadlock, and hand-resolved manifests. The seat rules were right; the
set being a compiled Go slice referenced by name-string everywhere was the
mistake. Seats become a table keyed by a stable id; claims/held/production code
reference the id; a rename is one UPDATE, no rebuild, no re-registration, no
freeze. The build machine reads the set from the mesh instead of embedding it,
removing the controller/builder seat coupling. Closed set and scope naming
unchanged; only storage and reference change. Outstanding renames (registry
seats, private-network scope) wait for this — as data each is a write.
2026-09-27 15:40:55 +02:00

108 lines
7.1 KiB
Markdown

---
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)