From ffcce927fe72a55f190294b570b956263912dfe2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 8 Nov 2026 02:40:52 +0100 Subject: [PATCH 1/2] Each node's account calls the engine's invokes and hears its seat events on the seat (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grants part built the node-engine's declaration with its consumes alone, so a node's account was granted none of the engine manifest's invokes: ace's engine could not publish mesh.seat.mesh-catalog.call.seat-definitions.node~ace, read no seat list and composed nothing new. And its consumes of mesh-catalog's seat-definition events were granted on the emitter's subject only, where the engine subscribes the seat's (ADR 0636 §4). The engine's declaration now carries its invokes and the seats it watches, split by the same consumedOf a placed module's consumes are; the node's account is granted the invokes as a module's are (in its own caller token by callerNamed) and the watched events on each seat's subject by the same watchedEvents a module's use. Its consumes stay granted on the emitter's subjects too, so nothing granted before is taken away. Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5 --- modules/mesh-grants/grants_test.go | 36 ++++++++++- .../mesh-grants/internal/buscompose/nats.go | 43 +++++++++---- .../mesh-grants/internal/buscompose/users.go | 8 ++- .../mesh-grants/internal/grants/records.go | 61 +++++++++++-------- 4 files changed, 108 insertions(+), 40 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index d211c947..ea058081 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2037,7 +2037,8 @@ func TestGrants(t *testing.T) { t.Run("assertions: the node-engine's declared state is created though no node is assigned it", func(t *testing.T) { b := fixture() - b.manifestOf(buscompose.EngineModule, `{"module":"mesh-host","version":"1","state":[{"name":"acts"}]}`) + b.manifestOf(buscompose.EngineModule, `{"module":"mesh-host","version":"1","state":[{"name":"acts"}],`+ + `"invokes":["seat:mesh-catalog.seat-definitions"],"consumes":["mesh-catalog.seat-definition-changed","restic.backed-up"]}`) b.loaded = shadowOf(t, b) p := partOn(b) p.Create = b @@ -2068,6 +2069,39 @@ func TestGrants(t *testing.T) { } } } + // Issue 1966: each node's account calls what the engine's manifest invokes in its own name, as a module's account + // does (G1), and hears a consumed seat's event on the seat's own subject, where the engine subscribes it, and still + // on the emitter's (G2); a consume naming no seat stays on its emitter's alone. + users, err := buscompose.Users(recs) + if err != nil { + t.Fatal(err) + } + nodes := 0 + for _, u := range users { + if u.Kind != buscompose.KindNode { + continue + } + nodes++ + perms, err := buscompose.PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + token := buscompose.CallerToken(u.Username()) + if !buscompose.MayPublish(perms, "mesh.seat.mesh-catalog.call.seat-definitions."+token) || + buscompose.MayPublish(perms, "mesh.seat.mesh-catalog.call.seat-definitions.other~one") || + buscompose.MayPublish(perms, "mesh.seat.mesh-catalog.call.modules."+token) { + t.Errorf("%s: the engine's invoke of seat-definitions is not granted in its own name alone", u.Username()) + } + if !buscompose.MaySubscribe(perms, "mesh.seat.mesh-catalog.event.seat-definition-changed") || + !buscompose.MaySubscribe(perms, "mesh.mod.mesh-catalog.event.seat-definition-changed") || + buscompose.MaySubscribe(perms, "mesh.seat.mesh-catalog.event.manifest-accepted") || + !buscompose.MaySubscribe(perms, "mesh.mod.restic.event.backed-up") { + t.Errorf("%s: the engine's consumes are not heard on the seat's subject and the emitter's", u.Username()) + } + } + if nodes == 0 { + t.Fatal("no node account composed") + } }) t.Run("assertions: a refused create is said, never stops it, and is tried at the next event", func(t *testing.T) { diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index b14eaddc..dbee661c 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -779,6 +779,16 @@ func PermissionsFor(p Principal) (Permissions, error) { } sub = append(sub, subject) } + // And the seats' events the node-engine's manifest consumes, on each seat's own subject, and the verbs it invokes, + // in the node's own name (callerNamed, below): as a module's are (issue 1966: the seat list's verb and events). + sub = append(sub, watchedEvents(p.Watches)...) + tp, ts := seatTrafficGrants(SeatTrafficOf(p.Username(), nil, nil, p.Watches)) + pub, sub = append(pub, tp...), append(sub, ts...) + invoked, err := invokedSubjects(p.Invokes) + if err != nil { + return Permissions{}, err + } + pub = append(pub, invoked...) // And the creation of the seat mesh-module-assigner's two states it composes from (ADR 0567 §9), so a node agent started // before they exist binds each when the bus says it was made, with no timer; no other bucket's. sub = append(sub, NodeComposeAdvisories()...) @@ -918,19 +928,7 @@ func PermissionsFor(p Principal) (Permissions, error) { // 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a // role is hearing what it announced, not taking part in it. - for _, w := range p.Watches { - if w.Kinded { - continue // composed by SeatTrafficOf below - } - for _, e := range w.Emits { - // An event that names its holder's machine is heard from every machine (machineEvent): a flat subject would - // match no event the holder says. - if pattern, ok := machineEvent(w, e); ok { - e = pattern - } - sub = append(sub, seatSubject(w, "event", e)) - } - } + sub = append(sub, watchedEvents(p.Watches)...) // 2c. Its own consumer, which it **pulls**: the runtime asks for the next message and is // answered on its own inbox, so what it needs is to ask about the consumer and to ask it @@ -1609,6 +1607,25 @@ func quoted(values []string) string { return strings.Join(out, ", ") } +// watchedEvents are the subjects a principal subscribes to hear the seats it watches (a `consumes` naming a seat's event): +// each on the seat's own namespace, an event naming its holder's machine heard from every machine (machineEvent), since a +// flat subject would match no event the holder says. A kinded seat's are SeatTrafficOf's. +func watchedEvents(watches []Seat) []string { + var out []string + for _, w := range watches { + if w.Kinded { + continue + } + for _, e := range w.Emits { + if pattern, ok := machineEvent(w, e); ok { + e = pattern + } + out = append(out, seatSubject(w, "event", e)) + } + } + return out +} + // invokedSubjects is the publish side of a grant to call tools: one subject per `.`, // or the whole tool namespace for `*`. A person's authority and a module's `invokes` are both this // (novox/hq ADR 0152), so a malformed entry is refused in one place, before it could be widened into diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 156a1214..b9fb73ed 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -200,12 +200,16 @@ func Users(r Records) ([]Principal, error) { // each with the node's permissions. // What the node-engine consumes is what its account hears (a `consumes` line of its own manifest; Engine): the // backup holder's word and the status view's `settled`, with no module named in this code. - var engineHears []string + // And the seats' events it consumes, on each seat's own subject, and the verbs it invokes, in the node's own name, + // granted as a module's are (issue 1966: the seat list's verb seat-definitions and its two events). + var engineHears, engineInvokes []string + var engineWatches []Seat if r.Engine != nil { - engineHears = r.Engine.Consumes + engineHears, engineWatches, engineInvokes = r.Engine.Consumes, r.Engine.Watches, r.Engine.Invokes } for _, login := range nodeLoginsOf(r, node) { out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks, Runner: runner, Consumes: engineHears, + Watches: engineWatches, Invokes: engineInvokes, MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)}) diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 55ac6e90..47bedaf3 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -111,7 +111,12 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // The node-engine's own manifest, placed on every node (ADR 0563): its state, logs and streams alone, created by this // part; it adds no principal, since each node's engine speaks as the node's account. if m, ok := declared[buscompose.EngineModule]; ok { - out.Engine = &buscompose.Declared{Module: m.Module, Consumes: m.Consumes, State: bucketsFor(m, in.Nodes), Logs: logsOf(m), Streams: streamsOf(m)} + // What it consumes and invokes are its node account's: a seat's event also on the seat's own subject, as a module's + // consumes are split (consumedOf), every consume still on its emitter's as before, and each verb it invokes in the + // node's own name (issue 1966: the seat list's verb and events). + _, watches := consumedOf(m, seats, declarers) + out.Engine = &buscompose.Declared{Module: m.Module, Consumes: m.Consumes, Watches: watches, Invokes: m.Invokes, + State: bucketsFor(m, in.Nodes), Logs: logsOf(m), Streams: streamsOf(m)} } return out, gaps } @@ -119,29 +124,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // declaredFor is mesh-controller's declaredFor (internal/inventory/busrecords.go), copied: one module's manifest as the // composer needs it. func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) buscompose.Declared { - var fromModules []string - var watches []buscompose.Seat - for _, c := range m.Consumes { - emitter, event, named := strings.Cut(c, ".") - if named { - if s, isASeat := seats[emitter]; isASeat && s.Kinded { - ev := strings.TrimSuffix(event, ".*") - if catalogue.SeatSays(s.Emits, ev) { - watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev}, - Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]}) - continue - } - } - if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) { - // Its scope, and whether the node-engine holds it, place an event that names its holder's machine (ADR 0618 §4). - compiled, _ := catalogue.SeatNamed(s.Name) - watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{event}, - HeldByTheEngine: compiled.HeldByTheEngine}) - continue - } - } - fromModules = append(fromModules, c) - } + fromModules, watches := consumedOf(m, seats, declarers) d := buscompose.Declared{ Module: m.Module, ToolRunner: catalogue.IsToolRunner(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools, Invokes: m.Invokes, Waits: waitsOf(m, seats), State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m), @@ -178,6 +161,36 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio return d } +// consumedOf splits what a manifest consumes as the composer grants it: a seat's event on the seat's own subject (its +// watches), every other by its emitter's (novox/hq ADR 0118). A placed module and the node-engine's own manifest are split +// alike. +func consumedOf(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration, declarers map[string]string) ([]string, []buscompose.Seat) { + var fromModules []string + var watches []buscompose.Seat + for _, c := range m.Consumes { + emitter, event, named := strings.Cut(c, ".") + if named { + if s, isASeat := seats[emitter]; isASeat && s.Kinded { + ev := strings.TrimSuffix(event, ".*") + if catalogue.SeatSays(s.Emits, ev) { + watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{ev}, + Kinded: true, Proofs: s.Proofs, DeclaredBy: declarers[s.Name]}) + continue + } + } + if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) { + // Its scope, and whether the node-engine holds it, place an event that names its holder's machine (ADR 0618 §4). + compiled, _ := catalogue.SeatNamed(s.Name) + watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{event}, + HeldByTheEngine: compiled.HeldByTheEngine}) + continue + } + } + fromModules = append(fromModules, c) + } + return fromModules, watches +} + // loadsABundle says the node's tool runtime loads a bundle of the module. The controller reads it from the resolved // manifest's Bundles, which a build derives and the indexer's manifest does not carry (the builder's build record names // the repository's manifest digest), so the same answer is derived here from `build.artifacts` by mesh-sdk's rule From ee6fd92b4d1a4375b03ea963e952a7592be4c0d7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 8 Nov 2026 03:53:11 +0100 Subject: [PATCH 2/2] The assigner and the indexer's hold count a bootstrapped seat as read (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every reader of the seat list holds the bootstrapped seats from its start (ADR 0636 §2), so a manifest naming one is read by every node-engine whatever its statement names. graph.MissingReads, in the Delivery domain's SDK, is the one rule both holds judge with; mesh-sdk is not changed. ace's engine states no seat, so the want of the node-engine's own build 0bd8cbad (needing mesh-catalog) and of restic (needing node-backup) were held on ace: the engine that would state them could not be delivered. The indexer's hold judges only mesh-wide reads today, so its verdicts do not change; it takes the rule so the two stay one. Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5 --- .../internal/assign/wants.go | 5 ++- .../internal/assign/wants_test.go | 9 +++++ .../internal/index/holds.go | 4 +- .../internal/index/holds_test.go | 6 +++ sdk/graph/graph_test.go | 19 ++++++++++ sdk/graph/reads.go | 37 +++++++++++++++++++ 6 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 sdk/graph/reads.go diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 650b2157..6e7845cd 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -186,14 +186,15 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) { } // readerLacking says why the module's want on node is held for the node's engine (ADR 0614 §4): a reader capability its -// manifest needs that the engine does not state, read as the baseline when it states none. It is held under rule 7 (the +// manifest needs that the engine does not state, read as the baseline when it states none, a bootstrapped seat counting as +// read by every engine (graph.MissingReads, ADR 0636 §2). It is held under rule 7 (the // composition), since no rule of its own is numbered in this release, with words naming the capability; it moves once the // engine states it. The node-engine's own new build is held only by a word of its own manifest no engine states. // // A build's reference forms hold the want only when the engine reads none of them (ADR 0624 §2): every engine reads a // first form, so a build written in one is never held by its forms; the words name the forms it reads none of. func (m Module) readerLacking(node string, n Nodes) (string, bool) { - missing := catalogue.MissingManifestReads(m.Needs, n.Reads[node]) + missing := graph.MissingReads(m.Needs, n.Reads[node]) if len(missing) == 0 && forms.Current.Held(m.FormNeeds, n.Reads[node]) { missing = slices.Clone(m.FormNeeds) } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 4707f6d7..7dc925fc 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -138,6 +138,15 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false, nil, ""}, {"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false, nil, ""}, {"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false, nil, ""}, + // Issue 1966: g14's engine states its words and forms but no seat (its statement made before its seat list was in + // use); needs of the bootstrapped seats mesh-catalog and node-backup move all the same (ADR 0636 §2), one of a seat + // not bootstrapped is held. + {"mesh-catalog on an engine stating no seat", "mesh-host", []string{"seat:mesh-catalog", "word:module"}, + map[string][]string{"g14": {"word:module"}}, false, nil, ""}, + {"node-backup on an engine stating no seat", "restic", []string{"seat:node-backup", "word:module"}, + map[string][]string{"g14": {"word:module"}}, false, nil, ""}, + {"a seat not bootstrapped on an engine stating no seat: held, naming it", "mail", []string{"seat:mesh-vault", "word:module"}, + map[string][]string{"g14": {"word:module"}}, true, nil, "seat:mesh-vault"}, // ADR 0624 §2, with form:new a form no engine reads unless it states it and artifact-store the first form. {"g14 back stating {old}, a build carrying {old,new}: moves", "mail", nil, map[string][]string{"g14": {"word:module"}}, false, []string{"form:artifact-store", "form:new"}, ""}, diff --git a/modules/mesh-delivery-indexer/internal/index/holds.go b/modules/mesh-delivery-indexer/internal/index/holds.go index 6fe11414..3847a1ec 100644 --- a/modules/mesh-delivery-indexer/internal/index/holds.go +++ b/modules/mesh-delivery-indexer/internal/index/holds.go @@ -14,6 +14,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-delivery/sdk/trunk" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/process" @@ -129,6 +130,7 @@ func meshWide(needs []string) []string { const noneCounted = "no node is counted yet (the assignments is not read), so the hold stays until it is" // lacking is, of the counted nodes, each whose engine lacks some of needs, with what it lacks; or why no node is counted. +// It judges as the assigner does (graph.MissingReads): a bootstrapped seat is read by every engine. func (ix *Indexer) lacking(needs []string) (map[string][]string, string) { counted, known := ix.Counted() if !known || len(counted) == 0 { @@ -136,7 +138,7 @@ func (ix *Indexer) lacking(needs []string) (map[string][]string, string) { } out := map[string][]string{} for node, stated := range counted { - if miss := catalogue.MissingManifestReads(needs, stated); len(miss) > 0 { + if miss := graph.MissingReads(needs, stated); len(miss) > 0 { out[node] = miss } } diff --git a/modules/mesh-delivery-indexer/internal/index/holds_test.go b/modules/mesh-delivery-indexer/internal/index/holds_test.go index 6c5bdc13..5466786f 100644 --- a/modules/mesh-delivery-indexer/internal/index/holds_test.go +++ b/modules/mesh-delivery-indexer/internal/index/holds_test.go @@ -112,4 +112,10 @@ func storeHoldersNewManifestWaitsForEveryCountedEngine(t *testing.T) { t.Fatalf("once every counted engine states form:seat-reach modules.distribution = manifest %s commit %s, %d holds, finding %s, bounds %v; want the new entry and none", m.Manifest, m.Commit, len(holds.vals), findings.vals[heldKey], bounds) } + // Issue 1966: the hold judges as the assigner does (graph.MissingReads): a bootstrapped seat is read by every engine, + // so one whose statement names no seat lacks none of it (ADR 0636 §2). + stated = map[string][]string{"n1": {"word:module"}} + if l, why := ix.lacking([]string{"seat:mesh-catalog", "form:seat-reach"}); why != "" || len(l) != 1 || !slices.Equal(l["n1"], []string{"form:seat-reach"}) { + t.Fatalf("an engine stating no seat lacks %v (%s), want form:seat-reach alone", l, why) + } } diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go index 71bef6ce..54379bba 100644 --- a/sdk/graph/graph_test.go +++ b/sdk/graph/graph_test.go @@ -18,6 +18,25 @@ func TestGraphEdges(t *testing.T) { t.Run("a node's value", nodeValues) t.Run("a reader of the seat list waits for its holder", readersOfTheSeatList) t.Run("a verb is served by a running healthy have of the node-engine contract", servedByHaves) + // ADR 0636 §2, issue 1966: a need of a bootstrapped seat is read by every engine whatever its statement names; a seat + // not bootstrapped and a word stay missing, and an engine stating none is read as the baseline. + t.Run("a bootstrapped seat is read by every engine", func(t *testing.T) { + stated := []string{"word:claims", "word:invokes"} + for _, c := range []struct { + name string + needs, stated []string + want []string + }{ + {"mesh-catalog and node-backup", []string{"seat:mesh-catalog", "seat:node-backup", "word:invokes"}, stated, nil}, + {"a seat not bootstrapped", []string{"seat:mesh-catalog", "seat:mesh-vault"}, stated, []string{"seat:mesh-vault"}}, + {"a word", []string{"seat:node-engine", "word:zone"}, stated, []string{"word:zone"}}, + {"the baseline", []string{"seat:mesh-catalog", "form:seat-reach"}, nil, []string{"form:seat-reach"}}, + } { + if got := MissingReads(c.needs, c.stated); !slices.Equal(got, c.want) { + t.Errorf("%s: missing %v, want %v", c.name, got, c.want) + } + } + }) fp := "src2:" + strings.Repeat("b", 64) node := func(repository, path string, edit func(*Node)) Node { n := Node{FP: fp, Repository: repository, Path: path} diff --git a/sdk/graph/reads.go b/sdk/graph/reads.go new file mode 100644 index 00000000..1ac59974 --- /dev/null +++ b/sdk/graph/reads.go @@ -0,0 +1,37 @@ +package graph + +import ( + "slices" + "strings" + "sync" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" +) + +// bootstrappedReads are the reader capabilities `seat:` of the bootstrapped seats: the embedded files' own, so +// computed once. +var bootstrappedReads = sync.OnceValue(func() []string { + var out []string + for _, d := range seatlist.Bootstrapped() { + out = append(out, catalogue.ReadSeat+d.Seat) + } + return out +}) + +// MissingReads is catalogue.MissingManifestReads with every need of a bootstrapped seat counted as read (novox/hq ADR 0614 +// §4, ADR 0636 §2): every reader of the seat list holds the bootstrapped seats from its start, before it has read the list, +// so a manifest naming one is read by every node-engine whatever its statement names. Without it an engine whose statement +// was made before its list was in use states no seat, and the node-engine's own build naming mesh-catalog is held on the +// very engine that would state it (issue 1966). The assigner's capability hold and the indexer's hold both judge with it, +// so the rule is one. +func MissingReads(needs, stated []string) []string { + boot := bootstrappedReads() + missing := slices.DeleteFunc(catalogue.MissingManifestReads(needs, stated), func(n string) bool { + return strings.HasPrefix(n, catalogue.ReadSeat) && slices.Contains(boot, n) + }) + if len(missing) == 0 { + return nil + } + return missing +}