From 54a9159b25161e9ad40026c03fed509fce16ab43 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:30:36 +0100 Subject: [PATCH 01/18] EE-D1: the grants part computes nothing from the waits list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool runner's manifest no longer names the event families it may wait on (mesh-sdk EE-S2), so the grants part's copy of that list goes whole: its subjects, its membership field and its record reading. Builds: design 34 every-event §5, §7 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/buscompose/membership.go | 1 - .../mesh-grants/internal/buscompose/nats.go | 8 -- .../internal/buscompose/sdk_membership.go | 1 - .../mesh-grants/internal/buscompose/users.go | 5 +- .../mesh-grants/internal/buscompose/waits.go | 94 ------------------- .../mesh-grants/internal/grants/records.go | 23 +---- 6 files changed, 2 insertions(+), 130 deletions(-) delete mode 100644 modules/mesh-grants/internal/buscompose/waits.go diff --git a/modules/mesh-grants/internal/buscompose/membership.go b/modules/mesh-grants/internal/buscompose/membership.go index b5c2a8a9..cc6a3ef0 100644 --- a/modules/mesh-grants/internal/buscompose/membership.go +++ b/modules/mesh-grants/internal/buscompose/membership.go @@ -64,7 +64,6 @@ func MembershipFor(node string, d Declared, where Assignments) Membership { m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...) m.Streams = streamsIssuedFor(d) m.Hears = HeardIssuedFor(node, d) - m.Waits = waitsIssuedFor(d) t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches) for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) { if !s.Kinded { diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index febf1457..679a17ad 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -1192,14 +1192,6 @@ func permissionsFor(p Principal) (Permissions, error) { pub = append(pub, logGrants(d.Module, logNames(d.Logs))...) pub = append(pub, readLogGrants(d.ReadLogs)...) } - // **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own, - // named one by one in its manifest, as a plain subscription — no consumer is made for them, and no - // call or setting adds one. - waits, err := waitGrants(p.Carries) - if err != nil { - return Permissions{}, err - } - sub = append(sub, waits...) // **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the // operator's account, which every agent runs as, so a module saying warrants or speaking for a kind // that proves its sender is never composed into it — refused here, naming it, whatever registration diff --git a/modules/mesh-grants/internal/buscompose/sdk_membership.go b/modules/mesh-grants/internal/buscompose/sdk_membership.go index c27b383b..bb07f183 100644 --- a/modules/mesh-grants/internal/buscompose/sdk_membership.go +++ b/modules/mesh-grants/internal/buscompose/sdk_membership.go @@ -20,7 +20,6 @@ type ( KindHeld = membership.KindHeld StreamIssued = membership.StreamIssued HeardIssued = membership.HeardIssued - WaitIssued = membership.WaitIssued ) // AssignmentsStream holds every assignment's membership, the newest per subject. diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 5bbff318..249bfc79 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -56,9 +56,6 @@ type Declared struct { // CatchUps are the module's own tools that bring a kept data forward after a restore of it, as its manifest's kept data // name them (design 47 G §406, §631, A-108): the node-engine on a node that places the module is granted to call each. CatchUps []string - // Waits are the event families a session may wait on through the tool runner, the tool runner's module's - // alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer. - Waits []Wait // State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201). State []Bucket // Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297). @@ -599,7 +596,7 @@ func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && // stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not // the test: a module with tools and its own credential is still carried (ADR 0175). func runnerServes(d Declared) bool { - return d.ToolRunner || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0 + return d.ToolRunner || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Checks) > 0 } // declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name diff --git a/modules/mesh-grants/internal/buscompose/waits.go b/modules/mesh-grants/internal/buscompose/waits.go deleted file mode 100644 index 1fb8616b..00000000 --- a/modules/mesh-grants/internal/buscompose/waits.go +++ /dev/null @@ -1,94 +0,0 @@ -package buscompose - -import ( - "fmt" - "strings" -) - -// What a session may wait on through the mesh MCP server (novox/hq ADR 0367). -// -// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its -// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain -// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its -// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one -// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest -// adds a family (§6). - -// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the -// emitter — a seat or a module — and the event, as the inventory resolved them. -type Wait struct { - // Name is the family as the manifest names it, `.`: what a session asks for. - Name string - Emitter string - // Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121). - Seat bool - // Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a - // seat that names it last (ADR 0259 §3). - Event string - // Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming - // its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited - // on with it given, and refused without. - Last string -} - -// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a -// wildcard, a dot inside a name or an empty token would widen the grant. -func WaitSubject(w Wait) (string, error) { - if !safeSubject.MatchString(w.Emitter) { - return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter) - } - for _, t := range strings.Split(w.Event, ".") { - if !safeSubject.MatchString(t) { - return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+ - "(novox/hq ADR 0367 §2)", w.Event) - } - } - if w.Last != "" && !strings.Contains(w.Event, ".") { - return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+ - "without it no event would ever be heard (novox/hq ADR 0367 §5)", - w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last) - } - if w.Seat { - return "mesh.seat." + w.Emitter + ".event." + w.Event, nil - } - return "mesh.mod." + w.Emitter + ".event." + w.Event, nil -} - -// waitGrants is the subscription the tool runner's account is granted for its module's families, and an -// error for a carried module other than the tool runner's that declares any (ADR 0367 §2). -func waitGrants(carries []Declared) ([]string, error) { - var sub []string - for _, d := range carries { - if len(d.Waits) == 0 { - continue - } - if !d.ToolRunner { - return nil, fmt.Errorf("%s declares what a session waits on; only the tool runner's module (its manifest says "+ - "tool-runner) does (novox/hq ADR 0367 §2)", d.Module) - } - for _, w := range d.Waits { - s, err := WaitSubject(w) - if err != nil { - return nil, err - } - sub = append(sub, s) - } - } - return sub, nil -} - -// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's. -func waitsIssuedFor(d Declared) []WaitIssued { - if !d.ToolRunner { - return nil - } - var out []WaitIssued - for _, w := range d.Waits { - s, err := WaitSubject(w) - if err != nil { - continue // refused where the account is composed, which names it - } - out = append(out, WaitIssued{Event: w.Name, Subject: s}) - } - return out -} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 133dc6b4..724d063b 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -146,7 +146,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio fromModules, watches := consumedOf(m, seats, declarers) d := buscompose.Declared{ Module: m.Module, ToolRunner: catalogue.IsToolRunner(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools, - Invokes: m.Invokes, Waits: waitsOf(m, seats), State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m), + Invokes: m.Invokes, State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m), Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), ServesOwn: ownServesOf(m), Checks: catalogue.HealthChecks(m), RunsAs: m.RunsAs, Issued: issuedTools(m), // The keys its served values fill, which every node may read where it runs (issue 1126). @@ -306,27 +306,6 @@ func loadsABundle(m catalogue.Manifest) bool { return false } -func waitsOf(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) []buscompose.Wait { - var out []buscompose.Wait - for _, w := range m.Waits { - emitter, event, _ := strings.Cut(w, ".") - first, _, _ := strings.Cut(event, ".") - wait := buscompose.Wait{Name: w, Emitter: emitter, Event: event} - if s, isASeat := seats[emitter]; isASeat { - switch { - case s.Kinded && catalogue.SeatSays(s.Emits, first): - wait.Seat, wait.Last = true, "kind" - case s.NamedByCaller(first) && catalogue.SeatSays(s.Emits, first): - wait.Seat, wait.Last = true, "asker" - case catalogue.SeatSays(s.Emits, event): - wait.Seat = true - } - } - out = append(out, wait) - } - return out -} - func issuedTools(m catalogue.Manifest) []string { var out []string for _, o := range m.Provides { From 69a0004fe6dfd9c8e7524d1119a16bd533975451 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:32:04 +0100 Subject: [PATCH 02/18] EE-D2: the tool runner's runtime hears every event subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where the runtime's permissions are composed, a runtime carrying the tool runner subscribes mesh-sdk's EveryEvent (every seat's, module's and module stream's events) and nothing else for it, replacing the per-family list. Builds: design 34 every-event §6, §8, §25, §27 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/buscompose/buscompose_test.go | 69 +++++++++++++++++++ .../mesh-grants/internal/buscompose/nats.go | 7 ++ .../mesh-grants/internal/buscompose/users.go | 2 +- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 9a623ad2..cebb8e9a 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -20,6 +20,7 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/announce" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatdef" "github.com/nats-io/nats.go/jetstream" @@ -60,6 +61,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Run("a module's ledger is written by its ledger account alone", aLedgerIsWrittenByItsLedgerAccountAlone) t.Run("the bus module's process alone keeps every module's bus state and serves the broker's verbs", theBusModulesProcessKeepsBusState) t.Run("a process serving a verb is granted its calls and chunk subjects, and the reverse goes to an account naming it", chunkSubjectsFollowTheAnswerer) + t.Run("the tool runner's runtime hears every event subject and nothing wider", theToolRunnerHearsEveryEvent) // ADR 0567 §3, §9: a node reads every key of the seat's assignments and its candidates (it composes with the whole // proposal laid over), and none of its open proposals; it writes neither. reads := strings.Join(NodeComposeReads("laptop"), " ") @@ -1447,3 +1449,70 @@ func chunkSubjectsFollowTheAnswerer(t *testing.T) { t.Errorf("the keeper's restore grant reaches a stream that is not restricted") } } + +// EE-D2 (design 34 every-event §6, §8, §25, §27; ADR 0670 §1, §3, ADR 0671 §1): the runtime carrying the tool runner +// subscribes the three event subjects of mesh-sdk's EveryEvent, beside a module with a restricted stream, and nothing +// wider; it publishes no other module's or seat's event and derives no durable consumer or acknowledgement on that +// stream; a runtime carrying no tool runner gets none of the three. +func theToolRunnerHearsEveryEvent(t *testing.T) { + shop := func() Declared { + return Declared{Module: "shop", Serves: []string{"sell"}, Emits: []string{"sold"}, + Streams: []ModuleStream{{Module: "shop", Name: "LEDGER", Events: []string{"booked"}, Restricted: true}}} + } + r := Records{Nodes: []string{"a", "b"}, Assigned: map[string][]Declared{ + "a": {{Module: "node-tools", ToolRunner: true}, shop()}, + "b": {shop()}}} + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + every := sdkbc.EveryEvent() + if len(every) != 3 { + t.Fatalf("EveryEvent answers %v, want three subjects", every) + } + wider := []string{">", "mesh.>", "mesh.*.>", "mesh.seat.>", "mesh.mod.>", "mesh.stream.>"} + runners := 0 + for _, u := range users { + p, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", u.Username(), err) + } + runs := false + for _, d := range u.Carries { + runs = runs || d.ToolRunner + } + for _, e := range every { + if slices.Contains(p.Subscribe, e) != runs { + t.Errorf("%s (tool runner %v) subscribes %s: %v", u.Username(), runs, e, !runs) + } + } + for _, s := range p.Subscribe { + if slices.Contains(wider, s) { + t.Errorf("%s subscribes %s, wider than every event", u.Username(), s) + } + } + if !runs { + continue + } + runners++ + for _, s := range p.Subscribe { + if strings.HasPrefix(s, "$JS.") && strings.Contains(s, "LEDGER") { + t.Errorf("the tool runner's runtime hears %s on the restricted stream", s) + } + } + for _, s := range []string{"mesh.mod.other.event.x", "mesh.seat.mesh-catalog.event.manifest-accepted", + "mesh.stream.other.event.x"} { + if MayPublish(p, s) { + t.Errorf("the tool runner's runtime may publish another's event %s", s) + } + } + for _, s := range p.Publish { + if strings.Contains(s, "LEDGER") || strings.HasPrefix(s, "$JS.ACK.") { + t.Errorf("the tool runner's runtime may %s on the restricted stream", s) + } + } + } + if runners != 1 { + t.Errorf("%d runtimes carry the tool runner, want 1", runners) + } +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 679a17ad..12fd9c8d 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -1192,6 +1192,13 @@ func permissionsFor(p Principal) (Permissions, error) { pub = append(pub, logGrants(d.Module, logNames(d.Logs))...) pub = append(pub, readLogGrants(d.ReadLogs)...) } + // **And every event subject** (design 34 every-event §6, §25; novox/hq ADR 0670 §1, ADR 0671 §1): the runtime + // carrying the tool runner hears every seat's, module's and module stream's events, a restricted stream + // included, as a plain subscription: no publish, no durable consumer, no acknowledgement and no deny is added + // for it, and a runtime carrying no tool runner gets none of them. + if slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.ToolRunner }) { + sub = append(sub, sdkbc.EveryEvent()...) + } // **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the // operator's account, which every agent runs as, so a module saying warrants or speaking for a kind // that proves its sender is never composed into it — refused here, naming it, whatever registration diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 249bfc79..c861f9d2 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -589,7 +589,7 @@ func Composed(d Declared) bool { return d.Module != EngineModule } func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && runnerServes(d) } // runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1, -// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on, +// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, // a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and // carrying it would hand every session on the machine the runner's account with that module's state, seat // traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it From f913d85e66cf161cda29c662e5cbb4589030824e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:37:08 +0100 Subject: [PATCH 03/18] EE-D3: the real bus holds the tool runner's hearing of every event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grants part's real-bus test gains a case: the tool runner's composed account hears a module's event, a module stream's and a restricted stream's whose grant names no tool runner, still after that grant changes and the accounts are composed again; it publishes none, hears no accept or proof, and gets no consumer or acknowledgement on the restricted stream. Builds: design 34 every-event §9, §27 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-grants/composed_integration_test.go | 220 ++++++++++++++++++ .../cmd/mesh-grants/integration_test.go | 1 + 2 files changed, 221 insertions(+) diff --git a/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go b/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go index 188a48e3..fb8fe2e7 100644 --- a/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go @@ -11,10 +11,12 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "time" "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/seal" @@ -403,3 +405,221 @@ func manifestIn(t *testing.T, module string) catalogue.Manifest { } return m } + +// composedBus is a server loading the accounts composed from records, its conf file rewritten and reloaded by recompose. +type composedBus struct { + t *testing.T + s *server.Server + conf string +} + +func startComposedBus(t *testing.T, records buscompose.Records) *composedBus { + dir := t.TempDir() + b := &composedBus{t: t, conf: filepath.Join(dir, "server.conf")} + b.write(records, dir) + opts, err := server.ProcessConfigFile(b.conf) + if err != nil { + t.Fatalf("the composed list is not the server's: %v", err) + } + opts.NoLog, opts.NoSigs = true, true + if b.s, err = server.NewServer(opts); err != nil { + t.Fatal(err) + } + go b.s.Start() + if !b.s.ReadyForConnections(30 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(b.s.Shutdown) + return b +} + +func (b *composedBus) write(records buscompose.Records, dir string) { + users, err := buscompose.Users(records) + if err != nil { + b.t.Fatal(err) + } + for i := range users { + users[i].PasswordHash = "pw-" + users[i].Username() + } + accounts, err := buscompose.ComposeAccounts(users) + if err != nil { + b.t.Fatal(err) + } + if err := os.WriteFile(b.conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+dir+"\" }\n"+accounts), 0o600); err != nil { + b.t.Fatal(err) + } +} + +// recompose writes the accounts composed from records again and has the server load them. +func (b *composedBus) recompose(records buscompose.Records) { + b.write(records, filepath.Dir(b.conf)) + if err := b.s.Reload(); err != nil { + b.t.Fatalf("the server does not load the composed list again: %v", err) + } +} + +// dial connects as a composed user; the server's refusals of its subscriptions and publishes arrive on refusals. +func (b *composedBus) dial(user string) (*nats.Conn, chan string) { + refusals := make(chan string, 16) + nc, err := nats.Connect(b.s.ClientURL(), nats.UserInfo(user, "pw-"+user), nats.CustomInboxPrefix("_INBOX."+user), + nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err.Error() })) + if err != nil { + b.t.Fatalf("%s: %v", user, err) + } + b.t.Cleanup(nc.Close) + return nc, refusals +} + +// refusedWithin waits, within a bound, for the server's refusal naming subject. +func refusedWithin(refusals chan string, subject string) bool { + bound := time.NewTimer(5 * time.Second) + defer bound.Stop() + for { + select { + case e := <-refusals: + if strings.Contains(e, "Permissions Violation") && strings.Contains(e, subject) { + return true + } + case <-bound.C: + return false + } + } +} + +// theToolRunnerHearsEveryEventOnTheBus, run by TestTheGrantsPartWhole, defends design 34 every-event §9 and §27 on a +// real server (ADR 0670 §1, §3, ADR 0671 §1, §2): the tool runner's account, as composed, hears a module's event, a +// module stream's and a restricted stream's whose grant value names no tool runner, still after that value changes and +// the accounts are composed again; it publishes none of them, hears no accept or proof, and gets no durable consumer +// and no acknowledgement on the restricted stream; another module hears no event it does not consume, and a module +// consuming the restricted stream with no entry in its grant value gets no consumer and no read. +func theToolRunnerHearsEveryEventOnTheBus(t *testing.T) { + const ledger = "mesh.stream.shop.event.booked" + records := func(auditorEntry []string) buscompose.Records { + return buscompose.Records{Nodes: []string{"a"}, Assigned: map[string][]buscompose.Declared{"a": { + {Module: buscompose.GrantsModule}, + {Module: "node-tools", ToolRunner: true}, + {Module: "mesh-delivery-view", Emits: []string{"delivered"}}, + {Module: "plex"}, + {Module: "shop", Streams: []buscompose.ModuleStream{ + {Module: "shop", Name: "SHOP", Events: []string{"opened"}, MaxAgeDays: 1, MaxMiB: 1}, + {Module: "shop", Name: "LEDGER", Events: []string{"booked"}, MaxAgeDays: 1, MaxMiB: 1, Restricted: true}}}, + {Module: "auditor", RunsAs: "auditor", Consumes: []string{"shop.booked"}, + Hears: []buscompose.Heard{{Stream: "LEDGER", Emitter: "shop", Filters: auditorEntry, Restricted: true}}}, + }}} + } + bus := startComposedBus(t, records(nil)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + refusedIn := func() context.Context { + c, stop := context.WithTimeout(ctx, 2*time.Second) + t.Cleanup(stop) + return c + } + grantsConn, _ := bus.dial("a." + buscompose.GrantsModule) + grants, err := jetstream.New(grantsConn) + if err != nil { + t.Fatal(err) + } + for _, st := range []jetstream.StreamConfig{{Name: "SHOP", Subjects: []string{"mesh.stream.shop.event.opened"}}, + {Name: "LEDGER", Subjects: []string{ledger}}} { + if _, err := grants.CreateStream(ctx, st); err != nil { + t.Fatalf("the grants part creates the declared stream %s: %v", st.Name, err) + } + } + runner, runnerRefused := bus.dial("a.node-tools") + heard := make(chan *nats.Msg, 16) + for _, s := range sdkbc.EveryEvent() { + if _, err := runner.ChanSubscribe(s, heard); err != nil { + t.Fatal(err) + } + } + if err := runner.Flush(); err != nil { + t.Fatal(err) + } + hears := func(subject, from string) { + t.Helper() + select { + case m := <-heard: + if m.Subject != subject { + t.Errorf("the tool runner heard %s, want %s published by %s", m.Subject, subject, from) + } + case <-time.After(5 * time.Second): + t.Errorf("the tool runner heard nothing, want %s published by %s", subject, from) + } + } + view, _ := bus.dial("a.mesh-delivery-view") + shop, _ := bus.dial("a.shop") + for _, e := range []struct { + conn *nats.Conn + subject, from string + }{ + {view, "mesh.mod.mesh-delivery-view.event.delivered", "the view"}, + {shop, "mesh.stream.shop.event.opened", "a module's stream"}, + {shop, ledger, "a restricted stream whose grant names no tool runner"}} { + if err := e.conn.Publish(e.subject, []byte(`{}`)); err != nil { + t.Fatal(err) + } + if err := e.conn.Flush(); err != nil { + t.Fatal(err) + } + hears(e.subject, e.from) + } + // It publishes no event, and hears no accept nor proof (they are never events). + if err := runner.Publish("mesh.mod.mesh-delivery-view.event.delivered", []byte(`{}`)); err != nil { + t.Fatal(err) + } + if !refusedWithin(runnerRefused, "mesh.mod.mesh-delivery-view.event.delivered") { + t.Error("the tool runner published the view's event") + } + for _, s := range []string{"mesh.seat.channel.accept.show.k", "mesh.seat.intake.proof.code.k"} { + if _, err := runner.SubscribeSync(s); err != nil { + t.Fatal(err) + } + if !refusedWithin(runnerRefused, s) { + t.Errorf("the tool runner subscribed %s", s) + } + } + // No durable consumer, no acknowledgement on the restricted stream. + runnerJS, err := jetstream.New(runner) + if err != nil { + t.Fatal(err) + } + if _, err := runnerJS.CreateConsumer(refusedIn(), "LEDGER", jetstream.ConsumerConfig{Durable: "node-tools"}); err == nil { + t.Error("the tool runner made a durable consumer on the restricted stream") + } + if err := runner.Publish("$JS.ACK.LEDGER.node-tools.1.1.1.1.0", nil); err != nil { + t.Fatal(err) + } + if !refusedWithin(runnerRefused, "$JS.ACK.LEDGER") { + t.Error("the tool runner acknowledged on the restricted stream") + } + // Another module hears no event it does not consume. + plex, plexRefused := bus.dial("a.plex") + if _, err := plex.SubscribeSync("mesh.mod.mesh-delivery-view.event.delivered"); err != nil { + t.Fatal(err) + } + if !refusedWithin(plexRefused, "mesh.mod.mesh-delivery-view.event.delivered") { + t.Error("plex subscribed an event it does not consume") + } + // A consumer of the restricted stream with no entry in its grant value gets no consumer and no read. + auditor, _ := bus.dial("a.auditor") + auditorJS, err := jetstream.New(auditor) + if err != nil { + t.Fatal(err) + } + if _, err := auditorJS.CreateConsumer(refusedIn(), "LEDGER", jetstream.ConsumerConfig{Durable: "auditor"}); err == nil { + t.Error("a consumer with no entry made a durable consumer on the restricted stream") + } + if _, err := auditorJS.Stream(refusedIn(), "LEDGER"); err == nil { + t.Error("a consumer with no entry reads the restricted stream") + } + // The grant value changes and the accounts are composed again: the tool runner still hears the restricted stream. + bus.recompose(records([]string{ledger})) + if err := shop.Publish(ledger, []byte(`{}`)); err != nil { + t.Fatal(err) + } + if err := shop.Flush(); err != nil { + t.Fatal(err) + } + hears(ledger, "the restricted stream after its grant value changed") +} diff --git a/modules/mesh-grants/cmd/mesh-grants/integration_test.go b/modules/mesh-grants/cmd/mesh-grants/integration_test.go index 2d4e9f82..4b5fc605 100644 --- a/modules/mesh-grants/cmd/mesh-grants/integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/integration_test.go @@ -627,6 +627,7 @@ func TestTheGrantsPartWhole(t *testing.T) { // And what the part composes holds on a server that loads it (one integration test per module, the test budget). t.Run("the composed grants hold on a server that loads them", theBusNodeFollowsTheSealedListUnderTheComposedGrants) + t.Run("the tool runner hears every event on a server that loads its grants", theToolRunnerHearsEveryEventOnTheBus) } // lockedLog is the part's log, read by the test while the part writes it. From 7ce80ce5903d0bdb9aa1a2b380da56eec01c9078 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:38:52 +0100 Subject: [PATCH 04/18] EE-D4: the validator has no rule about the waits list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest no longer carries what a session waits on (mesh-sdk EE-S2), so the rule judging it and its token form go; a manifest still carrying waits is a field the validator does not know, refused by Shelf and set aside by ShelfWaiting, and the tool runner's manifest without it is accepted. Builds: design 34 every-event §10 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-delivery-indexer/judge/rules.go | 40 ------------------- .../mesh-delivery-indexer/judge/rules_test.go | 39 +++++++----------- 2 files changed, 15 insertions(+), 64 deletions(-) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 5e32d375..affc57b8 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -2938,7 +2938,6 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M } } problems = append(problems, invokeProblems(m)...) - problems = append(problems, waitProblems(m)...) problems = append(problems, replacesProblems(m)...) problems = append(problems, UnitSettingProblems(m)...) problems = append(problems, OwnerSettingProblems(m)...) @@ -3390,43 +3389,6 @@ func replacesProblems(m catalogue.Manifest) []string { return problems } -// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6): -// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused -// here, in the manifest's words, rather than at the composition of the bus's users. -func waitProblems(m catalogue.Manifest) []string { - if len(m.Waits) == 0 { - return nil - } - if !catalogue.IsToolRunner(m) { - return []string{fmt.Sprintf("%s says what a session waits on; only the tool runner's module (it says "+ - "\"tool-runner\": true) declares waits (novox/hq ADR 0367 §2)", m.Module)} - } - var problems []string - seen := map[string]bool{} - for _, w := range m.Waits { - if strings.ContainsAny(w, "*>") { - problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+ - "wildcard (novox/hq ADR 0367 §2)", m.Module, w)) - continue - } - tokens := strings.Split(w, ".") - whole := len(tokens) >= 2 && name.MatchString(tokens[0]) - for _, t := range tokens[1:] { - whole = whole && waitToken.MatchString(t) - } - if !whole { - problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+ - "event: a family is ., as consumes names one", m.Module, w)) - continue - } - if seen[w] { - problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w)) - } - seen[w] = true - } - return problems -} - // whileStoppedProblems judges one container's maintenance window against its own definition // (novox/hq ADR 0189). // @@ -3646,8 +3608,6 @@ var renamed = map[string]string{ var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) -var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) - var engineGeneric = map[string]string{ "database": "postgres-database, mssql-database, mariadb-database", "db": "postgres-database, mssql-database, mariadb-database", diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 92b0d9fb..15b0f1c6 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3156,29 +3156,20 @@ func caseAVerbWithAnUnknownKeyIsRefused(t *testing.T) { } } -// A family is named one by one, never by a wildcard, and only by the tool runner's module (ADR 0367 §2, §6): -// each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users. -func caseAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { - for manifest, want := range map[string]string{ - `{"module":"shop","version":"1.0","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", - `{"module":"node-tools","version":"1.0","waits":["mesh-knowledge.reviewed"]}`: "only the tool runner's module", // its name says nothing (issue 774) - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.*"]}`: "never a wildcard", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["**"]}`: "never a wildcard", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.>"]}`: "never a wildcard", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["*.reviewed"]}`: "never a wildcard", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["a.b","a.b"]}`: "twice", - } { - _, err := ParseManifest([]byte(manifest)) - if err == nil { - t.Errorf("accepted: %s", manifest) - continue - } - if !strings.Contains(err.Error(), want) { - t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err) - } +// EE-D4 (design 34 every-event §10; ADR 0670 §2, ADR 0578 §1): the manifest no longer names what a session waits on, so +// `waits` is a field the validator does not know: refused by the checker's Shelf naming it, set aside to wait by the +// indexer's ShelfWaiting; the tool runner's manifest without it is accepted. +func caseAManifestCarryingWaitsIsAFieldNotKnown(t *testing.T) { + raws := map[string][]byte{ + "node-tools": []byte(`{"module":"node-tools","version":"1.0","tool-runner":true,"waits":["mesh-knowledge.reviewed"]}`), + "runner": []byte(`{"module":"runner","version":"1.0","tool-runner":true}`), + } + if strict, _ := Shelf(raws, nil, 0); len(strict) != 1 || !strings.Contains(strings.Join(strict["node-tools"], " "), "waits") { + t.Errorf("Shelf on a manifest carrying waits: %v", strict) + } + if refused, waits, _ := ShelfWaiting(raws, nil, 0, nil, nil); len(refused) != 0 || + !reflect.DeepEqual(waits, map[string][]string{"node-tools": {"waits"}}) { + t.Errorf("ShelfWaiting on a manifest carrying waits: refused %v, waits %v", refused, waits) } } @@ -6355,7 +6346,7 @@ func TestRulesBus(t *testing.T) { t.Run("AServedValueNamingSomethingTheMeshDoesNotHaveIsRefused", caseAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused) t.Run("ASiteScopedProvisionIsRefused", caseASiteScopedProvisionIsRefused) t.Run("AStateNameIsLocalAndOneToken", caseAStateNameIsLocalAndOneToken) - t.Run("AWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner", caseAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner) + t.Run("AManifestCarryingWaitsIsAFieldNotKnown", caseAManifestCarryingWaitsIsAFieldNotKnown) t.Run("AWorkQueueStreamIsReadByExactlyOneProcessOfItsModule", caseAWorkQueueStreamIsReadByExactlyOneProcessOfItsModule) t.Run("AWorkQueueStreamsValuesAreBounded", caseAWorkQueueStreamsValuesAreBounded) t.Run("AZoneMustNameOneOfTheModulesListens", caseAZoneMustNameOneOfTheModulesListens) From 558a93c25b56ff7aca249661ce136bd6eae3d4ab Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:46:16 +0100 Subject: [PATCH 05/18] AM-D1: the validator refuses a mark nothing enforces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manifest marking a tool its tools do not list, a verb its claims do not serve or an event it does not carry, one name both a tool and an event, a marked tool or verb (or a held seat verb its seat marks) without operator-channel in uses, or a marked tool or verb while every bundle it builds is run by its own process, is refused by both paths, naming the entry and the rule. The login shell's captured manifest is copied at AM-K1, which gives it operator-channel. Builds: design 46 7-approval-mark §4, §7, §21; design 34 every-event §32; lead answers A-177, A-183 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-delivery-indexer/judge/rules.go | 98 +++++++++++++++++++ .../mesh-delivery-indexer/judge/rules_test.go | 40 +++++++- .../testdata/beside/CAPTURED | 2 + .../modules/zsh/module.json.captured | 5 +- 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index affc57b8..6a389aca 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -2938,6 +2938,7 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M } } problems = append(problems, invokeProblems(m)...) + problems = append(problems, needsApprovalProblems(m)...) problems = append(problems, replacesProblems(m)...) problems = append(problems, UnitSettingProblems(m)...) problems = append(problems, OwnerSettingProblems(m)...) @@ -3389,6 +3390,103 @@ func replacesProblems(m catalogue.Manifest) []string { return problems } +// needsApprovalProblems refuses a mark the mesh cannot honour (design 46 *The approval mark* §4; design 34 every-event +// §32; ADR 0286 §4): a mark on a tool its tools do not list (where it lists them; a bundle's own tools are checked by +// the tool runner at load), on a verb its claims do not serve, or on an event neither its emits nor its streams' events +// carry; one name both one of its tools and one of its events; a tool or verb marked, or a held seat verb its seat +// marks, without operator-channel in uses (§7: a module marking only events needs none); and a tool or verb marked +// while every bundle it builds is run by one of its process resources, so its own program answers and no tool runner +// gates them. Each refusal names the entry and the rule. +func needsApprovalProblems(m catalogue.Manifest) []string { + var problems []string + tools, events, served := map[string]bool{}, map[string]bool{}, map[string]map[string]bool{} + for _, t := range m.Tools { + tools[t] = true + } + for _, e := range m.Emits { + events[e] = true + } + for _, st := range m.Streams { + for _, e := range st.Events { + events[e] = true + } + } + for _, c := range m.Claims { + served[c.Name] = map[string]bool{} + for _, v := range c.ServesFor(m) { + served[c.Name][v] = true + } + } + var acts []string // the tools and verbs it marks, and the held seat verbs its seats mark + for _, e := range m.NeedsApproval { + seat, verb, dotted := strings.Cut(e, ".") + switch { + case tools[e] && events[e]: + problems = append(problems, fmt.Sprintf("%s marks %q in needs-approval, which names both one of its tools "+ + "and one of its events: an entry names one thing (design 46 approval mark §4)", m.Module, e)) + case events[e]: + case tools[e], dotted && served[seat][verb]: + acts = append(acts, e) + case dotted && served[seat] != nil: + problems = append(problems, fmt.Sprintf("%s marks the verb %q in needs-approval, which its claim of %s does "+ + "not serve (ADR 0286 §4)", m.Module, e, seat)) + case dotted: + problems = append(problems, fmt.Sprintf("%s marks %q in needs-approval, which is neither a verb its claims "+ + "serve nor an event its emits or its streams' events carry (ADR 0286 §4; design 34 every-event §32)", m.Module, e)) + case len(m.Tools) > 0: + problems = append(problems, fmt.Sprintf("%s marks the tool %q in needs-approval, which its tools do not list "+ + "(ADR 0286 §4)", m.Module, e)) + default: // a tool its bundle registers: the tool runner checks it at load (§4) + acts = append(acts, e) + } + } + marked := len(acts) > 0 + for _, c := range m.Claims { + seat, ok := catalogue.SeatNamed(c.Name) + if !ok { + continue + } + for _, v := range seat.Serves { + if v.NeedsApproval && served[c.Name][v.Name] { + acts = append(acts, c.Name+"."+v.Name) + } + } + } + if len(acts) > 0 && !slices.Contains(m.Uses, "operator-channel") { + problems = append(problems, fmt.Sprintf("%s marks or holds a marked tool or verb (%s) without operator-channel in "+ + "uses: its tool runner asks the operator through that seat (design 46 approval mark §4, §7)", + m.Module, strings.Join(acts, ", "))) + } + if marked && everyBundleRunByAProcess(m) { + problems = append(problems, fmt.Sprintf("%s marks a tool or verb while every bundle it builds is run by one of "+ + "its process resources: its own program answers them and no tool runner gates them, so the mark would guard "+ + "nothing (design 46 approval mark §4)", m.Module)) + } + return problems +} + +// everyBundleRunByAProcess says every artifact of kind bundle the module builds is the artifact of one of its process +// resources (none built is every one run so: no tool runner loads any). +func everyBundleRunByAProcess(m catalogue.Manifest) bool { + run := map[string]bool{} + for _, r := range m.Resources { + if r["type"] == "process" { + if a, ok := r["artifact"].(string); ok { + run[a] = true + } + } + } + if m.Build == nil { + return true + } + for _, a := range m.Build.Artifacts { + if a.Kind == "bundle" && !run[a.Name] { + return false + } + } + return true +} + // whileStoppedProblems judges one container's maintenance window against its own definition // (novox/hq ADR 0189). // diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 15b0f1c6..214c8aea 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3173,6 +3173,43 @@ func caseAManifestCarryingWaitsIsAFieldNotKnown(t *testing.T) { } } +// AM-D1 (design 46 approval mark §4, §21; design 34 every-event §32): a mark nothing enforces is refused, naming the +// entry and the rule; the login shell's and the forge's manifests of §21 are accepted, and a module marking only events +// needs no operator-channel. +func caseAMarkNothingEnforcesIsRefused(t *testing.T) { + const bundle = `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}` + const oc = `"uses":["operator-channel"]` + shell := `{"module":"zsh","claims":[{"name":"node-login-shell","scope":"node","serves":["execute"]}],"tools":["zsh_config"],` + bundle + for _, row := range []struct{ name, manifest, want string }{ + {"a tool its tools do not list", `{"module":"shop","tools":["sell"],"needs-approval":["refund"],` + oc + `,` + bundle + `}`, "its tools do not list"}, + {"a verb its claims do not serve", `{"module":"shop","claims":[{"name":"shop-till","serves":["price"]}],"tools":["price"],"needs-approval":["shop-till.refund"],` + oc + `,` + bundle + `}`, "does not serve"}, + {"a marked tool without operator-channel", `{"module":"shop","tools":["sell"],"needs-approval":["sell"],` + bundle + `}`, "without operator-channel"}, + {"a held seat verb its seat marks without operator-channel", shell + `}`, "without operator-channel"}, + {"every bundle run by its own process", `{"module":"shop","tools":["sell"],"needs-approval":["sell"],` + oc + `,` + bundle + + `,"resources":[{"id":"serve","type":"process","artifact":"tools","user":"shop"}]}`, "every bundle it builds is run"}, + {"one name a tool and an event", `{"module":"shop","tools":["sold"],"emits":["sold"],"needs-approval":["sold"],` + oc + `,` + bundle + `}`, "both one of its tools"}, + {"an event it does not carry", `{"module":"shop","emits":["sold"],"needs-approval":["sold.again"]}`, "nor an event its emits"}, + {"the login shell", shell + `,` + oc + `}`, ""}, + {"the forge", `{"module":"forge","needs-approval":["gitea_branch_protection_loosen"],` + oc + `,"build":{"artifacts":[` + + `{"name":"code","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"],"loads":["tools/index.js"]},` + + `{"name":"repositories","kind":"bundle","language":"typescript","entrypoints":["repositories/index.js"]}]},` + + `"resources":[{"id":"repositories","type":"process","artifact":"repositories","user":"forge"}]}`, ""}, + {"only events, no operator-channel", `{"module":"shop","emits":["sold"],"streams":[{"name":"ledger","events":["booked"],"max-age-days":1,"max-mib":1}],"needs-approval":["sold","booked"]}`, ""}, + } { + var problems []string + WithSeats([]catalogue.Seat{{Name: "node-login-shell", Serves: []catalogue.Verb{{Name: "execute", NeedsApproval: true}}}}, func() { + _, problems = Manifest(versioned(t, []byte(row.manifest)), nil) + }) + got := strings.Join(problems, "\n") + switch { + case row.want == "" && got != "": + t.Errorf("%s is refused: %s", row.name, got) + case row.want != "" && !strings.Contains(got, row.want): + t.Errorf("%s: problems %q, want one saying %q", row.name, got, row.want) + } + } +} + // 1178/AC2: a work-queue stream is read by exactly one process of its module and by no other module's consumes: one // process naming it is accepted; none, two, or another module's consumes is refused, naming it. func caseAWorkQueueStreamIsReadByExactlyOneProcessOfItsModule(t *testing.T) { @@ -3974,7 +4011,7 @@ func caseNoModuleMayDeclareTheLoginShell(t *testing.T) { t.Fatalf("declaring login-shell was not refused by name: %q", got) } // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. - m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + + m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],"uses":["operator-channel"],` + `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) if err != nil { t.Fatal(err) @@ -6382,6 +6419,7 @@ func TestRulesCatalogue(t *testing.T) { // TestRulesManifests runs the tests of one kind as subtests, each by its former name. func TestRulesManifests(t *testing.T) { t.Run("AMalformedEnvironmentIsRefusedAtParse", caseAMalformedEnvironmentIsRefusedAtParse) + t.Run("AMarkNothingEnforcesIsRefused", caseAMarkNothingEnforcesIsRefused) t.Run("AMalformedFamilyIsRefused", caseAMalformedFamilyIsRefused) t.Run("AMalformedPromptIsRefused", caseAMalformedPromptIsRefused) t.Run("AManifestIsJudgedAgainstTheListOfItsTree", caseAManifestIsJudgedAgainstTheListOfItsTree) diff --git a/modules/mesh-delivery-indexer/testdata/beside/CAPTURED b/modules/mesh-delivery-indexer/testdata/beside/CAPTURED index 6db0cff8..f4430ea3 100644 --- a/modules/mesh-delivery-indexer/testdata/beside/CAPTURED +++ b/modules/mesh-delivery-indexer/testdata/beside/CAPTURED @@ -13,6 +13,8 @@ was mesh-controller's copy, which still held two modules that had left the catal mesh-catalog b5dbfbb784507e211372a7c9640d91f7d6dadd84 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json mesh-host 233bb23bbcb1a829ce2a6164a31a5c5cc4f3d950 internal/apply/placement_guard.go +mesh-catalog 2d5358b8 (milestone 47/G's AM-K1) modules/zsh/module.json alone: the login shell uses operator-channel for + its seat's marked execute (design 46 approval mark §21), which the validator requires (§4) To move them, from mesh-delivery's modules/mesh-delivery-indexer directory, with the two repositories checked out beside mesh-delivery: diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured index da125af2..028f2213 100644 --- a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured @@ -1,6 +1,6 @@ { "module": "zsh", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" @@ -14,6 +14,9 @@ ] } ], + "uses": [ + "operator-channel" + ], "tools": [ "zsh_config" ], From 58e4a737d551c0b1e6c8dcc570ecad03f49ade94 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:48:17 +0100 Subject: [PATCH 06/18] AM-D5: a per-machine state reaches its machine's keys below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-machine state's account reaches its machine's key and the keys below it (mesh-sdk PerMachineKeys), no other machine's, so the tool runner's approvals keep .ask. and .act.; the membership names the machine's key in the same form. Builds: design 46 7-approval-mark §13 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/buscompose/buscompose_test.go | 42 +++++++++++++++++++ .../mesh-grants/internal/buscompose/state.go | 12 ++++-- .../buscompose/testdata/user-lists.json | 2 +- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index cebb8e9a..53a1bc51 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -62,6 +62,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Run("the bus module's process alone keeps every module's bus state and serves the broker's verbs", theBusModulesProcessKeepsBusState) t.Run("a process serving a verb is granted its calls and chunk subjects, and the reverse goes to an account naming it", chunkSubjectsFollowTheAnswerer) t.Run("the tool runner's runtime hears every event subject and nothing wider", theToolRunnerHearsEveryEvent) + t.Run("a per-machine state reaches its machine's keys below it and no other machine's", aPerMachineStateReachesItsMachinesKeys) // ADR 0567 §3, §9: a node reads every key of the seat's assignments and its candidates (it composes with the whole // proposal laid over), and none of its open proposals; it writes neither. reads := strings.Join(NodeComposeReads("laptop"), " ") @@ -1516,3 +1517,44 @@ func theToolRunnerHearsEveryEvent(t *testing.T) { t.Errorf("%d runtimes carry the tool runner, want 1", runners) } } + +// AM-D5 (design 46 approval mark §13): a per-machine state reaches its machine's key and the keys below it, `.>`, +// and no other machine's, on the account and in the membership alike. +func aPerMachineStateReachesItsMachinesKeys(t *testing.T) { + runner := func() Declared { + return Declared{Module: "node-tools", ToolRunner: true, + State: []Bucket{{Module: "node-tools", Name: "approvals", PerMachine: true, KeyTTL: true, TTLSeconds: 172800}}} + } + r := Records{Nodes: []string{"a", "b"}, Assigned: map[string][]Declared{"a": {runner()}, "b": {runner()}}} + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + const bucket = "node-tools_approvals" + found := false + for _, u := range users { + if u.Username() != "a.node-tools" { + continue + } + found = true + p, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + for key, reach := range map[string]bool{"a": true, "a.ask.1": true, "a.act.d": true, "b": false, "b.ask.1": false, "ab.ask.1": false} { + if MayPublish(p, "$KV."+bucket+"."+key) != reach { + t.Errorf("a's runner writes %s's key %s: %v", bucket, key, !reach) + } + if MayPublish(p, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+key) != reach { + t.Errorf("a's runner reads %s's key %s: %v", bucket, key, !reach) + } + } + } + if !found { + t.Fatal("no account a.node-tools composed") + } + // The membership names the machine's key, which reaches the keys below it in the same form (mesh-sdk membership). + if issued := stateIssuedFor(runner(), "a"); len(issued) != 1 || issued[0].Key != "a" || issued[0].Bucket != bucket { + t.Errorf("a's membership issues %+v", issued) + } +} diff --git a/modules/mesh-grants/internal/buscompose/state.go b/modules/mesh-grants/internal/buscompose/state.go index 86e9029a..b489d2a8 100644 --- a/modules/mesh-grants/internal/buscompose/state.go +++ b/modules/mesh-grants/internal/buscompose/state.go @@ -2,6 +2,7 @@ package buscompose import ( "fmt" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "slices" "strings" "time" @@ -36,8 +37,8 @@ type Bucket struct { History int // TTLSeconds is how long a value lives; zero is until replaced or deleted. TTLSeconds int - // PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR - // 0260), and is granted that key and no other. + // PerMachine says each machine's instance writes only the key named for its machine and the keys below it (novox/hq + // ADR 0260; design 46 approval mark §13), and is granted those and no other machine's. PerMachine bool // KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its // own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends. @@ -166,8 +167,11 @@ func stateGrants(a stateAccess) []string { if machineKey == "" { continue // a machine with no usable name reaches none of it } - read(bucket, machineKey) - out = append(out, "$KV."+bucket+"."+machineKey) + // Its machine's key and the keys below it, no other machine's (design 46 approval mark §13). + for _, k := range sdkbc.PerMachineKeys(machineKey) { + read(bucket, k) + out = append(out, "$KV."+bucket+"."+k) + } continue } read(bucket, "") diff --git a/modules/mesh-grants/internal/buscompose/testdata/user-lists.json b/modules/mesh-grants/internal/buscompose/testdata/user-lists.json index fd128363..2879ecd2 100644 --- a/modules/mesh-grants/internal/buscompose/testdata/user-lists.json +++ b/modules/mesh-grants/internal/buscompose/testdata/user-lists.json @@ -152,7 +152,7 @@ }, { "Name": "state, logs, streams, queues and processes", - "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"ace.plex\", password: \"$2a$11$ace.plexace.plexace.plfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.ace_plex.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.ace_plex\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.ace_plex\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.plex\", \"$SRV.INFO.plex.\u003e\", \"$SRV.PING\", \"$SRV.PING.plex\", \"$SRV.PING.plex.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.plex\", \"$SRV.STATS.plex.\u003e\", \"_INBOX.ace.plex.\u003e\", \"mesh.mod.plex.call.\u003e\", \"mesh.mod.plex.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.ace\", password: \"$2a$11$node.acenode.acenode.afixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_findings.*.$KV.mesh-host_findings.machine.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-ace.*.$O.declaration-files-ace.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_findings.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-ace.*\", \"$JS.API.CONSUMER.INFO.NODES.ace\", \"$JS.API.DIRECT.GET.KV_composed-ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_bounds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_findings.$KV.mesh-host_findings.machine.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.ace\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.ace\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.ace.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-ace.$O.declaration-files-ace.M.*\", \"$JS.API.STREAM.INFO.KV_composed-ace\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_bounds\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_findings\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-ace.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_bounds.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_findings.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-ace.\u003e\", \"$KV.composed-ace.\u003e\", \"$KV.mesh-host_acts.ace.\u003e\", \"$KV.mesh-host_bounds.\u003e\", \"$KV.mesh-host_data.ace.\u003e\", \"$KV.mesh-host_findings.machine.ace.\u003e\", \"$KV.mesh-host_health.ace\", \"$KV.mesh-host_packages.ace\", \"$KV.node-engine_have.ace.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.ace\", \"$SRV.PING.\u003e\", \"mesh.control.ace.\u003e\", \"mesh.mod.plex.call.plex_status.ace.node~ace\", \"mesh.mod.plex.tool.plex_status.ace\", \"mesh.seat.node-backup.call.backed-up.ace.node~ace\", \"mesh.seat.node-backup.call.now.ace.node~ace\", \"mesh.seat.node-backup.tool.backed-up.ace\", \"mesh.seat.node-backup.tool.now.ace\", \"mesh.seat.node-engine.event.*.ace\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.ace\", \"_DELIVER.ace.\u003e\", \"_INBOX.node.ace.\u003e\", \"mesh.node.ace.ask.access-act\", \"mesh.node.ace.ask.hand-over\", \"mesh.node.ace.ask.judge-directories\", \"mesh.node.ace.ask.move-secret\", \"mesh.node.ace.ask.report\", \"mesh.node.ace.ask.setuid-search\", \"mesh.node.ace.declare\", \"mesh.seat.node-engine.call.*.ace.*\", \"mesh.seat.node-engine.tool.*.ace\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.novox\", password: \"$2a$11$node.novoxnode.novoxnofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_findings.*.$KV.mesh-host_findings.machine.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-novox.*.$O.declaration-files-novox.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_findings.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-novox.*\", \"$JS.API.CONSUMER.INFO.NODES.novox\", \"$JS.API.DIRECT.GET.KV_composed-novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_bounds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_findings.$KV.mesh-host_findings.machine.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.novox\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.novox\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.novox.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-novox.$O.declaration-files-novox.M.*\", \"$JS.API.STREAM.INFO.KV_composed-novox\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_bounds\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_findings\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-novox.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_bounds.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_findings.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-novox.\u003e\", \"$KV.composed-novox.\u003e\", \"$KV.mesh-host_acts.novox.\u003e\", \"$KV.mesh-host_bounds.\u003e\", \"$KV.mesh-host_data.novox.\u003e\", \"$KV.mesh-host_findings.machine.novox.\u003e\", \"$KV.mesh-host_health.novox\", \"$KV.mesh-host_packages.novox\", \"$KV.node-engine_have.novox.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.novox\", \"$SRV.PING.\u003e\", \"mesh.control.novox.\u003e\", \"mesh.seat.node-backup.call.backed-up.novox.node~novox\", \"mesh.seat.node-backup.call.now.novox.node~novox\", \"mesh.seat.node-backup.tool.backed-up.novox\", \"mesh.seat.node-backup.tool.now.novox\", \"mesh.seat.node-engine.event.*.novox\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.novox\", \"_DELIVER.novox.\u003e\", \"_INBOX.node.novox.\u003e\", \"mesh.node.novox.ask.access-act\", \"mesh.node.novox.ask.hand-over\", \"mesh.node.novox.ask.judge-directories\", \"mesh.node.novox.ask.move-secret\", \"mesh.node.novox.ask.report\", \"mesh.node.novox.ask.setuid-search\", \"mesh.node.novox.declare\", \"mesh.seat.node-engine.call.*.novox.*\", \"mesh.seat.node-engine.tool.*.novox\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-builder.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-delivery-builder_presence.novox\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.log.mesh-delivery-builder.builds.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\", \"mesh.stream.mesh-delivery-builder.event.ask\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-builder\", \"$SRV.INFO.mesh-delivery-builder.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-builder\", \"$SRV.PING.mesh-delivery-builder.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-builder\", \"$SRV.STATS.mesh-delivery-builder.\u003e\", \"_INBOX.novox.mesh-delivery-builder.\u003e\", \"mesh.mod.mesh-delivery-builder.call.\u003e\", \"mesh.mod.mesh-delivery-builder.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder.worker\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.asks.novox_mesh-delivery-builder_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"_INBOX.novox.mesh-delivery-builder.worker.\u003e\"] }\n } }\n { user: \"novox.mesh-delivery-view\", password: \"$2a$11$novox.mesh-delivery-vifixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-view.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-view\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_STEP_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_STEP_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_STEP_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-view\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-view\", \"$SRV.INFO.mesh-delivery-view.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-view\", \"$SRV.PING.mesh-delivery-view.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-view\", \"$SRV.STATS.mesh-delivery-view.\u003e\", \"_INBOX.novox.mesh-delivery-view.\u003e\", \"mesh.mod.mesh-delivery-view.call.\u003e\", \"mesh.mod.mesh-delivery-view.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-grants\", password: \"$2a$11$novox.mesh-grantsnovoxfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.CONSUMER.CREATE.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-grants\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_assertions.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.CREATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.CREATE.asks\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_assertions\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.asks\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.UPDATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.UPDATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.UPDATE.asks\", \"$JS.FC.KV_mesh-grants_assertions.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-grants_assertions.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\", \"mesh.mod.mesh-grants.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions\", \"$SRV.INFO\", \"$SRV.INFO.mesh-grants\", \"$SRV.INFO.mesh-grants.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-grants\", \"$SRV.PING.mesh-grants.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-grants\", \"$SRV.STATS.mesh-grants.\u003e\", \"_INBOX.novox.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", + "List": "# The mesh's users, composed by the grants part. Do not edit: the next\n# composition overwrites it. Permissions are derived from what each module\n# declares and nothing else (novox/hq ADR 0043, design 29 §2).\n\naccounts {\n MESH {\n jetstream: enabled\n imports = [\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.CONNECT\" }, to: \"mesh.bus.connect\" }\n { stream: { account: \"$SYS\", subject: \"$SYS.ACCOUNT.MESH.DISCONNECT\" }, to: \"mesh.bus.disconnect\" }\n { service: { account: \"$SYS\", subject: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\" }, to: \"mesh.bus.connz\" }\n ]\n users = [\n { user: \"ace.plex\", password: \"$2a$11$ace.plexace.plexace.plfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.ace_plex.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.ace_plex\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.ace_plex\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.ace.plex\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$SRV.INFO\", \"$SRV.INFO.plex\", \"$SRV.INFO.plex.\u003e\", \"$SRV.PING\", \"$SRV.PING.plex\", \"$SRV.PING.plex.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.plex\", \"$SRV.STATS.plex.\u003e\", \"_INBOX.ace.plex.\u003e\", \"mesh.mod.plex.call.\u003e\", \"mesh.mod.plex.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.ace\", password: \"$2a$11$node.acenode.acenode.afixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_findings.*.$KV.mesh-host_findings.machine.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.ace\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.ace.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-ace.*.$O.declaration-files-ace.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-ace.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_findings.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-ace.*\", \"$JS.API.CONSUMER.INFO.NODES.ace\", \"$JS.API.DIRECT.GET.KV_composed-ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_bounds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_findings.$KV.mesh-host_findings.machine.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.ace\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.ace\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.ace.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.ace.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-ace.$O.declaration-files-ace.M.*\", \"$JS.API.STREAM.INFO.KV_composed-ace\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_bounds\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_findings\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-ace.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_bounds.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_findings.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-ace.\u003e\", \"$KV.composed-ace.\u003e\", \"$KV.mesh-host_acts.ace.\u003e\", \"$KV.mesh-host_bounds.\u003e\", \"$KV.mesh-host_data.ace.\u003e\", \"$KV.mesh-host_findings.machine.ace.\u003e\", \"$KV.mesh-host_health.ace\", \"$KV.mesh-host_packages.ace\", \"$KV.node-engine_have.ace.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.ace\", \"$SRV.PING.\u003e\", \"mesh.control.ace.\u003e\", \"mesh.mod.plex.call.plex_status.ace.node~ace\", \"mesh.mod.plex.tool.plex_status.ace\", \"mesh.seat.node-backup.call.backed-up.ace.node~ace\", \"mesh.seat.node-backup.call.now.ace.node~ace\", \"mesh.seat.node-backup.tool.backed-up.ace\", \"mesh.seat.node-backup.tool.now.ace\", \"mesh.seat.node-engine.event.*.ace\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.ace\", \"_DELIVER.ace.\u003e\", \"_INBOX.node.ace.\u003e\", \"mesh.node.ace.ask.access-act\", \"mesh.node.ace.ask.hand-over\", \"mesh.node.ace.ask.judge-directories\", \"mesh.node.ace.ask.move-secret\", \"mesh.node.ace.ask.report\", \"mesh.node.ace.ask.setuid-search\", \"mesh.node.ace.declare\", \"mesh.seat.node-engine.call.*.ace.*\", \"mesh.seat.node-engine.tool.*.ace\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"node.novox\", password: \"$2a$11$node.novoxnode.novoxnofixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.NODES.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_acts.*.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.*.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_findings.*.$KV.mesh-host_findings.machine.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_health.*.$KV.mesh-host_health.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-host_packages.*.$KV.mesh-host_packages.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_provisions.*.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.*.node\", \"$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.OBJ_declaration-files-novox.*.$O.declaration-files-novox.C.*\", \"$JS.API.CONSUMER.DELETE.KV_composed-novox.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_acts.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_bounds.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_data.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_findings.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_health.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-host_packages.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_provisions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_node-engine_have.\u003e\", \"$JS.API.CONSUMER.DELETE.OBJ_declaration-files-novox.*\", \"$JS.API.CONSUMER.INFO.NODES.novox\", \"$JS.API.DIRECT.GET.KV_composed-novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_bounds.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_findings.$KV.mesh-host_findings.machine.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.novox\", \"$JS.API.DIRECT.GET.KV_mesh-host_packages.$KV.mesh-host_packages.novox\", \"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.*.node\", \"$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.novox.\u003e\", \"$JS.API.DIRECT.GET.OBJ_declaration-files-novox.$O.declaration-files-novox.M.*\", \"$JS.API.STREAM.INFO.KV_composed-novox\", \"$JS.API.STREAM.INFO.KV_mesh-config_resolved\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_assignments\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_candidates\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_manifests\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_modules\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-indexer_verdicts\", \"$JS.API.STREAM.INFO.KV_mesh-host_acts\", \"$JS.API.STREAM.INFO.KV_mesh-host_bounds\", \"$JS.API.STREAM.INFO.KV_mesh-host_data\", \"$JS.API.STREAM.INFO.KV_mesh-host_findings\", \"$JS.API.STREAM.INFO.KV_mesh-host_health\", \"$JS.API.STREAM.INFO.KV_mesh-host_packages\", \"$JS.API.STREAM.INFO.KV_mesh-provisioner_provisions\", \"$JS.API.STREAM.INFO.KV_node-engine_have\", \"$JS.FC.KV_composed-novox.\u003e\", \"$JS.FC.KV_mesh-config_resolved.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_assignments.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-assigner_wants.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_builds.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_candidates.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_manifests.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_modules.\u003e\", \"$JS.FC.KV_mesh-delivery-indexer_verdicts.\u003e\", \"$JS.FC.KV_mesh-host_acts.\u003e\", \"$JS.FC.KV_mesh-host_bounds.\u003e\", \"$JS.FC.KV_mesh-host_data.\u003e\", \"$JS.FC.KV_mesh-host_findings.\u003e\", \"$JS.FC.KV_mesh-host_health.\u003e\", \"$JS.FC.KV_mesh-host_packages.\u003e\", \"$JS.FC.KV_mesh-provisioner_provisions.\u003e\", \"$JS.FC.KV_node-engine_have.\u003e\", \"$JS.FC.OBJ_declaration-files-novox.\u003e\", \"$KV.composed-novox.\u003e\", \"$KV.mesh-host_acts.novox.\u003e\", \"$KV.mesh-host_bounds.\u003e\", \"$KV.mesh-host_data.novox.\u003e\", \"$KV.mesh-host_findings.machine.novox.\u003e\", \"$KV.mesh-host_health.novox\", \"$KV.mesh-host_packages.novox\", \"$KV.node-engine_have.novox.\u003e\", \"$SRV.INFO\", \"$SRV.INFO.\u003e\", \"$SRV.PING\", \"$SRV.PING.*.novox\", \"$SRV.PING.\u003e\", \"mesh.control.novox.\u003e\", \"mesh.seat.node-backup.call.backed-up.novox.node~novox\", \"mesh.seat.node-backup.call.now.novox.node~novox\", \"mesh.seat.node-backup.tool.backed-up.novox\", \"mesh.seat.node-backup.tool.now.novox\", \"mesh.seat.node-engine.event.*.novox\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_assignments\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates\", \"$SRV.INFO\", \"$SRV.INFO.node-engine\", \"$SRV.INFO.node-engine.\u003e\", \"$SRV.PING\", \"$SRV.PING.node-engine\", \"$SRV.PING.node-engine.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.node-engine\", \"$SRV.STATS.node-engine.\u003e\", \"_DELIVER.novox\", \"_DELIVER.novox.\u003e\", \"_INBOX.node.novox.\u003e\", \"mesh.node.novox.ask.access-act\", \"mesh.node.novox.ask.hand-over\", \"mesh.node.novox.ask.judge-directories\", \"mesh.node.novox.ask.move-secret\", \"mesh.node.novox.ask.report\", \"mesh.node.novox.ask.setuid-search\", \"mesh.node.novox.declare\", \"mesh.seat.node-engine.call.*.novox.*\", \"mesh.seat.node-engine.tool.*.novox\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-builder.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-builder\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-builder\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds\", \"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_builds.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-delivery-builder_presence.novox\", \"$KV.mesh-delivery-builder_presence.novox.\u003e\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.log.mesh-delivery-builder.builds.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\", \"mesh.stream.mesh-delivery-builder.event.ask\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-builder\", \"$SRV.INFO.mesh-delivery-builder.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-builder\", \"$SRV.PING.mesh-delivery-builder.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-builder\", \"$SRV.STATS.mesh-delivery-builder.\u003e\", \"_INBOX.novox.mesh-delivery-builder.\u003e\", \"mesh.mod.mesh-delivery-builder.call.\u003e\", \"mesh.mod.mesh-delivery-builder.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-delivery-builder.worker\", password: \"$2a$11$novox.mesh-delivery-bufixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.asks.novox_mesh-delivery-builder_worker.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$KV.mesh-delivery-builder_steps.\u003e\", \"mesh.mod.gitea.call.gitea_commit.*.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.call.gitea_commit.novox~mesh-delivery-builder~worker\", \"mesh.mod.gitea.tool.gitea_commit\", \"mesh.mod.gitea.tool.gitea_commit.*\", \"mesh.mod.mesh-delivery-builder.event.built\", \"mesh.mod.mesh-delivery-builder.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"_INBOX.novox.mesh-delivery-builder.worker.\u003e\"] }\n } }\n { user: \"novox.mesh-delivery-view\", password: \"$2a$11$novox.mesh-delivery-vifixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-delivery-view.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_presence.*.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-delivery-view\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_GO.SEAT_NODE_BUILD_GO_STEP_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_IMAGE.SEAT_NODE_BUILD_IMAGE_STEP_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_CHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_DELIVERY_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_RECHECK_worker\", \"$JS.API.CONSUMER.INFO.SEAT_NODE_BUILD_NODE.SEAT_NODE_BUILD_NODE_STEP_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-delivery-view\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_presence.$KV.mesh-delivery-builder_presence.novox\", \"$JS.API.DIRECT.GET.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-delivery-view\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.INFO.SEAT_NODE_BUILD_NODE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_GO\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_IMAGE\", \"$JS.API.STREAM.MSG.GET.SEAT_NODE_BUILD_NODE\", \"$JS.FC.KV_mesh-delivery-builder_presence.\u003e\", \"$JS.FC.KV_mesh-delivery-builder_steps.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_presence\", \"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps\", \"$SRV.INFO\", \"$SRV.INFO.mesh-delivery-view\", \"$SRV.INFO.mesh-delivery-view.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-delivery-view\", \"$SRV.PING.mesh-delivery-view.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-delivery-view\", \"$SRV.STATS.mesh-delivery-view.\u003e\", \"_INBOX.novox.mesh-delivery-view.\u003e\", \"mesh.mod.mesh-delivery-view.call.\u003e\", \"mesh.mod.mesh-delivery-view.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n { user: \"novox.mesh-grants\", password: \"$2a$11$novox.mesh-grantsnovoxfixturefixturefixturefixture00\", permissions: {\n publish: { allow: [\"$JS.ACK.EVENTS.novox_mesh-grants.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.CREATE.KV_mesh-grants_memberships.*.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.CONSUMER.CREATE.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_assertions.\u003e\", \"$JS.API.CONSUMER.DELETE.KV_mesh-grants_memberships.\u003e\", \"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-grants\", \"$JS.API.CONSUMER.INFO.asks.novox_mesh-delivery-builder_worker\", \"$JS.API.CONSUMER.MSG.NEXT.EVENTS.novox_mesh-grants\", \"$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.*.*\", \"$JS.API.DIRECT.GET.KV_mesh-grants_assertions.\u003e\", \"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.novox.mesh-grants\", \"$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.\u003e\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.CREATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.CREATE.asks\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.INFO.KV_mesh-grants_assertions\", \"$JS.API.STREAM.INFO.KV_mesh-grants_memberships\", \"$JS.API.STREAM.INFO.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.INFO.asks\", \"$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list\", \"$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_presence\", \"$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps\", \"$JS.API.STREAM.UPDATE.KV_mesh-grants_assertions\", \"$JS.API.STREAM.UPDATE.LOG_mesh-delivery-builder_builds\", \"$JS.API.STREAM.UPDATE.asks\", \"$JS.FC.KV_mesh-grants_assertions.\u003e\", \"$JS.FC.KV_mesh-grants_memberships.\u003e\", \"$KV.mesh-grants_assertions.\u003e\", \"$O.mesh-grants-user-list.\u003e\", \"mesh.assignment.*.*\", \"mesh.mod.mesh-grants.event.membership-changed\", \"mesh.mod.mesh-grants.event.states\"] }\n subscribe: { allow: [\"$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions\", \"$SRV.INFO\", \"$SRV.INFO.mesh-grants\", \"$SRV.INFO.mesh-grants.\u003e\", \"$SRV.PING\", \"$SRV.PING.mesh-grants\", \"$SRV.PING.mesh-grants.\u003e\", \"$SRV.STATS\", \"$SRV.STATS.mesh-grants\", \"$SRV.STATS.mesh-grants.\u003e\", \"_INBOX.novox.mesh-grants.\u003e\", \"mesh.mod.mesh-grants.call.\u003e\", \"mesh.mod.mesh-grants.tool.\u003e\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n \"$SYS\" {\n exports = [\n { stream: \"$SYS.ACCOUNT.MESH.CONNECT\", accounts: [\"MESH\"] }\n { stream: \"$SYS.ACCOUNT.MESH.DISCONNECT\", accounts: [\"MESH\"] }\n { service: \"$SYS.REQ.ACCOUNT.MESH.CONNZ\", accounts: [\"MESH\"] }\n ]\n }\n}\n", "Missing": null, "Err": "", "Memberships": { From f3c35722d913625e5003730e29729085fc117f08 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:48:49 +0100 Subject: [PATCH 07/18] AM-D2: the grants part's own copy of the wire pattern goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reader turns a stream's events into the bus's subjects with mesh-sdk's buscompose.WirePattern; the grants part's copy goes. The catalog graph entry already carries the validated manifest whole, its needs-approval, emits and streams with their grant among it (design 47 G §443). Builds: design 46 7-approval-mark §5 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-grants/internal/buscompose/modstreams.go | 13 +------------ modules/mesh-grants/internal/grants/records.go | 5 +++-- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/modules/mesh-grants/internal/buscompose/modstreams.go b/modules/mesh-grants/internal/buscompose/modstreams.go index 0c724157..94805b01 100644 --- a/modules/mesh-grants/internal/buscompose/modstreams.go +++ b/modules/mesh-grants/internal/buscompose/modstreams.go @@ -29,23 +29,12 @@ func ModuleStreamSubject(module, event string) string { // OnAModuleStream says a subject is one a module's own stream takes. func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) } -// WirePattern is a local event pattern as the bus spells it: `**` is `>`. -func WirePattern(local string) string { - parts := strings.Split(local, ".") - for i, p := range parts { - if p == "**" { - parts[i] = ">" - } - } - return strings.Join(parts, ".") -} - // A ModuleStream is one module's declared stream as the bus holds it. type ModuleStream struct { Module string // Name is its name on the bus, `MAIL`. Name string - // Events are the events it carries, as the bus spells them (WirePattern). + // Events are the events it carries, as the bus spells them (mesh-sdk buscompose.WirePattern). Events []string MaxAgeDays int MaxMiB int diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 724d063b..1442daf8 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -3,6 +3,7 @@ package grants import ( "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + sdkbc "git.novox.be/novox/mesh-sdk/go/buscompose" "maps" "reflect" "slices" @@ -409,7 +410,7 @@ func streamsOf(m catalogue.Manifest) []buscompose.ModuleStream { for _, s := range m.Streams { events := make([]string, 0, len(s.Events)) for _, e := range s.Events { - events = append(events, buscompose.WirePattern(e)) + events = append(events, sdkbc.WirePattern(e)) } out = append(out, buscompose.ModuleStream{Module: m.Module, Name: s.BusName(), Events: events, MaxAgeDays: s.Days(), MaxMiB: s.Cap(), Restricted: s.Grant != nil, @@ -491,7 +492,7 @@ func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node for _, p := range parts { h := buscompose.Heard{Stream: p.Stream.BusName(), Emitter: p.Emitter, Restricted: p.Stream.Grant != nil} for _, pattern := range p.Patterns { - h.Filters = append(h.Filters, buscompose.ModuleStreamSubject(p.Emitter, buscompose.WirePattern(pattern))) + h.Filters = append(h.Filters, buscompose.ModuleStreamSubject(p.Emitter, sdkbc.WirePattern(pattern))) } out = append(out, h) } From f8bae4bee8fee9b1491b72bae2e561ad4bd7302f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:56:06 +0100 Subject: [PATCH 08/18] GR13: a build seat's queues take named asks beside main's, each asker granted its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each queue gets a named worker filtered to accept..*; an asker is granted accept.. for the queues of the kinds it is a caller of, read from the definitions' callers lists; main's accept. is the checker's and the builder's alone. The purge of named subjects and workers (grants_named_purge) is not in this commit. Builds: design 47 G §543, §544, §600, §601, A-163, A-170, A-184 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/buscompose/named_asks_test.go | 69 +++++++++++++++++++ .../mesh-grants/internal/buscompose/nats.go | 4 ++ .../internal/buscompose/seattraffic.go | 33 ++++++++- modules/mesh-grants/internal/grants/inputs.go | 3 + modules/mesh-grants/internal/grants/kept.go | 42 +++++++++++ .../internal/grants/named_asks_test.go | 36 ++++++++++ .../mesh-grants/internal/grants/records.go | 2 + 7 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-grants/internal/buscompose/named_asks_test.go create mode 100644 modules/mesh-grants/internal/grants/named_asks_test.go diff --git a/modules/mesh-grants/internal/buscompose/named_asks_test.go b/modules/mesh-grants/internal/buscompose/named_asks_test.go new file mode 100644 index 00000000..066edd62 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/named_asks_test.go @@ -0,0 +1,69 @@ +package buscompose + +import "testing" + +// Design 47 G §543, §544, §600: a build seat's ask names its asker, each queue has a named worker beside main's, an asker +// is granted the queues of its kinds under its own seat's name alone, and main's subject is the checker's and the builder's. +func TestNamedAsks(t *testing.T) { + build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true, + AcceptCallers: map[string][]string{ + "delivery": {"mesh-builds"}, + "check": {"mesh-pipeline-runner", "mesh-catalog", "mesh-delivery-checker"}, + "recheck": {"mesh-delivery-checker"}, + }} + asker := func(module string, held ...string) Principal { + p := Principal{Kind: KindModule, Node: "one", Module: module, Uses: []Seat{build}} + for _, h := range held { + p.Holds = append(p.Holds, Seat{Name: h}) + } + return p + } + const base = "mesh.seat.node-build-go.accept." + for _, c := range []struct { + who Principal + want map[string]bool + }{ + {asker("runner-module", "mesh-pipeline-runner"), map[string]bool{ + base + "check.mesh-pipeline-runner": true, base + "delivery.mesh-pipeline-runner": false, + base + "check.mesh-catalog": false, base + "check": false, base + "delivery": false}}, + {asker("an-indexer", "mesh-catalog"), map[string]bool{ + base + "check.mesh-catalog": true, base + "check.mesh-pipeline-runner": false, base + "recheck.mesh-catalog": false}}, + {asker("the-builder", "mesh-builds"), map[string]bool{ + base + "delivery.mesh-builds": true, base + "delivery": true, base + "check": true, base + "check.mesh-builds": false}}, + {asker("the-checker", "mesh-delivery-checker"), map[string]bool{ + base + "check.mesh-delivery-checker": true, base + "recheck.mesh-delivery-checker": true, base + "recheck": true, + base + "delivery.mesh-delivery-checker": false}}, + {asker("a-stranger"), map[string]bool{base + "check": false, base + "delivery": false, base + "check.a-stranger": false}}, + } { + perms, err := PermissionsFor(c.who) + if err != nil { + t.Fatal(err) + } + for subject, want := range c.want { + if got := MayPublish(perms, subject); got != want { + t.Errorf("%s may publish %s: %v, want %v", c.who.Module, subject, got, want) + } + } + } + // Each queue's workers: main's and the named one, filtered to subjects that overlap nothing. + held := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check"}, PulledByKind: true} + tr := SeatTrafficOf("go-module", []Seat{held}, nil, nil) + filters := map[string]string{} + for _, w := range tr.Workers { + filters[w.Consumer] = w.Filter + } + for consumer, filter := range map[string]string{ + "SEAT_NODE_BUILD_GO_CHECK_worker": base + "check", + "SEAT_NODE_BUILD_GO_CHECK_named_worker": base + "check.*", + "SEAT_NODE_BUILD_GO_DELIVERY_worker": base + "delivery", + "SEAT_NODE_BUILD_GO_DELIVERY_named_worker": base + "delivery.*", + } { + if filters[consumer] != filter { + t.Errorf("worker %s filters %q, want %q", consumer, filters[consumer], filter) + } + } + _, consumers := SeatTrafficObjects([]Principal{{Kind: KindModule, Node: "one", Module: "go-module", Holds: []Seat{held}}}) + if len(consumers) != 4 { + t.Errorf("the work queue's consumers: %d, want 4 (a main and a named worker for each of two queues)", len(consumers)) + } +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 12fd9c8d..9d9942b7 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -103,6 +103,10 @@ type Seat struct { // PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which // every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4). PulledByKind bool + // AcceptCallers are, for a seat pulled by kind, the seats that may ask each of its queues, by queue: the `seat:` + // entries of the callers of the accepted kinds the definition puts on that queue (design 47 G §543, §600). The asker + // in a named ask's subject is the asking seat's name (A-163). Empty where the seat list is unread. + AcceptCallers map[string][]string // HeldByTheEngine says every node's engine holds the seat on its own account (catalogue.Seat.HeldByTheEngine, novox/hq // ADR 0563 §1): each of its events names the engine's machine as its last token (machineEvent). HeldByTheEngine bool diff --git a/modules/mesh-grants/internal/buscompose/seattraffic.go b/modules/mesh-grants/internal/buscompose/seattraffic.go index 5e369c6b..0cb21b5f 100644 --- a/modules/mesh-grants/internal/buscompose/seattraffic.go +++ b/modules/mesh-grants/internal/buscompose/seattraffic.go @@ -35,6 +35,16 @@ const BuildAskMaxDeliver = 5 // KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded. var KindedBenches = map[string]bool{"channel": true, "intake": true} +// NamedWorkerName is the worker of a pulled-by-kind seat's named asks, `accept..`, beside the queue's own (WorkerName): +// two filters that overlap nothing, so the work queue takes both (design 47 G §544). +func NamedWorkerName(seat, queue string) string { + return "SEAT_" + upperSnake(seat) + "_" + upperSnake(queue) + "_named_worker" +} + +// MainAskers are the seats that may publish a build seat's ask on main's subject `accept.`, which names no asker: the +// checker and the builder, until main's subject goes (design 47 G §543, §600, A-184). +var MainAskers = []string{"mesh-delivery-checker", "mesh-builds"} + // WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench. func WorkerName(seat, kind string) string { if kind == "" { @@ -86,6 +96,9 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { for _, a := range s.Accepts { t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: WorkerName(s.Name, a), Filter: "mesh.seat." + s.Name + ".accept." + a}) + // Its named asks have a worker of their own (§544); a holder of G pulls it, one before G knows only main's. + t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: NamedWorkerName(s.Name, a), + Filter: "mesh.seat." + s.Name + ".accept." + a + ".*"}) } } else if len(s.Accepts) > 0 { stream := seatStreamName(s.Name) @@ -121,7 +134,25 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic { if !s.isNewTraffic() { continue } + if s.PulledByKind { + // **An ask names its asker** (§543, §600): each asker is granted `accept..` for the queues whose + // kinds it is a caller of, its name the seat it holds, and main's `accept.` is the checker's and the + // builder's alone. + for _, q := range s.Accepts { + for _, h := range holds { + if namesVerb(s.AcceptCallers[q], h.Name) { + t.Publish = append(t.Publish, seatSubject(s, "accept", q+"."+h.Name)) + } + if namesVerb(MainAskers, h.Name) { + t.Publish = append(t.Publish, seatSubject(s, "accept", q)) + } + } + } + } for _, a := range s.Accepts { + if s.PulledByKind { + break + } switch { case namesVerb(s.ByCaller, a): t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module)) @@ -205,7 +236,7 @@ func (s Seat) isNewTraffic() bool { // plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by // its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else. func plainVerbs(s Seat, verbs []string) []string { - if s.Kinded { + if s.Kinded || s.PulledByKind { return nil } var out []string diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 251515b1..0308ae7a 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -190,6 +190,8 @@ type Inputs struct { // EventSubjects are, by seat, the subjects of its events that carry parts, by the event's local name, as the definitions // give them: where a node-scoped seat's event names its machine (buscompose.Seat.EventSubjects). EventSubjects map[string]map[string]string + // AcceptCallers are, by pulled-by-kind seat and queue, the seats that may ask it (acceptCallersOf). + AcceptCallers map[string]map[string][]string // Defined are the events a seat definition of the seat list defines, by local name, and mesh-sdk's finding events: the // events a `*` emitter before them reaches each declaring module's own stream for (design 47 G §386, §474, §644). Defined map[string]bool @@ -370,6 +372,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Kept = buscompose.KeptEventsOf(d.Definitions()) in.Chunked = buscompose.ChunkVerbsOf(d.Definitions()) in.EventSubjects = eventSubjectsOf(d.Definitions()) + in.AcceptCallers = acceptCallersOf(d.Definitions()) in.Defined = definedEventsOf(d.Definitions()) } nodes := map[string]bool{} diff --git a/modules/mesh-grants/internal/grants/kept.go b/modules/mesh-grants/internal/grants/kept.go index 42afb8bb..3994d901 100644 --- a/modules/mesh-grants/internal/grants/kept.go +++ b/modules/mesh-grants/internal/grants/kept.go @@ -135,3 +135,45 @@ func withEventSubjects(subjects map[string]map[string]string, d *buscompose.Decl } } } + +// acceptCallersOf is, by pulled-by-kind seat and then by queue, the seats that may ask the queue: the `seat:` entries +// of the callers of every accepted kind the definition puts on it (a kind's `stream` is its queue, else its name is). Never a +// name of the grants part's own: it reads the definitions' lists (design 47 G §543, §600, A-163). +func acceptCallersOf(defs map[string]seatdef.Definition) map[string]map[string][]string { + out := map[string]map[string][]string{} + for _, d := range defs { + if !d.PulledByKind { + continue + } + for _, a := range d.Accepts { + if a.Callers == nil || a.Callers.IsText() { + continue + } + queue := a.Name + if a.Stream != "" { + queue = a.Stream + } + for _, e := range a.Callers.List { + if seat, ok := strings.CutPrefix(e, seatdef.CallerSeatPrefix); ok && !slices.Contains(out[d.Seat][queue], seat) { + if out[d.Seat] == nil { + out[d.Seat] = map[string][]string{} + } + out[d.Seat][queue] = append(out[d.Seat][queue], seat) + } + } + } + } + return out +} + +// withAcceptCallers gives each pulled-by-kind seat a declaration holds or uses the askers of its queues (Seat.AcceptCallers). +func withAcceptCallers(askers map[string]map[string][]string, d *buscompose.Declared) { + if len(askers) == 0 { + return + } + for _, list := range []*[]buscompose.Seat{&d.Holds, &d.Uses, &d.Watches} { + for i := range *list { + (*list)[i].AcceptCallers = askers[(*list)[i].Name] + } + } +} diff --git a/modules/mesh-grants/internal/grants/named_asks_test.go b/modules/mesh-grants/internal/grants/named_asks_test.go new file mode 100644 index 00000000..bc58743c --- /dev/null +++ b/modules/mesh-grants/internal/grants/named_asks_test.go @@ -0,0 +1,36 @@ +package grants + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// The askers of a build seat's queue are read from the definition's callers lists (design 47 G §543, §600, A-163): a kind's +// `stream` is its queue, a text callers names none, and only `seat:` entries are askers. +func TestAcceptCallersOf(t *testing.T) { + msg := func(name, stream string, callers ...string) seatdef.Message { + m := seatdef.Message{Name: name, Stream: stream} + if callers != nil { + m.Callers = &seatdef.Callers{List: callers} + } + return m + } + got := acceptCallersOf(map[string]seatdef.Definition{ + "node-build-go": {Seat: "node-build-go", PulledByKind: true, Accepts: []seatdef.Message{ + msg("check", "", "seat:mesh-catalog", "operator"), + msg("step", "check", "seat:mesh-pipeline-runner"), + msg("delivery", "", "seat:mesh-builds"), + {Name: "recheck", Callers: &seatdef.Callers{Text: "the checker"}}, + }}, + "other": {Seat: "other", Accepts: []seatdef.Message{msg("go", "", "seat:x")}}, + }) + if len(got) != 1 { + t.Fatalf("seats: %v, want node-build-go alone", got) + } + q := got["node-build-go"] + if !slices.Equal(q["check"], []string{"mesh-catalog", "mesh-pipeline-runner"}) || !slices.Equal(q["delivery"], []string{"mesh-builds"}) || len(q) != 2 { + t.Errorf("queues: %v", q) + } +} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 1442daf8..5fce46a5 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -106,6 +106,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // so a grant and its binding are written together, never one before the other (issue 1966, the kind of issue 249). d.Bindings = in.Bindings[m.Module] withEventSubjects(in.EventSubjects, &d) + withAcceptCallers(in.AcceptCallers, &d) return d } d := declare(m) @@ -137,6 +138,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { out.Engine = &buscompose.Declared{Module: m.Module, Consumes: m.Consumes, Watches: watches, Invokes: m.Invokes, State: bucketsFor(m, in.Nodes), Logs: logsOf(m), Streams: streamsOf(m)} withEventSubjects(in.EventSubjects, out.Engine) + withAcceptCallers(in.AcceptCallers, out.Engine) } return out, gaps } From 6182730923f21bf0332f81b486d9ac956f3f3bca Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:58:25 +0100 Subject: [PATCH 09/18] GR2: grants_callers and grants_why_refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first reads the callers each verb and accepted kind of the seat definitions admits, the second says which call subject an account lacks (the bus's own nats_user_can) beside the definition's callers. Builds: design 47 G §83, §391, §395 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-grants/cmd/mesh-grants/tools.go | 12 ++ .../internal/buscompose/callers.go | 3 + .../mesh-grants/internal/grants/callers.go | 139 ++++++++++++++++++ .../internal/grants/callers_test.go | 57 +++++++ modules/mesh-grants/internal/grants/tools.go | 2 +- modules/mesh-grants/module.json | 4 +- 6 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/callers.go create mode 100644 modules/mesh-grants/internal/grants/callers_test.go diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 54971dea..93153dfc 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -257,6 +257,18 @@ func toolsOf(p *grants.Part) []stdio.Tool { "composed yet by this version of the part, with its step, the acting part, its version, since when, its bound (10 minutes) and " + "clock-from (the start of this version's first pass), and which have passed their bound. Read-only.", Run: func(map[string]any) (any, error) { return p.WaitsOf() }}, + {Name: grants.ToolCallers, Description: "The callers each verb and each accepted kind of the seat definitions admits (design 47 G §83): " + + "the list the definition names, or the default of the verb's class. Name a seat, or a seat and a verb, or neither for every one. Read-only.", + Input: schema(map[string]string{"seat": "a seat's name (default: every seat)", "verb": "a verb or accepted kind (default: every one)"}), + Run: func(a map[string]any) (any, error) { + return p.Callers(grants.Answer(a, "seat"), grants.Answer(a, "verb")) + }}, + {Name: grants.ToolWhyRefused, Description: "Why an account's call of a seat's verb is refused (design 47 G §83): the call subject the account " + + "lacks a grant for (the bus's own answer, nats_user_can) and the definition's callers that leave it out. Read-only.", + Input: schema(map[string]string{"account": "the bus user, e.g. g14.node-tools", "verb": "the verb as .", "node": "the node, for a node seat"}, "account", "verb"), + Run: func(a map[string]any) (any, error) { + return p.WhyRefused(grants.Answer(a, "account"), grants.Answer(a, "verb"), grants.Answer(a, "node")) + }}, {Name: grants.ToolRelease, Description: "Release one held granting wait by a person's act with why, recorded as a hand act " + "(the seat mesh-hand-acts) before it is released (design 47 G §276). grants_waits lists them.", Input: schema(map[string]string{"module": "the held entry's module", "why": "why, kept in the hand act"}, "module", "why"), diff --git a/modules/mesh-grants/internal/buscompose/callers.go b/modules/mesh-grants/internal/buscompose/callers.go index 5af25dd0..2cd8a779 100644 --- a/modules/mesh-grants/internal/buscompose/callers.go +++ b/modules/mesh-grants/internal/buscompose/callers.go @@ -115,3 +115,6 @@ func callerNamed(user string, pub, sub []string) ([]string, []string) { } return unique(pub), unique(sub) } + +// SeatToolSubject is where a seat's verb is served: `mesh.seat..tool.`, with the node last on a node seat. +func SeatToolSubject(s Seat, verb, node string) string { return seatToolSubject(s, verb, node) } diff --git a/modules/mesh-grants/internal/grants/callers.go b/modules/mesh-grants/internal/grants/callers.go new file mode 100644 index 00000000..677f053b --- /dev/null +++ b/modules/mesh-grants/internal/grants/callers.go @@ -0,0 +1,139 @@ +package grants + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "mesh-grants/internal/buscompose" +) + +// The tools that say who may call and why a call is refused (design 47 G §83, GR2). +const ( + ToolCallers = "grants_callers" + ToolWhyRefused = "grants_why_refused" +) + +// CallerLine is one verb's, or one accepted kind's, callers as a seat definition gives them. +type CallerLine struct { + Seat string `json:"seat"` + // Verb is the verb's name, or the accepted kind's. + Verb string `json:"verb"` + // Kind is `verb` or `accepts`. + Kind string `json:"kind"` + Class string `json:"class,omitempty"` + // Callers are the entries the verb admits: the list the definition names, or its class's default. + Callers []string `json:"callers"` + // Named says the definition names the list; false is the class's default (§391). + Named bool `json:"named"` +} + +// definitions are the seat definitions the part composes from, or why it has none. +func (p *Part) definitions() (map[string]seatdef.Definition, error) { + d, ok := p.SeatList.(SeatDefinitions) + if !ok { + return nil, errors.New("the seat list is not read here: the definitions' callers are unknown") + } + defs := d.Definitions() + if len(defs) == 0 { + return nil, errors.New("the seat list holds no definitions yet") + } + return defs, nil +} + +// CallerLines are the callers each verb and each accepted kind admits, from the definitions, of the seat named (every seat +// when empty) and the verb named (every verb when empty). +func CallerLines(defs map[string]seatdef.Definition, seat, verb string) []CallerLine { + var out []CallerLine + for _, d := range defs { + if seat != "" && d.Seat != seat { + continue + } + for _, v := range d.Verbs { + if verb == "" || v.Name == verb { + out = append(out, CallerLine{Seat: d.Seat, Verb: v.Name, Kind: "verb", Class: v.Class, + Callers: v.EffectiveCallers(), Named: v.Callers != nil && !v.Callers.IsText()}) + } + } + for _, a := range d.Accepts { + if verb == "" || a.Name == verb { + l := CallerLine{Seat: d.Seat, Verb: a.Name, Kind: "accepts"} + if a.Callers != nil && !a.Callers.IsText() { + l.Callers, l.Named = a.Callers.List, true + } + out = append(out, l) + } + } + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + return a.Seat+"\x00"+a.Kind+"\x00"+a.Verb < b.Seat+"\x00"+b.Kind+"\x00"+b.Verb + }) + return out +} + +// Callers answers the tool grants_callers. +func (p *Part) Callers(seat, verb string) (any, error) { + defs, err := p.definitions() + if err != nil { + return nil, err + } + lines := CallerLines(defs, seat, verb) + if len(lines) == 0 { + return nil, fmt.Errorf("no seat definition names a verb or an accepted kind matching seat %q verb %q", seat, verb) + } + return map[string]any{"callers": lines, "count": len(lines)}, nil +} + +// WhyRefused answers the tool grants_why_refused: the grant an account lacks to call address (`.`), and the +// definition's line that leaves it out. The bus answers whether the account may publish the verb's call subject +// (nats.nats_user_can, the account's grants as the server holds them); the definition's callers say who was meant to. +func (p *Part) WhyRefused(account, address, node string) (any, error) { + seat, verb, ok := strings.Cut(address, ".") + if account == "" || !ok || seat == "" || verb == "" { + return nil, errors.New("why_refused names an account and a verb as .") + } + defs, err := p.definitions() + if err != nil { + return nil, err + } + lines := CallerLines(defs, seat, verb) + if len(lines) == 0 { + return nil, fmt.Errorf("no seat definition names the verb %s", address) + } + def := defs[seat] + s := buscompose.Seat{Name: seat, Scope: def.Scope} + subject := buscompose.CalledSubject(buscompose.SeatToolSubject(s, verb, node), account) + if subject == "" { + return nil, fmt.Errorf("%s is not the name of a bus user", account) + } + out := map[string]any{"account": account, "verb": address, "subject": subject, "definition": lines} + raw, err := p.Call.Call(CallUserCan, map[string]any{"user": account, "action": "publish", "subject": subject}) + if err != nil { + out["bus"] = "not read: " + err.Error() + return out, nil + } + var can struct { + Allowed *bool `json:"allowed"` + } + _ = json.Unmarshal(raw, &can) + out["bus"] = json.RawMessage(raw) + switch { + case can.Allowed == nil: + out["says"] = "the bus's answer names no verdict; read it beside the definition's line" + case *can.Allowed: + out["says"] = "not refused at the bus: the account holds the grant to publish " + subject + default: + callers := lines[0].Callers + out["says"] = fmt.Sprintf("the account lacks the grant to publish %s; the definition admits %s (%s), and the grants part grants a call only to a caller one of those entries names", + subject, strings.Join(callers, ", "), map[bool]string{true: "named by the definition", false: "the class's default"}[lines[0].Named]) + out["lacks"] = subject + out["admitted_by"] = slices.Clone(callers) + } + return out, nil +} diff --git a/modules/mesh-grants/internal/grants/callers_test.go b/modules/mesh-grants/internal/grants/callers_test.go new file mode 100644 index 00000000..2c73338b --- /dev/null +++ b/modules/mesh-grants/internal/grants/callers_test.go @@ -0,0 +1,57 @@ +package grants + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +type defsGate struct{ defs map[string]seatdef.Definition } + +func (defsGate) MayAct(...string) bool { return true } +func (g defsGate) Definitions() map[string]seatdef.Definition { return g.defs } + +type canCall struct { + allowed bool + asked map[string]any +} + +func (c *canCall) Call(key string, body any) (json.RawMessage, error) { + c.asked = body.(map[string]any) + return json.Marshal(map[string]any{"allowed": c.allowed}) +} + +// grants_callers and grants_why_refused read the definitions' callers (design 47 G §83): a verb that names none takes its +// class's default, and a refused account is told the call subject it lacks and the callers the definition admits. +func TestCallersAndWhyRefused(t *testing.T) { + defs := map[string]seatdef.Definition{"mesh-wants": {Seat: "mesh-wants", Scope: "mesh", Verbs: []seatdef.Verb{ + {Name: "upgrade", Class: seatdef.ClassAct}, + {Name: "entry", Callers: &seatdef.Callers{List: []string{"seat:mesh-pipeline-runner", "operator"}}}, + }}} + lines := CallerLines(defs, "mesh-wants", "") + if len(lines) != 2 || lines[0].Verb != "entry" || !lines[0].Named || lines[1].Named || + strings.Join(lines[1].Callers, ",") != "operator,sessions" { + t.Fatalf("lines: %+v", lines) + } + can := &canCall{} + p := &Part{SeatList: defsGate{defs}, Call: can} + got, err := p.WhyRefused("one.shop", "mesh-wants.upgrade", "") + if err != nil { + t.Fatal(err) + } + out := got.(map[string]any) + if out["lacks"] != "mesh.seat.mesh-wants.call.upgrade.one~shop" || can.asked["subject"] != out["lacks"] || + !strings.Contains(out["says"].(string), "operator, sessions") { + t.Errorf("a refused caller: %+v", out) + } + can.allowed = true + got, _ = p.WhyRefused("one.shop", "mesh-wants.upgrade", "") + if _, lacks := got.(map[string]any)["lacks"]; lacks { + t.Errorf("an allowed caller lacks a grant: %+v", got) + } + if _, err := p.WhyRefused("one.shop", "mesh-wants.nothing", ""); err == nil { + t.Error("a verb no definition names was explained") + } +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 1942cb64..0f2c3fe4 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -40,7 +40,7 @@ const ( // Tools is every tool, in the manifest's order. var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch, - ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain} + ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain, ToolCallers, ToolWhyRefused} // 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). diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 7b3efe8b..4e112870 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -73,7 +73,9 @@ "grants_waits", "grants_release", "grants_seat_list", - "grants_seat_list_again" + "grants_seat_list_again", + "grants_callers", + "grants_why_refused" ], "state": [ { From 3eb07835f7b977eb26fe76cac00587f9dd98edee Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:59:50 +0100 Subject: [PATCH 10/18] GR6: a state's -objects store is granted to the state's owner alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §58, §91 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/buscompose/busstate.go | 5 +++ .../internal/buscompose/state_objects_test.go | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 modules/mesh-grants/internal/buscompose/state_objects_test.go diff --git a/modules/mesh-grants/internal/buscompose/busstate.go b/modules/mesh-grants/internal/buscompose/busstate.go index f0716943..7b868fea 100644 --- a/modules/mesh-grants/internal/buscompose/busstate.go +++ b/modules/mesh-grants/internal/buscompose/busstate.go @@ -249,6 +249,11 @@ func boundOf(module string, bindings []membership.BusBinding) boundGrants { case catalogue.StorageKeyValue: if b.Bucket == base { g.States = append(g.States, b.Local) + // Its object store beside it, `-objects`, is its owner's alone (design 47 G §58, §91): the one bucket the + // binding names for this module's own state. + if b.Objects != "" && b.Objects == module+"_"+catalogue.StateObjectsName(b.Local) { + g.Objects = append(g.Objects, b.Objects) + } } case catalogue.StorageObjectStore: if b.ObjectStore == base { diff --git a/modules/mesh-grants/internal/buscompose/state_objects_test.go b/modules/mesh-grants/internal/buscompose/state_objects_test.go new file mode 100644 index 00000000..8a3975a4 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/state_objects_test.go @@ -0,0 +1,38 @@ +package buscompose + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/membership" +) + +// Design 47 G §58, §59, §91, §124: a state's `-objects` store is its owner's alone, and a sent store is read, never +// written, by the modules it is for. +func TestStateObjectsAndSentStores(t *testing.T) { + owner, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", Bound: []membership.BusBinding{ + {Local: "orders", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "shop_orders", Objects: "shop_orders-objects"}}}) + if err != nil { + t.Fatal(err) + } + other, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "bank"}) + if err != nil { + t.Fatal(err) + } + for _, subject := range []string{"$O.shop_orders-objects.C.x", "$O.shop_orders-objects.M.x", "$JS.API.DIRECT.GET.OBJ_shop_orders-objects.x"} { + if !MayPublish(owner, subject) { + t.Errorf("the owner may not publish %s", subject) + } + if MayPublish(other, subject) { + t.Errorf("another module may publish %s", subject) + } + } + reader, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "bank", ReadsObjects: []string{"shop_sent-objects"}}) + if err != nil { + t.Fatal(err) + } + if !MayPublish(reader, "$JS.API.DIRECT.GET.OBJ_shop_sent-objects.x") || MayPublish(reader, "$O.shop_sent-objects.C.x") || + MayPublish(reader, "$JS.API.DIRECT.GET.OBJ_other_sent-objects.x") { + t.Error("a reader of a sent store reads that one and writes none") + } +} From 41ecd473dab3dd75ba90a48ca5208b21437429ac Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:59:54 +0100 Subject: [PATCH 11/18] GR5: a module's sent store is read by the callers and consumers it is for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derived at every pass from the catalog graph's manifests and the seat definitions' callers, never from a manifest field. Builds: design 47 G §59, §124, §442 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-grants/internal/grants/inputs.go | 3 + .../mesh-grants/internal/grants/records.go | 3 + .../internal/grants/sentobjects.go | 86 +++++++++++++++++++ .../internal/grants/sentobjects_test.go | 38 ++++++++ 4 files changed, 130 insertions(+) create mode 100644 modules/mesh-grants/internal/grants/sentobjects.go create mode 100644 modules/mesh-grants/internal/grants/sentobjects_test.go diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 0308ae7a..1db94a80 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -192,6 +192,8 @@ type Inputs struct { EventSubjects map[string]map[string]string // AcceptCallers are, by pulled-by-kind seat and queue, the seats that may ask it (acceptCallersOf). AcceptCallers map[string]map[string][]string + // VerbCallers are, by seat and verb, the callers each verb of the definitions admits (verbCallersOf). + VerbCallers map[string]map[string][]string // Defined are the events a seat definition of the seat list defines, by local name, and mesh-sdk's finding events: the // events a `*` emitter before them reaches each declaring module's own stream for (design 47 G §386, §474, §644). Defined map[string]bool @@ -373,6 +375,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Chunked = buscompose.ChunkVerbsOf(d.Definitions()) in.EventSubjects = eventSubjectsOf(d.Definitions()) in.AcceptCallers = acceptCallersOf(d.Definitions()) + in.VerbCallers = verbCallersOf(d.Definitions()) in.Defined = definedEventsOf(d.Definitions()) } nodes := map[string]bool{} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 5fce46a5..2fa9228b 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -102,6 +102,9 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { d.Watches, d.Consumes, d.Hears, keptHeard = keptOutOfHearing(in.Kept, d.Watches, d.Consumes, d.Hears) d.HearsAStream = d.HearsAStream || keptHeard d.Reads, d.ReadsObjects, d.ReadsRefused = readsOfObjects(m.Module, node, d.Reads, d.ReadsRefused, declared, values) + // The sent stores of the modules it is for (design 47 G §59, §124), read from the catalog graph's manifests and the + // definitions' callers. + d.ReadsObjects = append(d.ReadsObjects, sentStoresRead(m, in.Manifests, in.VerbCallers)...) // Its bindings as kept (bindings.go): the same source its membership's `bindings` is written from in this pass, // so a grant and its binding are written together, never one before the other (issue 1966, the kind of issue 249). d.Bindings = in.Bindings[m.Module] diff --git a/modules/mesh-grants/internal/grants/sentobjects.go b/modules/mesh-grants/internal/grants/sentobjects.go new file mode 100644 index 00000000..fc0082f1 --- /dev/null +++ b/modules/mesh-grants/internal/grants/sentobjects.go @@ -0,0 +1,86 @@ +package grants + +import ( + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// **A module's sent store is read by the modules it is for** (design 47 G §59, §124): the grants part derives, from the +// catalog graph and the seat definitions, never from a manifest field, a standing read of `_sent-objects` for the +// callers the module's seat definition names for the verbs it serves, and for the consumers whose `consumes` declare its +// events. The grant follows the catalog graph, so it is composed with each module's declaration at every pass. + +// verbCallersOf is, by seat and verb, the callers each verb of the definitions admits (a verb naming none takes its class's +// default). +func verbCallersOf(defs map[string]seatdef.Definition) map[string]map[string][]string { + out := map[string]map[string][]string{} + for _, d := range defs { + for _, v := range d.Verbs { + if out[d.Seat] == nil { + out[d.Seat] = map[string][]string{} + } + out[d.Seat][v.Name] = v.EffectiveCallers() + } + } + return out +} + +// admits says whether a verb's callers name the module: `invokers` (every module whose manifest invokes the verb), the module +// itself, or a seat it claims. +func admits(callers []string, m catalogue.Manifest) bool { + for _, c := range callers { + switch { + case c == seatdef.CallerInvokers, c == seatdef.CallerModulePrefix+m.Module: + return true + } + if seat, ok := strings.CutPrefix(c, seatdef.CallerSeatPrefix); ok && slices.ContainsFunc(m.Claims, func(x catalogue.Claim) bool { return x.Name == seat }) { + return true + } + } + return false +} + +// sentStoresRead are the bus names of the sent stores the module m reads: of each module whose event it consumes (the module's +// own, or the seat a module claims), and of each module claiming a seat whose verb m invokes where the verb's callers admit m. +// Never m's own store, which its binding grants it. +func sentStoresRead(m catalogue.Manifest, all map[string]catalogue.Manifest, callers map[string]map[string][]string) []string { + claimants := func(name string) []string { + var out []string + if _, ok := all[name]; ok { + out = append(out, name) + } + for module, x := range all { + if slices.ContainsFunc(x.Claims, func(c catalogue.Claim) bool { return c.Name == name }) { + out = append(out, module) + } + } + return out + } + var stores []string + for _, c := range m.Consumes { + name, _, ok := strings.Cut(strings.TrimPrefix(c, "seat:"), ".") + if !ok || name == "*" { + continue + } + for _, module := range claimants(name) { + stores = append(stores, catalogue.SentObjectsName(module)) + } + } + for _, in := range m.Invokes { + seat, verb, ok := catalogue.SeatInvoke(in) + if !ok || !admits(callers[seat][verb], m) { + continue + } + for module, x := range all { + if slices.ContainsFunc(x.Claims, func(c catalogue.Claim) bool { return c.Name == seat }) { + stores = append(stores, catalogue.SentObjectsName(module)) + } + } + } + stores = slices.DeleteFunc(stores, func(s string) bool { return s == catalogue.SentObjectsName(m.Module) }) + slices.Sort(stores) + return slices.Compact(stores) +} diff --git a/modules/mesh-grants/internal/grants/sentobjects_test.go b/modules/mesh-grants/internal/grants/sentobjects_test.go new file mode 100644 index 00000000..28e7c6cd --- /dev/null +++ b/modules/mesh-grants/internal/grants/sentobjects_test.go @@ -0,0 +1,38 @@ +package grants + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// The sent stores a module reads follow the catalog graph (design 47 G §59, §124): the consumers of a module's events and the +// callers a seat's verb admits, and no other module. +func TestSentStoresRead(t *testing.T) { + holder := catalogue.Manifest{Module: "shop", Claims: []catalogue.Claim{{Name: "orders-seat"}}} + all := map[string]catalogue.Manifest{"shop": holder, "bank": {Module: "bank"}, "plain": {Module: "plain"}} + callers := map[string]map[string][]string{"orders-seat": { + "place": {"seat:billing", "module:plain"}, "list": seatdef.DefaultCallers(seatdef.ClassRead), "wipe": seatdef.DefaultCallers(seatdef.ClassAct)}} + for _, c := range []struct { + name string + m catalogue.Manifest + want []string + }{ + {"consumes a module's event", catalogue.Manifest{Module: "bank", Consumes: []string{"shop.sold"}}, []string{"shop_sent-objects"}}, + {"consumes a seat's event", catalogue.Manifest{Module: "bank", Consumes: []string{"orders-seat.sold"}}, []string{"shop_sent-objects"}}, + {"invokes a verb that names its seat", catalogue.Manifest{Module: "bank", Claims: []catalogue.Claim{{Name: "billing"}}, + Invokes: []string{"seat:orders-seat.place"}}, []string{"shop_sent-objects"}}, + {"invokes a verb that names it", catalogue.Manifest{Module: "plain", Invokes: []string{"seat:orders-seat.place"}}, []string{"shop_sent-objects"}}, + {"invokes a read, its callers include invokers", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.list"}}, []string{"shop_sent-objects"}}, + {"invokes an act no one names it for", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.wipe"}}, nil}, + {"invokes a verb that names others", catalogue.Manifest{Module: "bank", Invokes: []string{"seat:orders-seat.place"}}, nil}, + {"declares neither", catalogue.Manifest{Module: "bank"}, nil}, + {"its own store is its binding's", holder, nil}, + } { + if got := sentStoresRead(c.m, all, callers); !slices.Equal(got, c.want) { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} From 56c45eb7d68956090be5d468685d16d63f5ba8b5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:01:44 +0100 Subject: [PATCH 12/18] RN8: a newer commit or publish supersedes the older running pipeline, its asks dropped where they stand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step keeps the node whose holder said ask-taken; a superseded step's drop is asked of that node's holder, an untaken ask's of any holder, as kept calls; steps on mesh-builds and mesh-wants are only ended superseded. A pipeline a publish started says that publish (published), so a newer publish of the package supersedes it. Builds: design 47 G §31, §119, §350, A-182, A-202 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../go/seats/meshpipelinerunner/contract.go | 16 +- .../seats/mesh-pipeline-runner/seat.json | 26 +++ .../ts/seats/meshpipelinerunner/index.d.ts | 12 ++ .../cmd/mesh-delivery-pipeline/drop.go | 41 +++++ .../cmd/mesh-delivery-pipeline/main.go | 12 +- .../cmd/mesh-delivery-pipeline/steps.go | 12 +- .../internal/pipeline/start.go | 14 +- .../internal/pipeline/start_test.go | 13 +- .../internal/pipeline/supersede.go | 155 ++++++++++++++++++ .../internal/pipeline/supersede_test.go | 93 +++++++++++ .../mesh-delivery-pipeline/manifest_test.go | 2 + modules/mesh-delivery-pipeline/module.json | 5 +- 12 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drop.go create mode 100644 modules/mesh-delivery-pipeline/internal/pipeline/supersede.go create mode 100644 modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go diff --git a/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner/contract.go b/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner/contract.go index 7b0bfe66..307ffd31 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner/contract.go @@ -432,8 +432,10 @@ type Pipeline struct { // yes when the module is over its test budget (design 47 G section 419) OverBudget bool `json:"over-budget,omitempty"` // the pipeline's key, .., a branch escaped as a key's name is - Pipeline string `json:"pipeline,omitempty"` - Repository string `json:"repository,omitempty"` + Pipeline string `json:"pipeline,omitempty"` + // for a pipeline a package's publish started, that publish (A-202) + Published *Published `json:"published,omitempty"` + Repository string `json:"repository,omitempty"` // the catalog graph revision of the branch the pipeline runs for Revision int64 `json:"revision,omitempty"` // the run on request's id, for a pipeline of a run; such a pipeline delivers nothing @@ -545,6 +547,8 @@ type PipelineStarted struct { Module string `json:"module"` // the pipeline's key, .., a branch escaped as a key's name is Pipeline string `json:"pipeline"` + // for a pipeline a package's publish started, that publish (A-202) + Published *Published `json:"published,omitempty"` // the catalog graph revision of the branch the pipeline runs for Revision int64 `json:"revision,omitempty"` // the run on request's id, for a pipeline of a run; such a pipeline delivers nothing @@ -556,6 +560,14 @@ type PipelineStarted struct { V int64 `json:"v"` } +// Published is the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202). +type Published struct { + // the package whose publish started the pipeline + Artifact string `json:"artifact"` + // the version that publish named + Version string `json:"version"` +} + // RunStarted is a run on request's start, with its members at their commits (ADR 0666 section 18). type RunStarted struct { At *time.Time `json:"at,omitempty"` diff --git a/modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json b/modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json index 01778d80..a644b81e 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-pipeline-runner/seat.json @@ -907,6 +907,24 @@ "name" ] }, + "Published": { + "type": "object", + "properties": { + "artifact": { + "type": "string", + "description": "the package whose publish started the pipeline" + }, + "version": { + "type": "string", + "description": "the version that publish named" + } + }, + "description": "the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202)", + "required": [ + "artifact", + "version" + ] + }, "Pipeline": { "type": "object", "properties": { @@ -1002,6 +1020,10 @@ "version": { "type": "string", "description": "the version package-published names, for a pipeline the runner started from a publication or from outdated; none for any other (design 47 G 550)" + }, + "published": { + "$ref": "#/defs/Published", + "description": "for a pipeline a package's publish started, that publish (A-202)" } }, "description": "one pipeline as the runner keeps it in its state pipelines, by compare-and-set (design 47 *The runner's kept data*)", @@ -1191,6 +1213,10 @@ "at": { "type": "string", "format": "date-time" + }, + "published": { + "$ref": "#/defs/Published", + "description": "for a pipeline a package's publish started, that publish (A-202)" } }, "description": "a pipeline's start", diff --git a/modules/mesh-delivery-contracts/ts/seats/meshpipelinerunner/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshpipelinerunner/index.d.ts index 73c3dcb1..7a7cc39a 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshpipelinerunner/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshpipelinerunner/index.d.ts @@ -195,6 +195,8 @@ export interface Pipeline { "over-budget"?: boolean; /** the pipeline's key, .., a branch escaped as a key's name is */ "pipeline"?: string; + /** for a pipeline a package's publish started, that publish (A-202) */ + "published"?: Published; "repository"?: string; /** the catalog graph revision of the branch the pipeline runs for */ "revision"?: number; @@ -268,6 +270,8 @@ export interface PipelineStarted { "module": string; /** the pipeline's key, .., a branch escaped as a key's name is */ "pipeline": string; + /** for a pipeline a package's publish started, that publish (A-202) */ + "published"?: Published; /** the catalog graph revision of the branch the pipeline runs for */ "revision"?: number; /** the run on request's id, for a pipeline of a run; such a pipeline delivers nothing */ @@ -279,6 +283,14 @@ export interface PipelineStarted { "v": number; } +/** the publish of a package that started a pipeline: a newer publish of the same artifact on the same branch supersedes it (design 47 G section 31, A-202) */ +export interface Published { + /** the package whose publish started the pipeline */ + "artifact": string; + /** the version that publish named */ + "version": string; +} + /** a run on request's start, with its members at their commits (ADR 0666 section 18) */ export interface RunStarted { "at"?: string; diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drop.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drop.go new file mode 100644 index 00000000..8a3f1a05 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drop.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "fmt" + + nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo" + "git.novox.be/novox/mesh-sdk/go/buscalls" + + "mesh-delivery-pipeline/internal/pipeline" +) + +// keptCaller is the runner's kept calls, to any holder of a seat or to one node's (process.Conn). +type keptCaller interface { + CallKept(key string, body any) (json.RawMessage, error) + CallKeptOn(node, key string, body any) (json.RawMessage, error) +} + +// dropAsk asks the build seat's `drop` of a superseded step's ask as a kept call (design 47 G §31, §350; A-182): of the +// holder on the node that took it, or, never taken, of any holder, which records it in the seat's shared drop key. The +// three build seats' drop take one input (nodebuildgo.DropInput; the manifest invokes each). +func dropAsk(calls keptCaller, by string) pipeline.Drop { + return func(buildSeat, node, ask, why string) error { + key := "seat:" + buildSeat + "." + nodebuildgo.VerbDrop + in := nodebuildgo.DropInput{ID: ask, Why: why, By: by} + var raw json.RawMessage + var err error + if node != "" { + raw, err = calls.CallKeptOn(node, key, in) + } else { + raw, err = calls.CallKept(key, in) + } + if err != nil { + return err + } + if _, said, ok := buscalls.Unwrap(raw); !ok { + return fmt.Errorf("%s answered: %s", key, said) + } + return nil + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go index 9712039e..1bfb5453 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go @@ -119,13 +119,23 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro logger.Printf("the steps placed on %s and not yet asked: %d asked again", branch, n) } resumer.Store(&resume) + // A newer commit or publish supersedes the older running pipelines it replaces, their asks dropped as kept calls (§31, + // §350; A-182, A-202). + starter.Supersede = func(newer seat.Pipeline) { + n, err := hearer.Supersede(newer, dropAsk(calls, cred.Module), dropBound(ctx, bounds)) + if err != nil { + logger.Printf("the pipelines %s supersedes: %d ended, then %v", newer.Pipeline, n, err) + } else if n > 0 { + logger.Printf("the pipeline %s superseded %d older pipelines", newer.Pipeline, n) + } + } if err := hearStepEnded(heard, hearer, logger.Printf); err != nil { return err } if err := hearToolchain(ctx, heard, toolchains, logger.Printf); err != nil { return err } - if err := hearAskTaken(ctx, heard, bounds, logger.Printf); err != nil { + if err := hearAskTaken(ctx, heard, bounds, hearer.Taken, logger.Printf); err != nil { return err } if err := watchBounds(ctx, bounds, hearer, logger.Printf); err != nil { diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go index 7322ee79..c75823dc 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go @@ -135,9 +135,9 @@ func hearStepEnded(hub handlers, h *pipeline.Hearer, logf func(string, ...any)) return nil } -// hearAskTaken deletes the bound of each ask a build seat's holder took (§602, §647): the ask is answered, so its five minutes -// end with no finding. -func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue, logf func(string, ...any)) error { +// hearAskTaken deletes the bound of each ask a build seat's holder took (§602, §647) and keeps on its step the node that took +// it (A-182): the ask is answered, so its five minutes end with no finding. +func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue, taken func(ask, node string) error, logf func(string, ...any)) error { for _, ts := range pipeline.TakenSeats { ts := ts err := hub.Handle(ts.Subject, func(m jetstream.Msg) { @@ -152,6 +152,12 @@ func hearAskTaken(ctx context.Context, hub handlers, bounds jetstream.KeyValue, _ = m.Nak() return } + // The step keeps the node that took its ask, so a drop of it is asked of that node's holder (A-182). + if err := taken(ev.Ask, ev.Node); err != nil { + logf("the node %s that took ask %s is not kept on its step, heard again: %v", ev.Node, ev.Ask, err) + _ = m.Nak() + return + } _ = m.Ack() }) if err != nil { diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/start.go b/modules/mesh-delivery-pipeline/internal/pipeline/start.go index 9e5737aa..0d8c561c 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/start.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/start.go @@ -37,6 +37,8 @@ type Begin struct { Revision int64 // Entries are the catalog half's entries of the pipeline's branch: the steps' artifacts and build seats are read from them. Entries map[string]catalog.CatalogEntry + // Published is the package's publish that started the pipeline, nil for any other start (A-202). + Published *seat.Published } // Key is a pipeline's key in the state pipelines, `..`, the branch escaped as a key's @@ -70,6 +72,8 @@ type Starter struct { Toolchain func(language string) (*nodebuildgo.ToolchainUsed, error) // Arm arms the bound of an ask put on a build seat's work queue (§535, §647); nil arms none. Arm Arm + // Supersede ends the older running pipelines the pipeline just written supersedes (§31; Hearer.Supersede); nil ends none. + Supersede func(newer seat.Pipeline) } // Start writes b's pipeline, running, and says pipeline-started; false, nil when its key was written already (the second @@ -85,7 +89,7 @@ func (s *Starter) Start(b Begin) (bool, error) { key := Key(b.Branch, b.Source, b.Module) p := seat.Pipeline{V: 1, Pipeline: key, Branch: b.Branch, Source: b.Source, Module: b.Module, Commit: b.Commit, Fingerprint: b.Fp, Group: b.Group, Repository: b.Repository, Revision: b.Revision, Outcome: seat.PipelineOutcomeRunning, Started: &now, - Steps: []seat.StepResult{}} + Steps: []seat.StepResult{}, Published: b.Published} var asks []Asked if s.Registration != nil { if reg, ok := s.Registration(b.Module); ok { @@ -105,7 +109,7 @@ func (s *Starter) Start(b Begin) (bool, error) { return false, fmt.Errorf("the pipeline %s is not written: %w", key, err) } ev := seat.PipelineStarted{V: 1, At: &now, Pipeline: key, Branch: b.Branch, Source: b.Source, Module: b.Module, - Commit: b.Commit, Fingerprint: b.Fp, Group: b.Group, Revision: b.Revision} + Commit: b.Commit, Fingerprint: b.Fp, Group: b.Group, Revision: b.Revision, Published: b.Published} commitOrGroup := b.Commit if b.Group != "" { commitOrGroup = b.Group @@ -113,6 +117,9 @@ func (s *Starter) Start(b Begin) (bool, error) { if err := s.Say(runnerclient.Subject(seat.EventPipelineStarted, b.Module, b.Branch, commitOrGroup), ev); err != nil { s.logf("pipeline-started of %s is not said: %v", key, err) } + if s.Supersede != nil { + s.Supersede(p) + } if s.Steps != nil && len(asks) > 0 { s.carry(key, asks) if err := askAll(s.Steps, s.Arm, key, asks); err != nil { @@ -252,7 +259,8 @@ func (s *Starter) Published(ev builds.PackagePublished, outdated func(catalog.Ou if o.Kind != outdatedKind || o.Module == "" { continue } - b := Begin{Branch: ev.Branch, Source: ev.Source, Module: o.Module, Revision: half.Revision, Entries: half.Entries} + b := Begin{Branch: ev.Branch, Source: ev.Source, Module: o.Module, Revision: half.Revision, Entries: half.Entries, + Published: &seat.Published{Artifact: ev.Artifact, Version: ev.Version}} if e, ok := half.Entries[o.Module]; ok { b.Commit, b.Fp, b.Repository = e.Commit, e.Fp, e.Repository } else { diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/start_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/start_test.go index 7fcfec71..63fef192 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/start_test.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/start_test.go @@ -27,11 +27,15 @@ func (c created) Create(key string, value []byte) error { func TestStarts(t *testing.T) { store := created{} var said []string + var superseding []string s := &Starter{Pipelines: store, Say: func(subject string, body any) error { ev := body.(seat.PipelineStarted) said = append(said, subject+" "+ev.Pipeline) + if ev.Published == nil || ev.Published.Artifact != "mesh-sdk:go" || ev.Published.Version != "0.1.16" { + t.Errorf("pipeline-started of %s says the publish %+v (A-202)", ev.Pipeline, ev.Published) + } return nil - }} + }, Supersede: func(newer seat.Pipeline) { superseding = append(superseding, newer.Pipeline) }} view := func(branch string) (Half, bool) { return Half{Revision: 9, Entries: map[string]catalog.CatalogEntry{ "app": {Module: "app", Commit: "c1", Fp: "src2:aa", Repository: "o/app"}}}, true @@ -51,9 +55,13 @@ func TestStarts(t *testing.T) { } var p seat.Pipeline if err := json.Unmarshal(store["main.41.app"], &p); err != nil || p.Commit != "c1" || p.Fingerprint != "src2:aa" || p.Revision != 9 || - p.Outcome != seat.PipelineOutcomeRunning || p.Source != 41 { + p.Outcome != seat.PipelineOutcomeRunning || p.Source != 41 || p.Published == nil || p.Published.Version != "0.1.16" { t.Errorf("the pipeline main.41.app = %+v (%v)", p, err) } + // Each pipeline written supersedes the older ones it replaces (§31). + if len(superseding) != 2 { + t.Errorf("the publish's pipelines superseding: %v, want both", superseding) + } if _, ok := store["main.41.ordered"]; ok { t.Error("an ordering edge starts no pipeline") } @@ -66,6 +74,7 @@ func TestStarts(t *testing.T) { if !strings.HasPrefix(said[0], "mesh.seat.mesh-pipeline-runner.event.pipeline-started.app.main.c1 main.41.app") { t.Errorf("pipeline-started said on %q", said[0]) } + s.Say = func(string, any) error { return nil } // A branch holding a dot is escaped in the key, so the key reads back; a start naming no source revision is refused. if _, err := s.Start(Begin{Branch: "release.1", Source: 3, Module: "app"}); err != nil { t.Fatal(err) diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/supersede.go b/modules/mesh-delivery-pipeline/internal/pipeline/supersede.go new file mode 100644 index 00000000..292f171f --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/supersede.go @@ -0,0 +1,155 @@ +package pipeline + +import ( + "errors" + "fmt" + "time" + + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" +) + +// Drop asks a build seat's `drop` of a superseded step's ask as a kept call (design 47 G §31, §350; A-182): of node's holder +// when a holder took it (node the one its ask-taken named), else of any holder, which records it in the seat's shared drop +// key so no holder takes it. why names the newer commit. +type Drop func(buildSeat, node, ask, why string) error + +// Taken notes on the running step asked under ask the node whose holder said ask-taken for it (A-182), so a drop of it is +// asked of that node's holder. A step no pipeline keeps running under ask takes nothing. +func (h *Hearer) Taken(ask, node string) error { + if ask == "" || node == "" { + return nil + } + pkeys, err := h.Store.Keys("*.*.*") + if err != nil { + return fmt.Errorf("the pipelines are not listed: %w", err) + } + for _, key := range pkeys { + for { + p, rev, err := h.Store.Get(key) + if err != nil { + break + } + i := stepOfAsk(p, ask) + if i < 0 || p.Steps[i].Outcome != seat.StepResultOutcomeRunning || p.Steps[i].Node == node { + break + } + p.Steps[i].Node = node + if _, err := h.Store.Update(key, p, rev); errors.Is(err, ErrChanged) { + continue + } else if err != nil { + return fmt.Errorf("pipeline %s cannot be written: %w", key, err) + } + return nil + } + } + return nil +} + +// Supersede ends, for the pipeline newer just written, each older running pipeline of the same watched branch and module +// whose fingerprint newer's commit changed, and for a publish's pipeline each an older publish of the package started +// (design 47 G §31, §119; ADR 0666 §9; A-202): the pipeline and each step not ended +// `superseded`, naming the newer commit, and it says pipeline-ended. A module the newer commit left as it was finishes its +// pipeline. The ask of each superseded step on a build seat that serves `drop` is dropped (drop; its bound deleted through +// unbound); a step on mesh-builds or mesh-wants, whose seats serve no drop, is only ended superseded (A-182). A run on +// request's pipeline neither supersedes nor is superseded. It says how many pipelines it ended. +func (h *Hearer) Supersede(newer seat.Pipeline, drop Drop, unbound func(ask string) error) (int, error) { + if newer.Run != "" || newer.Source <= 0 { + return 0, nil + } + pkeys, err := h.Store.Keys("*.*.*") + if err != nil { + return 0, fmt.Errorf("the pipelines are not listed: %w", err) + } + why := supersededWhy(newer) + ended := 0 + var first error + for _, key := range pkeys { + if key == newer.Pipeline { + continue + } + done, err := h.supersedeOne(key, newer, why, drop, unbound) + if err != nil && first == nil { + first = err + } + if done { + ended++ + } + } + return ended, first +} + +// superseded says whether old is a running pipeline newer supersedes (§31, A-202): of the same watched branch and an older +// source revision, either of the same module at another fingerprint, or, for newer a package's publish started, one an older +// publish of that package started (its version another, made from an older source revision of the branch). +func superseded(old, newer seat.Pipeline) bool { + if old.Outcome != seat.PipelineOutcomeRunning || old.Run != "" || old.Branch != newer.Branch || old.Source >= newer.Source { + return false + } + if newer.Published != nil && old.Published != nil && old.Published.Artifact == newer.Published.Artifact { + return old.Published.Version != newer.Published.Version + } + return old.Module == newer.Module && old.Fingerprint != newer.Fingerprint +} + +// supersededWhy names the newer commit, or the newer publish, that ended a pipeline. +func supersededWhy(newer seat.Pipeline) string { + if newer.Published != nil { + return fmt.Sprintf("the newer publish %s %s of %s supersedes it", newer.Published.Artifact, newer.Published.Version, newer.Branch) + } + return fmt.Sprintf("the newer commit %s of %s changed %s's fingerprint", newer.Commit, newer.Branch, newer.Module) +} + +func (h *Hearer) supersedeOne(key string, newer seat.Pipeline, why string, drop Drop, unbound func(string) error) (bool, error) { + for { + p, rev, err := h.Store.Get(key) + if err != nil || !superseded(p, newer) { + return false, nil + } + now := h.now() + var drops []seat.StepResult + var changed []int + for i := range p.Steps { + s := &p.Steps[i] + if s.Outcome != seat.StepResultOutcomeRunning && s.Outcome != seat.StepResultOutcomeWaiting { + continue + } + if s.Ask != "" && BoundSeat(s.Seat) { + drops = append(drops, *s) + } + s.Outcome, s.Why, s.Ended = seat.StepResultOutcomeSuperseded, why, ptrTime(now) + changed = append(changed, i) + } + p.Outcome, p.Why, p.Ended = seat.PipelineOutcomeSuperseded, why, ptrTime(now) + p.EndCount++ + if _, err := h.Store.Update(key, p, rev); errors.Is(err, ErrChanged) { + continue + } else if err != nil { + return false, fmt.Errorf("pipeline %s cannot be written: %w", key, err) + } + var first error + for _, i := range changed { + h.logEnd(key, p.Steps[i], now) + if err := h.say(seat.EventStepEnded, p, &p.Steps[i]); err != nil && first == nil { + first = fmt.Errorf("pipeline %s's %s is written but not said: %w", key, seat.EventStepEnded, err) + } + } + for _, s := range drops { + if drop != nil { + if err := drop(s.Seat, s.Node, s.Ask, why); err != nil { + h.logf("the drop of ask %s of the superseded %s is not answered: %v", s.Ask, key, err) + } + } + if unbound != nil { + if err := unbound(s.Ask); err != nil { + h.logf("the bound of ask %s of the superseded %s is not dropped: %v", s.Ask, key, err) + } + } + } + if err := h.said(p, false, true); err != nil && first == nil { + first = err + } + return true, first + } +} + +func ptrTime(t time.Time) *time.Time { return &t } diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go new file mode 100644 index 00000000..d6b96b54 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go @@ -0,0 +1,93 @@ +package pipeline + +import ( + "slices" + "strings" + "testing" + + builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshbuilds" + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" + nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo" +) + +// RN8 (design 47 G §31, §119, §350; A-182, A-202): a step keeps the node that took its ask; a newer commit changing the +// module's fingerprint, or a newer publish of the package that started a pipeline, ends the older running pipeline +// superseded, its asks dropped of the node that took each (any holder for one never taken), a mesh-builds step only ended; +// an unchanged fingerprint, a run on request and an equal publish supersede nothing. +func TestSupersede(t *testing.T) { + h, store, said, _, _, _ := fixture() + old := store[pkey] + old.p.Steps = append(old.p.Steps, seat.StepResult{Artifact: "program", Step: seat.StepResultStepComposition, Status: seat.StepResultStatusRequired, + Outcome: seat.StepResultOutcomeRunning, Ask: "b1", Seat: builds.Seat}) + store[pkey] = old + if err := h.Taken("a1", "n7"); err != nil { + t.Fatal(err) + } + if p, _, _ := store.Get(pkey); p.Steps[0].Node != "n7" || p.Steps[1].Node != "" { + t.Fatalf("after ask-taken of a1 on n7 the steps name %q and %q", p.Steps[0].Node, p.Steps[1].Node) + } + type dropped struct{ seat, node, ask string } + var drops []dropped + var unbound []string + drop := func(s, node, ask, why string) error { + if !strings.Contains(why, "c2") { + t.Errorf("the drop of %s names no newer commit: %q", ask, why) + } + drops = append(drops, dropped{s, node, ask}) + return nil + } + unbind := func(ask string) error { unbound = append(unbound, ask); return nil } + newer := func(key string, source int64, fp string, run string, pub *seat.Published) seat.Pipeline { + return seat.Pipeline{Pipeline: key, Branch: "main", Source: source, Module: "app", Commit: "c2", Fingerprint: fp, Run: run, Published: pub, + Outcome: seat.PipelineOutcomeRunning} + } + for _, c := range []struct { + name string + newer seat.Pipeline + }{ + {"an unchanged fingerprint", newer("main.6.app", 6, "src2:aa", "", nil)}, + {"a run on request", newer("main.r1.app", 6, "src2:bb", "r1", nil)}, + {"an older source revision", newer("main.4.app", 4, "src2:bb", "", nil)}, + } { + if n, err := h.Supersede(c.newer, drop, unbind); n != 0 || err != nil { + t.Errorf("%s superseded %d (%v)", c.name, n, err) + } + } + *said = nil + if n, err := h.Supersede(newer("main.6.app", 6, "src2:bb", "", nil), drop, unbind); n != 1 || err != nil { + t.Fatalf("a changed fingerprint superseded %d (%v)", n, err) + } + p, _, _ := store.Get(pkey) + if p.Outcome != seat.PipelineOutcomeSuperseded || p.EndCount != 1 || !strings.Contains(p.Why, "c2") { + t.Errorf("the older pipeline is %s (%d ends): %q", p.Outcome, p.EndCount, p.Why) + } + for _, s := range p.Steps { + if s.Outcome != seat.StepResultOutcomeSuperseded { + t.Errorf("the %s step is %s, want superseded", s.Step, s.Outcome) + } + } + want := []dropped{{nodebuildgo.Seat, "n7", "a1"}, {nodebuildgo.Seat, "", "a2"}} + if !slices.Equal(drops, want) || !slices.Equal(unbound, []string{"a1", "a2"}) { + t.Errorf("dropped %+v and unbound %v, want %+v (the mesh-builds step only ended)", drops, unbound, want) + } + if n := len(*said); n != 5 || (*said)[4] != seat.EventPipelineEnded+".app.main.c1" { + t.Errorf("said %v, want four step-ended then pipeline-ended", *said) + } + if n, _ := h.Supersede(newer("main.7.app", 7, "src2:cc", "", nil), drop, unbind); n != 0 { + t.Errorf("a superseded pipeline was superseded again: %d", n) + } + + // A newer publish of a package supersedes the pipelines an older publish of it started, whatever their module. + pub := memStore{"main.5.lib": {p: seat.Pipeline{Pipeline: "main.5.lib", Branch: "main", Source: 5, Module: "lib", Fingerprint: "src2:aa", + Outcome: seat.PipelineOutcomeRunning, Published: &seat.Published{Artifact: "core:pkg", Version: "v1"}}, rev: 1}} + h.Store = pub + if n, _ := h.Supersede(newer("main.6.app", 6, "src2:aa", "", &seat.Published{Artifact: "core:pkg", Version: "v1"}), drop, unbind); n != 0 { + t.Errorf("a publish of the same version superseded %d", n) + } + if n, err := h.Supersede(newer("main.6.app", 6, "src2:aa", "", &seat.Published{Artifact: "core:pkg", Version: "v2"}), drop, unbind); n != 1 || err != nil { + t.Fatalf("a newer publish superseded %d (%v)", n, err) + } + if p, _, _ := pub.Get("main.5.lib"); p.Outcome != seat.PipelineOutcomeSuperseded || !strings.Contains(p.Why, "v2") { + t.Errorf("the older publish's pipeline is %s: %q", p.Outcome, p.Why) + } +} diff --git a/modules/mesh-delivery-pipeline/manifest_test.go b/modules/mesh-delivery-pipeline/manifest_test.go index 58693b51..457b0298 100644 --- a/modules/mesh-delivery-pipeline/manifest_test.go +++ b/modules/mesh-delivery-pipeline/manifest_test.go @@ -84,6 +84,8 @@ func TestManifest(t *testing.T) { {"invokes", equalSets(m.Invokes, []string{"seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-hand-acts.hand-act", // RN3 (§53, §334): a publication's dependents behind. "seat:mesh-catalog.outdated", + // RN8 (§31, §350; A-182): a superseded step's ask dropped of the build seat that holds it. + "seat:node-build-go.drop", "seat:node-build-node.drop", "seat:node-build-image.drop", // A-74, A-89: who holds the seat operator-channel, whose state asks holds the router's record of the approval ask. "seat:mesh-module-assigner.assignments", // A-102 (§22, §24): the accepted kinds the runner puts its steps on. diff --git a/modules/mesh-delivery-pipeline/module.json b/modules/mesh-delivery-pipeline/module.json index e185be5c..e16d652f 100644 --- a/modules/mesh-delivery-pipeline/module.json +++ b/modules/mesh-delivery-pipeline/module.json @@ -54,7 +54,10 @@ "seat:mesh-wants.step", "seat:node-build-go.check.step", "seat:node-build-node.check.step", - "seat:node-build-image.check.step" + "seat:node-build-image.check.step", + "seat:node-build-go.drop", + "seat:node-build-node.drop", + "seat:node-build-image.drop" ], "uses": [ "operator-channel" From a4fcf3de5177db3fe5c1f9bb553cc8e92ac5a526 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:02:44 +0100 Subject: [PATCH 13/18] GR12: the seat mesh-grants claims its seat and serves grants-for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The part claims mesh-grants, serving grants-for: a run by its group run- or a source revision by the branch and the new optional source of mesh-catalog's revision (contract regenerated), compared with the branch's newest by the permissions the composed accounts gain and lose with the candidate's entries in. Builds: design 47 G §401, §441, §449, A-205 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../go/seats/meshcatalog/contract.go | 2 + .../seats/mesh-catalog/seat.json | 4 + .../ts/seats/meshcatalog/index.d.ts | 2 + modules/mesh-grants/cmd/mesh-grants/tools.go | 6 + modules/mesh-grants/grants_test.go | 64 +++++- .../mesh-grants/internal/grants/grantsfor.go | 188 ++++++++++++++++++ modules/mesh-grants/internal/grants/tools.go | 4 +- modules/mesh-grants/module.json | 9 + 8 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/grantsfor.go diff --git a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go index 504752fc..5919ecb2 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go @@ -1281,6 +1281,8 @@ type RevisionInput struct { Heads string `json:"heads,omitempty"` // a kept revision; absent: the newest of trunk, or of the candidate named Revision int64 `json:"revision,omitempty"` + // a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441) + Source int64 `json:"source,omitempty"` } // RevisionAnswer is what revision answers. diff --git a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json index 0233171e..1c776e5d 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json @@ -266,6 +266,10 @@ "type": "string", "description": "a candidate's heads" }, + "source": { + "type": "integer", + "description": "a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441)" + }, "after": { "type": "string", "description": "the last module of the page before; absent: the first page" diff --git a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts index dfe29ba2..9d60bb13 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts @@ -731,6 +731,8 @@ export interface RevisionInput { "heads"?: string; /** a kept revision; absent: the newest of trunk, or of the candidate named */ "revision"?: number; + /** a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441) */ + "source"?: number; } /** is what revision answers. */ diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 93153dfc..0ad31793 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" @@ -269,6 +270,11 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.WhyRefused(grants.Answer(a, "account"), grants.Answer(a, "verb"), grants.Answer(a, "node")) }}, + {Name: meshgrants.Seat + "." + meshgrants.VerbGrantsFor, + Description: "The grants a candidate would compose, per module (design 47 G §441): the grants it would add and remove against the branch's newest.", + Run: func(a map[string]any) (any, error) { + return p.GrantsFor(meshgrants.GrantsForInput{Branch: grants.Answer(a, "branch"), Run: grants.Answer(a, "run"), Source: grants.Answer(a, "source")}) + }}, {Name: grants.ToolRelease, Description: "Release one held granting wait by a person's act with why, recorded as a hand act " + "(the seat mesh-hand-acts) before it is released (design 47 G §276). grants_waits lists them.", Input: schema(map[string]string{"module": "the held entry's module", "why": "why, kept in the hand act"}, "module", "why"), diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 8f722f46..20ac0160 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -29,6 +29,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" @@ -91,8 +92,11 @@ type fakeBus struct { maxValue int // entries are the catalog half's entries by fingerprint, the manifest each holds as the indexer's manifests state has it // (entry); entriesAsked says every one is asked, not answered: the half cannot be read yet. - entries map[string]string - entriesAsked bool + entries map[string]string + entriesAsked bool + // revision is the catalog half of mesh-catalog's `revision`: the branch's `newest` and a `candidate` (a source or a group), each + // module's manifest. + revision map[string]map[string]string catalogWaited []string } @@ -363,6 +367,21 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) { } } return json.Marshal(map[string]any{"users": out}) + case grants.CallRevision: + var in meshcatalog.RevisionInput + raw, _ := json.Marshal(body) + _ = json.Unmarshal(raw, &in) + which := "newest" + if in.Group != "" || in.Source != 0 { + which = "candidate" + } + var out meshcatalog.RevisionAnswer + for module, manifest := range b.revision[which] { + var m map[string]any + _ = json.Unmarshal([]byte(manifest), &m) + out.Entries = append(out.Entries, meshcatalog.CatalogEntry{Module: module, Manifest: m}) + } + return json.Marshal(out) case grants.CallStreams: if stream, _ := args["stream"].(string); stream != "" { return json.Marshal(map[string]any{"detail": []map[string]any{{"name": stream, "consumer_detail": b.consumers[stream]}}}) @@ -602,10 +621,9 @@ func TestGrants(t *testing.T) { if err != nil { t.Fatalf("mesh-sdk cannot decode the manifest: %v", err) } - // The part claims no seat: the seat mesh-grants served the lease write alone, which went with the controller - // (novox/hq ADR 0590 §3, issue 1126). - if m.Module != "mesh-grants" || len(m.Claims) != 0 { - t.Errorf("module %q claims %v: the part claims no seat", m.Module, m.Claims) + // The part claims the seat mesh-grants, which serves `grants-for` (design 47 G §401, §441). + if m.Module != "mesh-grants" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-grants" || !slices.Equal(m.Claims[0].Serves, []string{"grants-for"}) { + t.Errorf("module %q claims %v: the part claims the seat mesh-grants alone, serving grants-for", m.Module, m.Claims) } for _, row := range []struct { what string @@ -5095,3 +5113,37 @@ func TestAnOldStateSectionDeclaresItsCap(t *testing.T) { } } } + +// grants-for answers what the composed accounts gain and lose with a candidate's entries in place of the branch's newest (design +// 47 G §441, A-205): a tool the candidate adds is an added grant naming its verb, and an input that names no source nor run is +// refused. +func TestGrantsFor(t *testing.T) { + b := fixture() + candidateX := strings.Replace(strings.Replace(xManifest, `,"logs":[{"name":"history","max-age-days":30}]`, "", 1), `"state"`, `"emits":["hello"],"state"`, 1) + b.revision = map[string]map[string]string{"newest": {"mod-x": xManifest}, "candidate": {"mod-x": candidateX}} + p := partOn(b) + got, err := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main", Run: "r1"}) + if err != nil || got.Error != "" || len(got.Modules) != 1 || got.Modules[0].Module != "mod-x" { + t.Fatalf("grants-for: %+v, %v", got, err) + } + var verbs []string + for _, g := range got.Modules[0].Added { + verbs = append(verbs, g.Verb+" "+g.Subject) + } + if !slices.ContainsFunc(got.Modules[0].Added, func(g meshgrants.Grant) bool { + return g.Verb == "hello" && strings.HasPrefix(g.Subject, "mesh.mod.mod-x.") + }) { + t.Errorf("the event the candidate adds is not an added grant: %v / %+v", verbs, got.Modules[0]) + } + if len(got.Modules[0].Removed) == 0 { + t.Error("the log the candidate drops is no removed grant") + } + b.revision["candidate"] = b.revision["newest"] + same, _ := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main", Source: "7"}) + if len(same.Modules) != 0 { + t.Errorf("a candidate equal to the newest changes grants: %+v", same.Modules) + } + if _, err := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main"}); err == nil { + t.Error("grants-for with neither a source nor a run answered") + } +} diff --git a/modules/mesh-grants/internal/grants/grantsfor.go b/modules/mesh-grants/internal/grants/grantsfor.go new file mode 100644 index 00000000..81985a6c --- /dev/null +++ b/modules/mesh-grants/internal/grants/grantsfor.go @@ -0,0 +1,188 @@ +package grants + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// **The seat mesh-grants' read `grants-for`** (design 47 G §441, §401, A-205): the grants a candidate would compose, per +// module, added and removed against the branch's newest. The candidate's catalog half is taken by mesh-catalog's `revision` (a +// run by its group `run-`, a source revision by the branch and `source`), the branch's newest by `revision` with the branch +// alone; the answer is what the composed memberships' permissions gain and lose with the candidate's entries swapped in for the +// branch's newest. + +// RunGroup is the group of a run's candidate in mesh-catalog's `revision` (A-205). +const RunGroup = "run-" + +// CallRevision is the seat mesh-catalog's `revision`, as the part calls it. +const CallRevision = "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbRevision + +// revisionEntries reads a catalog graph revision's entries, page after page, as manifests by module. none says the branch has +// no revision yet. +func (p *Part) revisionEntries(in meshcatalog.RevisionInput) (manifests map[string]catalogue.Manifest, none bool, err error) { + manifests = map[string]catalogue.Manifest{} + for { + raw, err := p.Call.Call(CallRevision, in) + if err != nil { + return nil, false, fmt.Errorf("%s does not answer: %w", CallRevision, err) + } + var a meshcatalog.RevisionAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, false, fmt.Errorf("%s answered what is not a revision: %w", CallRevision, err) + } + if a.NoneYet { + return nil, true, nil + } + for _, e := range a.Entries { + body, err := json.Marshal(e.Manifest) + if err != nil || len(e.Manifest) == 0 { + continue + } + m, err := catalogue.Decode(body) + if err != nil { + return nil, false, fmt.Errorf("the manifest of %s in the revision does not decode: %w", e.Module, err) + } + manifests[e.Module] = m + } + if a.Next == "" || a.Next == in.After { + return manifests, false, nil + } + in.After = a.Next + } +} + +// grantsOfEntries is, by module, the subjects the composed accounts of that module may publish or subscribe, composed from in with +// the entries' manifests in place of the trunk's and of what runs. +func grantsOfEntries(in Inputs, entries map[string]catalogue.Manifest) (map[string]map[string]bool, error) { + in.Manifests = maps.Clone(in.Manifests) + in.Running = maps.Clone(in.Running) + for module, m := range entries { + in.Manifests[module] = m + for key := range in.Running { + if strings.HasSuffix(key, "."+module) { + delete(in.Running, key) + } + } + } + recs, _ := ListRecords(in) + users, refused, err := ComposeEach(recs) + if err != nil { + return nil, err + } + if len(refused) > 0 { + user := sortedKeys(refused)[0] + return nil, fmt.Errorf("the grants of %s do not compose: %s", user, refused[user]) + } + out := map[string]map[string]bool{} + for _, u := range users { + _, module, ok := strings.Cut(u.User, ".") + if _, named := entries[module]; !ok || !named { + continue + } + if out[module] == nil { + out[module] = map[string]bool{} + } + for _, s := range append(slices.Clone(u.Publish), u.Subscribe...) { + out[module][s] = true + } + } + return out, nil +} + +// verbOf names the verb, event or tool a subject is for: `mesh.seat...…` and `mesh.mod...…`. +func verbOf(subject string) string { + parts := strings.Split(subject, ".") + if len(parts) >= 5 && parts[0] == "mesh" && (parts[1] == "seat" || parts[1] == "mod") { + return parts[4] + } + return "" +} + +func grantList(subjects []string) []meshgrants.Grant { + out := []meshgrants.Grant{} + for _, s := range subjects { + out = append(out, meshgrants.Grant{Subject: s, Verb: verbOf(s)}) + } + return out +} + +// GrantsFor answers the verb grants-for. +func (p *Part) GrantsFor(in meshgrants.GrantsForInput) (meshgrants.GrantsFor, error) { + if in.Branch == "" { + return meshgrants.GrantsFor{}, errors.New("grants-for names the candidate's watched branch") + } + candidate := meshcatalog.RevisionInput{Branch: in.Branch} + switch { + case in.Run != "": + candidate.Group = RunGroup + in.Run + case in.Source != "": + n, err := strconv.ParseInt(in.Source, 10, 64) + if err != nil { + return meshgrants.GrantsFor{}, fmt.Errorf("the source revision %q is not a number", in.Source) + } + candidate.Source = n + default: + return meshgrants.GrantsFor{}, errors.New("grants-for names a source revision or a run") + } + cand, none, err := p.revisionEntries(candidate) + if err != nil || none { + return meshgrants.GrantsFor{Error: orWhy(err, "the candidate's catalog half is not held: the branch has no revision of it")}, nil + } + newest, none, err := p.revisionEntries(meshcatalog.RevisionInput{Branch: in.Branch}) + if err != nil || none { + return meshgrants.GrantsFor{Error: orWhy(err, "the branch's newest catalog half is not held")}, nil + } + base := map[string]catalogue.Manifest{} + for module := range cand { + if m, ok := newest[module]; ok { + base[module] = m + } + } + input := p.inputs() + was, err := grantsOfEntries(input, base) + if err != nil { + return meshgrants.GrantsFor{Error: "the branch's newest does not compose: " + err.Error()}, nil + } + will, err := grantsOfEntries(input, cand) + if err != nil { + return meshgrants.GrantsFor{Error: "the candidate does not compose: " + err.Error()}, nil + } + var out meshgrants.GrantsFor + for _, module := range sortedKeys(will) { + var added, removed []string + for s := range will[module] { + if !was[module][s] { + added = append(added, s) + } + } + for s := range was[module] { + if !will[module][s] { + removed = append(removed, s) + } + } + if len(added)+len(removed) == 0 { + continue + } + sort.Strings(added) + sort.Strings(removed) + out.Modules = append(out.Modules, meshgrants.GrantsForModulesItem{Module: module, Added: grantList(added), Removed: grantList(removed)}) + } + return out, nil +} + +func orWhy(err error, why string) string { + if err != nil { + return err.Error() + } + return why +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 0f2c3fe4..49fd88e0 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -14,8 +14,8 @@ import ( "mesh-grants/internal/buscompose" ) -// The part's tools, as its manifest lists them. The part claims no seat: the seat mesh-grants served the lease write -// alone, which went with the controller (novox/hq ADR 0590 §3, issue 1126). +// The part's tools, as its manifest lists them. The part claims the seat mesh-grants, which serves `grants-for` (design 47 G +// §401, §441); its own tools are named below. const ( ToolGrants = "grants_grants" ToolMembership = "grants_membership" diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 4e112870..a5b98926 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -2,6 +2,15 @@ "module": "mesh-grants", "version": "0.1", "slug": "grants", + "claims": [ + { + "name": "mesh-grants", + "scope": "mesh", + "serves": [ + "grants-for" + ] + } + ], "reads": [ "mesh-config.resolved", "node-engine.have", From d9a9da921a31a878bf7044c7a6484c9a0af213f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:07:38 +0100 Subject: [PATCH 14/18] RN12: the runner runs on request, its pipelines placed from the run's candidate revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run writes each member's pipeline at once under the run's random id and its named branch and says run-started; the candidate run-'s catalog-revision, handed to the runner by meshcatalogclient's candidates hook, is read whole through the revision verb and places each member's steps and a pipeline of each entry the members change, saying pipeline-started once each. Builds: design 47 G §64, §373, §420, A-160, A-180, A-185, A-193, A-199 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../clients/meshcatalogclient/candidates.go | 36 +++ .../meshcatalogclient/catalog_view_test.go | 26 ++ .../cmd/mesh-delivery-pipeline/candidate.go | 43 ++++ .../mesh-delivery-pipeline/candidate_test.go | 36 +++ .../cmd/mesh-delivery-pipeline/main.go | 23 ++ .../cmd/mesh-delivery-pipeline/verbs.go | 5 +- .../cmd/mesh-delivery-pipeline/verbs_test.go | 2 +- .../cmd/mesh-delivery-pipeline/views.go | 8 +- .../internal/pipeline/run.go | 239 ++++++++++++++++++ .../internal/pipeline/run_test.go | 124 +++++++++ .../internal/pipeline/start.go | 5 +- .../internal/pipeline/verbs.go | 9 + 12 files changed, 551 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-delivery-contracts/go/clients/meshcatalogclient/candidates.go create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go create mode 100644 modules/mesh-delivery-pipeline/internal/pipeline/run.go create mode 100644 modules/mesh-delivery-pipeline/internal/pipeline/run_test.go diff --git a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/candidates.go b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/candidates.go new file mode 100644 index 00000000..5ca67701 --- /dev/null +++ b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/candidates.go @@ -0,0 +1,36 @@ +package meshcatalogclient + +import ( + "context" + "encoding/json" + "fmt" + + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// WireCandidatesProcess hands each candidate's `catalog-revision` to hook (design 47 G A-185, A-193, A-199): a candidate's +// revision no Catalog keeps, heard once on the event's one subscription of the process's reader, beside every watched +// branch's Catalog and whether or not the part follows any, so the runner places a run's pipelines on it. A trunk revision +// is passed over (each branch's Catalog keeps it); data that is no such event is an error. The part's manifest consumes the +// seat's event catalog-revision. +func WireCandidatesProcess(r *views.ProcessReader, hook func(ctx context.Context, ev seat.CatalogRevisionEvent) error) error { + if hook == nil { + return fmt.Errorf("meshcatalogclient: a candidates hook is a function") + } + return r.AdoptEvent(seat.Seat, seat.EventCatalogRevision, candidateHear(hook)) +} + +// candidateHear is the handler of catalog-revision that hands a candidate's revision to hook. +func candidateHear(hook func(ctx context.Context, ev seat.CatalogRevisionEvent) error) func(context.Context, []byte) error { + return func(ctx context.Context, data []byte) error { + var ev seat.CatalogRevisionEvent + if err := json.Unmarshal(data, &ev); err != nil || ev.Revision <= 0 { + return fmt.Errorf("meshcatalogclient: not a %s event: %s", seat.EventCatalogRevision, clipBytes(data)) + } + if ev.Kind != KindCandidate { + return nil + } + return hook(ctx, ev) + } +} diff --git a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/catalog_view_test.go b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/catalog_view_test.go index 6c74285e..760d8bfc 100644 --- a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/catalog_view_test.go +++ b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/catalog_view_test.go @@ -162,3 +162,29 @@ func TestCatalogIsAViewPerWatchedBranch(t *testing.T) { t.Error("a Catalog of no branch was made") } } + +// A-185, A-193, A-199: a candidate's catalog-revision reaches the hook, adopted on the event's one subscription of the +// reader, also with no watched branch's Catalog; a trunk revision does not, and data that is no such event is said. +func TestCandidatesReachTheHook(t *testing.T) { + var hooked []seat.CatalogRevisionEvent + hook := func(_ context.Context, ev seat.CatalogRevisionEvent) error { hooked = append(hooked, ev); return nil } + r := &views.ProcessReader{Module: "runner"} + if err := WireCandidatesProcess(r, hook); err != nil { + t.Fatal(err) + } + if err := WireCandidatesProcess(r, nil); err == nil { + t.Error("a nil hook was taken") + } + hear := candidateHear(hook) + for _, data := range [][]byte{revisionEvent("main", KindTrunk, 3), revisionEvent("main", KindCandidate, 4)} { + if err := hear(context.Background(), data); err != nil { + t.Fatal(err) + } + } + if err := hear(context.Background(), []byte(`not json`)); err == nil { + t.Error("data that is no catalog-revision was taken") + } + if len(hooked) != 1 || hooked[0].Revision != 4 || hooked[0].Kind != KindCandidate { + t.Errorf("the hook was handed %+v, want the candidate's revision 4 alone", hooked) + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go new file mode 100644 index 00000000..81fb00b2 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/json" + "fmt" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/buscalls" + + "mesh-delivery-pipeline/internal/pipeline" +) + +// readCandidate reads a candidate's revision whole through the catalog seat's verb revision, with the candidate's group and +// heads and the revision its event names, page by page (design 47 G A-180). +func readCandidate(call func(key string, body any) (json.RawMessage, error), ev catalog.CatalogRevisionEvent) (pipeline.Half, error) { + half := pipeline.Half{Revision: ev.Revision, Source: ev.Source, Entries: map[string]catalog.CatalogEntry{}} + after := "" + for { + raw, err := call(meshcatalogclient.RevisionVerb, catalog.RevisionInput{Branch: ev.Branch, Group: ev.Group, Heads: ev.Heads, Revision: ev.Revision, After: after}) + if err != nil { + return half, err + } + answer, said, ok := buscalls.Unwrap(raw) + if !ok { + return half, fmt.Errorf("%s answered: %s", meshcatalogclient.RevisionVerb, said) + } + var a catalog.RevisionAnswer + if err := json.Unmarshal(answer, &a); err != nil { + return half, fmt.Errorf("%s answered no revision: %w", meshcatalogclient.RevisionVerb, err) + } + if a.Revision == nil || a.Revision.Revision != ev.Revision || a.Revision.Group != ev.Group { + return half, fmt.Errorf("%s answered %+v for the candidate %s's revision %d", meshcatalogclient.RevisionVerb, a.Revision, ev.Group, ev.Revision) + } + for _, e := range a.Entries { + half.Entries[e.Module] = e + } + if a.Next == "" { + return half, nil + } + after = a.Next + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go new file mode 100644 index 00000000..21efca52 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "testing" + + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" +) + +// A-180: a run's candidate revision is read whole through the seat's revision verb with the candidate's group, heads and +// revision, page by page; an answer of another revision is refused. +func TestReadCandidateReadsTheRevisionWhole(t *testing.T) { + ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: "run-0a1b2c3d4e5f", Heads: "h", Revision: 12, Branch: "main"} + var asked []catalog.RevisionInput + answerRev := int64(12) + call := func(key string, body any) (json.RawMessage, error) { + in := body.(catalog.RevisionInput) + asked = append(asked, in) + a := catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: answerRev, Kind: "candidate", Group: in.Group}} + if in.After == "" { + a.Entries, a.Next = []catalog.CatalogEntry{{Module: "app", Fp: "src2:a"}}, "app" + } else { + a.Entries = []catalog.CatalogEntry{{Module: "lib", Fp: "src2:l"}} + } + return json.Marshal(a) + } + half, err := readCandidate(call, ev) + if err != nil || len(half.Entries) != 2 || half.Revision != 12 || len(asked) != 2 || asked[1].After != "app" || + asked[0].Group != ev.Group || asked[0].Heads != "h" || asked[0].Revision != 12 || asked[0].Branch != "main" { + t.Fatalf("read %+v (%v), asked %+v", half, err, asked) + } + answerRev = 13 + if _, err := readCandidate(call, ev); err == nil { + t.Error("an answer of another revision was taken") + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go index 1bfb5453..b5b04915 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go @@ -11,11 +11,13 @@ import ( "log" "os" "os/signal" + "strings" "sync/atomic" "syscall" "github.com/nats-io/nats.go" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" "git.novox.be/novox/mesh-sdk/go/process" @@ -81,10 +83,16 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // A branch's view holding its first half asks again the steps placed in its running pipelines that were never asked // (§88); the starter is made after the views, so it is reached through a pointer set once it is. var resumer atomic.Pointer[func(string)] + var placer atomic.Pointer[func(catalog.CatalogRevisionEvent) error] rv, err := startViews(ctx, js, conn, cred, hub, calls.State("findings"), calls.Call, func(branch string) { if f := resumer.Load(); f != nil { (*f)(branch) } + }, func(_ context.Context, ev catalog.CatalogRevisionEvent) error { + if f := placer.Load(); f != nil { + return (*f)(ev) + } + return fmt.Errorf("the candidate %s's revision %d is heard before the runner can place it", ev.Group, ev.Revision) }, logger.Printf) if err != nil { return err @@ -119,6 +127,21 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro logger.Printf("the steps placed on %s and not yet asked: %d asked again", branch, n) } resumer.Store(&resume) + starter.Store = pipelineStore{ctx: ctx, kv: pipelines} + // A run's candidate revision places its pipelines' steps, its entries read through the seat's revision verb (A-180). + place := func(ev catalog.CatalogRevisionEvent) error { + if !strings.HasPrefix(ev.Group, pipeline.RunGroupPrefix) { + return nil + } + half, err := readCandidate(calls.CallKept, ev) + if err != nil { + return fmt.Errorf("the candidate %s's revision %d is not read, so its run is not placed: %w", ev.Group, ev.Revision, err) + } + n, err := starter.PlaceRun(ev, half, rv.Catalog.View) + logger.Printf("the candidate %s's revision %d placed %d pipelines of its run", ev.Group, ev.Revision, n) + return err + } + placer.Store(&place) // A newer commit or publish supersedes the older running pipelines it replaces, their asks dropped as kept calls (§31, // §350; A-182, A-202). starter.Supersede = func(newer seat.Pipeline) { diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go index 1be958c2..cecbacbd 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go @@ -38,10 +38,11 @@ func answerVerbs(conn *process.Conn, v pipeline.Verbs) (func(), error) { return conn.AnswerSeat(seat.Seat, writeHandlers(v)) } -// writeHandlers are the seat's write verbs the process answers: review, rerun, accept, cancel, start and ask-again -// (§306; RN14). +// writeHandlers are the seat's write verbs the process answers: run, review, rerun, accept, cancel, start and ask-again +// (§64, §306; RN12, RN14). func writeHandlers(v pipeline.Verbs) map[string]process.SeatHandler { return map[string]process.SeatHandler{ + seat.VerbRun: verbOf(func(c string, in seat.RunInput) (any, error) { return v.Run(c, in) }), seat.VerbReview: verbOf(func(c string, in seat.ReviewInput) (any, error) { return v.Review(c, in) }), seat.VerbRerun: verbOf(func(c string, in seat.RerunInput) (any, error) { return v.Rerun(c, in) }), seat.VerbAccept: verbOf(func(c string, in seat.AcceptInput) (any, error) { return v.Accept(c, in) }), diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go index 6b1db4e8..b535357a 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs_test.go @@ -18,7 +18,7 @@ func TestEveryWriteVerbIsServed(t *testing.T) { got = append(got, v) } slices.Sort(got) - want := []string{seat.VerbAccept, seat.VerbAskAgain, seat.VerbCancel, seat.VerbRerun, seat.VerbReview, seat.VerbStart} + want := []string{seat.VerbAccept, seat.VerbAskAgain, seat.VerbCancel, seat.VerbRerun, seat.VerbReview, seat.VerbStart, seat.VerbRun} slices.Sort(want) if !slices.Equal(got, want) { t.Errorf("the process answers %v, want %v", got, want) diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go index 363c34db..eca36ef1 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go @@ -25,7 +25,8 @@ type runnerViews struct { // startViews wires the runner's views on hub and asks each first view; register every other handler on hub before or // after, then start hub once. func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cred process.Account, hub *process.EventHub, findingsState process.State, - call meshcatalogclient.Call, onReady func(branch string), logf func(string, ...any)) (*runnerViews, error) { + call meshcatalogclient.Call, onReady func(branch string), candidate func(ctx context.Context, ev catalog.CatalogRevisionEvent) error, + logf func(string, ...any)) (*runnerViews, error) { findings, err := findingWriter(ctx, cred, conn, js, findingsState, logf) if err != nil { return nil, err @@ -52,6 +53,11 @@ func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cr if err != nil { return nil, err } + // A candidate's revision, heard once on the event's one subscription whatever branches are followed, places a run's + // pipelines (A-185, A-193, A-199). + if err := meshcatalogclient.WireCandidatesProcess(reader, candidate); err != nil { + return nil, err + } reader.Start(ctx, js, cred, status) return &runnerViews{Registrations: regs, Catalog: cv}, nil } diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/run.go b/modules/mesh-delivery-pipeline/internal/pipeline/run.go new file mode 100644 index 00000000..bd7a68f8 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/run.go @@ -0,0 +1,239 @@ +package pipeline + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + "time" + + runnerclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshpipelinerunnerclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// RunGroupPrefix is a run on request's candidate group before its id, `run-` (design 47 G A-160). +const RunGroupPrefix = "run-" + +// RunKey is the key of a run on request's pipeline, `..`: the run's id under the member's named branch +// (design 47 G §420, A-193). +func RunKey(branch, run, module string) string { + return seat.PipelinesByBranchRevisionModuleKey(keys.EscapeName(branch), run, module) +} + +// NewRunID is a run's id, a random 12-hex id, so two runs of one commit are two pipelines (A-180). +func NewRunID() (string, error) { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// Run starts a run on request (design 47 G §64, §420; A-180, A-193): it writes each member's pipeline at once, running and +// with no steps, its `run` the run's new id, and says run-started, from which the indexer builds the run's candidate; the +// candidate's revision places the steps (PlaceRun). A member naming no module, commit or branch is refused, the run then +// writing nothing. caller is the one the bus proved. newID makes the run's id (NewRunID). +func (s *Starter) Run(caller string, in seat.RunInput, newID func() (string, error)) (seat.RunAnswer, error) { + if len(in.Members) == 0 { + return seat.RunAnswer{}, errors.New("a run names one or more members, each a module at a commit on a branch") + } + for _, m := range in.Members { + if m.Module == "" || m.Commit == "" || m.Branch == "" { + return seat.RunAnswer{}, fmt.Errorf("a run's member names its module, commit and branch: %+v", m) + } + } + id, err := newID() + if err != nil { + return seat.RunAnswer{}, fmt.Errorf("the run's id is not made: %w", err) + } + now := s.now() + var written []string + for _, m := range in.Members { + key := RunKey(m.Branch, id, m.Module) + p := seat.Pipeline{V: 1, Pipeline: key, Branch: m.Branch, Module: m.Module, Commit: m.Commit, Run: id, + Outcome: seat.PipelineOutcomeRunning, Started: &now, Steps: []seat.StepResult{}} + raw, err := json.Marshal(p) + if err == nil { + err = s.Pipelines.Create(key, raw) + } + if err != nil { + s.failRun(written, "the run's other pipelines were not written") + return seat.RunAnswer{}, fmt.Errorf("the pipeline %s of the run is not written: %w", key, err) + } + written = append(written, key) + } + first := in.Members[0] + last := first.Commit + if len(in.Members) > 1 { + last = RunGroupPrefix + id + } + ev := seat.RunStarted{V: 1, At: &now, By: caller, Members: in.Members, Run: id} + if err := s.Say(runnerclient.Subject(seat.EventRunStarted, first.Module, first.Branch, last), ev); err != nil { + s.failRun(written, "run-started was not said, so no candidate is built for it") + return seat.RunAnswer{}, fmt.Errorf("the run %s is not started: run-started is not said: %w", id, err) + } + return seat.RunAnswer{Run: id, Pipelines: written}, nil +} + +// failRun ends the run's pipelines written before its start failed, failed and saying why, so none stands running. +func (s *Starter) failRun(written []string, why string) { + if s.Store == nil { + return + } + for _, key := range written { + for { + p, rev, err := s.Store.Get(key) + if err != nil || p.Outcome != seat.PipelineOutcomeRunning { + break + } + now := s.now() + p.Outcome, p.Why, p.Ended = seat.PipelineOutcomeFailed, why, &now + p.EndCount++ + if _, err := s.Store.Update(key, p, rev); errors.Is(err, ErrChanged) { + continue + } else if err != nil { + s.logf("the pipeline %s of a run not started stays running: %v", key, err) + } + break + } + } +} + +// PlaceRun places a run's pipelines from its candidate's revision (design 47 G §64, §373, A-180, A-193): ev is the +// catalog-revision of the candidate `run-`, half its entries read whole through the seat's revision verb. Each member's +// pipeline of the run not yet placed takes its fingerprint, the candidate's revision and its steps, by compare-and-set, and +// says pipeline-started; each further entry whose fingerprint the members change (another than in base, the branch's catalog +// half, or absent there) gets a pipeline of the run of its own, placed the same way. Another candidate's revision places +// nothing; a revision heard again places nothing twice. It says how many pipelines it placed. +func (s *Starter) PlaceRun(ev catalog.CatalogRevisionEvent, half Half, base func(branch string) (Half, bool)) (int, error) { + id, ok := strings.CutPrefix(ev.Group, RunGroupPrefix) + if !ok || id == "" { + return 0, nil + } + if s.Store == nil { + return 0, errors.New("the runner holds no pipelines to place a run's in") + } + pkeys, err := s.Store.Keys("*." + id + ".*") + if err != nil { + return 0, fmt.Errorf("the pipelines of the run %s are not listed: %w", id, err) + } + slices.Sort(pkeys) + members := map[string]bool{} + branch := "" + for _, key := range pkeys { + if p, _, err := s.Store.Get(key); err == nil && p.Run == id { + members[p.Module] = true + if branch == "" { + branch = p.Branch + } + } + } + if branch == "" { + s.logf("the candidate %s's revision %d names a run with no pipeline here: nothing is placed", ev.Group, ev.Revision) + return 0, nil + } + placed := 0 + var first error + keep := func(ok bool, err error) { + if err != nil && first == nil { + first = err + } + if ok { + placed++ + } + } + for _, key := range pkeys { + keep(s.placeRunPipeline(key, id, ev.Revision, half)) + } + // The dependents (§373): the candidate's entries the members change, beside the members, on the first member's branch. + trunk, ok := base(branch) + if b, err := keys.UnescapeName(ev.Branch); err == nil && b != "" && b != branch { + if h, held := base(b); held { + trunk, ok = h, true + } + } + if !ok { + s.logf("the run %s's dependents are not known: the catalog half of %s is not held, so its members run alone", id, branch) + return placed, first + } + for _, module := range slices.Sorted(maps.Keys(half.Entries)) { + e := half.Entries[module] + if members[module] { + continue + } + if was, held := trunk.Entries[module]; held && was.Fp == e.Fp { + continue + } + key := RunKey(branch, id, module) + now := s.now() + p := seat.Pipeline{V: 1, Pipeline: key, Branch: branch, Module: module, Commit: e.Commit, Run: id, + Outcome: seat.PipelineOutcomeRunning, Started: &now, Steps: []seat.StepResult{}} + raw, err := json.Marshal(p) + if err == nil { + err = s.Pipelines.Create(key, raw) + } + if err != nil && !errors.Is(err, ErrExists) { + keep(false, fmt.Errorf("the dependent %s of the run %s is not written: %w", module, id, err)) + continue + } + keep(s.placeRunPipeline(key, id, ev.Revision, half)) + } + return placed, first +} + +// placeRunPipeline places one pipeline of the run, written and not yet placed (no revision), from the candidate's half. +func (s *Starter) placeRunPipeline(key, id string, revision int64, half Half) (bool, error) { + for { + p, rev, err := s.Store.Get(key) + if err != nil { + return false, fmt.Errorf("the pipeline %s is not read: %w", key, err) + } + if p.Run != id || p.Outcome != seat.PipelineOutcomeRunning || p.Revision != 0 { + return false, nil + } + now := s.now() + e, ok := half.Entries[p.Module] + if !ok { + s.logf("the candidate of the run %s holds no entry of %s: its pipeline is placed with no fingerprint", id, p.Module) + } + p.Fingerprint, p.Repository, p.Revision = e.Fp, e.Repository, revision + var asks []Asked + if s.Registration != nil { + if reg, ok := s.Registration(p.Module); ok { + p.Steps, asks = Place(p, reg, half.Entries, now, s.Name) + } else { + s.logf("%s has no registration yet: its pipeline is placed with no step", key) + } + } + if _, err := s.Store.Update(key, p, rev); errors.Is(err, ErrChanged) { + continue + } else if err != nil { + return false, fmt.Errorf("the pipeline %s cannot be written: %w", key, err) + } + ev := seat.PipelineStarted{V: 1, At: &now, Pipeline: key, Branch: p.Branch, Module: p.Module, Commit: p.Commit, + Fingerprint: p.Fingerprint, Revision: p.Revision, Run: id} + if err := s.Say(runnerclient.Subject(seat.EventPipelineStarted, p.Module, p.Branch, p.Commit), ev); err != nil { + s.logf("pipeline-started of %s is not said: %v", key, err) + } + if s.Steps != nil && len(asks) > 0 { + s.carry(key, asks) + if err := askAll(s.Steps, s.Arm, key, asks); err != nil { + return true, err + } + } + return true, nil + } +} + +func (s *Starter) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go new file mode 100644 index 00000000..b14ffe56 --- /dev/null +++ b/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go @@ -0,0 +1,124 @@ +package pipeline + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" +) + +// memCreated is memStore written by Create too, as the state pipelines is. +type memCreated struct{ memStore } + +func (m memCreated) Create(key string, value []byte) error { + if _, ok := m.memStore[key]; ok { + return ErrExists + } + var p seat.Pipeline + if err := json.Unmarshal(value, &p); err != nil { + return err + } + m.memStore[key] = struct { + p seat.Pipeline + rev uint64 + }{p, 1} + return nil +} + +// RN12 (design 47 G §64, §373, §420; A-180, A-193): `run` writes each member's pipeline at once under the run's id and its +// named branch and says run-started; the candidate run-'s revision places each member's steps and a pipeline of each +// entry the members change, says pipeline-started once each, and places nothing twice; a member naming no branch is refused. +func TestRunOnRequest(t *testing.T) { + reg, entries := placeFixture() + store := memCreated{memStore{}} + var said []string + asks := &stepAsks{} + s := &Starter{Pipelines: store, Store: store, Steps: asks, Name: "the-runner", + Registration: func(m string) (catalog.Registration, bool) { + r := reg + r.Module = m + return r, m == "app" || m == "lib" + }, + Say: func(subject string, body any) error { + said = append(said, subject[strings.Index(subject, ".event.")+7:]) + return nil + }} + id := func() (string, error) { return "0a1b2c3d4e5f", nil } + if _, err := s.Run("sessions", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c1"}}}, id); err == nil { + t.Error("a member naming no branch was taken") + } + if len(store.memStore) != 0 || len(said) != 0 { + t.Fatalf("a refused run wrote %v and said %v", store.memStore, said) + } + a, err := s.Run("sessions", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c1", Branch: "fix/1"}}}, id) + if err != nil || a.Run != "0a1b2c3d4e5f" || len(a.Pipelines) != 1 { + t.Fatalf("run = %+v, %v", a, err) + } + key := a.Pipelines[0] + if key != RunKey("fix/1", "0a1b2c3d4e5f", "app") { + t.Errorf("the member's pipeline is %s, want the run's id under its named branch", key) + } + if p, _, _ := store.Get(key); p.Run != a.Run || p.Outcome != seat.PipelineOutcomeRunning || len(p.Steps) != 0 || p.Branch != "fix/1" || p.Source != 0 { + t.Errorf("the member's pipeline %s = %+v", key, p) + } + if len(said) != 1 || !strings.HasPrefix(said[0], seat.EventRunStarted+".app.") || !strings.HasSuffix(said[0], ".c1") { + t.Errorf("said %v, want run-started on the member's module, branch and commit", said) + } + cand := entries + cand["app"] = catalog.CatalogEntry{Module: "app", Commit: "c1", Fp: "src2:new", Repository: "o/app", Artifacts: entries["app"].Artifacts} + cand["lib"] = catalog.CatalogEntry{Module: "lib", Commit: "c0", Fp: "src2:lib2", Artifacts: entries["app"].Artifacts} + cand["same"] = catalog.CatalogEntry{Module: "same", Fp: "src2:same"} + base := func(string) (Half, bool) { + return Half{Entries: map[string]catalog.CatalogEntry{"lib": {Fp: "src2:lib1"}, "same": {Fp: "src2:same"}, "mesh-build-go": entries["mesh-build-go"]}}, true + } + ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: "run-" + a.Run, Revision: 12} + if n, err := s.PlaceRun(catalog.CatalogRevisionEvent{Kind: "candidate", Group: "try-3", Revision: 11}, Half{Entries: cand}, base); n != 0 || err != nil { + t.Errorf("a try's candidate placed %d (%v)", n, err) + } + said = nil + n, err := s.PlaceRun(ev, Half{Revision: 12, Entries: cand}, base) + if err != nil || n != 2 { + t.Fatalf("the candidate placed %d (%v), want the member and the dependent lib", n, err) + } + p, _, _ := store.Get(key) + if p.Fingerprint != "src2:new" || p.Revision != 12 || len(p.Steps) == 0 { + t.Errorf("the member's pipeline placed = %+v", p) + } + dep, _, err := store.Get(RunKey("fix/1", a.Run, "lib")) + if err != nil || dep.Run != a.Run || dep.Fingerprint != "src2:lib2" || len(dep.Steps) == 0 { + t.Errorf("the dependent's pipeline = %+v (%v)", dep, err) + } + if _, _, err := store.Get(RunKey("fix/1", a.Run, "same")); err == nil { + t.Error("an entry the members do not change got a pipeline") + } + if len(said) != 2 || !strings.HasPrefix(said[0], seat.EventPipelineStarted+".") || len(asks.ids) == 0 { + t.Errorf("placing said %v and asked %d steps", said, len(asks.ids)) + } + if n, _ := s.PlaceRun(ev, Half{Revision: 12, Entries: cand}, base); n != 0 { + t.Errorf("the revision heard again placed %d", n) + } + // A restart asks a run's steps again from no branch's half: they are its candidate's. + if n, err := s.Resume(store, "fix/1", Half{Entries: entries}); n != 0 || err != nil { + t.Errorf("a restart asked %d steps of the run again (%v)", n, err) + } + + // Several members say run-started on the first member's module and branch and run-; a run-started not said fails the + // pipelines written, so none stands running. + said = nil + two, err := s.Run("operator", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c2", Branch: "main"}, {Module: "lib", Commit: "c3", Branch: "main"}}}, + func() (string, error) { return "ffffffffffff", nil }) + if err != nil || len(two.Pipelines) != 2 || len(said) != 1 || said[0] != seat.EventRunStarted+".app.main.run-ffffffffffff" { + t.Errorf("a run of two: %+v (%v), said %v", two, err, said) + } + s.Say = func(string, any) error { return errors.New("bus away") } + three, err := s.Run("operator", seat.RunInput{Members: []seat.Member{{Module: "app", Commit: "c4", Branch: "main"}}}, func() (string, error) { return "eeeeeeeeeeee", nil }) + if err == nil || three.Run != "" { + t.Errorf("a run whose run-started was not said answered %+v", three) + } + if p, _, _ := store.Get(RunKey("main", "eeeeeeeeeeee", "app")); p.Outcome != seat.PipelineOutcomeFailed { + t.Errorf("its pipeline is %s, want failed", p.Outcome) + } +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/start.go b/modules/mesh-delivery-pipeline/internal/pipeline/start.go index 0d8c561c..e874fce0 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/start.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/start.go @@ -72,6 +72,8 @@ type Starter struct { Toolchain func(language string) (*nodebuildgo.ToolchainUsed, error) // Arm arms the bound of an ask put on a build seat's work queue (§535, §647); nil arms none. Arm Arm + // Store is the state pipelines read and written by compare-and-set, where a run's pipelines are placed (PlaceRun). + Store Updater // Supersede ends the older running pipelines the pipeline just written supersedes (§31; Hearer.Supersede); nil ends none. Supersede func(newer seat.Pipeline) } @@ -156,7 +158,8 @@ func (s *Starter) Resume(store Updater, branch string, half Half) (int, error) { } continue } - if p.Branch != branch || p.Outcome != seat.PipelineOutcomeRunning { + // A run on request's steps are placed from its candidate, not from the branch's half (PlaceRun). + if p.Branch != branch || p.Outcome != seat.PipelineOutcomeRunning || p.Run != "" { continue } reg, ok := s.Registration(p.Module) diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go b/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go index e3caef80..6ede0c1b 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go @@ -335,6 +335,15 @@ func (v Verbs) askOf(p seat.Pipeline, s seat.StepResult, caller string, now time return nil, fmt.Errorf("the %s step of %s is placed no ask now", s.Step, p.Pipeline) } +// Run starts a run on request for the caller the bus proved (design 47 G §64; Starter.Run): its pipelines written, its +// run-started said, its steps placed when its candidate's revision is heard. +func (v Verbs) Run(caller string, in seat.RunInput) (seat.RunAnswer, error) { + if v.Starter == nil { + return seat.RunAnswer{}, errors.New("the runner cannot start a run: it holds no starter") + } + return v.Starter.Run(caller, in, NewRunID) +} + // Start starts by hand a delivering pipeline for the head of a watched branch the runner's catalog half holds, recorded in // the hand-act log first (§306, §394): the pipeline of that branch's source revision and the module, as a take-in would // start it. A head whose pipeline is written already starts nothing and is refused, naming the pipeline. From 0cd1ab79e4d7fdbe86b8983bf594a6fca6d90de3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:14:34 +0100 Subject: [PATCH 15/18] B1g-2: the runner at its start places each run's lost pipelines from its candidate's newest revision Builds: design 47 G A-222, A-193, A-180, A-205 --- .../cmd/mesh-delivery-pipeline/candidate.go | 36 +++++++++++++++++- .../mesh-delivery-pipeline/candidate_test.go | 37 +++++++++++++++++++ .../cmd/mesh-delivery-pipeline/main.go | 21 +++++++++-- .../internal/pipeline/run.go | 25 +++++++++++++ .../internal/pipeline/run_test.go | 7 ++++ 5 files changed, 122 insertions(+), 4 deletions(-) diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go index 81fb00b2..7f199d77 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate.go @@ -7,12 +7,14 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/keys" "mesh-delivery-pipeline/internal/pipeline" ) // readCandidate reads a candidate's revision whole through the catalog seat's verb revision, with the candidate's group and -// heads and the revision its event names, page by page (design 47 G A-180). +// heads and the revision its event names, page by page (design 47 G A-180); an event naming no revision reads the candidate's +// newest, the one its answer names (A-205, A-222). func readCandidate(call func(key string, body any) (json.RawMessage, error), ev catalog.CatalogRevisionEvent) (pipeline.Half, error) { half := pipeline.Half{Revision: ev.Revision, Source: ev.Source, Entries: map[string]catalog.CatalogEntry{}} after := "" @@ -29,6 +31,10 @@ func readCandidate(call func(key string, body any) (json.RawMessage, error), ev if err := json.Unmarshal(answer, &a); err != nil { return half, fmt.Errorf("%s answered no revision: %w", meshcatalogclient.RevisionVerb, err) } + if a.Revision != nil && ev.Revision == 0 && after == "" { + half.Revision, half.Source = a.Revision.Revision, a.Revision.Source + ev.Revision = a.Revision.Revision + } if a.Revision == nil || a.Revision.Revision != ev.Revision || a.Revision.Group != ev.Group { return half, fmt.Errorf("%s answered %+v for the candidate %s's revision %d", meshcatalogclient.RevisionVerb, a.Revision, ev.Group, ev.Revision) } @@ -41,3 +47,31 @@ func readCandidate(call func(key string, body any) (json.RawMessage, error), ev after = a.Next } } + +// placeLostRuns places, at the runner's start, each run whose pipelines have no steps yet (A-222): the candidate's +// newest revision is read through the seat's revision verb with the run's group and placed as its catalog-revision event +// would place it, so a placing lost while the runner was down is taken up. A run whose candidate cannot be read is said and +// the others go on; its first failure is returned, to be tried at the next start. +func placeLostRuns(unplaced func() ([]pipeline.UnplacedRun, error), call func(key string, body any) (json.RawMessage, error), + place func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error, logf func(string, ...any)) error { + runs, err := unplaced() + if err != nil { + return err + } + var first error + for _, u := range runs { + ev := catalog.CatalogRevisionEvent{Kind: "candidate", Group: pipeline.RunGroupPrefix + u.Run, Branch: keys.EscapeName(u.Branch)} + half, err := readCandidate(call, ev) + if err == nil { + ev.Revision, ev.Source = half.Revision, half.Source + err = place(ev, half) + } + if err != nil { + logf("the run %s of %s is not placed at the start, its candidate's newest revision: %v", u.Run, u.Branch, err) + if first == nil { + first = err + } + } + } + return first +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go index 21efca52..26a92e9c 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/candidate_test.go @@ -2,9 +2,12 @@ package main import ( "encoding/json" + "errors" "testing" catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + + "mesh-delivery-pipeline/internal/pipeline" ) // A-180: a run's candidate revision is read whole through the seat's revision verb with the candidate's group, heads and @@ -34,3 +37,37 @@ func TestReadCandidateReadsTheRevisionWhole(t *testing.T) { t.Error("an answer of another revision was taken") } } + +// A-222: at its start the runner reads the newest revision of each unplaced run's candidate through the seat's revision verb +// (its group, no heads, no revision) and places it; a run whose candidate cannot be read is said and the others go on. +func TestPlaceLostRunsReadsEachCandidateNewest(t *testing.T) { + var asked []catalog.RevisionInput + call := func(key string, body any) (json.RawMessage, error) { + in := body.(catalog.RevisionInput) + asked = append(asked, in) + if in.Group == "run-bad" { + return nil, errors.New("no responders") + } + return json.Marshal(catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 7, Source: 3, Kind: "candidate", Group: in.Group}, + Entries: []catalog.CatalogEntry{{Module: "app", Fp: "src2:a"}}}) + } + var placed []catalog.CatalogRevisionEvent + var said []string + err := placeLostRuns(func() ([]pipeline.UnplacedRun, error) { + return []pipeline.UnplacedRun{{Run: "bad", Branch: "fix/1"}, {Run: "good", Branch: "fix/1"}}, nil + }, call, + func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error { + placed = append(placed, ev) + return nil + }, + func(f string, a ...any) { said = append(said, f) }) + if err == nil || len(said) == 0 { + t.Errorf("an unreadable candidate was not said: %v, %v", err, said) + } + if len(placed) != 1 || placed[0].Group != "run-good" || placed[0].Revision != 7 || placed[0].Source != 3 || placed[0].Branch != "fix/1" { + t.Fatalf("placed %+v", placed) + } + if asked[1].Heads != "" || asked[1].Revision != 0 || asked[1].Group != "run-good" { + t.Errorf("asked %+v, want the candidate's group alone", asked[1]) + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go index b5b04915..8a388b9d 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go @@ -114,6 +114,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro starter := &pipeline.Starter{Pipelines: createdKV{ctx: ctx, kv: pipelines}, Logf: logger.Printf, Say: say, Registration: rv.Registrations.Registration, Steps: busStepAsker{ctx: ctx, js: js, cred: cred}, Name: cred.Module, Arm: armBound(ctx, bounds), Kept: keptReader(ctx, kept), Toolchain: toolchainReader(ctx, toolchains)} + placeHalf := func(ev catalog.CatalogRevisionEvent, half pipeline.Half) error { + n, err := starter.PlaceRun(ev, half, rv.Catalog.View) + logger.Printf("the candidate %s's revision %d placed %d pipelines of its run", ev.Group, ev.Revision, n) + return err + } resume := func(branch string) { half, ok := rv.Catalog.View(branch) if !ok { @@ -128,6 +133,18 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } resumer.Store(&resume) starter.Store = pipelineStore{ctx: ctx, kv: pipelines} + // A run's placing lost while the runner was down is taken up from its candidate's newest revision, once the registrations + // that place its steps are held (A-222). + go func() { + select { + case <-ctx.Done(): + return + case <-rv.Registrations.Ready(): + } + if err := placeLostRuns(starter.UnplacedRuns, calls.CallKept, placeHalf, logger.Printf); err != nil { + logger.Printf("the runs not placed at the start: %v", err) + } + }() // A run's candidate revision places its pipelines' steps, its entries read through the seat's revision verb (A-180). place := func(ev catalog.CatalogRevisionEvent) error { if !strings.HasPrefix(ev.Group, pipeline.RunGroupPrefix) { @@ -137,9 +154,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err != nil { return fmt.Errorf("the candidate %s's revision %d is not read, so its run is not placed: %w", ev.Group, ev.Revision, err) } - n, err := starter.PlaceRun(ev, half, rv.Catalog.View) - logger.Printf("the candidate %s's revision %d placed %d pipelines of its run", ev.Group, ev.Revision, n) - return err + return placeHalf(ev, half) } placer.Store(&place) // A newer commit or publish supersedes the older running pipelines it replaces, their asks dropped as kept calls (§31, diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/run.go b/modules/mesh-delivery-pipeline/internal/pipeline/run.go index bd7a68f8..b0fbef9b 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/run.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/run.go @@ -187,6 +187,31 @@ func (s *Starter) PlaceRun(ev catalog.CatalogRevisionEvent, half Half, base func return placed, first } +// UnplacedRun is a run with a pipeline running and no steps placed yet, on the branch the run names. +type UnplacedRun struct{ Run, Branch string } + +// UnplacedRuns are the runs with a pipeline running and no steps placed yet, by branch and id (design 47 G A-222): a placing +// lost while the runner was down is taken up from these at its start. +func (s *Starter) UnplacedRuns() ([]UnplacedRun, error) { + if s.Store == nil { + return nil, errors.New("the runner holds no pipelines to read a run's from") + } + pkeys, err := s.Store.Keys("*.*.*") + if err != nil { + return nil, fmt.Errorf("the pipelines are not listed: %w", err) + } + var out []UnplacedRun + for _, key := range pkeys { + if p, _, err := s.Store.Get(key); err == nil && p.Run != "" && p.Outcome == seat.PipelineOutcomeRunning && p.Revision == 0 { + if u := (UnplacedRun{Run: p.Run, Branch: p.Branch}); !slices.Contains(out, u) { + out = append(out, u) + } + } + } + slices.SortFunc(out, func(a, b UnplacedRun) int { return strings.Compare(a.Branch+"\n"+a.Run, b.Branch+"\n"+b.Run) }) + return out, nil +} + // placeRunPipeline places one pipeline of the run, written and not yet placed (no revision), from the candidate's half. func (s *Starter) placeRunPipeline(key, id string, revision int64, half Half) (bool, error) { for { diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go index b14ffe56..769311ca 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/run_test.go @@ -67,6 +67,10 @@ func TestRunOnRequest(t *testing.T) { if len(said) != 1 || !strings.HasPrefix(said[0], seat.EventRunStarted+".app.") || !strings.HasSuffix(said[0], ".c1") { t.Errorf("said %v, want run-started on the member's module, branch and commit", said) } + // A-222: a restart finds the runs whose steps were never placed, by their branch. + if ids, err := s.UnplacedRuns(); err != nil || len(ids) != 1 || ids[0] != (UnplacedRun{Run: a.Run, Branch: "fix/1"}) { + t.Errorf("the unplaced runs = %v (%v), want %s on fix/1", ids, err, a.Run) + } cand := entries cand["app"] = catalog.CatalogEntry{Module: "app", Commit: "c1", Fp: "src2:new", Repository: "o/app", Artifacts: entries["app"].Artifacts} cand["lib"] = catalog.CatalogEntry{Module: "lib", Commit: "c0", Fp: "src2:lib2", Artifacts: entries["app"].Artifacts} @@ -97,6 +101,9 @@ func TestRunOnRequest(t *testing.T) { if len(said) != 2 || !strings.HasPrefix(said[0], seat.EventPipelineStarted+".") || len(asks.ids) == 0 { t.Errorf("placing said %v and asked %d steps", said, len(asks.ids)) } + if ids, _ := s.UnplacedRuns(); len(ids) != 0 { + t.Errorf("a placed run is still unplaced: %v", ids) + } if n, _ := s.PlaceRun(ev, Half{Revision: 12, Entries: cand}, base); n != 0 { t.Errorf("the revision heard again placed %d", n) } From cbb678f42ebb35ecb346626733d5fea5f664fce9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:15:38 +0100 Subject: [PATCH 16/18] B1g-3: a run's rerun asks with the candidate's catalog half, not the branch's Builds: design 47 G A-222, A-193, A-205 --- .../cmd/mesh-delivery-pipeline/verbs.go | 6 ++- .../internal/pipeline/verbs.go | 20 +++++-- .../internal/pipeline/verbs_test.go | 53 +++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go index cecbacbd..44671341 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/verbs.go @@ -9,6 +9,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" "git.novox.be/novox/mesh-sdk/go/process" @@ -65,5 +66,8 @@ func dropBound(ctx context.Context, bounds jetstream.KeyValue) func(string) erro // writeVerbs are the verbs over the hearer and the starter, their hand acts recorded through the seat mesh-hand-acts. func writeVerbs(ctx context.Context, h *pipeline.Hearer, s *pipeline.Starter, view func(string) (pipeline.Half, bool), calls *process.Conn, bounds jetstream.KeyValue) pipeline.Verbs { - return pipeline.Verbs{Hearer: h, Starter: s, View: view, Acts: meshhandactsclient.Recorder{Ask: calls.Call}, Drop: dropBound(ctx, bounds)} + candidate := func(group, branch string, revision int64) (pipeline.Half, error) { + return readCandidate(calls.CallKept, catalog.CatalogRevisionEvent{Kind: "candidate", Group: group, Branch: branch, Revision: revision}) + } + return pipeline.Verbs{Hearer: h, Starter: s, View: view, Candidate: candidate, Acts: meshhandactsclient.Recorder{Ask: calls.Call}, Drop: dropBound(ctx, bounds)} } diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go b/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go index 6ede0c1b..03bdf718 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/verbs.go @@ -10,6 +10,7 @@ import ( seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner" wants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -21,6 +22,9 @@ type Verbs struct { // Starter asks a step again (rerun), as it asks the steps of a start; View reads a branch's catalog half for the ask. Starter *Starter View func(branch string) (Half, bool) + // Candidate reads a run's candidate's catalog half, its group `run-`, on branch at the revision its pipeline was placed + // from, for a rerun of a run's step (A-222): a run's steps are the candidate's, never the branch's. + Candidate func(group, branch string, revision int64) (Half, error) // Acts records a hand act before the verb does it (rerun, accept, cancel); a nil Acts refuses the verb, nothing recorded. Acts views.HandActs // Drop drops the bound of an ask a cancelled pipeline no longer waits on. @@ -296,15 +300,25 @@ func (v Verbs) Rerun(caller string, in seat.RerunInput) (seat.RerunAnswer, error // askOf is the ask to put on the serving seat for step s again: the one Place makes for it, under a new id (an ask put twice // under one id is kept once), saying it is a rerun and by whom. func (v Verbs) askOf(p seat.Pipeline, s seat.StepResult, caller string, now time.Time) (*Asked, error) { - if v.Starter == nil || v.Starter.Registration == nil || v.Starter.Steps == nil || v.View == nil { + if v.Starter == nil || v.Starter.Registration == nil || v.Starter.Steps == nil || (v.View == nil && p.Run == "") { return nil, errors.New("the runner cannot ask a step: it holds no registration or no ask") } reg, ok := v.Starter.Registration(p.Module) if !ok { return nil, fmt.Errorf("%s has no registration", p.Module) } - half, ok := v.View(p.Branch) - if !ok { + var half Half + if p.Run != "" { + if v.Candidate == nil { + return nil, errors.New("the runner cannot read a run's candidate") + } + var err error + if half, err = v.Candidate(RunGroupPrefix+p.Run, keys.EscapeName(p.Branch), p.Revision); err != nil { + return nil, fmt.Errorf("the candidate %s%s's revision %d is not read, so its step is not asked: %w", RunGroupPrefix, p.Run, p.Revision, err) + } + } else if h, ok := v.View(p.Branch); ok { + half = h + } else { return nil, fmt.Errorf("the catalog half of %s is not held yet: the step is asked when it is", p.Branch) } _, placed := Place(p, reg, half.Entries, now, v.Starter.Name) diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/verbs_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/verbs_test.go index 633f120b..6b6ed3df 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/verbs_test.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/verbs_test.go @@ -155,6 +155,59 @@ func TestAcceptRerunAndCancelWriteByCompareAndSetAndRecordTheirHandAct(t *testin } } +// A-222: a rerun of a run's step asks with the candidate's catalog half (its entries, here the directory), never the branch's, +// and is refused, nothing asked, when the candidate cannot be read. +func TestRerunOfARunAsksWithItsCandidatesHalf(t *testing.T) { + v, store, _, asks, _, _ := verbsFixture() + p, rev, _ := store.Get(pkey) + p.Run, p.Branch, p.Revision = "0a1b2c3d4e5f", "fix/1", 12 + for i := range p.Steps { + if p.Steps[i].Step == "lint" && p.Steps[i].Artifact == "app:program" { + p.Steps[i].Outcome = seat.StepResultOutcomeFailed + } + } + store.Update(pkey, p, rev) + _, cand := placeFixture() + e := cand["app"] + e.Path = "candidate/app" + cand["app"] = e + var read struct { + group, branch string + revision int64 + } + v.Candidate = func(group, branch string, revision int64) (Half, error) { + read.group, read.branch, read.revision = group, branch, revision + return Half{Revision: revision, Entries: cand}, nil + } + in := seat.RerunInput{Pipeline: pkey, Step: "lint", Artifact: "app:program", Why: "retry"} + if _, err := v.Rerun("operator", in); err != nil { + t.Fatal(err) + } + if b, ok := asks.asks[0].(nodebuildgo.StepAsk); !ok || b.Step == nil || b.Step.Fetch.Dir != "candidate/app" { + t.Errorf("the ask = %+v, want the candidate's directory", asks.asks[0]) + } + if read.group != "run-0a1b2c3d4e5f" || read.branch != "fix/1" || read.revision != 12 { + t.Errorf("the candidate read = %+v", read) + } + p, rev, _ = store.Get(pkey) + p.Steps[stepIndex(p, "lint", "app:program")].Outcome = seat.StepResultOutcomeFailed + store.Update(pkey, p, rev) + v.Candidate = func(string, string, int64) (Half, error) { return Half{}, errors.New("no answer") } + n := len(asks.asks) + if _, err := v.Rerun("operator", in); err == nil || len(asks.asks) != n { + t.Errorf("a rerun with no candidate read: %v, asked %d more", err, len(asks.asks)-n) + } +} + +func stepIndex(p seat.Pipeline, step, artifact string) int { + for i, s := range p.Steps { + if s.Step == step && s.Artifact == artifact { + return i + } + } + return -1 +} + // A hand act the log does not take is a verb that does nothing (ADR 0565 clause 11). func TestAVerbWhoseHandActIsNotRecordedDoesNothing(t *testing.T) { v, store, _, _, _, _ := verbsFixture() From 8e7b8ab1d1d228246b23e5d58bcd71495e4d1215 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:17:05 +0100 Subject: [PATCH 17/18] B1g-4: an unanswered drop is said call-unanswered on its five-minute bound key's expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G A-222, A-77, §350 --- .../cmd/mesh-delivery-pipeline/drops.go | 92 +++++++++++++++++++ .../cmd/mesh-delivery-pipeline/drops_test.go | 52 +++++++++++ .../cmd/mesh-delivery-pipeline/main.go | 5 +- .../cmd/mesh-delivery-pipeline/steps.go | 10 +- .../cmd/mesh-delivery-pipeline/views.go | 4 +- modules/mesh-delivery-pipeline/go.mod | 1 + .../internal/pipeline/bounds.go | 33 +++++++ .../internal/pipeline/supersede_test.go | 32 +++++++ 8 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops.go create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops_test.go diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops.go new file mode 100644 index 00000000..dc1fde33 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/nats-io/nats.go/jetstream" + + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + + "mesh-delivery-pipeline/internal/pipeline" +) + +// callFindings is the runner's finding writer as its drops use it (design 47 G A-222, §350). +type callFindings interface { + Write(ctx context.Context, f condseat.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// dropFindingID is the id of the call-unanswered finding of buildSeat's drop of ask. +func dropFindingID(buildSeat, ask string) string { return buildSeat + ".drop." + ask } + +// deleteBound deletes a key of the state bounds; a key already gone is no fault. +func deleteBound(ctx context.Context, bounds jetstream.KeyValue) func(key string) error { + return func(key string) error { + if err := bounds.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) { + return err + } + return nil + } +} + +// armDropBound arms the five-minute bound of a drop, a key of the state bounds with its own time to live (A-77, A-222). +func armDropBound(ctx context.Context, bounds jetstream.KeyValue) func(key string) error { + return func(key string) error { + _, err := bounds.Create(ctx, key, []byte(`{"drop":"`+key+`"}`), jetstream.KeyTTL(pipeline.AskBound)) + if errors.Is(err, jetstream.ErrKeyExists) { + return nil + } + return err + } +} + +// dropAnswered ends the wait of an answered drop: its bound is deleted and a call-unanswered finding it raised before is +// cleared. +func dropAnswered(ctx context.Context, unbound func(key string) error, w callFindings) func(buildSeat, ask string) error { + return func(buildSeat, ask string) error { + if err := unbound(pipeline.DropBoundKey(buildSeat, ask)); err != nil { + return err + } + return w.Clear(ctx, mcc.ScopeCall, dropFindingID(buildSeat, ask), mcc.KindCallUnanswered, "the drop is answered") + } +} + +// dropUnanswered says the drop of ask on buildSeat unanswered, the finding call-unanswered in the runner's findings, on the +// expiry of its five-minute bound (A-222, §350, ADR 0668 §3). +func dropUnanswered(ctx context.Context, w callFindings, module string, now func() time.Time) func(buildSeat, ask string) error { + return func(buildSeat, ask string) error { + if w == nil { + return errors.New("the runner has no finding writer") + } + id := dropFindingID(buildSeat, ask) + call := buildSeat + ".drop" + return w.Write(ctx, condseat.Finding{Scope: mcc.ScopeCall, ID: id, Kind: mcc.KindCallUnanswered, Severity: condseat.FindingSeverityWarning, + Summary: fmt.Sprintf("the call %s of ask %s is unanswered", call, ask), + Headline: "A call whose answer is kept is not answered", + Explanation: fmt.Sprintf("The drop of the superseded step's ask %s was asked of the seat %s as a kept call and no holder answered it within %s, so the build it names may still run.", ask, buildSeat, pipeline.AskBound), + Source: module + "." + mcc.KindCallUnanswered, Observer: module, At: now().UTC(), Subject: condseat.Subject{Scope: mcc.ScopeCall, ID: id}}) + } +} + +// boundExpired takes the expiry of a key of the state bounds: a drop's says the drop unanswered, an ask's ends the step no +// holder took (§32, §535, A-77); another key is none of the runner's. +func boundExpired(h *pipeline.Hearer, unanswered func(buildSeat, ask string) error, logf func(string, ...any)) func(key string) { + return func(key string) { + if s, ask, ok := pipeline.ParseDropBound(key); ok { + if err := unanswered(s, ask); err != nil { + logf("the drop of ask %s on %s, unanswered, is not said: %v", ask, s, err) + } + return + } + if !pipeline.IsAskKey(key) { + return + } + if err := h.NotTaken(key, pipeline.AskBound); err != nil { + logf("the step of ask %s, which no holder took, is not ended: %v", key, err) + } + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops_test.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops_test.go new file mode 100644 index 00000000..8b8321b0 --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/drops_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "testing" + "time" + + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + + "mesh-delivery-pipeline/internal/pipeline" +) + +type fakeFindings struct { + written []condseat.Finding + cleared []string +} + +func (f *fakeFindings) Write(_ context.Context, x condseat.Finding) error { + f.written = append(f.written, x) + return nil +} + +func (f *fakeFindings) Clear(_ context.Context, scope, id, kind, _ string) error { + f.cleared = append(f.cleared, scope+"."+id+"."+kind) + return nil +} + +// A-222, A-77: the expiry of a drop's five-minute bound says the drop call-unanswered in the runner's findings, naming the +// seat and the ask; an answered drop clears it; a key that is no drop's is not said. +func TestADropBoundsExpirySaysCallUnanswered(t *testing.T) { + w := &fakeFindings{} + var logged int + expired := boundExpired(nil, dropUnanswered(context.Background(), w, "the-runner", func() time.Time { return time.Unix(100, 0) }), func(string, ...any) { logged++ }) + expired("not-a-bound") + if len(w.written) != 0 { + t.Fatalf("a key of no drop was said: %+v", w.written) + } + expired(pipeline.DropBoundKey("node-build-go", "step-ab")) + if len(w.written) != 1 { + t.Fatalf("written %+v, want one finding", w.written) + } + f := w.written[0] + if f.Scope != mcc.ScopeCall || f.Kind != mcc.KindCallUnanswered || f.ID != "node-build-go.drop.step-ab" || f.Observer != "the-runner" || f.Subject.ID != f.ID { + t.Errorf("the finding = %+v", f) + } + var unbound []string + if err := dropAnswered(context.Background(), func(k string) error { unbound = append(unbound, k); return nil }, w)("node-build-go", "step-ab"); err != nil || + len(w.cleared) != 1 || w.cleared[0] != "call.node-build-go.drop.step-ab.call-unanswered" || len(unbound) != 1 || unbound[0] != "drop.node-build-go.step-ab" { + t.Errorf("an answered drop: %v, cleared %v, unbound %v", err, w.cleared, unbound) + } +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go index 8a388b9d..0016c85e 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go @@ -14,6 +14,7 @@ import ( "strings" "sync/atomic" "syscall" + "time" "github.com/nats-io/nats.go" @@ -160,7 +161,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // A newer commit or publish supersedes the older running pipelines it replaces, their asks dropped as kept calls (§31, // §350; A-182, A-202). starter.Supersede = func(newer seat.Pipeline) { - n, err := hearer.Supersede(newer, dropAsk(calls, cred.Module), dropBound(ctx, bounds)) + n, err := hearer.Supersede(newer, pipeline.BoundDrop(dropAsk(calls, cred.Module), armDropBound(ctx, bounds), dropAnswered(ctx, deleteBound(ctx, bounds), rv.Findings)), dropBound(ctx, bounds)) if err != nil { logger.Printf("the pipelines %s supersedes: %d ended, then %v", newer.Pipeline, n, err) } else if n > 0 { @@ -176,7 +177,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err := hearAskTaken(ctx, heard, bounds, hearer.Taken, logger.Printf); err != nil { return err } - if err := watchBounds(ctx, bounds, hearer, logger.Printf); err != nil { + if err := watchBounds(ctx, bounds, boundExpired(hearer, dropUnanswered(ctx, rv.Findings, cred.Module, time.Now), logger.Printf), logger.Printf); err != nil { return err } if err := hearDecided(heard, hearer, cred.Module, logger.Printf); err != nil { diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go index c75823dc..45d3759d 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/steps.go @@ -179,8 +179,8 @@ func armBound(ctx context.Context, bounds jetstream.KeyValue) pipeline.Arm { } // watchBounds hears the end of each ask's bound, the bus's marker of the key's expiry, and ends the step no holder took (§32, -// §535, A-77): no timer in the process. -func watchBounds(ctx context.Context, bounds jetstream.KeyValue, h *pipeline.Hearer, logf func(string, ...any)) error { +// §535, A-77), and says a drop no holder answered unanswered (A-222): no timer in the process. +func watchBounds(ctx context.Context, bounds jetstream.KeyValue, expired func(key string), logf func(string, ...any)) error { w, err := bounds.WatchAll(ctx, jetstream.UpdatesOnly()) if err != nil { return err @@ -195,12 +195,10 @@ func watchBounds(ctx context.Context, bounds jetstream.KeyValue, h *pipeline.Hea if !ok { return } - if e == nil || e.Operation() != jetstream.KeyValuePurge || !pipeline.IsAskKey(e.Key()) { + if e == nil || e.Operation() != jetstream.KeyValuePurge { continue } - if err := h.NotTaken(e.Key(), pipeline.AskBound); err != nil { - logf("the step of ask %s, which no holder took, is not ended: %v", e.Key(), err) - } + expired(e.Key()) } } }() diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go index eca36ef1..19a132c2 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go @@ -20,6 +20,8 @@ import ( type runnerViews struct { Registrations *meshcatalogclient.Registrations Catalog *pipeline.Views + // Findings is the runner's finding writer, the reader's and the runner's own findings through it. + Findings callFindings } // startViews wires the runner's views on hub and asks each first view; register every other handler on hub before or @@ -59,7 +61,7 @@ func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cr return nil, err } reader.Start(ctx, js, cred, status) - return &runnerViews{Registrations: regs, Catalog: cv}, nil + return &runnerViews{Registrations: regs, Catalog: cv, Findings: findings}, nil } // catalogView is a branch's meshcatalogclient.Catalog as the runner reads it. diff --git a/modules/mesh-delivery-pipeline/go.mod b/modules/mesh-delivery-pipeline/go.mod index 9bfc48db..3fcef96d 100644 --- a/modules/mesh-delivery-pipeline/go.mod +++ b/modules/mesh-delivery-pipeline/go.mod @@ -10,6 +10,7 @@ require ( require ( git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshpipelinerunnerclient v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/bounds.go b/modules/mesh-delivery-pipeline/internal/pipeline/bounds.go index c4a107b2..44089fcb 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/bounds.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/bounds.go @@ -93,3 +93,36 @@ func (h *Hearer) endNotTaken(key, ask string, bound time.Duration) error { // IsAskKey says whether a key of the state `bounds` is an ask's bound. func IsAskKey(key string) bool { return strings.HasPrefix(key, "step-") && !strings.Contains(key, ".") } + +// DropBoundPrefix begins the key of a superseded step's drop in the state `bounds`, `drop..` (design 47 G +// A-222, A-77): its five-minute expiry says the drop unanswered. +const DropBoundPrefix = "drop." + +// DropBoundKey is the key of the bound of buildSeat's drop of ask. +func DropBoundKey(buildSeat, ask string) string { return DropBoundPrefix + buildSeat + "." + ask } + +// ParseDropBound reads a key of the state `bounds` as a drop's bound: the build seat and the ask it names. +func ParseDropBound(key string) (buildSeat, ask string, ok bool) { + rest, found := strings.CutPrefix(key, DropBoundPrefix) + if !found { + return "", "", false + } + buildSeat, ask, found = strings.Cut(rest, ".") + return buildSeat, ask, found && buildSeat != "" && ask != "" && !strings.Contains(ask, ".") +} + +// BoundDrop is drop with a bound of five minutes (A-222, A-77): the bound key is armed, with its own time to live, before +// the drop is asked, and answered is told when the drop is answered, to delete the key, so an answer ends the wait with no +// finding; a drop never answered leaves its key to expire, and the expiry says it unanswered. A bound that cannot be armed +// is returned and the drop not asked. +func BoundDrop(drop Drop, arm func(key string) error, answered func(buildSeat, ask string) error) Drop { + return func(buildSeat, node, ask, why string) error { + if err := arm(DropBoundKey(buildSeat, ask)); err != nil { + return fmt.Errorf("the bound of the drop of ask %s is not armed: %w", ask, err) + } + if err := drop(buildSeat, node, ask, why); err != nil { + return err + } + return answered(buildSeat, ask) + } +} diff --git a/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go b/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go index d6b96b54..8490be86 100644 --- a/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go +++ b/modules/mesh-delivery-pipeline/internal/pipeline/supersede_test.go @@ -1,6 +1,7 @@ package pipeline import ( + "errors" "slices" "strings" "testing" @@ -91,3 +92,34 @@ func TestSupersede(t *testing.T) { t.Errorf("the older publish's pipeline is %s: %q", p.Outcome, p.Why) } } + +// A-222, A-77: a drop is bounded by a key of its own armed before it is asked and deleted when it is answered; one never +// answered leaves its key to expire. +func TestBoundDrop(t *testing.T) { + var armed, answered []string + arm := func(key string) error { armed = append(armed, key); return nil } + done := func(s, ask string) error { answered = append(answered, s+"/"+ask); return nil } + if s, ask, ok := ParseDropBound(DropBoundKey(nodebuildgo.Seat, "step-ab")); !ok || s != nodebuildgo.Seat || ask != "step-ab" { + t.Errorf("the drop's key reads %q, %q, %v", s, ask, ok) + } + if _, _, ok := ParseDropBound("step-ab"); ok { + t.Error("an ask's bound was read as a drop's") + } + if IsAskKey(DropBoundKey("s", "step-ab")) { + t.Error("a drop's bound was taken for an ask's") + } + err := BoundDrop(func(string, string, string, string) error { return nil }, arm, done)(nodebuildgo.Seat, "n1", "step-ab", "why") + if err != nil || len(armed) != 1 || armed[0] != "drop."+nodebuildgo.Seat+".step-ab" || len(answered) != 1 { + t.Errorf("an answered drop: %v, armed %v, answered %v", err, armed, answered) + } + answered = nil + err = BoundDrop(func(string, string, string, string) error { return errors.New("no responders") }, arm, done)(nodebuildgo.Seat, "", "step-cd", "why") + if err == nil || len(armed) != 2 || len(answered) != 0 { + t.Errorf("an unanswered drop: %v, armed %v, answered %v (its key is left to expire)", err, armed, answered) + } + asked := false + err = BoundDrop(func(string, string, string, string) error { asked = true; return nil }, func(string) error { return errors.New("full") }, done)(nodebuildgo.Seat, "", "step-ef", "why") + if err == nil || asked { + t.Errorf("a drop whose bound is not armed: %v, asked %v", err, asked) + } +} From bff7b402e707025dc73bb137a5a948a82886ca12 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:19:07 +0100 Subject: [PATCH 18/18] B1g-5: the catalog's revision verb answers a candidate's group alone and a branch's source revision Builds: design 47 G A-205, A-222 --- .../internal/index/catalog_branches_test.go | 32 +++++++++ .../internal/index/revision_verb.go | 69 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go index d7e625f9..7de43afc 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go @@ -63,4 +63,36 @@ func TestACatalogRevisionIsKeptPerWatchedBranch(t *testing.T) { if a := answer("beta", 0); !a.NoneYet { t.Errorf("a branch with no revision answered %+v, want none-yet", a) } + // A-205: `source` answers the branch's revision taken in from that source revision; a candidate's group alone answers its + // newest revision, whatever its heads. + bySource := func(branch string, source int64) (meshcatalog.RevisionAnswer, error) { + return Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Branch: branch, Source: source}) + } + if a, err := bySource("main", 5); err != nil || a.Revision.Revision != 1 || a.Entries[0].Commit != "c1" { + t.Errorf("main's source 5 answered %+v (%v)", a.Revision, err) + } + if a, err := bySource("feature/x=2ey", 7); err != nil || a.Revision.Revision != 1 || a.Entries[0].Commit != "c2" { + t.Errorf("the branch's source 7 answered %+v (%v), want its revision 1 at c2", a.Revision, err) + } + if a, err := bySource("feature/x=2ey", 8); err != nil || a.Revision.Revision != 2 { + t.Errorf("the branch's source 8 answered %+v (%v)", a.Revision, err) + } + if _, err := bySource("feature/x=2ey", 9); err == nil { + t.Error("a source no revision was taken in from was answered") + } + for i, heads := range []string{"h1", "h2"} { + rev, objects, err := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: int64(3 + i), Kind: "candidate", Group: "run-ab", Heads: heads}, tip("c9")) + if err != nil { + t.Fatal(err) + } + if err := KeepRevision(ctx, ix.Revisions, rev, objects); err != nil { + t.Fatal(err) + } + } + if a, err := Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Group: "run-ab"}); err != nil || a.Revision.Revision != 4 || a.Revision.Heads != "h2" || a.Entries[0].Commit != "c9" { + t.Errorf("a group alone answered %+v (%v), want its newest revision 4", a.Revision, err) + } + if _, err := Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Group: "run-none"}); err == nil { + t.Error("a candidate never kept was answered") + } } diff --git a/modules/mesh-delivery-indexer/internal/index/revision_verb.go b/modules/mesh-delivery-indexer/internal/index/revision_verb.go index d279affc..3a0d48e0 100644 --- a/modules/mesh-delivery-indexer/internal/index/revision_verb.go +++ b/modules/mesh-delivery-indexer/internal/index/revision_verb.go @@ -71,11 +71,21 @@ func Revision(ctx context.Context, o Objects, holder, caller string, in meshcata } name := RevisionObject(newest) switch { + case in.Group != "" && in.Heads == "": + // A candidate's group alone answers its newest revision, whatever its heads (A-205). + if name, err = newestCandidate(ctx, o, in.Group); err != nil { + return out, err + } case in.Group != "" || in.Heads != "": if in.Group == "" || in.Heads == "" { return out, fmt.Errorf("a candidate's revision is named by its group and its heads, both") } name = RevisionObject(meshcatalog.CatalogRevision{Kind: "candidate", Group: in.Group, Heads: in.Heads}) + case in.Source != 0: + // The branch's revision taken in from that source revision (A-205). + if name, err = revisionOfSource(ctx, o, branch, in.Source); err != nil { + return out, err + } case in.Revision != 0 && in.Revision != newest.Revision: name = RevisionObject(meshcatalog.CatalogRevision{Branch: branch, Revision: in.Revision}) } @@ -121,3 +131,62 @@ func RevisionTool(o Objects, holder string) stdio.Tool { return Revision(context.Background(), o, holder, stdio.CallerOf(args), in) }} } + +// newestCandidate is the object of the newest revision a candidate group keeps, over whatever heads it was kept at (A-205). +func newestCandidate(ctx context.Context, o Objects, group string) (string, error) { + names, err := o.Names(ctx) + if err != nil { + return "", fmt.Errorf("the candidate revisions cannot be listed: %w", err) + } + best, newest := "", int64(-1) + for _, n := range names { + if !strings.HasPrefix(n, "candidate."+group+".") { + continue + } + rev, _, err := ReadRevision(ctx, o, n) + if err != nil { + return "", err + } + if rev.Group == group && rev.Revision > newest { + best, newest = n, rev.Revision + } + } + if best == "" { + return "", fmt.Errorf("the catalog keeps no candidate of the group %s", group) + } + return best, nil +} + +// revisionOfSource is the object of the newest revision of branch taken in from the source revision source (A-205); a source +// none was taken in from is an error. +func revisionOfSource(ctx context.Context, o Objects, branch string, source int64) (string, error) { + names, err := o.Names(ctx) + if err != nil { + return "", fmt.Errorf("the catalog graph revisions cannot be listed: %w", err) + } + number := TrunkRevisionOf + if !isTrunkBranch(branch) { + number = func(n string) (int64, bool) { return BranchRevisionOf(branch, n) } + } + var found []int64 + for _, n := range names { + if r, ok := number(n); ok { + found = append(found, r) + } + } + slices.SortFunc(found, func(a, b int64) int { return int(b - a) }) + for _, r := range found { + name := RevisionObject(meshcatalog.CatalogRevision{Branch: branch, Revision: r}) + rev, _, err := ReadRevision(ctx, o, name) + if err != nil { + return "", err + } + if rev.Source == source { + return name, nil + } + if rev.Source < source { + break + } + } + return "", fmt.Errorf("no revision of %q was taken in from the source revision %d", branch, source) +}