From 585a6abbdda47a42aae6cc863f8a6a1330a72a06 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 23:22:20 +0200 Subject: [PATCH] A seat is handed over as one act, and the holder is on record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `seat --to /` makes one assignment the holder of a seat in the same write that removes the previous one. The row is new (migration 0039); without one, the resolver derives the holder as it always did — the sole eligible assignment, two refused — so nothing changes for a mesh that never hands a seat over. With one, the recorded assignment holds and any other whose module could hold the seat is eligible and silent: not refused, not holding. That is what lets the next holder run beside the current one until the switch (hq design 26, design 28 task 5.3, ADR 0131). Why: the controller finds its own bus through a seat, and the day that seat was left with nobody in it — because two eligible holders could not coexist and the old one's claim was taken away — the control plane looped for two hours while every service stayed up. A handover that is never empty in between is the fix, not a workaround for it. `CanHold` is the one judgement of whether a module may hold a seat — claims it at its scope, provides what it delivers, against the store's row — shared by registration and the handover so they cannot drift apart. The holding belongs to the assignment and goes when it does, so a seat never points at nothing running. Tests: the resolver with and without a record, on the same and another machine, under a former name; the store's row replaced not added, refused for an unassigned target, removed with its assignment; CanHold's four answers and that they follow the store. Full suite green against a real NATS and store. --- cmd/mesh-controller/plan.go | 13 +- cmd/mesh-controller/seats.go | 81 +++++++++++- internal/catalogue/broker_seat_test.go | 42 +++++-- internal/catalogue/holdings_test.go | 116 ++++++++++++++++++ internal/catalogue/resolve.go | 30 ++++- internal/catalogue/seats.go | 25 ++++ internal/catalogue/seats_declared.go | 11 +- internal/inventory/holdings_test.go | 82 +++++++++++++ ...at-is-held-by-one-assignment-on-record.sql | 24 ++++ internal/inventory/seats.go | 41 +++++++ 10 files changed, 438 insertions(+), 27 deletions(-) create mode 100644 internal/catalogue/holdings_test.go create mode 100644 internal/inventory/holdings_test.go create mode 100644 internal/inventory/migrations/0039-a-seat-is-held-by-one-assignment-on-record.sql diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 2c00f9b..0d90139 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -185,6 +185,15 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, // Every node, not only the placed ones. A machine that was never put on the private network // still runs modules, still holds claims, and still offers whatever it offers. + // **Who holds each seat on record, before anything is resolved** (novox/hq ADR 0131). Both + // passes below need it: without it, the assignment standing beside a seat's holder — the next + // holder, waiting for the handover — is refused as a second holder, and its node's whole set + // with it. + holdings, err := inv.Holdings(ctx) + if err != nil { + return catalogue.World{}, err + } + nodes, err := inv.Nodes(ctx) if err != nil { return catalogue.World{}, err @@ -227,7 +236,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, offered := map[string][]catalogue.Provider{} var firstHeld []catalogue.Held for _, o := range others { - got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true}) + got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings}) if err != nil { // Their set does not resolve for some other reason. Not this node's problem to // report, and nothing of theirs is running, so it offers nothing. @@ -258,7 +267,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, // with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the // holder is known. Without them its set is refused here, and a refused node's own claims drop // out of what the mesh holds — so a second holder of one of its seats would pass unrefused. - world := catalogue.World{Offered: offered, Held: firstHeld} + world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings} var held []catalogue.Held for _, o := range others { got, err := catalogue.Resolve(shelf, o.assigned, o.node, world) diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go index b041c10..b4af7cc 100644 --- a/cmd/mesh-controller/seats.go +++ b/cmd/mesh-controller/seats.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "os" + "slices" "sort" "strings" "text/tabwriter" @@ -85,7 +86,8 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata return rows, outside } -// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122). +// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122) — and, since +// ADR 0131, changes who holds a seat. func seatCommand(ctx context.Context, args []string) error { if len(args) == 3 && args[0] == "rename" { from, to := args[1], args[2] @@ -101,7 +103,82 @@ func seatCommand(ctx context.Context, args []string) error { "re-registered or frozen (novox/hq ADR 0122)\n", from, to) return nil } - return fmt.Errorf("seat rename ") + if len(args) == 3 && args[1] == "--to" { + return handOver(ctx, args[0], args[2]) + } + return fmt.Errorf("seat rename | seat --to /") +} + +// handOver makes one assignment the holder of a seat, as one act, so the seat is never without a +// holder in between (novox/hq ADR 0131, design 28 task 5.3). The control plane finds its own bus +// through one of these seats; the day it was left empty mid-change is why this exists. +// +// Everything that could make the new holder wrong is refused here, before the row is written: the +// seat must exist, the assignment must exist, and the module must be able to hold the seat — +// claim it at its scope and provide what it delivers, judged against the store's row. What is +// **not** checked is whether the module is running yet: that is what `push` confirms afterwards, +// and refusing to record a handover to a module the node has not started would make the handover +// impossible to do before the switch instead of as the switch. +func handOver(ctx context.Context, seatName, to string) error { + nodeName, module, ok := strings.Cut(to, "/") + if !ok || nodeName == "" || module == "" { + return fmt.Errorf("the new holder is named /, not %q", to) + } + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + inv := open.inventory + + seat, known := catalogue.SeatNamed(seatName) + if !known { + return fmt.Errorf("%q is not a seat this mesh defines — `seats` lists them", seatName) + } + assigned, err := inv.Assigned(ctx, nodeName) + if err != nil { + return err + } + if !slices.Contains(assigned, module) { + return fmt.Errorf("%s is not assigned to %s, so it cannot hold anything there — "+ + "`assign %s %s` first", module, nodeName, nodeName, module) + } + entries, err := inv.Catalogued(ctx) + if err != nil { + return err + } + var m *catalogue.Manifest + for i := range entries { + if entries[i].Manifest.Module == module { + m = &entries[i].Manifest + } + } + if m == nil { + return fmt.Errorf("%s is assigned but not in the catalogue, which should not happen", module) + } + if err := catalogue.CanHold(*m, seat); err != nil { + return fmt.Errorf("%s cannot hold %s: %w", module, seat.Name, err) + } + + var was string + if holdings, err := inv.Holdings(ctx); err == nil { + for _, h := range holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name { + was = h.Node + } + } + } + if err := inv.HoldSeat(ctx, seat.Name, seat.Scope, nodeName, module); err != nil { + return err + } + fmt.Printf("%s is held by %s on %s\n", seat.Name, module, nodeName) + if was != "" && was != nodeName { + fmt.Printf(" `push %s` and `push %s` send both machines what changed\n", was, nodeName) + } else { + fmt.Printf(" `push %s` sends the machine what changed; every other machine that reads the "+ + "seat is re-declared by `push --behind`\n", nodeName) + } + return nil } func seatsCommand(ctx context.Context, args []string) error { diff --git a/internal/catalogue/broker_seat_test.go b/internal/catalogue/broker_seat_test.go index 20c33c5..d10c5a1 100644 --- a/internal/catalogue/broker_seat_test.go +++ b/internal/catalogue/broker_seat_test.go @@ -46,21 +46,39 @@ func TestTheSeatRefusesADifferentBusToo(t *testing.T) { } } -// The old broker no longer claims the seat: it is an ordinary provider of `amqp` -// (novox/hq ADR 0119), so it can sit on the same mesh as the bus without contending for it. -func TestTheAmqpBrokerDoesNotContendForTheSeat(t *testing.T) { +// **The old broker claims the seat until the seat is handed over, and stands beside the new one +// while it waits** (novox/hq ADR 0131, superseding the record this test used to pin). Whoever is on +// record holds it; the other eligible claimant is neither refused nor holding. This is the shape the +// handover needs: both brokers assigned, one bus, no moment with nobody in the seat. +func TestTheOldBrokerStandsBesideTheNewOneUntilTheHandover(t *testing.T) { + was := Seats() + t.Cleanup(func() { UseSeats(was) }) + UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) + lavinmq := catalogueManifest(t, "lavinmq") - for _, c := range lavinmq.Claims { - if c.Name == "mesh-broker" { - t.Fatal("the amqp broker still claims mesh-broker; it is a provider, not foundation") + if !lavinmq.ClaimsSeat("mesh-broker") { + t.Skip("the old broker no longer claims the seat: design 28 task 5.4 has removed it") + } + nats := catalogueManifest(t, "nats") + onRecord := World{Holdings: []Held{{Claim: "mesh-broker", Scope: ScopeMesh, + Node: "anchor", Module: "nats"}}} + + // The same machine runs both. Without the record this is two holders and refused; with it, the + // recorded one holds and the other is silent. + anchor := workstation() + anchor.Name = "anchor" + got, err := Resolve(shelf(lavinmq, nats), []string{"lavinmq", "nats"}, anchor, onRecord) + if err != nil { + t.Fatalf("the old broker beside the recorded holder was refused: %v", err) + } + var holders []string + for _, h := range got.Claims { + if h.Claim == "mesh-broker" { + holders = append(holders, h.Module) } } - busHeld := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh, - Node: "anchor", Module: "nats"}}} - other := workstation() - other.Name = "laptop" - if _, err := Resolve(shelf(lavinmq), []string{"lavinmq"}, other, busHeld); err != nil { - t.Fatalf("the amqp broker was refused beside the mesh bus: %v", err) + if len(holders) != 1 || holders[0] != "nats" { + t.Fatalf("the seat is held by %v, not by the holder on record alone", holders) } } diff --git a/internal/catalogue/holdings_test.go b/internal/catalogue/holdings_test.go new file mode 100644 index 0000000..e89a4dc --- /dev/null +++ b/internal/catalogue/holdings_test.go @@ -0,0 +1,116 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// **A seat's holder on record settles who holds it, and lets the next holder stand beside the +// current one** (novox/hq ADR 0131, design 28 task 5.3). Until the record existed, two assignments +// whose modules both claimed a seat were refused outright — which left no way to hand a seat over +// without a moment where nobody held it, and the control plane finds its own bus through one of +// these seats. That moment was the outage of 2026-09-27. + +func busSeatDelivering(t *testing.T, delivers string) { + t.Helper() + was := Seats() + t.Cleanup(func() { UseSeats(was) }) + UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}}) +} + +func oldBroker() Manifest { + return Manifest{Module: "old-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} +} + +func newBroker() Manifest { + return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} +} + +// Nothing on record: exactly the old rule. One claimant holds; two are refused. +func TestWithNoHolderOnRecordTheSoleClaimantHoldsAndTwoAreRefused(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + node := Node{Name: "anchor"} + + held, problems := checkClaims([]Manifest{oldBroker()}, node, nil, nil) + if len(problems) != 0 || len(held) != 1 || held[0].Module != "old-broker" { + t.Fatalf("a sole claimant did not hold the seat: held=%v problems=%v", held, problems) + } + _, problems = checkClaims([]Manifest{oldBroker(), newBroker()}, node, nil, nil) + if len(problems) != 1 || !strings.Contains(problems[0], "both claim") { + t.Fatalf("two claimants with nothing on record were not refused: %v", problems) + } +} + +// With a holder on record, the other eligible assignment is silent: not refused, and not holding. +func TestTheHolderOnRecordHoldsAndTheOtherClaimantStandsBesideIt(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + node := Node{Name: "anchor"} + record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}} + + held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, node, nil, record) + if len(problems) != 0 { + t.Fatalf("the assignment beside the holder was refused: %v", problems) + } + if len(held) != 1 || held[0].Module != "new-broker" { + t.Fatalf("the holder on record is not the one holding: %v", held) + } +} + +// The record names a node too: an eligible module on another machine holds nothing, and its +// machine's set still resolves. +func TestAHolderOnRecordElsewhereLeavesThisMachinesClaimantSilent(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}} + + held, problems := checkClaims([]Manifest{oldBroker()}, Node{Name: "laptop"}, nil, record) + if len(problems) != 0 || len(held) != 0 { + t.Fatalf("a claimant elsewhere than the recorded holder was not simply silent: held=%v problems=%v", + held, problems) + } +} + +// A record naming a seat's former name still applies to it after a rename (ADR 0122). +func TestAHolderRecordedUnderAFormerNameStillHolds(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + wasAliases := aliases + t.Cleanup(func() { UseAliases(wasAliases) }) + UseAliases(map[string]string{"the-broker": "mesh-broker"}) + record := []Held{{Claim: "the-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}} + + held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, Node{Name: "anchor"}, nil, record) + if len(problems) != 0 || len(held) != 1 || held[0].Module != "new-broker" { + t.Fatalf("a record under the former name did not settle the seat: held=%v problems=%v", held, problems) + } +} + +// CanHold is the one judgement registration and the handover share, against the store's row. +func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + seat, _ := SeatNamed("mesh-broker") + + if err := CanHold(newBroker(), seat); err != nil { + t.Fatalf("a module that claims the seat and provides what it delivers was refused: %v", err) + } + noClaim := Manifest{Module: "quiet", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}} + if err := CanHold(noClaim, seat); err == nil || !strings.Contains(err.Error(), "does not claim") { + t.Fatalf("a module that never claimed the seat was allowed to hold it: %v", err) + } + wrongScope := newBroker() + wrongScope.Claims[0].Scope = ScopeNode + if err := CanHold(wrongScope, seat); err == nil || !strings.Contains(err.Error(), "scope") { + t.Fatalf("a claim at the wrong scope was allowed: %v", err) + } + cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} + if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) { + t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err) + } + // And the judgement follows the store's row, not a compiled copy. + busSeatDelivering(t, "amqp") + seat, _ = SeatNamed("mesh-broker") + if err := CanHold(cannotAnswer, seat); err != nil { + t.Fatalf("with the row saying amqp, an amqp provider was refused: %v", err) + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 213490e..6f9c89c 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -41,6 +41,11 @@ type Node struct { type World struct { // Held is the claims already taken, for the scopes wider than one node. Held []Held + // Holdings is every seat whose holder is **on record** (novox/hq ADR 0131): the one assignment + // that holds it, chosen by a handover. A seat absent here is held by derivation — the sole + // eligible assignment — as it always was. Present, it decides, and any other assignment whose + // module could hold the seat is eligible and silent rather than refused. + Holdings []Held // Offered is what other nodes provide at mesh scope, and everything needed to use it. Offered map[string][]Provider // Pinned is which node this machine was told to get a provision from, by name. Only consulted @@ -557,7 +562,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world } problems = append(problems, checkCapabilities(resolution.Modules, node)...) - claims, claimProblems := checkClaims(resolution.Modules, node, elsewhere) + claims, claimProblems := checkClaims(resolution.Modules, node, elsewhere, world.Holdings) problems = append(problems, claimProblems...) problems = append(problems, checkResources(resolution.Modules)...) resolution.Claims = claims @@ -650,14 +655,35 @@ func checkCapabilities(modules []Manifest, node Node) []string { // // Within this node's own set, and against what is already held elsewhere for the wider scopes. A // claim at mesh scope is the same idea as the mesh's one hub, said once instead of hard-coded. -func checkClaims(modules []Manifest, node Node, elsewhere []Held) ([]Held, []string) { +func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Held) ([]Held, []string) { var problems []string var held []Held + // onRecord is the recorded holder of a seat, if a handover ever named one. + onRecord := func(claim, scope string) (Held, bool) { + for _, h := range holdings { + hs, ok := SeatNamed(h.Claim) + cs, cok := SeatNamed(claim) + if ok && cok && hs.Name == cs.Name && h.Scope == scope { + return h, true + } + } + return Held{}, false + } + byScope := map[string]map[string]string{} // scope → claim → module for _, m := range modules { for _, c := range m.Claims { scope := c.At() + // **A recorded holder settles it before any counting.** An assignment that could hold + // the seat but is not the one on record is eligible, and that is all: it is not a second + // holder, so it is not refused, and it does not hold (novox/hq ADR 0131). This is what + // lets the next holder stand beside the current one until the seat is handed over. + if rec, recorded := onRecord(c.Name, scope); recorded { + if rec.Node != node.Name || rec.Module != m.Module { + continue + } + } if byScope[scope] == nil { byScope[scope] = map[string]string{} } diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 9379370..6cb9827 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -222,6 +222,31 @@ func claimProblems(m Manifest) []string { return problems } +// CanHold is why a module could not hold a seat, or nothing: its definition must claim the seat at +// the seat's scope, and provide what the seat delivers, if it delivers anything. The seat is the +// store's row, so this is judged only where the store's set is loaded — at registration and in the +// handover command (novox/hq ADR 0131), never in the parser. +func CanHold(m Manifest, seat Seat) error { + var claimed *Claim + for i := range m.Claims { + if hs, ok := SeatNamed(m.Claims[i].Name); ok && hs.Name == seat.Name { + claimed = &m.Claims[i] + } + } + if claimed == nil { + return fmt.Errorf("%s does not claim %s", m.Module, seat.Name) + } + if claimed.At() != seat.Scope { + return fmt.Errorf("%s claims %s at scope %q, and %s is a %s seat", + m.Module, seat.Name, claimed.At(), seat.Name, seat.Scope) + } + if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) { + return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope", + m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope) + } + return nil +} + func providesAt(m Manifest, provision, scope string) bool { for _, o := range m.Provides { if o.Name == provision && o.At() == scope { diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index 0188106..bbc03eb 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -195,15 +195,8 @@ func CatalogueProblems(shelf Shelf) []string { // The parser cannot do it — it also runs on the build machine, against whatever // set that binary was compiled with. seat, _ := SeatNamed(c.Name) - if c.At() != seat.Scope { - problems = append(problems, fmt.Sprintf( - "%s claims %s at scope %q, and %s is a %s seat", - module, c.Name, c.At(), c.Name, seat.Scope)) - } - if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) { - problems = append(problems, fmt.Sprintf( - "%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope", - module, c.Name, seat.Delivers, module, seat.Delivers, seat.Scope)) + if err := CanHold(m, seat); err != nil { + problems = append(problems, err.Error()) } continue } diff --git a/internal/inventory/holdings_test.go b/internal/inventory/holdings_test.go new file mode 100644 index 0000000..3525cdc --- /dev/null +++ b/internal/inventory/holdings_test.go @@ -0,0 +1,82 @@ +package inventory + +import ( + "context" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// A seat's holder is a row, changed as one act (novox/hq ADR 0131). What these pin is the shape of +// that row's life: it needs an assignment to point at, it is replaced rather than added to, and it +// goes when the assignment does — so a seat never points at something that is not running anywhere. + +func twoBrokersOnTwoNodes(t *testing.T) (*Inventory, context.Context) { + t.Helper() + old := catalogue.Manifest{Module: "old-broker", Version: "1", + Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} + new := catalogue.Manifest{Module: "new-broker", Version: "1", + Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} + inv, ctx := aMeshWith(t, old, new) + // The holding references the seat's row, which `migrate` seeds on a real mesh. + if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + for _, n := range []string{"anchor", "laptop"} { + if _, err := inv.AddNode(ctx, n); err != nil { + t.Fatal(err) + } + } + if _, err := inv.Assign(ctx, "anchor", "old-broker"); err != nil { + t.Fatal(err) + } + if _, err := inv.Assign(ctx, "laptop", "new-broker"); err != nil { + t.Fatal(err) + } + return inv, ctx +} + +func TestAHandoverIsOneRowReplacedNotOneAdded(t *testing.T) { + inv, ctx := twoBrokersOnTwoNodes(t) + + if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "anchor", "old-broker"); err != nil { + t.Fatal(err) + } + if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "laptop", "new-broker"); err != nil { + t.Fatal(err) + } + held, err := inv.Holdings(ctx) + if err != nil { + t.Fatal(err) + } + if len(held) != 1 || held[0].Node != "laptop" || held[0].Module != "new-broker" || held[0].Scope != catalogue.ScopeMesh { + t.Fatalf("after a handover the seat is not held by exactly the new holder: %+v", held) + } +} + +func TestASeatCannotBeHandedToSomethingNotAssigned(t *testing.T) { + inv, ctx := twoBrokersOnTwoNodes(t) + // new-broker is assigned to laptop, not anchor. + if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "anchor", "new-broker"); err == nil { + t.Fatal("a seat was handed to a module not assigned where it was named") + } +} + +func TestUnassigningTheHolderTakesTheHoldingWithIt(t *testing.T) { + inv, ctx := twoBrokersOnTwoNodes(t) + if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "laptop", "new-broker"); err != nil { + t.Fatal(err) + } + if err := inv.Unassign(ctx, "laptop", "new-broker"); err != nil { + t.Fatal(err) + } + held, err := inv.Holdings(ctx) + if err != nil { + t.Fatal(err) + } + if len(held) != 0 { + t.Fatalf("the holding outlived the assignment it pointed at: %+v", held) + } +} diff --git a/internal/inventory/migrations/0039-a-seat-is-held-by-one-assignment-on-record.sql b/internal/inventory/migrations/0039-a-seat-is-held-by-one-assignment-on-record.sql new file mode 100644 index 0000000..b2846eb --- /dev/null +++ b/internal/inventory/migrations/0039-a-seat-is-held-by-one-assignment-on-record.sql @@ -0,0 +1,24 @@ +-- A seat's holder is a recorded fact, not a derivation (novox/hq ADR 0131, design 26). +-- +-- Until this, "which assignment holds the seat" was derived: the module that is assigned and +-- claims the seat holds it, and a second eligible assignment was refused at resolution. That has no +-- way to hand a seat from one holder to the next without a moment where nothing holds it — and the +-- control plane finds its own bus through one of these seats, so that moment was an outage +-- (2026-09-27). Now the holder is one row here, changed by `seat --to /` as one +-- act, and other assignments whose module could hold the seat are simply eligible and silent. +-- +-- No row means what it always meant: the sole eligible assignment holds the seat, and two eligible +-- ones are refused. So a mesh that has never handed a seat over behaves exactly as before, and the +-- row appears the first time somebody does. +-- +-- The seat is referenced by name because claims still are (0034); the rename cascades here so a +-- handed-over seat survives being renamed. The holder is the assignment itself, so unassigning it +-- takes the holding with it and the seat falls back to derivation rather than pointing at nothing. +create table seat_holding ( + seat text primary key references seat(name) on update cascade on delete cascade, + scope text not null, + node uuid not null, + module text not null, + since timestamptz not null default now(), + foreign key (node, module) references assignment(node, module) on delete cascade +); diff --git a/internal/inventory/seats.go b/internal/inventory/seats.go index 481e6bb..b920f82 100644 --- a/internal/inventory/seats.go +++ b/internal/inventory/seats.go @@ -106,3 +106,44 @@ func (i *Inventory) RenameSeat(ctx context.Context, from, to string) error { } return nil } + +// HoldSeat records that one assignment holds a seat, replacing whoever held it — as one write, so +// the seat is never without a holder in between (novox/hq ADR 0131, design 28 task 5.3). The +// assignment must exist; the store refuses otherwise, and that refusal is the right one: a seat +// cannot be handed to something that is not running anywhere. +func (i *Inventory) HoldSeat(ctx context.Context, seat, scope, nodeName, module string) error { + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return err + } + _, err = i.store.Pool().Exec(ctx, + `insert into seat_holding (seat, scope, node, module) values ($1, $2, $3, $4) + on conflict (seat) do update set scope = excluded.scope, node = excluded.node, + module = excluded.module, since = now()`, + seat, scope, node.ID, module) + if err != nil { + return fmt.Errorf("recording %s on %s as the holder of %s: %w", module, nodeName, seat, err) + } + return nil +} + +// Holdings is every seat whose holder is on record, as the resolver reads it. A seat with no row here +// is held by derivation, exactly as before the table existed. +func (i *Inventory) Holdings(ctx context.Context) ([]catalogue.Held, error) { + rows, err := i.store.Pool().Query(ctx, + `select h.seat, h.scope, n.name, h.module, coalesce(n.site, '') + from seat_holding h join node n on n.id = h.node order by h.seat`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []catalogue.Held + for rows.Next() { + var h catalogue.Held + if err := rows.Scan(&h.Claim, &h.Scope, &h.Node, &h.Module, &h.Site); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +}