From 82fe44b38017d044d5d2a2a9b3e4dde45c9e4a1c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 7 Oct 2026 19:16:19 +0200 Subject: [PATCH] Give the uplink seat its verbs, staged until its holders serve them (hq ADR 0241) What a machine resolves through and over which links had no tool: the resolver file and its writer, and each link with its routes and resolvers, are now verbs of node-uplink, the same whatever manages the network. A verb required at once would refuse every holder before the catalogue could serve it, and a stored one would be required by an older controller, so it is staged: promised and routed, never stored, and not yet a condition of holding. --- internal/catalogue/seats.go | 63 ++++++++++++++++++++++- internal/catalogue/uplink_verbs_test.go | 68 +++++++++++++++++++++++++ internal/inventory/seats.go | 2 + internal/inventory/seats_test.go | 22 ++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 internal/catalogue/uplink_verbs_test.go diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index ff30ca7b..afc80ddd 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -2,6 +2,7 @@ package catalogue import ( "fmt" + "slices" "sort" "strings" ) @@ -53,6 +54,14 @@ type Seat struct { // ${contribution::}. Compiled, never stored: like the protocol, it is the mesh's // definition of the role, and the store's rows carry no column for it. Receives []Receivable + // Staged names verbs of Serves that are promised and routed but not yet a condition of holding the + // seat: a verb added to a seat whose holders do not serve it yet. Holding requires every verb, and a + // claim may name only promised ones (ADR 0132), so a verb required at once would refuse every holder + // until the catalogue serves it — and the catalogue could not name it until this controller ran. Staged, + // the controller rolls out first, the holders serve the verb next, and a later change requires it. + // Compiled, never stored, and kept out of the stored row's serves: a controller older than the verb + // reads the row strictly and would require what no holder of its time serves. + Staged []string // Decision is the record that made it a seat. Decision string } @@ -281,7 +290,13 @@ var defaultSeats = append([]Seat{ // channel a fix could arrive on. A seat // rather than a condition in the resolver's module, so a machine running two managers is // refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117). - {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"}, + // + // Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the + // resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers + // the manager knows for it — the same for every holder, so a question about a machine's names is asked + // the same way whatever manages its network. Staged until both holders serve them. + {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", + Serves: uplinkVerbs(), Staged: []string{"resolvers", "links"}}, }, // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) @@ -338,6 +353,17 @@ func UseSeats(s []Seat) { if d, known := byName[row.Name]; known { row.Receives = d.Receives row.Replicated = d.Replicated + // Staged verbs are never in the row: they come from the binary that promises them. + row.Staged = d.Staged + have := map[string]bool{} + for _, v := range row.Serves { + have[v.Name] = true + } + for _, v := range d.Serves { + if slices.Contains(d.Staged, v.Name) && !have[v.Name] { + row.Serves = append(row.Serves, v) + } + } } merged = append(merged, row) } @@ -477,7 +503,7 @@ func CanHold(m Manifest, seat Seat) error { // **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that // does not answer what the role promises is every caller's timeout, found at registration and // at handover instead, naming the verbs rather than the fact that something is missing. - if missing := unservedVerbs(claimed.ServesFor(m), seat.Serves); len(missing) > 0 { + if missing := unservedVerbs(claimed.ServesFor(m), seat.Required()); len(missing) > 0 { return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+ "(novox/hq ADR 0132) — a holder names every verb its seat declares, under the claim's "+ "serves or among its own tools", @@ -591,6 +617,39 @@ func serviceManagerVerbs() []Verb { } } +// Required is the verbs a holder must serve: every one the seat promises but those still staged. +func (s Seat) Required() []Verb { + var out []Verb + for _, v := range s.Serves { + if !slices.Contains(s.Staged, v.Name) { + out = append(out, v) + } + } + return out +} + +// Stored is the seat as its row keeps it: its staged verbs left out (see Staged). +func (s Seat) Stored() Seat { + s.Serves = s.Required() + return s +} + +// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers +// about what the machine resolves through and over which links, whatever program manages its network. +func uplinkVerbs() []Verb { + return []Verb{ + {Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " + + "domains and options it lists, whether it is the file the mesh declares, and when it is not, who " + + "wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " + + "in its place, a known writer running.", + Input: schema(nil, nil)}, + {Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " + + "the default route leaves through it, and the resolvers and search domains the network manager " + + "knows for it — a VPN's tunnel included.", + Input: schema(nil, nil)}, + } +} + // loginShellVerbs is the contract every holder of node-login-shell serves (novox/hq ADR 0176, ADR // 0204): one command, run the way the operator's own terminal would run it, bounded below the // runtime's thirty-second call limit so a hung command answers rather than times the caller out. diff --git a/internal/catalogue/uplink_verbs_test.go b/internal/catalogue/uplink_verbs_test.go new file mode 100644 index 00000000..ae83e351 --- /dev/null +++ b/internal/catalogue/uplink_verbs_test.go @@ -0,0 +1,68 @@ +package catalogue + +import ( + "slices" + "strings" + "testing" +) + +// The node-uplink seat's verbs (novox/hq ADR 0241): promised and routed, staged — not yet a condition of +// holding — so the holders of today still hold it, a holder may name them, and nothing it does not promise. + +func uplinkSeat(t *testing.T) Seat { + t.Helper() + for _, s := range DefaultSeats() { + if s.Name == "node-uplink" { + return s + } + } + t.Fatal("no node-uplink seat") + return Seat{} +} + +func TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet(t *testing.T) { + seat := uplinkSeat(t) + var names []string + for _, v := range seat.Serves { + names = append(names, v.Name) + } + if !slices.Equal(names, []string{"resolvers", "links"}) || len(seat.Required()) != 0 { + t.Fatalf("node-uplink promises %v and requires %v", names, seat.Required()) + } + today := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}} + if err := CanHold(today, seat); err != nil { + t.Fatalf("a holder that serves no verb yet is refused: %v", err) + } + serving := today + serving.Claims = []Claim{{Name: "node-uplink", Scope: ScopeNode, Serves: []string{"resolvers", "links"}}} + if err := CanHold(serving, seat); err != nil { + t.Fatalf("a holder serving both is refused: %v", err) + } + typo := today + typo.Claims = []Claim{{Name: "node-uplink", Scope: ScopeNode, Serves: []string{"resolver"}}} + if err := CanHold(typo, seat); err == nil || !strings.Contains(err.Error(), "does not promise") { + t.Fatalf("a verb the seat does not promise was accepted: %v", err) + } +} + +func TestAStagedVerbIsNeverStoredAndComesBackFromTheBinary(t *testing.T) { + seat := uplinkSeat(t) + if stored := seat.Stored(); len(stored.Serves) != 0 { + t.Fatalf("the row would keep %v", stored.Serves) + } + before := seats + t.Cleanup(func() { seats = before }) + // A row as the store keeps it: no protocol of its own. And one an operator gave a verb of its own. + UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode}, {Name: "node-service-manager", Scope: ScopeNode}}) + for _, s := range Seats() { + if s.Name == "node-uplink" && (len(s.Serves) != 2 || len(s.Staged) != 2) { + t.Fatalf("the working set's node-uplink is %+v", s) + } + } + UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode, Serves: []Verb{{Name: "operators-own"}}}}) + for _, s := range Seats() { + if s.Name == "node-uplink" && len(s.Serves) != 3 { + t.Fatalf("a row with a verb of its own lost or did not gain the staged ones: %+v", s.Serves) + } + } +} diff --git a/internal/inventory/seats.go b/internal/inventory/seats.go index a0065f39..0832e6b7 100644 --- a/internal/inventory/seats.go +++ b/internal/inventory/seats.go @@ -62,6 +62,8 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) { func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) { var added int for _, s := range defaults { + // A verb still staged is never stored (catalogue.Seat.Staged). + s = s.Stored() accepts, emits, serves, err := protocolJSON(s) if err != nil { return added, err diff --git a/internal/inventory/seats_test.go b/internal/inventory/seats_test.go index ab51567e..94594362 100644 --- a/internal/inventory/seats_test.go +++ b/internal/inventory/seats_test.go @@ -156,3 +156,25 @@ func TestTheControllersVerbsInTheRowAreTheBinarys(t *testing.T) { t.Fatal("a verb this binary adds was not added") } } + +// A verb still staged (novox/hq ADR 0241) is never in the store's row: a controller older than it reads +// the row and would require what no holder of its time serves. +func TestAStagedVerbIsNotSeeded(t *testing.T) { + inv := ForTest(t) + if _, err := inv.SeedSeats(t.Context(), catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + // Seeded twice: the second widens, and must not add it either. + if _, err := inv.SeedSeats(t.Context(), catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + got, err := inv.Seats(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, s := range got { + if s.Name == "node-uplink" && len(s.Serves) != 0 { + t.Fatalf("the row keeps %v", s.Serves) + } + } +}