From 6d423fb3dc4f2bf1fdfe027ff37960c4a30f10b1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:59:22 +0100 Subject: [PATCH 1/8] fix9-1: the teller, view and watchman manifest tests' reader takes requires, and hold the findings as requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §383, §387; A-30 --- modules/mesh-delivery-teller/manifest_test.go | 31 ++++++++++++++++--- modules/mesh-delivery-view/manifest_test.go | 29 +++++++++++++++-- .../mesh-delivery-watchman/manifest_test.go | 30 +++++++++++++++--- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/modules/mesh-delivery-teller/manifest_test.go b/modules/mesh-delivery-teller/manifest_test.go index bbc56f58..aca44653 100644 --- a/modules/mesh-delivery-teller/manifest_test.go +++ b/modules/mesh-delivery-teller/manifest_test.go @@ -55,6 +55,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` @@ -93,7 +94,7 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1 // answers is the teller's answer to each ask; asked is what it asked of the router, which condition it raised and which // gate key and revision each ask was made at (issue 1343); ends is the ends asked by hand with teller_end_ask (issue 774). // first-views is where its first view of the assigner's asks stands and findings its condition past the bound (ADR 0615). - if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } // The view's answers to the assigner's asks are read so the view's answer fills an ask that needs no proof (1297/B10); the @@ -133,14 +134,17 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1 t.Errorf("emits = %v, want %v", m.Emits, want) } // design 47 G §383, §387: the stream -findings, 7 days and 16 MiB; §368: version is MAJOR.MINOR. - if want := []stream{{Name: "mesh-delivery-teller-findings", Events: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, MaxAgeDays: 7, MaxMiB: 16}}; !reflect.DeepEqual(m.Streams, want) { - t.Errorf("streams = %+v, want %+v", m.Streams, want) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(1); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } if m.Version != "0.1" { t.Errorf("version = %q, want 0.1", m.Version) } // No bus or seat (ADR 0437 §4). - for _, k := range []string{"logs", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} { + for _, k := range []string{"logs", "claims", "provides", "defines-seats", "seat", "seats", "bus"} { if _, ok := top[k]; ok { t.Errorf("the manifest declares %q: the teller has none", k) } @@ -243,3 +247,22 @@ func TestNoPartHandWritesItsBusConnection(t *testing.T) { } } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 846c0b58..bf6b6067 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -75,6 +75,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Items []item `json:"items"` Build struct { Artifacts []artifact `json:"artifacts"` @@ -128,8 +129,11 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 t.Errorf("invokes = %v, want %v", m.Invokes, want) } // design 47 G §387: the keeper's ask for the findings is heard, the finding stream declared (§383), and §368: version is MAJOR.MINOR. - if want := []stream{{Name: "mesh-delivery-view-findings", Events: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, MaxAgeDays: 7, MaxMiB: 16}}; !reflect.DeepEqual(m.Streams, want) { - t.Errorf("streams = %+v, want %+v", m.Streams, want) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(1); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } if m.Version != "0.1" { t.Errorf("version = %q, want 0.1", m.Version) @@ -149,7 +153,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 // The states the view writes: the operator's answers to the asks that need no proof (ADR 0528 clause 6, 1297/B9), which the // teller reads, and how far each repository reached (ADR 0556 clause 2, issue 1280), which the gate reads. // Its first views of the owners' states and the finding of one unanswered past its bound (ADR 0615 §6). - if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 { @@ -430,3 +434,22 @@ func TestEveryStateTheCommandsReadIsIssued(t *testing.T) { t.Fatal("no stdio.State literal found under cmd/: the scan reads nothing") } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +} diff --git a/modules/mesh-delivery-watchman/manifest_test.go b/modules/mesh-delivery-watchman/manifest_test.go index 5f6a53dc..16598284 100644 --- a/modules/mesh-delivery-watchman/manifest_test.go +++ b/modules/mesh-delivery-watchman/manifest_test.go @@ -66,6 +66,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` @@ -95,7 +96,7 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 if m.Module != "mesh-delivery-watchman" || m.Moved || m.Slug != "watchman" { t.Errorf("module %q moved %v (it has no template, so it is not moved) slug %q", m.Module, m.Moved, m.Slug) } - want := []state{{Name: "presence", History: 20}, {Name: "pulse", KeyTTL: true}, {Name: "stuck", History: 20}, {Name: "findings", History: 5}, {Name: "first-views", History: 1}} + want := []state{{Name: "presence", History: 20}, {Name: "pulse", KeyTTL: true}, {Name: "stuck", History: 20}, {Name: "first-views", History: 1}} if !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } @@ -110,9 +111,11 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 t.Errorf("version = %q, want MAJOR.MINOR", m.Version) } // Design 47 G §387: the finding stream, 7 days and 16 MiB, and the keeper's ask. - if len(m.Streams) != 1 || m.Streams[0].Name != "mesh-delivery-watchman-findings" || m.Streams[0].MaxAgeDays != 7 || m.Streams[0].MaxMiB != 16 || - !reflect.DeepEqual(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) { - t.Errorf("streams = %+v, want the finding stream", m.Streams) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(5); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } // DLV-M5 (ADR 0615 §1): the assigner's wants and the indexer's modules are their owners' states, asked by their read // tools and followed by their views families, never read. @@ -266,3 +269,22 @@ func TestNoTickerTimerOrSleep(t *testing.T) { // 1135/AC7 t.Fatalf("read %d source files, want the program's", files) } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +} From 6d94262f8c096050036ef8595891244445b4696f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:59:48 +0100 Subject: [PATCH 2/8] fix9-2: the enrolment's manifest test holds its findings as requires (two states, the findings state and the finding stream) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §383, §387; A-30 --- .../cmd/mesh-enrolment/tokens_test.go | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go index fa8dfbe3..cbfca62a 100644 --- a/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go +++ b/modules/mesh-enrolment/cmd/mesh-enrolment/tokens_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -82,11 +83,17 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) { Scope string `json:"scope"` Serves []string `json:"serves"` } `json:"claims"` - Emits []string `json:"emits"` - Reads []string `json:"reads"` - Invokes []string `json:"invokes"` - Tools []string `json:"tools"` - State []json.RawMessage `json:"state"` + Emits []string `json:"emits"` + Reads []string `json:"reads"` + Invokes []string `json:"invokes"` + Tools []string `json:"tools"` + State []json.RawMessage `json:"state"` + Requires []struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` + } `json:"requires"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) @@ -132,7 +139,7 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) { t.Errorf("the verb %s has no tool", v) } } - if len(m.State) != 3 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool { + if len(m.State) != 2 || string(m.State[0]) == "" || !slices.ContainsFunc(m.State, func(s json.RawMessage) bool { var d struct { Name string `json:"name"` TTL bool `json:"key-ttl"` @@ -141,4 +148,12 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) { }) { t.Errorf("state %s: enrolments must allow a time to live per key", m.State) } + // Design 47 G §383, §387 (A-30): its findings are the requires findings (state) and finding-events (the three finding + // events, 7 days, 16 MiB), not a state or a stream of its own. + if len(m.Requires) != 2 || m.Requires[0].Name != "findings" || m.Requires[0].Storage != "key-value" || m.Requires[0].Template != "state" || + m.Requires[1].Name != "finding-events" || m.Requires[1].Storage != "stream" || m.Requires[1].Template != "events" || + m.Requires[1].Options["max-age-days"] != float64(7) || m.Requires[1].Options["max-mib"] != float64(16) || + !reflect.DeepEqual(m.Requires[1].Options["events"], []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) { + t.Errorf("requires %+v: want the findings state and the finding stream", m.Requires) + } } From 1e2091fcba840db746d7b7040d9a260d0373b7ec Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:00:31 +0100 Subject: [PATCH 3/8] fix9-3: the indexer judge's process-bus rule accepts serves, read whole by the grant composition Builds: design 47 G A-107, A-109, A-110 --- modules/mesh-delivery-indexer/judge/rules_test.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 7ff73f7e..a1db9033 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -2457,11 +2457,11 @@ func caseAProcessBusNamesOnlyTheModulesOwn(t *testing.T) { } } -// 1179/AC4: a bus declaring serves, holds, claims or a seat is refused naming the key; a bus with invokes of -// a tool or a seat verb is accepted. +// 1179/AC4, changed by design 47 G A-107, A-109, A-110: a bus declaring holds, claims or a seat is refused naming the +// key; a bus declaring serves (the seat verbs the process answers, each seat:.) is accepted and given whole to +// the grant composition, as is a bus with invokes of a tool or a seat verb. func caseAProcessBusNeverServesOrHoldsASeat(t *testing.T) { for name, c := range map[string]struct{ bus, says string }{ - "serves": {`{"emits":["mail.send.sent"],"serves":["send"]}`, "serves"}, "holds": {`{"emits":["mail.send.sent"],"holds":["mail-sender"]}`, "holds"}, "claims": {`{"emits":["mail.send.sent"],"claims":["mail-sender"]}`, "claims"}, "seat": {`{"emits":["mail.send.sent"],"seat":"mail-sender"}`, "seat"}, @@ -2475,6 +2475,14 @@ func caseAProcessBusNeverServesOrHoldsASeat(t *testing.T) { if _, err := ParseManifest([]byte(mailSenderProcessManifest(t, accepted))); err != nil { t.Fatalf("a bus invoking a tool and a seat verb was refused: %v", err) } + serving := `{"emits":["mail.send.sent"],"serves":["seat:mail-sender.send"]}` + m, err := ParseManifest([]byte(mailSenderProcessManifest(t, serving))) + if err != nil { + t.Fatalf("a bus serving a seat verb was refused: %v", err) + } + if got := m.ProcessBuses()["send"].Serves; !strSliceEqual(got, []string{"seat:mail-sender.send"}) { + t.Fatalf("the process's serves read as %v", got) + } } // 1179/AC1: a well-formed bus on a process whose account the module makes is accepted, and the catalogue From d260113d51274fcaaaa699e3c284a00fe989645e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:01:30 +0100 Subject: [PATCH 4/8] fix9-4: the mesh-wants wanted stream keeps each want's last word with no age limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated contract is unchanged by seatgen (the keep is read from the definition). Builds: design 47 G A-114 (§693, A-61) --- contracts/seats/mesh-wants/seat.json | 3 +- .../internal/grants/wantedkeep_test.go | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-grants/internal/grants/wantedkeep_test.go diff --git a/contracts/seats/mesh-wants/seat.json b/contracts/seats/mesh-wants/seat.json index f149808c..e0564718 100644 --- a/contracts/seats/mesh-wants/seat.json +++ b/contracts/seats/mesh-wants/seat.json @@ -305,7 +305,8 @@ "name": "wanted", "subject": "wanted..", "keep": { - "last-per-subject": true + "last-per-subject": true, + "max-age-days": 0 }, "body": { "type": "object", diff --git a/modules/mesh-grants/internal/grants/wantedkeep_test.go b/modules/mesh-grants/internal/grants/wantedkeep_test.go new file mode 100644 index 00000000..e8356bbe --- /dev/null +++ b/modules/mesh-grants/internal/grants/wantedkeep_test.go @@ -0,0 +1,32 @@ +package grants + +import ( + "os" + "path/filepath" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// Design 47 G A-114 (§693, A-61): the seat mesh-wants' `wanted` stream keeps the last message of each subject with no age +// limit, so each want's last word is read whole at a start. +func TestWantedKeepsEachWantsLastWordWithNoAgeLimit(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "contracts", "seats", "mesh-wants", "seat.json")) + if err != nil { + t.Fatal(err) + } + d, err := seatdef.Parse(raw) + if err != nil { + t.Fatal(err) + } + for _, e := range d.Events { + if e.Name != "wanted" { + continue + } + if !e.KeptLastPerSubject() || e.KeptAgeDays() != 0 { + t.Errorf("wanted keeps last-per-subject %v for %d days, want true and 0 (no age limit)", e.KeptLastPerSubject(), e.KeptAgeDays()) + } + return + } + t.Fatal("the seat mesh-wants has no event wanted") +} From a8601f5a5ca2df22d260163aea7a57f481b8ad6c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:03:31 +0100 Subject: [PATCH 5/8] fix9-5: the indexer, the runner and the teller read the router's record through mesh-sdk's operatorask; no copy of the seat-holder lookup, no messenger_asks constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G A-101, A-74, §700 --- .../cmd/mesh-delivery-indexer/registry.go | 45 +++++-------------- .../cmd/mesh-delivery-pipeline/approval.go | 44 ++++-------------- .../cmd/mesh-delivery-teller/decided.go | 37 +++++---------- .../mesh-delivery-teller/integration_test.go | 25 +++++++++-- .../cmd/mesh-delivery-teller/main.go | 16 +++---- 5 files changed, 60 insertions(+), 107 deletions(-) diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/registry.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/registry.go index 8778e00b..527c941f 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/registry.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/registry.go @@ -8,12 +8,12 @@ import ( "time" mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/operatorask" "git.novox.be/novox/mesh-sdk/go/process" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -135,41 +135,16 @@ func newRegistry(js jetstream.JetStream, conn *nats.Conn, cred process.Account, Logf: logf} } -// routerBucket is the bucket of the router's asks: the state `asks` of the seat operator-channel (the contract's StateAsks) in -// the bucket of the seat's holder on the seat list, which the seat mesh-module-assigner's verb assignments answers with seats -// (design 47 G §700, A-74); no module's name is in the program. -func routerBucket(call toolCaller) (string, error) { - raw, err := call("seat:"+mma.Seat+"."+mma.VerbAssignments, mma.AssignmentsInput{Seats: mma.AssignmentsInputSeatsTrue}) - if err != nil { - return "", fmt.Errorf("who holds the seat %s is not asked of the seat %s: %w", oc.Seat, mma.Seat, err) - } - var held struct { - Seats []mma.SeatHolders `json:"seats"` - } - if err := json.Unmarshal(raw, &held); err != nil { - return "", fmt.Errorf("the answer of %s.%s is not its seat's: %w", mma.Seat, mma.VerbAssignments, err) - } - for _, s := range held.Seats { - if s.Seat == oc.Seat && len(s.Holders) > 0 { - return s.Holders[0].Module + "_" + oc.StateAsks, nil - } - } - return "", fmt.Errorf("no module holds the seat %s on the seat list, so the router's record of an ask is not read", oc.Seat) -} - -// routerRecordOf is Registry.Where: the router's own record of an ask, read under the indexer's module's name through -// operatorchannelclient, whose Record is a get of that key (`.` of the seat's state asks) on the process's own bus -// connection (record is process.Conn.Ask), as the tool runner gives it the runtime's mesh/seat.record (design 47 G §700): never -// the decided event's body. A warrant is then left unapplied and said while the record cannot be read. +// routerRecordOf is Registry.Where: the router's own record of an ask, read under the indexer's module's name by mesh-sdk's +// operatorask (design 47 G A-101, §700, A-74): the state asks of the seat operator-channel (the contract's StateAsks) in the +// bucket of the seat's holder on the seat list, which the seat mesh-module-assigner's verb assignments answers with seats; +// record is process.Conn.Ask, as the tool runner gives it the runtime's mesh/seat.record: never the decided event's body. A +// warrant is then left unapplied and said while the record cannot be read. func routerRecordOf(module string, call toolCaller, record func(bucket, id string) (*stdio.StateEntry, error)) func(id string) (string, *oc.Warrant, error) { - client := &operatorchannelclient.Client{Module: module, Record: func(id string) (*stdio.StateEntry, error) { - bucket, err := routerBucket(call) - if err != nil { - return nil, err - } - return record(bucket, id) - }} - return client.Where + asker := &operatorask.Asker[oc.Warrant]{Module: module, Call: operatorask.Caller(call), Record: record, + Contract: operatorask.Contract{Lists: "seat:" + mma.Seat + "." + mma.VerbAssignments, ListBody: mma.AssignmentsInput{Seats: mma.AssignmentsInputSeatsTrue}, + Seat: oc.Seat, StateAsks: oc.StateAsks}} + return asker.Where } // heardDecided is the handler of the decided events for this module: a warrant is applied by the registry; one it could not diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/approval.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/approval.go index ff6cae89..a5419fcb 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/approval.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/approval.go @@ -3,16 +3,15 @@ package main import ( "encoding/json" "errors" - "fmt" "time" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/operatorask" "git.novox.be/novox/mesh-sdk/go/process" "mesh-delivery-pipeline/internal/pipeline" @@ -21,40 +20,15 @@ import ( // toolCaller is a kept call of a seat's verb on the bus (process.Conn.Call). type toolCaller func(key string, body any) (json.RawMessage, error) -// routerBucket is the bucket of the router's asks: the state `asks` of the seat operator-channel (the contract's StateAsks) in -// the bucket of the seat's holder on the seat list, which the seat mesh-module-assigner's verb assignments answers with seats -// (design 47 G §700, A-74); no module's name is in the program. (The indexer's function, to move into mesh-sdk.) -func routerBucket(call toolCaller) (string, error) { - raw, err := call("seat:"+mma.Seat+"."+mma.VerbAssignments, mma.AssignmentsInput{Seats: mma.AssignmentsInputSeatsTrue}) - if err != nil { - return "", fmt.Errorf("who holds the seat %s is not asked of the seat %s: %w", oc.Seat, mma.Seat, err) - } - var held struct { - Seats []mma.SeatHolders `json:"seats"` - } - if err := json.Unmarshal(raw, &held); err != nil { - return "", fmt.Errorf("the answer of %s.%s is not its seat's: %w", mma.Seat, mma.VerbAssignments, err) - } - for _, s := range held.Seats { - if s.Seat == oc.Seat && len(s.Holders) > 0 { - return s.Holders[0].Module + "_" + oc.StateAsks, nil - } - } - return "", fmt.Errorf("no module holds the seat %s on the seat list, so the router's record of an ask is not read", oc.Seat) -} - -// routerRecordOf is Hearer.Where: the router's own record of an ask, read under the runner's module's name through -// operatorchannelclient, whose Record is a get of that key (`.` of the seat's state asks) on the process's own bus -// connection (design 47 G §700, A-89): never the decided event's body. +// routerRecordOf is Hearer.Where: the router's own record of an ask, read under the runner's module's name by mesh-sdk's +// operatorask (design 47 G A-101, §700, A-89): the state asks of the seat operator-channel in the bucket of the seat's holder +// on the seat list, which the seat mesh-module-assigner's verb assignments answers with seats; record is a get of the key +// `.` on the process's own bus connection: never the decided event's body. func routerRecordOf(module string, call toolCaller, record func(bucket, id string) (*stdio.StateEntry, error)) func(id string) (string, *oc.Warrant, error) { - client := &operatorchannelclient.Client{Module: module, Record: func(id string) (*stdio.StateEntry, error) { - bucket, err := routerBucket(call) - if err != nil { - return nil, err - } - return record(bucket, id) - }} - return client.Where + asker := &operatorask.Asker[oc.Warrant]{Module: module, Call: operatorask.Caller(call), Record: record, + Contract: operatorask.Contract{Lists: "seat:" + mma.Seat + "." + mma.VerbAssignments, ListBody: mma.AssignmentsInput{Seats: mma.AssignmentsInputSeatsTrue}, + Seat: oc.Seat, StateAsks: oc.StateAsks}} + return asker.Where } // busAsker publishes the runner's approval ask on the seat operator-channel's accept subject under the runner's own name, its diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go index 1b25805e..8270d794 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go @@ -4,15 +4,15 @@ import ( "context" "encoding/json" "errors" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "strings" - "time" - "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/operatorask" ) // The teller's three bus adapters for mesh-sdk go/asker, which in a bundle reaches the bus through the runtime: here the @@ -20,9 +20,6 @@ import ( // the seat operator-channel (mesh-control internal/broker seattraffic.go SeatTrafficOf): publish its own ask, cancel and // condition, hear its own decided event, and read the router's record of its own asks. -// callTimeout bounds a read of the router's record. -const callTimeout = 30 * time.Second - // publishVia publishes an accept of the seat, de-duplicated by id: the same id twice is one message. func publishVia(ctx context.Context, js jetstream.JetStream) func(subject string, body any, id string) error { return func(subject string, body any, id string) error { @@ -39,30 +36,20 @@ func publishVia(ctx context.Context, js jetstream.JetStream) func(subject string } } -// messengerAsks is the router's records bucket as the seat operator-channel's manifest declares it (its `records`), the -// one the controller composes this account's direct get on (mesh-sdk buscompose SeatTrafficOf): never `asks`, a bucket -// no grant names (issue 774). The SDK names no constant for it. -const messengerAsks = "messenger_asks" - -// recordVia reads the router's record of one of this module's asks: the key `.` of the router's bucket -// messenger_asks, by a direct get on the subject the controller grants for it, -// `$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks..>`. The record is {state, warrant}. An ask the -// router holds no record of is nil. -func recordVia(ctx context.Context, conn *nats.Conn, module string) func(id string) (*stdio.StateEntry, error) { +// recordVia reads the router's record of one of this module's asks under the module's own name: the key `.` of the +// state asks of the seat operator-channel, in the bucket of the seat's holder on the seat list (mesh-sdk's operatorask, design +// 47 G A-101, A-74); no bucket's name is in the program. The record is {state, warrant}; an ask the router holds no record +// of is nil. call asks the seat list's verb (process.Conn.Call) and ask is the get of the record (process.Conn.Ask), which the +// controller grants for a module that uses the seat. +func recordVia(call operatorask.Caller, ask func(bucket, id string) (*stdio.StateEntry, error)) func(id string) (*stdio.StateEntry, error) { + contract := operatorask.Contract{Lists: "seat:" + mma.Seat + "." + mma.VerbAssignments, ListBody: mma.AssignmentsInput{Seats: mma.AssignmentsInputSeatsTrue}, + Seat: oc.Seat, StateAsks: oc.StateAsks} return func(id string) (*stdio.StateEntry, error) { - ctx, cancel := context.WithTimeout(ctx, callTimeout) - defer cancel() - msg, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+messengerAsks+".$KV."+messengerAsks+"."+module+"."+id, nil) - if errors.Is(err, nats.ErrNoResponders) { - return nil, nil - } + bucket, err := operatorask.Bucket(call, contract) if err != nil { return nil, err } - if status := msg.Header.Get("Status"); status == "404" || len(msg.Data) == 0 { - return nil, nil - } - return &stdio.StateEntry{Key: id, Value: msg.Data}, nil + return ask(bucket, id) } } diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go index 636318ee..2ae5d2ad 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go @@ -8,6 +8,7 @@ import ( "fmt" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/views/viewstest" "os" "path/filepath" @@ -178,13 +179,16 @@ const ( seatCancel = "mesh.seat.operator-channel.accept.cancel.mesh-delivery-teller" seatCondition = "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" seatDecided = "mesh.seat.operator-channel.event.decided.mesh-delivery-teller" + // routerAsksBucket is the router's state asks in the bucket of the seat operator-channel's holder on the seat list (here ops-router): + // the holder's module name and the seat's state, never a name in the program (design 47 G A-101, A-74). + routerAsksBucket = "ops-router_asks" ) func fullConf(dir string) string { // ends is the teller's own state (manifest state entry ends), which the controller makes before it starts: a bed // without it held the start ten seconds on a read the bus never answered, and the tests raced that stall. teller := append(append(append(append(keepsOf(tellerAnswers), keepsOf(tellerAsked)...), keepsOf(ends.Bucket)...), readOf(gateAsksKV)...), - seatAsk, seatCancel, seatCondition, "$JS.API.DIRECT.GET.KV_"+messengerAsks+".$KV."+messengerAsks+".mesh-delivery-teller.>") + seatAsk, seatCancel, seatCondition, "$JS.API.DIRECT.GET.KV_"+routerAsksBucket+".$KV."+routerAsksBucket+".mesh-delivery-teller.>") // The durable consumer on EVENTS the controller makes for the seat it uses, pulled and acknowledged (issue 1126). teller = append(teller, "$JS.API.CONSUMER.INFO.EVENTS."+tellerConsumer, "$JS.API.CONSUMER.MSG.NEXT.EVENTS."+tellerConsumer, "$JS.ACK.EVENTS."+tellerConsumer+".>") // The finding writer finds the stream of the keeper's findings-asked (design 47 G §384): the bed has none, so it is @@ -261,7 +265,7 @@ func startFull(t *testing.T) *fullBed { } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) t.Cleanup(cancel) - for name, history := range map[string]uint8{gateAsksKV: gateAsksHistory, tellerAnswers: answersHistory, tellerAsked: 1, ends.Bucket: 1, messengerAsks: 1, assignerAsks: assignerAsksHistory, viewAnswers: viewAnswersHistory, firstViewsBucket: 1, findingsBucket: 1} { + for name, history := range map[string]uint8{gateAsksKV: gateAsksHistory, tellerAnswers: answersHistory, tellerAsked: 1, ends.Bucket: 1, routerAsksBucket: 1, assignerAsks: assignerAsksHistory, viewAnswers: viewAnswersHistory, firstViewsBucket: 1, findingsBucket: 1} { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil { t.Fatal(err) } @@ -376,7 +380,7 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2 t.Cleanup(func() { rn.finish(t, 10*time.Second) }) // Issue 774: the router's record is read on the teller's own credential from the bucket the controller grants it. - if mkv, err := b.js.KeyValue(b.ctx, messengerAsks); err != nil { + if mkv, err := b.js.KeyValue(b.ctx, routerAsksBucket); err != nil { t.Fatal(err) } else if _, err := mkv.Put(b.ctx, "mesh-delivery-teller.r1", []byte(`{"state":"open"}`)); err != nil { t.Fatal(err) @@ -386,7 +390,20 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2 t.Fatal(err) } t.Cleanup(tc.Close) - if rec, err := recordVia(b.ctx, tc, "mesh-delivery-teller")("r1"); err != nil || rec == nil { + seatList := func(key string, _ any) (json.RawMessage, error) { + if key != "seat:mesh-module-assigner.assignments" { + t.Errorf("the router's holder was asked of %s, want the seat mesh-module-assigner's assignments", key) + } + return json.RawMessage(`{"seats":[{"seat":"operator-channel","scope":"mesh","holders":[{"module":"ops-router","node":"n2"}]}]}`), nil + } + getRecord := func(bucket, id string) (*stdio.StateEntry, error) { + msg, err := tc.RequestWithContext(b.ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+".mesh-delivery-teller."+id, nil) + if err != nil { + return nil, err + } + return &stdio.StateEntry{Key: id, Value: msg.Data}, nil + } + if rec, err := recordVia(seatList, getRecord)("r1"); err != nil || rec == nil { t.Fatalf("the router's record read as the teller: %v, %v; want it", rec, err) } diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go index 3342bd6b..34ef4bc2 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go @@ -76,8 +76,15 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // started once both are registered. hub := process.NewEventHub(logger.Printf) defer hub.Stop() + // The assigner's asks are its state, read by the first view of its read tool and then its views family, never its + // bucket (ADR 0615 §1, DLV-M3): the picture reads as the bucket did. + calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true)) + if err != nil { + return fmt.Errorf("the teller's connection for its tool calls: %w", err) + } + defer calls.Close() client := &occ.Client{Module: tellerModule, State: state, Publish: publish, - Record: recordVia(ctx, conn, tellerModule), Subscribe: decidedVia(hub.Handle, logger.Printf)} + Record: recordVia(calls.Call, calls.Ask), Subscribe: decidedVia(hub.Handle, logger.Printf)} t := &Teller{Asker: client, Store: state, Answers: kvAnswers{ctx: ctx, kv: kvs[0]}, Gate: kvGate{ctx: ctx, kv: gate}, Say: sayVia(ctx, js, tellerModule), Logf: logger.Printf, Publish: publish, // The teller's own events (its manifest's emits), on EVENTS under its module's subject. @@ -92,13 +99,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // The view's answers to the assigner's asks (issue 1297 B10): both buckets are the controller's to create, and a bucket not // there is said and leaves the teller without that path rather than without the gate's asks (a wait for it needs a timer). failed := make(chan error, 4) - // The assigner's asks are its state, read by the first view of its read tool and then its views family, never its - // bucket (ADR 0615 §1, DLV-M3): the picture reads as the bucket did. - calls, err := process.Connect(cred, nats.Name(cred.Node+"."+cred.Module+".calls"), nats.RetryOnFailedConnect(true)) - if err != nil { - return fmt.Errorf("the teller's connection for its tool calls: %w", err) - } - defer calls.Close() t.Answered = func(key, askID string, answer []byte) error { return calls.Emit(answeredEvent, Answered{Key: key, Ask: askID, Answer: answer}, stdio.EmitOptions{MessageID: answeredID(key, askID)}) } From 13c5433c0d766c5ceff998cb902edd9b2862b5ef Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:07:00 +0100 Subject: [PATCH 6/8] fix9-6: the view's build usage finds the build seats and their holders, never a build module's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_usage reads each build seat's steps and pauses through the seat; the view's tools follow the seat list (view_seat_list, view_seat_list_again) and ask the holder of each seat of the seat mesh-module-assigner. Builds: design 47 G A-82, A-74, §204 --- .../cmd/mesh-delivery-view-tools/main.go | 53 ++++++++++-- .../cmd/mesh-delivery-view-tools/main_test.go | 24 ++++++ modules/mesh-delivery-view/go.mod | 1 + .../mesh-delivery-view/internal/view/usage.go | 81 +++++++++++++------ .../internal/view/usage_test.go | 49 +++++++++-- modules/mesh-delivery-view/manifest_test.go | 8 +- modules/mesh-delivery-view/module.json | 20 +++-- 7 files changed, 189 insertions(+), 47 deletions(-) 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 9baa4b52..481c95ec 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 @@ -13,14 +13,18 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/busretry" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -405,6 +409,29 @@ func ownersRead(ask func(key string, body any) (json.RawMessage, error)) view.Re GraphState: indexer("indexer_read_graph")} } +// holderOf is a seat's holder as the seat mesh-module-assigner's verb assignments answers it with the seats (design 47 G A-82, +// A-74): the module the seat list names for the seat, and false while the seat has none or the list cannot be read. +func holderOf(ask func(key string, body any) (json.RawMessage, error)) func(seat string) (string, bool) { + return func(seat string) (string, bool) { + raw, err := ask("seat:"+mma.Seat+"."+mma.VerbAssignments, mma.AssignmentsInput{Seats: mma.AssignmentsInputSeatsTrue}) + if err != nil { + return "", false + } + var held struct { + Seats []mma.SeatHolders `json:"seats"` + } + if json.Unmarshal(raw, &held) != nil { + return "", false + } + for _, s := range held.Seats { + if s.Seat == seat && len(s.Holders) > 0 { + return s.Holders[0].Module, true + } + } + return "", false + } +} + func main() { r := ownersRead(stdio.Ask) r.Builds, r.HavesState = read(stdio.State("mesh-delivery-builder.builds")), read(stdio.State("node-engine.have")) @@ -416,16 +443,28 @@ func main() { r.ProvisionsState = read(stdio.State(view.ProvisionsRead)) r.BuildLog = stdio.Log(builds.BuildLogRead).Since r.Pending = pendingOf + // The build modules are no names (design 47 G A-82): the build seats on the seat list, each held by the module the seat + // list names for it; the seat list and its two tools are wired once by mesh-sdk (§204). + gate := seatlist.NewPart("view", []string{stdio.AnyPerson, "node-tools", "mesh-healers"}) + go func() { + <-stdio.Served() + gate.Follow(seatlist.StdioBus(), nil, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] "+f+"\n", a...) }) + }() + seats := buildrun.NewBuildSeats(func() map[string]seatdef.Definition { + if rd := gate.Reader(); rd != nil { + return rd.Definitions() + } + return nil + }, holderOf(stdio.Ask)) u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), - Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())} - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - u.Steps[lang] = read(stdio.State(module + "." + view.StepsState)) - u.Pauses[lang] = read(stdio.State(module + "." + buildstep.PausesState)) - } + Seats: seats, + StepsOf: func(seat string) view.StateRead { return read(stdio.State(seat + "." + view.StepsState)) }, + PausesOf: func(seat string) view.StateRead { return read(stdio.State(seat + "." + buildstep.PausesState)) }, + Backlog: backlogOf(socket())} // view_first_views and view_first_view_again: where the view's process stands with its first views of the indexer's and // the assigner's states (ADR 0615 §6), and a re-read by hand. - tools := append(Tools(r, u), views.StatusTools(viewstate.ViewModule, "view_first_views", "view_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) + tools := append(Tools(r, u), gate.Tools()...) + tools = append(tools, views.StatusTools(viewstate.ViewModule, "view_first_views", "view_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) if err := stdio.Serve("", tools); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] %v\n", err) os.Exit(1) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index c4231d61..416ba470 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -3,6 +3,7 @@ package main import ( "crypto/ed25519" "encoding/json" + "errors" "fmt" "mesh-delivery-view/internal/view" "reflect" @@ -625,3 +626,26 @@ func manifestReadsAsksEveryNode(t *testing.T) { t.Errorf("node_manifest_reads = %+v", rows) } } + +// Design 47 G A-82, A-74: a build seat's holder is the module the seat mesh-module-assigner's verb assignments names for it with +// the seats, and none while the seat has no holder or the list cannot be read. +func TestABuildSeatsHolderIsTheOneTheSeatListNames(t *testing.T) { + var asked []string + ask := func(key string, _ any) (json.RawMessage, error) { + asked = append(asked, key) + return json.RawMessage(`{"seats":[{"seat":"node-build-go","scope":"node","holders":[{"module":"acme-go","node":"n1"}]},{"seat":"node-build-node","scope":"node","holders":[]}]}`), nil + } + holder := holderOf(ask) + if m, ok := holder("node-build-go"); !ok || m != "acme-go" { + t.Errorf("the holder of node-build-go = %q, %v; want acme-go", m, ok) + } + if m, ok := holder("node-build-node"); ok { + t.Errorf("a seat with no holder has %q", m) + } + if len(asked) != 2 || asked[0] != "seat:mesh-module-assigner.assignments" { + t.Errorf("asked %v; want the seat mesh-module-assigner's assignments", asked) + } + if _, ok := holderOf(func(string, any) (json.RawMessage, error) { return nil, errors.New("down") })("node-build-go"); ok { + t.Error("a seat list that cannot be read names a holder") + } +} diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index a97d1bc4..b86a0904 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -16,6 +16,7 @@ require ( git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-delivery-view/internal/view/usage.go b/modules/mesh-delivery-view/internal/view/usage.go index 190d6baa..368a2605 100644 --- a/modules/mesh-delivery-view/internal/view/usage.go +++ b/modules/mesh-delivery-view/internal/view/usage.go @@ -21,6 +21,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + buildseats "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/sdk/buildrun" "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) @@ -50,9 +51,11 @@ const ( // UsageReader reads what build_usage answers from. type UsageReader struct { - // Steps and Pauses are each build module's states by language (go, node, image). - Steps map[string]StateRead - Pauses map[string]StateRead + // Seats are the build seats, each by the acting rule that builds, with the module holding it on the seat list (design 47 G + // A-82): no build module is named here. StepsOf and PausesOf read a build seat's steps and pauses through the seat. + Seats *buildseats.BuildSeats + StepsOf func(seat string) StateRead + PausesOf func(seat string) StateRead // Resolved is mesh-config's resolved values (..); Haves the node-engine's have. Resolved StateRead Haves StateRead @@ -209,6 +212,16 @@ func (u UsageReader) Usage() UsageAnswer { note := func(f string, a ...any) { ans.Notes = append(ans.Notes, fmt.Sprintf(f, a...)) } tries := u.tries(now, &ans, note) + // The build seats once for this answer: each asks the seat list for its holder (design 47 G A-82). + buildSeats := u.Seats.All() + buildSeatOfModule := func(module string) buildseats.BuildModule { + for _, m := range buildSeats { + if m.Module == module && module != "" { + return m + } + } + return buildseats.BuildModule{} + } seats := map[string]map[string]*SeatUsage{} // node -> language -> seat seat := func(node, lang string) *SeatUsage { if seats[node] == nil { @@ -217,9 +230,13 @@ func (u UsageReader) Usage() UsageAnswer { if s := seats[node][lang]; s != nil { return s } - name, _ := buildstep.BuildSeat(lang) - module, _ := buildstep.BuildModuleOf(lang) - s := &SeatUsage{Seat: name, Module: module, Running: RunningUsage{ByKind: map[string]int{}}, LeftRunning: []StepUsage{}, EndedStale: []StepUsage{}, Steps: []StepUsage{}} + var bm buildseats.BuildModule + for _, m := range buildSeats { + if m.AskLanguage() == lang { + bm = m + } + } + s := &SeatUsage{Seat: bm.Seat, Module: bm.Module, Running: RunningUsage{ByKind: map[string]int{}}, LeftRunning: []StepUsage{}, EndedStale: []StepUsage{}, Steps: []StepUsage{}} seats[node][lang] = s return s } @@ -233,10 +250,8 @@ func (u UsageReader) Usage() UsageAnswer { } else { var want []string for _, k := range keys { - for _, lang := range buildstep.AskLanguages { - if m, _ := buildstep.BuildModuleOf(lang); strings.HasSuffix(k, "."+m) { - want = append(want, k) - } + if _, module, _ := strings.Cut(k, "."); buildSeatOfModule(module).Seat != "" { + want = append(want, k) } } raws, err := u.Haves.values(want) @@ -256,8 +271,8 @@ func (u UsageReader) Usage() UsageAnswer { } for k, h := range haves { node, module, _ := strings.Cut(k, ".") - lang := strings.TrimPrefix(module, "mesh-build-") - s := seat(node, lang) + bm := buildSeatOfModule(module) + s := seat(node, bm.AskLanguage()) s.Phase, s.Holds = string(h.Phase), h.Phase == nodeengineclient.PhaseRunning } @@ -310,19 +325,27 @@ func (u UsageReader) Usage() UsageAnswer { } for key := range unreadable { node, module, _ := strings.Cut(key, ".") - seat(node, strings.TrimPrefix(module, "mesh-build-")) + bm := buildSeatOfModule(module) + seat(node, bm.AskLanguage()) } // Every node with a resolved capacity is listed, so "is this node eligible" is answered for it. for key := range resolved { node, module, _ := strings.Cut(key, ".") - seat(node, strings.TrimPrefix(module, "mesh-build-")) + bm := buildSeatOfModule(module) + seat(node, bm.AskLanguage()) + } + if len(buildSeats) == 0 { + note("no build seat is known from the seat definitions and the seat list, so no steps or pauses are read") } // The steps of each build module. steps := map[string]map[string][]usageStep{} // node -> language -> steps - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - sr, ok := u.Steps[lang] - if !ok || sr.Keys == nil || sr.Get == nil { + for _, bm := range buildSeats { + lang, module := bm.AskLanguage(), cmpOr(bm.Module, bm.Seat) + var sr StateRead + if u.StepsOf != nil { + sr = u.StepsOf(bm.Seat) + } + if sr.Keys == nil || sr.Get == nil { note("the steps of %s are not readable here", module) continue } @@ -379,10 +402,13 @@ func (u UsageReader) Usage() UsageAnswer { // Pauses: paused. of each module; and capacity., the capacity the module says it judges by. paused := map[string]bool{} // . judged := map[string]buildstep.Capacity{} - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - pr, ok := u.Pauses[lang] - if !ok || pr.Get == nil { + for _, bm := range buildSeats { + lang, module := bm.AskLanguage(), cmpOr(bm.Module, bm.Seat) + var pr StateRead + if u.PausesOf != nil { + pr = u.PausesOf(bm.Seat) + } + if pr.Get == nil { note("the pauses of %s are not readable here, so none is said paused", module) continue } @@ -422,7 +448,8 @@ func (u UsageReader) Usage() UsageAnswer { for _, node := range nodes { nu := NodeUsage{Node: node, Seats: []SeatUsage{}, Said: []string{}, CPU: "not on the bus: the disk-load module keeps no state; " + node + "/disk-load.cpu_top reads it (the build modules' share among its units)"} - for _, lang := range buildstep.AskLanguages { + for _, bm := range buildSeats { + lang := bm.AskLanguage() s := seats[node][lang] if s == nil { continue @@ -696,3 +723,11 @@ func sumMedian(d []int64) (sum, median int64) { } return sum, s[(len(s)-1)/2] } + +// cmpOr is the first of two names that is not empty. +func cmpOr(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/modules/mesh-delivery-view/internal/view/usage_test.go b/modules/mesh-delivery-view/internal/view/usage_test.go index 2762f299..02d87032 100644 --- a/modules/mesh-delivery-view/internal/view/usage_test.go +++ b/modules/mesh-delivery-view/internal/view/usage_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + buildseats "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/seatdef" "strconv" "strings" "testing" @@ -82,8 +84,19 @@ func usageFixture() UsageReader { "shanks.mesh-config.other": `{"value":"x"}`, "g14.mesh-build-node.capacity": `{"node":"g14","module":"mesh-build-node","key":"capacity","source":"unreadable"}`, }), - Steps: map[string]StateRead{"go": states(goSteps), "node": states(map[string]string{}), "image": states(map[string]string{})}, - Pauses: map[string]StateRead{"go": states(map[string]string{"paused.ace": `{"v":1,"paused":true,"by":"lead@shanks","at":"2026-10-26T09:00:00Z"}`}), "node": states(nil), "image": states(nil)}, + Seats: buildSeatsOf(), + StepsOf: func(seat string) StateRead { + if seat == "node-build-go" { + return states(goSteps) + } + return states(map[string]string{}) + }, + PausesOf: func(seat string) StateRead { + if seat == "node-build-go" { + return states(map[string]string{"paused.ace": `{"v":1,"paused":true,"by":"lead@shanks","at":"2026-10-26T09:00:00Z"}`}) + } + return states(nil) + }, Tries: states(map[string]string{ "try-" + strconv.FormatInt(at("11:29").UnixNano(), 10): `{"id":"try-1","repository":"novox/mesh-sdk","head":"h","trunk":"t","state":"pass","asks":["` + tryAsk + `"]}`, "try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","started":"2026-10-26T11:58:00Z"}`, @@ -167,7 +180,7 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { // Nothing readable: each state is named in a note, the backlog's refusal too, and nothing is said held. none := UsageReader{Now: func() time.Time { return usageNow }, Backlog: func() ([]BacklogRow, error) { return nil, errors.New("no socket") }}.Usage() notes := strings.Join(none.Notes, " | ") - for _, want := range []string{"have is not readable", "capacity unknown to this tool: mesh-config's resolved values are not readable", "steps of mesh-build-go are not readable", "tries are not readable", "did not answer: no socket"} { + for _, want := range []string{"have is not readable", "capacity unknown to this tool: mesh-config's resolved values are not readable", "no build seat is known", "tries are not readable", "did not answer: no socket"} { if !strings.Contains(notes, want) { t.Errorf("the notes %q do not say %q", notes, want) } @@ -196,8 +209,8 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { // Issue 1806's follow-up: the module on shanks says it judges by 1 of mesh-config's 2 (its build CPU bounds it); the // seat says the module's capacity and why. And issue 1890: a running try that names its asks is said with them. u = usageFixture() - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":1,"configured":2,"quota-from":"the cpu.max of /meshyield.slice","quota-cores":6,"cores-per-step":6,"by-cpu":1,"why":"1, bounded by the CPU","at":"2026-10-26T11:00:00Z"}`, - "capacity.ace": `{"v":1,"effective":2,"configured":3,"why":"stale: mesh-config says 2 now"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":1,"configured":2,"quota-from":"the cpu.max of /meshyield.slice","quota-cores":6,"cores-per-step":6,"by-cpu":1,"why":"1, bounded by the CPU","at":"2026-10-26T11:00:00Z"}`, + "capacity.ace": `{"v":1,"effective":2,"configured":3,"why":"stale: mesh-config says 2 now"}`})) u.Tries = states(map[string]string{"try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","asks":["` + tryAsk0 + `"],"steps":[{"ask":"` + tryAsk0 + `","node":"shanks","state":"running","phase":"unit: tests of 3 packages"}]}`}) bound := u.Usage() sc := seatOf(t, bound, "shanks", "node-build-go") @@ -208,13 +221,13 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { t.Errorf("a module's word older than mesh-config's value is not taken: %+v", sa) } // The operator, 2026-10-26: outside its build hours the seat takes nothing new, and says so. - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":0,"configured":2,"build-hours":"18:00-06:00 CET","outside-hours":true,"why":"0, outside its build hours 18:00-06:00 CET until 18:00 (otherwise 2)","at":"2026-10-26T11:00:00Z"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":0,"configured":2,"build-hours":"18:00-06:00 CET","outside-hours":true,"why":"0, outside its build hours 18:00-06:00 CET until 18:00 (otherwise 2)","at":"2026-10-26T11:00:00Z"}`})) if so := seatOf(t, u.Usage(), "shanks", "node-build-go"); so.Capacity != 0 || !so.OutsideHours || so.Eligible == nil || *so.Eligible || so.WhyNot != "outside its build hours 18:00-06:00 CET on shanks: it takes nothing new until they open" { t.Errorf("a seat outside its build hours: %+v", so) } // A build-hours value the module cannot read: it builds at any hour, and the seat says the value and why. - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":2,"configured":2,"build-hours-fault":"the build hours \"shanks=18:00-6\": \"6\" is not a time HH:MM","why":"2; at any hour, because its build hours cannot be read","at":"2026-10-26T11:00:00Z"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":2,"configured":2,"build-hours-fault":"the build hours \"shanks=18:00-6\": \"6\" is not a time HH:MM","why":"2; at any hour, because its build hours cannot be read","at":"2026-10-26T11:00:00Z"}`})) if sf := seatOf(t, u.Usage(), "shanks", "node-build-go"); sf.BuildHours != "any hour" || !strings.Contains(sf.BuildHoursFault, "shanks=18:00-6") || sf.Eligible == nil || !*sf.Eligible { t.Errorf("a seat whose build hours cannot be read: %+v", sf) } @@ -225,3 +238,25 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { } var tryAsk0 = stepID(at("11:58"), "a00010") + +// buildSeatsOf is the build seats as the seat definitions and the seat list give them (design 47 G A-82): the seats whose +// acting rule builds, each held by the module the seat list names. +func buildSeatsOf() *buildseats.BuildSeats { + rule := func(on string) seatdef.Definition { + return seatdef.Definition{Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: on}}} + } + defs := map[string]seatdef.Definition{"node-build-go": rule("go"), "node-build-node": rule("typescript"), "node-build-image": rule(seatdef.OnKindImage)} + holders := map[string]string{"node-build-go": "mesh-build-go", "node-build-node": "mesh-build-node", "node-build-image": "mesh-build-image"} + return buildseats.NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok }) +} + +// setPauses replaces what one build seat's pauses read. +func setPauses(u *UsageReader, seat string, st StateRead) { + prev := u.PausesOf + u.PausesOf = func(s string) StateRead { + if s == seat { + return st + } + return prev(s) + } +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index bf6b6067..d0e3e918 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -106,7 +106,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-engine.have"} // no reads of the indexer's or the assigner's states (ADR 0615 §1, DLV-M4); view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-build-go.pauses", "node-build-go.steps", "node-build-image.pauses", "node-build-image.steps", "node-build-node.pauses", "node-build-node.steps", "node-engine.have"} // the build seats' states by seat, never a build module (design 47 G A-82); // no reads of the indexer's or the assigner's states (ADR 0615 §1, DLV-M4); view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -124,7 +124,9 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 "mesh-delivery-indexer.indexer_read_graph", "mesh-delivery-indexer.indexer_read_heard", "mesh-delivery-indexer.indexer_pending", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken", "seat:mesh-catalog.revision", // ADR 0663: the modules are the catalog half's, read through the seat's verb revision, never the indexer's + "seat:mesh-catalog.seat-definitions", // design 47 G A-82, §204: the build seats are read from the seat list "seat:mesh-hand-acts.hand-act", // the join: its first view asked again by hand is a hand act (views.StatusTools) + "seat:mesh-module-assigner.assignments", // A-82: the holder of each build seat "seat:node-engine.manifest-reads"}; !reflect.DeepEqual(m.Invokes, want) { // ADR 0618 §3: node_manifest_reads asks the node-engine seat's verb of every node; 1585/T24: view_tiers asks indexer_pending, a verb edge (ADR 0614 §2) t.Errorf("invokes = %v, want %v", m.Invokes, want) } @@ -139,7 +141,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 t.Errorf("version = %q, want 0.1", m.Version) } if want := []string{"mesh-conditions.findings-asked", "mesh-delivery-assigner.views.asks", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants", - "mesh-catalog.catalog-revision", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) { + "mesh-catalog.catalog-revision", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) { t.Errorf("consumes = %v, want %v", m.Consumes, want) } // The one event the view says: a source revision is delivered (1906/D9, ADR 0539 §17), derived by it from the wants and the haves. @@ -164,7 +166,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads", "view_seat_list", "view_seat_list_again"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } // D-11: the holders and buckets the view names are its manifest's (no contract names a holder, novox/hq ADR 0634 §1): a diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index feb4694c..cb6231ff 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -27,7 +27,9 @@ "view_tiers", "view_first_views", "view_first_view_again", - "node_manifest_reads" + "node_manifest_reads", + "view_seat_list", + "view_seat_list_again" ], "emits": [ "delivered", @@ -56,6 +58,8 @@ "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants", "mesh-catalog.catalog-revision", + "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken" ], @@ -73,16 +77,18 @@ "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken", "seat:mesh-catalog.revision", + "seat:mesh-catalog.seat-definitions", "seat:mesh-hand-acts.hand-act", + "seat:mesh-module-assigner.assignments", "seat:node-engine.manifest-reads" ], "reads": [ - "mesh-build-go.pauses", - "mesh-build-go.steps", - "mesh-build-image.pauses", - "mesh-build-image.steps", - "mesh-build-node.pauses", - "mesh-build-node.steps", + "node-build-go.pauses", + "node-build-go.steps", + "node-build-image.pauses", + "node-build-image.steps", + "node-build-node.pauses", + "node-build-node.steps", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", From 90718ba7a3e66c04c8f839f8052e08326028ae4d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:10:17 +0100 Subject: [PATCH 7/8] fix9-7: a module that hears a seat's kept event is granted that stream's info (process.HearSeatEvents reads it); the forge bridge's bed holds the grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause of the five forge bridge integration failures: HearSeatEvents (G §641) asks STREAM.INFO of the kept stream, which the grants part never composed. Builds: design 47 G §641, A-19, §556 --- .../mesh-delivery-forge-bridge/integration_test.go | 2 ++ .../mesh-grants/internal/buscompose/modstreams.go | 7 +++++++ .../internal/buscompose/orderedread_test.go | 14 ++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go index a33ce668..f0365727 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/integration_test.go @@ -86,9 +86,11 @@ accounts { "$JS.ACK.EVENTS.n1_mesh-delivery-forge-bridge.>" "$JS.API.CONSUMER.INFO.EVENTS.n1_mesh-delivery-forge-bridge" "$JS.API.CONSUMER.MSG.NEXT.EVENTS.n1_mesh-delivery-forge-bridge" + "$JS.API.STREAM.INFO.SEAT_mesh-delivery_owed" "$JS.ACK.SEAT_mesh-delivery_owed.n1_mesh-delivery-forge-bridge.>" "$JS.API.CONSUMER.INFO.SEAT_mesh-delivery_owed.n1_mesh-delivery-forge-bridge" "$JS.API.CONSUMER.MSG.NEXT.SEAT_mesh-delivery_owed.n1_mesh-delivery-forge-bridge" + "$JS.API.STREAM.INFO.SEAT_mesh-delivery-checker_owed" "$JS.ACK.SEAT_mesh-delivery-checker_owed.n1_mesh-delivery-forge-bridge.>" "$JS.API.CONSUMER.INFO.SEAT_mesh-delivery-checker_owed.n1_mesh-delivery-forge-bridge" "$JS.API.CONSUMER.MSG.NEXT.SEAT_mesh-delivery-checker_owed.n1_mesh-delivery-forge-bridge" diff --git a/modules/mesh-grants/internal/buscompose/modstreams.go b/modules/mesh-grants/internal/buscompose/modstreams.go index af945c1b..0c724157 100644 --- a/modules/mesh-grants/internal/buscompose/modstreams.go +++ b/modules/mesh-grants/internal/buscompose/modstreams.go @@ -4,6 +4,8 @@ import ( "fmt" "sort" "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // A module's own stream on the bus (novox/hq ADR 0363). @@ -260,6 +262,11 @@ func heardGrants(p Principal, hears []Heard) []string { "$JS.API.CONSUMER.INFO."+h.Stream+"."+durable, "$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable, "$JS.ACK."+h.Stream+"."+durable+".>") + if strings.HasPrefix(h.Stream, seatdef.KeptStreamPrefix) { + // A seat's kept event is heard through process.HearSeatEvents, which reads the stream's own info to see whether it is + // kept last per subject (design 47 G §641, A-19): the info of this stream alone. + out = append(out, "$JS.API.STREAM.INFO."+h.Stream) + } if h.LastPerSubject { // A stream kept last per subject is read whole at each start through an ordered consumer, made for that one read // and with no durable name: its creation, its next message, its info and the deletion of its own (design 47 G §556, A-4). diff --git a/modules/mesh-grants/internal/buscompose/orderedread_test.go b/modules/mesh-grants/internal/buscompose/orderedread_test.go index fc61c48f..8c7192f0 100644 --- a/modules/mesh-grants/internal/buscompose/orderedread_test.go +++ b/modules/mesh-grants/internal/buscompose/orderedread_test.go @@ -21,3 +21,17 @@ func TestAReaderOfAStreamKeptLastPerSubjectIsGrantedItsOrderedConsumer(t *testin t.Errorf("a reader of another stream is granted an ordered consumer: %v", plain) } } + +// A reader of a seat's kept event is granted the info of that stream, which process.HearSeatEvents reads to see whether the +// stream is kept last per subject, and no stream's info besides (design 47 G §641, A-19). +func TestAReaderOfASeatsKeptEventIsGrantedItsStreamInfo(t *testing.T) { + p := Principal{Module: "m", Node: "a"} + kept := heardGrants(p, []Heard{{Stream: "SEAT_mesh-delivery_owed", Filters: []string{"f"}}}) + if !slices.Contains(kept, "$JS.API.STREAM.INFO.SEAT_mesh-delivery_owed") { + t.Errorf("a reader of a seat's kept event is not granted its stream's info: %v", kept) + } + other := heardGrants(p, []Heard{{Stream: "mesh-x-events", Filters: []string{"f"}}}) + if slices.Contains(other, "$JS.API.STREAM.INFO.mesh-x-events") { + t.Errorf("a reader of a module's stream is granted its info: %v", other) + } +} From 8fc1997b1b8c511691f985c59a7496c346905cce Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:13:03 +0100 Subject: [PATCH 8/8] fix9-8: the grants part composes the node-engine's grants for the seat mesh-recovery and for a module's catch-up tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node-engine of a node hears the kept events ended and restored through an ordered consumer, and calls the catch-up tool of a module placed on its node (its manifest's kept data name it); the verbs snapshot-kept, restore-kept and kept-points were granted already as the engine's invokes. No nodegrant mirror lives in mesh-delivery (it is mesh-host's). Builds: design 47 G §406, §554, §631, A-108 --- .../mesh-grants/internal/buscompose/engine.go | 11 +++++ .../mesh-grants/internal/buscompose/nats.go | 3 ++ .../buscompose/recovery_engine_test.go | 49 +++++++++++++++++++ .../mesh-grants/internal/buscompose/users.go | 16 +++++- .../internal/grants/catchups_test.go | 21 ++++++++ .../mesh-grants/internal/grants/records.go | 13 +++++ 6 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-grants/internal/buscompose/recovery_engine_test.go create mode 100644 modules/mesh-grants/internal/grants/catchups_test.go diff --git a/modules/mesh-grants/internal/buscompose/engine.go b/modules/mesh-grants/internal/buscompose/engine.go index 3dadd65b..ecb4366a 100644 --- a/modules/mesh-grants/internal/buscompose/engine.go +++ b/modules/mesh-grants/internal/buscompose/engine.go @@ -85,6 +85,17 @@ func engineGrants(p Principal) (pub, sub []string) { sub = append(sub, k.Filter) } } + // And the kept events of other seats it follows (the recovery's ended and restored): each stream read through an ordered + // consumer made for the read, with no durable of its own, and the stream's own info (design 47 G §554, A-108). + for _, h := range p.EngineHears { + if !safeSubject.MatchString(h.Stream) { + continue + } + pub = append(pub, "$JS.API.STREAM.INFO."+h.Stream, + "$JS.API.CONSUMER.CREATE."+h.Stream, "$JS.API.CONSUMER.CREATE."+h.Stream+".>", + "$JS.API.CONSUMER.MSG.NEXT."+h.Stream+".*", "$JS.API.CONSUMER.INFO."+h.Stream+".*", + "$JS.API.CONSUMER.DELETE."+h.Stream+".*") + } sub = append(sub, announcing(EngineSeat)...) pub = append(pub, discovering()...) tp, ts := seatTrafficGrants(SeatTrafficOf(occ.EngineAsker(p.Node), nil, p.Uses, nil)) diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index a3872693..926aaff8 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -124,6 +124,9 @@ type Principal struct { // (engineGrants). EngineEvents map[string]string EngineKept []KeptEvent + // EngineHears are the kept events of other seats its engine consumes (design 47 G §554, A-108: the events ended and + // restored of the seat mesh-recovery), each stream followed through an ordered consumer made for the read. + EngineHears []Heard // Ledgers are the local names of the ledgers its module asks for in `requires`, for a KindLedger principal alone // (issue 1966 D5): its whole authority is publishing on those ledgers' histories and snapshots (LedgerPublishGrants). Ledgers []string diff --git a/modules/mesh-grants/internal/buscompose/recovery_engine_test.go b/modules/mesh-grants/internal/buscompose/recovery_engine_test.go new file mode 100644 index 00000000..11f66d56 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/recovery_engine_test.go @@ -0,0 +1,49 @@ +package buscompose + +import ( + "testing" +) + +// Design 47 G §406, §554, A-108: the node-engine of a node is granted to hear the seat mesh-recovery's kept events ended and +// restored through an ordered consumer, to ask the verbs snapshot-kept, restore-kept and kept-points of the seat, and to call +// the catch-up tool of a module placed on its node, on its own node's tool subject and no other module's tool. +func TestTheNodeEngineIsGrantedWhatItNeedsOfTheRecoverySeat(t *testing.T) { + kept := []KeptEvent{ + {Seat: "mesh-recovery", Event: "ended", Stream: "SEAT_mesh-recovery_ended", Filter: "mesh.seat.mesh-recovery.event.ended.*.*"}, + {Seat: "mesh-recovery", Event: "restored", Stream: "SEAT_mesh-recovery_restored", Filter: "mesh.seat.mesh-recovery.event.restored.*.*"}, + } + records := Records{Nodes: []string{"one"}, Kept: kept, + Engine: &Declared{Module: EngineModule, Consumes: []string{"mesh-recovery.ended", "mesh-recovery.restored"}, + Invokes: []string{"seat:mesh-recovery.snapshot-kept", "seat:mesh-recovery.restore-kept", "seat:mesh-recovery.kept-points"}}, + Assigned: map[string][]Declared{"one": {{Module: "keeper", CatchUps: []string{"catch_up"}}, {Module: "plain"}}}} + users, err := Users(records) + if err != nil { + t.Fatal(err) + } + var node Principal + for _, u := range users { + if u.Kind == KindNode { + node = u + } + } + perms, err := PermissionsFor(node) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "$JS.API.CONSUMER.CREATE.SEAT_mesh-recovery_ended", "$JS.API.CONSUMER.CREATE.SEAT_mesh-recovery_ended.>", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_mesh-recovery_ended.*", "$JS.API.CONSUMER.DELETE.SEAT_mesh-recovery_restored.*", + "$JS.API.STREAM.INFO.SEAT_mesh-recovery_restored", + "mesh.seat.mesh-recovery.tool.snapshot-kept", "mesh.seat.mesh-recovery.tool.restore-kept", "mesh.seat.mesh-recovery.tool.kept-points", + "mesh.mod.keeper.tool.catch_up.one", + } { + if !MayPublish(perms, want) { + t.Errorf("the node-engine of one may not publish %s", want) + } + } + for _, bad := range []string{"mesh.mod.plain.tool.catch_up.one", "mesh.mod.keeper.tool.other.one", "$JS.API.CONSUMER.CREATE.SEAT_mesh-recovery_set-aside"} { + if MayPublish(perms, bad) { + t.Errorf("the node-engine of one may publish %s", bad) + } + } +} diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index c5d133c1..2f9c62a5 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -53,6 +53,9 @@ type Declared struct { Watches []Seat // Invokes are the tools it calls, `.` or `*` (novox/hq ADR 0152). Invokes []string + // CatchUps are the module's own tools that bring a kept data forward after a restore of it, as its manifest's kept data + // name them (design 47 G §406, §631, A-108): the node-engine on a node that places the module is granted to call each. + CatchUps []string // Waits are the event families a session may wait on through the tool runner, the tool runner's module's // alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer. Waits []Wait @@ -223,9 +226,20 @@ func Users(r Records) ([]Principal, error) { if r.Engine != nil { engineHears, engineWatches, engineInvokes = r.Engine.Consumes, r.Engine.Watches, r.Engine.Invokes } + // The node-engine's hearing of kept events of other seats (the recovery's ended and restored) and its call of each + // catch-up tool of a module placed on its node (design 47 G §406, §554, A-108), granted as the seat's grants name them. + _, engineKeptHears := SplitKept(r.Kept, engineHears) + engineInvokes = slices.Clone(engineInvokes) + for _, d := range r.Assigned[node] { + for _, tool := range d.CatchUps { + if t := d.Module + "." + tool; !slices.Contains(engineInvokes, t) { + engineInvokes = append(engineInvokes, t) + } + } + } for _, login := range nodeLoginsOf(r, node) { out = append(out, Principal{Kind: KindNode, Node: node, Login: login, Checks: checks, Runner: runner, Consumes: engineHears, - Watches: engineWatches, Invokes: engineInvokes, EngineEvents: r.EngineEvents, EngineKept: keptOfSeat(r.Kept, EngineSeat), + EngineHears: engineKeptHears, Watches: engineWatches, Invokes: engineInvokes, EngineEvents: r.EngineEvents, EngineKept: keptOfSeat(r.Kept, EngineSeat), MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)}) diff --git a/modules/mesh-grants/internal/grants/catchups_test.go b/modules/mesh-grants/internal/grants/catchups_test.go new file mode 100644 index 00000000..94985bae --- /dev/null +++ b/modules/mesh-grants/internal/grants/catchups_test.go @@ -0,0 +1,21 @@ +package grants + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Design 47 G §406, §631, A-108: the catch-up tools a manifest's kept data name are the module's own, each once, whichever +// kind of kept data names them. +func TestCatchUpsAreTheToolsTheManifestsKeptDataName(t *testing.T) { + m, err := catalogue.Decode([]byte(`{"module":"keeper","version":"1.0","state":[{"name":"seen","form":2,"catch-up":{"tool":"seen_again","arguments":{"all":true}}},"plain"],` + + `"logs":[{"name":"lines","form":1,"catch-up":"seen_again"}],"streams":[{"name":"ev","events":["a"],"max-age-days":1,"max-mib":1,"form":1,"catch-up":{"tool":"ev_again"}}]}`)) + if err != nil { + t.Fatal(err) + } + if got := catchUpsOf(m); !slices.Equal(got, []string{"seen_again", "ev_again"}) && !slices.Equal(got, []string{"ev_again", "seen_again"}) { + t.Errorf("catchUpsOf = %v, want seen_again and ev_again once each", got) + } +} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 50e2a5f5..e195923f 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -156,6 +156,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Ledgers: ledgersOf(m), } d.Loads = loadsABundle(m) + d.CatchUps = catchUpsOf(m) if _, reads := m.OwnSecrets["broker"]; !reads { d.NoAccount = true } @@ -608,3 +609,15 @@ func unionFields(dst, src reflect.Value) { } } } + +// catchUpsOf are the catch-up tools a manifest's kept data name, each once (design 47 G §406, §631): the module's own tools +// the node-engine calls after a restore of that data, which it is granted to call on its own node (A-108). +func catchUpsOf(m catalogue.Manifest) []string { + var tools []string + for _, k := range catalogue.KeptDeclarations(m) { + if k.CatchUp != nil && k.CatchUp.Tool != "" && !slices.Contains(tools, k.CatchUp.Tool) { + tools = append(tools, k.CatchUp.Tool) + } + } + return tools +}