Seats are data the controller owns, loaded from its store (ADR 0122, phase 1)

The seat set was a Go slice compiled into the controller and referenced by
name everywhere, so changing it meant a rebuild and a freeze-prone deploy. It
is now a table: catalogue keeps the shipped set as defaultSeats (the seed and
the fallback) and a loadable working set; inventory adds the seat table
(migration 0034), Seats to read it, and SeedSeats to fill it idempotently
without overwriting an operator's edit; migrate seeds it; openInventory loads
it, and an empty or unreadable table leaves the compiled defaults in force so
it can never brick the control plane's boot.

Behaviour-neutral: the seeded table equals the defaults. Phase 2 (reference by
a stable id so a rename touches no manifest or code, and the builder reads the
set from the mesh) follows.
This commit is contained in:
2026-09-27 16:04:44 +02:00
parent 1c56210530
commit 2ec0fd218b
6 changed files with 215 additions and 2 deletions
@@ -0,0 +1,17 @@
-- The seats are data the control plane owns, not a slice compiled into it (novox/hq ADR 0122).
--
-- Until this, the closed set 0110 defines lived only as a Go slice, referenced by name everywhere,
-- so renaming a seat or adding one meant a controller rebuild and a mesh-wide, freeze-prone deploy.
-- The set is now a table: one row per seat, seeded from the binary's defaults the first time the
-- control plane comes up, and thereafter the live copy the control plane reads and an operator can
-- change. A rename becomes an update here rather than a release.
--
-- The name is the key for now, because claims and held records still reference a seat by name; the
-- move to a stable id that a rename does not touch is the next step (ADR 0122). `delivers` is empty
-- for a seat that answers for no provision, matching the compiled default.
create table seat (
name text primary key,
scope text not null,
delivers text not null default '',
decided text not null
);
+56
View File
@@ -0,0 +1,56 @@
package inventory
import (
"context"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The seats the mesh has, as data (novox/hq ADR 0122).
//
// The set the control plane reads is a table here, not a slice compiled into it. It is seeded from
// the binary's defaults the first time the mesh comes up (SeedSeats), and thereafter it is the live
// copy: a rename or an added seat is a write here, and the control plane loads it at startup rather
// than being rebuilt for it.
// Seats is every seat the mesh defines, read from the store.
func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
rows, err := i.store.Pool().Query(ctx,
`select name, scope, delivers, decided from seat order by name`)
if err != nil {
return nil, err
}
defer rows.Close()
var seats []catalogue.Seat
for rows.Next() {
var s catalogue.Seat
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision); err != nil {
return nil, err
}
seats = append(seats, s)
}
return seats, rows.Err()
}
// SeedSeats writes the mesh's default set into the table where it is not already present.
//
// **Idempotent, and never overwriting.** Run every time the control plane migrates, it fills an
// empty table on first boot and adds a seat a new release ships — but it leaves a row already there
// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting
// the old name back. What a release removes from the defaults is not deleted here either; retiring a
// seat is its own decision, not a silent consequence of it dropping out of the binary.
func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) {
var added int
for _, s := range defaults {
tag, err := i.store.Pool().Exec(ctx,
`insert into seat (name, scope, delivers, decided) values ($1, $2, $3, $4)
on conflict (name) do nothing`,
s.Name, s.Scope, s.Delivers, s.Decision)
if err != nil {
return added, err
}
added += int(tag.RowsAffected())
}
return added, nil
}
+78
View File
@@ -0,0 +1,78 @@
package inventory
import (
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The seat set is data the control plane owns (novox/hq ADR 0122): seeded from the binary's
// defaults, read back as the working set, and thereafter an operator's to change without a rebuild.
func TestSeatsAreSeededFromTheDefaultsAndReadBack(t *testing.T) {
inv := ForTest(t)
defaults := catalogue.DefaultSeats()
added, err := inv.SeedSeats(t.Context(), defaults)
if err != nil {
t.Fatal(err)
}
if added != len(defaults) {
t.Fatalf("seeded %d of %d seats", added, len(defaults))
}
got, err := inv.Seats(t.Context())
if err != nil {
t.Fatal(err)
}
if len(got) != len(defaults) {
t.Fatalf("read back %d seats, seeded %d", len(got), len(defaults))
}
// The set round-trips: name, scope and what it delivers survive the store.
by := map[string]catalogue.Seat{}
for _, s := range got {
by[s.Name] = s
}
for _, d := range defaults {
if by[d.Name].Scope != d.Scope || by[d.Name].Delivers != d.Delivers {
t.Errorf("%s came back as %+v, seeded %+v", d.Name, by[d.Name], d)
}
}
}
// Re-seeding an already-seeded set adds nothing and changes nothing — every deploy runs SeedSeats,
// and a mesh already holding the set must be left exactly as it is (an operator's edit to a row
// included). A row's fields are not overwritten: on conflict the insert does nothing.
//
// (Phase 1 keys the table by name, so a seat *renamed* in the table would have its old name
// re-seeded — the move to a stable id a rename does not touch is the next step, ADR 0122. This test
// asserts only the property that holds now: an unchanged set re-seeds to a no-op.)
func TestReSeedingAnUnchangedSetIsANoOp(t *testing.T) {
inv := ForTest(t)
defaults := catalogue.DefaultSeats()
if _, err := inv.SeedSeats(t.Context(), defaults); err != nil {
t.Fatal(err)
}
// An operator changes a row's scope in the table — the point of it being data.
if _, err := inv.store.Pool().Exec(t.Context(),
`update seat set scope = 'mesh' where name = 'node-uplink'`); err != nil {
t.Fatal(err)
}
added, err := inv.SeedSeats(t.Context(), defaults)
if err != nil {
t.Fatal(err)
}
if added != 0 {
t.Fatalf("re-seeding an already-present set added %d rows", added)
}
got, err := inv.Seats(t.Context())
if err != nil {
t.Fatal(err)
}
for _, s := range got {
if s.Name == "node-uplink" && s.Scope != "mesh" {
t.Fatalf("re-seeding overwrote the operator's change: node-uplink scope is %q", s.Scope)
}
}
}