Seats keep their former names, so a rename breaks nothing (ADR 0122, phase 2)

Phase 1 made the set data; a rename still broke every reference to the old
name. This adds the stable identity: a seat's canonical name changes and its
old name becomes an alias that resolves to it forever. SeatNamed and the holder
and display matching resolve a name (former or current) to its seat, so a
manifest's claim, a held record, the git-seat lookup and the build machine's
embedded set all go on working unchanged after a rename. seat_alias table
(migration 0035), inventory Aliases/RenameSeat, openInventory loads them, and a
'seat rename <from> <to>' command does the whole thing — one operation, no
rebuild, no re-registration, no freeze. Behaviour-neutral until a seat is
renamed. Validated against postgres.
This commit is contained in:
2026-09-27 16:32:22 +02:00
parent 2ec0fd218b
commit 6da9a5478b
8 changed files with 175 additions and 6 deletions
+23 -2
View File
@@ -94,18 +94,36 @@ func UseSeats(s []Seat) {
}
}
// aliases maps a seat's former names to its current canonical name (novox/hq ADR 0122). Loaded from
// the store alongside the set, so a reference to a name a seat used to have — a manifest's claim, a
// held record — still resolves to it after a rename, and nothing downstream has to change.
var aliases = map[string]string{}
// UseAliases replaces the former-name map with the one the control plane read from its store. Empty
// is fine and ordinary: a mesh whose seats have never been renamed has no aliases.
func UseAliases(m map[string]string) { aliases = m }
// Seats is every seat the mesh defines, in reading order.
func Seats() []Seat {
return append([]Seat(nil), seats...)
}
// SeatNamed is the seat a claim names, if the mesh defines one.
// SeatNamed is the seat a name refers to, whether that is its current name or one it used to have
// (novox/hq ADR 0122). A former name resolves to the seat's canonical row, so a rename breaks no
// reference to the old name.
func SeatNamed(name string) (Seat, bool) {
for _, s := range seats {
if s.Name == name {
return s, true
}
}
if canonical, aliased := aliases[name]; aliased {
for _, s := range seats {
if s.Name == canonical {
return s, true
}
}
}
return Seat{}, false
}
@@ -211,7 +229,10 @@ func HolderAmong(provision string, providers []Provider, held []Held) (Provider,
return Provider{}, false
}
for _, h := range held {
if h.Claim != seat.Name || h.Scope != seat.Scope {
// Resolve the held claim to a seat rather than comparing names, so a record naming a seat's
// former name still matches it after a rename (novox/hq ADR 0122).
hs, ok := SeatNamed(h.Claim)
if !ok || hs.Name != seat.Name || h.Scope != seat.Scope {
continue
}
for _, p := range providers {
+20
View File
@@ -266,3 +266,23 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) {
t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats()))
}
}
// A former name resolves to the seat it was renamed from (novox/hq ADR 0122), so a manifest's claim
// and a held record naming the old name break nothing after a rename.
func TestAFormerNameResolvesAfterARename(t *testing.T) {
defer func() { UseSeats(DefaultSeats()); UseAliases(nil) }()
UseSeats([]Seat{{Name: "mesh-git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0121"}})
UseAliases(map[string]string{"git": "mesh-git"})
// The old name resolves to the renamed seat.
if s, ok := SeatNamed("git"); !ok || s.Name != "mesh-git" {
t.Fatalf("the former name did not resolve to the renamed seat: %+v ok=%v", s, ok)
}
// And a holder recorded under the old name is still found for the provision the seat delivers.
providers := []Provider{{Node: "anchor", At: "anchor.internal", Module: "gitea"}}
held := []Held{{Claim: "git", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}}
holder, found := HolderAmong("git", providers, held)
if !found || holder.Module != "gitea" {
t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found)
}
}
@@ -0,0 +1,12 @@
-- A seat keeps its former names, so a rename breaks nothing (novox/hq ADR 0122).
--
-- Phase 1 made the seat set data, but a rename still broke every reference to the old name — a
-- manifest's claim, a held record, the git-seat lookup — because they name the seat and the name
-- had changed. This is the stable identity ADR 0122 asked for, realised the simple way: a seat's
-- canonical name changes, and its old name becomes an alias that resolves to it forever. Nothing
-- downstream has to change — a manifest goes on claiming the old name, the build machine goes on
-- validating it — and a rename is one operation: set the new name, remember the old.
create table seat_alias (
alias text primary key, -- a former name of a seat
seat text not null -- the seat's current canonical name it resolves to
);
+52
View File
@@ -2,6 +2,7 @@ package inventory
import (
"context"
"fmt"
"github.com/novox/mesh-controller/internal/catalogue"
)
@@ -54,3 +55,54 @@ func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (i
}
return added, nil
}
// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122).
func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) {
rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`)
if err != nil {
return nil, err
}
defer rows.Close()
aliases := map[string]string{}
for rows.Next() {
var alias, seat string
if err := rows.Scan(&alias, &seat); err != nil {
return nil, err
}
aliases[alias] = seat
}
return aliases, rows.Err()
}
// RenameSeat gives a seat a new name and keeps the old one as an alias (novox/hq ADR 0122).
//
// **This is the whole of a rename.** The seat's canonical name becomes `to`; `from` is remembered as
// an alias so every reference to it — a manifest's claim, a held record, the build machine's
// embedded set — goes on resolving to the same seat, unchanged. Nothing is rebuilt and nothing
// freezes. Any alias that pointed to `from` is repointed to `to`, so a chain of renames does not
// leave an older name resolving to a name that no longer exists.
func (i *Inventory) RenameSeat(ctx context.Context, from, to string) error {
if from == to {
return fmt.Errorf("a seat is renamed to a different name; %q is already its name", to)
}
tag, err := i.store.Pool().Exec(ctx, `update seat set name = $1 where name = $2`, to, from)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("no seat named %q to rename", from)
}
// The old name resolves to the new one; and any name that resolved to the old one now resolves
// to the new one, so no alias is left pointing at a name that is gone.
if _, err := i.store.Pool().Exec(ctx,
`insert into seat_alias (alias, seat) values ($1, $2)
on conflict (alias) do update set seat = excluded.seat`, from, to); err != nil {
return err
}
if _, err := i.store.Pool().Exec(ctx,
`update seat_alias set seat = $1 where seat = $2`, to, from); err != nil {
return err
}
return nil
}
+37
View File
@@ -76,3 +76,40 @@ func TestReSeedingAnUnchangedSetIsANoOp(t *testing.T) {
}
}
}
// A rename is one operation: the seat gets the new name, the old name becomes an alias that still
// resolves to it (novox/hq ADR 0122).
func TestRenameSeatKeepsTheFormerNameAsAnAlias(t *testing.T) {
inv := ForTest(t)
if _, err := inv.SeedSeats(t.Context(), catalogue.DefaultSeats()); err != nil {
t.Fatal(err)
}
if err := inv.RenameSeat(t.Context(), "node-packet-filter", "node-firewall"); err != nil {
t.Fatal(err)
}
seats, err := inv.Seats(t.Context())
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, s := range seats {
names[s.Name] = true
}
if !names["node-firewall"] || names["node-packet-filter"] {
t.Fatalf("the seat was not renamed in place: %v", names)
}
aliases, err := inv.Aliases(t.Context())
if err != nil {
t.Fatal(err)
}
if aliases["node-packet-filter"] != "node-firewall" {
t.Fatalf("the former name is not an alias of the new one: %v", aliases)
}
// Renaming what has no seat is refused; renaming to the same name is refused.
if err := inv.RenameSeat(t.Context(), "no-such-seat", "x"); err == nil {
t.Fatal("renaming a seat that does not exist was accepted")
}
if err := inv.RenameSeat(t.Context(), "node-firewall", "node-firewall"); err == nil {
t.Fatal("renaming a seat to its own name was accepted")
}
}