From 390e50d7d1b6a33f79a1aa26f4bd5a5fd4488988 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:09:20 +0100 Subject: [PATCH 01/17] Words back: the tool runner is what says tool-runner again; sdk/toolrunner goes (issue 774, issue 1585) Reverts e3ce4b0: node-tools' manifest says tool-runner again, so the judge and mesh-grants ask catalogue.IsToolRunner and the kept RuntimeModule is not counted. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- modules/mesh-delivery-indexer/judge/rules.go | 5 ++-- .../mesh-delivery-indexer/judge/rules_test.go | 20 +++----------- modules/mesh-grants/go.mod | 4 --- modules/mesh-grants/grants_test.go | 27 ++++++------------- .../mesh-grants/internal/grants/records.go | 3 +-- sdk/toolrunner/toolrunner.go | 16 ----------- 6 files changed, 15 insertions(+), 60 deletions(-) delete mode 100644 sdk/toolrunner/toolrunner.go diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index b780e482..734c5730 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -10,7 +10,6 @@ import ( json "encoding/json" errors "errors" fmt "fmt" - toolrunner "git.novox.be/novox/mesh-delivery/sdk/toolrunner" catalogue "git.novox.be/novox/mesh-sdk/go/catalogue" math "math" net "net" @@ -137,7 +136,7 @@ func spokenSystems() string { // and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here, // naming the field that would deliver it. func undeliveredBundles(m catalogue.Manifest) []string { - if m.Build == nil || toolrunner.Is(m) { + if m.Build == nil || catalogue.IsToolRunner(m) { return nil } named := runByAResource(m) @@ -3307,7 +3306,7 @@ func waitProblems(m catalogue.Manifest) []string { if len(m.Waits) == 0 { return nil } - if !toolrunner.Is(m) { + if !catalogue.IsToolRunner(m) { return []string{fmt.Sprintf("%s says what a session waits on; only the tool runner's module (it says "+ "\"tool-runner\": true) declares waits (novox/hq ADR 0367 §2)", m.Module)} } diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 32d37b9c..5e3c4325 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -163,18 +163,11 @@ func TestABundleNothingDeliversIsRefused(t *testing.T) { t.Errorf("a bundle delivered by %s was refused: %v", name, p) } } - // The tool runner is what says it is one (issue 774), and, while node-tools' trunk manifest leaves `tool-runner` out - // for the running indexer, mesh-sdk's deprecated catalogue.RuntimeModule too (issue 1585 note 42): any other name alone - // exempts nothing. - kept := base() - kept.Module = catalogue.RuntimeModule - if p := undeliveredBundles(kept); len(p) != 0 { - t.Errorf("a bundle of the kept runtime module, which does not say it is the tool runner, was refused: %v", p) - } + // The tool runner is what says it is one (issue 774): its name alone exempts nothing. named := base() - named.Module = "agent-tools" + named.Module = "node-tools" if p := undeliveredBundles(named); len(p) != 1 { - t.Errorf("a bundle of a module that does not say it is the tool runner: %v", p) + t.Errorf("a bundle of a module named as the tool runner, which does not say it is one: %v", p) } } @@ -3122,7 +3115,7 @@ func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) { func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { for manifest, want := range map[string]string{ `{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", - `{"module":"agent-tools","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774) + `{"module":"node-tools","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774) `{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge.*"]}`: "never a wildcard", `{"module":"node-tools","version":"1","tool-runner":true,"waits":["**"]}`: "never a wildcard", `{"module":"node-tools","version":"1","tool-runner":true,"waits":["mesh-knowledge.>"]}`: "never a wildcard", @@ -3141,11 +3134,6 @@ func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err) } } - // mesh-sdk's deprecated catalogue.RuntimeModule waits without saying `tool-runner`, while node-tools' trunk manifest - // leaves the word out for the running indexer (issue 774, issue 1585 note 42). - if _, err := ParseManifest([]byte(`{"module":"` + catalogue.RuntimeModule + `","version":"1","waits":["mesh-knowledge.reviewed"]}`)); err != nil { - t.Errorf("the kept runtime module's waits were refused: %v", err) - } } // 1178/AC2: a work-queue stream is read by exactly one process of its module and by no other module's consumes: one diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index bba38e07..a90cee61 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -20,7 +20,3 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.15.0 // indirect ) - -require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 - -replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index d639fee7..88deec86 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2842,38 +2842,27 @@ func TestGrants(t *testing.T) { // ADR 0601 §4: a tool runner whose manifest does not say so yet (an older build's) keeps its user as the list in force // holds it, known by what it runs (it says the runner is alive), and the held condition says so; never composed as a // plain module. - const runner = `{"module":"agent-runtime","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/nt/broker"}}` - b.manifestOf("agent-runtime", runner) - b.assign("a", "agent-runtime") - b.put(grants.ReadAccounts, "a.agent-runtime", map[string]any{"user": "a.agent-runtime", "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"}) + const runner = `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/nt/broker"}}` + b.manifestOf("node-tools", runner) + b.assign("a", "node-tools") + b.put(grants.ReadAccounts, "a.node-tools", map[string]any{"user": "a.node-tools", "hash": "$2a$10$first", "generation": 1, "fingerprint": "sha256:1"}) p.Compose() inForce, _, _ = p.Sealed() - ran, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "a.agent-runtime")) + ran, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "a.node-tools")) if !ok || !strings.Contains(string(ran), "mesh.control.a.tools-alive") { t.Fatalf("the list in force holds no tool runner on a: %s", ran) } - b.manifestOf("agent-runtime", strings.Replace(runner, `"tool-runner":true,`, "", 1)) + b.manifestOf("node-tools", strings.Replace(runner, `"tool-runner":true,`, "", 1)) b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": "$2a$10$gen10", "generation": 10, "fingerprint": "sha256:10"}) p.Compose() now, _, _ := p.Sealed() - kept, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "a.agent-runtime")) + kept, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "a.node-tools")) if !ok || string(kept) != string(ran) { t.Errorf("the tool runner of a was composed without what it runs: %s, was %s", kept, ran) } - if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "a.agent-runtime (stays in the list") { + if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "a.node-tools (stays in the list") { t.Errorf("the kept tool runner was not said: %+v", last) } - // While node-tools' trunk manifest leaves `tool-runner` out for the running indexer, mesh-sdk's deprecated - // catalogue.RuntimeModule is the tool runner without saying so (issue 774, issue 1585 note 42): composed as one. - b.manifestOf(catalogue.RuntimeModule, strings.Replace(strings.Replace(runner, `"tool-runner":true,`, "", 1), "agent-runtime", catalogue.RuntimeModule, 1)) - b.assign("c", catalogue.RuntimeModule) - b.put(grants.ReadAccounts, "c."+catalogue.RuntimeModule, map[string]any{"user": "c." + catalogue.RuntimeModule, "hash": "$2a$10$c", "generation": 1, "fingerprint": "sha256:c"}) - p.Compose() - now, _, _ = p.Sealed() - if composed, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(now.Digest, "c."+catalogue.RuntimeModule)); !ok || - !strings.Contains(string(composed), "mesh.control.c.tools-alive") { - t.Errorf("the kept runtime module on c was not composed as the tool runner: %s", composed) - } }) } diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 1213cb9e..c93bf116 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -6,7 +6,6 @@ import ( "slices" "strings" - "git.novox.be/novox/mesh-delivery/sdk/toolrunner" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-grants/internal/buscompose" @@ -133,7 +132,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio fromModules = append(fromModules, c) } d := buscompose.Declared{ - Module: m.Module, ToolRunner: toolrunner.Is(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools, + 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), Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), Checks: catalogue.HealthChecks(m), RunsAs: m.RunsAs, Issued: issuedTools(m), diff --git a/sdk/toolrunner/toolrunner.go b/sdk/toolrunner/toolrunner.go deleted file mode 100644 index b42c5f35..00000000 --- a/sdk/toolrunner/toolrunner.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package toolrunner says whether a manifest is the node's tool runner, for the parts of mesh-delivery that judge or -// compose one (the indexer's judge, mesh-grants). -package toolrunner - -import "git.novox.be/novox/mesh-sdk/go/catalogue" - -// Is says whether a manifest is the node's tool runner: it says so (`tool-runner`, catalogue.IsToolRunner), or its module -// is mesh-sdk's deprecated catalogue.RuntimeModule. -// -// The second clause is kept only while node-tools' manifest on mesh-tools' trunk does not say `tool-runner`: the running -// indexer refuses that word, so node-tools' trunk manifest leaves it out until the indexer that knows it is delivered -// (issue 774, issue 1585 note 42). It goes, with this helper's use of catalogue.RuntimeModule, once `tool-runner` is -// back on node-tools' trunk manifest; then every caller asks catalogue.IsToolRunner alone. -func Is(m catalogue.Manifest) bool { - return catalogue.IsToolRunner(m) || m.Module == catalogue.RuntimeModule -} From b83085adac6e3661a92542359d35a458a742da12 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:10:13 +0100 Subject: [PATCH 02/17] =?UTF-8?q?An=20unreadable=20manifest=20holds=20only?= =?UTF-8?q?=20its=20module's=20users:=20the=20list=20advances=20for=20ever?= =?UTF-8?q?y=20other=20principal,=20and=20the=20condition=20and=20grants?= =?UTF-8?q?=5Fuser=5Flist=20name=20the=20module=20unreadable=20(ADR=200614?= =?UTF-8?q?=20=C2=A78,=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- modules/mesh-grants/grants_test.go | 13 ++++++++++--- modules/mesh-grants/internal/grants/held.go | 8 +++++++- modules/mesh-grants/internal/grants/inputs.go | 9 +++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 88deec86..162596c9 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2822,7 +2822,8 @@ func TestGrants(t *testing.T) { t.Errorf("%s: the kept user was not said: %+v", held.name, last) } } - // A held module that runs no build the part can read declares nothing to anyone: the list in force is kept, said. + // A held module that runs no build the part can read declares nothing to anyone: only its own users stay as the list in + // force holds them, said unreadable; the others move (ADR 0614 §8). delete(b.states[grants.ReadHave], "b.mod-r") b.put(grants.ReadModules, "mod-r", delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), Manifest: delivery.ManifestDigest([]byte(ctl)), Repository: "novox/x", Commit: "c", Verdict: delivery.VerdictRefused, @@ -2831,8 +2832,14 @@ func TestGrants(t *testing.T) { wasList, _, _ := p.Sealed() res := p.Compose() nowList, _, _ := p.Sealed() - if nowList.Digest != wasList.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "running manifest of mod-r") { - t.Errorf("a held module with no running manifest did not keep the list in force: %+v", res.Sealed) + if nowList.Digest == wasList.Digest || slices.ContainsFunc(res.Errors, func(e string) bool { return strings.Contains(e, "composed whole") }) { + t.Errorf("a module with no readable manifest held the whole list: %+v", res.Sealed) + } + if kept, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(nowList.Digest, "b.mod-r.mod-r-tools")); !ok || string(kept) != string(before) { + t.Errorf("b.mod-r.mod-r-tools did not keep its grants of the list in force: %s", kept) + } + if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "the manifest of mod-r is unreadable") { + t.Errorf("the unreadable manifest was not said: %+v", last) } // Accepted again: the users compose from the manifest and the condition clears. b.manifestOf("mod-r", ctl) diff --git a/modules/mesh-grants/internal/grants/held.go b/modules/mesh-grants/internal/grants/held.go index 1a283593..d5177e59 100644 --- a/modules/mesh-grants/internal/grants/held.go +++ b/modules/mesh-grants/internal/grants/held.go @@ -47,9 +47,15 @@ func heldModuleUsers(in Inputs, candidates []string) map[string]string { continue } prefix := node + "." + module + // No manifest of the module can be read at all (the trunk's is refused and no running build's is found): named + // unreadable, in the condition and in grants_user_list (ADR 0614 §8). + state := "held" + if _, readable := in.Held[module]; !readable { + state = "unreadable" + } for _, user := range candidates { if user == prefix || strings.HasPrefix(user, prefix+".") { - out[user] = fmt.Sprintf("the manifest of %s is held: %s", module, why) + out[user] = fmt.Sprintf("the manifest of %s is %s: %s", module, state, why) } } } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 82b6a39c..839a8f86 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -446,8 +446,9 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { } in.Held = heldManifests(r, in, cache) // A held module placed on a node whose running manifest is not found declares no seat, stream or object bucket to - // anyone: composing past it would drop the grants of those who use them, so the list in force is kept (ADR 0601 - // clause 3), and the gap says which module and why. + // anyone: composing past it would drop the grants of those who use them, so the users of that module alone stay as the + // list in force holds them (held.go, named unreadable; ADR 0614 §8, ADR 0601 clause 3). The gap says which module and why, + // and holds nothing else: it is not a failed read. for _, g := range in.Refused { module, ok := strings.CutPrefix(g.Input, "manifest of ") if _, found := in.Held[module]; !ok || found { @@ -455,8 +456,8 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { } for _, assigned := range in.Assigned { if slices.Contains(assigned, module) { - in.Gaps = append(in.Gaps, Gap{Input: "running manifest of " + module, Failed: true, - Why: "its manifest is held and no running build's manifest is found, so the seats and streams it declares cannot be composed: the list in force is kept"}) + in.Gaps = append(in.Gaps, Gap{Input: "running manifest of " + module, + Why: "its manifest is unreadable (held, and no running build's manifest is found), so the seats and streams it declares cannot be composed: its users stay as the list in force holds them"}) break } } From ffdeb9771a56fffc79f01693b534d27a41091a6b Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:11:21 +0100 Subject: [PATCH 03/17] =?UTF-8?q?The=20indexer=20claims=20the=20seat=20mes?= =?UTF-8?q?h-catalog=20and=20its=20tools=20bundle=20serves=20the=20four=20?= =?UTF-8?q?verbs=20from=20its=20states=20(ADR=200604=20=C2=A72,=20issue=20?= =?UTF-8?q?774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CatalogTools moves into the index package so its row sits in the existing catalog test. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../mesh-delivery-indexer-tools/catalog.go | 29 ++----------------- .../cmd/mesh-delivery-indexer-tools/main.go | 2 +- .../internal/index/catalog.go | 25 ++++++++++++++++ .../internal/index/catalog_test.go | 13 +++++++++ .../mesh-delivery-indexer/manifest_test.go | 22 ++++++++++---- modules/mesh-delivery-indexer/module.json | 12 ++++++++ 6 files changed, 70 insertions(+), 33 deletions(-) diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go index 62df17fb..7248b5ab 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go @@ -4,7 +4,6 @@ import ( "fmt" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-delivery/sdk/graph" @@ -12,30 +11,6 @@ import ( "mesh-delivery-indexer/internal/index" ) -// CatalogTools are the seat mesh-catalog's four verbs as tools named ., with the descriptions and inputs the -// seat's contract gives them (novox/hq ADR 0604 §1, §2), answered from the indexer's own states. They are served once the -// indexer's manifest claims the seat, in the release after L3's (the lead's decision): until then main serves them not. -func CatalogTools(c index.CatalogRead) []stdio.Tool { - seat, ok := catalogue.SeatNamed(catalogue.CatalogSeat) - if !ok { - return nil - } - str := func(args map[string]any, k string) string { s, _ := args[k].(string); return s } - runs := map[string]func(map[string]any) (any, error){ - "modules": func(map[string]any) (any, error) { return c.CatalogModulesAnswer() }, - "module": func(a map[string]any) (any, error) { return c.CatalogModuleAnswer(str(a, "module")) }, - "provides": func(a map[string]any) (any, error) { return c.CatalogProvidesAnswer(str(a, "provision")) }, - "dependents": func(a map[string]any) (any, error) { return c.CatalogDependentsAnswer(str(a, "module")) }, - } - var out []stdio.Tool - for _, v := range seat.Serves { - if run, ok := runs[v.Name]; ok { - out = append(out, stdio.Tool{Name: catalogue.CatalogSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run}) - } - } - return out -} - // catalogRead reads the indexer's modules, manifests and graph states for the seat's verbs. func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead { return index.CatalogRead{ @@ -97,7 +72,7 @@ func catalogRead(modules, manifests, nodes stdio.KeptState) index.CatalogRead { } } -// catalogTools are the seat's verbs over the indexer's own states, as main serves them once the indexer claims the seat. +// catalogTools are the seat's verbs over the indexer's own states, served beside the indexer's own tools, since the indexer's manifest claims the seat (ADR 0604 §2). func catalogTools() []stdio.Tool { - return CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State))) + return index.CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(graph.State))) } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go index cfc4ff71..d9cb1aa0 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go @@ -190,7 +190,7 @@ func heldCounts(s stdio.KeptState) func() (index.OldSectionsCount, error) { } func main() { - if err := stdio.Serve("", Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState)))); err != nil { + if err := stdio.Serve("", append(Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState))), catalogTools()...)); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-indexer-tools] %v\n", err) os.Exit(1) } diff --git a/modules/mesh-delivery-indexer/internal/index/catalog.go b/modules/mesh-delivery-indexer/internal/index/catalog.go index 33807d48..c9da450b 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog.go @@ -7,6 +7,7 @@ import ( "slices" "strings" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -147,3 +148,27 @@ func (c CatalogRead) graphAndModules() (graph.Graph, map[string]delivery.Module, } return g, mods, nil } + +// CatalogTools are the seat mesh-catalog's four verbs as tools named ., with the descriptions and inputs the +// seat's contract gives them (novox/hq ADR 0604 §1, §2), answered from the indexer's own states. They are served once the +// indexer's manifest claims the seat, in the release after L3's (the lead's decision): until then main serves them not. +func CatalogTools(c CatalogRead) []stdio.Tool { + seat, ok := catalogue.SeatNamed(catalogue.CatalogSeat) + if !ok { + return nil + } + str := func(args map[string]any, k string) string { s, _ := args[k].(string); return s } + runs := map[string]func(map[string]any) (any, error){ + "modules": func(map[string]any) (any, error) { return c.CatalogModulesAnswer() }, + "module": func(a map[string]any) (any, error) { return c.CatalogModuleAnswer(str(a, "module")) }, + "provides": func(a map[string]any) (any, error) { return c.CatalogProvidesAnswer(str(a, "provision")) }, + "dependents": func(a map[string]any) (any, error) { return c.CatalogDependentsAnswer(str(a, "module")) }, + } + var out []stdio.Tool + for _, v := range seat.Serves { + if run, ok := runs[v.Name]; ok { + out = append(out, stdio.Tool{Name: catalogue.CatalogSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run}) + } + } + return out +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_test.go index 03c0b68c..dead2776 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_test.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_test.go @@ -93,6 +93,19 @@ func catalogVerbsAnswerFromTheStates(t *testing.T) { } return true } + // The tools bundle serves the four verbs as mesh-catalog., answering from the same states. + var names []string + for _, tool := range CatalogTools(c) { + names = append(names, tool.Name) + if tool.Name == "mesh-catalog.modules" { + if got, err := tool.Run(nil); err != nil || !strings.Contains(string(answer(got, nil)), `"module":"avahi"`) { + t.Errorf("mesh-catalog.modules answered %v, %v", got, err) + } + } + } + if !slices.Equal(names, []string{"mesh-catalog.modules", "mesh-catalog.module", "mesh-catalog.provides", "mesh-catalog.dependents"}) { + t.Errorf("the tools bundle serves %v", names) + } raw := answer(c.CatalogModulesAnswer()) if got, want := keysAt(t, raw, "modules"), capturedKeys(t, "catalog_modules.json", "modules"); !contains(got, want) { t.Errorf("modules' fields %v, want the old catalog's %v", got, want) diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index 7569b44c..4911041b 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-delivery/sdk/graph" @@ -38,9 +39,14 @@ type artifact struct { } type manifest struct { - Module string `json:"module"` - Version string `json:"version"` - Slug string `json:"slug"` + Module string `json:"module"` + Version string `json:"version"` + Slug string `json:"slug"` + Claims []struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + } `json:"claims"` Tools []string `json:"tools"` Emits []string `json:"emits"` Consumes []string `json:"consumes"` @@ -122,8 +128,14 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build if want := []string{"indexer_take_in", "indexer_old_sections", "indexer_verdicts", "indexer_judge"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } - // No streams, no bus, no seat (ADR 0437 §4); it says no event. - for _, k := range []string{"streams", "logs", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} { + // ADR 0604 §2: it holds the seat mesh-catalog, serving its four verbs from its tools bundle (which stays without a + // request subject). + if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.CatalogSeat || m.Claims[0].Scope != catalogue.ScopeMesh || + !slices.Equal(m.Claims[0].Serves, []string{"modules", "module", "provides", "dependents"}) { + t.Errorf("claims = %+v, want mesh-catalog with its four verbs", m.Claims) + } + // No streams, no bus, no other seat (ADR 0437 §4); it says no event. + for _, k := range []string{"streams", "logs", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} { if _, ok := top[k]; ok { t.Errorf("the manifest declares %q: the indexer has none", k) } diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index 11fac677..3fa9e8a7 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -2,6 +2,18 @@ "module": "mesh-delivery-indexer", "version": "1", "slug": "indexer", + "claims": [ + { + "name": "mesh-catalog", + "scope": "mesh", + "serves": [ + "modules", + "module", + "provides", + "dependents" + ] + } + ], "tools": [ "indexer_take_in", "indexer_old_sections", From 477b3bfea62d487566ddbee99f8e070d7391f392 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:22:08 +0100 Subject: [PATCH 04/17] =?UTF-8?q?The=20indexer's=20judge=20names,=20with?= =?UTF-8?q?=20each=20verdict,=20the=20reader=20capabilities=20a=20manifest?= =?UTF-8?q?'s=20own=20words,=20forms=20and=20seat=20names=20need,=20in=20i?= =?UTF-8?q?ts=20module=20value=20(ADR=200614=20=C2=A74,=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module.Needs is catalogue.ManifestNeeds of the manifest held: written by the merge that takes a changed manifest in, so no reader takes it as needing nothing before the judge's pass, named again by the judge with the verdict, and carried over by a write that leaves the digest, as the verdict is. The module value is the one the assigner and the checker already read. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../internal/index/modules.go | 4 ++++ .../internal/index/modules_test.go | 6 ++++++ .../internal/index/toolchains.go | 4 +++- .../internal/index/verdicts.go | 14 +++++++++----- .../internal/index/verdicts_test.go | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/modules/mesh-delivery-indexer/internal/index/modules.go b/modules/mesh-delivery-indexer/internal/index/modules.go index fece3567..241bd88e 100644 --- a/modules/mesh-delivery-indexer/internal/index/modules.go +++ b/modules/mesh-delivery-indexer/internal/index/modules.go @@ -7,6 +7,7 @@ import ( "slices" "strings" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-indexer/internal/fingerprint" @@ -95,6 +96,9 @@ func (ix *Indexer) applyMoving(ctx context.Context, f delivery.Forge, drop bool, } dirOf[name] = dir next := delivery.Module{Repository: f.Repository, Commit: f.Merge, Src: ix.srcOf(f.Repository, f.Merge), Manifest: digests[dir], Path: pathOf(dir)} + // The reader capabilities a changed manifest needs are written with it, before the judge's pass names them with its + // verdict, so no reader takes a new manifest as needing nothing (ADR 0614 §4). + next.Needs, _ = catalogue.ManifestNeeds([]byte(raw)) if next.Manifest == "" { next.None2 = "the manifest cannot be read: it cannot be kept as a value" } else { diff --git a/modules/mesh-delivery-indexer/internal/index/modules_test.go b/modules/mesh-delivery-indexer/internal/index/modules_test.go index b7c32974..294e2ff5 100644 --- a/modules/mesh-delivery-indexer/internal/index/modules_test.go +++ b/modules/mesh-delivery-indexer/internal/index/modules_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" ) @@ -413,6 +414,11 @@ func TestAMergeAddingAModuleDirectoryWritesItsValueWithNoBuildRecordAnywhere(t * if m.FP2 == "" && m.None2 == "" { t.Errorf("module = %+v, want a new-form fingerprint or its reason", m) } + // ADR 0614 §4: the merge writes the reader capabilities its manifest needs, so no reader takes it as needing nothing + // before the judge's pass. + if want, _ := catalogue.ManifestNeeds([]byte(avahi)); len(m.Needs) == 0 || !slices.Equal(m.Needs, want) { + t.Errorf("module needs %v, want %v", m.Needs, want) + } } func TestAMergeWritesOnlyTheModulesItsManifestsName(t *testing.T) { // I1 diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains.go b/modules/mesh-delivery-indexer/internal/index/toolchains.go index 2b13d2f7..8218c5f7 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains.go @@ -365,8 +365,9 @@ func (ix *Indexer) writeOverlay(ctx context.Context, overlay, finals map[string] held := found && err == nil if held && cur.Manifest == next.Manifest { // The judge's verdict stands while the digest does (ADR 0578 §3, ADR 0579 §1): a recompute never writes a - // refused or outgrown module as accepted; only the judge changes it. + // refused or outgrown module as accepted; only the judge changes it. So do the reader capabilities it named. next.Verdict, next.Problems, next.Unknown, next.Warnings = cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings + next.Needs = cur.Needs } // The source revision at which FP2 last changed (ADR 0601 §1): this merge's when it moved it (0 until the // source revision naming the merge is heard, which stamps it), else the one held. @@ -401,6 +402,7 @@ func sameButWhere(cur, next delivery.Module) bool { next = withV(next) next.Commit, next.Src, next.FPSrc = cur.Commit, cur.Src, cur.FPSrc next.Verdict, next.Problems, next.Unknown, next.Warnings = cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings + next.Needs = cur.Needs return reflect.DeepEqual(cur, next) } diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts.go b/modules/mesh-delivery-indexer/internal/index/verdicts.go index 6d79ec5a..50b72e2d 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts.go @@ -160,7 +160,9 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, } } } - if err := ix.carryVerdict(ctx, name, digests[name], v); err != nil { + // The reader capabilities the manifest's own words, forms and seat names need, named with the verdict (ADR 0614 §4). + needs, _ := catalogue.ManifestNeeds(raws[name]) + if err := ix.carryVerdict(ctx, name, digests[name], v, needs); err != nil { ix.logf("judge: modules.%s does not carry its verdict: %v", name, err) } } @@ -257,9 +259,10 @@ func (ix *Indexer) ownBuildIsNewest(held map[string]delivery.Module) bool { } // carryVerdict puts a refusal into the module's value, and takes one out once the manifest is accepted, while the value -// still names the digest judged; an accepted module's value carries nothing, so judging writes no value of a module the +// still names the digest judged, and the reader capabilities the manifest needs beside it (needs, ADR 0614 §4); an +// accepted module's value carries no verdict, so judging writes no value of a module the // judge accepts (a reader takes a value without a verdict as accepted, delivery.TakeManifestVerdict). -func (ix *Indexer) carryVerdict(ctx context.Context, name, digest string, v delivery.ManifestVerdict) error { +func (ix *Indexer) carryVerdict(ctx context.Context, name, digest string, v delivery.ManifestVerdict, needs []string) error { key, err := delivery.ModulesKey(name) if err != nil { return err @@ -280,10 +283,11 @@ func (ix *Indexer) carryVerdict(ctx context.Context, name, digest string, v deli case v.Refused(): state = delivery.VerdictRefused } - if cur.Verdict == state && slices.Equal(cur.Problems, problems) && slices.Equal(cur.Unknown, v.Unknown) && slices.Equal(cur.Warnings, v.Warnings) { + if cur.Verdict == state && slices.Equal(cur.Problems, problems) && slices.Equal(cur.Unknown, v.Unknown) && slices.Equal(cur.Warnings, v.Warnings) && + slices.Equal(cur.Needs, needs) { return nil, false, nil } - cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings = state, problems, v.Unknown, v.Warnings + cur.Verdict, cur.Problems, cur.Unknown, cur.Warnings, cur.Needs = state, problems, v.Unknown, v.Warnings, needs if state == "" { cur.Problems = nil } diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index 051fe168..c19f5143 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "slices" "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -113,6 +115,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { // A merge's write of a module whose digest it leaves as it is carries the verdict over, and nothing is fresh; a changed // digest is fresh, judged in full (review R1, R2). w := &mergeWrite{} + oldNeeds := module("old").Needs same := delivery.Module{FP2: "src2:" + strings.Repeat("c", 64), Manifest: changed, Repository: "novox/mesh-catalog", Commit: "c2"} if err := ix.writeOverlay(ctx, map[string]delivery.Module{"old": same}, nil, w); err != nil { t.Fatal(err) @@ -120,6 +123,9 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { if m := module("old"); m.Verdict != delivery.VerdictRefused || len(m.Problems) == 0 || len(w.fresh) != 0 { t.Fatalf("a merge rewrote a refused module as %+v, fresh %v", m, w.fresh) } + if got := module("old").Needs; len(oldNeeds) == 0 || !slices.Equal(got, oldNeeds) { + t.Fatalf("a merge leaving the digest dropped the reader capabilities the judge named: %v, held %v", got, oldNeeds) + } other := strings.Repeat("d", 64) same.Manifest = other if err := ix.writeOverlay(ctx, map[string]delivery.Module{"old": same}, nil, w); err != nil || !w.fresh[other] { @@ -137,6 +143,15 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { t.Fatal(err) } } + // ADR 0614 §4: the judge names, with the verdict, the reader capabilities the manifest's own words need: the bus-state + // words, which an engine stating nothing (the baseline) lacks; a manifest of the baseline's words lacks none. + if needs := module("moved").Needs; !slices.Contains(needs, "word:bus-state.storage") || + !slices.Contains(catalogue.MissingManifestReads(needs, nil), "word:bus-state") { + t.Fatalf("modules.moved needs %v", needs) + } + if needs := module("good").Needs; !slices.Contains(needs, "word:module") || len(catalogue.MissingManifestReads(needs, nil)) != 0 { + t.Fatalf("modules.good needs %v", needs) + } if len(accepted) != 1 || accepted[0].Module != "moved" || accepted[0].Digest != moved || accepted[0].Judge != "judge-2" || len(accepted[0].BusState) != 2 || accepted[0].BusState[0].Name != "cache" || accepted[0].BusState[1].Template != "log" || string(accepted[0].BusState[0].Options["history"]) != "8" { From 509d277b329d858f8684d74538b8a6604de316e5 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:24:39 +0100 Subject: [PATCH 05/17] =?UTF-8?q?The=20assigner=20holds=20a=20node's=20wan?= =?UTF-8?q?t=20while=20that=20node's=20engine=20does=20not=20state=20a=20r?= =?UTF-8?q?eader=20capability=20the=20manifest=20needs,=20and=20moves=20it?= =?UTF-8?q?=20once=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. From df05b1a5f7964451f4c059c89670069494a4f1b2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:14:22 +0100 Subject: [PATCH 06/17] =?UTF-8?q?The=20module=20graph=20carries=20verb=20a?= =?UTF-8?q?nd=20bus-state=20edges,=20and=20leaves=20out=20every=20verb=20e?= =?UTF-8?q?dge=20on=20a=20cycle=20(ADR=200614=20=C2=A72,=20=C2=A73,=20?= =?UTF-8?q?=C2=A75,=20=C2=A76;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A graph node now names what its manifest invokes and serves, and the verb edges the graph leaves out; a bus-state entry is a requirement of bus-state. Before and Cycle count the kept verb edges beside bases and awaited provisions, never a package or toolchain edge. LeaveOut leaves out every verb edge whose ends reach each other, so the builder's remove and a holder's builder_versions hold nothing and the check never refuses them. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- sdk/graph/graph.go | 235 +++++++++++++++++++++++++++++++++++++--- sdk/graph/graph_test.go | 74 +++++++++++-- 2 files changed, 286 insertions(+), 23 deletions(-) diff --git a/sdk/graph/graph.go b/sdk/graph/graph.go index b0c7951c..4af2b677 100644 --- a/sdk/graph/graph.go +++ b/sdk/graph/graph.go @@ -91,6 +91,10 @@ func ParseKey(key string) (string, error) { // build module and the toolchain it was computed with. Provides are the provisions its manifest offers (its own name is // provided too and is not listed), Requires what it requires and does not require optionally, and Awaits those of // Requires a want of it waits for (catalogue.Awaited, ADR 0570 §4): the provision edges that order a source revision. +// A bus-state entry of its manifest is a requirement of the provision BusState (ADR 0614 §5). Invokes are the seat verbs +// (`seat:.`) and module tools (`.`) its manifest and its own bus accounts invoke, Serves those +// its claims serve and its own tools answer, in the same two forms (ADR 0614 §2), and LeftOut those of Invokes whose +// verb edge lies on a cycle and is left out of the graph, so no want waits on it (§3; LeaveOut works them out). // Every list is sorted and each entry once; an encode sorts them. type Node struct { V int `json:"v"` @@ -104,8 +108,15 @@ type Node struct { Provides []string `json:"provides,omitempty"` Requires []string `json:"requires,omitempty"` Awaits []string `json:"awaits,omitempty"` + Invokes []string `json:"invokes,omitempty"` + Serves []string `json:"serves,omitempty"` + LeftOut []string `json:"left-out,omitempty"` } +// BusState is the provision a bus-state entry of a manifest requires (ADR 0614 §5): the bus module's build that offers it +// goes before the module that asks bus state. +const BusState = "bus-state" + // Toolchain is a build edge of one language: the build module that builds it (Module) and the toolchain version // and recipe version its fingerprint names. type Toolchain struct { @@ -152,9 +163,30 @@ func (g Node) check() error { return fmt.Errorf("graph: the graph node awaits %q, which it does not require", a) } } + for _, list := range [][]string{g.Invokes, g.Serves} { + for _, v := range list { + if !isVerb(v) { + return fmt.Errorf("graph: the graph node names %q, which is neither seat:. nor .", v) + } + } + } + for _, l := range g.LeftOut { + if !slices.Contains(g.Invokes, l) { + return fmt.Errorf("graph: the graph node leaves out %q, which it does not invoke", l) + } + } return nil } +// isVerb says whether v is a seat verb `seat:.` or a module tool `.`. +func isVerb(v string) bool { + if _, _, ok := catalogue.SeatInvoke(v); ok { + return true + } + module, tool, ok := strings.Cut(v, ".") + return ok && module != "" && tool != "" && !strings.HasPrefix(v, "seat:") +} + func sortedOnce(in []string) []string { if len(in) == 0 { return nil @@ -167,6 +199,7 @@ func sortedOnce(in []string) []string { func (g Node) normal() Node { g.Bases, g.Packages = sortedOnce(g.Bases), sortedOnce(g.Packages) g.Provides, g.Requires, g.Awaits = sortedOnce(g.Provides), sortedOnce(g.Requires), sortedOnce(g.Awaits) + g.Invokes, g.Serves, g.LeftOut = sortedOnce(g.Invokes), sortedOnce(g.Serves), sortedOnce(g.LeftOut) if len(g.Toolchains) == 0 { g.Toolchains = nil } @@ -304,9 +337,22 @@ func (g Graph) Dependents(module string) []string { return out } -// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases and, for each provision it -// awaits, every other module that provides it (ADR 0603 §1). A toolchain or package edge orders nothing (§3). -func (g Graph) Before(name string) []string { +// Servers are the modules whose Serves names invoke (a seat verb or a module tool), sorted. +func (g Graph) Servers(invoke string) []string { + var out []string + for name, n := range g { + if slices.Contains(n.Serves, invoke) { + out = append(out, name) + } + } + slices.Sort(out) + return out +} + +// waits are the modules name waits for, sorted: its bases, for each provision it awaits every other module that provides +// it (ADR 0603 §1), and with verbs for each invoke the module serving it (ADR 0614 §2), those it leaves out (§3) left +// out unless all is set. +func (g Graph) waits(name string, verbs, all bool) []string { n := g[name] out := slices.Clone(n.Bases) for _, a := range n.Awaits { @@ -316,15 +362,101 @@ func (g Graph) Before(name string) []string { } } } + if verbs { + for _, v := range n.Invokes { + if !all && slices.Contains(n.LeftOut, v) { + continue + } + for _, s := range g.Servers(v) { + if s != name || all { + out = append(out, s) + } + } + } + } return sortedOnce(out) } -// Cycle is the first cycle of the graph's ordering edges, as the modules it passes through with the first named again -// at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges are a -// module's bases and, for each provision it awaits, every other module that provides it: modules ordered on each other -// would each wait for the other for good. A toolchain edge is no ordering edge (ADR 0603 §3: nothing waits on a build -// module), and neither is a package edge. The modules are visited in sorted order, so the same graph names the same -// cycle. +// Before are the modules name waits for, by the graph's ordering edges, sorted: its bases, for each provision it awaits +// (bus state among them, ADR 0614 §5) every other module that provides it (ADR 0603 §1), and for each verb it invokes +// and the graph keeps, every other module that serves it (ADR 0614 §2, §3). A toolchain or package edge orders nothing: +// it is a trigger, never a wait (ADR 0614 §6). +func (g Graph) Before(name string) []string { return g.waits(name, true, false) } + +// LeaveOut sets each node's LeftOut (ADR 0614 §3): every invoke whose verb edge lies on a cycle of the edges a part waits +// on (bases, awaited provisions, every verb edge), whichever module the edge starts from, a module invoking what it +// serves itself included. An edge lies on a cycle when the module it reaches reaches the module it starts from: both are +// in one strongly connected part of the graph. What is left of the graph then has no cycle through a verb edge, so the +// check's refusal (Cycle) never meets one. +func (g Graph) LeaveOut() { + part := g.parts(func(name string) []string { return g.waits(name, true, true) }) + for _, name := range slices.Sorted(maps.Keys(g)) { + n := g[name] + n.LeftOut = nil + for _, v := range n.Invokes { + for _, s := range g.Servers(v) { + if part[s] == part[name] { + n.LeftOut = append(n.LeftOut, v) + break + } + } + } + g[name] = n.normal() + } +} + +// parts numbers each module by the strongly connected part of edges it is in (Tarjan's), so two modules have one number +// when each reaches the other. +func (g Graph) parts(edges func(string) []string) map[string]int { + index, low, part := map[string]int{}, map[string]int{}, map[string]int{} + onStack := map[string]bool{} + var stack []string + next, parts := 0, 0 + var visit func(string) + visit = func(name string) { + index[name], low[name] = next, next + next++ + stack = append(stack, name) + onStack[name] = true + for _, to := range edges(name) { + if _, ours := g[to]; !ours { + continue + } + if _, seen := index[to]; !seen { + visit(to) + low[name] = min(low[name], low[to]) + } else if onStack[to] { + low[name] = min(low[name], index[to]) + } + } + if low[name] == index[name] { + for { + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + onStack[top] = false + part[top] = parts + if top == name { + break + } + } + parts++ + } + } + for _, name := range slices.Sorted(maps.Keys(g)) { + if _, seen := index[name]; !seen { + visit(name) + } + } + return part +} + +// Cycle is the first cycle of the graph's ordering edges (Before), as the modules it passes through with the first named +// again at its end (a -> b -> a), or nil for none (ADR 0603 §2: the check refuses it and names it). The ordering edges +// are what a part waits on, provisions, bus state and the verb edges the graph keeps (ADR 0614 §6), and bases, since a +// module built on another's artifact can never build in a cycle of bases: modules ordered on each other would each wait +// for the other for good. A verb edge the graph leaves out (LeaveOut) is none, and neither is a toolchain edge (ADR 0603 +// §3: nothing waits on a build module) or a package edge (ADR 0614 §6: a trigger). The modules are visited in sorted +// order, so the same graph names the same cycle. func (g Graph) Cycle() []string { edges := g.Before const ( @@ -367,9 +499,11 @@ func (g Graph) Cycle() []string { } // EdgesOf is the graph node of manifest's own edges: its bases (each module its build stands on), what it provides, -// what it requires and does not require optionally, and of those what a want of it waits for (catalogue.Awaited, as the -// assigner judges it), providers answering the manifests that provide a provision. The fingerprint, place, packages and -// toolchains are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way. +// what it requires and does not require optionally, a bus-state entry as a requirement of BusState (ADR 0614 §5), and of +// those what a want of it waits for (catalogue.Awaited, as the assigner judges it; BusState whenever a provider offers +// it), providers answering the manifests that provide a provision; and the verbs and tools it invokes and serves +// (InvokesOf, ServesOf). The fingerprint, place, packages, toolchains and the verb edges left out (LeaveOut, over the +// whole graph) are the indexer's to add; this is the part a reader of manifests alone (the checker) works out the same way. func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []catalogue.Manifest) Node { var n Node if manifest.Build != nil { @@ -388,11 +522,56 @@ func EdgesOf(manifest catalogue.Manifest, providers func(provision string) []cat } } n.Awaits = catalogue.Awaited(manifest, providers) + if len(manifest.BusState) > 0 { + n.Requires = append(n.Requires, BusState) + if len(providers(BusState)) > 0 { + n.Awaits = append(n.Awaits, BusState) + } + } + n.Invokes, n.Serves = InvokesOf(manifest), ServesOf(manifest) return n.normal() } +// InvokesOf are the seat verbs and module tools manifest invokes, its own and its processes' and tools bundles' bus +// accounts' (catalogue.Manifest.BusAccounts), sorted, each once; `*`, every tool, names no edge and is left out. +func InvokesOf(manifest catalogue.Manifest) []string { + all := slices.Clone(manifest.Invokes) + for _, a := range manifest.BusAccounts() { + all = append(all, a.Bus.Invokes...) + } + var out []string + for _, v := range all { + if isVerb(v) { + out = append(out, v) + } + } + return sortedOnce(out) +} + +// ServesOf are the seat verbs manifest's claims serve (`seat:.`, catalogue.Claim.ServesFor) and its own +// tools (`.`), sorted, each once. +func ServesOf(manifest catalogue.Manifest) []string { + var out []string + for _, c := range manifest.Claims { + for _, v := range c.ServesFor(manifest) { + out = append(out, "seat:"+c.Name+"."+v) + } + } + for _, t := range manifest.Tools { + out = append(out, manifest.Module+"."+t) + } + var verbs []string + for _, v := range out { + if isVerb(v) { + verbs = append(verbs, v) + } + } + return sortedOnce(verbs) +} + // OfManifests is the graph of these manifests' own edges (EdgesOf), by module, each provision's providers being -// the manifests among them that offer it: what the check works out for a change to find a cycle (ADR 0603 §2). +// the manifests among them that offer it, with the verb edges on a cycle left out (LeaveOut): what the check works out +// for a change to find a cycle (ADR 0603 §2, ADR 0614 §3). func OfManifests(manifests map[string]catalogue.Manifest) Graph { providers := func(provision string) []catalogue.Manifest { var out []catalogue.Manifest @@ -407,5 +586,35 @@ func OfManifests(manifests map[string]catalogue.Manifest) Graph { for name, m := range manifests { g[name] = EdgesOf(m, providers) } + g.LeaveOut() return g } + +// Served is whether a running build serves a seat verb or module tool (ADR 0614 §2, §7), counted from the holders' own +// haves as a provider is (ADR 0603 §5): every have running healthy, by node and module, whose build's manifest serves it +// (servesOf answers the verbs of a module's build at a fingerprint, ServesOf of its manifest, and whether it was read). +// A build whose manifest cannot be read counts what fallback answers of its module (the graph's Serves, so a build read +// late never holds its callers for good, or nothing), and is named in unread, sorted, so its reader says so. +func Served(haves map[string]map[string]delivery.Have, servesOf func(module, fp string) ([]string, bool), + fallback func(module string) []string) (served func(invoke string) bool, unread []string) { + set := map[string]bool{} + for _, node := range slices.Sorted(maps.Keys(haves)) { + for _, module := range slices.Sorted(maps.Keys(haves[node])) { + h := haves[node][module] + if h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthOK { + continue + } + serves, ok := servesOf(module, h.FP) + if !ok { + unread = append(unread, module) + if fallback != nil { + serves = fallback(module) + } + } + for _, v := range serves { + set[v] = true + } + } + } + return func(v string) bool { return set[v] }, slices.Compact(slices.Sorted(slices.Values(unread))) +} diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go index 2848f19c..e87a15c5 100644 --- a/sdk/graph/graph_test.go +++ b/sdk/graph/graph_test.go @@ -2,6 +2,7 @@ package graph import ( "errors" + "maps" "reflect" "slices" "strings" @@ -97,6 +98,39 @@ func TestGraphEdges(t *testing.T) { if got := mg.Cycle(); !reflect.DeepEqual(got, []string{"db", "web", "db"}) { t.Errorf("the cycle of manifests = %v", got) } + // The verb and bus-state edges (ADR 0614 §2, §3, §5, §6), from manifests alone: a caller waits for the module serving + // what it invokes, a bus-state entry requires bus-state of the bus module offering it, the builder's remove and the + // holder's builder_versions close a cycle and both are left out, read from either side, a package edge gives no order, + // and a module invoking its own tool is no wait. + builder := catalogue.Manifest{Module: "builder", Tools: []string{"builder_versions"}, Invokes: []string{"seat:artifact-store.remove"}} + store := catalogue.Manifest{Module: "store", Claims: []catalogue.Claim{{Name: "artifact-store", Scope: catalogue.ScopeMesh, + Serves: []string{"remove"}}}, Invokes: []string{"builder.builder_versions", "*"}} + verbs := map[string]catalogue.Manifest{ + "builder": builder, "store": store, + "bus": {Module: "bus", Provides: []catalogue.Offer{{Name: BusState, Scope: catalogue.ScopeMesh}}}, + "keeper": {Module: "keeper", BusState: []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}}}, + "caller": {Module: "caller", Invokes: []string{"seat:artifact-store.remove", "caller.own", "nobody.serves"}, Tools: []string{"own"}}, + } + vg := OfManifests(verbs) + for name, want := range map[string][]string{"caller": {"store"}, "keeper": {"bus"}, "builder": nil, "store": nil} { + if got := vg.Before(name); !reflect.DeepEqual(got, want) { + t.Errorf("%s waits for %v, want %v", name, got, want) + } + } + if b, s := vg["builder"], vg["store"]; !reflect.DeepEqual(b.LeftOut, []string{"seat:artifact-store.remove"}) || + !reflect.DeepEqual(s.LeftOut, []string{"builder.builder_versions"}) || + !reflect.DeepEqual(s.Serves, []string{"seat:artifact-store.remove"}) || !reflect.DeepEqual(s.Invokes, []string{"builder.builder_versions"}) { + t.Errorf("the builder's cycle is not left out from either side: %+v %+v", b, s) + } + if k := vg["keeper"]; !reflect.DeepEqual(k.Requires, []string{BusState}) || !reflect.DeepEqual(k.Awaits, []string{BusState}) { + t.Errorf("a bus-state entry is no requirement of bus-state: %+v", k) + } + if c := vg["caller"]; !reflect.DeepEqual(c.LeftOut, []string{"caller.own"}) || vg.Cycle() != nil { + t.Errorf("a module's own tool is a wait, or a left-out verb edge a cycle: %+v %v", c, vg.Cycle()) + } + if got := OfManifests(map[string]catalogue.Manifest{"keeper": verbs["keeper"]})["keeper"]; got.Awaits != nil { + t.Errorf("bus-state with no module offering it is awaited: %+v", got) + } for name, c := range map[string]struct { edit func(Graph) want []string @@ -115,6 +149,19 @@ func TestGraphEdges(t *testing.T) { w.Provides = []string{"site"} g["web"] = w }, []string{"db", "web", "db"}}, + "a package cycle orders nothing": {func(g Graph) { + l := g["lib"] + l.Packages = []string{"git.novox.be/novox/mesh-catalog/modules/app"} + g["lib"] = l + }, nil}, + "a verb edge with a provision, before LeaveOut leaves it out": {func(g Graph) { + d := g["db"] + d.Invokes = []string{"web.page"} + g["db"] = d + w := g["web"] + w.Serves = []string{"web.page"} + g["web"] = w + }, []string{"db", "web", "db"}}, "a requirement not awaited is no edge": {func(g Graph) { d := g["db"] d.Requires = []string{"site"} @@ -132,6 +179,9 @@ func TestGraphEdges(t *testing.T) { if got := h.Cycle(); !reflect.DeepEqual(got, c.want) { t.Errorf("%s: cycle = %v, want %v", name, got, c.want) } + if h.LeaveOut(); h.Cycle() != nil && slices.ContainsFunc(slices.Collect(maps.Values(h)), func(n Node) bool { return len(n.Invokes) > 0 }) { + t.Errorf("%s: a cycle through a verb edge is left once its verb edges are left out: %v", name, h.Cycle()) + } } } @@ -140,7 +190,9 @@ func sampleNode() Node { return Node{FP: "src2:" + strings.Repeat("a", 64), Repository: "novox/mesh-catalog", Path: "modules/gitea", Bases: []string{"z-base", "a-base"}, Packages: []string{"git.novox.be/novox/mesh-sdk/go"}, Toolchains: map[string]Toolchain{"go": {Module: "build-go", Version: "go1.27.1", Recipe: "3"}}, - Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"}} + Provides: []string{"forge"}, Requires: []string{"postgres-database", "dns"}, Awaits: []string{"postgres-database"}, + Invokes: []string{"seat:mesh-catalog.modules", "builder.builder_versions"}, Serves: []string{"seat:forge.pulls"}, + LeftOut: []string{"builder.builder_versions"}} } // nodeValues: a node round-trips with its lists sorted, an unknown version is refused, a node that does not hold is @@ -169,15 +221,17 @@ func nodeValues(t *testing.T) { change func(*Node) word string }{ - "both": {func(g *Node) { g.None = "why" }, "both"}, - "neither": {func(g *Node) { g.FP = "" }, "neither"}, - "an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"}, - "a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"}, - "a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"}, - "a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"}, - "a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"}, - "awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"}, - "no repository": {func(g *Node) { g.Repository = "" }, "repositor"}, + "both": {func(g *Node) { g.None = "why" }, "both"}, + "neither": {func(g *Node) { g.FP = "" }, "neither"}, + "an old form": {func(g *Node) { g.FP = "src1:x" }, "src2:"}, + "a dotted base": {func(g *Node) { g.Bases = []string{"a.b"} }, "a.b"}, + "a foreign package": {func(g *Node) { g.Packages = []string{"example.com/x"} }, "example.com/x"}, + "a toolchain of no module": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Version: "go1", Recipe: "1"}} }, "build module"}, + "a toolchain of no recipe": {func(g *Node) { g.Toolchains = map[string]Toolchain{"go": {Module: "b", Version: "go1"}} }, "recipe"}, + "awaiting what it does not require": {func(g *Node) { g.Awaits = []string{"other"} }, "other"}, + "no repository": {func(g *Node) { g.Repository = "" }, "repositor"}, + "leaving out what it does not invoke": {func(g *Node) { g.LeftOut = []string{"x.y"} }, "x.y"}, + "an invoke of no verb": {func(g *Node) { g.Invokes = []string{"seat:nothing"} }, "seat:nothing"}, } { g := sampleNode() c.change(&g) From b4bfc1455654636317d57d4ac3163d3b8812141b Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:15:22 +0100 Subject: [PATCH 07/17] =?UTF-8?q?The=20indexer=20writes=20each=20module's?= =?UTF-8?q?=20verb=20edges=20into=20the=20module=20graph,=20the=20ones=20o?= =?UTF-8?q?n=20a=20cycle=20left=20out=20over=20the=20whole=20graph=20(ADR?= =?UTF-8?q?=200614=20=C2=A72,=20=C2=A73;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assigner reads which edges are left out from the graph, so one place decides the cycle; the whole graph is judged, a module past the graph's cap included, before the first GraphMost are written. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../internal/index/graph.go | 13 ++++++++++--- .../internal/index/toolchains_test.go | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-indexer/internal/index/graph.go b/modules/mesh-delivery-indexer/internal/index/graph.go index d50caa64..be3a5d1b 100644 --- a/modules/mesh-delivery-indexer/internal/index/graph.go +++ b/modules/mesh-delivery-indexer/internal/index/graph.go @@ -15,8 +15,8 @@ import ( ) // The module graph (novox/hq ADR 0603 §2): at every recompute the indexer writes, in its state graph -// (graph.Bucket), one key per module it holds the inputs of, its build and provision edges as that recompute -// computed them, and deletes the key of a module the modules state no longer holds. A key is written only when its value +// (graph.Bucket), one key per module it holds the inputs of, its build, provision and verb edges as that recompute +// computed them (ADR 0614 §1), the verb edges on a cycle left out over the whole graph (§3), and deletes the key of a module the modules state no longer holds. A key is written only when its value // changes, so its history of 5 is five changes of the module's edges. The delivery view, the checker and the seat // mesh-catalog's verbs read it; nothing stores an order (ADR 0603 §1). @@ -89,13 +89,20 @@ func (ix *Indexer) writeGraph(ctx context.Context, edges map[string]graphEdges) } return out } + // The verb edges on a cycle are left out over the whole graph (ADR 0614 §3), every module it knows, so a module past + // GraphMost still closes the cycle it lies on. + whole := graph.Graph{} + for name, e := range edges { + whole[name] = graphNode(e, providers) + } + whole.LeaveOut() for _, name := range names { key, err := graph.Key(name) if err != nil { ix.logf("graph.%s is not written: %v", name, err) continue } - next := graphNode(edges[name], providers) + next := whole[name] err = write(ctx, ix.Graph, key, func(value []byte, found bool) ([]byte, bool, error) { if found { if cur, err := graph.Decode(value); err == nil && graph.Same(cur, next) { diff --git a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go index 2510e054..435c00c6 100644 --- a/modules/mesh-delivery-indexer/internal/index/toolchains_test.go +++ b/modules/mesh-delivery-indexer/internal/index/toolchains_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "reflect" + "slices" "strings" "testing" "time" @@ -330,6 +331,24 @@ func TestBasesAreComputedFirstAndNoFingerprintTakesTheBuilder(t *testing.T) { // t.Errorf("%s moved with a build of build-agent: fp2 = %q, want %s", name, m.FP2, fp) } } + // ADR 0614 §2, §3: the graph names what each module invokes and serves, and leaves out, over the whole graph, the + // verb edges of two modules calling each other, from either side; a third calling one of them keeps its edge. + invoking := func(name, invokes string) mod { + raw := renamed(t, "avahi", name) + return mod{name, strings.Replace(raw, `"version": "1",`, `"version": "1", "invokes": [`+invokes+`],`, 1)} + } + vx, _ := newFormIndexer(t, invoking("caller", `"callee.avahi_status"`), invoking("callee", `"caller.avahi_browse", "*"`), + invoking("third", `"callee.avahi_status"`)) + for name, want := range map[string][2][]string{ + "caller": {{"callee.avahi_status"}, {"callee.avahi_status"}}, + "callee": {{"caller.avahi_browse"}, {"caller.avahi_browse"}}, + "third": {{"callee.avahi_status"}, nil}, + } { + if g := graphOf(t, vx, name); !reflect.DeepEqual(g.Invokes, want[0]) || !reflect.DeepEqual(g.LeftOut, want[1]) || + !slices.Contains(g.Serves, name+".avahi_status") { + t.Errorf("graph.%s = %+v, want it to invoke %v and leave out %v", name, g, want[0], want[1]) + } + } } func TestABuildHeardBeforeAnyToolchainIsReadDropsNoFingerprint(t *testing.T) { // 1727 (review: a restart flapped every FP2) From c45bbfc391c8b153ba900fac064be07dd5534cb8 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:18:45 +0100 Subject: [PATCH 08/17] =?UTF-8?q?A=20want=20waits=20until=20each=20verb=20?= =?UTF-8?q?it=20invokes=20is=20served=20by=20a=20running=20build,=20read?= =?UTF-8?q?=20from=20the=20module=20graph=20(ADR=200614=20=C2=A72,=20?= =?UTF-8?q?=C2=A73;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assigner reads the indexer's module graph (a new reads line): of what a module invokes it waits only on the edges the graph keeps and another module serves, so a left-out cycle holds nothing; a verb counts as served once a running healthy build's manifest serves it. The hold is under the provisions' rule, its words naming the verb, since no reader knows a rule number of its own yet. Without the graph no want waits on a verb. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-delivery-assigner/main.go | 11 ++- .../cmd/mesh-delivery-assigner/pass.go | 6 ++ .../cmd/mesh-delivery-assigner/pass_test.go | 52 +++++++++++ .../cmd/mesh-delivery-assigner/verbs.go | 91 +++++++++++++++++++ modules/mesh-delivery-assigner/go.mod | 4 + .../internal/assign/verbs.go | 30 ++++++ .../internal/assign/wants.go | 10 ++ .../internal/assign/wants_test.go | 31 +++++++ .../mesh-delivery-assigner/manifest_test.go | 5 +- modules/mesh-delivery-assigner/module.json | 1 + 10 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go create mode 100644 modules/mesh-delivery-assigner/internal/assign/verbs.go diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index b87acaac..ec894b3b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -28,6 +28,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/provisions" + "git.novox.be/novox/mesh-delivery/sdk/graph" + "mesh-delivery-assigner/internal/assign" ) @@ -62,6 +64,7 @@ var readBuckets = []string{ provisions.ProvisionsBucket, delivery.SourceBucket, delivery.MergesBucket, + graph.Bucket, } // readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every @@ -148,6 +151,11 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err) continue } + if name == graph.Bucket && err != nil { + // The indexer's module graph (ADR 0614 §2, §3): without it no want waits for a verb it invokes until it is bound. + logf("the bucket %s cannot be opened (%v): no want waits for a verb it invokes until it is bound once the bus says it was made", name, err) + continue + } if name == delivery.PartGrantsBucket && err != nil { // The grants part's grants (ADR 0571 clause 9): without them no module counts as granted until it is bound. logf("the bucket %s cannot be opened (%v): no grant is read until it is bound once the bus says it was made", name, err) @@ -166,7 +174,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any func startsWithout(name string) bool { switch name { case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket, - delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket: + delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, graph.Bucket: return true } return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket @@ -251,6 +259,7 @@ var watched = []string{ delivery.SeatHoldersBucket, delivery.SourceBucket, delivery.MergesBucket, + graph.Bucket, } // follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after 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 ed31fc50..6170b6a2 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -155,6 +155,9 @@ func (p *passer) wants(ctx context.Context) { defer func() { p.sayNeither(neither, stale, left) }() // Where grants are read from, once for the pass: the grants part's (issue 774, ADR 0571 clause 9). grants := p.w.grantsBucket() + // The verb edges, once for the pass (ADR 0614 §2, §3). + moduleGraph := p.moduleGraph() + served := p.served(ctx, get, moduleGraph, nodes) for name := range names { fp := trunk[name].FP2 name := name @@ -192,6 +195,9 @@ func (p *passer) wants(ctx context.Context) { p.clearHeld(name, fp) } } + if served != nil { + m.Invokes, m.Served = keptInvokes(moduleGraph, name), served + } last := map[string]string{} for _, node := range m.Nodes { last[node] = p.w.lastHealthy(node, name, fp) 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 498889bb..c3486869 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 @@ -17,6 +17,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/sdk/graph" + "mesh-delivery-assigner/internal/assign" ) @@ -405,6 +407,8 @@ func putOfTheBus(t *testing.T) { func TestAModuleOnNeitherListIsRaisedOnceAndClearedOnce(t *testing.T) { // A pair only one source names is a condition of the same kind (issue 1126): folded here to hold the count. aPlacementAloneNamesWhereModulesRun(t) + // The verb edges a want waits on come from the graph and what runs (ADR 0614 §2, §3): folded here to hold the count. + aWantWaitsOnTheVerbEdgesTheGraphKeeps(t) t.Run("gathers", testThePassGathersTheModulesOnNeitherList) p := newPasser(buckets{}, time.Date(2026, 10, 25, 15, 0, 0, 0, time.UTC)) var said []asks.Condition @@ -1021,3 +1025,51 @@ func TestAnApprovedPlacementCallsNoControllerVerb(t *testing.T) { t.Fatalf("the old conditions cleared = %+v", conds) } } + +// ADR 0614 §2, §3: of what a module invokes, the pass keeps as edges the verbs the graph does not leave out and another +// module serves; a verb is served once a running healthy build's manifest (the builder's build, the indexer's manifest) +// serves it, and a build whose manifest cannot be read counts what the graph says its module serves. No graph read: no wait. +func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) { + p := newPasser(buckets{}, time.Now()) + fp := func(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) } + if p.served(context.Background(), nil, graph.Graph{}, assign.Nodes{}) != nil { + t.Error("with no graph read, a want waits on a verb") + } + for module, n := range map[string]graph.Node{ + "caller": {FP: fp("c"), Repository: "novox/caller", Invokes: []string{"seat:x.v", "loop.l", "none.t", "caller.own"}, LeftOut: []string{"loop.l"}, + Serves: []string{"caller.own"}}, + "server": {FP: fp("e"), Repository: "novox/server", Serves: []string{"seat:x.v", "seat:x.w"}}, + "loop": {FP: fp("1"), Repository: "novox/loop", Serves: []string{"loop.l"}}, + "late": {FP: fp("2"), Repository: "novox/late", Serves: []string{"late.t"}}, + } { + raw, err := graph.Encode(n) + if err != nil { + t.Fatal(err) + } + p.w.put(graph.Bucket, module, raw) + } + g := p.moduleGraph() + if got := keptInvokes(g, "caller"); !slices.Equal(got, []string{"seat:x.v"}) || keptInvokes(g, "unknown") != nil { + t.Errorf("the kept verb edges of caller = %v", got) + } + digest := strings.Repeat("d", 64) + man, _ := delivery.EncodeManifest(delivery.Manifest{Module: "server", JSON: `{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`}) + get := func(_ context.Context, key string) ([]byte, error) { + if key == digest { + return man, nil + } + return nil, errNotThere + } + build, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest}) + key, _ := delivery.BuilderBuildKey("server", fp("f")) + p.w.put(delivery.BuilderBuildsBucket, key, build) + running := delivery.Have{Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + at := func(f string) delivery.Have { h := running; h.FP = f; return h } + served := p.served(context.Background(), get, g, assign.Nodes{Haves: map[string]map[string]delivery.Have{ + "g14": {"server": at(fp("f")), "late": at(fp("2"))}}}) + for v, want := range map[string]bool{"seat:x.w": true, "seat:x.v": false, "late.t": true} { + if served(v) != want { + t.Errorf("served(%s) = %v, want %v: the running server's manifest serves w alone, late's is not read", v, served(v), want) + } + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go new file mode 100644 index 00000000..049a42e3 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "slices" + + "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-delivery/sdk/graph" + + "mesh-delivery-assigner/internal/assign" +) + +// The verb edges a want waits on (novox/hq ADR 0614 §2, §3): the module graph the indexer writes names what each module +// invokes and serves and which of its verb edges it leaves out on a cycle (one place decides the cycle); a want waits for +// each kept edge until a build serving its verb runs healthy, read from the haves, the builder's builds and the indexer's +// manifests, as a provider is counted (ADR 0603 §5). + +// moduleGraph is the module graph as the indexer last wrote it; a key that does not decode is left out, so its module +// waits on no verb. +func (p *passer) moduleGraph() graph.Graph { + g := graph.Graph{} + for key, raw := range p.w.read(graph.Bucket) { + module, err := graph.ParseKey(key) + if err != nil { + continue + } + if n, err := graph.Decode(raw); err == nil { + g[module] = n + } + } + return g +} + +// keptInvokes are the verbs of module the graph keeps as edges: each it invokes and does not leave out, that a module +// other than itself serves. A verb no module of the graph serves is no edge, so it holds nothing; nil when the graph holds +// no node of module. +func keptInvokes(g graph.Graph, module string) []string { + n, ok := g[module] + if !ok { + return nil + } + var out []string + for _, v := range n.Invokes { + if slices.Contains(n.LeftOut, v) { + continue + } + if slices.ContainsFunc(g.Servers(v), func(s string) bool { return s != module }) { + out = append(out, v) + } + } + return out +} + +// served is, for this pass, whether a build serving a verb runs healthy on some node (graph.Served): every running healthy +// have's build (the builder's builds key of its module and fingerprint) names its manifest, and the verbs that manifest +// serves are served. A healthy build whose manifest cannot be read counts what the graph says its module serves, said in +// the log, so a build read late never holds its callers for good; nil when the graph holds nothing, and no want waits on +// a verb. +func (p *passer) served(ctx context.Context, get getter, g graph.Graph, n assign.Nodes) func(string) bool { + if len(g) == 0 { + return nil + } + served, unread := graph.Served(n.Haves, func(module, fp string) ([]string, bool) { return p.servesOf(ctx, get, module, fp) }, + func(module string) []string { return g[module].Serves }) + if len(unread) > 0 { + p.logf("the manifest of the running build of %v cannot be read, so what the module graph says they serve is counted as served", unread) + } + return served +} + +// servesOf are the verbs the manifest of module's build at fp serves (graph.ServesOf), and whether it could be read. +func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([]string, bool) { + key, err := delivery.BuilderBuildKey(module, fp) + if err != nil { + return nil, false + } + raw := p.w.value(delivery.BuilderBuildsBucket, key) + if raw == nil { + return nil, false + } + b, err := delivery.DecodeBuilderBuild(raw) + if err != nil || b.Manifest == "" { + return nil, false + } + got := p.w.manifest(ctx, get, b.Manifest) + if got.err != "" { + return nil, false + } + return graph.ServesOf(got.manifest), true +} diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index d08194bd..46194575 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -20,3 +20,7 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.15.0 // indirect ) + +require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk diff --git a/modules/mesh-delivery-assigner/internal/assign/verbs.go b/modules/mesh-delivery-assigner/internal/assign/verbs.go new file mode 100644 index 00000000..9edf0d85 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/verbs.go @@ -0,0 +1,30 @@ +package assign + +import ( + "fmt" + "slices" +) + +// verbWaiting says why the module's want on node waits for a verb it invokes (novox/hq ADR 0614 §2): the first seat verb or +// module tool of Invokes (the module graph's verb edges, those it leaves out on a cycle not among them, §3) that no +// running healthy build serves. Nil Served (nothing read of what runs) waits for none, and a verb a running build serves +// already adds no wait. It holds under the provisions' rule (RuleProvisions): no reader knows a rule number of its own +// yet, so the words name the edge. +func (m Module) verbWaiting(node string) (string, bool) { + if m.Served == nil { + return "", false + } + for _, v := range slices.Sorted(slices.Values(m.Invokes)) { + if !m.Served(v) { + return VerbWaitWords(v, m.Name, node), true + } + } + return "", false +} + +// VerbWaitWords are the words of a want held for a verb it invokes (ADR 0614 §2): they open with "the verb", so a reader +// of the want's words (the delivery view) names the edge. +func VerbWaitWords(verb, module, node string) string { + return fmt.Sprintf("the verb %s %s invokes is served by no running build: its want on %s waits until a build serving it runs healthy (ADR 0614 §2)", + verb, module, node) +} diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 37d8185b..cf53a27e 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -40,6 +40,13 @@ type Module struct { // manifest the trunk holds and the modules the mesh provides itself, placed or not. Nil: not known, and no requirement // is waited on for want of an offer. Offered func(provision string) bool + // Invokes are the seat verbs and module tools the module graph says the module invokes and keeps as edges to the + // modules serving them, those it leaves out on a cycle not among them (ADR 0614 §2, §3): the want waits until each is + // served (Served). Nil: no graph node is read, and no want waits on a verb. + Invokes []string + // Served says whether a build serving a seat verb or module tool runs healthy, counted from its holder's have (ADR 0614 + // §2, as ADR 0603 §5 counts a provider's). Nil: nothing is read of what runs, and no want waits on a verb. + Served func(invoke string) bool // Chosen is a person's choice of the module's upgrade policy (the assigner's `policies`, ADR 0565 §7); nil: none. Chosen *catalogue.PersonPolicy Nodes []string @@ -153,6 +160,9 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) { if words, waiting := m.provisionWaiting(node); waiting { return false, delivery.RuleProvisions, words } + if words, waiting := m.verbWaiting(node); waiting { + return false, delivery.RuleProvisions, words + } return true, 0, "" } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index b2e4b4b7..278c9971 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -142,6 +142,37 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { t.Errorf("%s: g14's want %+v, want it kept at what it runs and naming rule 7", c.why, w) } } + // ADR 0614 §2, §3: a want waits, under the provisions' rule, until each verb it invokes is served by a running build, + // and moves once one is; a verb a running build serves already adds no wait, and an edge the graph leaves out on a cycle + // is not among Invokes, so it holds nothing; nothing read of what runs waits for none. + for _, c := range []struct { + name string + invokes []string + served []string + nilRead bool + holds string + }{ + {"a verb no running build serves", []string{"store.store_put", "seat:mesh-catalog.modules"}, []string{"store.store_put"}, false, "seat:mesh-catalog.modules"}, + {"once a build serving it runs", []string{"seat:mesh-catalog.modules"}, []string{"seat:mesh-catalog.modules"}, false, ""}, + {"an edge left out on a cycle", nil, nil, false, ""}, + {"nothing read of what runs", []string{"seat:mesh-catalog.modules"}, nil, true, ""}, + } { + m, n := ready() + m.Invokes = c.invokes + if !c.nilRead { + m.Served = func(v string) bool { return slices.Contains(c.served, v) } + } + out := Wants(m, n, wantedOld(), nil) + if c.holds == "" { + if out.Wants["g14"].FP != fNew { + t.Errorf("%s: held %+v", c.name, out.Rules["g14"]) + } + continue + } + if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != VerbWaitWords(c.holds, "mail", "g14") { + t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"]) + } + } // 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. diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index c594947e..087b34cd 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -34,8 +34,9 @@ func TestManifest(t *testing.T) { reads := slices.Clone(m.Reads) slices.Sort(reads) // ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads - // every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1). - want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", + // every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1); + // and the indexer's module graph, whose verb edges a want waits on (ADR 0614 §2, §3). + want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", "mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed", "mesh-provisioner.provisions", "node-engine.have"} if !reflect.DeepEqual(reads, want) { diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 86ee65ec..7f94deaa 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -27,6 +27,7 @@ "mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source", + "mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", From 1d9f9cfa2cbaa7fa222c595e5f423ad49b7eb84f Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:21:55 +0100 Subject: [PATCH 09/17] =?UTF-8?q?The=20check=20names=20a=20changed=20build?= =?UTF-8?q?=20module=20that=20invokes=20a=20verb=20no=20running=20build=20?= =?UTF-8?q?serves=20as=20one=20it=20cannot=20run=20(ADR=200614=20=C2=A77;?= =?UTF-8?q?=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the running builds serve is read when a changed build module is met: the node-engine's haves, the builder's build of each, its manifest. The modules it builds are composed from their manifests, as for any changed build module, and the composition's warnings name the verb. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-delivery-checker/judge.go | 60 ++++++++++++++++++- .../internal/check/build_change.go | 31 ++++++++++ .../internal/check/composition_test.go | 23 ++++++- .../internal/check/judge.go | 6 ++ 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go index 047e9ab0..d826ebee 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -17,6 +17,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-delivery/sdk/graph" + "mesh-delivery-checker/internal/check" ) @@ -95,6 +97,58 @@ func (n nodeNames) Names(ctx context.Context) ([]string, error) { return out, nil } +// runningServes says what the running builds serve (novox/hq ADR 0614 §7), read when a check asks it: every module's have +// in the node-engine's have that runs healthy, the builder's build of its fingerprint, and the verbs its manifest serves +// (graph.Served). A build whose manifest cannot be read serves nothing here, said in the log. +type runningServes struct { + have *nodeNames + builds check.BuildStates + read check.ManifestReader + logf func(format string, args ...any) +} + +func (r runningServes) Served(ctx context.Context) (func(string) bool, error) { + if r.have == nil || r.have.kv == nil || r.builds == nil || r.read == nil { + return nil, fmt.Errorf("the node-engine's have, the builder's builds or the indexer's manifests cannot be read") + } + lister, err := r.have.kv.ListKeys(ctx) + if err != nil { + return nil, err + } + haves := map[string]map[string]delivery.Have{} + for key := range lister.Keys() { + node, module, isNode, err := delivery.ParseHaveKey(key) + if err != nil || isNode { + continue + } + e, err := r.have.kv.Get(ctx, key) + if err != nil { + continue + } + if h, err := delivery.DecodeHave(e.Value()); err == nil { + if haves[node] == nil { + haves[node] = map[string]delivery.Have{} + } + haves[node][module] = h + } + } + served, unread := graph.Served(haves, func(module, fp string) ([]string, bool) { + b, found, err := r.builds.Build(ctx, module, fp) + if err != nil || !found || b.Manifest == "" { + return nil, false + } + m, err := r.read.Manifest(ctx, b.Manifest) + if err != nil { + return nil, false + } + return graph.ServesOf(m), true + }, nil) + if len(unread) > 0 && r.logf != nil { + r.logf("the manifest of the running build of %v cannot be read, so it serves nothing the check counts (ADR 0614 §7)", unread) + } + return served, nil +} + func newJudge(d judgeDeps) check.Judge { var nodes interface { Names(ctx context.Context) ([]string, error) @@ -102,8 +156,12 @@ func newJudge(d judgeDeps) check.Judge { if d.nodes != nil { nodes = d.nodes } - return &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, + ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Predefined: d.predefined, Logf: d.logf} + if d.nodes != nil && d.builds != nil && d.repos != nil { + ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf} + } + return ex } // manifestKV reads a manifest by its digest. diff --git a/modules/mesh-delivery-checker/internal/check/build_change.go b/modules/mesh-delivery-checker/internal/check/build_change.go index d22339e4..9c4ede7b 100644 --- a/modules/mesh-delivery-checker/internal/check/build_change.go +++ b/modules/mesh-delivery-checker/internal/check/build_change.go @@ -28,6 +28,9 @@ import ( // trunk's base; the base's own build is waited on. // // A module the trunk holds no record of yet is not built by the builder until it is merged, so it is not waited on either. +// +// A changed build module whose manifest invokes a seat verb or module tool no running build serves is one the check cannot +// run (ADR 0614 §7): the modules it builds are composed from their manifests, as above, and the words name the verb. // buildsOfTheCheck are the builds the check waits on before it composes (module to fingerprint) and the words for the // changed modules it composes without waiting on their build, one line each, sorted. A manifest that cannot be read leaves @@ -88,6 +91,14 @@ func (st *state) buildsOfTheCheck(ctx context.Context) (map[string]string, []str words = append(words, fmt.Sprintf("the change alters the build module %s: %s, which it builds, are built by the running %s, not the changed one, which the check cannot run, so the check does not wait on their builds; they are built again once the agreed toolchain moves (ADR 0603 §3, §10)", bm, strings.Join(built, ", "), bm)) } + if unserved := st.unservedVerbs(ctx, changed[bm]); len(unserved) > 0 { + what := "nothing it builds is in the change" + if len(built) > 0 { + what = strings.Join(built, ", ") + ", which it builds, are composed from their manifests" + } + words = append(words, fmt.Sprintf("the changed build module %s invokes %s, which no running build serves, so the check cannot run it: %s (ADR 0614 §7)", + bm, strings.Join(unserved, ", "), what)) + } } g := graph.OfManifests(changed) for _, base := range slices.Sorted(maps.Keys(changed)) { @@ -109,3 +120,23 @@ func buildsWith(m catalogue.Manifest, language string) bool { return a.Kind == catalogue.ArtifactBundle && a.Language == language }) } + +// unservedVerbs are the seat verbs and module tools m invokes (graph.InvokesOf) that no running build serves, sorted; none +// when nothing reads what runs, or what runs cannot be read (said in the log: nothing is said that was not read). +func (st *state) unservedVerbs(ctx context.Context, m catalogue.Manifest) []string { + if st.j.Serving == nil { + return nil + } + served, err := st.j.Serving.Served(ctx) + if err != nil { + st.j.logf("%s: what the running builds serve cannot be read, so no build module is named for a verb it invokes: %v", st.c.Key, err) + return nil + } + var out []string + for _, v := range graph.InvokesOf(m) { + if !served(v) { + out = append(out, v) + } + } + return out +} diff --git a/modules/mesh-delivery-checker/internal/check/composition_test.go b/modules/mesh-delivery-checker/internal/check/composition_test.go index 72ec4ecb..9ba4c332 100644 --- a/modules/mesh-delivery-checker/internal/check/composition_test.go +++ b/modules/mesh-delivery-checker/internal/check/composition_test.go @@ -127,6 +127,13 @@ func (f *fakeEnds) Ended(_ context.Context, builds map[string]string) (map[strin func (f *fakeEnds) Done(context.Context, map[string]string) bool { return false } +// fixedServing are the verbs the running builds serve. +type fixedServing []string + +func (f fixedServing) Served(context.Context) (func(string) bool, error) { + return func(v string) bool { return slices.Contains(f, v) }, nil +} + // knownRuns are the modules the trunk holds a record of (the indexer's modules): any commit. type knownRuns []string @@ -154,10 +161,17 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { builds map[string]delivery.BuilderBuild // by module, at fp; nil leaves the check without the builds' ends runs knownRuns // nil: the trunk holds every module want []string - waited string // the modules waited on, sorted - failed string // the failure's test, when the check fails on a build - tests bool // the tests fail: the check fails before it would wait + waited string // the modules waited on, sorted + failed string // the failure's test, when the check fails on a build + tests bool // the tests fail: the check fails before it would wait + served []string // what the running builds serve (ADR 0614 §7); nil reads nothing of what runs }{ + {name: "the Go build module invokes a verb no running build serves", + manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild, + Invokes: []string{"seat:package-registry.publish", "seat:artifact-store.put"}}, "web": {Module: "web", Build: goBuild}}, + builds: map[string]delivery.BuilderBuild{"mesh-build-go": done}, served: []string{"seat:artifact-store.put"}, + want: []string{"the changed build module mesh-build-go invokes seat:package-registry.publish, which no running build serves, " + + "so the check cannot run it: web, which it builds, are composed from their manifests (ADR 0614 §7)"}, waited: "mesh-build-go"}, {name: "the Go build module changes", manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild}, "web": {Module: "web", Build: goBuild}, "mail": {Module: "mail", Build: goBuild}, "docs": {Module: "docs"}}, @@ -211,6 +225,9 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { if row.runs != nil { b.ex.Runs = row.runs } + if row.served != nil { + b.ex.Serving = fixedServing(row.served) + } read, fps := fixedManifests{}, map[string]delivery.CandidateModule{} for module, m := range row.manifest { d := dig(string(rune('a' + len(read)))) diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index cb77a6e5..3480dca7 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -122,6 +122,12 @@ type Examiner struct { // Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq // ADR 0603 §2); nil works out no graph. Read ManifestReader + // Serving says, from what runs, whether a running healthy build serves a seat verb or module tool (novox/hq ADR 0614 + // §7): the node-engines' haves, the builder's build of each, its manifest (graph.Served). Nil reads nothing, and no + // changed build module is named for a verb it invokes. + Serving interface { + Served(ctx context.Context) (func(invoke string) bool, error) + } // Predefined reads the catalogue's predefined keys the indexer keeps (manifests_judge.go); nil reads none. Predefined PredefinedList // Compositions makes a check's composition (ADR 0593 §7): the engines' answers (EngineCompositions); nil leaves the From 0279c5c28842ff94330bd2a04613b6154995cf02 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:22:51 +0100 Subject: [PATCH 10/17] =?UTF-8?q?The=20check's=20cycle=20refusal=20meets?= =?UTF-8?q?=20no=20verb=20edge=20on=20a=20cycle=20and=20no=20package=20edg?= =?UTF-8?q?e,=20and=20still=20refuses=20provisions=20and=20bases=20(ADR=20?= =?UTF-8?q?0614=20=C2=A73,=20=C2=A76;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows in the judge's table: two modules invoking each other, or a verb closing a cycle with a provision, are not refused; a cycle of provisions and one of bases are. The refusal's words name what a part waits on now. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../internal/check/examiner_test.go | 37 +++++++++++++++---- .../internal/check/graph_cycle.go | 6 ++- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index 7da5b258..b237c86f 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -1029,32 +1029,53 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { // untouched is the judge's refusal of mail, which the change leaves as it is, and trunkMail its refusal on the trunk: // a problem the trunk had is a warning, one the change causes fails (ADR 0579 §2) untouched, trunkMail string - // cycle reads the manifests, so the change's module graph is worked out (ADR 0603 §2): the change's web awaits - // mail's site while mail awaits web's database; trunkCycle has the trunk's web await it too. - cycle, trunkCycle bool + // cycle reads the manifests, so the change's module graph is worked out (ADR 0603 §2): with "provisions" the change's + // web awaits mail's site while mail awaits web's database, and trunkCycle has the trunk's web await it too; with "bases" + // each is built on the other; with "verbs" each invokes the other's tool, and with "a verb and a provision" web invokes + // mail's tool while mail awaits web's database: both left out, so neither is refused (ADR 0614 §3, §6). + cycle string + trunkCycle bool }{ {name: "accepted"}, {name: "an untouched manifest's problem the trunk had", untouched: "mail says no credential form", trunkMail: "mail says no credential form"}, {name: "a problem the change causes in an untouched manifest", untouched: "mail reads a state web no longer keeps", fails: "refuses the manifest of mail: mail reads a state web no longer keeps"}, - {name: "a cycle the change makes", cycle: true, fails: "the change's module graph has the cycle mail -> web -> mail"}, - {name: "a cycle the trunk has", cycle: true, trunkCycle: true}, + {name: "a cycle the change makes", cycle: "provisions", fails: "the change's module graph has the cycle mail -> web -> mail"}, + {name: "a cycle the trunk has", cycle: "provisions", trunkCycle: true}, + {name: "a cycle of bases", cycle: "bases", fails: "the change's module graph has the cycle mail -> web -> mail"}, + {name: "a cycle of verbs", cycle: "verbs"}, + {name: "a cycle of a verb and a provision", cycle: "a verb and a provision"}, {name: "refused", refused: "web requires \"amqp\"\nweb has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web requires "amqp"; web has no id`}, } { b := newBench(t) b.ex.Manifests = fixedDigests{"mail": strings.Repeat("a", 64), "web": strings.Repeat("b", 64)} b.ex.Trunks = fixedTrunks{heads: map[string]string{DeliveryRepository: trunk}} b.ex.Predefined = keptList(`{"font-size": {"type": "number", "description": "the size"}}`) - if row.cycle { + if row.cycle != "" { offer := func(name string) []catalogue.Offer { return []catalogue.Offer{{Name: name, Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}} } + on := func(module string) *catalogue.Build { + return &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: module, Artifact: "img"}}} + } trunkWeb := catalogue.Manifest{Module: "web", Provides: offer("database")} if row.trunkCycle { trunkWeb.Requires = []string{"site"} } - b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail", Provides: offer("site"), Requires: []string{"database"}}, - strings.Repeat("b", 64): trunkWeb, web: {Module: "web", Provides: offer("database"), Requires: []string{"site"}}} + mail := catalogue.Manifest{Module: "mail", Provides: offer("site"), Requires: []string{"database"}} + changed := catalogue.Manifest{Module: "web", Provides: offer("database"), Requires: []string{"site"}} + switch row.cycle { + case "bases": + mail = catalogue.Manifest{Module: "mail", Build: on("web")} + changed = catalogue.Manifest{Module: "web", Build: on("mail")} + case "verbs": + mail = catalogue.Manifest{Module: "mail", Tools: []string{"mail_send"}, Invokes: []string{"web.web_page"}} + changed = catalogue.Manifest{Module: "web", Tools: []string{"web_page"}, Invokes: []string{"mail.mail_send"}} + case "a verb and a provision": + mail = catalogue.Manifest{Module: "mail", Tools: []string{"mail_send"}, Requires: []string{"database"}} + changed = catalogue.Manifest{Module: "web", Provides: offer("database"), Invokes: []string{"mail.mail_send"}} + } + b.ex.Read = fixedManifests{strings.Repeat("a", 64): mail, strings.Repeat("b", 64): trunkWeb, web: changed} } var judged []delivery.ComposeJob answer := b.asks.Answer diff --git a/modules/mesh-delivery-checker/internal/check/graph_cycle.go b/modules/mesh-delivery-checker/internal/check/graph_cycle.go index 673ff548..56766476 100644 --- a/modules/mesh-delivery-checker/internal/check/graph_cycle.go +++ b/modules/mesh-delivery-checker/internal/check/graph_cycle.go @@ -15,7 +15,9 @@ import ( ) // The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each -// wait for the other for good, through their bases or the provisions they await. The graph is worked out from the +// wait for the other for good, through their bases, the provisions and bus state they await, or the verb edges the graph +// keeps (ADR 0614 §6). A verb edge on a cycle is left out of the graph, so it is never refused (§3), and a package or a +// build module's edge is a trigger, never a wait, so a cycle through one is not refused either (§6). The graph is worked out from the // catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's), // with the contract's ManifestGraph, the way the indexer works out its graph's edges. A cycle the trunk already has among // the same modules is the trunk's: said in the verdict's notes, not the change's failure. @@ -70,5 +72,5 @@ func (st *state) refuseCycle(ctx context.Context) *delivery.Verdict { } } return fail(goModule, fmt.Sprintf("the change's module graph has the cycle %s: these modules would each wait for the other for "+ - "good, through their bases or the provisions they await (ADR 0603 §2)", words)) + "good, through their bases, the provisions and bus state they await, or the verbs they invoke (ADR 0603 §2, ADR 0614 §6)", words)) } From 702b19fdb1bd006d0b7086ea8c902012712fce63 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:25:27 +0100 Subject: [PATCH 11/17] =?UTF-8?q?view=5Ftiers=20names=20each=20wait=20by?= =?UTF-8?q?=20its=20edge,=20a=20verb,=20a=20reader=20capability=20or=20bus?= =?UTF-8?q?=20state,=20and=20every=20verb=20edge=20left=20out=20(ADR=20061?= =?UTF-8?q?4=20=C2=A72-=C2=A75;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The words of a want held on a verb or a reader capability are written and read in one place, the module graph's package: the assigner writes them, the view names the edge from them. A bus-state wait is named bus state, and each module's verb edges the graph leaves out on a cycle are listed beside its waits. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../internal/assign/verbs.go | 14 ++---- .../internal/assign/wants.go | 5 +- .../internal/assign/wants_test.go | 4 +- .../cmd/mesh-delivery-view-tools/main.go | 8 +-- .../tiers_rows_test.go | 28 +++++++++++ .../mesh-delivery-view/internal/view/tiers.go | 41 +++++++++------ sdk/graph/graph_test.go | 11 ++++ sdk/graph/waits.go | 50 +++++++++++++++++++ 8 files changed, 131 insertions(+), 30 deletions(-) create mode 100644 sdk/graph/waits.go diff --git a/modules/mesh-delivery-assigner/internal/assign/verbs.go b/modules/mesh-delivery-assigner/internal/assign/verbs.go index 9edf0d85..79714dcc 100644 --- a/modules/mesh-delivery-assigner/internal/assign/verbs.go +++ b/modules/mesh-delivery-assigner/internal/assign/verbs.go @@ -1,30 +1,24 @@ package assign import ( - "fmt" "slices" + + "git.novox.be/novox/mesh-delivery/sdk/graph" ) // verbWaiting says why the module's want on node waits for a verb it invokes (novox/hq ADR 0614 §2): the first seat verb or // module tool of Invokes (the module graph's verb edges, those it leaves out on a cycle not among them, §3) that no // running healthy build serves. Nil Served (nothing read of what runs) waits for none, and a verb a running build serves // already adds no wait. It holds under the provisions' rule (RuleProvisions): no reader knows a rule number of its own -// yet, so the words name the edge. +// yet, so the words name the edge (graph.VerbWaitWords, which the delivery view reads). func (m Module) verbWaiting(node string) (string, bool) { if m.Served == nil { return "", false } for _, v := range slices.Sorted(slices.Values(m.Invokes)) { if !m.Served(v) { - return VerbWaitWords(v, m.Name, node), true + return graph.VerbWaitWords(v, m.Name, node), true } } return "", false } - -// VerbWaitWords are the words of a want held for a verb it invokes (ADR 0614 §2): they open with "the verb", so a reader -// of the want's words (the delivery view) names the edge. -func VerbWaitWords(verb, module, node string) string { - return fmt.Sprintf("the verb %s %s invokes is served by no running build: its want on %s waits until a build serving it runs healthy (ADR 0614 §2)", - verb, module, node) -} diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index cf53a27e..f75faf8f 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -11,6 +11,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provisions" + + "git.novox.be/novox/mesh-delivery/sdk/graph" ) // Module is what the six rules read of one module (ADR 0429 §6, ADR 0528 clause 3): its newest fingerprint F at the @@ -175,8 +177,7 @@ func (m Module) readerLacking(node string, n Nodes) (string, bool) { 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 + return graph.ReaderWaitWords(node, missing, m.Name, short(m.FP)), true // the words the delivery view names the edge by } // provisionWaiting says why the module's want on node waits for a provision (ADR 0570 §4): a provision it requires that has diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 278c9971..dceaec80 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -9,6 +9,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provisions" + + "git.novox.be/novox/mesh-delivery/sdk/graph" ) func fpOf(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } @@ -169,7 +171,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { } continue } - if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != VerbWaitWords(c.holds, "mail", "g14") { + if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != graph.VerbWaitWords(c.holds, "mail", "g14") { t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"]) } } diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index d147a802..746dc902 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -48,9 +48,11 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool { }}, {Name: view.TiersTool, Description: "A source revision's tiers, worked out now and never stored (novox/hq ADR 0603 §6): the modules the revision moves " + - "(each whose fingerprint last changed at it), ordered by the module graph (a module after the bases and the providers " + - "of the provisions it awaits among them), and for each module what it waits on now and why: its build key, on each node " + - "that wants it its want (the assigner's rule and words when held back), its have, and each provision it awaits. Nodes " + + "(each whose fingerprint last changed at it), ordered by the module graph (a module after the bases, the providers " + + "of the provisions and bus state it awaits and the modules serving the verbs it invokes, among them; novox/hq ADR 0614), " + + "and for each module what it waits on now and why, named by its edge: its build key, on each node that wants it its want " + + "(the assigner's rule and words when held back: a verb no running build serves, a reader capability the node-engine " + + "does not state), its have, each provision and its bus state; and the verb edges the graph leaves out on a cycle. Nodes " + "the watchman says are offline are named away and left out. At most 500 modules, said when cut short; said when the " + "module graph cannot be read. Give revision for an older one, none for the gate's newest. Read-only.", Input: map[string]any{"type": "object", "properties": map[string]any{ diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go index 6236315b..ed5f0c94 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go @@ -26,6 +26,9 @@ func encoded(t *testing.T, v []byte, err error) string { return string(v) } +// short is a fingerprint as the assigner's words shorten it. +func short(fp string) string { return fp[:len("src2:")+12] } + // tiersToolSaysEachModulesWait: view_tiers orders a source revision's modules by the module graph and names each one's // wait (ADR 0603 §6): a consumer after its provider, held by the assigner's provision rule and its provision not provided; // a module whose build waits for a package; a provider with no wait; a node offline named away; older modules left out. @@ -104,6 +107,31 @@ func tiersToolSaysEachModulesWait(t *testing.T) { if out, err := run(t, r, view.TiersTool, map[string]any{}); err != nil || out.(view.Tiers).Revision != 9 || out.(view.Tiers).Modules != 3 { t.Errorf("view_tiers of the gate's newest = %+v, %v", out, err) } + // ADR 0614 §2-§5: a want held on a verb or a reader capability is named by its edge, a wait for bus state as bus state, + // and each verb edge the graph leaves out on a cycle is named. + r.GraphState = fake{ + "db": node(graph.Node{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}), + "web": node(graph.Node{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database", graph.BusState}, Awaits: []string{"database", graph.BusState}, + Invokes: []string{"seat:x.v", "db.loop"}, LeftOut: []string{"db.loop"}}), + "lib": node(graph.Node{FP: fp("c"), Repository: "novox/mesh-catalog"}), + }.reader().State + for words, edge := range map[string]string{ + graph.VerbWaitWords("seat:x.v", "web", "ace"): "verb seat:x.v", + graph.ReaderWaitWords("ace", []string{"tool-runner"}, "web", short(fp("b"))): "reader capability tool-runner", + "the provision ace.web.database of web on ace is asked, not provided": "want", + } { + r.WantsState = fake{key(delivery.WantKey("ace", "web")): want(delivery.Want{Self: true, FP: fp("f"), Newest: fp("b"), Rule: delivery.RuleProvisions, Words: words})}.reader().State + out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}) + got, _ := out.(view.Tiers) + if err != nil || len(got.Tiers) != 2 { + t.Fatalf("view_tiers 9 = %+v, %v", out, err) + } + web := got.Tiers[1][0] + if len(web.Waits) != 3 || web.Waits[0].Edge != edge || web.Waits[1].Edge != "bus state" || !strings.Contains(web.Waits[1].Words, "no key of bus-state") || + !reflect.DeepEqual(web.LeftOut, []string{"db.loop"}) { + t.Errorf("web held by %q = %+v, want its want named %s, a bus state wait and db.loop left out", words, web, edge) + } + } // The graph not readable: every module in the first tier, said; past 500 modules the answer says it is cut short. r.GraphState = view.StateRead{Keys: func() ([]string, error) { return nil, errors.New("no bucket") }, Get: r.GraphState.Get} if out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}); err != nil || len(out.(view.Tiers).Tiers) != 1 || diff --git a/modules/mesh-delivery-view/internal/view/tiers.go b/modules/mesh-delivery-view/internal/view/tiers.go index 8055ad38..4d0a6ba4 100644 --- a/modules/mesh-delivery-view/internal/view/tiers.go +++ b/modules/mesh-delivery-view/internal/view/tiers.go @@ -16,9 +16,11 @@ import ( ) // The tiers of a source revision are a view, worked out at each call and never stored (novox/hq ADR 0603 §1, §6): the -// modules the revision moves, ordered by the module graph's ordering edges among them (a base, a provision awaited from -// another of them), and for each module what it waits on now: its build key, each node's want and have, and each -// provision it awaits on each node, with the rule and the words of the part that holds it. Nothing in the mesh waits on +// modules the revision moves, ordered by the module graph's ordering edges among them (a base, a provision or bus state +// awaited from another of them, a verb another of them serves whose edge the graph keeps, ADR 0614 §1), and for each module +// what it waits on now: its build key, each node's want (named by its edge: a verb, a reader capability) and have, and +// each provision it awaits on each node, with the rule and the words of the part that holds it, and the verb edges the +// graph leaves out. Nothing in the mesh waits on // a tier; the tiers say where the revision stands. A node the watchman says is offline is named away and left out of // what the revision waits on (ADR 0603 §7). @@ -43,18 +45,22 @@ type Tiers struct { Away []string `json:"away,omitempty"` } -// TierModule is one module of a tier: its fingerprint, the modules of the same revision it waits for (After), and each -// wait of its own now; no wait is a module that reached every node present. +// TierModule is one module of a tier: its fingerprint, the modules of the same revision it waits for (After), each +// wait of its own now (no wait is a module that reached every node present), and the verbs it invokes whose edge the +// module graph leaves out on a cycle, so nothing waits on them (LeftOut, ADR 0614 §3). type TierModule struct { - Module string `json:"module"` - FP string `json:"fp,omitempty"` - None string `json:"none,omitempty"` - After []string `json:"after,omitempty"` - Waits []TierWait `json:"waits,omitempty"` + Module string `json:"module"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` + After []string `json:"after,omitempty"` + Waits []TierWait `json:"waits,omitempty"` + LeftOut []string `json:"left-out,omitempty"` } -// TierWait is one thing a module waits on: the edge (build, want, have, or provision ), the node it waits on (none -// for its build), the assigner's rule for a want held back, the part's words and since when, where the part says it. +// TierWait is one thing a module waits on: the edge (build, have, provision , bus state, or for a want held back +// want, verb or reader capability as the assigner's words name it, ADR 0614 §2, §4, §5), the node +// it waits on (none for its build), the assigner's rule for a want held back, the part's words and since when, where the +// part says it. type TierWait struct { Edge string `json:"edge"` Node string `json:"node,omitempty"` @@ -169,7 +175,7 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) { out.Tiers = append(out.Tiers, nil) } m := moved[n] - tm := TierModule{Module: n, FP: m.FP2, None: m.None2, After: after[n]} + tm := TierModule{Module: n, FP: m.FP2, None: m.None2, After: after[n], LeftOut: gr[n].LeftOut} tm.Waits = r.waitsOf(n, m, gr[n], wants, provided, perr, away) out.Tiers[t] = append(out.Tiers[t], tm) } @@ -234,7 +240,11 @@ func (r Reader) waitsOf(module string, m delivery.Module, g graph.Node, wants, p if words == "" { words = "the want names " + short(w.FP) + ", not " + short(m.FP2) } - out = append(out, TierWait{Edge: "want", Node: node, Rule: w.Rule, Words: words, Since: r.since(r.WantsState, mustWantKey(node, module))}) + edge := graph.EdgeOfWords(w.Words) + if edge == "" { + edge = "want" + } + out = append(out, TierWait{Edge: edge, Node: node, Rule: w.Rule, Words: words, Since: r.since(r.WantsState, mustWantKey(node, module))}) } else if hw, ok := r.haveWait(node, module, m.FP2); ok { out = append(out, hw) } @@ -313,6 +323,9 @@ func (r Reader) haveWait(node, module, fp string) (TierWait, bool) { // provisionWaits are the waits of the provision a that module awaits on node: none once every key of it is provided. func provisionWaits(node, module, a string, provided map[string]json.RawMessage, perr error) []TierWait { edge := "provision " + a + if a == graph.BusState { + edge = "bus state" // a bus-state entry of the manifest (ADR 0614 §5) + } if perr != nil { return []TierWait{{Edge: edge, Node: node, Words: "the provisioner's keys cannot be read: " + perr.Error()}} } diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go index e87a15c5..ad97e5a3 100644 --- a/sdk/graph/graph_test.go +++ b/sdk/graph/graph_test.go @@ -131,6 +131,17 @@ func TestGraphEdges(t *testing.T) { if got := OfManifests(map[string]catalogue.Manifest{"keeper": verbs["keeper"]})["keeper"]; got.Awaits != nil { t.Errorf("bus-state with no module offering it is awaited: %+v", got) } + // The words of a want held on a verb or a reader capability name their edge, and no other words do. + for words, want := range map[string]string{ + VerbWaitWords("seat:mesh-catalog.modules", "artifact-store-tools", "g14"): "verb seat:mesh-catalog.modules", + ReaderWaitWords("g14", []string{"tool-runner", "bus-state"}, "node-tools", "src2:abcdef"): "reader capability tool-runner, bus-state", + "the verb is served": "", + "the node-engine of g14 has not started": "", + } { + if got := EdgeOfWords(words); got != want { + t.Errorf("the edge of %q = %q, want %q", words, got, want) + } + } for name, c := range map[string]struct { edit func(Graph) want []string diff --git a/sdk/graph/waits.go b/sdk/graph/waits.go new file mode 100644 index 00000000..eea7dcb2 --- /dev/null +++ b/sdk/graph/waits.go @@ -0,0 +1,50 @@ +package graph + +import ( + "fmt" + "strings" +) + +// The words of a want held on an edge of ADR 0614 (novox/hq ADR 0614 §2, §4): no reader knows a rule number of its own for +// either yet, so the assigner holds them under the provisions' and the composition's rules, and these words are the one +// place they are written and read. The assigner writes them (VerbWaitWords, ReaderWaitWords); the delivery view names +// the edge a want waits on from them (EdgeOfWords). + +const ( + verbOpen = "the verb " + verbClose = " invokes is served by no running build" + readerOpen = "the node-engine of " + readerMid = " does not read " + readerTail = ", which the manifest of " +) + +// VerbWaitWords are the words of a want of module on node held until a build serving verb runs healthy (ADR 0614 §2). +func VerbWaitWords(verb, module, node string) string { + return fmt.Sprintf("%s%s %s%s: its want on %s waits until a build serving it runs healthy (ADR 0614 §2)", verbOpen, verb, + module, verbClose, node) +} + +// ReaderWaitWords are the words of a want of module at fp held on node until its node-engine states the reader +// capabilities missing (ADR 0614 §4). +func ReaderWaitWords(node string, missing []string, module, fp string) string { + return fmt.Sprintf("%s%s%s%s%s%s at %s needs: the want moves once that engine states it", readerOpen, node, readerMid, + strings.Join(missing, ", "), readerTail, module, fp) +} + +// EdgeOfWords is the edge a held want's words name: `verb ` for a verb wait, `reader capability ` for +// a reader capability's hold, or "" for words of neither. +func EdgeOfWords(words string) string { + if rest, ok := strings.CutPrefix(words, verbOpen); ok { + if verb, _, ok := strings.Cut(rest, " "); ok && strings.Contains(rest, verbClose) { + return "verb " + verb + } + } + if rest, ok := strings.CutPrefix(words, readerOpen); ok { + if _, rest, ok := strings.Cut(rest, readerMid); ok { + if missing, _, ok := strings.Cut(rest, readerTail); ok { + return "reader capability " + missing + } + } + } + return "" +} From 46c735473f6f9be73c8a18565ca5bf0c624df242 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:28:41 +0100 Subject: [PATCH 12/17] =?UTF-8?q?A=20want=20asking=20bus=20state=20waits?= =?UTF-8?q?=20until=20a=20build=20offering=20bus-state=20runs=20healthy=20?= =?UTF-8?q?on=20a=20node=20present=20(ADR=200614=20=C2=A75;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bus-state is answered with no credential of a consumer's own, so ADR 0603 §5 counts the provider's own have: the assigner reads, per pass, what the running healthy builds on online nodes offer (their manifests through the builder's builds) and holds a consumer with a bus-state entry under the provisions' rule until one offers it, handed or not and with no provisioner key. The words name bus state, and the delivery view names the edge from them. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-delivery-assigner/pass.go | 2 + .../cmd/mesh-delivery-assigner/verbs.go | 47 +++++++++++++++++-- .../internal/assign/verbs.go | 12 +++++ .../internal/assign/wants.go | 7 +++ .../internal/assign/wants_test.go | 13 ++++- sdk/graph/graph_test.go | 5 +- sdk/graph/waits.go | 17 +++++-- 7 files changed, 92 insertions(+), 11 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 6170b6a2..cc520b40 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -158,6 +158,7 @@ func (p *passer) wants(ctx context.Context) { // The verb edges, once for the pass (ADR 0614 §2, §3). moduleGraph := p.moduleGraph() served := p.served(ctx, get, moduleGraph, nodes) + offering := p.offering(ctx, get, manifests, nodes) // bus state (ADR 0614 §5) for name := range names { fp := trunk[name].FP2 name := name @@ -198,6 +199,7 @@ func (p *passer) wants(ctx context.Context) { if served != nil { m.Invokes, m.Served = keptInvokes(moduleGraph, name), served } + m.Offering = offering last := map[string]string{} for _, node := range m.Nodes { last[node] = p.w.lastHealthy(node, name, fp) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go index 049a42e3..28fc87b9 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/verbs.go @@ -4,6 +4,7 @@ import ( "context" "slices" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-delivery/sdk/graph" @@ -69,23 +70,59 @@ func (p *passer) served(ctx context.Context, get getter, g graph.Graph, n assign return served } +// offering is, for this pass, whether a build whose manifest offers a provision runs healthy on a node present (ADR 0603 +// §5, ADR 0614 §5): every running healthy have on an online node, its build's manifest read as servesOf reads it. A +// healthy build whose manifest cannot be read counts what its module's trunk manifest offers, so a build read late never +// holds a consumer for good. +func (p *passer) offering(ctx context.Context, get getter, trunk map[string]catalogue.Manifest, n assign.Nodes) func(string) bool { + offered := map[string]bool{} + for node, haves := range n.Haves { + if !n.Online[node] { + continue + } + for module, h := range haves { + if h.Phase != delivery.PhaseRunning || h.Health != delivery.HealthOK { + continue + } + m, ok := p.manifestOf(ctx, get, module, h.FP) + if !ok { + m = trunk[module] + } + for _, o := range m.Offers() { + offered[o] = true + } + } + } + return func(provision string) bool { return offered[provision] } +} + // servesOf are the verbs the manifest of module's build at fp serves (graph.ServesOf), and whether it could be read. func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([]string, bool) { + m, ok := p.manifestOf(ctx, get, module, fp) + if !ok { + return nil, false + } + return graph.ServesOf(m), true +} + +// manifestOf is the manifest of module's build at fp (the builder's build names its digest in the indexer's manifests), +// and whether it could be read. +func (p *passer) manifestOf(ctx context.Context, get getter, module, fp string) (catalogue.Manifest, bool) { key, err := delivery.BuilderBuildKey(module, fp) if err != nil { - return nil, false + return catalogue.Manifest{}, false } raw := p.w.value(delivery.BuilderBuildsBucket, key) if raw == nil { - return nil, false + return catalogue.Manifest{}, false } b, err := delivery.DecodeBuilderBuild(raw) if err != nil || b.Manifest == "" { - return nil, false + return catalogue.Manifest{}, false } got := p.w.manifest(ctx, get, b.Manifest) if got.err != "" { - return nil, false + return catalogue.Manifest{}, false } - return graph.ServesOf(got.manifest), true + return got.manifest, true } diff --git a/modules/mesh-delivery-assigner/internal/assign/verbs.go b/modules/mesh-delivery-assigner/internal/assign/verbs.go index 79714dcc..f41d4360 100644 --- a/modules/mesh-delivery-assigner/internal/assign/verbs.go +++ b/modules/mesh-delivery-assigner/internal/assign/verbs.go @@ -22,3 +22,15 @@ func (m Module) verbWaiting(node string) (string, bool) { } return "", false } + +// busStateWaiting says why the module's want on node waits for bus state (ADR 0614 §5): its manifest asks bus state (a +// bus-state entry, as the module graph reads it) and no build whose manifest offers bus-state runs healthy on a node +// present. The provision bus-state is answered with no credential of a consumer's own, so ADR 0603 §5 counts the +// provider's own have and no provisioner key is waited for, handed or not. Nil Offering (nothing read of what runs) waits +// for none. It holds under the provisions' rule, its words naming bus state (graph.BusStateWaitWords). +func (m Module) busStateWaiting(node string) (string, bool) { + if len(m.Manifest.BusState) == 0 || m.Offering == nil || m.Offering(graph.BusState) { + return "", false + } + return graph.BusStateWaitWords(m.Name, node), true +} diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index f75faf8f..5057439e 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -49,6 +49,10 @@ type Module struct { // Served says whether a build serving a seat verb or module tool runs healthy, counted from its holder's have (ADR 0614 // §2, as ADR 0603 §5 counts a provider's). Nil: nothing is read of what runs, and no want waits on a verb. Served func(invoke string) bool + // Offering says whether a build whose manifest offers a provision runs healthy on a node present, counted from the + // provider's own have (ADR 0603 §5): what a want asking bus state waits for (ADR 0614 §5). Nil: nothing is read of what + // runs, and no want waits on bus state. + Offering func(provision string) bool // Chosen is a person's choice of the module's upgrade policy (the assigner's `policies`, ADR 0565 §7); nil: none. Chosen *catalogue.PersonPolicy Nodes []string @@ -165,6 +169,9 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) { if words, waiting := m.verbWaiting(node); waiting { return false, delivery.RuleProvisions, words } + if words, waiting := m.busStateWaiting(node); waiting { + return false, delivery.RuleProvisions, words + } return true, 0, "" } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index dceaec80..96f86432 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -158,12 +158,23 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { {"once a build serving it runs", []string{"seat:mesh-catalog.modules"}, []string{"seat:mesh-catalog.modules"}, false, ""}, {"an edge left out on a cycle", nil, nil, false, ""}, {"nothing read of what runs", []string{"seat:mesh-catalog.modules"}, nil, true, ""}, + // ADR 0614 §5: a module asking bus state is held while no build offering bus-state runs, and moves once one does. + {"bus state no running build offers", nil, nil, false, graph.BusState}, + {"bus state a running build offers", nil, []string{graph.BusState}, false, ""}, } { m, n := ready() m.Invokes = c.invokes if !c.nilRead { m.Served = func(v string) bool { return slices.Contains(c.served, v) } } + if strings.HasPrefix(c.name, "bus state") { + m.Manifest.BusState = []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}} + m.Offering = func(p string) bool { return slices.Contains(c.served, p) } + } + words := graph.VerbWaitWords(c.holds, "mail", "g14") + if c.holds == graph.BusState { + words = graph.BusStateWaitWords("mail", "g14") + } out := Wants(m, n, wantedOld(), nil) if c.holds == "" { if out.Wants["g14"].FP != fNew { @@ -171,7 +182,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { } continue } - if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != graph.VerbWaitWords(c.holds, "mail", "g14") { + if out.Wants["g14"].FP == fNew || out.Rules["g14"].Rule != delivery.RuleProvisions || out.Rules["g14"].Words != words { t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"]) } } diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go index ad97e5a3..e5c7fdd0 100644 --- a/sdk/graph/graph_test.go +++ b/sdk/graph/graph_test.go @@ -135,8 +135,9 @@ func TestGraphEdges(t *testing.T) { for words, want := range map[string]string{ VerbWaitWords("seat:mesh-catalog.modules", "artifact-store-tools", "g14"): "verb seat:mesh-catalog.modules", ReaderWaitWords("g14", []string{"tool-runner", "bus-state"}, "node-tools", "src2:abcdef"): "reader capability tool-runner, bus-state", - "the verb is served": "", - "the node-engine of g14 has not started": "", + BusStateWaitWords("keeper", "g14"): "bus state", + "the verb is served": "", + "the node-engine of g14 has not started": "", } { if got := EdgeOfWords(words); got != want { t.Errorf("the edge of %q = %q, want %q", words, got, want) diff --git a/sdk/graph/waits.go b/sdk/graph/waits.go index eea7dcb2..3cbfd5ce 100644 --- a/sdk/graph/waits.go +++ b/sdk/graph/waits.go @@ -5,9 +5,9 @@ import ( "strings" ) -// The words of a want held on an edge of ADR 0614 (novox/hq ADR 0614 §2, §4): no reader knows a rule number of its own for +// The words of a want held on an edge of ADR 0614 (novox/hq ADR 0614 §2, §4, §5): no reader knows a rule number of its own for // either yet, so the assigner holds them under the provisions' and the composition's rules, and these words are the one -// place they are written and read. The assigner writes them (VerbWaitWords, ReaderWaitWords); the delivery view names +// place they are written and read. The assigner writes them (VerbWaitWords, ReaderWaitWords, BusStateWaitWords); the delivery view names // the edge a want waits on from them (EdgeOfWords). const ( @@ -16,8 +16,16 @@ const ( readerOpen = "the node-engine of " readerMid = " does not read " readerTail = ", which the manifest of " + busOpen = "bus state: " ) +// BusStateWaitWords are the words of a want of module on node held until a build of a module offering bus-state runs +// healthy on a node present (ADR 0614 §5, through ADR 0603 §5's count of the provider's own have). +func BusStateWaitWords(module, node string) string { + return fmt.Sprintf("%s%s asks bus state, and no build of a module offering %s runs healthy on a node present: its want on %s waits for one (ADR 0614 §5)", + busOpen, module, BusState, node) +} + // VerbWaitWords are the words of a want of module on node held until a build serving verb runs healthy (ADR 0614 §2). func VerbWaitWords(verb, module, node string) string { return fmt.Sprintf("%s%s %s%s: its want on %s waits until a build serving it runs healthy (ADR 0614 §2)", verbOpen, verb, @@ -32,8 +40,11 @@ func ReaderWaitWords(node string, missing []string, module, fp string) string { } // EdgeOfWords is the edge a held want's words name: `verb ` for a verb wait, `reader capability ` for -// a reader capability's hold, or "" for words of neither. +// a reader capability's hold, `bus state` for a bus-state wait, or "" for words of none of them. func EdgeOfWords(words string) string { + if strings.HasPrefix(words, busOpen) { + return "bus state" + } if rest, ok := strings.CutPrefix(words, verbOpen); ok { if verb, _, ok := strings.Cut(rest, " "); ok && strings.Contains(rest, verbClose) { return "verb " + verb From 9d2816d001a8c135dc789f96ee672f3d8ffa6294 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:42:18 +0100 Subject: [PATCH 13/17] =?UTF-8?q?The=20module=20offering=20bus-state=20nev?= =?UTF-8?q?er=20waits=20on=20bus=20state=20itself,=20and=20an=20offline=20?= =?UTF-8?q?node's=20build=20offers=20nothing=20(ADR=200614=20=C2=A75;=20is?= =?UTF-8?q?sue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bus module that both offers and asks bus state waited on itself for good at its first such build; it now waits on nobody for it, as the graph and catalogue.Awaited leave a module's own provision out. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-delivery-assigner/pass_test.go | 10 ++++++++++ .../mesh-delivery-assigner/internal/assign/verbs.go | 6 ++++-- .../internal/assign/wants_test.go | 4 ++++ 3 files changed, 18 insertions(+), 2 deletions(-) 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 c3486869..ec5f1bc0 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 @@ -1072,4 +1072,14 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) { t.Errorf("served(%s) = %v, want %v: the running server's manifest serves w alone, late's is not read", v, served(v), want) } } + // ADR 0614 §5: bus-state counts as offered only from a healthy build on a node present; the bus module's build on an + // offline node offers nothing, and one whose manifest is not read counts its trunk manifest's offer. + bus := map[string]catalogue.Manifest{"bus": {Module: "bus", Provides: []catalogue.Offer{{Name: graph.BusState, Scope: catalogue.ScopeMesh}}}} + for _, online := range []bool{false, true} { + offering := p.offering(context.Background(), get, bus, assign.Nodes{Online: map[string]bool{"n2": online}, + Haves: map[string]map[string]delivery.Have{"n2": {"bus": at(fp("2"))}}}) + if offering(graph.BusState) != online { + t.Errorf("with the bus module's node online %v, bus-state offered %v", online, offering(graph.BusState)) + } + } } diff --git a/modules/mesh-delivery-assigner/internal/assign/verbs.go b/modules/mesh-delivery-assigner/internal/assign/verbs.go index f41d4360..86466d74 100644 --- a/modules/mesh-delivery-assigner/internal/assign/verbs.go +++ b/modules/mesh-delivery-assigner/internal/assign/verbs.go @@ -27,9 +27,11 @@ func (m Module) verbWaiting(node string) (string, bool) { // bus-state entry, as the module graph reads it) and no build whose manifest offers bus-state runs healthy on a node // present. The provision bus-state is answered with no credential of a consumer's own, so ADR 0603 §5 counts the // provider's own have and no provisioner key is waited for, handed or not. Nil Offering (nothing read of what runs) waits -// for none. It holds under the provisions' rule, its words naming bus state (graph.BusStateWaitWords). +// for none, and neither does a module that offers bus-state itself. It holds under the provisions' rule, its words naming bus state (graph.BusStateWaitWords). func (m Module) busStateWaiting(node string) (string, bool) { - if len(m.Manifest.BusState) == 0 || m.Offering == nil || m.Offering(graph.BusState) { + if len(m.Manifest.BusState) == 0 || m.Offering == nil || m.Offering(graph.BusState) || + slices.Contains(m.Manifest.Offers(), graph.BusState) { // the bus module offering it waits on nobody for it + return "", false } return graph.BusStateWaitWords(m.Name, node), true diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 96f86432..5e61414d 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -161,6 +161,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { // ADR 0614 §5: a module asking bus state is held while no build offering bus-state runs, and moves once one does. {"bus state no running build offers", nil, nil, false, graph.BusState}, {"bus state a running build offers", nil, []string{graph.BusState}, false, ""}, + {"bus state its own module offers", nil, nil, false, ""}, } { m, n := ready() m.Invokes = c.invokes @@ -170,6 +171,9 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) { if strings.HasPrefix(c.name, "bus state") { m.Manifest.BusState = []catalogue.BusStateEntry{{Name: "cache", Storage: "key-value"}} m.Offering = func(p string) bool { return slices.Contains(c.served, p) } + if c.name == "bus state its own module offers" { + m.Manifest.Provides = []catalogue.Offer{{Name: graph.BusState, Scope: catalogue.ScopeMesh}} + } } words := graph.VerbWaitWords(c.holds, "mail", "g14") if c.holds == graph.BusState { From 2d49c660c00e230b865beb64d366bfe8f3268f23 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:42:37 +0100 Subject: [PATCH 14/17] =?UTF-8?q?view=5Ftiers=20names=20bus=20state=20by?= =?UTF-8?q?=20the=20assigner's=20held=20want=20alone,=20never=20by=20a=20p?= =?UTF-8?q?rovisioner=20key=20no=20one=20makes=20(ADR=200614=20=C2=A75;=20?= =?UTF-8?q?issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bus-state is offered with no credential of a consumer's own, so the provisioner keys nothing for it; the view showed every module asking bus state as waiting for that key for good. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-delivery-view-tools/tiers_rows_test.go | 9 +++++---- modules/mesh-delivery-view/internal/view/tiers.go | 12 +++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go index ed5f0c94..bf2c81dd 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/tiers_rows_test.go @@ -107,8 +107,9 @@ func tiersToolSaysEachModulesWait(t *testing.T) { if out, err := run(t, r, view.TiersTool, map[string]any{}); err != nil || out.(view.Tiers).Revision != 9 || out.(view.Tiers).Modules != 3 { t.Errorf("view_tiers of the gate's newest = %+v, %v", out, err) } - // ADR 0614 §2-§5: a want held on a verb or a reader capability is named by its edge, a wait for bus state as bus state, - // and each verb edge the graph leaves out on a cycle is named. + // ADR 0614 §2-§5: a want held on a verb, a reader capability or bus state is named by its edge; bus state is no + // provisioner key (none is ever made for it), so a module asking it shows no provision wait of it; and each verb edge + // the graph leaves out on a cycle is named. r.GraphState = fake{ "db": node(graph.Node{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}), "web": node(graph.Node{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database", graph.BusState}, Awaits: []string{"database", graph.BusState}, @@ -127,9 +128,9 @@ func tiersToolSaysEachModulesWait(t *testing.T) { t.Fatalf("view_tiers 9 = %+v, %v", out, err) } web := got.Tiers[1][0] - if len(web.Waits) != 3 || web.Waits[0].Edge != edge || web.Waits[1].Edge != "bus state" || !strings.Contains(web.Waits[1].Words, "no key of bus-state") || + if len(web.Waits) != 2 || web.Waits[0].Edge != edge || web.Waits[1].Edge != "provision database" || !reflect.DeepEqual(web.LeftOut, []string{"db.loop"}) { - t.Errorf("web held by %q = %+v, want its want named %s, a bus state wait and db.loop left out", words, web, edge) + t.Errorf("web held by %q = %+v, want its want named %s, no wait for a bus-state key and db.loop left out", words, web, edge) } } // The graph not readable: every module in the first tier, said; past 500 modules the answer says it is cut short. diff --git a/modules/mesh-delivery-view/internal/view/tiers.go b/modules/mesh-delivery-view/internal/view/tiers.go index 4d0a6ba4..35a1e0a7 100644 --- a/modules/mesh-delivery-view/internal/view/tiers.go +++ b/modules/mesh-delivery-view/internal/view/tiers.go @@ -57,8 +57,8 @@ type TierModule struct { LeftOut []string `json:"left-out,omitempty"` } -// TierWait is one thing a module waits on: the edge (build, have, provision , bus state, or for a want held back -// want, verb or reader capability as the assigner's words name it, ADR 0614 §2, §4, §5), the node +// TierWait is one thing a module waits on: the edge (build, have, provision , or for a want held back want, verb +// , reader capability or bus state as the assigner's words name it, ADR 0614 §2, §4, §5), the node // it waits on (none for its build), the assigner's rule for a want held back, the part's words and since when, where the // part says it. type TierWait struct { @@ -249,6 +249,11 @@ func (r Reader) waitsOf(module string, m delivery.Module, g graph.Node, wants, p out = append(out, hw) } for _, a := range g.Awaits { + if a == graph.BusState { + // Bus state is offered with no credential of a consumer's own, so the provisioner keys nothing for it: its wait is + // the assigner's held want alone, named bus state from its words (ADR 0614 §5). + continue + } out = append(out, provisionWaits(node, module, a, provided, perr)...) } } @@ -323,9 +328,6 @@ func (r Reader) haveWait(node, module, fp string) (TierWait, bool) { // provisionWaits are the waits of the provision a that module awaits on node: none once every key of it is provided. func provisionWaits(node, module, a string, provided map[string]json.RawMessage, perr error) []TierWait { edge := "provision " + a - if a == graph.BusState { - edge = "bus state" // a bus-state entry of the manifest (ADR 0614 §5) - } if perr != nil { return []TierWait{{Edge: edge, Node: node, Words: "the provisioner's keys cannot be read: " + perr.Error()}} } From 9d0b3cafc5a6d3ec46efcb12e96e62e2ad8187be Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:43:26 +0100 Subject: [PATCH 15/17] =?UTF-8?q?A=20modules=20value=20with=20no=20verdict?= =?UTF-8?q?=20yet=20holds=20only=20its=20module's=20users,=20and=20one=20m?= =?UTF-8?q?odule's=20value=20that=20does=20not=20read=20is=20a=20gap=20of?= =?UTF-8?q?=20that=20module,=20never=20of=20the=20whole=20list=20(ADR=2006?= =?UTF-8?q?14=20=C2=A78,=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indexer writes a changed manifest's value before it judges it; the part now reads the indexer's verdicts. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-grants/integration_test.go | 2 + modules/mesh-grants/grants_test.go | 20 ++++++++ modules/mesh-grants/internal/grants/follow.go | 2 +- modules/mesh-grants/internal/grants/inputs.go | 46 ++++++++++++++++--- modules/mesh-grants/module.json | 1 + 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/modules/mesh-grants/cmd/mesh-grants/integration_test.go b/modules/mesh-grants/cmd/mesh-grants/integration_test.go index c546269e..25a157c0 100644 --- a/modules/mesh-grants/cmd/mesh-grants/integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/integration_test.go @@ -93,6 +93,8 @@ func TestTheGrantsPartWhole(t *testing.T) { put(grants.ReadManifests, digest, delivery.Manifest{V: delivery.Version, Module: module, JSON: raw}) put(grants.ReadModules, module, delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), Manifest: digest, Repository: "novox/x", Commit: "c"}) + verdict, _ := delivery.EncodeManifestVerdict(delivery.ManifestVerdict{Module: module, State: delivery.VerdictAccepted, Judge: "j", At: time.Now()}) + put(grants.ReadVerdicts, digest, json.RawMessage(verdict)) } manifest("nats", `{"module":"nats","version":"1","claims":[{"name":"mesh-broker"}],"own-secrets":{"broker":"/var/lib/nats/broker"}}`) manifest("mod-c", `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["nats.done"]}`) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 162596c9..5967fbc2 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -154,6 +154,12 @@ func (b *fakeBus) put(state, key string, v any) { b.states[state] = map[string][]byte{} } b.states[state][key] = raw + // A modules value that carries no verdict is accepted by the indexer's verdict on its digest, as the indexer writes it; + // a row that wants the window before the judge deletes that key. + if m, ok := v.(delivery.Module); ok && state == grants.ReadModules && m.Verdict == "" && m.Manifest != "" { + verdict, _ := delivery.EncodeManifestVerdict(delivery.ManifestVerdict{Module: key, State: delivery.VerdictAccepted, Judge: "j", At: time.Now()}) + b.put(grants.ReadVerdicts, m.Manifest, json.RawMessage(verdict)) + } } func (b *fakeBus) Keys(state string) ([]string, error) { @@ -2841,6 +2847,20 @@ func TestGrants(t *testing.T) { if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "the manifest of mod-r is unreadable") { t.Errorf("the unreadable manifest was not said: %+v", last) } + // The window before the judge: the indexer wrote a changed manifest's value and no verdict on its digest yet. Only that + // module's users stay (named unreadable); the others move. + b.manifestOf("mod-r", ctl) + delete(b.states[grants.ReadVerdicts], delivery.ManifestDigest([]byte(ctl))) + b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": "$2a$10$gen11", "generation": 11, "fingerprint": "sha256:11"}) + wasList, _, _ = p.Sealed() + res = p.Compose() + nowList, _, _ = p.Sealed() + if nowList.Digest == wasList.Digest || strings.Contains(fmt.Sprint(res.Sealed), "kept while a read") { + t.Errorf("a manifest with no verdict yet held the whole list: %+v", res.Sealed) + } + if last := said[len(said)-1]; last.State != asks.StateRaised || !strings.Contains(last.Summary, "no verdict on this manifest yet") { + t.Errorf("a manifest with no verdict yet was not said: %+v", last) + } // Accepted again: the users compose from the manifest and the condition clears. b.manifestOf("mod-r", ctl) if p.Compose(); said[len(said)-1].State != asks.StateCleared { diff --git a/modules/mesh-grants/internal/grants/follow.go b/modules/mesh-grants/internal/grants/follow.go index dfaef644..9e01a74d 100644 --- a/modules/mesh-grants/internal/grants/follow.go +++ b/modules/mesh-grants/internal/grants/follow.go @@ -21,7 +21,7 @@ type Watcher interface { // // And the seat mesh-enrolment's keys (ADR 0587 §4, §5): a key at live composes enrol., and its end, by the holder or by // the bus's expiry, is a change of the key that takes the user out; no event is needed beside the watch. -var Followed = []string{ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadEnrolments, +var Followed = []string{ReadModules, ReadVerdicts, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadEnrolments, ReadNodeLogins} // Follower composes again on every change it hears, never on a timer. It never stops for a refusal: a watch it cannot diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 839a8f86..3fd6310c 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -25,10 +25,13 @@ import ( const ( ReadModules = "mesh-delivery-indexer.modules" ReadManifests = "mesh-delivery-indexer.manifests" - ReadHave = "node-engine.have" - ReadResolved = "mesh-config.resolved" - ReadWants = "mesh-delivery-assigner.wants" - ReadBuilds = "mesh-delivery-builder.builds" + // ReadVerdicts is the indexer's verdicts, one key per manifest digest: a manifest it has not judged yet has none, and is + // held like a refused one (ADR 0614 §8). + ReadVerdicts = "mesh-delivery-indexer.verdicts" + ReadHave = "node-engine.have" + ReadResolved = "mesh-config.resolved" + ReadWants = "mesh-delivery-assigner.wants" + ReadBuilds = "mesh-delivery-builder.builds" // ReadAccounts is the vault's bcrypt hashes of the bus accounts it holds, by bus user (ADR 0547 clause 9). ReadAccounts = "mesh-vault.bus-accounts" // ReadPlacement is the seat mesh-placement's placement (ADR 0567 §1, §9): each node's modules and the seats @@ -44,7 +47,7 @@ const ( // Reads is every state the part reads, in the manifest's order. Not the controller's assignments: the seat's placement // alone places modules (novox/hq ADR 0549 §5, ADR 0571 §10; issue 774). -var Reads = []string{ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds, +var Reads = []string{ReadModules, ReadManifests, ReadVerdicts, ReadResolved, ReadHave, ReadWants, ReadBuilds, ReadAccounts, ReadPlacement, ReadEnrolments, ReadNodeLogins} // The bus module's tools the part calls, as its manifest's invokes name them: read-only, each. @@ -336,7 +339,10 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { for _, module := range indexed { raw, found, err := r.Get(ReadModules, module) if err != nil { - in.Gaps = append(in.Gaps, Gap{Input: ReadModules + " " + module, Why: err.Error(), Failed: true}) + // One module's value that does not read holds that module alone, as a refused manifest does (ADR 0614 §8). + g := Gap{Input: "manifest of " + module, Why: "its value in " + ReadModules + " does not read: " + err.Error()} + in.Refused = append(in.Refused, g) + in.Gaps = append(in.Gaps, g) continue } if !found { @@ -360,6 +366,10 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { // The indexer's refusal, or its wait for a judge that knows a field, carried in its value (ADR 0578 §3): held and named as a refused manifest. if verdict := v.VerdictOf(module); verdict != nil && verdict.Held() { err = errors.New(verdict.Why()) + } else if verdict == nil { + // The indexer writes a changed manifest's value before it judges it, and a new digest has no verdict: until + // the verdict is written the manifest is held (ADR 0614 §8). + err = noVerdictYet(r, v.Manifest) } } if err == nil { @@ -651,3 +661,27 @@ func (in Inputs) declared() map[string]catalogue.Manifest { } return out } + +// noVerdictYet is nil when the indexer's verdicts state holds an accepted verdict on the manifest, and the reason it is held +// when it holds none yet, cannot be read, or refuses it. +func noVerdictYet(r Reader, digest string) error { + key, err := delivery.VerdictsKey(digest) + if err != nil { + return nil + } + raw, found, err := r.Get(ReadVerdicts, key) + if err != nil { + return fmt.Errorf("its verdict cannot be read: %w", err) + } + if !found { + return errors.New("the indexer has written no verdict on this manifest yet") + } + v, err := delivery.DecodeManifestVerdict(raw) + if err != nil { + return fmt.Errorf("its verdict does not read: %w", err) + } + if v.Held() { + return errors.New(v.Why()) + } + return nil +} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 11e2149f..f7fae6e5 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -8,6 +8,7 @@ "mesh-delivery-builder.builds", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", + "mesh-delivery-indexer.verdicts", "mesh-delivery-assigner.placement", "node-engine.have", "mesh-vault.bus-accounts", From cbbc10871f2ec4383ad2e08e9a6c01145e927794 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 23:45:00 +0100 Subject: [PATCH 16/17] Pin mesh-sdk 85ba0a9a70d8, the group's head of feat/l3-final (issue 774) Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 4 ++-- modules/mesh-delivery-builder/buildrun/go.mod | 2 +- modules/mesh-delivery-builder/buildrun/go.sum | 4 ++-- modules/mesh-delivery-builder/go.mod | 2 +- modules/mesh-delivery-builder/go.sum | 4 ++-- modules/mesh-delivery-checker/go.mod | 2 +- modules/mesh-delivery-checker/go.sum | 4 ++-- modules/mesh-delivery-forge-bridge/go.mod | 2 +- modules/mesh-delivery-forge-bridge/go.sum | 4 ++-- modules/mesh-delivery-indexer/go.mod | 2 +- modules/mesh-delivery-indexer/go.sum | 4 ++-- modules/mesh-delivery-teller/go.mod | 2 +- modules/mesh-delivery-teller/go.sum | 4 ++-- modules/mesh-delivery-view/go.mod | 2 +- modules/mesh-delivery-view/go.sum | 4 ++-- modules/mesh-delivery-watchman/go.mod | 2 +- modules/mesh-delivery-watchman/go.sum | 4 ++-- modules/mesh-enrolment/go.mod | 2 +- modules/mesh-enrolment/go.sum | 4 ++-- modules/mesh-grants/go.mod | 2 +- modules/mesh-grants/go.sum | 4 ++-- sdk/go.mod | 2 +- sdk/go.sum | 4 ++-- 24 files changed, 36 insertions(+), 36 deletions(-) diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 46194575..777c0c97 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index abad2969..7679a5dc 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-builder/buildrun/go.mod b/modules/mesh-delivery-builder/buildrun/go.mod index bd7aa559..1306670a 100644 --- a/modules/mesh-delivery-builder/buildrun/go.mod +++ b/modules/mesh-delivery-builder/buildrun/go.mod @@ -3,7 +3,7 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-builder/buildrun/go.sum b/modules/mesh-delivery-builder/buildrun/go.sum index abad2969..7679a5dc 100644 --- a/modules/mesh-delivery-builder/buildrun/go.sum +++ b/modules/mesh-delivery-builder/buildrun/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index 70c1b694..3131c6f3 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-builder go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-builder/go.sum b/modules/mesh-delivery-builder/go.sum index 8518fa28..7ff42872 100644 --- a/modules/mesh-delivery-builder/go.sum +++ b/modules/mesh-delivery-builder/go.sum @@ -1,7 +1,7 @@ git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg= git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index 2927751e..bf775228 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-checker go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-checker/go.sum b/modules/mesh-delivery-checker/go.sum index 8518fa28..7ff42872 100644 --- a/modules/mesh-delivery-checker/go.sum +++ b/modules/mesh-delivery-checker/go.sum @@ -1,7 +1,7 @@ git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg= git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-forge-bridge/go.mod b/modules/mesh-delivery-forge-bridge/go.mod index c3e1e343..16f053bc 100644 --- a/modules/mesh-delivery-forge-bridge/go.mod +++ b/modules/mesh-delivery-forge-bridge/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-forge-bridge/go.sum b/modules/mesh-delivery-forge-bridge/go.sum index abad2969..7679a5dc 100644 --- a/modules/mesh-delivery-forge-bridge/go.sum +++ b/modules/mesh-delivery-forge-bridge/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 36f08934..4c66d058 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index abad2969..7679a5dc 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index 28754187..ee19602f 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index abad2969..7679a5dc 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index ff85b24b..035f39f7 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index abad2969..7679a5dc 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-watchman/go.mod b/modules/mesh-delivery-watchman/go.mod index b7c67621..9a0d188f 100644 --- a/modules/mesh-delivery-watchman/go.mod +++ b/modules/mesh-delivery-watchman/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-watchman go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-watchman/go.sum b/modules/mesh-delivery-watchman/go.sum index 2f163f06..c0603c2b 100644 --- a/modules/mesh-delivery-watchman/go.sum +++ b/modules/mesh-delivery-watchman/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E= github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-enrolment/go.mod b/modules/mesh-enrolment/go.mod index 733bf732..d094f71e 100644 --- a/modules/mesh-enrolment/go.mod +++ b/modules/mesh-enrolment/go.mod @@ -3,7 +3,7 @@ module mesh-enrolment go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-enrolment/go.sum b/modules/mesh-enrolment/go.sum index abad2969..7679a5dc 100644 --- a/modules/mesh-enrolment/go.sum +++ b/modules/mesh-enrolment/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index a90cee61..ee23823d 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index abad2969..7679a5dc 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/sdk/go.mod b/sdk/go.mod index 1ae3ea03..7e692d40 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -2,4 +2,4 @@ module git.novox.be/novox/mesh-delivery/sdk go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 diff --git a/sdk/go.sum b/sdk/go.sum index 30b01cbe..bede8250 100644 --- a/sdk/go.sum +++ b/sdk/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929 h1:RtAFbUJRP1qO9AIMM2ue+cXNzqj7WZ7IOlSqNvttwuU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103174208-b7abe203a929/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= From 70ade15c36eb13f2570ed1f4226d970b7600dc7d Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 00:45:59 +0100 Subject: [PATCH 17/17] A refusal an older judge made never stands: the indexer judges again a pull's manifests and, at its start, every other judge's held verdict; indexer_judge takes a digest (issue 774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indexer judged only the manifests its modules state holds, so a digest refused by an older judge (node-tools 48c729f6 and mesh-provisioner-client a382b9df, refused for words then unknown) kept that refusal once it was back on the trunk or in a candidate. Each such digest is now judged alone, in the catalogue the trunk holds, writing its verdict and nothing else; an accepted verdict stays accepted under any later judge (ADR 0579 §1), so only a refused or waiting verdict by another judge, or none, is judged again. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-delivery-indexer-tools/main.go | 6 +- .../cmd/mesh-delivery-indexer/takein.go | 4 +- .../internal/index/candidates.go | 14 ++ .../internal/index/takein.go | 4 +- .../internal/index/verdicts.go | 158 ++++++++++++++++-- .../internal/index/verdicts_test.go | 49 +++++- 6 files changed, 212 insertions(+), 23 deletions(-) diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go index d9cb1aa0..baa62b17 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go @@ -104,12 +104,14 @@ func Tools(ask asker, now func() time.Time, read stdio.KeptState, counts func() Description: "Judge the manifests again now, as the indexer does at its start and after each merge, and answer how the module's " + "verdict stands (waits, refused or accepted, and why). For a manifest that waits for a judge that knows its fields, or " + "after a manifest was mended where the indexer has not heard it. A waiting manifest becomes refused here only when the " + - "running indexer is the newest build of itself the trunk holds. indexer_verdicts reads the result.", + "running indexer is the newest build of itself the trunk holds. With a digest it judges that one manifest of the " + + "module (a candidate's, or one an older judge refused) in the catalogue the trunk holds. indexer_verdicts reads the result.", Input: map[string]any{"type": "object", "required": []string{"module"}, "properties": map[string]any{ "module": map[string]any{"type": "string", "description": "the module's name, as the catalogue registers it"}, + "digest": map[string]any{"type": "string", "description": "one manifest digest of the module to judge (indexer_verdicts names it); none judges every manifest the trunk holds"}, "why": map[string]any{"type": "string", "description": "why it is judged again now, in plain words"}}}, Run: func(args map[string]any) (any, error) { - key, value, err := index.NewJudgeAgain(str(args, "module"), str(args, "why"), stdio.CallerOf(args), now()) + key, value, err := index.NewJudgeAgain(str(args, "module"), str(args, "digest"), str(args, "why"), stdio.CallerOf(args), now()) if err != nil { return nil, err } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go index a32aea21..7a98edb7 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go @@ -15,7 +15,7 @@ import ( type takerIn interface { TakeIn(ctx context.Context, module string) index.TakenIn TakeInRepository(ctx context.Context, repository string) index.TakenIn - JudgeAgain(ctx context.Context, module string) index.TakenIn + JudgeAgain(ctx context.Context, module, digest string) index.TakenIn } // takenIn answers one value of the takeins state (the tool indexer_take_in, issue 1945): a value not yet answered takes the @@ -38,7 +38,7 @@ func takenIn(ctx context.Context, ix takerIn, kv jetstream.KeyValue, e jetstream asked, r = "take-in of a repository", ix.TakeInRepository(ctx, a.Repository) a.Module = a.Repository } else if a.Judge { - asked, r = "judging again", ix.JudgeAgain(ctx, a.Module) + asked, r = "judging again", ix.JudgeAgain(ctx, a.Module, a.Digest) } else { r = ix.TakeIn(ctx, a.Module) } diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index b1620ac0..85d1c415 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -186,6 +186,20 @@ func (ix *Indexer) ApplyMessage(ctx context.Context, f delivery.Forge) (Applied, if held, err = ix.heldModules(ctx); err != nil { return applied, err } + // Each manifest the pull carries is judged by this judge, unless it holds a verdict already that this judge made or + // that accepts it: a refusal an older judge made never stands for a candidate (or a digest back on the trunk). + over := map[string]string{} + for _, digest := range digests { + if ix.Manifests == nil { + break + } + if value, _, found, err := ix.Manifests.Get(ctx, digest); err == nil && found { + if kept, err := delivery.DecodeManifest(value); err == nil { + over[kept.Module] = digest + } + } + } + ix.judgeDigests(ctx, over, fmt.Sprintf("%s#%d", f.Repository, f.Number)) if err := ix.configCandidateOfPull(ctx, f, held); err != nil { if !errors.Is(err, ErrUnreadable) { return applied, err diff --git a/modules/mesh-delivery-indexer/internal/index/takein.go b/modules/mesh-delivery-indexer/internal/index/takein.go index 10866eb0..579ba62b 100644 --- a/modules/mesh-delivery-indexer/internal/index/takein.go +++ b/modules/mesh-delivery-indexer/internal/index/takein.go @@ -31,7 +31,9 @@ type TakeInAsk struct { Repository string `json:"repository,omitempty"` Why string `json:"why,omitempty"` // Judge asks the manifests to be judged again now (indexer_judge_again) instead of the module to be taken in. - Judge bool `json:"judge,omitempty"` + Judge bool `json:"judge,omitempty"` + // Digest names, with Judge, the one manifest digest of the module to judge (indexer_judge with a digest). + Digest string `json:"digest,omitempty"` By string `json:"by,omitempty"` At time.Time `json:"at"` Result *TakenIn `json:"result,omitempty"` diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts.go b/modules/mesh-delivery-indexer/internal/index/verdicts.go index 50b72e2d..1263a8e1 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts.go @@ -49,6 +49,14 @@ type Judged struct { // watchman raises as manifest-outgrown. A manifest in fresh (the digests a merge just took in) with no verdict yet, and // one already refused, is judged in full. func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, error) { + return ix.judgeOver(ctx, fresh, nil) +} + +// judgeOver is JudgeAll; with over (module to manifest digest) it judges those digests alone, each in the catalogue the +// modules state holds with its module's manifest replaced by it (a module the state does not hold added), and writes +// their verdicts and nothing else: no event, no count of the old sections, and no module's value but one that names the +// digest judged (carryVerdict). A digest of over with no verdict yet is judged in full, as a fresh one. +func (ix *Indexer) judgeOver(ctx context.Context, fresh map[string]bool, over map[string]string) (Judged, error) { // A merge, the start pass and indexer_judge may all judge at once: one pass at a time, so none writes a verdict from // modules another is rewriting. ix.judgeMu.Lock() @@ -61,6 +69,15 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, if err != nil { return out, err } + if over != nil { + fresh = map[string]bool{} + for module, digest := range over { + m := held[module] + m.Manifest = digest + held[module] = m + fresh[digest] = true + } + } raws := map[string][]byte{} digests := map[string]string{} for _, name := range slices.Sorted(maps.Keys(held)) { @@ -99,12 +116,12 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, // release's Approve; ADR 0578 §1): it is refused only when this program is the newest build of the indexer the trunk // holds, so no judge that could know the field is still to come. newest := ix.ownBuildIsNewest(held) - id := ix.JudgeID - if id == "" { - id = judge.Identity() - } + id := ix.judgeID() var accepted []catalogue.Manifest for _, name := range slices.Sorted(maps.Keys(digests)) { + if _, judged := over[name]; over != nil && !judged { + continue + } out.Judged++ v := delivery.ManifestVerdict{Module: name, State: delivery.VerdictAccepted, Judge: id, At: ix.now()} if fields := waits[name]; len(fields) > 0 { @@ -143,7 +160,10 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, ix.logf("judge: the verdict on %s (digest %s) is not written: %v", name, digests[name], err) continue } - if wrote { + if wrote && over != nil { + out.Written++ + ix.logf("judge: %s judged again by %s: %s", digests[name], id, v.State) + } else if wrote { out.Written++ if v.Waits() { ix.logf("judge: %s", v.Why()) @@ -166,6 +186,9 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, ix.logf("judge: modules.%s does not carry its verdict: %v", name, err) } } + if over != nil { + return out, nil + } if err := ix.keepOldSections(ctx, judge.CountOldSections(accepted)); err != nil { ix.logf("judge: the count of the old sections is not kept: %v", err) } @@ -173,14 +196,86 @@ func (ix *Indexer) JudgeAll(ctx context.Context, fresh map[string]bool) (Judged, } // JudgeAtStart is JudgeAll of every manifest held, none fresh, as the indexer starts (a judge that changed judges every -// one again); one that fails is judged again at the next message or source revision heard (kickJudge). +// one again); one that fails is judged again at the next message or source revision heard (kickJudge). Then every other +// digest of the verdicts state that another judge refused or held waiting (one of a candidate, or one back on the trunk +// later) is judged again by this judge (judgeStale), so a refusal an older judge made never stands for good. func (ix *Indexer) JudgeAtStart(ctx context.Context) (Judged, error) { ix.learnOwnBuild(ctx) j, err := ix.JudgeAll(ctx, nil) ix.judgePending.Store(err != nil) + if err == nil { + ix.judgeStale(ctx) + } return j, err } +// judgeID is the judge a verdict of this program names. +func (ix *Indexer) judgeID() string { + if ix.JudgeID != "" { + return ix.JudgeID + } + return judge.Identity() +} + +// staleVerdict says whether the digest's verdict wants this judge's judgement: there is none, or another judge refused +// it or held it waiting. An accepted verdict stays accepted under any later judge (ADR 0579 §1), so judging it again +// changes nothing. +func (ix *Indexer) staleVerdict(ctx context.Context, digest string) bool { + value, _, found, err := ix.Verdicts.Get(ctx, digest) + if err != nil { + return false // unread: left, said by the next pass that reads it + } + if !found { + return true + } + v, err := delivery.DecodeManifestVerdict(value) + return err != nil || (v.Held() && v.Judge != ix.judgeID()) +} + +// judgeDigests judges each digest of over (module to digest) whose verdict is stale (staleVerdict), one at a time in the +// catalogue the modules state holds; a pass that fails is said and the others go on. +func (ix *Indexer) judgeDigests(ctx context.Context, over map[string]string, why string) { + if ix.Verdicts == nil || ix.Manifests == nil || ix.Modules == nil { + return + } + for _, module := range slices.Sorted(maps.Keys(over)) { + if !ix.staleVerdict(ctx, over[module]) { + continue + } + if _, err := ix.judgeOver(ctx, nil, map[string]string{module: over[module]}); err != nil { + ix.logf("judge: the manifest %s of %s (%s) is not judged: %v", over[module], module, why, err) + } + } +} + +// judgeStale judges again every digest of the verdicts state another judge refused or held waiting, each as a manifest +// of the module its verdict names (judgeDigests). Never on a timer: at the start pass. +func (ix *Indexer) judgeStale(ctx context.Context) { + if ix.Verdicts == nil { + return + } + keys, err := ix.Verdicts.Keys(ctx) + if err != nil { + ix.logf("judge: the verdicts other judges made are not judged again: listing the verdicts: %v", err) + return + } + byModule := map[string][]string{} + for _, digest := range keys { + value, _, found, err := ix.Verdicts.Get(ctx, digest) + if err != nil || !found { + continue + } + if v, err := delivery.DecodeManifestVerdict(value); err == nil && v.Held() && v.Judge != ix.judgeID() { + byModule[v.Module] = append(byModule[v.Module], digest) + } + } + for _, module := range slices.Sorted(maps.Keys(byModule)) { + for _, digest := range byModule[module] { + ix.judgeDigests(ctx, map[string]string{module: digest}, "made by another judge") + } + } +} + // kickJudge judges again a start pass that failed, when the indexer hears something: never on a timer. func (ix *Indexer) kickJudge(ctx context.Context) { if !ix.judgePending.Load() { @@ -374,26 +469,55 @@ func ListVerdicts(keys []string, get func(key string) ([]byte, error), module st // JudgeAgain judges the manifests again now, as at the start, and answers how the module's stands: the tool // indexer_judge. A module the modules state does not hold, or whose manifest is not in the manifests state, is -// answered so. -func (ix *Indexer) JudgeAgain(ctx context.Context, module string) TakenIn { +// answered so. With a digest it judges that manifest alone, in the catalogue the trunk holds (judgeOver), whoever made +// its verdict: a candidate's, or one the trunk names again, refused by an older judge. +func (ix *Indexer) JudgeAgain(ctx context.Context, module, digest string) TakenIn { out := TakenIn{Module: module} held, err := ix.heldModules(ctx) if err != nil { out.Answer = "the modules state cannot be read: " + err.Error() return out } + // The build this program runs is asked again, so a start pass that missed the engine's answer is recovered without a restart. + ix.learnOwnBuild(ctx) + if digest != "" { + // One digest (a candidate's, or one the trunk no longer names), judged by this judge in the catalogue the trunk holds. + value, _, found, err := ix.Manifests.Get(ctx, digest) + kept, derr := delivery.DecodeManifest(value) + switch { + case err != nil || !found: + out.Answer = "the indexer keeps no manifest of digest " + digest + return out + case derr != nil: + out.Answer = "the manifest of digest " + digest + " cannot be read: " + derr.Error() + return out + case module != "" && kept.Module != module: + out.Answer = "the manifest of digest " + digest + " is " + kept.Module + "'s, not " + module + "'s" + return out + } + out.Module = kept.Module + if _, err := ix.judgeOver(ctx, nil, map[string]string{kept.Module: digest}); err != nil { + out.Answer = "the manifest could not be judged: " + err.Error() + return out + } + return ix.answerVerdict(ctx, out, digest) + } m, ok := held[module] if !ok || m.Manifest == "" { out.Answer = "the indexer holds no manifest of " + module return out } - // The build this program runs is asked again, so a start pass that missed the engine's answer is recovered without a restart. - ix.learnOwnBuild(ctx) if _, err := ix.JudgeAll(ctx, nil); err != nil { out.Answer = "the manifests could not be judged: " + err.Error() return out } - value, _, found, err := ix.Verdicts.Get(ctx, m.Manifest) + return ix.answerVerdict(ctx, out, m.Manifest) +} + +// answerVerdict is out answering how the verdict under digest stands. +func (ix *Indexer) answerVerdict(ctx context.Context, out TakenIn, digest string) TakenIn { + module := out.Module + value, _, found, err := ix.Verdicts.Get(ctx, digest) if err != nil || !found { out.Answer = "judged, but the verdict on " + module + " cannot be read" return out @@ -411,8 +535,14 @@ func (ix *Indexer) JudgeAgain(ctx context.Context, module string) TakenIn { return out } -// NewJudgeAgain is the value indexer_judge writes for a module: its key in the takeins state and the value. -func NewJudgeAgain(module, why, by string, at time.Time) (string, []byte, error) { +// NewJudgeAgain is the value indexer_judge writes for a module, and one digest of its manifest when given: its key in +// the takeins state and the value. +func NewJudgeAgain(module, digest, why, by string, at time.Time) (string, []byte, error) { + if digest != "" { + if _, err := delivery.ManifestsKey(digest); err != nil { + return "", nil, err + } + } key, data, err := NewTakeIn(module, why, by, at) if err != nil { return "", nil, err @@ -421,7 +551,7 @@ func NewJudgeAgain(module, why, by string, at time.Time) (string, []byte, error) if err := json.Unmarshal(data, &a); err != nil { return "", nil, err } - a.Judge = true + a.Judge, a.Digest = true, digest data, err = json.Marshal(a) return judgeAgainPrefix + key, data, err } diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index c19f5143..b8ce4a49 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -276,25 +276,66 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { // the indexer is the newer one, so the field waits; asked and answered with that same build it is refused). ix.ownFP = "" ix.OwnBuild = func(context.Context) (string, error) { return "src2:" + strings.Repeat("a", 64), nil } - if got := ix.JudgeAgain(ctx, "later"); !got.Stored || !strings.Contains(got.Answer, "refused") || ix.ownFP == "" { + if got := ix.JudgeAgain(ctx, "later", ""); !got.Stored || !strings.Contains(got.Answer, "refused") || ix.ownFP == "" { t.Fatalf("indexer_judge did not learn the running build: %+v", got) } ix.ownFP = ownFP ix.OwnBuild = nil - if got := ix.JudgeAgain(ctx, "later"); !got.Stored || !strings.Contains(got.Answer, "waits") || !strings.Contains(got.Answer, "a-field-from-later") { + if got := ix.JudgeAgain(ctx, "later", ""); !got.Stored || !strings.Contains(got.Answer, "waits") || !strings.Contains(got.Answer, "a-field-from-later") { t.Fatalf("indexer_judge: %+v", got) } if rows, _ := ListVerdicts(keys, get, "later", false); len(rows) != 1 { t.Fatalf("indexer_verdicts of later after: %+v", rows) } - if got := ix.JudgeAgain(ctx, "nobody"); got.Stored || !strings.Contains(got.Answer, "holds no manifest") { + if got := ix.JudgeAgain(ctx, "nobody", ""); got.Stored || !strings.Contains(got.Answer, "holds no manifest") { t.Fatalf("indexer_judge of nobody: %+v", got) } - if key, value, err := NewJudgeAgain("later", "why", "me", time.Now()); err != nil || key != "judge.later" { + if key, value, err := NewJudgeAgain("later", "", "why", "me", time.Now()); err != nil || key != "judge.later" { t.Fatalf("the ask: %q %v", key, err) } else if a, err := DecodeTakeIn(value); err != nil || !a.Judge || a.Module != "later" { t.Fatalf("the ask's value: %+v %v", a, err) } + // A refusal an older judge made never stands: a digest the trunk no longer names (a candidate's, or one back on the + // trunk later) refused by judge-0 for words it did not know is judged again by this judge at the start, at a pull that + // carries it, and by indexer_judge with its digest; one this judge refused itself is left as it is. + stale := func(module, raw string) string { + digest, err := ix.keepManifest(ctx, module, raw) + if err != nil { + t.Fatal(err) + } + if _, err := ix.writeVerdict(ctx, digest, delivery.ManifestVerdict{Module: module, State: delivery.VerdictRefused, + Problems: []string{module + " says tool-runner, which this judge does not know"}, Judge: "judge-0", At: time.Now()}); err != nil { + t.Fatal(err) + } + return digest + } + atStart := stale("tools", `{"module":"tools","version":"7"}`) + ownRefusal := stale("client", `{"module":"client","version":"7"}`) + if _, err := ix.writeVerdict(ctx, ownRefusal, delivery.ManifestVerdict{Module: "client", State: delivery.VerdictRefused, + Problems: []string{"x"}, Judge: ix.judgeID(), At: time.Now()}); err != nil { + t.Fatal(err) + } + if _, err := ix.JudgeAtStart(ctx); err != nil || verdict(atStart).Refused() || verdict(atStart).Judge != ix.judgeID() || !verdict(ownRefusal).Refused() { + t.Fatalf("the start pass: %+v, this judge's own refusal %+v (%v)", verdict(atStart), verdict(ownRefusal), err) + } + byPull := stale("tools", `{"module":"tools","version":"8"}`) + pull := delivery.Forge{Kind: delivery.ForgePull, Repository: "novox/mesh-tools", Number: 3, Branch: "main", + Manifests: map[string]string{"modules/tools": `{"module":"tools","version":"8"}`}} + if _, err := ix.ApplyMessage(ctx, pull); err != nil || verdict(byPull).Refused() || verdict(byPull).Judge != ix.judgeID() { + t.Fatalf("a pull carrying a digest an older judge refused: %+v %v", verdict(byPull), err) + } + byTool := stale("tools", `{"module":"tools","version":"9"}`) + if got := ix.JudgeAgain(ctx, "client", byTool); got.Stored || !strings.Contains(got.Answer, "tools's, not client's") || !verdict(byTool).Refused() { + t.Fatalf("indexer_judge with another module's digest: %+v", got) + } + if got := ix.JudgeAgain(ctx, "tools", byTool); !got.Stored || !strings.Contains(got.Answer, "accepted") || verdict(byTool).Refused() { + t.Fatalf("indexer_judge with a digest: %+v %+v", got, verdict(byTool)) + } + if key, value, err := NewJudgeAgain("tools", byTool, "why", "me", time.Now()); err != nil || key != "judge.tools" { + t.Fatalf("the ask with a digest: %q %v", key, err) + } else if a, err := DecodeTakeIn(value); err != nil || a.Digest != byTool { + t.Fatalf("the ask's digest: %+v %v", a, err) + } // Without the verdicts state the indexer judges nothing. ix.Verdicts = nil if j, err := ix.JudgeAll(ctx, nil); err != nil || j.Judged != 0 {