From 509d277b329d858f8684d74538b8a6604de316e5 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:24:39 +0100 Subject: [PATCH] =?UTF-8?q?The=20assigner=20holds=20a=20node's=20want=20wh?= =?UTF-8?q?ile=20that=20node's=20engine=20does=20not=20state=20a=20reader?= =?UTF-8?q?=20capability=20the=20manifest=20needs,=20and=20moves=20it=20on?= =?UTF-8?q?ce=20it=20does=20(ADR=200614=20=C2=A74,=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rules read Module.Needs from the indexer's modules value and each node's stated reads from its record in `.node` of the haves; an engine stating none is read as the baseline, so the node-engine's own new build, whose manifest uses only the baseline's words, is not held. The hold is rule 7 (no rule of its own is numbered in this release, ADR 0614 F2) with words naming the node's engine and each capability, which the want tools and view_tiers already show. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-delivery-assigner/pass.go | 2 +- .../cmd/mesh-delivery-assigner/pass_test.go | 17 +++++++++ .../cmd/mesh-delivery-assigner/world.go | 12 +++++- .../internal/assign/wants.go | 22 +++++++++++ .../internal/assign/wants_test.go | 38 +++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index 73e1b9c4..ed31fc50 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -158,7 +158,7 @@ func (p *passer) wants(ctx context.Context) { for name := range names { fp := trunk[name].FP2 name := name - m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], + m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], Self: func(node string) bool { return placed[node] }, Composed: func(node, fp string) (delivery.ComposedDigest, bool) { return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index 79baa718..498889bb 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -148,6 +148,23 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { t.Error("an answer to an ask taken away was taken") } t.Run("a put of the bus acts on the operator's warrant alone", putOfTheBus) + // ADR 0614 §4: the rules read what each node's engine states it reads from its record in `.node`; a node with no + // record, or one that states nothing, is read as stating nothing (the rules then read the baseline). + t.Run("the rules read what each node's engine reads", func(t *testing.T) { + w := newWorld() + for node, reads := range map[string][]string{"ace": {"word:module", "word:tool-runner"}, "g14": nil} { + raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node, ManifestReads: reads}}) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.HaveNodeKey(node) + w.put(delivery.HaveBucket, key, raw) + } + n := w.nodes(nil, nil) + if !slices.Equal(n.Reads["ace"], []string{"word:module", "word:tool-runner"}) || len(n.Reads["g14"]) != 0 || len(n.Reads["novox"]) != 0 { + t.Errorf("the nodes' reads are %v", n.Reads) + } + }) } // memEntry is a value a memKV holds, as a bucket's Get answers it. diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index 1f33e577..f37724e7 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -338,7 +338,8 @@ func (w *world) wantsOf() map[string]map[string]delivery.Want { // nodes is what the six rules read of the nodes: presence, haves, and every provision's providers with their nodes. func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[string][]string) assign.Nodes { - n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]delivery.Have{}, Providers: map[string]map[string][]string{}} + n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]delivery.Have{}, Providers: map[string]map[string][]string{}, + Reads: map[string][]string{}} for key, value := range w.read(delivery.PresenceBucket) { node, _, _, err := delivery.ParsePresenceKey(key) if err != nil || node == "" { @@ -350,7 +351,14 @@ func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[stri } for key, value := range w.read(delivery.HaveBucket) { node, module, isNode, err := delivery.ParseHaveKey(key) - if err != nil || isNode { + if err != nil { + continue + } + if isNode { + // What the node's engine reads (ADR 0614 §4); a record that states none is read as the baseline. + if h, err := delivery.DecodeHaveNode(value); err == nil && h.Record != nil { + n.Reads[node] = h.Record.ManifestReads + } continue } if h, err := delivery.DecodeHave(value); err == nil { diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index ac7cc4fa..37d8185b 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -64,6 +64,9 @@ type Module struct { Now time.Time // Puts are the puts of the module, by node (ADR 0546): while one owns a node's want (PutOwns), the rules pass over it. Puts map[string]delivery.Put + // Needs are the reader capabilities the manifest at FP needs (the indexer's modules value, catalogue.ManifestNeeds, ADR + // 0614 §4): a node's want moves only while that node's engine states each of them. Nil: none. + Needs []string // Self says whether node composes the module itself: the catalogue's placement names the node (ADR 0547 §2). It is the one // source of a want's Self: every want the rules write or keep is derived from it (selfDerived), so a want written Self while // the node was placed does not outlive the placement (issue 1126). Nil: no node does. @@ -85,6 +88,9 @@ type Nodes struct { Haves map[string]map[string]delivery.Have // Providers is, by provision, the modules that provide it, with the nodes each is assigned to. Providers map[string]map[string][]string + // Reads is, by node, the reader capabilities its node-engine states in its node record (delivery.NodeRecord.ManifestReads, + // ADR 0614 §4). A node with none, or none read, is read as stating the baseline (catalogue.StatedManifestReads). + Reads map[string][]string } // healthy says whether a have runs fp healthy. @@ -138,6 +144,9 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) { return false, delivery.RuleGrant, fmt.Sprintf("the controller has not issued the credential of %s on %s for %s", m.Name, node, short(m.FP)) } + if words, lacking := m.readerLacking(node, n); lacking { + return false, delivery.RuleComposed, words + } if _, ok := m.composition(node, m.FP); !ok { return false, delivery.RuleComposed, fmt.Sprintf("%s has named no composition of its own for %s at %s yet", node, m.Name, short(m.FP)) } @@ -147,6 +156,19 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) { return true, 0, "" } +// 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 +// 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. +func (m Module) readerLacking(node string, n Nodes) (string, bool) { + missing := catalogue.MissingManifestReads(m.Needs, n.Reads[node]) + if len(missing) == 0 { + return "", false + } + return fmt.Sprintf("the node-engine of %s does not read %s, which the manifest of %s at %s needs: the want moves once that engine states it", + node, strings.Join(missing, ", "), m.Name, short(m.FP)), true +} + // provisionWaiting says why the module's want on node waits for a provision (ADR 0570 §4): a provision it requires that has // no key yet, or the first of its keys that is not provided. Nil Provisions (the seat's state not on the bus) waits for none. func (m Module) provisionWaiting(node string) (string, bool) { diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 39972c46..b2e4b4b7 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -104,6 +104,44 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { t.Errorf("provided provisions held g14's want: %+v (%s)", out.Wants["g14"], out.Rules["g14"].Words) } } + // ADR 0614 §4: a want is held on a node whose engine does not state a reader capability the manifest needs, under rule 7 + // with the capability as its words, and moves once the engine states it; an engine stating nothing reads the baseline, + // so a manifest of the baseline's words, the node-engine's own new build among them, is not held. + hostNeeds, err := catalogue.ManifestNeeds([]byte(`{"module":"mesh-host","version":"1","slug":"host","build":{"artifacts":[` + + `{"name":"host-arch","kind":"bundle","language":"go","system":"arch","from":"cmd/mesh-host"}]},"consumes":["restic.backed-up"],` + + `"state":[{"name":"health"}],"config":{"x":{"type":"number","minimum":1,"default":50,"runtime":true}},` + + `"resources":[{"id":"k","type":"file","path":"/var/lib/k","content":"${config:x}\n"}]}`)) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + why string + name string + needs []string + reads map[string][]string + held bool + }{ + {"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true}, + {"the same, once both engines state it", "mail", []string{"word:module", "word:tool-runner"}, + map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false}, + {"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false}, + {"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false}, + } { + m, n := ready() + m.Name, m.Needs, n.Reads = c.name, c.needs, c.reads + n.Haves["ace"][c.name], n.Haves["g14"][c.name] = n.Haves["ace"]["mail"], n.Haves["g14"]["mail"] + out := Wants(m, n, wantedOld(), nil) + if moved := out.Wants["g14"].FP == fNew; moved == c.held { + t.Errorf("%s: g14's want %+v, held by %+v", c.why, out.Wants["g14"], out.Rules["g14"]) + } + if r := out.Rules["g14"]; c.held && (r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, "word:tool-runner") || + !strings.Contains(r.Words, "node-engine of g14")) { + t.Errorf("%s: held by %+v, want rule 7 naming the capability", c.why, r) + } + if w := out.Wants["g14"]; c.held && (w.Rule != delivery.RuleComposed || w.Newest != fNew) { + t.Errorf("%s: g14's want %+v, want it kept at what it runs and naming rule 7", c.why, w) + } + } // ADR 0603 §9: the newest source revision governs. The module now at fNew no longer requires the database an older revision // asked for and requires oidc instead; the seat's keys follow the newest manifest, so the older edge is no wait: a provided // oidc moves the want, an asked one holds it and names oidc and never database.