From c9be75b13ee56b12d561d74d8f14c5541e61ad8b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 16:37:12 +0200 Subject: [PATCH] Carry a channel's capabilities on its claim and tell the router every kind, so an answer is judged by the controller's record and not the channel's word --- internal/broker/membership.go | 45 ++++++++++++++++++++++++++-- internal/broker/nats.go | 2 ++ internal/broker/seattraffic.go | 13 ++++++++ internal/broker/seattraffic_test.go | 32 ++++++++++++++++++++ internal/catalogue/kinded_test.go | 21 +++++++++++++ internal/catalogue/manifest.go | 4 +++ internal/catalogue/seats_declared.go | 35 ++++++++++++++++++++++ internal/inventory/busrecords.go | 1 + 8 files changed, 151 insertions(+), 2 deletions(-) diff --git a/internal/broker/membership.go b/internal/broker/membership.go index 2929378c..1909e99e 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -84,6 +84,8 @@ type Placements struct { // Interchangeable is each module whose definition says its instances are the same anywhere, // so the module's plain subject is issued to all of them in one queue. Interchangeable map[string]bool + // Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5). + Kinds []KindHeld } // AnswersForTheModule says whether an instance of a module on one machine is issued the module's @@ -115,8 +117,18 @@ func MembershipFor(node string, d Declared, where Placements) Membership { } } m.State = stateIssuedFor(d, node) - if t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches); len(t.Publish)+len(t.Subscribe)+ - len(t.Answers)+len(t.Workers)+len(t.Records) > 0 { + t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) + for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { + if !s.Kinded { + continue + } + for _, k := range where.Kinds { + if k.Seat == s.Name && !kindListed(t.Kinds, k) { + t.Kinds = append(t.Kinds, k) + } + } + } + if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 { m.SeatTraffic = &t } if len(d.Invokes) > 0 { @@ -155,5 +167,34 @@ func PlacementsOf(r Records, interchangeable map[string]bool) Placements { for _, nodes := range p.Nodes { sort.Strings(nodes) } + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Kinded && s.Kind != "" { + p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node, + Capabilities: s.Capabilities}) + } + } + } + } + sort.Slice(p.Kinds, func(i, j int) bool { + a, b := p.Kinds[i], p.Kinds[j] + if a.Seat != b.Seat { + return a.Seat < b.Seat + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + return a.Node < b.Node + }) return p } + +func kindListed(list []KindHeld, k KindHeld) bool { + for _, x := range list { + if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node { + return true + } + } + return false +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 9372e0a4..d0481852 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -75,6 +75,8 @@ type Seat struct { Proofs []string // Records are the holder's buckets, by their full name, each user reads under its own name. Records []string + // Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2). + Capabilities []string } // A Principal is one user of the bus. Its permissions are derived from what it declares and diff --git a/internal/broker/seattraffic.go b/internal/broker/seattraffic.go index 1cb4c1ee..d2ebee8c 100644 --- a/internal/broker/seattraffic.go +++ b/internal/broker/seattraffic.go @@ -47,6 +47,19 @@ type SeatTraffic struct { Workers []Worker `json:"workers,omitempty"` // Records is the direct-get subjects of the records it reads under its own name. Records []string `json:"records,omitempty"` + // Kinds are the holders of every kinded bench it uses or watches, with what each promises: the one + // account of which channel is which, and what it can carry, that the router judges an answer by. The + // controller's, from the claims, never a channel's word (novox/hq ADR 0259 §5). + Kinds []KindHeld `json:"kinds,omitempty"` +} + +// KindHeld is one kind of a kinded bench and who holds it. +type KindHeld struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Module string `json:"module"` + Node string `json:"node"` + Capabilities []string `json:"capabilities,omitempty"` } // KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. diff --git a/internal/broker/seattraffic_test.go b/internal/broker/seattraffic_test.go index 3df3c708..2f8b7bbd 100644 --- a/internal/broker/seattraffic_test.go +++ b/internal/broker/seattraffic_test.go @@ -238,3 +238,35 @@ func TestASeatWithoutTheNewRulesIsComposedAsBefore(t *testing.T) { t.Error("an old seat's holder lost its accept") } } + +// The router learns which channel is which, and what each promises, from the controller's membership: +// the claims, never a channel's word (ADR 0259 §5). +func TestTheRoutersMembershipNamesEveryKindAndItsCapabilities(t *testing.T) { + tg := channelSeat("telegram") + tg.Capabilities = []string{"choice", "verified-sender"} + desk := channelSeat("desktop") + desk.Capabilities = []string{"choice"} + router := Declared{Module: "messenger", Holds: []Seat{operatorChannel()}, Uses: []Seat{channelSeat("")}} + records := Records{Nodes: []string{"anchor", "laptop"}, Assigned: map[string][]Declared{ + "anchor": {router, {Module: "telegram", Holds: []Seat{tg}}}, + "laptop": {{Module: "desk-channel", Holds: []Seat{desk}}}, + }} + where := PlacementsOf(records, nil) + m := MembershipFor("anchor", router, where) + if m.SeatTraffic == nil || len(m.SeatTraffic.Kinds) != 2 { + t.Fatalf("the router is not told the kinds: %+v", m.SeatTraffic) + } + byKind := map[string]KindHeld{} + for _, k := range m.SeatTraffic.Kinds { + byKind[k.Kind] = k + } + if k := byKind["telegram"]; k.Module != "telegram" || k.Node != "anchor" || !namesVerb(k.Capabilities, "verified-sender") { + t.Errorf("telegram is %+v", k) + } + if k := byKind["desktop"]; k.Module != "desk-channel" || namesVerb(k.Capabilities, "verified-sender") { + t.Errorf("the desk is %+v", k) + } + if other := MembershipFor("anchor", Declared{Module: "mesh-delivery", Uses: []Seat{operatorChannel()}}, where); other.SeatTraffic != nil && len(other.SeatTraffic.Kinds) > 0 { + t.Error("an asker is told the channels") + } +} diff --git a/internal/catalogue/kinded_test.go b/internal/catalogue/kinded_test.go index 4e752214..c1fd1aa3 100644 --- a/internal/catalogue/kinded_test.go +++ b/internal/catalogue/kinded_test.go @@ -93,3 +93,24 @@ func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { t.Fatal("one kind held on two machines was not refused") } } + +// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any. +func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { + good := aChannel("telegram", "telegram") + good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} + if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" { + t.Fatalf("the vocabulary was refused: %s", got) + } + bad := aChannel("telegram", "telegram") + bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"} + got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad}) + for _, w := range []string{`"trusted"`, `"max-length:lots"`} { + if !strings.Contains(got, w+", which channel-capabilities/1 does not have") { + t.Errorf("%s was not refused: %s", w, got) + } + } + odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}} + if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") { + t.Errorf("capabilities on a seat that is not kinded stood: %s", got) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 6f16db81..bbae198c 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -67,6 +67,10 @@ type Claim struct { // Kind is the kind this module holds a kinded bench as (novox/hq ADR 0234 §2, ADR 0259): `telegram`, // `desktop`. Refused on any other seat, and a second claim of one kind is refused. Kind string `json:"kind,omitempty"` + // Capabilities are what a channel of this kind promises, from the fixed vocabulary + // channel-capabilities/1 (novox/hq ADR 0234 §2): the router judges an answer by these, read from the + // controller's record of this claim and never from the channel. + Capabilities []string `json:"capabilities,omitempty"` } // ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's diff --git a/internal/catalogue/seats_declared.go b/internal/catalogue/seats_declared.go index b97fc1f1..fd4b0899 100644 --- a/internal/catalogue/seats_declared.go +++ b/internal/catalogue/seats_declared.go @@ -2,6 +2,7 @@ package catalogue import ( "fmt" + "regexp" "sort" "strings" ) @@ -331,9 +332,43 @@ func CatalogueProblems(shelf Shelf) []string { return problems } +// ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word +// outside it is refused. `max-length:` takes a number. +var ChannelCapabilities = map[string]bool{ + "deliver": true, "reaches-away": true, "loud": true, "silent": true, "edit": true, + "reaches-when-mesh-down": true, "private": true, + "choice": true, "reply": true, "threads": true, "operator-first": true, + "verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true, +} + +var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`) + +// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a +// seat that is not kinded. +func capabilityProblems(module string, c Claim, kinded bool) []string { + if len(c.Capabilities) == 0 { + return nil + } + if !kinded { + return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them", + module, c.Name)} + } + var problems []string + for _, w := range c.Capabilities { + if !ChannelCapabilities[w] && !maxLength.MatchString(w) { + problems = append(problems, fmt.Sprintf( + "%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w)) + } + } + return problems +} + // kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind, // a usable name; any other seat takes none. func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string { + if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 { + return problems + } switch { case !s.Kinded && c.Kind != "": return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind", diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 3fd78d65..4af67be5 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -183,6 +183,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio if s, hasAProtocol := seats[c.Name]; hasAProtocol { held := asSeat(s, declarers[s.Name]) held.Kind = c.Kind + held.Capabilities = c.Capabilities d.Holds = append(d.Holds, held) } }