From 23a209f3d0deafdcc42be420bc581274ae213a5a Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 21:40:26 +0000 Subject: [PATCH 1/4] Answer plans what-if as data, the document delivery-plan answers (issue 1324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate reads a delivery's plan as data once delivery-plan goes (ADR 0450 §2). The data form is an explicit switch, `data: true`, as plans wait asks for its data: a person still gets the text. Both verbs build their document from one function, deliveryPlanDocument, so they cannot differ; delivery-plan is unchanged (ADR 0450 §8). --- cmd/mesh-controller/delivery.go | 34 ++++-- cmd/mesh-controller/plans_data_test.go | 140 +++++++++++++++++++++++++ cmd/mesh-controller/release_plan.go | 26 +++++ cmd/mesh-controller/seatverbs.go | 9 ++ internal/catalogue/verbs.go | 14 ++- 5 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 cmd/mesh-controller/plans_data_test.go diff --git a/cmd/mesh-controller/delivery.go b/cmd/mesh-controller/delivery.go index c8f6cd3a..62d26956 100644 --- a/cmd/mesh-controller/delivery.go +++ b/cmd/mesh-controller/delivery.go @@ -784,6 +784,27 @@ func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[s return out } +// changeMember is one change as the planner is asked about it, the same for `delivery plan` and `plans --what-if`. +func changeMember(repository, base, head string, paths, removed []string, moduleDirs *[]string) orderMember { + m := orderMember{ID: repository + "@" + head, Repository: repository, Base: base, Head: head, + Paths: paths, Removed: removed} + if moduleDirs != nil { + m.ModuleDirs, m.ModuleDirsSaid = *moduleDirs, true + } + return m +} + +// deliveryPlanDocument is the answer of `delivery plan` and of `plans --what-if … --data`: one change's +// change plan with its reach, the mesh's own part in it and whether the gate would run. One function, so +// the two verbs cannot differ (novox/hq ADR 0450 §2, issue 1324). +func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository, + edges []inventory.Edge, policy policyOf) map[string]any { + s := pullScope(m.pull(), entries, read, edges) + plan := changePlanOf(m.Repository, m.Base, m.Head, s.Reach, entries, policy) + return map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents, + New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()} +} + // deliveryCommand is `delivery`, the controller's verbs for the delivery's owner: // // delivery plan --repository owner/repo --head [--base main] --paths a,b [--module-dirs …] [--removed …] @@ -855,22 +876,15 @@ func deliveryCommand(ctx context.Context, args []string) error { if *repository == "" || *head == "" && *paths == "" { return errors.New("delivery plan --repository owner/repo --head --paths a,b") } - m := orderMember{ID: *repository + "@" + *head, Repository: *repository, Base: *base, Head: *head, - Paths: splitList(*paths), Removed: splitList(*removed)} - if d := moduleDirsOf(*moduleDirs); d != nil { - m.ModuleDirs, m.ModuleDirsSaid = *d, true - } + m := changeMember(*repository, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs)) entries, read, edges, err := theGraph(ctx, inv) if err != nil { return err } - s := pullScope(m.pull(), entries, read, edges) - plan := changePlanOf(*repository, *base, *head, s.Reach, entries, func(module string) (inventory.Upgrade, bool) { + return answer(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) { u, err := inv.UpgradeOf(ctx, module) return u, err == nil - }) - return answer(map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents, - New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()}) + })) case "order": if len(members) == 0 { return errors.New("delivery order --members : a group has members") diff --git a/cmd/mesh-controller/plans_data_test.go b/cmd/mesh-controller/plans_data_test.go new file mode 100644 index 00000000..27c74ed2 --- /dev/null +++ b/cmd/mesh-controller/plans_data_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// flagsOf reads a command line's flags (and its first positional after --what-if) into a map. +func flagsOf(argv []string) map[string]string { + out := map[string]string{} + for i := 0; i < len(argv); i++ { + if !strings.HasPrefix(argv[i], "--") { + continue + } + name := strings.TrimPrefix(argv[i], "--") + if i+1 < len(argv) && !strings.HasPrefix(argv[i+1], "--") { + out[name] = argv[i+1] + i++ + } else { + out[name] = "true" + } + } + return out +} + +// **The plan of a change as data from `plans`** (novox/hq ADR 0450 §2, issue 1324 AC1): asked for one change +// both ways, `plans` with data and `delivery-plan` answer the same plan in every field. +func TestPlansWhatIfAsDataAnswersWhatDeliveryPlanAnswers(t *testing.T) { + change := map[string]any{"repository": "novox/mesh-catalog", "paths": "modules/gitea/index.ts,modules/gitea/old.txt", + "head": "abc123", "base": "main", "module-dirs": "modules/gitea", "removed": "modules/gitea/old.txt"} + with := func(extra map[string]any) map[string]any { + out := map[string]any{} + for k, v := range change { + out[k] = v + } + for k, v := range extra { + out[k] = v + } + return out + } + plansArgv, err := argvFor("plans", with(map[string]any{"data": "true"})) + if err != nil { + t.Fatal(err) + } + deliveryArgv, err := argvFor("delivery-plan", change) + if err != nil { + t.Fatal(err) + } + if plansArgv[0] != "plans" || !slicesHas(plansArgv, "--data") || !slicesHas(plansArgv, "--what-if") { + t.Fatalf("plans with data became %q", strings.Join(plansArgv, " ")) + } + p, d := flagsOf(plansArgv), flagsOf(deliveryArgv) + for _, name := range []string{"paths", "head", "base", "module-dirs", "removed"} { + if p[name] == "" || p[name] != d[name] { + t.Errorf("--%s: plans says %q, delivery-plan %q", name, p[name], d[name]) + } + } + if p["what-if"] != d["repository"] { + t.Errorf("repository: plans says %q, delivery-plan %q", p["what-if"], d["repository"]) + } + + // Both commands build their document from the one function, from the same member. + const src = "http://forge.internal:20000/novox/mesh-catalog.git" + gitea := fromRepo("gitea", src, "modules/gitea") + gitea.Source.BuiltFrom = "old" + gitea.On = []string{"anchor", "laptop"} + entries := []inventory.Entry{gitea} + policy := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true } + member := func(f map[string]string, repository string) orderMember { + return changeMember(repository, f["base"], f["head"], splitList(f["paths"]), splitList(f["removed"]), + moduleDirsOf(f["module-dirs"])) + } + a := deliveryPlanDocument(member(p, p["what-if"]), entries, nil, nil, policy) + b := deliveryPlanDocument(member(d, d["repository"]), entries, nil, nil, policy) + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + if !reflect.DeepEqual(ja, jb) { + t.Errorf("the two plans differ:\n%s\n%s", ja, jb) + } + plan := a["plan"].(interface{}) + raw, _ := json.Marshal(plan) + var fields map[string]any + _ = json.Unmarshal(raw, &fields) + for _, want := range []string{"moved", "tiers", "machines", "summary", "class", "head", "base", "repository"} { + if _, ok := fields[want]; !ok { + t.Errorf("the plan has no %q: %s", want, raw) + } + } + if fields["head"] != "abc123" || fields["base"] != "main" { + t.Errorf("head and base are %v and %v", fields["head"], fields["base"]) + } +} + +// Without the data form the text answer is unchanged (AC2): no data flag, and the data-only arguments are not passed. +func TestPlansWhatIfWithoutDataIsUnchanged(t *testing.T) { + argv, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a,b"}) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a,b" { + t.Errorf("the text what-if reads %q", got) + } + if _, err := argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"}); err == nil { + t.Error("a head without data was passed over, not refused") + } + argv, _ = argvFor("plans", map[string]any{"repository": "novox/x", "paths": "a", "data": "false"}) + if got := strings.Join(argv, " "); got != "plans --what-if novox/x --paths a" { + t.Errorf("data false reads %q", got) + } +} + +// delivery-plan stays and answers as before (AC3, ADR 0450 §8). +func TestDeliveryPlanStaysAsItWas(t *testing.T) { + argv, err := argvFor("delivery-plan", map[string]any{"repository": "novox/x", "paths": "a", "head": "h"}) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(argv, " "); got != "delivery plan --repository novox/x --paths a --head h" { + t.Errorf("delivery-plan reads %q", got) + } + if _, ok := catalogueVerb("delivery-plan"); !ok { + t.Error("delivery-plan is gone") + } +} + +func catalogueVerb(name string) (catalogue.Verb, bool) { return controllerVerb(name) } + +func slicesHas(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index e9f1b7bb..e6364de5 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -6,6 +6,7 @@ import ( "errors" "flag" "fmt" + "os" "slices" "sort" "strings" @@ -2037,6 +2038,12 @@ func plansCommand(ctx context.Context, args []string) error { modules := set.String("modules", "", "or the modules it would change, comma-separated") moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+ "comma-separated, as the forge's announcer says them (novox/hq issue 278)") + // The what-if as data, the document `delivery plan` answers (novox/hq ADR 0450 §2, issue 1324). + data := set.Bool("data", false, "with --what-if: answer the change plan as data, the document `delivery plan` answers; "+ + "without it the answer is the text a person reads") + head := set.String("head", "", "with --data: the commit at hand") + base := set.String("base", "main", "with --data: the branch it merges into") + removed := set.String("removed", "", "with --data: the files among --paths it deletes, comma-separated") // Following a plan (novox/hq issue 229): `plans wait `. until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)") bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+ @@ -2152,6 +2159,25 @@ func plansCommand(ctx context.Context, args []string) error { } return nil } + if *data && *whatIf == "" { + return errors.New("--data answers a what-if: give --what-if owner/repository") + } + if *whatIf != "" && *data { + if *paths == "" { + return errors.New("plans --what-if --data: the change plan is of the files a change touches; give --paths") + } + entries, read, edges, err := theGraph(ctx, inv) + if err != nil { + return err + } + m := changeMember(*whatIf, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs)) + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) { + u, err := inv.UpgradeOf(ctx, module) + return u, err == nil + })) + } if *whatIf != "" { return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules), moduleDirsOf(*moduleDirs)) } diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index da238e3f..5decbfef 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -352,6 +352,15 @@ func (a *verbArguments) commandLine() ([]string, error) { if d := str("module-dirs"); d != "" { argv = append(argv, "--module-dirs", d) } + // The plan as data, the document delivery-plan answers (novox/hq ADR 0450 §2, issue 1324). + if str("data") == "true" { + argv = append(argv, "--data") + for _, flag := range []string{"head", "base", "removed"} { + if v := str(flag); v != "" { + argv = append(argv, "--"+flag, v) + } + } + } return argv, nil } for _, act := range []string{"stop", "close", "retry", "go"} { diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index aa470807..17e417a0 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -135,10 +135,16 @@ var ControllerVerbs = []Verb{ "modules": "with repository: or the modules it would change, comma-separated", "module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " + "as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest", - "limit": "how many plans to list (default 10); only when listing", - "why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)", - "cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)", - }, nil)}, + "data": "with repository and paths: true answers the change plan as data, the document delivery-plan answers " + + "(moved, dependents, new, tiers, machines with what each receives and waits for, steps, summary, class, head, base); " + + "without it the answer is text (novox/hq ADR 0450 §2)", + "head": "with data: the commit at hand", + "base": "with data: the branch it merges into (default main)", + "removed": "with data: the files among paths the change deletes, comma-separated", + "limit": "how many plans to list (default 10); only when listing", + "why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)", + "cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)", + }, nil, "data")}, // The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and // composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them. {Name: "delivery-plan", Description: "The delivery plan of a diffset, as the planner computes it and keeping " + -- 2.54.0 From 4083755ca1bde2bac0b86fc8dbb40b51825da5fb Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 21:42:37 +0000 Subject: [PATCH 2/4] Let the build agent read the gate's pull keys and watch both buckets (issue 1325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From R3e the gate writes mesh-delivery-gate_pulls and today's bucket stops (ADR 0450 §1). The build agent's manifest reads both, and a running check's watch listens in every bucket that exists, so a move is heard whichever holds the key. --- internal/link/checks_nats.go | 97 ++++++++++++++++++---------- internal/link/pull_buckets_test.go | 85 ++++++++++++++++++++++++ modules/build-agent/manifest_test.go | 15 +++++ modules/build-agent/module.json | 1 + 4 files changed, 164 insertions(+), 34 deletions(-) diff --git a/internal/link/checks_nats.go b/internal/link/checks_nats.go index 7b3064df..176c70ec 100644 --- a/internal/link/checks_nats.go +++ b/internal/link/checks_nats.go @@ -8,6 +8,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/nats-io/nats.go" @@ -180,45 +181,73 @@ func WatchPull(ctx context.Context, conn *nats.Conn, r BuildRequest, moved func( if err != nil { return nil, err } - var kv jetstream.KeyValue - _, err = firstBucketThatExists(ctx, PullBucketsOf(c), func(ctx context.Context, bucket string) error { - var err error - kv, err = api.KeyValue(ctx, bucket) - return err - }) - if err != nil { - return nil, fmt.Errorf("the pull requests' keys: %w", err) - } + return watchPullIn(ctx, PullBucketsOf(c), func(ctx context.Context, bucket string) (pullWatcher, error) { + kv, err := api.KeyValue(ctx, bucket) + if err != nil { + return nil, err + } + return kv.Watch(ctx, PullKeyOf(c.Owner, c.Repo, c.Number), jetstream.UpdatesOnly()) + }, r, moved) +} + +// pullWatcher is what a watch of one bucket's key gives: the updates, and a stop. +type pullWatcher interface { + Updates() <-chan jetstream.KeyValueEntry + Stop() error +} + +// watchPullIn is WatchPull over buckets watched by open: the key is watched in every bucket that exists, since +// the gate writes its own bucket and mesh-delivery's stops only when the gate takes over (novox/hq ADR 0450 §1, +// issue 1325); a move in any of them stops the check, once. None existing is an error. +func watchPullIn(ctx context.Context, buckets []string, open func(ctx context.Context, bucket string) (pullWatcher, error), + r BuildRequest, moved func(PullKey)) (stop func(), err error) { watching, end := context.WithCancel(ctx) - w, err := kv.Watch(watching, PullKeyOf(c.Owner, c.Repo, c.Number), jetstream.UpdatesOnly()) - if err != nil { - end() - return nil, err + var watchers []pullWatcher + for _, bucket := range buckets { + w, err := open(watching, bucket) + if errors.Is(err, jetstream.ErrBucketNotFound) { + continue + } + if err != nil { + end() + for _, held := range watchers { + _ = held.Stop() + } + return nil, fmt.Errorf("the pull requests' keys in %s: %w", bucket, err) + } + watchers = append(watchers, w) } - go func() { - defer w.Stop() - for { - select { - case <-watching.Done(): - return - case e, ok := <-w.Updates(): - if !ok { - return - } - if e == nil || e.Operation() != jetstream.KeyValuePut { - continue - } - var key PullKey - if json.Unmarshal(e.Value(), &key) != nil { - continue - } - if key.Moved(r.Ref, c.Trunk) { - moved(key) + if len(watchers) == 0 { + end() + return nil, fmt.Errorf("the pull requests' keys: none of the buckets %v exists", buckets) + } + var once sync.Once + for _, w := range watchers { + go func(w pullWatcher) { + defer w.Stop() + for { + select { + case <-watching.Done(): return + case e, ok := <-w.Updates(): + if !ok { + return + } + if e == nil || e.Operation() != jetstream.KeyValuePut { + continue + } + var key PullKey + if json.Unmarshal(e.Value(), &key) != nil { + continue + } + if key.Moved(r.Ref, r.Check.Trunk) { + once.Do(func() { moved(key) }) + return + } } } - } - }() + }(w) + } return end, nil } diff --git a/internal/link/pull_buckets_test.go b/internal/link/pull_buckets_test.go index 6329b7ae..2e27f187 100644 --- a/internal/link/pull_buckets_test.go +++ b/internal/link/pull_buckets_test.go @@ -7,6 +7,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/nats-io/nats.go/jetstream" @@ -111,3 +112,87 @@ func mustJSON(t *testing.T, v any) string { } func contains(s, sub string) bool { return strings.Contains(s, sub) } + +// fakeWatch is one bucket's watch of the key, fed by the test. +type fakeWatch struct { + updates chan jetstream.KeyValueEntry + stopped bool +} + +func (f *fakeWatch) Updates() <-chan jetstream.KeyValueEntry { return f.updates } +func (f *fakeWatch) Stop() error { f.stopped = true; return nil } + +type fakeEntry struct { + jetstream.KeyValueEntry + value string +} + +func (e fakeEntry) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePut } +func (e fakeEntry) Value() []byte { return []byte(e.value) } + +// 1325/AC2: a running check's watch hears the key move in the gate's bucket when it is there, in today's when only +// it is there, and in either when both are; a bucket that does not exist is passed over, and none is an error. +func TestARunningChecksWatchFindsItsKeyInTheGatesBucketOrTodays(t *testing.T) { + req := BuildRequest{Ref: "old", Check: &CheckRequest{Owner: "novox", Repo: "a", Number: 7, Trunk: "t", + Pulls: []string{"mesh-delivery-gate_pulls", "mesh-delivery_pulls"}}} + run := func(exist ...string) (map[string]*fakeWatch, chan PullKey, func(), error) { + watches := map[string]*fakeWatch{} + for _, b := range exist { + watches[b] = &fakeWatch{updates: make(chan jetstream.KeyValueEntry, 1)} + } + moved := make(chan PullKey, 2) + stop, err := watchPullIn(t.Context(), req.Check.Pulls, func(_ context.Context, bucket string) (pullWatcher, error) { + if w, ok := watches[bucket]; ok { + return w, nil + } + return nil, jetstream.ErrBucketNotFound + }, req, func(k PullKey) { moved <- k }) + return watches, moved, stop, err + } + wait := func(moved chan PullKey) (PullKey, bool) { + select { + case k := <-moved: + return k, true + case <-time.After(2 * time.Second): + return PullKey{}, false + } + } + for name, c := range map[string]struct { + exist []string + feed string + }{ + "only the gate's": {[]string{"mesh-delivery-gate_pulls"}, "mesh-delivery-gate_pulls"}, + "only today's": {[]string{"mesh-delivery_pulls"}, "mesh-delivery_pulls"}, + "both, the gate's": {[]string{"mesh-delivery-gate_pulls", "mesh-delivery_pulls"}, "mesh-delivery-gate_pulls"}, + "both, today's": {[]string{"mesh-delivery-gate_pulls", "mesh-delivery_pulls"}, "mesh-delivery_pulls"}, + } { + watches, moved, stop, err := run(c.exist...) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + watches[c.feed].updates <- fakeEntry{value: `{"head":"newer","trunk":"t"}`} + if k, ok := wait(moved); !ok || k.Head != "newer" { + t.Errorf("%s: the move was not heard (%+v, %v)", name, k, ok) + } + stop() + } + // A key that only gains its verdict is no move, and a move is told once. + watches, moved, stop, err := run("mesh-delivery-gate_pulls", "mesh-delivery_pulls") + if err != nil { + t.Fatal(err) + } + watches["mesh-delivery-gate_pulls"].updates <- fakeEntry{value: `{"head":"old","trunk":"t","verdict":"pass"}`} + if _, ok := wait(moved); ok { + t.Error("a verdict alone moved the check") + } + watches["mesh-delivery-gate_pulls"].updates <- fakeEntry{value: `{"head":"newer","trunk":"t"}`} + watches["mesh-delivery_pulls"].updates <- fakeEntry{value: `{"head":"newer","trunk":"t"}`} + wait(moved) + if _, ok := wait(moved); ok { + t.Error("a move was told twice") + } + stop() + if _, _, _, err := run(); err == nil { + t.Error("no bucket existing was not an error") + } +} diff --git a/modules/build-agent/manifest_test.go b/modules/build-agent/manifest_test.go index 59e041d9..a0716251 100644 --- a/modules/build-agent/manifest_test.go +++ b/modules/build-agent/manifest_test.go @@ -13,6 +13,7 @@ import ( ) type manifest struct { + Reads []string `json:"reads"` Requires []json.RawMessage `json:"requires"` Binds map[string]string `json:"binds"` Secrets map[string]string `json:"secrets"` @@ -172,3 +173,17 @@ func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) { t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted) } } + +// A running check's watch reads the pull requests' keys in the gate's bucket and in mesh-delivery's, so it +// stays sighted whichever of them the keys are written to (novox/hq ADR 0450 §1, issue 1325 AC1). +func TestTheAgentReadsTheGatesPullKeysAndTodaysBoth(t *testing.T) { + got := map[string]bool{} + for _, r := range read(t).Reads { + got[r] = true + } + for _, want := range []string{"mesh-delivery-gate.pulls", "mesh-delivery.pulls"} { + if !got[want] { + t.Errorf("the build agent does not read %s: %v", want, read(t).Reads) + } + } +} diff --git a/modules/build-agent/module.json b/modules/build-agent/module.json index 4d74764d..5a550ef6 100644 --- a/modules/build-agent/module.json +++ b/modules/build-agent/module.json @@ -13,6 +13,7 @@ } ], "reads": [ + "mesh-delivery-gate.pulls", "mesh-delivery.pulls" ], "requires": [ -- 2.54.0 From d8c13d97ca8b75d91e61dc4b0669f50d429d7f51 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 21:47:47 +0000 Subject: [PATCH 3/4] Test that the forge bridge's, indexer's and teller's credentials grant exactly their manifests (issue 1243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0437 §4's check: the three manifests, copied from mesh-delivery's main, are composed through Users and PermissionsFor; each may do what it declares and is refused another module's bucket, another module's name and a seat's verb subject. --- internal/broker/delivery_parts_grant_test.go | 490 +++++++++++++++++++ 1 file changed, 490 insertions(+) create mode 100644 internal/broker/delivery_parts_grant_test.go diff --git a/internal/broker/delivery_parts_grant_test.go b/internal/broker/delivery_parts_grant_test.go new file mode 100644 index 00000000..836a9f9a --- /dev/null +++ b/internal/broker/delivery_parts_grant_test.go @@ -0,0 +1,490 @@ +package broker + +import ( + "encoding/json" + "slices" + "sort" + "strings" + "testing" +) + +// The delivery's forge bridge, indexer and teller are processes on their module's own broker credential, granted from +// the manifest alone (novox/hq ADR 0437 §4, issue 1243): `state` grants writing and watching the module's own buckets +// and streams, `reads` watching another module's bucket and never writing it, `consumes` hearing the named events, +// `invokes` calling the named tools, `uses` the seat operator-channel's by-caller subjects under the module's own name. +// +// The three manifests are copied from mesh-delivery's main at 6a23627426c6 (modules//module.json, read +// 2026-10-19), whole, so a change to one of them there that changes its grant is a change to read here. +const bridgeManifest = `{ + "module": "mesh-delivery-forge-bridge", + "version": "1", + "slug": "forge-bridge", + "consumes": [ + "gitea.pull.updated", + "gitea.pull.merged", + "gitea.pull.closed" + ], + "streams": [ + { "name": "forge", "events": ["forge.**"] } + ], + "state": [ + { "name": "heads", "history": 1 } + ], + "reads": [ + "mesh-delivery-gate.merges", + "mesh-delivery-gate.owed" + ], + "invokes": [ + "gitea.gitea_merge_pull_request", + "gitea.gitea_commit_status", + "gitea.gitea_delivery_view", + "gitea.gitea_note_append", + "gitea.gitea_add_comment", + "gitea.gitea_open_pulls", + "gitea.gitea_commit_trees" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-forge-bridge", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-forge-bridge", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-forge-bridge" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-forge-bridge", + "place": ".", + "unknown": "program" + }, + { + "id": "bridge", + "type": "process", + "name": "mesh-delivery-forge-bridge", + "artifact": "program", + "run": [ + "./mesh-delivery-forge-bridge" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-forge-bridge", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-forge-bridge", + "binary": "mesh-delivery-forge-bridge" + } + ] + } +}` + +const indexerManifest = `{ + "module": "mesh-delivery-indexer", + "version": "1", + "slug": "indexer", + "consumes": [ + "mesh-delivery-forge-bridge.forge.**" + ], + "state": [ + { "name": "candidates", "history": 10 }, + { "name": "modules", "history": 20 } + ], + "reads": [ + "mesh-controller.builds", + "mesh-delivery-gate.source" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-indexer", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-indexer", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-indexer" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-indexer", + "place": ".", + "unknown": "program" + }, + { + "id": "indexer", + "type": "process", + "name": "mesh-delivery-indexer", + "artifact": "program", + "run": [ + "./mesh-delivery-indexer" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-indexer", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-indexer", + "binary": "mesh-delivery-indexer" + } + ] + } +}` + +const tellerManifest = `{ + "module": "mesh-delivery-teller", + "version": "1", + "slug": "teller", + "state": [ + { "name": "answers", "history": 5 } + ], + "reads": [ + "mesh-delivery-gate.asks" + ], + "uses": [ + "operator-channel" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-delivery-teller", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-delivery-teller", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-delivery-teller" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-delivery-teller", + "place": ".", + "unknown": "program" + }, + { + "id": "teller", + "type": "process", + "name": "mesh-delivery-teller", + "artifact": "program", + "run": [ + "./mesh-delivery-teller" + ], + "env": { + "MESH_BROKER_FILE": "${dir:state}/broker" + }, + "user": "mesh-delivery-teller", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "program", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-teller", + "binary": "mesh-delivery-teller" + } + ] + } +}` + +// manifestOf is the part of a module's manifest that composes a bus account, as the controller reads it. +type manifestOf struct { + Module string `json:"module"` + Consumes []string `json:"consumes"` + Streams []struct { + Name string `json:"name"` + Events []string `json:"events"` + } `json:"streams"` + State []struct { + Name string `json:"name"` + History int `json:"history"` + } `json:"state"` + Reads []string `json:"reads"` + Invokes []string `json:"invokes"` + Uses []string `json:"uses"` + OwnSecrets map[string]json.RawMessage `json:"own-secrets"` + Claims []struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + } `json:"claims"` +} + +// declaredOf is what the controller declares of a manifest to the broker (inventory's busrecords): its state as +// buckets, its streams under the module's name in the bus's spelling, its claims as held seats, the module streams +// its consumes name as heard, and no account when it declares no own secret `broker`. streamsOf are the module +// streams the other manifests of the test own, by module. +func declaredOf(t *testing.T, raw string, streamsOf map[string]ModuleStream) Declared { + t.Helper() + var m manifestOf + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatal(err) + } + d := Declared{Module: m.Module, Consumes: m.Consumes, Reads: m.Reads, Invokes: m.Invokes} + if _, reads := m.OwnSecrets["broker"]; !reads { + d.NoAccount = true + } + for _, s := range m.State { + d.State = append(d.State, Bucket{Module: m.Module, Name: s.Name, History: s.History}) + } + for _, s := range m.Streams { + var events []string + for _, e := range s.Events { + events = append(events, WirePattern(e)) + } + d.Streams = append(d.Streams, ModuleStream{Module: m.Module, Name: strings.ToUpper(s.Name), Events: events, + MaxAgeDays: 7, MaxMiB: 64}) + } + for _, c := range m.Claims { + d.Holds = append(d.Holds, Seat{Name: c.Name, Scope: c.Scope, Serves: c.Serves}) + } + for _, name := range m.Uses { + if name == "operator-channel" { + d.Uses = append(d.Uses, operatorChannelWithCondition()) + } + } + for _, c := range m.Consumes { + emitter, event, _ := strings.Cut(c, ".") + if s, owned := streamsOf[emitter]; owned && strings.HasPrefix(event, strings.ToLower(s.Name)+".") { + d.Hears = append(d.Hears, Heard{Stream: s.Name, Emitter: emitter, + Filters: []string{ModuleStreamSubject(emitter, WirePattern(event))}}) + d.HearsAStream = true + } + } + return d +} + +// canWrite says a principal may publish to a bucket's key; canRead says it may get one directly. +func canWrite(p Permissions, bucket, key string) bool { + return allowed(p.Publish, "$KV."+bucket+"."+key) +} +func canRead(p Permissions, bucket, key string) bool { + return allowed(p.Publish, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+key) +} + +// composed is every module's and every machine's tool runner's grants the broker composes for these records, +// as `@` and `runner@`. +func composed(t *testing.T, r Records) map[string]Permissions { + t.Helper() + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + out := map[string]Permissions{} + for _, u := range users { + switch u.Kind { + case KindNodeTools: + out["runner@"+u.Node] = perms(t, u) + case KindModule: + out[u.Module+"@"+u.Node] = perms(t, u) + } + } + return out +} + +// withPrefix is the patterns that start with any of the prefixes, sorted. +func withPrefix(patterns []string, prefixes ...string) []string { + var out []string + for _, p := range patterns { + for _, prefix := range prefixes { + if strings.HasPrefix(p, prefix) { + out = append(out, p) + break + } + } + } + sort.Strings(out) + return out +} + +// eventsSaid is the events a principal may publish, under a module's name or on a module's stream. +func eventsSaid(p Permissions) []string { + var out []string + for _, s := range withPrefix(p.Publish, "mesh.mod.", "mesh.stream.") { + if strings.Contains(s, ".event.") { + out = append(out, s) + } + } + return out +} + +// toolsCalled is the tools of one module a principal may call, by name. +func toolsCalled(p Permissions, module string) []string { + var out []string + for _, s := range withPrefix(p.Publish, "mesh.mod."+module+".tool.") { + if name := strings.TrimPrefix(s, "mesh.mod."+module+".tool."); !strings.Contains(name, ".") { + out = append(out, name) + } + } + return out +} + +// partsRecords is the three parts on one machine, beside the machine's tool runner. +func partsRecords(t *testing.T) Records { + t.Helper() + streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}} + return Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: RuntimeModule}, + declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams), declaredOf(t, tellerManifest, streams), + }}} +} + +// 1243/AC1: each part's credential, composed from its manifest, may do exactly what its declaration names: it writes +// its own buckets and stream, reads the buckets it reads (and cannot write them), hears what it consumes, calls what it +// invokes, and says what the seat it uses lets it say under its own name. +func TestEachPartsCredentialGrantsExactlyItsDeclaration(t *testing.T) { + all := composed(t, partsRecords(t)) + bridge, indexer, teller := all["mesh-delivery-forge-bridge@novox"], all["mesh-delivery-indexer@novox"], all["mesh-delivery-teller@novox"] + + // The forge bridge: state heads, the stream FORGE, reads merges and owed, invokes seven tools of the forge. + if got, want := withPrefix(bridge.Publish, "$KV."), []string{"$KV.mesh-delivery-forge-bridge_heads.>"}; !slices.Equal(got, want) { + t.Errorf("the bridge writes %v; want exactly %v", got, want) + } + if got, want := eventsSaid(bridge), []string{"mesh.stream.mesh-delivery-forge-bridge.event.forge.>"}; !slices.Equal(got, want) { + t.Errorf("the bridge says %v; want exactly %v", got, want) + } + for _, b := range []string{"merges", "owed"} { + if !canRead(bridge, "mesh-delivery-gate_"+b, "g.1") || canWrite(bridge, "mesh-delivery-gate_"+b, "g.1") { + t.Errorf("the bridge must read and not write the gate's %s", b) + } + } + for _, event := range []string{"pull.updated", "pull.merged", "pull.closed"} { + if !allowed(bridge.Subscribe, "mesh.mod.gitea.event."+event) { + t.Errorf("the bridge does not hear gitea's %s", event) + } + } + if allowed(bridge.Subscribe, "mesh.mod.gitea.event.pull.opened") { + t.Error("the bridge hears an event of gitea's it does not consume") + } + called := []string{"gitea_add_comment", "gitea_commit_status", "gitea_commit_trees", "gitea_delivery_view", + "gitea_merge_pull_request", "gitea_note_append", "gitea_open_pulls"} + if got := toolsCalled(bridge, "gitea"); !slices.Equal(got, called) { + t.Errorf("the bridge calls %v; want exactly %v", got, called) + } + if allowed(bridge.Publish, "mesh.mod.gitea.tool.gitea_delete_repo") { + t.Error("the bridge may call a tool of gitea's it does not invoke") + } + + // The indexer: state candidates and modules, reads the controller's builds and the gate's source, hears FORGE. + if got, want := withPrefix(indexer.Publish, "$KV."), []string{"$KV.mesh-delivery-indexer_candidates.>", "$KV.mesh-delivery-indexer_modules.>"}; !slices.Equal(got, want) { + t.Errorf("the indexer writes %v; want exactly %v", got, want) + } + if got := eventsSaid(indexer); len(got) != 0 { + t.Errorf("the indexer says %v; it declares no event", got) + } + for _, read := range [][2]string{{"mesh-controller_builds", "b.1"}, {"mesh-delivery-gate_source", "main"}} { + if !canRead(indexer, read[0], read[1]) || canWrite(indexer, read[0], read[1]) { + t.Errorf("the indexer must read and not write %s", read[0]) + } + } + if !allowed(indexer.Subscribe, "mesh.mod.mesh-delivery-forge-bridge.event.forge.merged") || + !allowed(indexer.Publish, "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-indexer") { + t.Errorf("the indexer does not hear FORGE through its own consumer: %v / %v", indexer.Subscribe, indexer.Publish) + } + if got := withPrefix(indexer.Publish, "mesh.seat."); len(got) != 0 { + t.Errorf("the indexer reaches the seats %v; it uses none", got) + } + + // The teller: state answers, reads the gate's asks, uses operator-channel under its own name. + if got, want := withPrefix(teller.Publish, "$KV."), []string{"$KV.mesh-delivery-teller_answers.>"}; !slices.Equal(got, want) { + t.Errorf("the teller writes %v; want exactly %v", got, want) + } + if !canRead(teller, "mesh-delivery-gate_asks", "merge.g.h") || canWrite(teller, "mesh-delivery-gate_asks", "merge.g.h") { + t.Error("the teller must read and not write the gate's asks") + } + for _, verb := range []string{"ask", "cancel", "condition"} { + if !allowed(teller.Publish, "mesh.seat.operator-channel.accept."+verb+".mesh-delivery-teller") { + t.Errorf("the teller may not say %s as itself", verb) + } + } + if !allowed(teller.Subscribe, "mesh.seat.operator-channel.event.decided.mesh-delivery-teller") { + t.Errorf("the teller does not hear its own decided events: %v", teller.Subscribe) + } +} + +// 1243/AC2: each part's credential is refused writing another module's bucket, publishing under another module's name +// and answering a verb subject of a seat. +func TestNoPartsCredentialReachesBeyondItsDeclaration(t *testing.T) { + all := composed(t, partsRecords(t)) + parts := []string{"mesh-delivery-forge-bridge", "mesh-delivery-indexer", "mesh-delivery-teller"} + buckets := map[string][]string{ + "mesh-delivery-forge-bridge": {"heads"}, "mesh-delivery-indexer": {"candidates", "modules"}, + "mesh-delivery-teller": {"answers"}, "mesh-delivery-gate": {"merges", "owed", "source", "asks", "deliveries"}, + "mesh-controller": {"builds", "plans"}, + } + for _, part := range parts { + p := all[part+"@novox"] + for owner, names := range buckets { + if owner == part { + continue + } + for _, name := range names { + if canWrite(p, owner+"_"+name, "some.key") { + t.Errorf("%s may write %s_%s", part, owner, name) + } + } + } + for _, other := range append(append([]string(nil), parts...), "mesh-delivery-gate", "mesh-controller", "gitea", "mesh-delivery") { + if other == part { + continue + } + for _, subject := range []string{"mesh.mod." + other + ".event.merged", "mesh.stream." + other + ".event.forge.merged"} { + if allowed(p.Publish, subject) { + t.Errorf("%s may publish %s, under %s's name", part, subject, other) + } + } + } + for _, verb := range []string{"mesh.seat.mesh-delivery.tool.deliveries", "mesh.seat.mesh-delivery.tool.release", + "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.operator-channel.tool.tell"} { + if allowed(p.Subscribe, verb) { + t.Errorf("%s answers the verb subject %s", part, verb) + } + } + } + // The teller says what it says as itself alone. + teller := all["mesh-delivery-teller@novox"] + for _, subject := range []string{"mesh.seat.operator-channel.accept.ask.mesh-delivery-gate", + "mesh.seat.operator-channel.accept.condition.mesh-delivery-indexer", "mesh.seat.operator-channel.accept.ask.*", + "mesh.seat.operator-channel.accept.ask.>"} { + if allowed(teller.Publish, subject) { + t.Errorf("the teller may publish %s", subject) + } + } +} -- 2.54.0 From 0aac89f4e22cb986ef1d2af10290ccf890db4331 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 21:48:07 +0000 Subject: [PATCH 4/4] Test that the gate's buckets are writable by its machine's tool runner account only (issue 1326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0437 §3's check: the gate, written in the test as the clause declares it, is composed with the parts and the view through Users; its seven buckets are writable by its machine's tool runner and by no other account, and each part reads only the gate buckets its manifest declares. --- internal/broker/delivery_gate_grant_test.go | 130 ++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 internal/broker/delivery_gate_grant_test.go diff --git a/internal/broker/delivery_gate_grant_test.go b/internal/broker/delivery_gate_grant_test.go new file mode 100644 index 00000000..0a1db088 --- /dev/null +++ b/internal/broker/delivery_gate_grant_test.go @@ -0,0 +1,130 @@ +package broker + +import "testing" + +// The delivery's gate, composed as novox/hq ADR 0437 §3 declares it and ADR 0450 §1 ships it in release 3's step R3d +// (issue 1326). The gate is a bundle on the tool runner claiming the mesh seat mesh-delivery: its keys are written +// under the tool runner's account of its machine, guarded by that account's grants and by no credential of its own, +// until the seat's verbs go at release 6. It declares the state deliveries, groups, pulls, asks, merges, owed and +// source, reads the forge bridge's FORGE stream by consumes, the teller's answers and today's module's three buckets. +// Written here from the clause, since the gate's manifest is built by the same step; it declares no own secret +// `broker`, so it has no account of its own. +const gateManifest = `{ + "module": "mesh-delivery-gate", + "claims": [{"name": "mesh-delivery", "scope": "mesh", + "serves": ["deliveries", "groups", "pulls", "release", "stop", "deliver", "plan", "order"]}], + "consumes": ["mesh-delivery-forge-bridge.forge.**"], + "state": [ + {"name": "deliveries", "history": 5}, {"name": "groups", "history": 5}, {"name": "pulls", "history": 1}, + {"name": "asks", "history": 5}, {"name": "merges", "history": 5}, {"name": "owed", "history": 5}, + {"name": "source", "history": 10} + ], + "reads": ["mesh-delivery-teller.answers", "mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls"] +}` + +// The view's credential as release 3's step R3d gives it (ADR 0437 §10): it reads the open asks to show them and is +// granted no write. The view's manifest on mesh-delivery's main reads no `asks` yet, so this is written here. +const viewReadingAsksManifest = `{ + "module": "mesh-delivery-view", + "reads": ["mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules"], + "own-secrets": {"broker": "${dir:state}/broker"} +}` + +// the gate's seven buckets, on the bus. +var gateBuckets = []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source"} + +// gateRecords is the gate, the three parts and the view on one machine, and a second machine with a tool runner alone. +func gateRecords(t *testing.T) Records { + t.Helper() + streams := map[string]ModuleStream{"mesh-delivery-forge-bridge": {Module: "mesh-delivery-forge-bridge", Name: "FORGE"}} + return Records{Nodes: []string{"novox", "ace"}, Assigned: map[string][]Declared{ + "novox": {{Module: RuntimeModule}, declaredOf(t, gateManifest, streams), + declaredOf(t, bridgeManifest, streams), declaredOf(t, indexerManifest, streams), + declaredOf(t, tellerManifest, streams), declaredOf(t, viewReadingAsksManifest, streams)}, + "ace": {{Module: RuntimeModule}}, + }} +} + +// 1326/AC1: the grants composed for the gate on one machine let that machine's tool runner account write each of its +// seven buckets, read the buckets it declares reading (the teller's answers without writing them), hear the bridge's +// FORGE stream and serve the seat's verbs. +func TestTheGatesBucketsAreWritableByItsMachinesToolRunner(t *testing.T) { + all := composed(t, gateRecords(t)) + runner, ok := all["runner@novox"] + if !ok { + t.Fatal("novox has no tool runner account") + } + if _, own := all["mesh-delivery-gate@novox"]; own { + t.Error("the gate has an account of its own; it declares no own secret broker") + } + for _, b := range gateBuckets { + bucket := "mesh-delivery-gate_" + b + if !canWrite(runner, bucket, "some.key") || !canRead(runner, bucket, "some.key") { + t.Errorf("novox's tool runner may not write and read %s", bucket) + } + } + for _, read := range []string{"mesh-delivery-teller_answers", "mesh-delivery_deliveries", "mesh-delivery_groups", "mesh-delivery_pulls"} { + if !canRead(runner, read, "k") { + t.Errorf("novox's tool runner may not read %s, which the gate declares reading", read) + } + // (The runner may write the teller's answers: the teller declares no runs-as, so the runner carries it too. See the report of issue 1326.) + if canWrite(runner, read, "k") && read != "mesh-delivery-teller_answers" { + t.Errorf("novox's tool runner may write %s, which the gate only reads", read) + } + } + if !allowed(runner.Publish, "$JS.API.CONSUMER.MSG.NEXT.FORGE.novox_mesh-delivery-gate") { + t.Errorf("the gate's FORGE consumer is not pulled by novox's tool runner") + } + if !allowed(runner.Subscribe, "mesh.seat.mesh-delivery.tool.deliveries") { + t.Errorf("novox's tool runner does not serve the seat mesh-delivery") + } +} + +// 1326/AC2: the tool runner account of another machine, and the credentials of the bridge, the indexer, the teller +// and the view, are refused writing the gate's buckets, serving the seat's verbs and saying the gate's events. +func TestNoOtherAccountWritesTheGatesBuckets(t *testing.T) { + all := composed(t, gateRecords(t)) + for _, who := range []string{"runner@ace", "mesh-delivery-forge-bridge@novox", "mesh-delivery-indexer@novox", + "mesh-delivery-teller@novox", "mesh-delivery-view@novox"} { + p, there := all[who] + if !there { + t.Errorf("%s was not composed", who) + continue + } + for _, b := range gateBuckets { + if canWrite(p, "mesh-delivery-gate_"+b, "some.key") { + t.Errorf("%s may write mesh-delivery-gate_%s", who, b) + } + } + if allowed(p.Subscribe, "mesh.seat.mesh-delivery.tool.deliveries") { + t.Errorf("%s serves the seat mesh-delivery's verbs", who) + } + if allowed(p.Publish, "mesh.mod.mesh-delivery-gate.event.merged") { + t.Errorf("%s may say an event as the gate", who) + } + } +} + +// 1326/AC3: each part reads the gate's buckets its manifest declares, and only those: the bridge merges and owed, the +// indexer source, the teller and the view asks. The tool runner of another machine reads none. +func TestEachPartReadsOnlyTheGateBucketsItDeclares(t *testing.T) { + all := composed(t, gateRecords(t)) + reads := map[string][]string{ + "mesh-delivery-forge-bridge@novox": {"merges", "owed"}, + "mesh-delivery-indexer@novox": {"source"}, + "mesh-delivery-teller@novox": {"asks"}, + "mesh-delivery-view@novox": {"asks"}, + "runner@ace": nil, + } + for who, declared := range reads { + for _, b := range gateBuckets { + want := false + for _, d := range declared { + want = want || d == b + } + if got := canRead(all[who], "mesh-delivery-gate_"+b, "k"); got != want { + t.Errorf("%s reads mesh-delivery-gate_%s: %v, declared %v", who, b, got, want) + } + } + } +} -- 2.54.0