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 {