From 986ab0396d41d34b3486786c64c901e5b2d6d15b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:24:21 +0100 Subject: [PATCH 01/14] The verdict events are the seat mesh-catalog's: the indexer says manifest-accepted and manifest-refused on the seat's subject and no longer lists them in emits; builder, watchman and the grants part consume mesh-catalog., the bus module's keeper hears a watched seat's events (B7d finding 1; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-builder/versions.go | 3 ++- modules/mesh-delivery-builder/go.mod | 1 + modules/mesh-delivery-builder/manifest_test.go | 4 ++-- modules/mesh-delivery-builder/module.json | 2 +- .../cmd/mesh-delivery-indexer/integration_test.go | 6 +++--- .../packages_integration_test.go | 15 ++++++++------- .../cmd/mesh-delivery-indexer/verdicts.go | 8 ++++---- modules/mesh-delivery-indexer/go.mod | 1 + modules/mesh-delivery-indexer/judge/rules_test.go | 2 +- modules/mesh-delivery-indexer/manifest_test.go | 6 +++--- modules/mesh-delivery-indexer/module.json | 2 -- .../cmd/mesh-delivery-watchman/consumes.go | 5 +++-- .../cmd/mesh-delivery-watchman/main_test.go | 2 +- modules/mesh-delivery-watchman/go.mod | 1 + modules/mesh-delivery-watchman/manifest_test.go | 2 +- modules/mesh-delivery-watchman/module.json | 2 +- modules/mesh-grants/cmd/mesh-grants/main.go | 8 ++++---- .../cmd/mesh-grants/testdata/nats.module.json | 2 +- modules/mesh-grants/go.mod | 1 + .../internal/buscompose/buscompose_test.go | 4 ++-- .../mesh-grants/internal/buscompose/busstate.go | 13 +++++++++++++ modules/mesh-grants/internal/buscompose/users.go | 1 + modules/mesh-grants/internal/grants/firstviews.go | 9 +++++---- modules/mesh-grants/module.json | 4 ++-- 24 files changed, 62 insertions(+), 42 deletions(-) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go index 98d3a4fc..f2bd2cd3 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go @@ -12,6 +12,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/process" @@ -235,7 +236,7 @@ func emitEvent(ctx context.Context, js jetstream.JetStream, cred process.Account // The events on the builder's consumer of EVENTS that wake a floor (design 51 *The floor*, DLV-B3b). var ( - manifestAcceptedSubject = "mesh.mod." + delivery.IndexerModule + ".event." + delivery.ManifestAcceptedEvent + manifestAcceptedSubject = meshcatalog.SubjectManifestAccepted conditionClearedSubject = "mesh.mod." + health.KeeperModule + ".event." + health.EventCleared ) diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index 2bd2074b..a493ed16 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -3,6 +3,7 @@ module mesh-delivery-builder go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f diff --git a/modules/mesh-delivery-builder/manifest_test.go b/modules/mesh-delivery-builder/manifest_test.go index ba7e67f1..7f5f470f 100644 --- a/modules/mesh-delivery-builder/manifest_test.go +++ b/modules/mesh-delivery-builder/manifest_test.go @@ -134,9 +134,9 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 // DLV-M1: the indexer's and the assigner's views families of the states it follows. if want := []string{"container-image-registry.kept", "container-image-registry.published", "container-image-registry.refused", "container-image-registry.removed", "go-package-registry.kept", "go-package-registry.published", "go-package-registry.refused", "go-package-registry.removed", - "mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", + "mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", "mesh-catalog.manifest-accepted", "mesh-conditions.condition-cleared", "mesh-delivery-assigner.views.placement", "mesh-delivery-checker.tag-wanted", "mesh-delivery-forge-bridge.forge.**", - "mesh-delivery-indexer.manifest-accepted", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", + "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.packages", "node-engine.manifest-reads-changed", "npm-package-registry.kept", "npm-package-registry.published", "npm-package-registry.refused", "npm-package-registry.removed", "oci-artifact-registry.kept", "oci-artifact-registry.published", "oci-artifact-registry.refused", "oci-artifact-registry.removed"}; !reflect.DeepEqual(sorted(m.Consumes), want) { diff --git a/modules/mesh-delivery-builder/module.json b/modules/mesh-delivery-builder/module.json index 504beb60..25e24e0a 100644 --- a/modules/mesh-delivery-builder/module.json +++ b/modules/mesh-delivery-builder/module.json @@ -39,10 +39,10 @@ "mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", + "mesh-catalog.manifest-accepted", "mesh-conditions.condition-cleared", "mesh-delivery-checker.tag-wanted", "mesh-delivery-forge-bridge.forge.**", - "mesh-delivery-indexer.manifest-accepted", "node-engine.manifest-reads-changed", "npm-package-registry.kept", "npm-package-registry.published", diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go index fb8e56f1..b3f136f3 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go @@ -534,13 +534,13 @@ func startIndexerRun(t *testing.T, put func(context.Context, buildLog), before . t.Fatal(err) } acceptedHeard.Store(0) - waiter, err := ctl.Subscribe("mesh.mod.mesh-delivery-indexer.event.manifest-accepted", func(*nats.Msg) { acceptedHeard.Add(1) }) + waiter, err := ctl.Subscribe("mesh.seat.mesh-catalog.event.manifest-accepted", func(*nats.Msg) { acceptedHeard.Add(1) }) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = waiter.Unsubscribe() }) // The shared events stream: the indexer's ordinary events and the assigner's views events (ADR 0635 §5). - if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil { + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil { t.Fatal(err) } put(ctx, buildLog{ctx: ctx, js: js}) @@ -733,7 +733,7 @@ func TestIndexerKeepsManifestsAtAMerge(t *testing.T) { // 1444/AC4, 1449/AC4, 15 break } var r delivery.ManifestRefused - if msg.Subject == "mesh.mod.mesh-delivery-indexer.event."+delivery.ManifestRefusedEvent && json.Unmarshal(msg.Data, &r) == nil { + if msg.Subject == "mesh.seat.mesh-catalog.event."+delivery.ManifestRefusedEvent && json.Unmarshal(msg.Data, &r) == nil { said[r.Module] = true } } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go index 58b6b254..8094349c 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go @@ -78,14 +78,10 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) } } serveForge(t, js.Conn(), listing, goSumContent) - // The seat definitions' state and stream (DLV-I17), as the grants part makes them from the manifest. + // The seat definitions' state (DLV-I17), as the grants part makes it from the manifest. if seatDefs, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: index.SeatDefinitionsBucket, History: 5}); err != nil { t.Fatal(err) } - if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEAT_MESH_CATALOG", - Subjects: []string{"mesh.seat." + index.CatalogSeat + ".event.>"}}); err != nil { - t.Fatal(err) - } // DLV-I19, I20: the indexer's findings and takeins states, and the checker's stream asks with the indexer's durable // consumer, which carries the checker's verdict event. if findings, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: findingsBucket, History: 1}); err != nil { @@ -263,12 +259,17 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) t.Fatalf("%s is kept 20 s after its pull request closed", pk) } } - info, err := js.Stream(ctx, "SEAT_MESH_CATALOG") + // The seat's events are on the shared events stream like every seat's. + info, err := js.Stream(ctx, "EVENTS") + if err != nil { + t.Fatal(err) + } + full, err := info.Info(ctx, jetstream.WithSubjectFilter("mesh.seat."+index.CatalogSeat+".event.seat-definition-changed")) if err != nil { t.Fatal(err) } // Said on the seat's subject: the trunk's git alone; a pull request's head (unparsed, then closed) says nothing (ADR 0636 §4). - if n := info.CachedInfo().State.Msgs; n != 1 { + if n := full.State.Subjects["mesh.seat."+index.CatalogSeat+".event.seat-definition-changed"]; n != 1 { t.Errorf("seat-definition-changed said %d times, want 1", n) } if m, err := info.GetLastMsgForSubject(ctx, "mesh.seat.mesh-catalog.event.seat-definition-changed"); err != nil || diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/verdicts.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/verdicts.go index 9bf2967c..72639600 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/verdicts.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/verdicts.go @@ -13,7 +13,7 @@ import ( "github.com/nats-io/nats.go/jetstream" ) -// sayRefused says the indexer's event manifest-refused (novox/hq ADR 0578 §1): mesh.mod..event.manifest-refused, +// sayRefused says the indexer's event manifest-refused (novox/hq ADR 0578 §1): mesh.seat.mesh-catalog.event.manifest-refused, // with the SDK's envelope headers and the digest and judge as the bus's dedup id, so one refusal is said once. func sayRefused(js jetstream.JetStream, cred process.Account, logf func(string, ...any)) func(context.Context, delivery.ManifestRefused) { return func(ctx context.Context, r delivery.ManifestRefused) { @@ -22,7 +22,7 @@ func sayRefused(js jetstream.JetStream, cred process.Account, logf func(string, logf("judge: %s for %s is not said: %v", delivery.ManifestRefusedEvent, r.Module, err) return } - msg := nats.NewMsg(cred.EventSubject(delivery.ManifestRefusedEvent)) + msg := nats.NewMsg(seatEventSubject(delivery.ManifestRefusedEvent)) id := "manifest-refused-" + r.Digest + "-" + r.Judge msg.Header.Set("x-event-id", id) msg.Header.Set("x-source", cred.Module) @@ -39,7 +39,7 @@ func sayRefused(js jetstream.JetStream, cred process.Account, logf func(string, } // sayAccepted says the indexer's event manifest-accepted (issue 1966 D3, novox/hq ADR 0595 §4): -// mesh.mod..event.manifest-accepted, carrying the module's bus-state entries whole, with the digest and judge as +// mesh.seat.mesh-catalog.event.manifest-accepted, carrying the module's bus-state entries whole, with the digest and judge as // the bus's dedup id (delivery.ManifestAccepted.EventID), so one acceptance is said once. func sayAccepted(js jetstream.JetStream, cred process.Account) func(context.Context, delivery.ManifestAccepted, string) error { return func(ctx context.Context, a delivery.ManifestAccepted, id string) error { @@ -47,7 +47,7 @@ func sayAccepted(js jetstream.JetStream, cred process.Account) func(context.Cont if err != nil { return err } - msg := nats.NewMsg(cred.EventSubject(delivery.ManifestAcceptedEvent)) + msg := nats.NewMsg(seatEventSubject(delivery.ManifestAcceptedEvent)) msg.Header.Set("x-event-id", id) msg.Header.Set("x-source", cred.Module) msg.Header.Set("x-node", cred.Node) diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 28275d38..040cad33 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,6 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 198c1388..2d3a8074 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -4355,7 +4355,7 @@ func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) { keeper := func(bus string, more string) []byte { return []byte(`{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], "claims":[{"name":"mesh-broker","scope":"mesh","serves":["list"]}],"own-secrets":{"broker":"/run/broker"}, - "tools":["nats_server"],"consumes":["mesh-delivery-indexer.manifest-accepted"],"uses":["operator-channel"], + "tools":["nats_server"],"consumes":["mesh-catalog.manifest-accepted"],"uses":["operator-channel"], "state":[{"name":"requests","history":5}],"streams":[{"name":"broker-events","events":["created"]}], "emits":["consumer-removed"], "resources":[{"id":"nats-keeper","type":"user","name":"nats-keeper"}, diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index 50c84878..005ba5f2 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -109,9 +109,9 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build } // 1126: placement is the seat mesh-placement's (ADR 0567): the indexer holds no placement state. ADR 0578 §1: it says // a refused manifest as manifest-refused, and (issue 1966 D3, 1966/AC10) a newly accepted one asking for bus state as - // manifest-accepted. - if own := slices.DeleteFunc(slices.Clone(m.Emits), func(e string) bool { return strings.HasPrefix(e, views.EventPrefix) }); !reflect.DeepEqual(own, []string{"manifest-refused", "manifest-accepted", takein.TakeInPendingEvent, takein.TakeInTakenEvent}) { - t.Errorf("emits = %v, want manifest-refused, manifest-accepted and the two take-in events", m.Emits) + // manifest-accepted, both events of the seat mesh-catalog it holds (ADR 0636 §4), so not among its `emits`. + if own := slices.DeleteFunc(slices.Clone(m.Emits), func(e string) bool { return strings.HasPrefix(e, views.EventPrefix) }); !reflect.DeepEqual(own, []string{takein.TakeInPendingEvent, takein.TakeInTakenEvent}) { + t.Errorf("emits = %v, want the two take-in events (the verdict events are the seat mesh-catalog's)", m.Emits) } // 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard. if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "modules", History: 20}, {Name: "predefined", History: 5}, {Name: "config-candidates", History: 5}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}, {Name: "verdicts", History: 5}, {Name: "oldsections", History: 1}, {Name: delivery.PackagesState, History: delivery.PackagesHistory}, diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index 54a6003f..a28580bc 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -89,8 +89,6 @@ { "name": "pending", "history": 1 } ], "emits": [ - "manifest-refused", - "manifest-accepted", "take-in.pending", "take-in.taken", "views.candidates", diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/consumes.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/consumes.go index bb1263ab..7dace553 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/consumes.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/consumes.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "strings" "sync" @@ -16,7 +17,7 @@ import ( "mesh-delivery-watchman/internal/watch" ) -// **What the watchman consumes** (its manifest's `consumes`; novox/hq ADR 0595 §4, design 25 § 12.2; DLV-W1): the indexer's +// **What the watchman consumes** (its manifest's `consumes`; novox/hq ADR 0595 §4, design 25 § 12.2; DLV-W1): the seat mesh-catalog's // manifest-accepted and the seat mesh-broker's created and refused, all on its durable consumer on EVENTS (the seat's // events on the seat's subjects like every seat's: the lead's ruling, registry milestone 13:0x); beside it any module // stream its membership names (Hears). Each consumer is the one the grants part makes; one @@ -34,7 +35,7 @@ const ( // and refused. Anything else is left. func consumed(w *watch.Watchman, subject string, body []byte, logf func(string, ...any)) { switch { - case strings.HasSuffix(subject, ".event."+eventAccepted) && strings.HasPrefix(subject, "mesh.mod."): + case subject == meshcatalog.SubjectManifestAccepted: var a delivery.ManifestAccepted if err := json.Unmarshal(body, &a); err != nil || a.Module == "" { logf("a manifest-accepted that does not read is left: %v", err) diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main_test.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main_test.go index 68e97fca..c96c4f0d 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main_test.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main_test.go @@ -64,7 +64,7 @@ func TestRunNeedsItsCredential(t *testing.T) { // 1135/AC6 if !out.findings[watch.FindingKey(watch.ScopeModule, "m", watch.KindBusStateRefused+"-l")] { t.Fatalf("refused reached no finding: %v", out.findings) } - consumed(w, "mesh.mod.mesh-delivery-indexer.event.manifest-accepted", []byte(`{"module":"m","digest":"d","bus-state":[]}`), say) + consumed(w, "mesh.seat.mesh-catalog.event.manifest-accepted", []byte(`{"module":"m","digest":"d","bus-state":[]}`), say) if len(out.findings) != 0 { t.Fatalf("an accepted manifest no longer asking for l left %v", out.findings) } diff --git a/modules/mesh-delivery-watchman/go.mod b/modules/mesh-delivery-watchman/go.mod index 71b2126f..defa3868 100644 --- a/modules/mesh-delivery-watchman/go.mod +++ b/modules/mesh-delivery-watchman/go.mod @@ -3,6 +3,7 @@ module mesh-delivery-watchman go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/meshbroker v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f diff --git a/modules/mesh-delivery-watchman/manifest_test.go b/modules/mesh-delivery-watchman/manifest_test.go index 0175acda..0724860c 100644 --- a/modules/mesh-delivery-watchman/manifest_test.go +++ b/modules/mesh-delivery-watchman/manifest_test.go @@ -109,7 +109,7 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 t.Errorf("invokes = %v, want %v", m.Invokes, want) } // DLV-M5 its first views, 1585/T20 the indexer's take-in events, and DLV-W1 (ADR 0595 §4): the accepted verdicts and the bus module's created and refused, durably. - if want := []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.take-in.pending", "mesh-delivery-indexer.take-in.taken", "mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.manifest-accepted", "mesh-broker.created", "mesh-broker.refused"}; !reflect.DeepEqual(m.Consumes, want) { + if want := []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.take-in.pending", "mesh-delivery-indexer.take-in.taken", "mesh-delivery-indexer.views.modules", "mesh-catalog.manifest-accepted", "mesh-broker.created", "mesh-broker.refused"}; !reflect.DeepEqual(m.Consumes, want) { t.Errorf("consumes = %v, want %v", m.Consumes, want) } // 1952/WM1: the watchman holds the seat mesh-watchdogs at mesh scope, its first seat (novox/hq ADR 0564 §1, §4). diff --git a/modules/mesh-delivery-watchman/module.json b/modules/mesh-delivery-watchman/module.json index 35d71c49..25acacd9 100644 --- a/modules/mesh-delivery-watchman/module.json +++ b/modules/mesh-delivery-watchman/module.json @@ -35,7 +35,7 @@ "mesh-delivery-indexer.take-in.pending", "mesh-delivery-indexer.take-in.taken", "mesh-delivery-indexer.views.modules", - "mesh-delivery-indexer.manifest-accepted", + "mesh-catalog.manifest-accepted", "mesh-broker.created", "mesh-broker.refused" ], diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index 6f7a4cb7..deb2ca28 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -32,11 +32,11 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "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/announce" "git.novox.be/novox/mesh-sdk/go/process" - "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/views" "mesh-grants/internal/grants" @@ -157,9 +157,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro hearViews(m) return } - // The indexer's verdict events go to the verdicts' first view (ADR 0622 §2), which takes each at its revision. - if event, ok := strings.CutPrefix(m.Subject(), "mesh.mod."+grants.IndexerModule+".event."); ok && - (event == delivery.ManifestAcceptedEvent || event == delivery.ManifestRefusedEvent) { + // The seat mesh-catalog's verdict events go to the verdicts' first view (ADR 0622 §2), which takes each at its revision. + if event, ok := strings.CutPrefix(m.Subject(), "mesh.seat."+meshcatalog.Seat+".event."); ok && + (event == meshcatalog.EventManifestAccepted || event == meshcatalog.EventManifestRefused) { if err := fv.HearVerdict(ctx, event, m.Data(), logger.Printf); err != nil { logger.Printf("views: %s is not a verdict event, dropped: %v", m.Subject(), err) _ = m.Term() diff --git a/modules/mesh-grants/cmd/mesh-grants/testdata/nats.module.json b/modules/mesh-grants/cmd/mesh-grants/testdata/nats.module.json index 02b1320c..af929ebe 100644 --- a/modules/mesh-grants/cmd/mesh-grants/testdata/nats.module.json +++ b/modules/mesh-grants/cmd/mesh-grants/testdata/nats.module.json @@ -57,7 +57,7 @@ "mesh-delivery-indexer.indexer_bus_state" ], "consumes": [ - "mesh-delivery-indexer.manifest-accepted" + "mesh-catalog.manifest-accepted" ], "uses": [ "operator-channel" diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index 56718814..26c51537 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,6 +3,7 @@ module mesh-grants go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/meshbroker v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 06d80f97..d0fcc253 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -1097,7 +1097,7 @@ func theBusModulesProcessKeepsBusState(t *testing.T) { {Module: "node-tools", ToolRunner: true}, {Module: "nats", SnapshotsTheBus: true, Holds: []Seat{{Name: meshbroker.Seat, Serves: BrokerVerbs(), Emits: []string{"created", "refused"}}}, - Consumes: []string{"mesh-delivery-indexer.manifest-accepted"}, Uses: []Seat{{Name: "operator-channel", Accepts: []string{"condition"}, Emits: []string{"decided"}, ByCaller: []string{"condition", "decided"}}}, + Watches: []Seat{{Name: "mesh-catalog", Emits: []string{"manifest-accepted"}}}, Uses: []Seat{{Name: "operator-channel", Accepts: []string{"condition"}, Emits: []string{"decided"}, ByCaller: []string{"condition", "decided"}}}, Streams: []ModuleStream{{Module: "nats", Name: "BROKER_EVENTS", Events: []string{"created", "refused"}, MaxAgeDays: 30, MaxMiB: 256}}, State: []Bucket{{Module: "nats", Name: "spent", ReadOnly: true}}, Processes: []ProcessPrincipal{{Process: "keeper", State: []string{"asked", "spent"}, Keeps: true}, {Process: "other"}, @@ -1214,7 +1214,7 @@ func theBusModulesProcessKeepsBusState(t *testing.T) { made := false for _, mc := range AccountConsumers(users) { if mc.Consumer.Stream == "EVENTS" && mc.Consumer.Name == "a_nats_keeper" && - slices.Equal(mc.Consumer.Filters, []string{"mesh.mod.mesh-delivery-indexer.event.manifest-accepted", + slices.Equal(mc.Consumer.Filters, []string{"mesh.seat.mesh-catalog.event.manifest-accepted", "mesh.seat.operator-channel.event.decided.nats"}) { made = true } diff --git a/modules/mesh-grants/internal/buscompose/busstate.go b/modules/mesh-grants/internal/buscompose/busstate.go index 5cf527cb..6d964df6 100644 --- a/modules/mesh-grants/internal/buscompose/busstate.go +++ b/modules/mesh-grants/internal/buscompose/busstate.go @@ -93,6 +93,19 @@ func keeperConsumer(p Principal) (Consumer, bool) { filters = append(filters, s) } } + // And the events of the seats its module watches (its consumes naming a seat's event, such as the seat mesh-catalog's + // manifest-accepted), on the seat's own subjects. + for _, w := range p.Watches { + if w.Kinded { + continue + } + for _, e := range w.Emits { + if pattern, ok := machineEvent(w, e); ok { + e = pattern + } + filters = append(filters, seatSubject(w, "event", e)) + } + } // And the events of the seats its module uses that are said to it by name, the operator channel's `decided` for its own // asks among them (design 25 § 12.3: the process wakes on the warrant, heard on its durable consumer, never on a timer). for _, s := range SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe { diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index b5450d65..0805cf13 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -249,6 +249,7 @@ func Users(r Records) ([]Principal, error) { k := &out[len(out)-1] k.KeepsBus, k.KeepsBusState = true, BusStateNames(r.BusState, r.Bound) k.Consumes, k.Streams = d.Consumes, d.Streams + k.Watches = d.Watches k.SaysSeatEvents = brokerSeatEvents(d.Holds) } if d.SnapshotsTheBus && len(pr.Removes) > 0 { diff --git a/modules/mesh-grants/internal/grants/firstviews.go b/modules/mesh-grants/internal/grants/firstviews.go index c9e0fc5b..52170378 100644 --- a/modules/mesh-grants/internal/grants/firstviews.go +++ b/modules/mesh-grants/internal/grants/firstviews.go @@ -14,6 +14,7 @@ import ( 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" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -22,7 +23,7 @@ import ( // DLV-R1). The part reads the assigner's placement and wants and the indexer's manifests, modules and verdicts no longer // from their buckets, but by each owner's first-view tool, asked once at its start, then from the owner's views family // alone (each event one changed key with its revision), asking once more only on a gap in revisions, never on a timer. -// The verdicts alone are followed on the indexer's manifest-accepted and manifest-refused, which carry the revision +// The verdicts alone are followed on the seat mesh-catalog's manifest-accepted and manifest-refused, which carry the revision // (ADR 0622 §2), not on its views family (HearVerdict). // Started while an owner is down it keeps asking and never reads the owner's state; past the bound (two minutes) it // raises FirstViewUnanswered naming the owner and the tool, cleared by the answer. @@ -260,11 +261,11 @@ func (v *Views) Again(ctx context.Context, state, why string) ([]string, error) return asked, nil } -// VerdictEvents are the indexer's two events the part follows the verdicts on after their first view (ADR 0622 §2), as its +// VerdictEvents are the seat mesh-catalog's two events the part follows the verdicts on after their first view (ADR 0622 §2), as its // manifest's consumes name them. -var VerdictEvents = []string{IndexerModule + "." + delivery.ManifestAcceptedEvent, IndexerModule + "." + delivery.ManifestRefusedEvent} +var VerdictEvents = []string{meshcatalog.Seat + "." + meshcatalog.EventManifestAccepted, meshcatalog.Seat + "." + meshcatalog.EventManifestRefused} -// HearVerdict takes one of the indexer's manifest-accepted or manifest-refused (event, its name) into the verdicts' first +// HearVerdict takes one of the seat mesh-catalog's manifest-accepted or manifest-refused (event, its name) into the verdicts' first // view, as one change of the verdicts state at the revision it carries (ADR 0622 §2): the manifest's digest its key, the // verdict it says its value. A revision past the one after the picture's is a gap, and so is an event carrying no // revision (an indexer that does not say it) or one saying no manifest above the picture's revision (the empty accepted diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 11d77c8e..5363b8ca 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -21,8 +21,8 @@ "mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", - "mesh-delivery-indexer.manifest-accepted", - "mesh-delivery-indexer.manifest-refused" + "mesh-catalog.manifest-accepted", + "mesh-catalog.manifest-refused" ], "uses": [ "operator-channel" From 9d360e701a0fd237c9c5493ab59a817582f3b120 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:27:41 +0100 Subject: [PATCH 02/14] Builder: its one pull of its durable on the shared events stream is the module's event hub: the seats' events and the condition keeper's are one handler, the views reader another, the hub started last (B7c finding 1; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../gone_integration_test.go | 2 +- .../mesh-delivery-builder/integration_test.go | 9 ++------ .../cmd/mesh-delivery-builder/main.go | 22 ++++++++++++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go index 58395d16..bf59a418 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go @@ -65,7 +65,7 @@ func (h *heldVerb) asks() int { // from the manifest's consumes. func eventsConsumer(t *testing.T, b *bed, node string) { t.Helper() - if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: eventsStream, Subjects: []string{delivery.ArtifactsGoneSubject}}); err != nil { + if _, err := b.js.CreateOrUpdateStream(b.ctx, jetstream.StreamConfig{Name: eventsStream, Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil { t.Fatal(err) } if _, err := b.js.CreateOrUpdateConsumer(b.ctx, eventsStream, jetstream.ConsumerConfig{Durable: node + "_mesh-delivery-builder", diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go index fe813e7a..795fd0c0 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go @@ -1191,11 +1191,10 @@ func (b *bed) registry(t *testing.T, node string, seatVersions ...string) *regis mk(versions.FloorWaitsBucket) // EVENTS with two seats' outcomes, and the forge bridge's stream (a branch's end): each with the builder's durable consumer. for stream, subjects := range map[string][]string{ - eventsStream: {delivery.ArtifactsGoneSubject, ociartifactregistry.SubjectPublished, ociartifactregistry.SubjectRefused, ociartifactregistry.SubjectRemoved, - gopackageregistry.SubjectPublished, gopackageregistry.SubjectRefused, gopackageregistry.SubjectRemoved}, + eventsStream: {"mesh.mod.*.event.>", "mesh.seat.*.event.>"}, forgeStream: {process.ModuleStreamSubject(delivery.ForgeBridgeModule, ">")}, } { - if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: stream, Subjects: subjects}); err != nil { + if _, err := b.js.CreateOrUpdateStream(b.ctx, jetstream.StreamConfig{Name: stream, Subjects: subjects}); err != nil { t.Fatal(err) } if _, err := b.js.CreateOrUpdateConsumer(b.ctx, stream, jetstream.ConsumerConfig{Durable: node + "_" + delivery.BuilderModule, @@ -1216,10 +1215,6 @@ func (b *bed) registry(t *testing.T, node string, seatVersions ...string) *regis t.Fatal(err) } t.Cleanup(func() { _ = rm.Unsubscribe() }) - own := "mesh.mod." + delivery.BuilderModule + ".event.>" - if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "BUILDER_EVENTS", Subjects: []string{own}}); err != nil { - t.Fatal(err) - } nc := b.js.Conn() // As a build module hears it: on the contract's subject, read by the contract's decoder (BG2's late publish). sub, err := nc.Subscribe(delivery.VersionNumberedSubject, func(m *nats.Msg) { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index c9493ceb..927a8ad9 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -75,7 +75,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } defer calls.Close() - reader := &views.ProcessReader{Module: delivery.BuilderModule, Call: calls.Call, Logf: logger.Printf} + // The module has one durable consumer of EVENTS: one hub pulls it and hands each message to the part that hears it (the + // views reader and the builder's own events), started once every part is registered. + hub := process.NewEventHub(logger.Printf) + reader := &views.ProcessReader{Module: delivery.BuilderModule, Call: calls.Call, Logf: logger.Printf, Hub: hub} findingsKV := process.AwaitBucket(ctx, js, conn, findingsBucket, "the builder's findings, a first view unanswered past its bound among them, are said in its log only", logger.Printf) reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findingsKV.KV) } modules, err := reader.Follow(delivery.IndexerModule, modulesState, delivery.IndexerModule+".indexer_read_modules") @@ -330,16 +333,29 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro floor := floorWake{b: b, waits: floorWaits, logf: logger.Printf} // And the four registry seats' published, refused and removed (DLV-B4), which move a numbered version. seats := seatHearer{n: numberer, b: b, logf: logger.Printf, pub: pubs} - hear(eventsStream, delivery.ArtifactsGoneEvent+", what wakes a floor, the registry seats' words and manifest-reads-changed are not heard until the builder starts again", func(m jetstream.Msg) { + events := func(m jetstream.Msg) { if reads.heard(m, logger.Printf) || floor.heard(ctx, m) || seats.heard(ctx, m) { // reads: the node-engine seat's manifest-reads-changed (ADR 0618 §4) return } heardGone(ctx, b, m, logger.Printf) - }) + } + // The seats' events (artifacts-gone, the registry seats' words, manifest-accepted and manifest-reads-changed) and the + // condition keeper's condition-cleared; the owners' views events are the views reader's. + for _, pattern := range []string{"mesh.seat.*.event.>", conditionClearedSubject} { + if err := hub.Handle(pattern, events); err != nil { + return err + } + } if stopped != nil { return stopped } reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "builder_first_views reads nothing and builder_first_view_again is not heard", logger.Printf)) + // Every part has registered: the hub pulls the module's durable. Where the consumer is not there yet the builder says so + // and binds it at a later change (heard.Writer; ADR 0615 §5). + _ = notHeard.Bind(eventsStream, delivery.ArtifactsGoneEvent+", what wakes a floor, the registry seats' words, the views and manifest-reads-changed are not heard until the builder starts again", func() error { + return hub.Start(ctx, js, cred) + }) + go func() { <-ctx.Done(); hub.Stop() }() // The modules, once their first views and the manifests' are answered: what a module builds is read from the manifests. go func() { errs <- buildFromModules(ctx, b, modules, manifests, candidates) }() // A package's name heard in the indexer's packages looks at its module again, so a done build is numbered then. From 4a0618ea117b50da0753d38d714a9fe5f1ce4765 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:29:41 +0100 Subject: [PATCH 03/14] Checker: its one pull of its durable on the shared events stream is the module's event hub: the registry seats' published and the views reader are two handlers of it (B7c finding 1; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../mesh-delivery-checker/integration_test.go | 10 ++----- .../cmd/mesh-delivery-checker/main.go | 29 ++++++++++--------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go index 1e30bbfe..e0fb6fad 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go @@ -27,7 +27,6 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry" "git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker" ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -425,8 +424,8 @@ func startBed(t *testing.T) *bed { meshdeliverychecker.HolderSubjectTagWanted(delivery.CheckerModule)}}); err != nil { t.Fatal(err) } - // The shared events stream with the package registry seats' published, and the checker's durable consumer of it. - if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{gopackageregistry.SubjectPublished, npmpackageregistry.SubjectPublished}}); err != nil { + // The shared events stream with the seats' and modules' events (the package registry seats' published among them), and the checker's durable consumer of it. + if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil { t.Fatal(err) } if _, err := js.CreateOrUpdateConsumer(ctx, process.EventsStream, jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-checker", AckPolicy: jetstream.AckExplicitPolicy}); err != nil { @@ -1057,11 +1056,6 @@ func TestATryIsJudgedAndItsEndWrittenWithNoStatusOwed(t *testing.T) { // the readable ones, which is what grants the forge module its read of owed (ADR 0495 §2-§3, ADR 0521; R4e finding R2-1). func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1 b := startBed(t) - // The bed's shared events stream carries the registry seats' published; the module events go on it too. - if _, err := b.js.UpdateStream(b.ctx, jetstream.StreamConfig{Name: process.EventsStream, Subjects: []string{"mesh.mod.*.event.>", - gopackageregistry.SubjectPublished, npmpackageregistry.SubjectPublished}}); err != nil { - t.Fatal(err) - } b.start(t, nil) events, err := b.js.Stream(b.ctx, "EVENTS") if err != nil { diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index 3449b79f..55cc4c02 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -142,7 +142,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // first view of their read tools and then their views families, never their buckets (ADR 0615 §1, ADR 0596, DLV-M2). // An owner that is down never holds the start: the checks start once every first view is answered, and until then the // unanswered first view is the checker's finding. - reader := &views.ProcessReader{Module: delivery.CheckerModule, Call: caller.Call, Logf: logger.Printf} + // The module has one durable consumer of EVENTS: one hub pulls it for the views reader and for the registry seats' + // published, started once both are registered. + hub := process.NewEventHub(logger.Printf) + reader := &views.ProcessReader{Module: delivery.CheckerModule, Call: caller.Call, Logf: logger.Printf, Hub: hub} findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf) reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) } // Each by the owner's read tool and views family as the owner's manifest names them. @@ -260,20 +263,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // not there yet a group's check asks and waits in vain until its bound, said in its verdict. tags := tagAsks{js: js, node: cred.Node, heard: &check.PublishedHeard{}, logf: logger.Printf, now: time.Now} ch.Says = tags // a pull request's verdict event, on the same stream asks (design 26 *Out of step*) - switch cons, err := cred.EventConsumer(ctx, js, process.EventsStream); { - case err != nil: - logger.Printf("the consumer %s_%s on %s does not exist yet, so no package registry's published is heard and a group's check fails at its publish bound until the checker starts again: %v", - cred.Node, cred.Module, process.EventsStream, err) - default: - cc, err := process.HearEvents(cons, func(m jetstream.Msg) { - if !tags.heardPublished(m) { - _ = m.Ack() // nothing else the checker consumes rides the shared stream - } - }, logger.Printf) - if err != nil { - return err + if err := hub.Handle("mesh.seat.*.event.>", func(m jetstream.Msg) { + if !tags.heardPublished(m) { + _ = m.Ack() // nothing else the checker consumes rides the shared stream } - defer cc.Stop() + }); err != nil { + return err } ch.Judge = newJudge(judgeDeps{ asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks, @@ -461,6 +456,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return nil } reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "checker_first_views reads nothing and checker_first_view_again is not heard", logger.Printf)) + // Every part has registered. Where the consumer is not there yet no package registry's published is heard and a group's + // check fails at its publish bound until the checker starts again. + if err := hub.Start(ctx, js, cred); err != nil { + logger.Printf("%v, so no package registry's published is heard and a group's check fails at its publish bound until the checker starts again", err) + } + defer hub.Stop() // The checks, the tries and the hand asks, once every first view is answered (the lead's ruling for DLV-M2): a check // judged without the candidates, the modules, the manifests, the published packages, the predefined keys or the // placement would be another check. From 2e66238a01291cc1b499f435fe3b9617587fce83 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:30:33 +0100 Subject: [PATCH 04/14] Watchman: its one pull of its durable on the shared events stream is the module's event hub: the take-in events, the seats' events and the views reader are three handlers of it (B7c finding 1; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-watchman/consumes.go | 19 ++++++++++++++++++- .../cmd/mesh-delivery-watchman/main.go | 13 ++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/consumes.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/consumes.go index 7dace553..cbd06fde 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/consumes.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/consumes.go @@ -59,7 +59,15 @@ func consumed(w *watch.Watchman, subject string, body []byte, logf func(string, // hearConsumed opens the watchman's durable consumers: on EVENTS now, and on each module stream its membership names as // that membership arrives. A consumer the bus does not hold yet is said and tried again at the membership's next change. -func hearConsumed(ctx context.Context, js jetstream.JetStream, cred process.Account, w *watch.Watchman, logf func(string, ...any)) { +func hearConsumed(ctx context.Context, js jetstream.JetStream, cred process.Account, hub *process.EventHub, w *watch.Watchman, logf func(string, ...any)) { + // The seats' events (manifest-accepted, the bus module's created and refused) are one handler of the module's one + // pull of EVENTS, beside the take-in events and the views reader. + if err := hub.Handle("mesh.seat.*.event.>", func(m jetstream.Msg) { + consumed(w, m.Subject(), m.Data(), logf) + _ = m.Ack() + }); err != nil { + w.NotHeard("EVENTS", "the seats' events cannot be handled", err) + } var mu sync.Mutex opened := map[string]bool{} open := func(stream, name string) { @@ -70,6 +78,15 @@ func hearConsumed(ctx context.Context, js jetstream.JetStream, cred process.Acco } // Not bound, the stream is the finding family-not-heard (ADR 0615 §5) until it is, at a later change of the // membership or a later start. + if stream == "EVENTS" { + if err := hub.Start(ctx, js, cred); err != nil { + w.NotHeard(stream, "what it carries is heard once the grants part has made the consumer "+name, err) + return + } + w.Heard(stream) + opened[stream] = true + return + } cons, err := js.Consumer(ctx, stream, name) if err != nil { w.NotHeard(stream, "what it carries is heard once the grants part has made the consumer "+name, err) diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go index 59b36ae6..f522d41e 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go @@ -234,7 +234,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return fmt.Errorf("the watchman's connection for its tool calls: %w", err) } defer calls.Close() - reader := &views.ProcessReader{Module: delivery.WatchmanModule, Call: calls.Call, Logf: logger.Printf, + // The module has one durable consumer of EVENTS: one hub pulls it for the views reader, the indexer's take-in events + // and the seats' events, started once every part is registered. + hub := process.NewEventHub(logger.Printf) + reader := &views.ProcessReader{Module: delivery.WatchmanModule, Call: calls.Call, Logf: logger.Printf, Hub: hub, Findings: func() views.FindingWriter { return sdkfindings.Writer(ctx, func() jetstream.KeyValue { return kvs.findings }) }} @@ -450,17 +453,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro w.TakeInCaughtUp() } } - hc, err := process.HearEvents(tc, func(m jetstream.Msg) { takeInHeard(w, m, logger.Printf) }, logger.Printf) - if err != nil { + if err := hub.Handle("mesh.mod."+delivery.IndexerModule+".event.take-in.*", func(m jetstream.Msg) { takeInHeard(w, m, logger.Printf) }); err != nil { return err } - defer hc.Stop() } w.Reconcile() if err := hear(conn, w, dead, logger.Printf); err != nil { return err } - hearConsumed(ctx, js, cred, w, logger.Printf) errs := make(chan error, 16) if healthWatch != nil { @@ -468,6 +468,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } // The first views of the wants and the modules, asked now and read each on its own goroutine once answered (ADR 0615). reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "watchman_first_views reads nothing and watchman_first_view_again is not heard", logger.Printf)) + // Every part has registered: the hub pulls the module's durable, and the watchman's other consumers open. + hearConsumed(ctx, js, cred, hub, w, logger.Printf) + defer hub.Stop() // The indexer's modules (ADR 0578 §1): a module whose value carries a refusal of its manifest is a finding. verdictEach := func(e jetstream.KeyValueEntry) { w.ModuleVerdict(e.Key(), e.Value(), opOf(e)) } go func() { errs <- readLate(ctx, w, modules, delivery.ModulesBucket, verdictEach) }() From 25a704a1f562090f619e33a20347fea8be847350 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:32:27 +0100 Subject: [PATCH 05/14] Grants part: its one pull of its durable on the shared events stream is the module's event hub: the owners' views, the seat mesh-catalog's verdicts and the other seats' events are handlers of it (B7c finding 1; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- modules/mesh-grants/cmd/mesh-grants/main.go | 62 +++++++++++-------- modules/mesh-grants/grants_test.go | 10 +++ .../mesh-grants/internal/grants/rootfree.go | 19 +++++- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index deb2ca28..012ac6cb 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -142,41 +142,40 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // dropped carry the bindings: ADR 0595 §1, §7), each on the durable consumer its own membership names; one not // there yet is opened at the next change, and one opened is never opened twice. opened := map[string]bool{} + // The module has one durable consumer of EVENTS: one hub pulls it and hands each message to the part that hears it + // (the owners' views events, the seat mesh-catalog's verdicts, the other seats' events the follower hears), started + // once all are registered. + hub := process.NewEventHub(logger.Printf) + defer hub.Stop() + registered := false f.Hear = func(handle func(string, []byte)) error { - consume := func(stream, name string) error { - if opened[stream] { - return nil + if !registered { + registered = true + // An owner's views event goes to the follower of its state, which acknowledges it (firstviews.go). + if err := hub.Handle(views.EventsPattern, hearViews); err != nil { + return err } - cons, err := js.Consumer(ctx, stream, name) - if err != nil { - return fmt.Errorf("the consumer %s on %s: %w", name, stream, err) - } - if _, err := cons.Consume(func(m jetstream.Msg) { - // An owner's views event goes to the follower of its state, which acknowledges it (firstviews.go). - if strings.Contains(m.Subject(), ".event."+views.EventPrefix) { - hearViews(m) - return - } - // The seat mesh-catalog's verdict events go to the verdicts' first view (ADR 0622 §2), which takes each at its revision. - if event, ok := strings.CutPrefix(m.Subject(), "mesh.seat."+meshcatalog.Seat+".event."); ok && - (event == meshcatalog.EventManifestAccepted || event == meshcatalog.EventManifestRefused) { - if err := fv.HearVerdict(ctx, event, m.Data(), logger.Printf); err != nil { + // The seat mesh-catalog's verdict events go to the verdicts' first view (ADR 0622 §2), which takes each at its revision. + for _, event := range []string{meshcatalog.EventManifestAccepted, meshcatalog.EventManifestRefused} { + if err := hub.Handle("mesh.seat."+meshcatalog.Seat+".event."+event, func(m jetstream.Msg) { + if err := fv.HearVerdict(ctx, strings.TrimPrefix(m.Subject(), "mesh.seat."+meshcatalog.Seat+".event."), m.Data(), logger.Printf); err != nil { logger.Printf("views: %s is not a verdict event, dropped: %v", m.Subject(), err) _ = m.Term() return } _ = m.Ack() - return + }); err != nil { + return err + } + } + // The other seats' events the part follows its passes by (Consumes). + for _, subject := range grants.SeatEventSubjects() { + if err := hub.Handle(subject, func(m jetstream.Msg) { handle(m.Subject(), m.Data()); _ = m.Ack() }); err != nil { + return err } - handle(m.Subject(), m.Data()) - _ = m.Ack() - }, jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { logger.Printf("hearing the bus: %v", err) })); err != nil { - return err } - opened[stream] = true - return nil } - if err := consume("EVENTS", account.Node+"_"+account.Module); err != nil { + if err := hub.Start(ctx, js, account); err != nil { return err } hears, err := grants.OwnHears(ownStore{conn}, account.Node) @@ -184,9 +183,20 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } for _, h := range hears { - if err := consume(h.Stream, h.Consumer); err != nil { + if opened[h.Stream] { + continue + } + cons, err := js.Consumer(ctx, h.Stream, h.Consumer) + if err != nil { + return fmt.Errorf("the consumer %s on %s: %w", h.Consumer, h.Stream, err) + } + if _, err := cons.Consume(func(m jetstream.Msg) { + handle(m.Subject(), m.Data()) + _ = m.Ack() + }, jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { logger.Printf("hearing the bus: %v", err) })); err != nil { return err } + opened[h.Stream] = true } return nil } diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index cd5fbfc5..69ea7ee3 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2214,6 +2214,16 @@ func TestGrants(t *testing.T) { t.Errorf("the part consumes %s", c) } } + // The hub's seat handlers are the seats' events of Consumes on the seats' own subjects, the verdicts' and views' apart. + for _, subject := range grants.SeatEventSubjects() { + seat, event, _ := strings.Cut(strings.TrimPrefix(subject, "mesh.seat."), ".event.") + if !slices.Contains(grants.Consumes, seat+"."+event) || strings.Contains(subject, "mesh-catalog") { + t.Errorf("%s is no seat event of Consumes beside the verdicts and views", subject) + } + } + if n := len(grants.SeatEventSubjects()); n != 6 { + t.Errorf("the part hears %d seats' events, want the keeper's two, the vault's one and the broker's three", n) + } for _, c := range b.calls { if strings.Contains(c, "mesh-controller") { t.Errorf("the part called %s", c) diff --git a/modules/mesh-grants/internal/grants/rootfree.go b/modules/mesh-grants/internal/grants/rootfree.go index 01e7c6df..a270dcc0 100644 --- a/modules/mesh-grants/internal/grants/rootfree.go +++ b/modules/mesh-grants/internal/grants/rootfree.go @@ -42,8 +42,23 @@ const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent // Consumes is every event the part hears, as its manifest's consumes name them. // The seat mesh-broker's created, recreated and dropped are heard as every seat's event, `.` on EVENTS (the // lead's ruling, registry milestone 13:0x), never on the bus module's own stream. -var Consumes = append([]string{EventKeeperRaised, EventKeeperCleared, EventAccountIssued, meshbroker.Seat + "." + BrokerCreated, - meshbroker.Seat + "." + BrokerDropped, meshbroker.Seat + "." + BrokerRecreated}, viewsEvents()...) +var Consumes = append(slices.Clone(seatEvents), viewsEvents()...) + +// seatEvents are the events of seats the part follows its passes by, as `.`. +var seatEvents = []string{EventKeeperRaised, EventKeeperCleared, EventAccountIssued, meshbroker.Seat + "." + BrokerCreated, + meshbroker.Seat + "." + BrokerDropped, meshbroker.Seat + "." + BrokerRecreated} + +// SeatEventSubjects are the subjects of the seats' events the part follows its passes by besides the verdicts' and the +// views': the keeper's condition events, the vault's account-issued and the seat mesh-broker's, each `.` of +// Consumes on the seat's own subject. +func SeatEventSubjects() []string { + var out []string + for _, c := range seatEvents { + seat, event, _ := strings.Cut(c, ".") + out = append(out, "mesh.seat."+seat+".event."+event) + } + return out +} // viewsEvents are the owners' events the part follows its first views by: `.views.`, but the verdicts by // the indexer's manifest-accepted and manifest-refused (VerdictEvents, ADR 0622 §2). From 8c80e1b0976e9e9c147af6737eaeb1a6ca4b6def Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:33:37 +0100 Subject: [PATCH 06/14] Teller: the router's warrants are a handler of the module's event hub beside the views reader, on the seat's own subject (B7c finding 1; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-teller/decided.go | 37 +++++-------------- .../cmd/mesh-delivery-teller/decided_test.go | 14 ++++--- .../cmd/mesh-delivery-teller/main.go | 13 +++++-- 3 files changed, 29 insertions(+), 35 deletions(-) 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 6f850e5c..1b25805e 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided.go @@ -6,6 +6,7 @@ import ( "errors" 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" @@ -65,14 +66,16 @@ func recordVia(ctx context.Context, conn *nats.Conn, module string) func(id stri } } -// decidedVia hears the warrants the router says to this module alone: its own `decided` event, a subject only the router -// publishes (the grant to subscribe it names the module). They are pulled through the module's durable consumer on EVENTS -// (made with its grant for the seat it uses), so an answer the router published while the teller was down is delivered -// when it is back, never lost (issue 1126). hear starts the pull, handing each message to its handler; the message is -// acknowledged once handled. The handler is the asker's: pattern is the key it expects. -func decidedVia(hear func(func(jetstream.Msg)) error, logf func(string, ...any)) func(pattern string, handle func(stdio.Envelope) error) error { +// decidedVia hears the warrants the router says to this module alone: its own `decided` event, a seat event on the seat's +// subject (`mesh.seat..event..`) that only the router publishes (the grant to subscribe it names the +// module). They are handled on the module's one hub over its durable consumer on EVENTS (made with its grant for the seat +// it uses), so an answer the router published while the teller was down is delivered when it is back, never lost (issue +// 1126). hear registers the handler of the subject on the hub; the message is acknowledged once handled. The handler is the +// asker's: pattern, `..`, is the key it expects. +func decidedVia(hear func(subject string, handle func(jetstream.Msg)) error, logf func(string, ...any)) func(pattern string, handle func(stdio.Envelope) error) error { return func(pattern string, handle func(stdio.Envelope) error) error { - return hear(func(m jetstream.Msg) { + seat, event, _ := strings.Cut(pattern, ".") + return hear("mesh.seat."+seat+".event."+event, func(m jetstream.Msg) { if err := handle(stdio.Envelope{Key: pattern, Body: m.Data()}); err != nil { logf("a warrant could not be handled and is read again from the router's record at the next start: %v", err) } @@ -83,26 +86,6 @@ func decidedVia(hear func(func(jetstream.Msg)) error, logf func(string, ...any)) } } -// decidedHandler is where the router's warrants are handed: the module has one durable consumer of the shared events stream, -// pulled by the views reader (views.ProcessReader.Start), which hands every message that is not a views event to its Other. -type decidedHandler struct{ handle func(jetstream.Msg) } - -// hear registers the handler of the warrants; it pulls nothing. -func (d *decidedHandler) hear(handle func(jetstream.Msg)) error { - d.handle = handle - return nil -} - -// other is the views reader's Other: a message of the consumer that is no views event is a warrant (or nothing the teller -// hears: it is left for the bus to deliver again). -func (d *decidedHandler) other(m jetstream.Msg) { - if d.handle == nil { - _ = m.Nak() - return - } - d.handle(m) -} - // sayVia publishes a condition on the seat's one subject for conditions, under the module's own name. func sayVia(ctx context.Context, js jetstream.JetStream, module string) func(c oc.Condition) error { return func(c oc.Condition) error { diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided_test.go index ee1c86db..2266ffc3 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/decided_test.go @@ -23,12 +23,13 @@ func (m pulled) Ack() error { *m.acked++; return nil } // the handler hears the warrant under the asker's key, and the message is acknowledged, handled or not (issue 1126). func TestDecidedViaHearsThePulledWarrantAndAcknowledgesIt(t *testing.T) { var deliver func(jetstream.Msg) + var subject string var logs []string - sub := decidedVia(func(h func(jetstream.Msg)) error { deliver = h; return nil }, + sub := decidedVia(func(s string, h func(jetstream.Msg)) error { subject, deliver = s, h; return nil }, func(f string, _ ...any) { logs = append(logs, f) }) var got []stdio.Envelope fail := false - if err := sub("k", func(e stdio.Envelope) error { + if err := sub("operator-channel.decided.teller", func(e stdio.Envelope) error { got = append(got, e) if fail { return errors.New("no") @@ -37,18 +38,21 @@ func TestDecidedViaHearsThePulledWarrantAndAcknowledgesIt(t *testing.T) { }); err != nil || deliver == nil { t.Fatalf("subscribe: %v, delivering %v", err, deliver != nil) } + if subject != "mesh.seat.operator-channel.event.decided.teller" { + t.Errorf("the warrants are heard on %q", subject) + } acked := 0 deliver(pulled{data: []byte(`{"ask":"a1"}`), acked: &acked}) fail = true deliver(pulled{data: []byte(`{"ask":"a2"}`), acked: &acked}) - if len(got) != 2 || got[0].Key != "k" || string(got[0].Body) != `{"ask":"a1"}` || string(got[1].Body) != `{"ask":"a2"}` { + if len(got) != 2 || got[0].Key != "operator-channel.decided.teller" || string(got[0].Body) != `{"ask":"a1"}` || string(got[1].Body) != `{"ask":"a2"}` { t.Errorf("heard %+v", got) } if acked != 2 || len(logs) != 1 { t.Errorf("acknowledged %d of 2, logged %d lines, want 1 (the failed handling)", acked, len(logs)) } - failing := decidedVia(func(func(jetstream.Msg)) error { return errors.New("no consumer") }, nil) - if err := failing("k", nil); err == nil { + failing := decidedVia(func(string, func(jetstream.Msg)) error { return errors.New("no consumer") }, nil) + if err := failing("operator-channel.decided.teller", nil); err == nil { t.Error("a pull that could not start is no error") } } 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 b306cbc0..f4e9df8c 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go @@ -74,9 +74,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // Essential: the router's warrants are how an answer reaches the teller. return fmt.Errorf("the consumer %s on %s cannot be read (%v): the teller hears no answer without it, so it stops; where the bus refused it or did not answer, this module's consumer (made with its grant for the seat it uses) has not reached the bus yet", cred.EventConsumerName(), process.EventsStream, err) } - warrants := &decidedHandler{} + // The module has one durable consumer of EVENTS: one hub pulls it for the router's warrants and the views reader, + // started once both are registered. + hub := process.NewEventHub(logger.Printf) + defer hub.Stop() client := &occ.Client{Module: delivery.TellerModule, State: state, Publish: publish, - Record: recordVia(ctx, conn, delivery.TellerModule), Subscribe: decidedVia(warrants.hear, logger.Printf)} + Record: recordVia(ctx, conn, delivery.TellerModule), 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, delivery.TellerModule), Logf: logger.Printf, Publish: publish} // Hear the warrants first, so none that comes while the asks are read is missed. @@ -93,7 +96,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return fmt.Errorf("the teller's connection for its tool calls: %w", err) } defer calls.Close() - reader := &views.ProcessReader{Module: delivery.TellerModule, Call: calls.Call, Logf: logger.Printf, Other: warrants.other} + reader := &views.ProcessReader{Module: delivery.TellerModule, Call: calls.Call, Logf: logger.Printf, Hub: hub} findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf) reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) } asks, err := reader.Follow(delivery.AssignerModule, assignerAsksState, delivery.AssignerModule+".assigner_read_asks") @@ -102,6 +105,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } assignerKV := jetstream.KeyValue(asks) reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "teller_first_views reads nothing and teller_first_view_again is not heard", logger.Printf)) + // Every part has registered: the hub pulls the module's durable. + if err := hub.Start(ctx, js, cred); err != nil { + return fmt.Errorf("the consumer %s on %s cannot be read (%v): the teller hears no answer without it, so it stops", cred.EventConsumerName(), process.EventsStream, err) + } viewKV := process.OptionalBucket(ctx, js, delivery.ViewAnswersBucket, teller, logger.Printf) // The assigner's asks are raised on the operator channel whether or not the view's answers can be read (issue 1906). if viewKV != nil { From 376119d61e9f1fd9d729e01e1096074059f464f4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:34:08 +0100 Subject: [PATCH 07/14] Forge bridge: its one pull of its durable on the shared events stream is the module's event hub, for consistency with the modules that have a second part (B7c finding 1; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-forge-bridge/main.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go index 25e4b970..3c6860fc 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go @@ -195,23 +195,23 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro go onReconnect(ctx, again, said, logger) // What a merge read of each module directory, by its tree (issue 1126): a merge reads only the directories it changed. inputs := newKept(process.OptionalBucket(ctx, js, inputsBucket, "a merge's module directories are kept in memory only, so the first merge after a start reads every one", logger.Printf), logger) - cons, err := cred.EventConsumer(ctx, js, eventsStream) - if err != nil { - // Essential: the forge's events are what the bridge carries. - return fmt.Errorf("the consumer %s_%s on %s cannot be read (%v): the bridge hears no forge event without it, so it stops; where the bus refused it or did not answer, this module's consumes (made by the controller with its grant) have not reached the bus yet", cred.Node, cred.Module, eventsStream, err) - } - cc, err := process.HearEvents(cons, func(m jetstream.Msg) { + // The module's one pull of its durable consumer on the shared events stream is its event hub; the bridge has the one + // handler, for every event its consumes filter lets through. Essential: the forge's events are what the bridge carries. + hub := process.NewEventHub(logger.Printf) + if err := hub.Handle("mesh.mod.*.event.>", func(m jetstream.Msg) { hear(&turn, func() { // Bounded: the turn is held, and no sync or other event goes on without it. hctx, cancel := context.WithTimeout(ctx, syncDeadline) defer cancel() handleGrouped(hctx, m, rec, treesVia(hctx, call), filesVia(hctx, call), inputs, groups, logger) }) - }, logger.Printf) - if err != nil { + }); err != nil { return err } - defer cc.Stop() + if err := hub.Start(ctx, js, cred); err != nil { + return fmt.Errorf("%w: the bridge hears no forge event without it, so it stops; where the bus refused it or did not answer, this module's consumes (made by the controller with its grant) have not reached the bus yet", err) + } + defer hub.Stop() select { case <-ctx.Done(): return nil From 839e27a064cc4df86e0e03a4e0fe3b5734fc0b6a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:41:04 +0100 Subject: [PATCH 08/14] meshcatalogclient: the holder's module name and the addresses of the states others read (modules, manifests), so a reader names them from the client (lead's request; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- contracts/go/clients/meshcatalogclient/catalog_test.go | 7 +++++++ contracts/go/clients/meshcatalogclient/module.go | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/contracts/go/clients/meshcatalogclient/catalog_test.go b/contracts/go/clients/meshcatalogclient/catalog_test.go index 1151fc00..91a4e3ef 100644 --- a/contracts/go/clients/meshcatalogclient/catalog_test.go +++ b/contracts/go/clients/meshcatalogclient/catalog_test.go @@ -35,6 +35,13 @@ func TestDigestAndFingerprintSetKeepTheirForms(t *testing.T) { } // A manifest and a module are read back as written, and each rule of the old values is kept: a refusal names its reason. +// The holder's states others read keep the addresses the manifests' `reads` and the first views name today. +func TestTheHoldersReadAddressesAreTheOnesTheManifestsName(t *testing.T) { + if Module != "mesh-delivery-indexer" || ModulesRead != "mesh-delivery-indexer.modules" || ManifestsRead != "mesh-delivery-indexer.manifests" { + t.Errorf("the holder %q reads %q and %q", Module, ModulesRead, ManifestsRead) + } +} + func TestManifestAndModuleValuesKeepTheirRules(t *testing.T) { if _, err := ManifestsKey("abc"); err == nil || !strings.Contains(err.Error(), `"abc"`) { t.Errorf("ManifestsKey(abc) = %v", err) diff --git a/contracts/go/clients/meshcatalogclient/module.go b/contracts/go/clients/meshcatalogclient/module.go index d07b3692..b07eada7 100644 --- a/contracts/go/clients/meshcatalogclient/module.go +++ b/contracts/go/clients/meshcatalogclient/module.go @@ -10,6 +10,15 @@ import ( "git.novox.be/novox/mesh-sdk/go/keys" ) +// Module is the module that holds the seat mesh-catalog, and the two states of its others read, by the address a +// manifest's `reads` and a first view name them: the modules the trunk holds and their manifests. A reader names them here, +// never by the holder's literal name. +const ( + Module = "mesh-delivery-indexer" + ModulesRead = Module + ".modules" + ManifestsRead = Module + ".manifests" +) + // RootPath is a `modules` value's path when the module's manifest is its repository's root. const RootPath = "." From b58e2616c790d37a415ba49579de444fae8f6668 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:47:35 +0100 Subject: [PATCH 09/14] Checker: the manifests of a change are judged against the seat list its reader holds (the request's Seats; a list not read fails the check), the module graph follows it, the manifest invokes seat-definitions and consumes its two events; composer from the delivery sdk (D-29; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-checker/engines.go | 2 +- .../mesh-delivery-checker/integration_test.go | 7 +++ .../cmd/mesh-delivery-checker/judge.go | 4 +- .../cmd/mesh-delivery-checker/main.go | 15 ++++-- .../cmd/mesh-delivery-checker/seatlist.go | 51 +++++++++++++++++++ .../internal/check/engines.go | 10 ++-- .../internal/check/engines_test.go | 2 +- .../internal/check/examiner_test.go | 33 +++++++++++- .../internal/check/graph_cycle.go | 2 +- .../internal/check/judge.go | 3 ++ .../internal/check/manifests_judge.go | 16 +++++- .../mesh-delivery-checker/manifest_test.go | 4 +- modules/mesh-delivery-checker/module.json | 3 ++ 13 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 modules/mesh-delivery-checker/cmd/mesh-delivery-checker/seatlist.go diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go index 0b1bc2fa..bb068040 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/engines.go @@ -15,8 +15,8 @@ import ( nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/buildstep" - "git.novox.be/novox/mesh-sdk/go/composer" "git.novox.be/novox/mesh-sdk/go/delivery" ) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go index e0fb6fad..02dcae26 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/integration_test.go @@ -395,6 +395,13 @@ func startBed(t *testing.T) *bed { if err != nil { t.Fatal(err) } + // The seat mesh-catalog's holder answers the seat list with no trunk seat named: the checker's reader has then heard the seat + // and holds the bootstrapped definitions, which the judge is handed (ADR 0636 §1). + for _, subject := range []string{"mesh.seat.mesh-catalog.call.seat-definitions", "mesh.seat.mesh-catalog.call.seat-definitions.>"} { + if _, err := ctl.Subscribe(subject, func(m *nats.Msg) { _ = m.Respond([]byte(`{"result":{"revision":1,"seats":[]},"node":"ctl"}`)) }); err != nil { + t.Fatal(err) + } + } ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) t.Cleanup(cancel) b := &bed{ctx: ctx, js: js, buckets: map[string]jetstream.KeyValue{}, tools: map[string]jetstream.KeyValue{}, srvURL: srv.ClientURL()} 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 fd0d494b..9e6a2075 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -28,6 +28,8 @@ type judgeDeps struct { asks check.Asks renames check.Renames seatDefs check.SeatDefinitions + // seats are the seat definitions the manifests are judged against (seatlist.go). + seats check.SeatList tools *buildrun.ToolchainSet trunks *check.Trunks kept check.KV @@ -220,7 +222,7 @@ func newJudge(d judgeDeps) check.Judge { nodes = d.nodes } 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, KnownPackages: 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, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, Logf: d.logf} + Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Published: d.published, KnownPackages: 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, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, SeatList: d.seats, 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} } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index 55cc4c02..55a908d7 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -26,6 +26,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-checker/internal/check" @@ -146,6 +147,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // published, started once both are registered. hub := process.NewEventHub(logger.Printf) reader := &views.ProcessReader{Module: delivery.CheckerModule, Call: caller.Call, Logf: logger.Printf, Hub: hub} + seats := seatList{reader: followSeatList(seatlist.ProcessBus(caller.Call, hub), logger.Printf)} findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf) reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) } // Each by the owner's read tool and views family as the owner's manifest names them. @@ -263,12 +265,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // not there yet a group's check asks and waits in vain until its bound, said in its verdict. tags := tagAsks{js: js, node: cred.Node, heard: &check.PublishedHeard{}, logf: logger.Printf, now: time.Now} ch.Says = tags // a pull request's verdict event, on the same stream asks (design 26 *Out of step*) - if err := hub.Handle("mesh.seat.*.event.>", func(m jetstream.Msg) { - if !tags.heardPublished(m) { - _ = m.Ack() // nothing else the checker consumes rides the shared stream + for subject := range publishedSeats { + if err := hub.Handle(subject, func(m jetstream.Msg) { + if !tags.heardPublished(m) { + _ = m.Ack() // nothing else the checker consumes rides the shared stream + } + }); err != nil { + return err } - }); err != nil { - return err } ch.Judge = newJudge(judgeDeps{ asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks, @@ -279,6 +283,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro predefined: keptPredefined{predefined}, renames: seatRenames{call: caller.Call}, seatDefs: seatDefinitionsOf(seatDefs), + seats: seats, }) ch.Announce = announce tryer.Judge = ch.Judge diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/seatlist.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/seatlist.go new file mode 100644 index 00000000..6da7f71e --- /dev/null +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/seatlist.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" +) + +// The checker reads the seat list as every part does (novox/hq ADR 0635 §1, ADR 0636): through the seat mesh-catalog's verb +// seat-definitions, its two events heard as handlers of the module's event hub, from the bootstrapped definitions until the seat +// has answered. What the reader holds is given to catalogue at every change, which the module graph is worked out with, and to the +// indexer's judge as a request's Seats, which the manifests of a change are judged against. + +// followSeatList starts the checker's reader of the seat list over the bus; the hub is started by the caller once every handler is registered. +func followSeatList(bus seatlist.Bus, logf func(string, ...any)) *seatlist.Reader { + reader, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(), + OnChange: func(seats []catalogue.Seat) { catalogue.UseSeats(seats) }}) + if err != nil { + logf("the checker cannot follow its seat list, so no manifest is judged until it starts again: %v", err) + } + if reader != nil { + catalogue.UseSeats(reader.Seats()) + } + return reader +} + +// seatList is the reader as the check's SeatList: the held definitions raw, and an error while the seat has not answered. +type seatList struct{ reader *seatlist.Reader } + +func (s seatList) Definitions() ([]json.RawMessage, error) { + if s.reader == nil { + return nil, fmt.Errorf("the checker follows no seat list") + } + if st := s.reader.Status(); !st.Heard { + return nil, fmt.Errorf("%s", st.Health()) + } + defs := s.reader.Definitions() + var out []json.RawMessage + for _, seat := range slices.Sorted(maps.Keys(defs)) { + raw, err := json.Marshal(defs[seat]) + if err != nil { + return nil, fmt.Errorf("the definition of %s cannot be encoded: %w", seat, err) + } + out = append(out, raw) + } + return out, nil +} diff --git a/modules/mesh-delivery-checker/internal/check/engines.go b/modules/mesh-delivery-checker/internal/check/engines.go index e6d73e9f..a5a4529e 100644 --- a/modules/mesh-delivery-checker/internal/check/engines.go +++ b/modules/mesh-delivery-checker/internal/check/engines.go @@ -11,7 +11,7 @@ import ( nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -205,10 +205,14 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s } } } - verdicts := map[string]delivery.ManifestVerdict{} + verdicts := map[string]json.RawMessage{} if e.Verdicts != nil { for name, v := range e.Verdicts() { - verdicts[name] = v + raw, err := json.Marshal(v) + if err != nil { + return a, fmt.Errorf("the verdict of the manifest of %s cannot be encoded: %w", name, err) + } + verdicts[name] = raw } } for _, p := range c.Pulls { diff --git a/modules/mesh-delivery-checker/internal/check/engines_test.go b/modules/mesh-delivery-checker/internal/check/engines_test.go index 5489310d..b5072813 100644 --- a/modules/mesh-delivery-checker/internal/check/engines_test.go +++ b/modules/mesh-delivery-checker/internal/check/engines_test.go @@ -11,7 +11,7 @@ import ( nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/delivery" ) diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index 5ac80658..b5d20493 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -21,8 +21,8 @@ import ( gobuild "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" "git.novox.be/novox/mesh-contracts/go/seats/meshconfig" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/composer" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -1054,7 +1054,13 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { // trunk's, in a second ask (ADR 0633 clause 6, 1585/T22, T23) indexer string ownJudge bool + // seats is the seat list the checker holds: "read" gives the judge its two definitions as the request's Seats, "unread" + // is a reader the seat has not answered, and the manifests are then judged by nothing (ADR 0636 §1). + seats string }{ + {name: "the seat list read is the request's Seats", seats: "read"}, + {name: "the seat list not read judges nothing", seats: "unread", + fails: "the change's manifests cannot be judged: the seat list is not read (the seat list is held from the bootstrap"}, {name: "a seat definition that does not parse", unparsed: "seats/web-api/seat.json: unexpected end of JSON input", fails: "novox/mesh-delivery#7: the seat definition of web-api (module web-contracts, go) does not parse: seats/web-api/seat.json: unexpected end of JSON input"}, {name: "a rename brought, the verb not served", renames: "bring", seat: "not served", @@ -1162,6 +1168,9 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web"}, web: {Module: "web", Claims: []catalogue.Claim{{Name: CatalogSeat}}}} } + if row.seats != "" { + b.ex.SeatList = fakeSeatList{read: row.seats == "read"} + } var judged []delivery.ComposeJob answer := b.asks.Answer b.asks.Answer = func(a delivery.StepAsk) delivery.Step { @@ -1207,6 +1216,12 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { }) { t.Errorf("%s: the case says %v, want the new indexer's manifest said accepted by the trunk's manifest validator", row.name, cs.notes) } + if row.seats == "unread" { + if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, row.fails) || len(judged) != 0 { + t.Errorf("%s: %+v, judged %d, want a fail saying %q before any judge", row.name, v.Failed, len(judged), row.fails) + } + continue + } if row.ownJudge && (len(judged) != 2 || judged[0].Fetch["commit"] != head1 || judged[1].Fetch["commit"] != trunk) { t.Fatalf("%s: judged %+v, want the head's judge and then the trunk's", row.name, judged) } @@ -1224,6 +1239,12 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { req.Cases[0].Manifests["mail"].Digest != strings.Repeat("a", 64) { t.Fatalf("%s: the request %+v: %v", row.name, req, err) } + if row.seats == "read" && (len(req.Seats) != 2 || string(req.Seats[0]) != `{"seat":"a"}` || string(req.Seats[1]) != `{"seat":"b"}`) { + t.Errorf("%s: the request's seats %s, want the two definitions the reader holds", row.name, req.Seats) + } + if row.seats == "" && len(req.Seats) != 0 { + t.Errorf("%s: the request's seats %s, want none from a checker that follows no list", row.name, req.Seats) + } if !reflect.DeepEqual(req.Packages, row.known) || len(req.Input) != 0 && string(req.Input) != "null" { t.Errorf("%s: the judge's known packages %v and input %s, want the known packages %v and no input", row.name, req.Packages, req.Input, row.known) @@ -1242,6 +1263,16 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) { } } +// fakeSeatList is the seat list reader as a test gives it: two definitions once read, else the reader's words. +type fakeSeatList struct{ read bool } + +func (f fakeSeatList) Definitions() ([]json.RawMessage, error) { + if !f.read { + return nil, errors.New("the seat list is held from the bootstrap (2 seats) and nothing new is acted on") + } + return []json.RawMessage{json.RawMessage(`{"seat":"a"}`), json.RawMessage(`{"seat":"b"}`)}, nil +} + // knownNames are the packages state's names as a test gives them, each with the / it is built from. type knownNames map[string]string diff --git a/modules/mesh-delivery-checker/internal/check/graph_cycle.go b/modules/mesh-delivery-checker/internal/check/graph_cycle.go index 56766476..444f0b1e 100644 --- a/modules/mesh-delivery-checker/internal/check/graph_cycle.go +++ b/modules/mesh-delivery-checker/internal/check/graph_cycle.go @@ -7,8 +7,8 @@ import ( "slices" "strings" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/composer" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-delivery/sdk/graph" diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index a7f0b8d4..e119b636 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -137,6 +137,9 @@ type Examiner struct { Manifests TrunkManifests // SeatDefinitions reads the indexer's seat definitions of a pull request (seatdefs.go, design 26); nil reads none. SeatDefinitions SeatDefinitions + // SeatList gives the seat definitions the change's manifests are judged against, as the seat list reader holds them + // (manifests_judge.go, novox/hq ADR 0636 §1); nil gives the judge none. + SeatList SeatList // Renames asks the seat mesh-config's verb renames for a change that brings or drops a `renamed-from` (renames.go, // design 32); nil fails such a change, saying it cannot ask. Renames Renames diff --git a/modules/mesh-delivery-checker/internal/check/manifests_judge.go b/modules/mesh-delivery-checker/internal/check/manifests_judge.go index 7be5f955..41b6b21a 100644 --- a/modules/mesh-delivery-checker/internal/check/manifests_judge.go +++ b/modules/mesh-delivery-checker/internal/check/manifests_judge.go @@ -9,9 +9,9 @@ import ( "slices" "strings" + "git.novox.be/novox/mesh-delivery/sdk/composer" "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/composer" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-delivery/sdk/packagename" @@ -63,6 +63,13 @@ type TrunkManifests interface { ManifestDigests() map[string]string } +// SeatList gives the seat definitions the manifests are judged against, whole, as raw definitions (a request's Seats): the +// list the seat list reader holds. It is an error when the list has not been read from the seat yet, since a manifest judged +// against the bootstrapped seats alone would be refused for a seat it names that the mesh defines (novox/hq ADR 0636 §1). +type SeatList interface { + Definitions() ([]json.RawMessage, error) +} + // PredefinedList is the catalogue's list of predefined keys the indexer keeps (its state predefined, key list), the one a // config value falling back to a key is judged against (ADR 0348 §7); nil when it keeps none. type PredefinedList interface { @@ -124,6 +131,13 @@ func (st *state) judgeManifests(ctx context.Context) *delivery.Verdict { st.c.note("the manifests are judged against the predefined keys of " + from) } req.Packages = st.knownPackages(ctx) + if st.j.SeatList != nil { + seats, err := st.j.SeatList.Definitions() + if err != nil { + return fail(goModule, "the change's manifests cannot be judged: the seat list is not read ("+err.Error()+")") + } + req.Seats = seats + } cv, trunkCase, v := st.runJudge(ctx, req, touched, commit, at) if v != nil { return v diff --git a/modules/mesh-delivery-checker/manifest_test.go b/modules/mesh-delivery-checker/manifest_test.go index 93b4e6a9..6fe40f36 100644 --- a/modules/mesh-delivery-checker/manifest_test.go +++ b/modules/mesh-delivery-checker/manifest_test.go @@ -124,7 +124,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1 "mesh-delivery-assigner.assigner_read_placement", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests", "mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_seat_definitions", - "seat:mesh-config.renames", "seat:mesh-hand-acts.hand-act", "seat:node-engine.compose-candidate"}; !reflect.DeepEqual(sorted(m.Invokes), want) { + "seat:mesh-catalog.seat-definitions", "seat:mesh-config.renames", "seat:mesh-hand-acts.hand-act", "seat:node-engine.compose-candidate"}; !reflect.DeepEqual(sorted(m.Invokes), want) { t.Errorf("invokes = %v, want the forge's reads and compose-candidate", m.Invokes) } if want := []string{"node-build-go", "node-build-node"}; !reflect.DeepEqual(sorted(m.Uses), want) { @@ -133,7 +133,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1 // The checker is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it starts. // And a group's check by the package registry seats' published of its tagged versions (design 51 *Tagged versions*). if want := []string{"go-package-registry.published", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", - "mesh-delivery-assigner.views.placement", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", + "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-assigner.views.placement", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.packages", "mesh-delivery-indexer.views.predefined", "mesh-delivery-indexer.views.seat-definitions", "npm-package-registry.published"}; !reflect.DeepEqual(sorted(m.Consumes), want) { t.Errorf("consumes = %v, want %v", m.Consumes, want) } diff --git a/modules/mesh-delivery-checker/module.json b/modules/mesh-delivery-checker/module.json index e1ccb9d1..8d47c3c4 100644 --- a/modules/mesh-delivery-checker/module.json +++ b/modules/mesh-delivery-checker/module.json @@ -15,6 +15,8 @@ ], "consumes": [ "go-package-registry.published", + "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported", "mesh-delivery-assigner.views.placement", @@ -81,6 +83,7 @@ "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_seat_definitions", + "seat:mesh-catalog.seat-definitions", "seat:mesh-config.renames", "seat:mesh-hand-acts.hand-act", "seat:node-engine.compose-candidate" From 6f7a3580494cd3d585fe330ab251594900160dc1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:57:55 +0100 Subject: [PATCH 10/14] Grants part: composes from the seat list it reads (reader over its connection and the event hub, catalogue and the pass follow it, a module whose seats the unread list does not name is held as a manifest waiting for a judge is, the two runtime tools, the manifest invokes and consumes) (D-30; issue 1966) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-grants/integration_test.go | 16 +++++ modules/mesh-grants/cmd/mesh-grants/main.go | 15 +++-- .../mesh-grants/cmd/mesh-grants/seatlist.go | 67 +++++++++++++++++++ modules/mesh-grants/cmd/mesh-grants/tools.go | 12 +++- modules/mesh-grants/grants_test.go | 47 +++++++++++++ .../mesh-grants/internal/grants/consumers.go | 2 +- modules/mesh-grants/internal/grants/inputs.go | 35 +++++++++- modules/mesh-grants/internal/grants/part.go | 13 +++- .../mesh-grants/internal/grants/rootfree.go | 7 +- modules/mesh-grants/internal/grants/tools.go | 9 ++- modules/mesh-grants/module.json | 9 ++- 11 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 modules/mesh-grants/cmd/mesh-grants/seatlist.go diff --git a/modules/mesh-grants/cmd/mesh-grants/integration_test.go b/modules/mesh-grants/cmd/mesh-grants/integration_test.go index b60896a0..6d72de3c 100644 --- a/modules/mesh-grants/cmd/mesh-grants/integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/integration_test.go @@ -189,6 +189,22 @@ func TestTheGrantsPartWhole(t *testing.T) { } t.Cleanup(func() { _ = osub.Unsubscribe() }) } + // D-30 (novox/hq ADR 0636 §1): the seat mesh-catalog's holder answers the seat list with no trunk seat named, so the reader + // has heard the seat and holds the bootstrapped definitions, and every module is composed. (The unit case holds a module + // while the list is unread.) + var seatAnswering atomic.Bool + seatAnswering.Store(true) + for _, subject := range []string{"mesh.seat.mesh-catalog.call.seat-definitions", "mesh.seat.mesh-catalog.call.seat-definitions.>"} { + if _, err := nc.Subscribe(subject, func(m *nats.Msg) { + if !seatAnswering.Load() { + _ = m.Respond([]byte(`{"error":"not connected yet","node":"a"}`)) + return + } + answer(m, map[string]any{"revision": 1, "seats": []any{}}) + }); err != nil { + t.Fatal(err) + } + } // A membership is never said on its subject any more (issue 1126). var said atomic.Int32 if _, err := nc.Subscribe("mesh.assignment.>", func(*nats.Msg) { said.Add(1) }); err != nil { diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index 012ac6cb..e1cd993f 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -36,6 +36,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" @@ -107,6 +108,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } bus := newBusReader(ctx, js) + // The module has one durable consumer of EVENTS: one hub pulls it and hands each message to the part that hears it (the + // owners' views events, the seat mesh-catalog's verdicts and seat list events, the other seats' events the follower hears), + // started once all are registered. + hub := process.NewEventHub(logger.Printf) + defer hub.Stop() // The owners' five states by their first views (ADR 0615, ADR 0622): asked through the part's own account, then // followed on the owners' views families, which the hearing below hands them. sayCond := sayVia(ctx, js, account.Module) @@ -124,6 +130,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf, Bus: grants.BusAddress{URL: account.URL, Fingerprint: account.Fingerprint}} f.Watch = fv + // The seat list: read over the connection and the hub, given to catalogue at every change, and a pass composes again then. + gate := &seatGate{} + gate.follow(seatlist.ProcessBus(conn.Call, hub), f.Wake, logger.Printf) + f.Part.SeatList = gate f.Part.Say = sayCond fv.Start(ctx, f.Wake) f.Asked = func(wake func()) error { @@ -142,11 +152,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // dropped carry the bindings: ADR 0595 §1, §7), each on the durable consumer its own membership names; one not // there yet is opened at the next change, and one opened is never opened twice. opened := map[string]bool{} - // The module has one durable consumer of EVENTS: one hub pulls it and hands each message to the part that hears it - // (the owners' views events, the seat mesh-catalog's verdicts, the other seats' events the follower hears), started - // once all are registered. - hub := process.NewEventHub(logger.Printf) - defer hub.Stop() registered := false f.Hear = func(handle func(string, []byte)) error { if !registered { diff --git a/modules/mesh-grants/cmd/mesh-grants/seatlist.go b/modules/mesh-grants/cmd/mesh-grants/seatlist.go new file mode 100644 index 00000000..3c477fb2 --- /dev/null +++ b/modules/mesh-grants/cmd/mesh-grants/seatlist.go @@ -0,0 +1,67 @@ +package main + +import ( + "errors" + "mesh-grants/internal/grants" + "sync/atomic" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatlist" +) + +// The grants part composes from the seat list it reads (novox/hq ADR 0635 §1, ADR 0636 §1, D-30): through the seat +// mesh-catalog's verb seat-definitions and its two events, from the bootstrapped definitions until the seat has answered. What +// the reader holds is given to catalogue at every change, which composes every membership and grant, and a module that names a +// seat the list does not yet is held back, as a manifest waiting for a judge is, and what runs is composed from its running +// build's manifest. The process reads over its connection and the event hub; the tools' bundle over the runtime. + +// seatListTools are the reader's two tools (ADR 0636 §7): every operator's session and the healers may read again. +var seatListAgainCallers = []string{stdio.AnyPerson, "mesh-healers"} + +// seatGate is the reader the part gates by, set once it is started; before, it acts on nothing new. +type seatGate struct { + reader atomic.Pointer[seatlist.Reader] +} + +func (g *seatGate) MayAct(seats ...string) bool { + r := g.reader.Load() + return r != nil && r.MayAct(seats...) +} + +// follow starts the reader over bus: what it holds is given to catalogue at once and at every change, and changed is called +// then (a pass composes again). +func (g *seatGate) follow(bus seatlist.Bus, changed func(), logf func(string, ...any)) *seatlist.Reader { + reader, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(), + OnChange: func(seats []catalogue.Seat) { + catalogue.UseSeats(seats) + if changed != nil { + changed() + } + }}) + if err != nil { + logf("the grants part cannot follow its seat list, so it composes only the modules the bootstrapped seats name: %v", err) + } + if reader != nil { + catalogue.UseSeats(reader.Seats()) + g.reader.Store(reader) + } + return reader +} + +// tools are the two tools over whatever reader the gate holds at the call. +func (g *seatGate) tools() []stdio.Tool { + names := []string{grants.ToolSeatList, grants.ToolSeatListAgain} + shape := seatlist.Tools(&seatlist.Reader{}, names[0], names[1], seatListAgainCallers) + for i := range shape { + run := i + shape[i].Run = func(args map[string]any) (any, error) { + r := g.reader.Load() + if r == nil { + return nil, errors.New("the seat list reader has not started yet: the runtime has not served this module") + } + return seatlist.Tools(r, names[0], names[1], seatListAgainCallers)[run].Run(args) + } + } + return shape +} diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index dc973aad..9c0f81db 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -5,11 +5,13 @@ import ( "encoding/json" "fmt" "maps" + "os" "sort" "strings" "sync" "time" + "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" @@ -254,8 +256,14 @@ func recordHandAct(what, why string) error { // serveTools serves the tools to the runtime until it stops. func serveTools() error { tv := &toolViews{} - p := &grants.Part{Read: runtimeReader{views: tv}, Call: runtimeBus{}, Store: runtimeStore{}, Emit: runtimeBus{}} - tools := toolsOf(p) + gate := &seatGate{} + p := &grants.Part{Read: runtimeReader{views: tv}, Call: runtimeBus{}, Store: runtimeStore{}, Emit: runtimeBus{}, SeatList: gate} + // The reader asks through the runtime, which serves once Serve has its channel. + go func() { + <-stdio.Served() + gate.follow(seatlist.StdioBus(), nil, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "mesh-grants: "+f+"\n", a...) }) + }() + tools := append(toolsOf(p), gate.tools()...) for i := range tools { run := tools[i].Run tools[i].Run = func(a map[string]any) (any, error) { tv.fresh(); return run(a) } diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 69ea7ee3..f54ed06e 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2231,6 +2231,31 @@ func TestGrants(t *testing.T) { } }) + // D-30 (novox/hq ADR 0636 §1): until the seat list has been read, a module whose seats are not all bootstrapped ones is held back + // as a manifest waiting for a judge is, named `manifest of : it waits for the seat list`, and composed once the + // list names its seats; the bus module, which claims a bootstrapped seat, is composed throughout. + t.Run("the seat list unread holds a module whose seats are not bootstrapped", func(t *testing.T) { + b := fixture() + p := partOn(b) + gate := &seatGate{bootstrapped: []string{"mesh-broker"}} + p.SeatList = gate + p.Compose() + refused := strings.Join(p.Last().Refused, "\n") + for _, module := range []string{"mod-x", "mod-y"} { + if !strings.Contains(refused, "manifest of "+module+": it waits for the seat list") { + t.Errorf("%s is not held while the seat list is unread: %v", module, p.Last().Refused) + } + } + if strings.Contains(refused, "manifest of nats") { + t.Errorf("the bus module claims a bootstrapped seat and is held: %v", p.Last().Refused) + } + gate.read = true + p.Compose() + if r := p.Last().Refused; len(r) != 0 { + t.Errorf("held after the seat list was read: %v", r) + } + }) + // DLV-G1, 1966/AC32-AC33 (novox/hq ADR 0595 §1, §7; design 25 § 12.2): the bus module's created writes the binding into // the module's membership on every machine it is assigned, one assigned later included; recreated rewrites it; dropped // removes it; before created, on a refused, or from an emitter holding no mesh-broker, none. @@ -3280,3 +3305,25 @@ func waitFor(t *testing.T, ok func() bool) { } t.Fatal("never came") } + +// seatGate is a seat list reader as a test gives it: unread, it lets a module act whose seats are all bootstrapped ones (and +// at least one); read, any. +type seatGate struct { + bootstrapped []string + read bool +} + +func (g *seatGate) MayAct(seats ...string) bool { + if g.read { + return true + } + if len(seats) == 0 { + return false + } + for _, s := range seats { + if !slices.Contains(g.bootstrapped, s) { + return false + } + } + return true +} diff --git a/modules/mesh-grants/internal/grants/consumers.go b/modules/mesh-grants/internal/grants/consumers.go index b77fae66..48bbd2ce 100644 --- a/modules/mesh-grants/internal/grants/consumers.go +++ b/modules/mesh-grants/internal/grants/consumers.go @@ -200,7 +200,7 @@ func (p *Part) MatchConsumer(stream, name string) (any, error) { if stream == "" || name == "" { return nil, errors.New("consumer-match names a stream and a consumer") } - in := ReadInputs(p.Read, &p.cache) + in := p.inputs() recs, _ := Records(in) users, err := buscompose.Users(recs) if err != nil { diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 0b330afa..49afd3f6 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -61,7 +61,10 @@ const ( ) // Invokes is every tool the part calls. -var Invokes = append([]string{CallUsers, CallUserCan, CallStreams, CallRootFree, CallHandAct}, firstViewTools()...) +var Invokes = append([]string{CallUsers, CallUserCan, CallStreams, CallRootFree, CallHandAct, CallSeatDefinitions}, firstViewTools()...) + +// CallSeatDefinitions reads the seat list (mesh-sdk seatlist): the verb of the seat mesh-catalog. +const CallSeatDefinitions = "seat:mesh-catalog.seat-definitions" // CallHandAct records a person's act (grants_first_view_again). const CallHandAct = "seat:mesh-hand-acts.hand-act" @@ -204,6 +207,30 @@ const BusModule = "nats" type Manifests struct { mu sync.Mutex parsed map[string]catalogue.Manifest + // gate says whether a manifest naming some seats may be composed yet (the seat list reader, ADR 0636 §1); nil gates none. + gate SeatGate +} + +// SeatGate is what the part asks of the seat list reader before it composes a module new to it: whether a module that +// claims or invokes the given seats may be acted on (seatlist.Reader.MayAct). Until the seat list has been read only a module +// whose seats are all bootstrapped ones may; the rest is held as a manifest waiting for a judge is, its running build's +// manifest composing what runs, so nothing that runs is stopped (held.go). +type SeatGate interface { + MayAct(seats ...string) bool +} + +// SeatsOf are the seats a manifest claims or invokes, which the seat list must name before the part composes the module. +func SeatsOf(m catalogue.Manifest) []string { + var seats []string + for _, c := range m.Claims { + seats = append(seats, c.Name) + } + for _, in := range m.Invokes { + if seat, _, ok := catalogue.SeatInvoke(in); ok { + seats = append(seats, seat) + } + } + return seats } func (c *Manifests) get(r Reader, digest string) (catalogue.Manifest, error) { @@ -263,6 +290,9 @@ func each(r Reader, state string, gaps *[]Gap) map[string][]byte { // ReadInputs reads what the shadow composes from. It never fails: what it cannot read is a gap. func ReadInputs(r Reader, cache *Manifests) Inputs { + cache.mu.Lock() + gate := cache.gate + cache.mu.Unlock() in := Inputs{Assigned: map[string][]string{}, Manifests: map[string]catalogue.Manifest{}, Digests: map[string]string{}, Fingerprints: map[string]string{}, NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{}, Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)} @@ -392,6 +422,9 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { if err == nil { m, err = cache.get(r, v.Manifest) } + if err == nil && gate != nil && !gate.MayAct(SeatsOf(m)...) { + err = errors.New("it waits for the seat list: the list has not been read yet and the manifest names seats the bootstrapped list does not (novox/hq ADR 0636 §1)") + } if err != nil { // Every manifest, assigned or not: one refused declares no seat, so its holders compose as placed nowhere. g := Gap{Input: "manifest of " + module, Why: err.Error()} diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index a23ab3b0..7fe5eb05 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -162,6 +162,9 @@ type Part struct { // Say says a condition to the operator: the process's; nil in the tools. Say func(oc.Condition) error Now func() time.Time + // SeatList is the seat list reader, which gates a module whose seats the list has not named (inputs.go, SeatGate); nil + // gates none. + SeatList SeatGate cache Manifests mu sync.Mutex @@ -279,7 +282,7 @@ func (p *Part) Compose() Result { }() // A first view's condition the condition keeper did not take is said again at every pass until it lands. p.Views.SayUnsaid() - in := ReadInputs(p.Read, &p.cache) + in := p.inputs() p.keepBrokers(in) if bound, err := p.boundStreams(); err != nil { res.Errors = append(res.Errors, err.Error()) @@ -542,3 +545,11 @@ func names(us []User) []string { } return out } + +// inputs reads what the part composes from, a module whose seats the seat list has not named held back (SeatGate). +func (p *Part) inputs() Inputs { + p.cache.mu.Lock() + p.cache.gate = p.SeatList + p.cache.mu.Unlock() + return ReadInputs(p.Read, &p.cache) +} diff --git a/modules/mesh-grants/internal/grants/rootfree.go b/modules/mesh-grants/internal/grants/rootfree.go index a270dcc0..83bc8626 100644 --- a/modules/mesh-grants/internal/grants/rootfree.go +++ b/modules/mesh-grants/internal/grants/rootfree.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "slices" "sort" "strings" @@ -42,7 +43,11 @@ const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent // Consumes is every event the part hears, as its manifest's consumes name them. // The seat mesh-broker's created, recreated and dropped are heard as every seat's event, `.` on EVENTS (the // lead's ruling, registry milestone 13:0x), never on the bus module's own stream. -var Consumes = append(slices.Clone(seatEvents), viewsEvents()...) +var Consumes = append(append(slices.Clone(seatEvents), seatListEvents...), viewsEvents()...) + +// seatListEvents are the two events of the seat mesh-catalog the seat list reader follows (mesh-sdk seatlist), heard by it on the +// module's event hub, so no handler of the hub takes them besides. +var seatListEvents = []string{meshcatalog.Seat + "." + meshcatalog.EventSeatDefinitionChanged, meshcatalog.Seat + "." + meshcatalog.EventSeatDefinitionRemoved} // seatEvents are the events of seats the part follows its passes by, as `.`. var seatEvents = []string{EventKeeperRaised, EventKeeperCleared, EventAccountIssued, meshbroker.Seat + "." + BrokerCreated, diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index a94e32d8..247d4081 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -26,11 +26,14 @@ const ( // ToolFirstViews and ToolFirstViewAgain read and ask again the part's first views (firstviews.go). ToolFirstViews = "grants_first_views" ToolFirstViewAgain = "grants_first_view_again" + // ToolSeatList and ToolSeatListAgain read and read again the seat list the part composes from (mesh-sdk seatlist.Tools). + ToolSeatList = "grants_seat_list" + ToolSeatListAgain = "grants_seat_list_again" ) // Tools is every tool, in the manifest's order. var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch, - ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain} + ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolSeatList, ToolSeatListAgain} // WritesGrants is what grants says of the part's own grants: written once the list the bus loaded carries a build's // accounts, and the one key the assigner reads (ADR 0571 clause 9, which changes ADR 0547 clause 5). @@ -105,7 +108,7 @@ func (p *Part) MembershipOf(node, module string) (any, error) { if node == "" || module == "" { return nil, errors.New("membership names a node and a module, or neither for every membership held back") } - in := ReadInputs(p.Read, &p.cache) + in := p.inputs() recs, left := Records(in) notFree := WithRootFree(&recs, p.Call) m, ok := Memberships(recs)[node+"."+module] @@ -277,7 +280,7 @@ func (p *Part) AssertOne(module string) (any, error) { held, heldErr := HeldStreams(p.Call) var nodes []string if module == buscompose.EngineModule { - nodes = ReadInputs(p.Read, &p.cache).Nodes // every node's bucket of compositions (ADR 0593 §6) + nodes = p.inputs().Nodes // every node's bucket of compositions (ADR 0593 §6) } a := Assert(module, v.Manifest, m, nodes, held, heldErr) out := map[string]any{"assertion": a} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 5363b8ca..e0e69f04 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -22,7 +22,9 @@ "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", "mesh-catalog.manifest-accepted", - "mesh-catalog.manifest-refused" + "mesh-catalog.manifest-refused", + "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed" ], "uses": [ "operator-channel" @@ -37,6 +39,7 @@ "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.verdicts", + "seat:mesh-catalog.seat-definitions", "seat:mesh-hand-acts.hand-act" ], "emits": [ @@ -56,7 +59,9 @@ "grants_consumer_match", "grants_accept_hashes", "grants_first_views", - "grants_first_view_again" + "grants_first_view_again", + "grants_seat_list", + "grants_seat_list_again" ], "state": [ { From 7fcd2d0b64bcf942a113498e86d517633e502ac5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:58:29 +0100 Subject: [PATCH 11/14] =?UTF-8?q?mesh-delivery-contracts:=20the=20seat=20m?= =?UTF-8?q?esh-artifact-sweep=20is=20a=20whole=20seat:=20held's=20answer?= =?UTF-8?q?=20(HeldAnswer)=20and=20the=20artifacts-gone=20body=20(Artifact?= =?UTF-8?q?sGone)=20typed,=20with=20fixtures=20from=20the=20holder's=20tes?= =?UTF-8?q?t=20values,=20generated=20Go=20and=20TypeScript=20(B2f;=20ADR?= =?UTF-8?q?=200571=20=C2=A72,=20ADR=200634=20=C2=A72;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../go/seats/meshartifactsweep/contract.go | 129 ++++++++++++++++++ .../seats/meshartifactsweep/contract_test.go | 54 ++++++++ contracts/go/seats/meshartifactsweep/go.mod | 3 + contracts/module.json | 20 +++ .../mesh-artifact-sweep/fixtures/events.json | 15 ++ .../mesh-artifact-sweep/fixtures/states.json | 1 + .../mesh-artifact-sweep/fixtures/verbs.json | 24 ++++ contracts/seats/mesh-artifact-sweep/seat.json | 106 +++++++++++++- .../seats/meshartifactsweep/package-lock.json | 12 ++ .../ts/seats/meshartifactsweep/package.json | 13 ++ .../ts/seats/meshartifactsweep/src/index.ts | 99 ++++++++++++++ .../ts/seats/meshartifactsweep/tsconfig.json | 12 ++ 12 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 contracts/go/seats/meshartifactsweep/contract.go create mode 100644 contracts/go/seats/meshartifactsweep/contract_test.go create mode 100644 contracts/go/seats/meshartifactsweep/go.mod create mode 100644 contracts/seats/mesh-artifact-sweep/fixtures/events.json create mode 100644 contracts/seats/mesh-artifact-sweep/fixtures/states.json create mode 100644 contracts/seats/mesh-artifact-sweep/fixtures/verbs.json create mode 100644 contracts/ts/seats/meshartifactsweep/package-lock.json create mode 100644 contracts/ts/seats/meshartifactsweep/package.json create mode 100644 contracts/ts/seats/meshartifactsweep/src/index.ts create mode 100644 contracts/ts/seats/meshartifactsweep/tsconfig.json diff --git a/contracts/go/seats/meshartifactsweep/contract.go b/contracts/go/seats/meshartifactsweep/contract.go new file mode 100644 index 00000000..8aa87d21 --- /dev/null +++ b/contracts/go/seats/meshartifactsweep/contract.go @@ -0,0 +1,129 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// Package meshartifactsweep is the contract of the seat "mesh-artifact-sweep", defined whole (seats/mesh-artifact-sweep/seat.json): no description. +package meshartifactsweep + +import "time" + +// Seat is the seat's name. +const Seat = "mesh-artifact-sweep" + +const ( + VerbArtifacts = "artifacts" + VerbHeld = "held" + VerbCollect = "collect" + VerbMirrors = "mirrors" + VerbImages = "images" +) + +const ( + EventArtifactsGone = "artifacts-gone" +) + +// Verbs is every verb the seat serves, in its definition's order: a new copy each call. +func Verbs() []string { + return []string{VerbArtifacts, VerbHeld, VerbCollect, VerbMirrors, VerbImages} +} + +// Events is every event published on the seat's subject: a new copy each call. +func Events() []string { + return []string{EventArtifactsGone} +} + +// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects. +const ( + Family = "mesh-artifact-sweep.events" + FamilyMaxAgeDays = 7 +) + +// SubjectArtifactsGone is where artifacts-gone is published. +const SubjectArtifactsGone = "mesh.seat.mesh-artifact-sweep.event.artifacts-gone" + +// ArtifactsGone is the body of artifacts-gone. +type ArtifactsGone struct { + // when the sweep found it + At *time.Time `json:"at,omitempty"` + // artifacts-gone + Event string `json:"event"` + // the key's fingerprint + Fingerprint string `json:"fingerprint"` + // every reference of the key the store does not hold, sorted + Gone []string `json:"gone"` + // the builder's done key + Key string `json:"key,omitempty"` + // the module whose builder's key names a gone artifact + Module string `json:"module"` +} + +// ArtifactsInput is what artifacts takes. +type ArtifactsInput struct { + // "true": list the collected references too (counted always) + Collected string `json:"collected,omitempty"` + // only this repository's references, as / (the counts stay whole) + Repository string `json:"repository,omitempty"` +} + +// The values of ArtifactsInput's collected. +const ( + ArtifactsInputCollectedTrue = "true" + ArtifactsInputCollectedFalse = "false" +) + +// ArtifactsAnswer is what artifacts answers. +type ArtifactsAnswer = map[string]any + +// HeldInput is what held takes. +type HeldInput struct { + // the artifacts' references, artifact-store:////blobs/sha256:: a list, or one text separated by commas + References []string `json:"references"` +} + +// HeldAnswer is what held answers. +type HeldAnswer struct { + // the references the store answered 404 for + Gone []string `json:"gone"` + // the references the store holds + Held []string `json:"held"` + // the store asked, host and port + Store string `json:"store,omitempty"` + // a reference the store could not be asked about, to why + Unknown map[string]string `json:"unknown,omitempty"` +} + +// CollectInput is what collect takes. +type CollectInput struct { + // "true": let go of what is eligible (needs why); without it nothing is done + Confirm string `json:"confirm,omitempty"` + // let go of at most this many (500 by default, at most 5000) + Most string `json:"most,omitempty"` + // with confirm: why, required and recorded + Why string `json:"why,omitempty"` +} + +// The values of CollectInput's confirm. +const ( + CollectInputConfirmTrue = "true" + CollectInputConfirmFalse = "false" +) + +// CollectAnswer is what collect answers. +type CollectAnswer = map[string]any + +// MirrorsInput is what mirrors takes. +type MirrorsInput struct { +} + +// MirrorsAnswer is what mirrors answers. +type MirrorsAnswer = map[string]any + +// ImagesInput is what images takes. +type ImagesInput struct { + // the machine's name + Node string `json:"node"` +} + +// ImagesAnswer is what images answers. +type ImagesAnswer = map[string]any + +// ArtifactsGoneEvent is the body of the event artifacts-gone. +type ArtifactsGoneEvent = ArtifactsGone diff --git a/contracts/go/seats/meshartifactsweep/contract_test.go b/contracts/go/seats/meshartifactsweep/contract_test.go new file mode 100644 index 00000000..ea7a5f1f --- /dev/null +++ b/contracts/go/seats/meshartifactsweep/contract_test.go @@ -0,0 +1,54 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +package meshartifactsweep + +import ( + "encoding/json" + "reflect" + "regexp" + "testing" +) + +// TestFixtures reads every fixture of seats/mesh-artifact-sweep/fixtures: each decodes into its type, a field the type does not +// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's. +func TestFixtures(t *testing.T) { + cases := []struct { + name, subject, given, wire string + decode func([]byte) (any, error) + subjectIs *regexp.Regexp + }{ + {name: "artifacts-gone on the seat's subject", subject: "mesh.seat.mesh-artifact-sweep.event.artifacts-gone", given: "{\"at\":\"2026-11-05T15:04:05Z\",\"event\":\"artifacts-gone\",\"fingerprint\":\"f1\",\"gone\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"key\":\"ca-trust.archive.f1\",\"module\":\"ca-trust\"}", wire: "{\"at\":\"2026-11-05T15:04:05Z\",\"event\":\"artifacts-gone\",\"fingerprint\":\"f1\",\"gone\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"key\":\"ca-trust.archive.f1\",\"module\":\"ca-trust\"}", + decode: func(b []byte) (any, error) { var v ArtifactsGoneEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-artifact-sweep\\.event\\.artifacts-gone$")}, + {name: "held asks the references", subject: "", given: "{\"references\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]}", wire: "{\"references\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"]}", + decode: func(b []byte) (any, error) { var v HeldInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "held answers held, gone and unknown", subject: "", given: "{\"gone\":[\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"held\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],\"store\":\"store.test:5100\",\"unknown\":{\"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\":\"the store answered 503\"}}", wire: "{\"gone\":[\"artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"],\"held\":[\"artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"],\"store\":\"store.test:5100\",\"unknown\":{\"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\":\"the store answered 503\"}}", + decode: func(b []byte) (any, error) { var v HeldAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + {name: "held answers with nothing unknown", subject: "", given: "{\"gone\":[],\"held\":[],\"store\":\"store.test:5100\"}", wire: "{\"gone\":[],\"held\":[],\"store\":\"store.test:5100\"}", + decode: func(b []byte) (any, error) { var v HeldAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, + } + for _, c := range cases { + v, err := c.decode([]byte(c.given)) + if err != nil { + t.Errorf("%s: does not decode: %v", c.name, err) + continue + } + back, err := json.Marshal(v) + if err != nil { + t.Errorf("%s: does not encode: %v", c.name, err) + continue + } + var got, want any + if err := json.Unmarshal(back, &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(c.wire), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire) + } + if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) { + t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs) + } + } +} diff --git a/contracts/go/seats/meshartifactsweep/go.mod b/contracts/go/seats/meshartifactsweep/go.mod new file mode 100644 index 00000000..77f0649b --- /dev/null +++ b/contracts/go/seats/meshartifactsweep/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep + +go 1.26.0 diff --git a/contracts/module.json b/contracts/module.json index d80c383c..09be597e 100644 --- a/contracts/module.json +++ b/contracts/module.json @@ -23,6 +23,16 @@ "series": "0.1", "generated-from": "." }, + { + "name": "go-meshartifactsweep", + "kind": "package", + "language": "go", + "paths": [ + "go/seats/meshartifactsweep" + ], + "series": "0.1", + "generated-from": "." + }, { "name": "go-meshcatalog", "kind": "package", @@ -186,6 +196,16 @@ "series": "0.1", "generated-from": "." }, + { + "name": "ts-meshartifactsweep", + "kind": "package", + "language": "typescript", + "paths": [ + "ts/seats/meshartifactsweep" + ], + "series": "0.1", + "generated-from": "." + }, { "name": "ts-meshcatalog", "kind": "package", diff --git a/contracts/seats/mesh-artifact-sweep/fixtures/events.json b/contracts/seats/mesh-artifact-sweep/fixtures/events.json new file mode 100644 index 00000000..3e39336a --- /dev/null +++ b/contracts/seats/mesh-artifact-sweep/fixtures/events.json @@ -0,0 +1,15 @@ +[ + { + "name": "artifacts-gone on the seat's subject", + "of": "event:artifacts-gone", + "subject": "mesh.seat.mesh-artifact-sweep.event.artifacts-gone", + "given": { + "event": "artifacts-gone", + "module": "ca-trust", + "key": "ca-trust.archive.f1", + "fingerprint": "f1", + "gone": ["artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "at": "2026-11-05T15:04:05Z" + } + } +] diff --git a/contracts/seats/mesh-artifact-sweep/fixtures/states.json b/contracts/seats/mesh-artifact-sweep/fixtures/states.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/contracts/seats/mesh-artifact-sweep/fixtures/states.json @@ -0,0 +1 @@ +[] diff --git a/contracts/seats/mesh-artifact-sweep/fixtures/verbs.json b/contracts/seats/mesh-artifact-sweep/fixtures/verbs.json new file mode 100644 index 00000000..b70970ee --- /dev/null +++ b/contracts/seats/mesh-artifact-sweep/fixtures/verbs.json @@ -0,0 +1,24 @@ +[ + { + "name": "held asks the references", + "of": "verb:held.input", + "why": "the references are the holder's own test values (the tool refuses a call without them through the console)", + "given": {"references": ["artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]} + }, + { + "name": "held answers held, gone and unknown", + "of": "verb:held.answer", + "why": "the answer of the holder's test (cmd/artifact-store-tools): every reference in one of the three", + "given": { + "store": "store.test:5100", + "held": ["artifact-store://ca-trust/archive/blobs/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + "gone": ["artifact-store://ca-trust/archive/blobs/sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "unknown": {"artifact-store://ca-trust/archive/blobs/sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc": "the store answered 503"} + } + }, + { + "name": "held answers with nothing unknown", + "of": "verb:held.answer", + "given": {"store": "store.test:5100", "held": [], "gone": []} + } +] diff --git a/contracts/seats/mesh-artifact-sweep/seat.json b/contracts/seats/mesh-artifact-sweep/seat.json index 2e5b673d..4763ce8d 100644 --- a/contracts/seats/mesh-artifact-sweep/seat.json +++ b/contracts/seats/mesh-artifact-sweep/seat.json @@ -2,7 +2,7 @@ "format": 1, "seat": "mesh-artifact-sweep", "scope": "mesh", - "grain": "seat-list", + "grain": "whole", "domain": "Delivery", "decision": "novox/hq ADR 0571", "verbs": [ @@ -25,6 +25,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "what artifacts answers, as its description says" } }, { @@ -44,6 +48,41 @@ "references" ], "type": "object" + }, + "answer": { + "type": "object", + "properties": { + "store": { + "type": "string", + "description": "the store asked, host and port" + }, + "held": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the references the store holds" + }, + "gone": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the references the store answered 404 for" + }, + "unknown": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "a reference the store could not be asked about, to why" + } + }, + "required": [ + "held", + "gone" + ], + "description": "what held answers: a reference is in held, gone or unknown, never none of them" } }, { @@ -69,6 +108,10 @@ } }, "type": "object" + }, + "answer": { + "type": "object", + "description": "what collect answers, as its description says" } }, { @@ -77,6 +120,10 @@ "input": { "properties": {}, "type": "object" + }, + "answer": { + "type": "object", + "description": "what mirrors answers, as its description says" } }, { @@ -93,12 +140,65 @@ "node" ], "type": "object" + }, + "answer": { + "type": "object", + "description": "what images answers, as its description says" } } ], + "family": { + "name": "mesh-artifact-sweep.events", + "max-age-days": 7 + }, "events": [ { - "name": "artifacts-gone" + "name": "artifacts-gone", + "body": { + "$ref": "#/defs/ArtifactsGone" + } } - ] + ], + "defs": { + "ArtifactsGone": { + "type": "object", + "properties": { + "event": { + "type": "string", + "description": "artifacts-gone" + }, + "module": { + "type": "string", + "description": "the module whose builder's key names a gone artifact" + }, + "key": { + "type": "string", + "description": "the builder's done key" + }, + "fingerprint": { + "type": "string", + "description": "the key's fingerprint" + }, + "gone": { + "type": "array", + "items": { + "type": "string" + }, + "description": "every reference of the key the store does not hold, sorted" + }, + "at": { + "type": "string", + "format": "date-time", + "description": "when the sweep found it" + } + }, + "required": [ + "event", + "module", + "fingerprint", + "gone" + ], + "description": "the body of artifacts-gone" + } + } } diff --git a/contracts/ts/seats/meshartifactsweep/package-lock.json b/contracts/ts/seats/meshartifactsweep/package-lock.json new file mode 100644 index 00000000..2d3064d4 --- /dev/null +++ b/contracts/ts/seats/meshartifactsweep/package-lock.json @@ -0,0 +1,12 @@ +{ + "lockfileVersion": 3, + "name": "@novox/mesh-delivery-contracts-mesh-artifact-sweep", + "packages": { + "": { + "name": "@novox/mesh-delivery-contracts-mesh-artifact-sweep", + "version": "0.1.0" + } + }, + "requires": true, + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshartifactsweep/package.json b/contracts/ts/seats/meshartifactsweep/package.json new file mode 100644 index 00000000..14c46319 --- /dev/null +++ b/contracts/ts/seats/meshartifactsweep/package.json @@ -0,0 +1,13 @@ +{ + "description": "The contract of the seat mesh-artifact-sweep, generated from its definition.", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src" + ], + "name": "@novox/mesh-delivery-contracts-mesh-artifact-sweep", + "type": "module", + "types": "./src/index.ts", + "version": "0.1.0" +} diff --git a/contracts/ts/seats/meshartifactsweep/src/index.ts b/contracts/ts/seats/meshartifactsweep/src/index.ts new file mode 100644 index 00000000..ad4d48a4 --- /dev/null +++ b/contracts/ts/seats/meshartifactsweep/src/index.ts @@ -0,0 +1,99 @@ +// Code generated by seatgen from the seat definitions; DO NOT EDIT. + +// The contract of the seat "mesh-artifact-sweep", defined whole (seats/mesh-artifact-sweep/seat.json): no description. + +export const seat = "mesh-artifact-sweep"; + +export const verbs = { + artifacts: "artifacts", + held: "held", + collect: "collect", + mirrors: "mirrors", + images: "images", +} as const; + +export const events = { + artifactsGone: "artifacts-gone", +} as const; + +export const family = { name: "mesh-artifact-sweep.events", maxAgeDays: 7 } as const; + +export const artifactsGoneSubject = "mesh.seat.mesh-artifact-sweep.event.artifacts-gone"; + +/** the body of artifacts-gone */ +export interface ArtifactsGone { + /** when the sweep found it */ + "at"?: string; + /** artifacts-gone */ + "event": string; + /** the key's fingerprint */ + "fingerprint": string; + /** every reference of the key the store does not hold, sorted */ + "gone": string[]; + /** the builder's done key */ + "key"?: string; + /** the module whose builder's key names a gone artifact */ + "module": string; +} + +/** is what artifacts takes. */ +export interface ArtifactsInput { + /** "true": list the collected references too (counted always) */ + "collected"?: "true" | "false"; + /** only this repository's references, as / (the counts stay whole) */ + "repository"?: string; +} + +/** is what artifacts answers. */ +export type ArtifactsAnswer = Record; + +/** is what held takes. */ +export interface HeldInput { + /** the artifacts' references, artifact-store:////blobs/sha256:: a list, or one text separated by commas */ + "references": string[]; +} + +/** is what held answers. */ +export interface HeldAnswer { + /** the references the store answered 404 for */ + "gone": string[]; + /** the references the store holds */ + "held": string[]; + /** the store asked, host and port */ + "store"?: string; + /** a reference the store could not be asked about, to why */ + "unknown"?: Record; +} + +/** is what collect takes. */ +export interface CollectInput { + /** "true": let go of what is eligible (needs why); without it nothing is done */ + "confirm"?: "true" | "false"; + /** let go of at most this many (500 by default, at most 5000) */ + "most"?: string; + /** with confirm: why, required and recorded */ + "why"?: string; +} + +/** is what collect answers. */ +export type CollectAnswer = Record; + +/** is what mirrors takes. */ +export interface MirrorsInput { +} + +/** is what mirrors answers. */ +export type MirrorsAnswer = Record; + +/** is what images takes. */ +export interface ImagesInput { + /** the machine's name */ + "node": string; +} + +/** is what images answers. */ +export type ImagesAnswer = Record; + +/** is the body of the event artifacts-gone. */ +export type ArtifactsGoneEvent = ArtifactsGone; + diff --git a/contracts/ts/seats/meshartifactsweep/tsconfig.json b/contracts/ts/seats/meshartifactsweep/tsconfig.json new file mode 100644 index 00000000..60274c6a --- /dev/null +++ b/contracts/ts/seats/meshartifactsweep/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} From 1286a24926b7e05f0cefb0abe6e79aaed8fb0e6a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:59:45 +0100 Subject: [PATCH 12/14] =?UTF-8?q?mesh-delivery-builder:=20reads=20held's?= =?UTF-8?q?=20answer=20and=20the=20artifacts-gone=20body=20in=20the=20seat?= =?UTF-8?q?=20mesh-artifact-sweep's=20generated=20types,=20not=20go/delive?= =?UTF-8?q?ry's=20(B2f;=20ADR=200571=20=C2=A72,=20ADR=200634=20=C2=A72;=20?= =?UTF-8?q?issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../gone_integration_test.go | 14 +++-- .../mesh-delivery-builder/integration_test.go | 3 +- .../cmd/mesh-delivery-builder/main.go | 3 +- .../cmd/mesh-delivery-builder/refused_test.go | 3 +- .../cmd/mesh-delivery-builder/stored.go | 57 +++++++++++++++++-- modules/mesh-delivery-builder/go.mod | 4 ++ .../internal/build/gone_test.go | 5 +- .../internal/build/run.go | 7 ++- 8 files changed, 78 insertions(+), 18 deletions(-) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go index 58395d16..30195bbc 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/gone_integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -35,7 +36,7 @@ func (h *heldVerb) serve(t *testing.T, b *bed) { References []string `json:"references"` } _ = json.Unmarshal(m.Data, &args) - a := delivery.HeldAnswer{Store: "store.test:5100", Held: []string{}, Gone: []string{}} + a := sweep.HeldAnswer{Store: "store.test:5100", Held: []string{}, Gone: []string{}} h.mu.Lock() for _, r := range args.References { h.asked = append(h.asked, r) @@ -65,11 +66,11 @@ func (h *heldVerb) asks() int { // from the manifest's consumes. func eventsConsumer(t *testing.T, b *bed, node string) { t.Helper() - if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: eventsStream, Subjects: []string{delivery.ArtifactsGoneSubject}}); err != nil { + if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: eventsStream, Subjects: []string{sweep.SubjectArtifactsGone}}); err != nil { t.Fatal(err) } if _, err := b.js.CreateOrUpdateConsumer(b.ctx, eventsStream, jetstream.ConsumerConfig{Durable: node + "_mesh-delivery-builder", - AckPolicy: jetstream.AckExplicitPolicy, FilterSubjects: []string{delivery.ArtifactsGoneSubject}}); err != nil { + AckPolicy: jetstream.AckExplicitPolicy, FilterSubjects: []string{sweep.SubjectArtifactsGone}}); err != nil { t.Fatal(err) } } @@ -168,12 +169,13 @@ func TestArtifactsGoneLeadsTheBuilderToBuildTheKeyAgain(t *testing.T) { collected = true mu.Unlock() key, _ := delivery.BuilderBuildKey("ca-trust", fp) - body, err := delivery.EncodeArtifactsGone(delivery.ArtifactsGone{Module: "ca-trust", Key: key, Fingerprint: fp, - Gone: []string{ref}, At: time.Now().UTC()}) + at := time.Now().UTC() + body, err := json.Marshal(sweep.ArtifactsGone{Event: sweep.EventArtifactsGone, Module: "ca-trust", Key: key, Fingerprint: fp, + Gone: []string{ref}, At: &at}) if err != nil { t.Fatal(err) } - if _, err := b.js.Publish(b.ctx, delivery.ArtifactsGoneSubject, body); err != nil { + if _, err := b.js.Publish(b.ctx, sweep.SubjectArtifactsGone, body); err != nil { t.Fatal(err) } v := b.await(t, "ca-trust", func(v delivery.BuilderBuild) bool { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go index eee4c068..32901723 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go @@ -28,6 +28,7 @@ import ( runner "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" "git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry" "git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -1191,7 +1192,7 @@ func (b *bed) registry(t *testing.T, node string, seatVersions ...string) *regis mk(versions.FloorWaitsBucket) // EVENTS with two seats' outcomes, and the forge bridge's stream (a branch's end): each with the builder's durable consumer. for stream, subjects := range map[string][]string{ - eventsStream: {delivery.ArtifactsGoneSubject, ociartifactregistry.SubjectPublished, ociartifactregistry.SubjectRefused, ociartifactregistry.SubjectRemoved, + eventsStream: {sweep.SubjectArtifactsGone, ociartifactregistry.SubjectPublished, ociartifactregistry.SubjectRefused, ociartifactregistry.SubjectRemoved, gopackageregistry.SubjectPublished, gopackageregistry.SubjectRefused, gopackageregistry.SubjectRemoved}, forgeStream: {process.ModuleStreamSubject(delivery.ForgeBridgeModule, ">")}, } { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index 0289ec8f..54414870 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -25,6 +25,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" @@ -329,7 +330,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro floor := floorWake{b: b, waits: floorWaits, logf: logger.Printf} // And the four registry seats' published, refused and removed (DLV-B4), which move a numbered version. seats := seatHearer{n: numberer, b: b, logf: logger.Printf, pub: pubs} - hear(eventsStream, delivery.ArtifactsGoneEvent+", what wakes a floor, the registry seats' words and manifest-reads-changed are not heard until the builder starts again", func(m jetstream.Msg) { + hear(eventsStream, sweep.EventArtifactsGone+", what wakes a floor, the registry seats' words and manifest-reads-changed are not heard until the builder starts again", func(m jetstream.Msg) { if reads.heard(m, logger.Printf) || floor.heard(ctx, m) || seats.heard(ctx, m) { // reads: the node-engine seat's manifest-reads-changed (ADR 0618 §4) return } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go index 0e53e2f2..cc000338 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go @@ -21,6 +21,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/sdk/enginereads" "git.novox.be/novox/mesh-delivery/sdk/forms" @@ -223,7 +224,7 @@ func aFloorsWakeIsHeard(t *testing.T) { if !f.heard(context.Background(), cleared) || !strings.Contains(logs.String(), "the floor of m/img is asked again: condition-cleared") { t.Fatalf("condition-cleared: log %q", logs.String()) } - if f.heard(context.Background(), &wakeMsg{subject: delivery.ArtifactsGoneSubject}) { + if f.heard(context.Background(), &wakeMsg{subject: sweep.SubjectArtifactsGone}) { t.Fatal("artifacts-gone was taken as a floor's wake") } // DLV-B4: a registry seat's outcome on the same consumer moves a version; a published wakes a name whose seat was diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/stored.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/stored.go index c5fc876a..7c1a023e 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/stored.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/stored.go @@ -9,8 +9,8 @@ import ( "github.com/nats-io/nats.go/jetstream" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-builder/internal/build" ) @@ -35,7 +35,7 @@ func storedVia(call toolCaller) func(ctx context.Context, references []string) ( if err != nil { return nil, fmt.Errorf("asking held: %w", err) } - a, err := delivery.DecodeHeldAnswer(raw, references) + a, err := decodeHeld(raw, references) if err != nil { return nil, err } @@ -54,13 +54,62 @@ func storedVia(call toolCaller) func(ctx context.Context, references []string) ( // heardGone takes one artifacts-gone event in: acknowledged, then handed to the builder, which asks the store and builds // the key again. A body that is not one is said and acknowledged, so it is not handed over again. func heardGone(ctx context.Context, b *build.Builder, m jetstream.Msg, logf func(string, ...any)) { - e, err := delivery.DecodeArtifactsGone(m.Data()) + e, err := decodeGone(m.Data()) if ackErr := m.Ack(); ackErr != nil { logf("acknowledging %s: %v", m.Subject(), ackErr) } if err != nil { - logf("%s holds what is not %s, left: %v", m.Subject(), delivery.ArtifactsGoneEvent, err) + logf("%s holds what is not %s, left: %v", m.Subject(), sweep.EventArtifactsGone, err) return } b.ArtifactsGone(ctx, e) } + +// decodeHeld reads the seat's answer to held: the verb answers {output, ok, answer}, the answer being the seat's +// HeldAnswer. A reference asked that the answer names in none of held, gone and unknown is an error: an answer that +// leaves one out would let a key with a gone blob be reused. +func decodeHeld(raw []byte, asked []string) (sweep.HeldAnswer, error) { + var wrapped struct { + Output string `json:"output"` + OK bool `json:"ok"` + Answer json.RawMessage `json:"answer"` + } + if err := json.Unmarshal(raw, &wrapped); err != nil { + return sweep.HeldAnswer{}, fmt.Errorf("the answer of %s is not {output, ok, answer}: %w", SeatHeld, err) + } + if !wrapped.OK || len(wrapped.Answer) == 0 { + return sweep.HeldAnswer{}, fmt.Errorf("%s did not answer: %s", SeatHeld, wrapped.Output) + } + var a sweep.HeldAnswer + if err := json.Unmarshal(wrapped.Answer, &a); err != nil { + return sweep.HeldAnswer{}, fmt.Errorf("the answer of %s is not {held, gone}: %w", SeatHeld, err) + } + named := map[string]bool{} + for _, r := range a.Held { + named[r] = true + } + for _, r := range a.Gone { + named[r] = true + } + for r := range a.Unknown { + named[r] = true + } + for _, r := range asked { + if !named[r] { + return sweep.HeldAnswer{}, fmt.Errorf("the answer of %s names nothing of %s", SeatHeld, r) + } + } + return a, nil +} + +// decodeGone reads the event's body, refusing one that names no module, fingerprint or reference. +func decodeGone(raw []byte) (sweep.ArtifactsGone, error) { + var e sweep.ArtifactsGone + if err := json.Unmarshal(raw, &e); err != nil { + return sweep.ArtifactsGone{}, fmt.Errorf("%s is not one: %w", sweep.EventArtifactsGone, err) + } + if e.Event != sweep.EventArtifactsGone || e.Module == "" || e.Fingerprint == "" || len(e.Gone) == 0 { + return sweep.ArtifactsGone{}, fmt.Errorf("%s names no module, fingerprint or gone reference: %s", sweep.EventArtifactsGone, raw) + } + return e, nil +} diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index 47f4e6af..ea243f5a 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -44,3 +44,7 @@ require ( ) replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk + +require git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000 + +replace git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep => ../../contracts/go/seats/meshartifactsweep diff --git a/modules/mesh-delivery-builder/internal/build/gone_test.go b/modules/mesh-delivery-builder/internal/build/gone_test.go index 499cdbc9..c114010a 100644 --- a/modules/mesh-delivery-builder/internal/build/gone_test.go +++ b/modules/mesh-delivery-builder/internal/build/gone_test.go @@ -9,6 +9,7 @@ import ( "sync" "testing" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -184,14 +185,14 @@ func TestArtifactsGoneMakesTheBuilderAskAndBuildAgain(t *testing.T) { held.mu.Lock() held.gone[goneRef] = true // collected after the builder's look; the controller's take-in found it held.mu.Unlock() - r.b.ArtifactsGone(context.Background(), delivery.ArtifactsGone{Event: delivery.ArtifactsGoneEvent, Module: "m", + r.b.ArtifactsGone(context.Background(), sweep.ArtifactsGone{Event: sweep.EventArtifactsGone, Module: "m", Fingerprint: "src2:" + strings.Repeat("9", 64), Gone: []string{goneRef}}) r.b.Wait() if r.st.revs[r.key] != rev || !log.has("a fingerprint the module does not have now") { t.Fatalf("an event for another fingerprint moved the key (revision %d, was %d): %v", r.st.revs[r.key], rev, log.lines) } - r.b.ArtifactsGone(context.Background(), delivery.ArtifactsGone{Event: delivery.ArtifactsGoneEvent, Module: "m", + r.b.ArtifactsGone(context.Background(), sweep.ArtifactsGone{Event: sweep.EventArtifactsGone, Module: "m", Key: r.key, Fingerprint: fp1, Gone: []string{goneRef}}) r.b.Wait() v := valueOf(t, r.st, r.key) diff --git a/modules/mesh-delivery-builder/internal/build/run.go b/modules/mesh-delivery-builder/internal/build/run.go index fecf91c8..e651e436 100644 --- a/modules/mesh-delivery-builder/internal/build/run.go +++ b/modules/mesh-delivery-builder/internal/build/run.go @@ -13,6 +13,7 @@ import ( "sync" "time" + sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/sdk/forms" "git.novox.be/novox/mesh-sdk/go/buildstep" @@ -281,7 +282,7 @@ func (b *Builder) storedOnce(key string) func(ctx context.Context, references [] // that the store does not hold what the module's key names. The module is looked at again, which asks the store (ClaimOptions.Stored) and // builds the key again; an event for a fingerprint the module no longer has is said and left. The event only wakes the // builder: what is built rests on the store's answer, never on the event alone. -func (b *Builder) ArtifactsGone(ctx context.Context, e delivery.ArtifactsGone) { +func (b *Builder) ArtifactsGone(ctx context.Context, e sweep.ArtifactsGone) { b.init() b.mu.Lock() m, ok := b.known[e.Module] @@ -295,11 +296,11 @@ func (b *Builder) ArtifactsGone(ctx context.Context, e delivery.ArtifactsGone) { b.mu.Unlock() if !ok || m.FP2 != e.Fingerprint { b.logf("%s: heard %s for %s, a fingerprint the module does not have now: nothing to build", e.Module, - delivery.ArtifactsGoneEvent, e.Fingerprint) + sweep.EventArtifactsGone, e.Fingerprint) return } b.logf("%s: heard %s (%s): asking the artifact store and building the key again", e.Module, - delivery.ArtifactsGoneEvent, strings.Join(e.Gone, ", ")) + sweep.EventArtifactsGone, strings.Join(e.Gone, ", ")) b.consider(ctx, e.Module, m) } From 5fe482edec98aff8e09a8ea1d25f7ae33f1b3b36 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:01:48 +0100 Subject: [PATCH 13/14] =?UTF-8?q?graph.Served=20takes=20the=20node-engine?= =?UTF-8?q?=20contract's=20haves=20(running,=20healthy),=20so=20a=20caller?= =?UTF-8?q?=20needs=20no=20adapter;=20the=20checker=20reads=20them=20throu?= =?UTF-8?q?gh=20nodeengineclient=20(B7j;=20ADR=200634=20=C2=A71,=20=C2=A72?= =?UTF-8?q?;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-checker/judge.go | 12 ++++++----- sdk/graph/graph.go | 8 ++++--- sdk/graph/graph_test.go | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+), 8 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 9e6a2075..c0a1eac6 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -14,6 +14,8 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" @@ -154,7 +156,7 @@ func (n nodeNames) Names(ctx context.Context) ([]string, error) { } var out []string for key := range lister.Keys() { - if node, _, isNode, err := delivery.ParseHaveKey(key); err == nil && isNode { + if node, _, isNode, err := nodeengineclient.ParseHaveKey(key); err == nil && isNode { out = append(out, node) } } @@ -180,9 +182,9 @@ func (r runningServes) Served(ctx context.Context) (func(string) bool, error) { if err != nil { return nil, err } - haves := map[string]map[string]delivery.Have{} + haves := map[string]map[string]nodeengine.HaveByNodeModuleValue{} for key := range lister.Keys() { - node, module, isNode, err := delivery.ParseHaveKey(key) + node, module, isNode, err := nodeengineclient.ParseHaveKey(key) if err != nil || isNode { continue } @@ -190,9 +192,9 @@ func (r runningServes) Served(ctx context.Context) (func(string) bool, error) { if err != nil { continue } - if h, err := delivery.DecodeHave(e.Value()); err == nil { + if h, err := nodeengineclient.DecodeHave(e.Value()); err == nil { if haves[node] == nil { - haves[node] = map[string]delivery.Have{} + haves[node] = map[string]nodeengine.HaveByNodeModuleValue{} } haves[node][module] = h } diff --git a/sdk/graph/graph.go b/sdk/graph/graph.go index 4af2b677..e5a0cad3 100644 --- a/sdk/graph/graph.go +++ b/sdk/graph/graph.go @@ -11,6 +11,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" ) // Version is the version of a node's value this package writes and the only one it reads. @@ -595,16 +597,16 @@ func OfManifests(manifests map[string]catalogue.Manifest) Graph { // (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), +func Served(haves map[string]map[string]nodeengine.HaveByNodeModuleValue, 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 { + if h.Phase != nodeengine.HaveByNodeModuleValuePhaseRunning || h.Health != nodeengine.HaveByNodeModuleValueHealthOk { continue } - serves, ok := servesOf(module, h.FP) + serves, ok := servesOf(module, h.Fp) if !ok { unread = append(unread, module) if fallback != nil { diff --git a/sdk/graph/graph_test.go b/sdk/graph/graph_test.go index d590041a..85f07596 100644 --- a/sdk/graph/graph_test.go +++ b/sdk/graph/graph_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -16,6 +17,7 @@ import ( func TestGraphEdges(t *testing.T) { t.Run("a node's value", nodeValues) t.Run("a reader of the seat list waits for its holder", readersOfTheSeatList) + t.Run("a verb is served by a running healthy have of the node-engine contract", servedByHaves) fp := "src2:" + strings.Repeat("b", 64) node := func(repository, path string, edit func(*Node)) Node { n := Node{FP: fp, Repository: repository, Path: path} @@ -291,3 +293,22 @@ func readersOfTheSeatList(t *testing.T) { t.Errorf("the node-agent waits for %v, want nothing: the bootstrap carries it", got) } } + +// servedByHaves: Served counts the node-engine contract's haves, running and healthy only; an unread build is named. +func servedByHaves(t *testing.T) { + running := nodeengine.HaveByNodeModuleValue{Fp: "a", Phase: nodeengine.HaveByNodeModuleValuePhaseRunning, Health: nodeengine.HaveByNodeModuleValueHealthOk} + failed := nodeengine.HaveByNodeModuleValue{Fp: "b", Phase: nodeengine.HaveByNodeModuleValuePhaseRunning, Health: nodeengine.HaveByNodeModuleValueHealthFailed} + haves := map[string]map[string]nodeengine.HaveByNodeModuleValue{"n1": {"m1": running, "m2": failed, "m3": running}} + served, unread := Served(haves, func(module, fp string) ([]string, bool) { + if module == "m3" { + return nil, false + } + return []string{module + ".verb"}, true + }, nil) + if !served("m1.verb") || served("m2.verb") { + t.Fatalf("a running healthy have serves, a failed one does not: m1 %v, m2 %v", served("m1.verb"), served("m2.verb")) + } + if !reflect.DeepEqual(unread, []string{"m3"}) { + t.Fatalf("unread = %v, want [m3]", unread) + } +} From e000ed0f664b763d22c3dc27b8d542a6be43b790 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:06:00 +0100 Subject: [PATCH 14/14] =?UTF-8?q?Teller:=20the=20gate's=20asks=20and=20ans?= =?UTF-8?q?wers=20are=20the=20delivery=20client's=20and=20the=20seat's=20t?= =?UTF-8?q?ypes,=20the=20assigner's=20asks=20its=20asks=20package's,=20the?= =?UTF-8?q?=20view's=20answers=20the=20view's;=20the=20holders=20and=20buc?= =?UTF-8?q?kets=20it=20names=20are=20its=20own=20constants=20checked=20aga?= =?UTF-8?q?inst=20its=20manifest=20(D-11,=20D-17;=20ADR=200634=20=C2=A71,?= =?UTF-8?q?=20=C2=A72,=20=C2=A75;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-teller/assignerasks.go | 17 +-- .../mesh-delivery-teller/assignerasks_test.go | 21 +-- .../mesh-delivery-teller/integration_test.go | 47 +++---- .../cmd/mesh-delivery-teller/main.go | 28 ++-- .../cmd/mesh-delivery-teller/names.go | 29 ++++ .../cmd/mesh-delivery-teller/names_test.go | 42 ++++++ .../cmd/mesh-delivery-teller/room_test.go | 18 +-- .../cmd/mesh-delivery-teller/teller.go | 45 ++++--- .../cmd/mesh-delivery-teller/teller_test.go | 126 ++++++++++-------- .../cmd/mesh-delivery-teller/viewanswers.go | 26 ++-- .../mesh-delivery-teller/viewanswers_test.go | 30 +++-- modules/mesh-delivery-teller/go.mod | 4 + .../internal/ends/ends.go | 12 +- 13 files changed, 273 insertions(+), 172 deletions(-) create mode 100644 modules/mesh-delivery-teller/cmd/mesh-delivery-teller/names.go create mode 100644 modules/mesh-delivery-teller/cmd/mesh-delivery-teller/names_test.go diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/assignerasks.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/assignerasks.go index 60f55915..2fbc1626 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/assignerasks.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/assignerasks.go @@ -10,7 +10,8 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" ) // The assigner's asks reach the operator (issue 1906). The assigner writes its asks (the hold of a fingerprint, the restore @@ -89,8 +90,8 @@ func (t *Teller) waitForAssignerRoom(key, id string) { // isAssignerKey says whether an ask key is one of the assigner's, not the gate's merge ask. func isAssignerKey(key string) bool { - k, err := delivery.ParseAnyAskKey(key) - return err == nil && k.Kind != delivery.AskKindMerge + k, err := asks.ParseAnyAskKey(key) + return err == nil && k.Kind != meshdeliveryclient.AskKindMerge } // assignerAsk reads the assigner's ask at key: found false when the key holds none. @@ -115,14 +116,14 @@ type assignerAsked struct { Ask oc.Ask } -// decodeAssignerAsk reads the assigner's ask at key as delivery.DecodeAssignerAsk did before the ask was JSON: it refuses an +// decodeAssignerAsk reads the assigner's ask at key as asks.DecodeAsk did before the ask was JSON: it refuses an // id other than the one the key and the asking give, and an ask with no option (occ.Carried). func decodeAssignerAsk(key string, data []byte) (assignerAsked, error) { - a, err := delivery.DecodeAssignerAsk(key, data) + a, err := asks.DecodeAsk(key, data) if err != nil { return assignerAsked{}, err } - ask, err := occ.Carried(a.Ask, delivery.AskingID(key, a.Asking)) + ask, err := occ.Carried(a.Ask, meshdeliveryclient.AskingID(key, a.Asking)) if err != nil { return assignerAsked{}, err } @@ -142,7 +143,7 @@ func (t *Teller) raiseAssignerAsk(key string) { return } id := a.Ask.ID - if raw, _, has, err := t.Answers.Get(delivery.AnswerKey(key)); err != nil { + if raw, _, has, err := t.Answers.Get(answerKey(key)); err != nil { t.logf("%s: the answers could not be read, so the ask is not raised: %v", key, err) return } else if has && answeredAsking(raw) == id { @@ -227,7 +228,7 @@ func (t *Teller) answerAssigner(key string, o oc.Option, w oc.Warrant) error { if occ.Digest(a.Ask) != w.AskDigest { return fmt.Errorf("the assigner's ask %s is no longer the ask the operator answered (its digest is %s, the warrant's %s)", key, occ.Digest(a.Ask), w.AskDigest) } - body, err := delivery.EncodeAnswer(delivery.Answer{Option: o.ID, Warrant: warrantID(w), Words: occ.Says(w)}) + body, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: o.ID, Warrant: warrantID(w), Words: occ.Says(w)}) if err != nil { return err } diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/assignerasks_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/assignerasks_test.go index 1e8a143d..9a878ce6 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/assignerasks_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/assignerasks_test.go @@ -7,7 +7,8 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" ) // 1906/H1: given the assigner's hold ask in its bucket and no answer, when the teller hears the key, then it raises the ask on @@ -19,7 +20,7 @@ func TestAnAssignerAskIsRaisedOnTheOperatorChannelOnce(t *testing.T) { assigner[key] = value b.t.OnAssignerAsk(key) b.t.OnAssignerAsk(key) - asked := b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule)) + asked := b.askedOf(oc.AcceptSubjectAsk(tellerModule)) if len(asked) != 1 { t.Fatalf("published %d asks, want one; log:\n%s", len(asked), b.logs.String()) } @@ -40,7 +41,7 @@ func TestAnAssignerAskIsRaisedWithoutTheView(t *testing.T) { key, value, _ := holdAsk(t) assigner[key] = value b.t.OnAssignerAsk(key) - if n := len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule))); n != 1 { + if n := len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))); n != 1 { t.Fatalf("published %d asks, want one; log:\n%s", n, b.logs.String()) } } @@ -55,11 +56,11 @@ func TestTheOperatorsAnswerToAnAssignerAskIsWrittenUnderItsKey(t *testing.T) { b.t.OnAssignerAsk(key) w := ackFor(ask, "release") b.deliver(t, w) - raw, ok := b.answers.written[delivery.AnswerKey(key)] + raw, ok := b.answers.written[answerKey(key)] if !ok { t.Fatalf("no answer under %s; log:\n%s", key, b.logs.String()) } - if a, err := delivery.DecodeAnswer(raw); err != nil || a.Option != "release" || answeredAsking(raw) != ask.ID { + if a, err := meshdeliveryclient.DecodeAnswer(raw); err != nil || a.Option != "release" || answeredAsking(raw) != ask.ID { t.Errorf("answer = %+v, %v", a, err) } b.deliver(t, w) @@ -86,7 +87,7 @@ func TestAnAnswerToAnAssignerAskTakenAwayWritesNothing(t *testing.T) { // itself) is taken back too; an ask answered before it is heard is never raised. func TestAnAssignerAskAnsweredInTheViewOrTakenAwayIsTakenBack(t *testing.T) { cancelled := func(b *bed, id string) bool { - for _, p := range b.askedOf(oc.AcceptSubjectCancel(delivery.TellerModule)) { + for _, p := range b.askedOf(oc.AcceptSubjectCancel(tellerModule)) { if string(p.body) == `{"id":"`+id+`"}` { return true } @@ -119,7 +120,7 @@ func TestAnAssignerAskAnsweredInTheViewOrTakenAwayIsTakenBack(t *testing.T) { assigner[key] = value view[key+".view"] = viewAnswer(t, "keep", "") b.t.OnAssignerAsk(key) - if n := len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule))); n != 0 { + if n := len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))); n != 0 { t.Errorf("an ask answered in the view was raised %d times", n) } } @@ -162,13 +163,13 @@ func ackFor(a oc.Ask, option string) oc.Warrant { // holdAskOf is the assigner's hold ask of module plex at the n-th fingerprint, shaped as holdAsk. func holdAskOf(t *testing.T, n int) (string, []byte, oc.Ask) { t.Helper() - key, err := delivery.HoldAskKey("plex", "src2:"+strings.Repeat(string(rune('0'+n)), 64)) + key, err := asks.HoldAskKey("plex", "src2:"+strings.Repeat(string(rune('0'+n)), 64)) if err != nil { t.Fatal(err) } _, _, ask := holdAsk(t) - ask.ID = delivery.AskID(key) - value, err := delivery.EncodeAssignerAsk(key, delivery.AssignerAsk{Ask: rawAsk(t, ask)}) + ask.ID = meshdeliveryclient.AskID(key) + value, err := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, ask)}) if err != nil { t.Fatal(err) } 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 0e8c6027..7237a5cf 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 @@ -20,7 +20,8 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" "git.novox.be/novox/mesh-sdk/go/process" "mesh-delivery-teller/internal/ends" @@ -189,7 +190,7 @@ func fullConf(dir string) string { teller = append(teller, readOf(viewAnswers)...) // issue 1297 B10: the manifest's reads // The assigner's asks by its read tool and its views family (ADR 0615, DLV-M3), and the teller's own first-views and // findings; the bucket itself is no longer the teller's to read. - teller = append(append(append(teller, viewstest.Grants(delivery.AssignerModule, delivery.AssignerModule+".assigner_read_asks", tellerConsumer)...), keepsOf(firstViewsBucket)...), keepsOf(findingsBucket)...) + teller = append(append(append(teller, viewstest.Grants(assignerModule, assignerModule+".assigner_read_asks", tellerConsumer)...), keepsOf(firstViewsBucket)...), keepsOf(findingsBucket)...) view := append(append(append(readOf(tellerAnswers), readOf(gateAsksKV)...), readOf(assignerAsks)...), keepsOf(viewAnswers)...) assigner := keepsOf(assignerAsks) tools := append(keepsOf(gateAsksKV), readOf(tellerAnswers)...) @@ -256,7 +257,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: delivery.AsksHistory, tellerAnswers: delivery.AnswersHistory, tellerAsked: 1, ends.Bucket: 1, messengerAsks: 1, assignerAsks: delivery.AssignerAsksHistory, viewAnswers: delivery.ViewAnswersHistory, firstViewsBucket: 1, findingsBucket: 1} { + 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} { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil { t.Fatal(err) } @@ -273,7 +274,7 @@ func startFull(t *testing.T) *fullBed { t.Fatal(err) } // The assigner as the owner of its asks: its read tool and its views family (ADR 0615). - viewstest.Serve(t, ctx, js, ctl, delivery.AssignerModule, "asks", delivery.AssignerModule+".assigner_read_asks", tellerConsumer) + viewstest.Serve(t, ctx, js, ctl, assignerModule, "asks", assignerModule+".assigner_read_asks", tellerConsumer) return &fullBed{srv: srv, ctl: ctl, js: js, ctx: ctx} } @@ -423,18 +424,18 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2 } var e jetstream.KeyValueEntry for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - if e, err = answers.Get(b.ctx, delivery.AnswerKey(key)); err == nil { + if e, err = answers.Get(b.ctx, answerKey(key)); err == nil { break } } if e == nil { t.Fatalf("no answer under %s: %v\n%s", key, err, logs.String()) } - if a, err := delivery.DecodeAnswer(e.Value()); err != nil || a.Option != "approve" { + if a, err := meshdeliveryclient.DecodeAnswer(e.Value()); err != nil || a.Option != "approve" { t.Errorf("answer %+v err %v", a, err) } // A second ask, withdrawn by the gate, is cancelled at the router. - other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}} + other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}} key2, value2, ga2 := gateAsk(t, other, other) if _, err := kv.Put(b.ctx, key2, value2); err != nil { t.Fatal(err) @@ -512,20 +513,20 @@ func TestTellerAsksAgainAnAskRefusedForWantOfRoom(t *testing.T) { // 1610/AC1 t.Fatal(err) } first := lastAsk(held.Ask.ID, 0) - say(oc.Warrant{Ask: held.Ask.ID, Asker: delivery.TellerModule, Outcome: occ.OutcomeRefused, Words: roomRefusal, At: occ.FormatTime(now)}) + say(oc.Warrant{Ask: held.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeRefused, Words: roomRefusal, At: occ.FormatTime(now)}) for deadline := time.Now().Add(10 * time.Second); !strings.Contains(logs.String(), "for want of room"); time.Sleep(50 * time.Millisecond) { if time.Now().After(deadline) { t.Fatalf("the refusal was not heard\n%s", logs.String()) } } - other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}} + other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}} key2, value2, ga2 := gateAsk(t, other, other) if _, err := kv.Put(b.ctx, key2, value2); err != nil { t.Fatal(err) } second := lastAsk(ga2.Ask.ID, first) // The other ask lapses at the router: there is room, and the refused ask is asked again. - say(oc.Warrant{Ask: ga2.Ask.ID, Asker: delivery.TellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(ga2.Ask)}) + say(oc.Warrant{Ask: ga2.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(ga2.Ask)}) lastAsk(held.Ask.ID, second) } @@ -569,14 +570,14 @@ func TestTellerFillsAnAskFromTheViewsAnswerOnce(t *testing.T) { // 1297/B10 AC } var e jetstream.KeyValueEntry for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - if e, err = answers.Get(b.ctx, delivery.AnswerKey(key)); err == nil { + if e, err = answers.Get(b.ctx, answerKey(key)); err == nil { break } } if e == nil { t.Fatalf("no answer under %s: %v\n%s", key, err, logs.String()) } - if a, err := delivery.DecodeAnswer(e.Value()); err != nil || a.Option != "release" || answeredAsking(e.Value()) != ask.ID { + if a, err := meshdeliveryclient.DecodeAnswer(e.Value()); err != nil || a.Option != "release" || answeredAsking(e.Value()) != ask.ID { t.Fatalf("answer %+v err %v", a, err) } // The view's answer written again with the same option changes nothing under the ask (another option, later, is the @@ -585,7 +586,7 @@ func TestTellerFillsAnAskFromTheViewsAnswerOnce(t *testing.T) { // 1297/B10 AC t.Fatal(err) } time.Sleep(time.Second) - if again, err := answers.Get(b.ctx, delivery.AnswerKey(key)); err != nil || again.Revision() != e.Revision() { + if again, err := answers.Get(b.ctx, answerKey(key)); err != nil || again.Revision() != e.Revision() { t.Errorf("the answer was written a second time: %v", err) } // An answer under the merge's key fills nothing. @@ -597,7 +598,7 @@ func TestTellerFillsAnAskFromTheViewsAnswerOnce(t *testing.T) { // 1297/B10 AC t.Fatal(err) } time.Sleep(time.Second) - if _, err := answers.Get(b.ctx, delivery.AnswerKey(mkey)); err == nil { + if _, err := answers.Get(b.ctx, answerKey(mkey)); err == nil { t.Error("the merge's ask was answered from a view key") } } @@ -656,24 +657,24 @@ func TestTellerRaisesAnAssignerAskAndWritesTheOperatorsAnswer(t *testing.T) { } var e jetstream.KeyValueEntry for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - if e, err = answers.Get(b.ctx, delivery.AnswerKey(key)); err == nil { + if e, err = answers.Get(b.ctx, answerKey(key)); err == nil { break } } if e == nil { t.Fatalf("no answer under %s: %v\n%s", key, err, logs.String()) } - if a, err := delivery.DecodeAnswer(e.Value()); err != nil || a.Option != "release" || answeredAsking(e.Value()) != ask.ID { + if a, err := meshdeliveryclient.DecodeAnswer(e.Value()); err != nil || a.Option != "release" || answeredAsking(e.Value()) != ask.ID { t.Fatalf("answer %+v err %v", a, err) } - key2, err := delivery.HoldAskKey("plex", "src2:"+strings.Repeat("3", 64)) + key2, err := asks.HoldAskKey("plex", "src2:"+strings.Repeat("3", 64)) if err != nil { t.Fatal(err) } ask2 := ask - ask2.ID = delivery.AskID(key2) - value2, err := delivery.EncodeAssignerAsk(key2, delivery.AssignerAsk{Ask: rawAsk(t, ask2)}) + ask2.ID = meshdeliveryclient.AskID(key2) + value2, err := asks.EncodeAsk(key2, asks.Ask{Ask: rawAsk(t, ask2)}) if err != nil { t.Fatal(err) } @@ -714,8 +715,8 @@ func TestTellerTakesAChangedHoldAnswerFromTheView(t *testing.T) { if err != nil { t.Fatal(err) } - kept, _ := delivery.EncodeAnswer(delivery.Answer{Option: "keep", Warrant: ask.ID + "@" + time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano), Words: "the operator chose keep"}) - if _, err := answers.Put(b.ctx, delivery.AnswerKey(key), kept); err != nil { + kept, _ := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "keep", Warrant: ask.ID + "@" + time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano), Words: "the operator chose keep"}) + if _, err := answers.Put(b.ctx, answerKey(key), kept); err != nil { t.Fatal(err) } askKV, err := assigner.KeyValue(b.ctx, assignerAsks) @@ -738,8 +739,8 @@ func TestTellerTakesAChangedHoldAnswerFromTheView(t *testing.T) { t.Fatal(err) } for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - if e, err := answers.Get(b.ctx, delivery.AnswerKey(key)); err == nil { - if a, err := delivery.DecodeAnswer(e.Value()); err == nil && a.Option == "release" && answeredAsking(e.Value()) == ask.ID { + if e, err := answers.Get(b.ctx, answerKey(key)); err == nil { + if a, err := meshdeliveryclient.DecodeAnswer(e.Value()); err == nil && a.Option == "release" && answeredAsking(e.Value()) == ask.ID { return } } 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 f4e9df8c..2e878c2e 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go @@ -25,8 +25,6 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-sdk/go/delivery" - "mesh-delivery-teller/internal/ends" ) @@ -51,7 +49,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } defer conn.Close() - gate, err := js.KeyValue(ctx, delivery.AsksBucket) + gate, err := js.KeyValue(ctx, gateAsksBucket) if errors.Is(err, jetstream.ErrBucketNotFound) { logger.Print(waiting) <-ctx.Done() @@ -59,11 +57,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } if err != nil { // Essential: the gate's asks are what the teller tells (issue 1945: the error names the grant). - return process.EssentialErr("teller", delivery.AsksBucket, "tell any of the gate's asks", err) + return process.EssentialErr("teller", gateAsksBucket, "tell any of the gate's asks", err) } // The answers it writes and what it asked: its own work, so each stops the start, naming its grant. var kvs [2]jetstream.KeyValue - for i, name := range []string{delivery.AnswersBucket, askedBucket} { + for i, name := range []string{answersBucket, askedBucket} { if kvs[i], err = process.EssentialBucket(ctx, js, "teller", name, "write an answer or remember what it asked"); err != nil { return err } @@ -78,10 +76,10 @@ 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() - client := &occ.Client{Module: delivery.TellerModule, State: state, Publish: publish, - Record: recordVia(ctx, conn, delivery.TellerModule), Subscribe: decidedVia(hub.Handle, logger.Printf)} + client := &occ.Client{Module: tellerModule, State: state, Publish: publish, + Record: recordVia(ctx, conn, tellerModule), 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, delivery.TellerModule), Logf: logger.Printf, Publish: publish} + Say: sayVia(ctx, js, tellerModule), Logf: logger.Printf, Publish: publish} // Hear the warrants first, so none that comes while the asks are read is missed. if err := client.Listen(t.Answer, t.Said); err != nil { return fmt.Errorf("hearing the router's warrants: %w", err) @@ -96,10 +94,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return fmt.Errorf("the teller's connection for its tool calls: %w", err) } defer calls.Close() - reader := &views.ProcessReader{Module: delivery.TellerModule, Call: calls.Call, Logf: logger.Printf, Hub: hub} + reader := &views.ProcessReader{Module: tellerModule, Call: calls.Call, Logf: logger.Printf, Hub: hub} findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf) reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) } - asks, err := reader.Follow(delivery.AssignerModule, assignerAsksState, delivery.AssignerModule+".assigner_read_asks") + asks, err := reader.Follow(assignerModule, assignerAsksState, assignerModule+".assigner_read_asks") if err != nil { return err } @@ -109,7 +107,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err := hub.Start(ctx, js, cred); err != nil { return fmt.Errorf("the consumer %s on %s cannot be read (%v): the teller hears no answer without it, so it stops", cred.EventConsumerName(), process.EventsStream, err) } - viewKV := process.OptionalBucket(ctx, js, delivery.ViewAnswersBucket, teller, logger.Printf) + viewKV := process.OptionalBucket(ctx, js, viewAnswersBucket, teller, logger.Printf) // The assigner's asks are raised on the operator channel whether or not the view's answers can be read (issue 1906). if viewKV != nil { t.View = kvGate{ctx: ctx, kv: viewKV} @@ -153,16 +151,16 @@ const teller = "the asks and answers it holds are not carried until the teller i // askedBucket is the teller's state bucket asked, `_`: what it asked of the router and which condition it // raised (manifest state entry asked, novox/hq ADR 0437 §4). -const askedBucket = delivery.TellerModule + "_asked" +const askedBucket = tellerModule + "_asked" // The teller's own states of its first view of the assigner's asks (views.StatusState) and of its findings, where the // condition keeper raises a first view unanswered past its bound (ADR 0615 §6). var ( - firstViewsBucket = delivery.TellerModule + "_" + views.StatusState - findingsBucket = delivery.TellerModule + "_findings" + firstViewsBucket = tellerModule + "_" + views.StatusState + findingsBucket = tellerModule + "_findings" ) -// assignerAsksState is the assigner's state of its asks (delivery.AssignerAsksBucket). +// assignerAsksState is the assigner's state of its asks (assignerAsksBucket). const assignerAsksState = "asks" // waiting is said once at start. diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/names.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/names.go new file mode 100644 index 00000000..9f8fd3a8 --- /dev/null +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/names.go @@ -0,0 +1,29 @@ +package main + +// The holders and buckets the teller names, taken from its own manifest (module.json: its name, its `reads`) and the +// states it keeps itself; a unit row checks them against it (TestTheNamesAreTheManifests). No contract names a holder +// (novox/hq ADR 0625 §4, ADR 0634 §1). +const ( + tellerModule = "mesh-delivery-teller" + assignerModule = "mesh-delivery-assigner" + gateModule = "mesh-delivery-gate" + viewModule = "mesh-delivery-view" + + // gateAsksBucket is the gate's state asks, read by the teller (reads: mesh-delivery-gate.asks). + gateAsksBucket = gateModule + "_asks" + // viewAnswersBucket is the view's state answers, read by the teller (reads: mesh-delivery-view.answers). + viewAnswersBucket = viewModule + "_answers" + // answersBucket is the teller's own state answers, which the gate reads. + answersBucket = tellerModule + "_answers" + // assignerAsksBucket is the assigner's state asks (the first view of assigner_read_asks stands in for it). + assignerAsksBucket = assignerModule + "_asks" + + // The revisions each keeps per key. + gateAsksHistory = 5 + answersHistory = 5 + assignerAsksHistory = 5 + viewAnswersHistory = 5 +) + +// answerKey is the key, in the teller's answers, of its answer to the ask at askKey: the same string. +func answerKey(askKey string) string { return askKey } diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/names_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/names_test.go new file mode 100644 index 00000000..33a47184 --- /dev/null +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/names_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "testing" +) + +// TestTheNamesAreTheManifests: the holders and buckets the teller names are its manifest's (its module, its `reads`, its +// own state answers), since no contract names a holder (novox/hq ADR 0634 §1). +func TestTheNamesAreTheManifests(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Module string `json:"module"` + Reads []string `json:"reads"` + State []struct { + Name string `json:"name"` + } `json:"state"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.Module != tellerModule { + t.Errorf("the manifest's module is %q, the teller names itself %q", m.Module, tellerModule) + } + for _, read := range []string{gateModule + ".asks", viewModule + ".answers"} { + if !slices.Contains(m.Reads, read) { + t.Errorf("the teller reads %s, its manifest's reads are %v", read, m.Reads) + } + } + var own []string + for _, s := range m.State { + own = append(own, tellerModule+"_"+s.Name) + } + if !slices.Contains(own, answersBucket) { + t.Errorf("the teller writes %s, its manifest's states are %v", answersBucket, own) + } +} diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/room_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/room_test.go index cdf1275c..2b87c8fb 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/room_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/room_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" ) // roomRefusal is the router's words when an asker already holds occ.MostOpen open asks, as the live router said them to @@ -17,8 +17,8 @@ import ( const roomRefusal = "its asker already has 3 open asks" // headsN is one pull request's head, distinct per n, so each n is an ask of its own key. -func headsN(n int) []delivery.Head { - return []delivery.Head{{Repository: "novox/mesh-delivery", Number: 10 + n, Head: strings.Repeat(string(rune('a'+n)), 40)}} +func headsN(n int) []meshdeliveryclient.Head { + return []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 10 + n, Head: strings.Repeat(string(rune('a'+n)), 40)}} } // askOf puts the gate's key for n and hands it to the teller, as the watch would. @@ -36,19 +36,19 @@ func (b *bed) refuse(t *testing.T, id, words string) { t.Helper() delete(b.seenIDs, id) delete(b.records, id) - b.deliver(t, oc.Warrant{Ask: id, Asker: delivery.TellerModule, Outcome: occ.OutcomeRefused, Words: words, At: occ.FormatTime(now)}) + b.deliver(t, oc.Warrant{Ask: id, Asker: tellerModule, Outcome: occ.OutcomeRefused, Words: words, At: occ.FormatTime(now)}) } // expire is the router's word that an ask of the teller's lapsed with nobody's answer: one of its asks ended. func (b *bed) expire(t *testing.T, ga gateAsked) { t.Helper() - b.deliver(t, oc.Warrant{Ask: ga.Ask.ID, Asker: delivery.TellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(ga.Ask)}) + b.deliver(t, oc.Warrant{Ask: ga.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(ga.Ask)}) } // askedIDs is the router ids of every ask the bus carried, in order. func (b *bed) askedIDs() []string { var out []string - for _, p := range b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule)) { + for _, p := range b.askedOf(oc.AcceptSubjectAsk(tellerModule)) { var a struct { ID string `json:"id"` } @@ -78,7 +78,7 @@ func TestAnAskRefusedForWantOfRoomIsAskedAgainWhenAnotherEnds(t *testing.T) { // if len(got) != 5 || got[4] != fourth.Ask.ID { t.Fatalf("asks = %v, want the refused %s asked again once the first was answered", got, fourth.Ask.ID) } - pubs := b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule)) + pubs := b.askedOf(oc.AcceptSubjectAsk(tellerModule)) if pubs[4].id == pubs[3].id { t.Errorf("asked again under the bus message id %s of its first asking: the bus drops it as a duplicate", pubs[4].id) } @@ -86,9 +86,9 @@ func TestAnAskRefusedForWantOfRoomIsAskedAgainWhenAnotherEnds(t *testing.T) { // t.Errorf("bus dropped %v, router dropped %v, conditions %+v", b.busDropped, b.dropped, b.conds) } // Taken this time: an answer to it writes the answer as for any ask. - key4, _ := delivery.MergeAskKey("g", headsN(4)) + key4, _ := meshdeliveryclient.MergeAskKey("g", headsN(4)) b.deliver(t, warrantFor(fourth.Ask, "approve")) - if _, ok := b.answers.written[delivery.AnswerKey(key4)]; !ok { + if _, ok := b.answers.written[answerKey(key4)]; !ok { t.Errorf("the answer to the ask asked again was not written; logs:\n%s", b.logs.String()) } // And it is asked no more: its answer ends its wait. diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go index 1ee44804..e7e2c4d6 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller.go @@ -13,8 +13,8 @@ import ( 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" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) // The teller carries the gate's merge asks to the operator and writes the operator's answer back (novox/hq ADR 0437 @@ -24,30 +24,35 @@ import ( // warrant that does not hold writes no answer and is said as a condition. It holds no timer and serves no request: // every reaction is a revision of the gate's key or a warrant. -// gateAsked is the gate's ask with the router's ask decoded from its JSON (delivery.Ask.Ask is operator-channel's ask as +// gateAsked is the gate's ask with the router's ask decoded from its JSON (the seat's ask value carries operator-channel's ask as // JSON, novox/hq ADR 0627 §4). type gateAsked struct { Group string - Heads []delivery.Head + Heads []meshdeliveryclient.Head Asking int Ask oc.Ask } -// decodeGateAsk reads the gate's ask value and the router's ask in it, as delivery.DecodeAsk did before the ask was JSON. +// decodeGateAsk reads the gate's ask value and the router's ask in it, as meshdeliveryclient.DecodeAsk did before the ask was JSON. func decodeGateAsk(value []byte) (gateAsked, error) { - ga, err := delivery.DecodeAsk(value) + ga, err := meshdeliveryclient.DecodeAsk(value) if err != nil { return gateAsked{}, err } - key, err := delivery.MergeAskKey(ga.Group, ga.Heads) + heads := meshdeliveryclient.HeadsOf(ga.Heads) + key, err := meshdeliveryclient.MergeAskKey(ga.Group, heads) if err != nil { return gateAsked{}, err } - a, err := occ.Carried(ga.Ask, delivery.AskingID(key, ga.Asking)) + raw, err := json.Marshal(ga.Ask) if err != nil { return gateAsked{}, err } - return gateAsked{Group: ga.Group, Heads: ga.Heads, Asking: ga.Asking, Ask: a}, nil + a, err := occ.Carried(raw, meshdeliveryclient.AskingID(key, int(ga.Asking))) + if err != nil { + return gateAsked{}, err + } + return gateAsked{Group: ga.Group, Heads: heads, Asking: int(ga.Asking), Ask: a}, nil } // store is where the teller keeps what it asked: the asker's own records (`ask.`) and, beside them, which gate key @@ -189,7 +194,7 @@ func (t *Teller) OnAsk(key string, rev uint64, value []byte) { t.logf("the gate's key %s at revision %d is not an ask, and is left: %v", key, rev, err) return } - if want, err := delivery.MergeAskKey(ga.Group, ga.Heads); err != nil || want != key { + if want, err := meshdeliveryclient.MergeAskKey(ga.Group, ga.Heads); err != nil || want != key { t.logf("the gate's key %s at revision %d holds the ask of %q, not its own, and is left (%v)", key, rev, want, err) return } @@ -277,7 +282,7 @@ func (t *Teller) cancelOpen(key, id string) { func (t *Teller) OnAskGone(key string) { t.mu.Lock() defer t.mu.Unlock() - if _, _, _, err := delivery.ParseAskKey(key); err != nil { + if _, _, _, err := meshdeliveryclient.ParseAskKey(key); err != nil { t.logf("the gate's key %s was taken away and is no ask key, and is left: %v", key, err) return } @@ -313,7 +318,7 @@ func (t *Teller) catchUp(id string) { t.said(h, *w, err) } -// mergeAct is what the approve option of the gate's ask binds: the ask's verb (delivery.AskVerb: mesh-delivery.merge, or +// mergeAct is what the approve option of the gate's ask binds: the ask's verb (meshdeliveryclient.AskVerb: mesh-delivery.merge, or // mesh-delivery.settle for a person's settle ask, novox/hq ADR 0556 clause 5), this ask, the answer, and the heads it covers. // It is spelled as the gate composes it (mesh-catalog modules/mesh-delivery-gate approval.go mergeAct, from // modules/mesh-delivery approval.go:63-65) and as mesh-controller verifies it (cmd/mesh-controller/moved.go:98-106). @@ -329,7 +334,7 @@ func mergeAct(verb, ask, option string, heads map[string]string) occ.Act { // headsOf is the heads an ask covers as an act names them: a delivery's id, `owner/repository@` with the repository in lower case, as the gate's IDOf spells it (mesh-catalog modules/mesh-delivery-gate model.go), // and its whole commit. -func headsOf(heads []delivery.Head) map[string]string { +func headsOf(heads []meshdeliveryclient.Head) map[string]string { out := map[string]string{} for _, h := range heads { short := h.Head @@ -344,7 +349,7 @@ func headsOf(heads []delivery.Head) map[string]string { // answeredAsking is the router id of the asking an answer is for: its warrant's id before the "@". An answer that cannot be // read answers no asking. func answeredAsking(raw []byte) string { - a, err := delivery.DecodeAnswer(raw) + a, err := meshdeliveryclient.DecodeAnswer(raw) if err != nil { return "" } @@ -395,10 +400,10 @@ func (t *Teller) Answer(o oc.Option, w oc.Warrant) error { return fmt.Errorf("the gate's ask %s is no longer the ask the operator answered (its digest is %s, the warrant's %s)", s.Key, occ.Digest(ga.Ask), w.AskDigest) } - if err := occ.Performs(o, mergeAct(delivery.AskVerb(ga.Group), w.Ask, o.ID, headsOf(ga.Heads))); err != nil { + if err := occ.Performs(o, mergeAct(meshdeliveryclient.AskVerb(ga.Group), w.Ask, o.ID, headsOf(ga.Heads))); err != nil { return err } - body, err := delivery.EncodeAnswer(delivery.Answer{Option: o.ID, Warrant: warrantID(w), Words: occ.Says(w)}) + body, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: o.ID, Warrant: warrantID(w), Words: occ.Says(w)}) if err != nil { return err } @@ -409,7 +414,7 @@ func (t *Teller) Answer(o oc.Option, w oc.Warrant) error { // updated at the revision read when it holds an earlier asking's answer (the reader ignores that one); left when it holds this // asking's. func (t *Teller) writeAnswer(key, askID string, body []byte, words string) error { - akey := delivery.AnswerKey(key) + akey := answerKey(key) cur, crev, found, err := t.Answers.Get(akey) if err != nil { return fmt.Errorf("the answer under %s could not be read: %w", key, err) @@ -484,7 +489,7 @@ func oneLine(err error) string { } // conditionKey is the key of the condition about an ask. -func conditionKey(id string) string { return delivery.TellerModule + ".merge-ask." + id } +func conditionKey(id string) string { return tellerModule + ".merge-ask." + id } // askWords are the words the teller's conditions use for one kind of ask: the gate's merge ask, or one of the assigner's. type askWords struct { @@ -791,7 +796,7 @@ func (t *Teller) stillAsked(x waitingAsk) (oc.Ask, string) { if ga.Ask.ID != x.ID { return oc.Ask{}, "the gate asks again under " + ga.Ask.ID } - if cur, _, has, err := t.Answers.Get(delivery.AnswerKey(x.Key)); err == nil && has && answeredAsking(cur) == x.ID { + if cur, _, has, err := t.Answers.Get(answerKey(x.Key)); err == nil && has && answeredAsking(cur) == x.ID { return oc.Ask{}, "it is answered" } if err := occ.Check(ga.Ask, t.now()); err != nil { @@ -811,7 +816,7 @@ func (t *Teller) stillAskedByAssigner(x waitingAsk) (oc.Ask, string) { case a.Ask.ID != x.ID: return oc.Ask{}, "the assigner asks again under " + a.Ask.ID } - if cur, _, has, err := t.Answers.Get(delivery.AnswerKey(x.Key)); err == nil && has && answeredAsking(cur) == x.ID { + if cur, _, has, err := t.Answers.Get(answerKey(x.Key)); err == nil && has && answeredAsking(cur) == x.ID { return oc.Ask{}, "it is answered" } if err := occ.Check(a.Ask, t.now()); err != nil { @@ -832,7 +837,7 @@ func (t *Teller) askAgain(a oc.Ask, msgID string) error { if _, err := t.Store.Create(keptKey(a.ID), occ.Kept{Ask: a, Asked: t.now()}); err != nil { return err } - if err := t.Publish(oc.AcceptSubjectAsk(delivery.TellerModule), a, msgID); err != nil { + if err := t.Publish(oc.AcceptSubjectAsk(tellerModule), a, msgID); err != nil { _ = t.Store.Delete(keptKey(a.ID)) return err } diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go index 65b87038..2c7e82eb 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/teller_test.go @@ -14,8 +14,9 @@ import ( "git.novox.be/novox/mesh-sdk/go/process" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + meshdelivery "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/delivery" ) const ( @@ -137,7 +138,7 @@ func newBed(t *testing.T) *bed { t.Helper() b := &bed{store: &memStore{entries: map[string]*stdio.StateEntry{}}, answers: &memAnswers{written: map[string][]byte{}, revs: map[string]uint64{}}, gate: memGate{}, records: map[string]string{}, seenIDs: map[string]bool{}, msgIDs: map[string]bool{}} - b.client = &occ.Client{Module: delivery.TellerModule, State: b.store, Now: func() time.Time { return now }, + b.client = &occ.Client{Module: tellerModule, State: b.store, Now: func() time.Time { return now }, Publish: func(subject string, body any, id string) error { raw, err := json.Marshal(body) if b.msgIDs[id] { @@ -146,7 +147,7 @@ func newBed(t *testing.T) *bed { return err } b.msgIDs[id] = true - if subject == oc.AcceptSubjectAsk(delivery.TellerModule) { + if subject == oc.AcceptSubjectAsk(tellerModule) { var a struct { ID string `json:"id"` } @@ -188,26 +189,26 @@ func newBed(t *testing.T) *bed { // `mesh-delivery.merge` for this ask and the heads (mesh-catalog modules/mesh-delivery approval.go:123-191 composeMergeAsk // and :63-65 mergeAct), under the id its key gives (mesh-sdk go/delivery AskID). boundTo are the heads the options bind, // which are the ask's own unless a test says another. -func gateAsk(t *testing.T, heads, boundTo []delivery.Head) (key string, value []byte, ask gateAsked) { +func gateAsk(t *testing.T, heads, boundTo []meshdeliveryclient.Head) (key string, value []byte, ask gateAsked) { t.Helper() return gateAskN(t, heads, boundTo, 1) } // gateAskN is the asking-th writing of the group's ask at these heads: its router id is AskingID(key, asking) (mesh-sdk // go/delivery Ask.Asking), the first asking's the key's own. -func gateAskN(t *testing.T, heads, boundTo []delivery.Head, asking int) (key string, value []byte, ask gateAsked) { +func gateAskN(t *testing.T, heads, boundTo []meshdeliveryclient.Head, asking int) (key string, value []byte, ask gateAsked) { t.Helper() return gateAskOf(t, "g", "mesh-delivery.merge", heads, boundTo, asking) } // gateAskOf is gateAskN for a group whose options bind the verb given. -func gateAskOf(t *testing.T, group, verb string, heads, boundTo []delivery.Head, asking int) (key string, value []byte, ask gateAsked) { +func gateAskOf(t *testing.T, group, verb string, heads, boundTo []meshdeliveryclient.Head, asking int) (key string, value []byte, ask gateAsked) { t.Helper() - key, err := delivery.MergeAskKey(group, heads) + key, err := meshdeliveryclient.MergeAskKey(group, heads) if err != nil { t.Fatal(err) } - id := delivery.AskingID(key, asking) + id := meshdeliveryclient.AskingID(key, asking) a := oc.Ask{ID: id, Headline: "Merge into main: novox/mesh-delivery#12", Explanation: "Merges two pull requests.\nReview: approved.", Who: occ.Operator, Expires: occ.FormatTime(now.Add(occ.ApproveLasts - 10*time.Minute)), OnExpiry: "the merge stays held", About: "delivery.mesh-delivery.12"} for _, o := range []struct{ id, label, does string }{{"approve", "Approve", "merges"}, {"decline", "Decline", "merges nothing"}} { @@ -221,7 +222,7 @@ func gateAskOf(t *testing.T, group, verb string, heads, boundTo []delivery.Head, return key, encodeGate(t, ask), ask } -// rawAsk is the router's ask as the JSON a gate or assigner value carries (delivery.Ask.Ask). +// rawAsk is the router's ask as the JSON a gate or assigner value carries (the seat's ask value). func rawAsk(t *testing.T, a oc.Ask) json.RawMessage { t.Helper() b, err := json.Marshal(a) @@ -235,27 +236,27 @@ func rawAsk(t *testing.T, a oc.Ask) json.RawMessage { // (an id other than the one they give, no option), then carried as JSON. func encodeGate(t *testing.T, ga gateAsked) []byte { t.Helper() - key, err := delivery.MergeAskKey(ga.Group, ga.Heads) + key, err := meshdeliveryclient.MergeAskKey(ga.Group, ga.Heads) if err != nil { t.Fatal(err) } - if want := delivery.AskingID(key, ga.Asking); ga.Ask.ID != want || len(ga.Ask.Options) == 0 { + if want := meshdeliveryclient.AskingID(key, ga.Asking); ga.Ask.ID != want || len(ga.Ask.Options) == 0 { t.Fatalf("the ask's id %q, want %q, with %d options", ga.Ask.ID, want, len(ga.Ask.Options)) } - value, err := delivery.EncodeAsk(delivery.Ask{Group: ga.Group, Heads: ga.Heads, Asking: ga.Asking, Ask: rawAsk(t, ga.Ask)}) + value, err := gateValue(ga.Group, ga.Heads, ga.Asking, rawAsk(t, ga.Ask)) if err != nil { t.Fatal(err) } return value } -func twoHeads() []delivery.Head { - return []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} +func twoHeads() []meshdeliveryclient.Head { + return []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} } // warrantFor is the router's warrant for a chosen option of an ask, as the router verified the operator. func warrantFor(a oc.Ask, option string) oc.Warrant { - return oc.Warrant{Ask: a.ID, Asker: delivery.TellerModule, Outcome: occ.OutcomeChosen, Option: option, Label: "Approve", Level: occ.Approve, + return oc.Warrant{Ask: a.ID, Asker: tellerModule, Outcome: occ.OutcomeChosen, Option: option, Label: "Approve", Level: occ.Approve, By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id"}, Channel: "telegram", At: occ.FormatTime(now.Add(time.Hour)), AskDigest: occ.Digest(a)} } @@ -263,7 +264,7 @@ func warrantFor(a oc.Ask, option string) oc.Warrant { func (b *bed) deliver(t *testing.T, w oc.Warrant) { t.Helper() raw, _ := json.Marshal(w) - if err := b.hear(stdio.Envelope{Key: oc.Seat + ".decided." + delivery.TellerModule, Body: raw}); err != nil { + if err := b.hear(stdio.Envelope{Key: oc.Seat + ".decided." + tellerModule, Body: raw}); err != nil { t.Fatal(err) } } @@ -293,30 +294,30 @@ func TestAnAsksKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1 key, value, ga := gateAsk(t, twoHeads(), twoHeads()) b.gate[key] = value b.t.OnAsk(key, 3, value) - asked := b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule)) + asked := b.askedOf(oc.AcceptSubjectAsk(tellerModule)) if len(asked) != 1 || asked[0].id != ga.Ask.ID { - t.Fatalf("published %+v, want one ask under %s with id %s", b.pubs, delivery.TellerModule, ga.Ask.ID) + t.Fatalf("published %+v, want one ask under %s with id %s", b.pubs, tellerModule, ga.Ask.ID) } var got oc.Ask if err := json.Unmarshal(asked[0].body, &got); err != nil { t.Fatal(err) } - if occ.Digest(got) != occ.Digest(ga.Ask) || got.Headline != ga.Ask.Headline || got.Explanation != ga.Ask.Explanation || got.ID != delivery.AskID(key) { + if occ.Digest(got) != occ.Digest(ga.Ask) || got.Headline != ga.Ask.Headline || got.Explanation != ga.Ask.Explanation || got.ID != meshdeliveryclient.AskID(key) { t.Errorf("the router's ask differs from the gate's: %+v", got) } b.t.OnAsk(key, 3, value) // the revision seen again, as at a restart b.t.OnAsk(key, 4, value) // the key written again with the same ask - if n := len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule))); n != 1 { + if n := len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))); n != 1 { t.Errorf("the ask was published %d times", n) } delete(b.gate, key) b.t.OnAskGone(key) - cancels := b.askedOf(oc.AcceptSubjectCancel(delivery.TellerModule)) + cancels := b.askedOf(oc.AcceptSubjectCancel(tellerModule)) if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+ga.Ask.ID+`"}` { t.Fatalf("cancels = %+v", cancels) } b.t.OnAskGone(key) - if n := len(b.askedOf(oc.AcceptSubjectCancel(delivery.TellerModule))); n != 1 { + if n := len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))); n != 1 { t.Errorf("the ask was cancelled %d times", n) } // Issue 774 (2026-10-31): two merge asks of pull requests merged by another path held two of the router's three places, @@ -337,15 +338,15 @@ func TestAnAsksKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1 _, fourth := b.askOf(t, 4, 4) b.refuse(t, fourth.Ask.ID, roomRefusal) r := b.t.EndAsk(c.by(k1, first), "its pull request merged without the Approve", "lead@shanks") - cancels := b.askedOf(oc.AcceptSubjectCancel(delivery.TellerModule)) + cancels := b.askedOf(oc.AcceptSubjectCancel(tellerModule)) if !r.Ended || r.ID != first.Ask.ID || r.Key != k1 || len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+first.Ask.ID+`"}` { t.Fatalf("end = %+v, cancels %+v", r, cancels) } - b.deliver(t, oc.Warrant{Ask: first.Ask.ID, Asker: delivery.TellerModule, Outcome: occ.OutcomeCancelled, At: occ.FormatTime(now), AskDigest: occ.Digest(first.Ask)}) + b.deliver(t, oc.Warrant{Ask: first.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeCancelled, At: occ.FormatTime(now), AskDigest: occ.Digest(first.Ask)}) if got := b.askedIDs(); len(got) != 5 || got[4] != fourth.Ask.ID { t.Errorf("asks = %v, want the refused %s asked again once the ask ended by hand left", got, fourth.Ask.ID) } - if again := b.t.EndAsk(k1, "again", "lead@shanks"); again.Ended || len(b.askedOf(oc.AcceptSubjectCancel(delivery.TellerModule))) != 1 { + if again := b.t.EndAsk(k1, "again", "lead@shanks"); again.Ended || len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))) != 1 { t.Errorf("a second end = %+v: an ended ask is left", again) } }) @@ -357,7 +358,7 @@ func TestAnAsksKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1 b.askOf(t, 3, 3) _, fourth := b.askOf(t, 4, 4) b.refuse(t, fourth.Ask.ID, roomRefusal) - if r := b.t.EndAsk(fourth.Ask.ID, "asked about what no longer holds", "lead@shanks"); !r.Ended || len(b.askedOf(oc.AcceptSubjectCancel(delivery.TellerModule))) != 0 { + if r := b.t.EndAsk(fourth.Ask.ID, "asked about what no longer holds", "lead@shanks"); !r.Ended || len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))) != 0 { t.Fatalf("end of a waiting ask = %+v", r) } b.deliver(t, warrantFor(first.Ask, "approve")) @@ -377,11 +378,11 @@ func TestAWarrantThatHoldsWritesOneAnswerAndASecondWritesNone(t *testing.T) { // b.t.OnAsk(key, 1, value) w := warrantFor(ga.Ask, "approve") b.deliver(t, w) - raw, ok := b.answers.written[delivery.AnswerKey(key)] + raw, ok := b.answers.written[answerKey(key)] if !ok || len(b.answers.written) != 1 { t.Fatalf("answers = %v", b.answers.written) } - a, err := delivery.DecodeAnswer(raw) + a, err := meshdeliveryclient.DecodeAnswer(raw) if err != nil || a.Option != "approve" || a.Warrant != warrantID(w) || !strings.Contains(a.Words, "chose Approve") { t.Errorf("answer %+v err %v", a, err) } @@ -398,19 +399,19 @@ func TestAWarrantThatHoldsWritesOneAnswerAndASecondWritesNone(t *testing.T) { // // 1280 (ADR 0556 clause 5): a settle ask holds when its options bind mesh-delivery.settle, and not when they bind a merge. for verb, holds := range map[string]bool{"mesh-delivery.settle": true, "mesh-delivery.merge": false} { b := newBed(t) - group := delivery.SettleGroup([]string{"novox/mesh-delivery@" + headA[:12]}) + group := meshdeliveryclient.SettleGroup([]string{"novox/mesh-delivery@" + headA[:12]}) key, value, ga := gateAskOf(t, group, verb, twoHeads(), twoHeads(), 1) b.gate[key] = value b.t.OnAsk(key, 1, value) b.deliver(t, warrantFor(ga.Ask, "approve")) - if _, written := b.answers.written[delivery.AnswerKey(key)]; written != holds { + if _, written := b.answers.written[answerKey(key)]; written != holds { t.Errorf("a settle ask binding %s: answer written %v, want %v", verb, written, holds) } } } func TestAWarrantThatDoesNotHoldWritesNoAnswerAndRaisesTheCondition(t *testing.T) { // 1343/AC3 - other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} + other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} cases := map[string]func(t *testing.T) (*bed, string, oc.Warrant){ "heads that differ from the ones the option bound": func(t *testing.T) (*bed, string, oc.Warrant) { b := newBed(t) @@ -455,7 +456,7 @@ func TestAWarrantThatDoesNotHoldWritesNoAnswerAndRaisesTheCondition(t *testing.T func TestAGoodAnswerClearsTheConditionOfAnAskAskedAgain(t *testing.T) { // 1343/AC3 (cleared when a good answer is written) b := newBed(t) - other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} + other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}} key, bad, gaBad := gateAsk(t, twoHeads(), other) b.gate[key] = bad b.t.OnAsk(key, 1, bad) @@ -467,11 +468,11 @@ func TestAGoodAnswerClearsTheConditionOfAnAskAskedAgain(t *testing.T) { // 1343/ _, good, gaGood := gateAskN(t, twoHeads(), twoHeads(), 2) b.gate[key] = good b.t.OnAsk(key, 2, good) - if n := len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule))); n != 2 { + if n := len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))); n != 2 { t.Fatalf("the ask was published %d times, want again at the new revision", n) } b.deliver(t, warrantFor(gaGood.Ask, "approve")) - if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok { + if _, ok := b.answers.written[answerKey(key)]; !ok { t.Fatalf("no answer: %v\n%s", b.answers.written, b.logs.String()) } if len(b.conds) != 2 || b.conds[1].State != occ.StateCleared || b.conds[1].Key != b.conds[0].Key { @@ -487,7 +488,7 @@ func TestAWarrantComingWhileTheTellerWasAwayIsReadFromTheRouter(t *testing.T) { w, _ := json.Marshal(warrantFor(ga.Ask, "approve")) b.records[ga.Ask.ID] = `{"state":"chosen","warrant":` + string(w) + `}` b.t.OnAsk(key, 1, value) // the teller starts again and reads the key at the same revision - if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok { + if _, ok := b.answers.written[answerKey(key)]; !ok { t.Errorf("no answer: %s", b.logs.String()) } } @@ -503,11 +504,11 @@ func TestARewriteWithAskingTwoCancelsAskingOneAndAsksUnderTheNewID(t *testing.T) } b.gate[key] = two b.t.OnAsk(key, 2, two) - cancels := b.askedOf(oc.AcceptSubjectCancel(delivery.TellerModule)) + cancels := b.askedOf(oc.AcceptSubjectCancel(tellerModule)) if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+g1.Ask.ID+`"}` { t.Fatalf("cancels = %+v, want asking 1's router ask", cancels) } - asked := b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule)) + asked := b.askedOf(oc.AcceptSubjectAsk(tellerModule)) if len(asked) != 2 || asked[1].id != g2.Ask.ID { t.Fatalf("asks = %+v, want the second under %s", asked, g2.Ask.ID) } @@ -517,14 +518,14 @@ func TestARewriteWithAskingTwoCancelsAskingOneAndAsksUnderTheNewID(t *testing.T) // The same revision again, and a warrant for the first asking (the gate has moved on), change nothing. b.t.OnAsk(key, 2, two) b.deliver(t, warrantFor(g1.Ask, "approve")) - if len(b.answers.written) != 0 || len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule))) != 2 { - t.Errorf("answers %v asks %d", b.answers.written, len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule)))) + if len(b.answers.written) != 0 || len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))) != 2 { + t.Errorf("answers %v asks %d", b.answers.written, len(b.askedOf(oc.AcceptSubjectAsk(tellerModule)))) } // The warrant of the second names it: its answer's warrant is the router id and the time. w := warrantFor(g2.Ask, "approve") b.deliver(t, w) - raw, ok := b.answers.written[delivery.AnswerKey(key)] - a, err := delivery.DecodeAnswer(raw) + raw, ok := b.answers.written[answerKey(key)] + a, err := meshdeliveryclient.DecodeAnswer(raw) if !ok || err != nil || a.Warrant != warrantID(w) { t.Errorf("answer %+v (%v) err %v", a, ok, err) } @@ -536,27 +537,27 @@ func TestAnAskWhoseWordsChangedUnderTheSameIDIsCancelledAndNotAskedAgain(t *test b.t.OnAsk(key, 1, value) changed := ga changed.Ask.Explanation = "Merges two pull requests, now with a third point.\nReview: approved." - v2, err := delivery.EncodeAsk(delivery.Ask{Group: changed.Group, Heads: changed.Heads, Asking: changed.Asking, Ask: rawAsk(t, changed.Ask)}) + v2, err := gateValue(changed.Group, changed.Heads, changed.Asking, rawAsk(t, changed.Ask)) if err != nil { t.Fatal(err) } b.t.OnAsk(key, 2, v2) - if n := len(b.askedOf(oc.AcceptSubjectCancel(delivery.TellerModule))); n != 1 { + if n := len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))); n != 1 { t.Errorf("cancels = %d", n) } - if n := len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule))); n != 1 || len(b.dropped) != 0 { + if n := len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))); n != 1 || len(b.dropped) != 0 { t.Errorf("asks = %d dropped %v: the router would drop a second ask under the id", n, b.dropped) } } func TestACapitalisedRepositoryIsSpelledInLowerCaseInTheAct(t *testing.T) { // T1 of review round 1 b := newBed(t) - heads := []delivery.Head{{Repository: "novox/Mesh-Delivery", Number: 12, Head: headA}} - key, err := delivery.MergeAskKey("g", heads) + heads := []meshdeliveryclient.Head{{Repository: "novox/Mesh-Delivery", Number: 12, Head: headA}} + key, err := meshdeliveryclient.MergeAskKey("g", heads) if err != nil { t.Fatal(err) } - id := delivery.AskID(key) + id := meshdeliveryclient.AskID(key) // The act as the gate binds it: its IDOf lowercases the repository (mesh-catalog modules/mesh-delivery-gate model.go). binds, err := occ.ActDigest(occ.Act{"verb": "mesh-delivery.merge", "ask": id, "option": "approve", "heads": "novox/mesh-delivery@" + headA[:12] + "=" + headA}) if err != nil { @@ -568,7 +569,7 @@ func TestACapitalisedRepositoryIsSpelledInLowerCaseInTheAct(t *testing.T) { // T b.gate[key] = value b.t.OnAsk(key, 1, value) b.deliver(t, warrantFor(a, "approve")) - if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok { + if _, ok := b.answers.written[answerKey(key)]; !ok { t.Errorf("no answer for the gate's spelling: %s", b.logs.String()) } } @@ -587,7 +588,7 @@ func TestARefusedAskIsSaidAsACondition(t *testing.T) { // ADR 0437 §9 b := newBed(t) key, value, ga := gateAsk(t, twoHeads(), twoHeads()) b.t.OnAsk(key, 1, value) - w := oc.Warrant{Ask: ga.Ask.ID, Asker: delivery.TellerModule, Outcome: occ.OutcomeRefused, Words: "no channel can send", At: occ.FormatTime(now)} + w := oc.Warrant{Ask: ga.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeRefused, Words: "no channel can send", At: occ.FormatTime(now)} b.deliver(t, w) if len(b.conds) != 1 || b.conds[0].State != occ.StateRaised || !strings.Contains(b.conds[0].Summary, "no channel can send") { t.Errorf("conditions = %+v", b.conds) @@ -600,22 +601,22 @@ func TestAnAnswerToAnEarlierAskingDoesNotBlockTheNextAsking(t *testing.T) { // R b.gate[key] = one b.t.OnAsk(key, 1, one) b.deliver(t, warrantFor(g1.Ask, "approve")) - first, ok := b.answers.written[delivery.AnswerKey(key)] - if a, err := delivery.DecodeAnswer(first); !ok || err != nil || !strings.HasPrefix(a.Warrant, g1.Ask.ID+"@") { + first, ok := b.answers.written[answerKey(key)] + if a, err := meshdeliveryclient.DecodeAnswer(first); !ok || err != nil || !strings.HasPrefix(a.Warrant, g1.Ask.ID+"@") { t.Fatalf("asking 1's answer: %+v %v %v", a, ok, err) } // The gate rewrites the key at asking 2 (a review record changed). Asking 1's answer stays in the bucket. _, two, g2 := gateAskN(t, twoHeads(), twoHeads(), 2) b.gate[key] = two b.t.OnAsk(key, 2, two) - asked := b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule)) + asked := b.askedOf(oc.AcceptSubjectAsk(tellerModule)) if len(asked) != 2 || asked[1].id != g2.Ask.ID { t.Fatalf("asks = %+v, want asking 2 asked under %s", asked, g2.Ask.ID) } w := warrantFor(g2.Ask, "approve") b.deliver(t, w) - raw := b.answers.written[delivery.AnswerKey(key)] - a, err := delivery.DecodeAnswer(raw) + raw := b.answers.written[answerKey(key)] + a, err := meshdeliveryclient.DecodeAnswer(raw) if err != nil || a.Warrant != warrantID(w) { t.Fatalf("the key holds %+v (%v), want asking 2's answer", a, err) } @@ -625,8 +626,8 @@ func TestAnAnswerToAnEarlierAskingDoesNotBlockTheNextAsking(t *testing.T) { // R b.deliver(t, w2) b.t.OnAsk(key, 2, two) b.t.OnAsk(key, 3, two) - if b.answers.writes != 2 || len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule))) != 2 || len(b.dropped) != 0 { - t.Errorf("writes %d asks %d dropped %v", b.answers.writes, len(b.askedOf(oc.AcceptSubjectAsk(delivery.TellerModule))), b.dropped) + if b.answers.writes != 2 || len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))) != 2 || len(b.dropped) != 0 { + t.Errorf("writes %d asks %d dropped %v", b.answers.writes, len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))), b.dropped) } } @@ -634,3 +635,16 @@ func TestAnAnswerToAnEarlierAskingDoesNotBlockTheNextAsking(t *testing.T) { // R type refusingBus struct{ err error } func (r refusingBus) KeyValue(context.Context, string) (jetstream.KeyValue, error) { return nil, r.err } + +// gateValue is the gate's ask value for the group, heads and asking, carrying the router's ask as its JSON. +func gateValue(group string, heads []meshdeliveryclient.Head, asking int, ask json.RawMessage) ([]byte, error) { + var asked any + if err := json.Unmarshal(ask, &asked); err != nil { + return nil, err + } + var items []meshdelivery.AsksByMergeGroupHeadsValueHeadsItem + for _, h := range heads { + items = append(items, meshdelivery.AsksByMergeGroupHeadsValueHeadsItem{Repository: h.Repository, Number: int64(h.Number), Head: h.Head}) + } + return meshdeliveryclient.EncodeAsk(meshdelivery.AsksByMergeGroupHeadsValue{Group: group, Heads: items, Asking: int64(asking), Ask: asked}) +} diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/viewanswers.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/viewanswers.go index 293055e7..9134f65f 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/viewanswers.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/viewanswers.go @@ -5,7 +5,9 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" ) // The view's answers (novox/hq ADR 0528 clause 6, ADR 0437 §10, issue 1297 B10). The operator may answer, in the view, the @@ -19,7 +21,7 @@ import ( func (t *Teller) OnViewAnswer(key string) { t.mu.Lock() defer t.mu.Unlock() - askKey, err := delivery.ParseViewAnswerKey(key) + askKey, err := viewstate.ParseViewAnswerKey(key) if err != nil { t.logf("the view's key %s is left: %v", key, err) return @@ -32,7 +34,7 @@ func (t *Teller) OnViewAnswer(key string) { func (t *Teller) OnAssignerAsk(key string) { t.mu.Lock() defer t.mu.Unlock() - if _, err := delivery.ViewAnswerKey(key); err != nil { + if _, err := viewstate.ViewAnswerKey(key); err != nil { t.logf("the assigner's key %s is left and never answered from the view: %v", key, err) return } @@ -49,7 +51,7 @@ func (t *Teller) fillFromView(askKey string) { if t.Assigner == nil || t.View == nil { return } - viewKey, err := delivery.ViewAnswerKey(askKey) // refuses an ask that needs proof, naming the phone + viewKey, err := viewstate.ViewAnswerKey(askKey) // refuses an ask that needs proof, naming the phone if err != nil { t.logf("%s is not answered from the view: %v", askKey, err) return @@ -70,7 +72,7 @@ func (t *Teller) fillFromView(askKey string) { if !found { return // the answer waits for its ask: OnAssignerAsk fills it when it comes } - va, err := delivery.DecodeViewAnswer(rawAnswer) + va, err := viewstate.DecodeViewAnswer(rawAnswer) if err != nil { t.logf("%s: the view's answer is not readable and fills nothing: %v", viewKey, err) return @@ -80,7 +82,7 @@ func (t *Teller) fillFromView(askKey string) { t.logf("%s: the assigner's ask is not readable and takes no answer: %v", askKey, err) return } - if err := delivery.ViewAnswerFits(va, askKey, delivery.AssignerAsk{Asking: ask.Asking}); err != nil { + if err := viewstate.ViewAnswerFits(va, askKey, asks.Ask{Asking: ask.Asking}); err != nil { t.logf("%s: nothing is written: %v", askKey, err) return } @@ -95,7 +97,7 @@ func (t *Teller) fillFromView(askKey string) { t.logf("%s: nothing is written: the view's answer chose %q, an option of level %s, which only a channel that proves who answered takes", askKey, va.Option, o.Level) return } - akey := delivery.AnswerKey(askKey) + akey := answerKey(askKey) cur, crev, has, err := t.Answers.Get(akey) if err != nil { t.logf("%s: the answer could not be read: %v", askKey, err) @@ -108,7 +110,7 @@ func (t *Teller) fillFromView(askKey string) { if w := strings.TrimSpace(va.Words); w != "" { words += ": " + w } - body, err := delivery.EncodeAnswer(delivery.Answer{Option: va.Option, Warrant: ask.Ask.ID + "@" + at.UTC().Format(time.RFC3339Nano), Words: words}) + body, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: va.Option, Warrant: ask.Ask.ID + "@" + at.UTC().Format(time.RFC3339Nano), Words: words}) if err != nil { t.logf("%s: the answer could not be made: %v", askKey, err) return @@ -148,12 +150,12 @@ func (t *Teller) viewAnswer(key string) ([]byte, time.Time, bool, error) { // changesMind says whether the view's answer va, written at at, replaces the answer cur of the same asking of the hold ask at // askKey: it chose another option and was written after cur's warrant. -func (t *Teller) changesMind(askKey string, cur []byte, va delivery.ViewAnswer, at time.Time) bool { - k, err := delivery.ParseAnyAskKey(askKey) - if err != nil || k.Kind != delivery.AskKindHold { +func (t *Teller) changesMind(askKey string, cur []byte, va viewstate.ViewAnswer, at time.Time) bool { + k, err := asks.ParseAnyAskKey(askKey) + if err != nil || k.Kind != asks.AskKindHold { return false } - a, err := delivery.DecodeAnswer(cur) + a, err := meshdeliveryclient.DecodeAnswer(cur) if err != nil || a.Option == va.Option { return false } diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/viewanswers_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/viewanswers_test.go index 726ff215..c2b9b126 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/viewanswers_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/viewanswers_test.go @@ -7,7 +7,9 @@ import ( "testing" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" ) const fpHeld = "src2:2222222222222222222222222222222222222222222222222222222222222222" @@ -17,14 +19,14 @@ const fpHeld = "src2:22222222222222222222222222222222222222222222222222222222222 // takes it; the value is `{"v":1,"ask":}`). func holdAsk(t *testing.T) (key string, value []byte, ask oc.Ask) { t.Helper() - key, err := delivery.HoldAskKey("plex", fpHeld) + key, err := asks.HoldAskKey("plex", fpHeld) if err != nil { t.Fatal(err) } - ask = oc.Ask{ID: delivery.AskID(key), Headline: "plex is held after a failed health check", Explanation: "ace failed the check.", Who: occ.Operator, + ask = oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: "plex is held after a failed health check", Explanation: "ace failed the check.", Who: occ.Operator, Expires: occ.FormatTime(now.Add(occ.ApproveLasts)), OnExpiry: "the fingerprint stays held", Options: []oc.Option{{ID: "keep", Label: "Keep it held", Does: "keeps it held", Level: occ.Acknowledge}, {ID: "release", Label: "Release it", Does: "lets the move go on", Level: occ.Acknowledge}}} - value, err = delivery.EncodeAssignerAsk(key, delivery.AssignerAsk{Ask: rawAsk(t, ask)}) + value, err = asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, ask)}) if err != nil { t.Fatal(err) } @@ -32,13 +34,13 @@ func holdAsk(t *testing.T) (key string, value []byte, ask oc.Ask) { } func holdKey() string { - k, _ := delivery.HoldAskKey("plex", fpHeld) + k, _ := asks.HoldAskKey("plex", fpHeld) return k } func viewAnswer(t *testing.T, option, words string) []byte { t.Helper() - v, err := delivery.EncodeViewAnswer(delivery.ViewAnswer{Asking: delivery.AskID(holdKey()), Option: option, Words: words}) + v, err := viewstate.EncodeViewAnswer(viewstate.ViewAnswer{Asking: meshdeliveryclient.AskID(holdKey()), Option: option, Words: words}) if err != nil { t.Fatal(err) } @@ -63,7 +65,7 @@ func TestAViewAnswerFillsTheAskOnce(t *testing.T) { // 1297/B10 AC if !ok { t.Fatalf("no answer under %s; log:\n%s", key, b.logs.String()) } - a, err := delivery.DecodeAnswer(raw) + a, err := meshdeliveryclient.DecodeAnswer(raw) if err != nil || a.Option != "release" || answeredAsking(raw) != ask.ID || !strings.Contains(a.Words, "view") || !strings.Contains(a.Words, "the check was a flake") { t.Errorf("answer = %+v, %v", a, err) } @@ -86,7 +88,7 @@ func TestAViewAnswerHeardBeforeItsAskFillsItWhenTheAskComes(t *testing.T) { // 1 } assigner[key] = value b.t.OnAssignerAsk(key) - if a, err := delivery.DecodeAnswer(b.answers.written[key]); err != nil || a.Option != "keep" { + if a, err := meshdeliveryclient.DecodeAnswer(b.answers.written[key]); err != nil || a.Option != "keep" { t.Errorf("answer = %+v, %v", a, err) } } @@ -129,7 +131,7 @@ func TestAViewAnswerNeverReplacesTheAnswerOfTheSameAsking(t *testing.T) { // 129 key, value, ask := holdAsk(t) assigner[key] = value view[key+".view"] = viewAnswer(t, "keep", "") - early, err := delivery.EncodeAnswer(delivery.Answer{Option: "release", Warrant: ask.ID + "@2026-10-20T09:00:00Z", Words: "answered elsewhere"}) + early, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "release", Warrant: ask.ID + "@2026-10-20T09:00:00Z", Words: "answered elsewhere"}) if err != nil { t.Fatal(err) } @@ -139,7 +141,7 @@ func TestAViewAnswerNeverReplacesTheAnswerOfTheSameAsking(t *testing.T) { // 129 t.Errorf("the answer of the same asking was replaced") } // An earlier asking's answer (another router id) does not stop this one. - older, _ := delivery.EncodeAnswer(delivery.Answer{Option: "release", Warrant: "an-older-id@2026-10-19T09:00:00Z", Words: "old"}) + older, _ := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "release", Warrant: "an-older-id@2026-10-19T09:00:00Z", Words: "old"}) b.answers.written[key], b.answers.revs[key] = older, 8 b.t.OnViewAnswer(key + ".view") if b.answers.writes != 1 || answeredAsking(b.answers.written[key]) != ask.ID { @@ -169,7 +171,7 @@ func TestAStaleViewAnswerOfAnotherAskingFillsNothing(t *testing.T) { // 1297/B10 assigner, view := withView(b) key, value, _ := holdAsk(t) assigner[key] = value - stale, err := delivery.EncodeViewAnswer(delivery.ViewAnswer{Asking: "an-earlier-asking", Option: "release"}) + stale, err := viewstate.EncodeViewAnswer(viewstate.ViewAnswer{Asking: "an-earlier-asking", Option: "release"}) if err != nil { t.Fatal(err) } @@ -203,7 +205,7 @@ func TestALaterViewAnswerChangesAHoldsAnswer(t *testing.T) { b.t.Assigner, b.t.View = assigner, view key, value, ask := holdAsk(t) assigner[key] = value - kept, _ := delivery.EncodeAnswer(delivery.Answer{Option: "keep", Warrant: ask.ID + "@" + now.Add(time.Hour).UTC().Format(time.RFC3339Nano), Words: "the operator, via telegram, chose keep"}) + kept, _ := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "keep", Warrant: ask.ID + "@" + now.Add(time.Hour).UTC().Format(time.RFC3339Nano), Words: "the operator, via telegram, chose keep"}) b.answers.written[key], b.answers.revs[key] = kept, 3 view.memGate[key+".view"] = viewAnswer(t, option, "it was a flake") view.at[key+".view"] = viewAt @@ -212,7 +214,7 @@ func TestALaterViewAnswerChangesAHoldsAnswer(t *testing.T) { } later := now.Add(2 * time.Hour) b, key, ask := setup(later, "release") - a, err := delivery.DecodeAnswer(b.answers.written[key]) + a, err := meshdeliveryclient.DecodeAnswer(b.answers.written[key]) if err != nil || b.answers.writes != 1 || a.Option != "release" || a.Warrant != ask.ID+"@"+later.UTC().Format(time.RFC3339Nano) { t.Fatalf("writes %d, answer %+v, %v; log:\n%s", b.answers.writes, a, err, b.logs.String()) } @@ -226,7 +228,7 @@ func TestALaterViewAnswerChangesAHoldsAnswer(t *testing.T) { } // A restore ask answered stays answered: only a hold's answer may be changed. bb := newBed(t) - if bb.t.changesMind("restore.ace.plex.2222222222222222222222222222222222222222222222222222222222222222", b.answers.written[key], delivery.ViewAnswer{Option: "keep"}, later.Add(time.Hour)) { + if bb.t.changesMind("restore.ace.plex.2222222222222222222222222222222222222222222222222222222222222222", b.answers.written[key], viewstate.ViewAnswer{Option: "keep"}, later.Add(time.Hour)) { t.Error("a restore ask's answer was changed") } } diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index 3df9f4fc..1ac7e76b 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,6 +3,10 @@ module mesh-delivery-teller go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient 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-view/viewstate v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f diff --git a/modules/mesh-delivery-teller/internal/ends/ends.go b/modules/mesh-delivery-teller/internal/ends/ends.go index 6fd80ebf..edeb147c 100644 --- a/modules/mesh-delivery-teller/internal/ends/ends.go +++ b/modules/mesh-delivery-teller/internal/ends/ends.go @@ -14,14 +14,16 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" ) const ( - Tool = "teller_end_ask" + // module is the teller's own name (its manifest's module), the holder of the bucket. + module = "mesh-delivery-teller" + Tool = "teller_end_ask" // State is the teller's state of ends asked by hand: the bucket mesh-delivery-teller_ends. State = "ends" - Bucket = delivery.TellerModule + "_" + State + Bucket = module + "_" + State ) // Result is the teller's answer to an end: which ask it found (its router id and the gate's or the assigner's key it was @@ -64,7 +66,7 @@ func Decode(data []byte) (End, error) { } // New is the value teller_end_ask writes: its key and the value. The key is the router's spelling of the name given -// (delivery.AskID), which a bucket key takes whatever the name holds, and is the same for the same name, so a second end +// (meshdeliveryclient.AskID), which a bucket key takes whatever the name holds, and is the same for the same name, so a second end // of one ask overwrites the first. func New(ask, why, by string, at time.Time) (string, []byte, error) { ask, why = strings.TrimSpace(ask), strings.TrimSpace(why) @@ -75,5 +77,5 @@ func New(ask, why, by string, at time.Time) (string, []byte, error) { return "", nil, errors.New("say why the ask is ended (why), in plain words: it is logged with who ended it") } data, err := json.Marshal(End{V: 1, Ask: ask, Why: why, By: by, At: at.UTC()}) - return delivery.AskID(ask), data, err + return meshdeliveryclient.AskID(ask), data, err }