From e7cff3d38ea2b116cfd0cd7ad3b47051d3811e6e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 14:02:46 +0200 Subject: [PATCH 1/2] The store seat promises two verbs, databases and query (hq ADR 0159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeded additively into the seat's row at the controller's next start; a holder must list tools of these names (design 33 §3), so this merges after the database engine's definition does. --- internal/catalogue/seats.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 39de060..7f75e04 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -59,7 +59,16 @@ var defaultSeats = []Seat{ {Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079", Emits: []string{"applied", "refused", "built-before"}, Serves: ControllerVerbs}, - {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"}, + // The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable, + // served by whichever module holds the seat with tools of these names. + {Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079", + Serves: []Verb{ + {Name: "databases", Description: "Every database the store holds, with its on-disk size.", + Input: schema(map[string]string{}, nil)}, + {Name: "query", Description: "One read-only statement against one database the store holds.", + Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"}, + []string{"database", "sql"})}, + }}, // **Delivers the mesh's own bus, not `amqp`.** Those were the same word until // ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is // the mesh's own transport. ADR 0128 then made that connection something a module requires -- 2.54.0 From 18958154f00e727d34d7b0dec869e1780e19e68f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 15:18:34 +0200 Subject: [PATCH 2/2] A claim may name the verbs it serves for its seat (hq ADR 0160) The store's databases is not postgres's postgres_list_databases, and a holder may serve both. A claim's serves names the seat's verbs the module implements for the role; absent, the module's own tools must list every verb the seat promises, which is how a module named like its seat says they are one and the same. Registration refuses a claim naming a verb the seat never promised, and the credential's claims carry the claim's own verbs to the runtime. --- cmd/mesh-controller/modules.go | 4 +++- internal/catalogue/holdings_test.go | 21 +++++++++++++++++++++ internal/catalogue/manifest.go | 16 ++++++++++++++++ internal/catalogue/seats.go | 12 ++++++++++-- internal/catalogue/seats_declared.go | 8 ++++---- internal/catalogue/seats_declared_test.go | 16 ++++++++++++++++ internal/catalogue/verbs.go | 17 ++++++++++++++++- 7 files changed, 86 insertions(+), 8 deletions(-) diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 13389a0..111ce4d 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -716,7 +716,9 @@ func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife claimed := seatClaimed{Seat: c.Name, Scope: c.At()} if s, known := byName[c.Name]; known { claimed.Scope = s.Scope - claimed.Serves = catalogue.VerbNames(s.Serves) + // The verbs the runtime serves for the seat: the claim's own when it names them + // (ADR 0160), else every verb the seat promises, which its tools then answer. + claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)}) } out = append(out, claimed) } diff --git a/internal/catalogue/holdings_test.go b/internal/catalogue/holdings_test.go index 0975f1c..0159490 100644 --- a/internal/catalogue/holdings_test.go +++ b/internal/catalogue/holdings_test.go @@ -107,6 +107,27 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) { 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) } + // A holder's own tools need not be the seat's verbs: the claim may name what it serves for the + // role (ADR 0160), and then only those count — and only the seat's verbs may be named. + promising := seat + promising.Serves = []Verb{{Name: "databases"}, {Name: "query"}} + engine := newBroker() + engine.Tools = []string{"engine_list_databases", "engine_query"} + if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve databases, query") { + t.Fatalf("a holder whose tools are not the seat's verbs was allowed without saying what it serves: %v", err) + } + engine.Claims[0].Serves = []string{"databases", "query"} + if err := CanHold(engine, promising); err != nil { + t.Fatalf("a claim naming the seat's verbs was refused: %v", err) + } + engine.Claims[0].Serves = []string{"databases"} + if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve query") { + t.Fatalf("a claim naming half the verbs was allowed: %v", err) + } + engine.Claims[0].Serves = []string{"databases", "query", "engine_query"} + if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not promise") { + t.Fatalf("a claim naming a verb the seat never promised was allowed: %v", err) + } // And the judgement follows the store's row, not a compiled copy. busSeatDelivering(t, "amqp") seat, _ = SeatNamed("mesh-broker") diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index f75fed6..faf1612 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -52,6 +52,22 @@ type Claim struct { Name string `json:"name"` // Scope defaults to the node, which is where nearly everything singular is singular. Scope string `json:"scope,omitempty"` + // Serves names the seat's verbs this module implements for the role, when its own tools are + // not the seat's (novox/hq ADR 0159, 0160): the store's `databases` is not postgres's + // `postgres_list_databases`, and a holder may well serve both. The runtime serves an + // implementation registered under the seat's name on the seat's subjects. Absent, the + // module's own `tools` must list every verb the seat promises, which is how a module named + // like its seat — the catalogue, the records — says they are one and the same. + Serves []string `json:"serves,omitempty"` +} + +// ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's +// own tools. +func (c Claim) ServesFor(m Manifest) []string { + if len(c.Serves) > 0 { + return c.Serves + } + return m.Tools } // At is this claim's scope, with the default applied. diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 7f75e04..08c7e06 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -295,11 +295,19 @@ 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(m.Tools, seat.Serves); len(missing) > 0 { + if missing := unservedVerbs(claimed.ServesFor(m), seat.Serves); 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 lists every verb its seat declares under tools", + "(novox/hq ADR 0132) — a holder names every verb its seat declares, under the claim's "+ + "serves or among its own tools", m.Module, seat.Name, strings.Join(missing, ", ")) } + // And nothing the seat does not promise: a verb named here that the protocol lacks is served + // to nobody, which is a typo the holder would otherwise discover as a caller's timeout. + if extra := unpromised(claimed.Serves, seat.Serves); len(extra) > 0 { + return fmt.Errorf("%s claims %s and says it serves %s, which that seat's protocol does not "+ + "promise — a claim's serves names the seat's verbs and nothing else", + m.Module, seat.Name, strings.Join(extra, ", ")) + } return nil } diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index 97a6198..f125cac 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -208,7 +208,7 @@ func CatalogueProblems(shelf Shelf) []string { } // A holder that does not answer what the seat promises is a caller's timeout, found // at assignment instead. - if missing := unserved(m, s); len(missing) > 0 { + if missing := unserved(m, c, s); len(missing) > 0 { problems = append(problems, fmt.Sprintf( "%s claims %s but does not serve %s, which that seat's protocol promises", module, c.Name, strings.Join(missing, ", "))) @@ -221,10 +221,10 @@ func CatalogueProblems(shelf Shelf) []string { // unserved is what a seat's protocol promises and the claimant does not answer. Only the tools // are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool -// is code the module either has or has not written. -func unserved(m Manifest, s SeatDeclaration) []string { +// is code the module either has or has not written — under the claim's serves, or among its own. +func unserved(m Manifest, c Claim, s SeatDeclaration) []string { has := map[string]bool{} - for _, t := range m.Tools { + for _, t := range c.ServesFor(m) { has[t] = true } var missing []string diff --git a/internal/catalogue/seats_declared_test.go b/internal/catalogue/seats_declared_test.go index 2bc6907..42958a8 100644 --- a/internal/catalogue/seats_declared_test.go +++ b/internal/catalogue/seats_declared_test.go @@ -70,6 +70,22 @@ func TestAHolderMustServeWhatItsSeatPromises(t *testing.T) { } } +// The claim may say what it serves for the role instead, when the module's own tools are not the +// seat's verbs (ADR 0160). +func TestAClaimMayNameWhatItServesForTheSeat(t *testing.T) { + m := telegram() + m.Tools = []string{"telegram_send"} + m.Claims = append([]Claim(nil), m.Claims...) + for i := range m.Claims { + if m.Claims[i].Name == "telegram-sender" { + m.Claims[i].Serves = []string{"status"} + } + } + if got := problemsFor(t, Shelf{"telegram": m}); strings.Contains(got, "does not serve") { + t.Fatalf("a claim naming the seat's verb was refused: %s", got) + } +} + // A seat with no protocol is a marker: which module is this node's showcase, or its packet filter. // Most node-scoped seats are markers, so refusing one would refuse the majority of the set. func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) { diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 2ad3907..cb96ed0 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -133,7 +133,22 @@ func schema(properties map[string]string, required []string) map[string]any { return out } -// unservedVerbs is what a seat promises and a claimant's `tools` does not answer. +// unpromised is what a claim says it serves and the seat's protocol never promised. +func unpromised(serves []string, promised []Verb) []string { + has := map[string]bool{} + for _, v := range promised { + has[v.Name] = true + } + var extra []string + for _, s := range serves { + if !has[s] { + extra = append(extra, s) + } + } + return extra +} + +// unservedVerbs is what a seat promises and a claimant's offer for it does not answer. func unservedVerbs(tools []string, promised []Verb) []string { has := map[string]bool{} for _, t := range tools { -- 2.54.0