From da7baa5e3909a096b0ae2b2b9e4e70a733e21cf2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:11:43 +0100 Subject: [PATCH 01/11] mesh-grants: take mesh-wireguard's manifest from mesh-sdk, where the mesh provides it, so its node-tools grants are composed (issue 1945) --- modules/mesh-grants/grants_test.go | 6 +++++ modules/mesh-grants/internal/grants/inputs.go | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 03acd28..6617cc1 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -424,7 +424,13 @@ func TestGrants(t *testing.T) { b.manifestOf("mod-p", pManifest) b.manifestOf("mod-z", zManifest) b.assign("b", "mod-p") + // mesh-wireguard is provided by the mesh, not read from a repository: no manifest in the indexer, none refused, + // no gap, and its manifest is the one mesh-sdk provides (issue 1945). + b.assign("b", "mesh-wireguard") in := grants.ReadInputs(b, &grants.Manifests{}) + if _, ok := in.Manifests["mesh-wireguard"]; !ok || gapNamed(in.Gaps, "manifest of mesh-wireguard") || !strings.HasPrefix(in.Digests["mesh-wireguard"], "provided:") { + t.Errorf("mesh-wireguard's manifest is not taken from mesh-sdk: gaps %v", in.Gaps) + } if len(in.Refused) != 1 || in.Refused[0].Input != "manifest of mod-z" || !gapNamed(in.Gaps, "manifest of mod-z") { t.Errorf("refused = %v: only mod-z's, and named in the gaps", in.Refused) } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index a281b81..3a46aab 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -8,6 +8,7 @@ package grants import ( + "crypto/sha256" "encoding/json" "fmt" "slices" @@ -18,6 +19,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/overlay" ) // The states of other parts the grants part reads, as its manifest's reads name them (`.`). @@ -289,6 +291,26 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Manifests[module] = m in.Digests[module] = v.Manifest } + // A module the mesh provides itself has no repository the indexer reads: its manifest comes with mesh-sdk, as the + // controller's composer takes it (issue 1945, the private network on every machine). + for _, module := range modules { + if _, ok := in.Manifests[module]; ok || module != overlay.Name { + continue + } + raw, err := json.Marshal(overlay.Manifest()) + var m catalogue.Manifest + if err == nil { + m, err = catalogue.ParseManifestForInputs(raw) + } + if err != nil { + g := Gap{Input: "manifest of " + module, Why: "the manifest mesh-sdk provides is refused by its own reader: " + err.Error()} + in.Refused = append(in.Refused, g) + in.Gaps = append(in.Gaps, g) + continue + } + in.Manifests[module] = m + in.Digests[module] = fmt.Sprintf("provided:sha256:%x", sha256.Sum256(raw)) + } for _, module := range modules { if _, ok := in.Manifests[module]; !ok && !gapNamed(in.Gaps, "manifest of "+module) { in.Gaps = append(in.Gaps, Gap{Input: "manifest of " + module, From 2a94d16834db6c5944b6409f6bd099f0ce88540c Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:10:37 +0100 Subject: [PATCH 02/11] mesh-grants: ask the controller's root-free verb and wake on its condition events, so a verified sender is composed (issue 1945) The part composed every membership with no root-free input, so telegram's verified-sender was dropped and the membership held as a shadow difference. It now asks mesh-controller.root-free for the machines a composition needs, per pass, failing closed and said once, and wakes on root-not-free condition-raised and condition-cleared. --- modules/mesh-grants/cmd/mesh-grants/main.go | 9 ++ modules/mesh-grants/grants_test.go | 101 +++++++++++++++ modules/mesh-grants/internal/grants/follow.go | 41 +++++- modules/mesh-grants/internal/grants/inputs.go | 2 +- .../internal/grants/memberships.go | 2 +- modules/mesh-grants/internal/grants/part.go | 6 +- .../mesh-grants/internal/grants/rootfree.go | 122 ++++++++++++++++++ modules/mesh-grants/internal/grants/tools.go | 3 +- modules/mesh-grants/module.json | 7 +- 9 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/rootfree.go diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index e5466c5..093e67c 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -125,6 +125,15 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return nil }) } + f.Hear = func(handle func(string, []byte)) error { + for _, event := range grants.Consumes { + event := event + if _, err := nc.Subscribe(grants.EventSubject(event), func(m *nats.Msg) { handle(event, m.Data) }); err != nil { + return fmt.Errorf("hearing %s: %w", event, err) + } + } + return nil + } logger.Printf("started on the account %s: publishing memberships and creating the declared buckets, logs and streams the bus lacks; the user list, grants and any change to a held stream in shadow", account.User) f.Run(ctx) return nil diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 6617cc1..5496f93 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -16,6 +16,7 @@ import ( "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -48,6 +49,8 @@ type fakeBus struct { // creates are the streams the part asked to create, refused when refuseCreate. creates []string refuseCreate bool + // rootFree answers the controller's root-free verb: nil is no answerer at all (no responders). + rootFree func(machines []string) (any, error) } func (b *fakeBus) LastMembership(node, module string) ([]byte, uint64, bool, error) { @@ -166,6 +169,15 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) { return json.Marshal(map[string]any{"users": out}) case grants.CallStreams: return json.Marshal(map[string]any{"detail": b.streams}) + case grants.CallRootFree: + if b.rootFree == nil { + return nil, errors.New("nats: no responders available for request") + } + out, err := b.rootFree(args["machines"].([]string)) + if err != nil { + return nil, err + } + return json.Marshal(out) } return nil, fmt.Errorf("no tool %s", key) } @@ -300,6 +312,7 @@ func TestGrants(t *testing.T) { }{ {"reads", m.Reads, grants.Reads}, {"invokes", m.Invokes, grants.Invokes}, + {"consumes", m.Consumes, grants.Consumes}, {"emits", m.Emits, grants.Emits}, {"tools", m.Tools, grants.Tools}, } { @@ -414,6 +427,94 @@ func TestGrants(t *testing.T) { } }) + t.Run("root-free: the controller's word fills the composition, failing closed (issue 1945)", func(t *testing.T) { + const router = `{"module":"messenger","version":"1","runs-as":"messenger","resources":[{"id":"account","type":"user","name":"messenger","shell":"/usr/bin/nologin","home":"/var/lib/messenger"}],"own-secrets":{"broker":"/var/lib/m/broker"},"secrets-owner":"messenger",` + + `"seats":[{"name":"operator-channel","scope":"mesh","serves":["open"]},{"name":"channel","scope":"mesh","kinded":true,"accepts":["show"]}],` + + `"claims":[{"name":"operator-channel","scope":"mesh","serves":["open"]}]}` + const telegram = `{"module":"telegram","version":"1","runs-as":"telegram","resources":[{"id":"account","type":"user","name":"telegram","shell":"/usr/bin/nologin","home":"/var/lib/telegram"}],"own-secrets":{"broker":"/var/lib/t/broker"},"secrets-owner":"telegram",` + + `"claims":[{"name":"channel","scope":"mesh","kind":"telegram","capabilities":["deliver","verified-sender"]}]}` + answer := func(free ...string) func([]string) (any, error) { + return func(machines []string) (any, error) { + var vs []map[string]any + for _, m := range machines { + ok := slices.Contains(free, m) + why := "judged free" + if !ok { + why = "its node-engine judged nothing" + } + vs = append(vs, map[string]any{"machine": m, "free": ok, "why": why}) + } + return map[string]any{"machines": vs}, nil + } + } + for _, row := range []struct { + name string + routerOn []string + telegramOn string + ask func([]string) (any, error) + keeps bool + note string + }{ + {"router and telegram on one free machine", []string{"a"}, "a", answer("a"), true, ""}, + {"router and telegram on two free machines", []string{"a"}, "b", answer("a", "b"), true, ""}, + {"the holder's machine is not free", []string{"a"}, "b", answer("a"), false, "b: not free, its node-engine judged nothing"}, + {"the router's machine is not free", []string{"a"}, "b", answer("b"), false, "a: not free"}, + {"an error is not free", []string{"a"}, "a", func([]string) (any, error) { return nil, errors.New("boom") }, false, "boom"}, + {"no answerer is not free", []string{"a"}, "a", nil, false, "no responders"}, + {"a machine left out of the answer is not free", []string{"a"}, "a", + func([]string) (any, error) { return map[string]any{"machines": []any{}}, nil }, false, "named no verdict"}, + {"the router on two machines frees nothing", []string{"a", "b"}, "a", answer("a", "b"), false, ""}, + } { + t.Run(row.name, func(t *testing.T) { + b := fixture() + b.manifestOf("messenger", router) + b.manifestOf("telegram", telegram) + for _, node := range row.routerOn { + b.assign(node, "messenger") + } + b.assign(row.telegramOn, "telegram") + b.rootFree = row.ask + res := partOn(b).Compose() + recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{})) + grants.WithRootFree(&recs, b) + kept := false + for _, k := range buscompose.PlacementsOf(recs, recs.Interchangeable).Kinds { + kept = kept || slices.Contains(k.Capabilities, "verified-sender") + } + if kept != row.keeps { + t.Errorf("verified-sender kept = %v, want %v (said %v)", kept, row.keeps, res.RootNotFree) + } + if !strings.Contains(strings.Join(res.RootNotFree, " | "), row.note) || (row.note == "" && row.keeps && len(res.RootNotFree) != 0) { + t.Errorf("the pass says %v, want it to name %q", res.RootNotFree, row.note) + } + }) + } + // A root-not-free condition raised or cleared wakes a pass; another kind does not (never a timer). + wb := fixture() + var handle func(string, []byte) + wf := &grants.Follower{Part: partOn(wb), Watch: &fakeWatcher{opened: make(chan string, 16)}, Logf: func(string, ...any) {}, + Hear: func(h func(string, []byte)) error { handle = h; return nil }} + wctx, wcancel := context.WithCancel(context.Background()) + wdone := make(chan struct{}) + go func() { wf.Run(wctx); close(wdone) }() + waitFor(t, func() bool { return wb.count(grants.CallUsers) > 0 && handle != nil }) + passes := func() int { return wb.count(grants.CallUsers) } + before := passes() + handle(grants.EventConditionRaised, []byte(`{"kind":"disk-full","key":"machine.a.disk-full"}`)) + time.Sleep(100 * time.Millisecond) + if passes() != before { + t.Errorf("a condition of another kind woke a pass") + } + handle(grants.EventConditionCleared, []byte(`{"kind":"root-not-free","key":"machine.a.root-not-free"}`)) + waitFor(t, func() bool { return passes() > before }) + wcancel() + <-wdone + if !grants.IsRootNotFree([]byte(`{"kind":"root-not-free"}`)) || !grants.IsRootNotFree([]byte(`{"key":"machine.novox.root-not-free"}`)) || + grants.IsRootNotFree([]byte(`{"kind":"disk-full","key":"machine.novox.disk-full"}`)) { + t.Errorf("only a root-not-free condition wakes a pass") + } + }) + t.Run("a predefined-key fallback is read with no list; every refused manifest listed", func(t *testing.T) { // mod-p's value falls back to a predefined key, as claude-code's managed_settings does, and no list is on the bus // (issue 1945); mod-z's manifest carries a key mesh-sdk's reader refuses, and mod-z is assigned nowhere. diff --git a/modules/mesh-grants/internal/grants/follow.go b/modules/mesh-grants/internal/grants/follow.go index 2fb7824..938a243 100644 --- a/modules/mesh-grants/internal/grants/follow.go +++ b/modules/mesh-grants/internal/grants/follow.go @@ -28,14 +28,20 @@ type Follower struct { Logf func(string, ...any) // Asked watches the part's own ask to compose now (a tool's compose-now), waking at each; nil watches none. Asked func(wake func()) error + // Hear hears the controller seat's condition events (Consumes) and hands each, by event name, with its body, to + // handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass: a machine's + // root-free word changed. + Hear func(handle func(event string, body []byte)) error - wake chan struct{} - once sync.Once - mu sync.Mutex - asked bool - watched map[string]bool - refused map[string]string - have map[string]string + wake chan struct{} + once sync.Once + mu sync.Mutex + asked bool + heard_ bool + rootSaid string + watched map[string]bool + refused map[string]string + have map[string]string // held and notAsserted are what the last pass held back and could not create, said again only when they change. held string notAsserted string @@ -116,6 +122,18 @@ func (f *Follower) open() { f.asked = true } } + if f.Hear != nil && !f.heard_ { + err := f.Hear(func(event string, body []byte) { + if IsRootNotFree(body) { + f.Wake() + } + }) + if err != nil { + f.logf("the controller's condition events cannot be heard yet (%v): tried again at the next change", err) + } else { + f.heard_ = true + } + } for _, state := range Followed { f.mu.Lock() open := f.watched[state] @@ -175,6 +193,15 @@ func (f *Follower) said(res Result) { } } } + if said := strings.Join(res.RootNotFree, "\n"); said != f.rootSaid { + f.rootSaid = said + if len(res.RootNotFree) == 0 { + f.logf("every machine a composition needs a root-free word for is free") + } + for _, n := range res.RootNotFree { + f.logf("root-free: %s (a verified sender is composed as from no root-free machine there)", n) + } + } for _, a := range res.Asserted { f.logf("asserted %s", a) } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 3a46aab..49b747a 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -48,7 +48,7 @@ const ( ) // Invokes is every tool the part calls. -var Invokes = []string{CallUsers, CallUserCan, CallStreams} +var Invokes = []string{CallUsers, CallUserCan, CallStreams, CallRootFree} // Reader reads another part's state, named `.`. A refusal (a read not granted yet, a bucket not there) is // an error the part names as a gap and composes past: it never stops for one. diff --git a/modules/mesh-grants/internal/grants/memberships.go b/modules/mesh-grants/internal/grants/memberships.go index d617ff8..e53b452 100644 --- a/modules/mesh-grants/internal/grants/memberships.go +++ b/modules/mesh-grants/internal/grants/memberships.go @@ -22,7 +22,7 @@ import ( // MembershipGaps are what a membership carries that the part cannot compose from the bus. var MembershipGaps = []Gap{ {Input: "membership: private network and received contributions", Why: "kept in each node's composition, which only that node reads: carried over from the membership last published, none for a first one"}, - {Input: "membership: root-free machines", Why: "judged by the controller on the bus at its composition, not kept: a verified sender is composed as from no root-free machine"}, + {Input: "membership: root-free machines", Why: "asked of the controller's root-free verb at each pass and kept for that pass alone; a machine it does not call free (an error included) is composed as not free, and the pass says why"}, } // Publisher publishes a membership on the assignments stream (membership.Subject), last per subject, and reads the one diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 21533b9..aa5f6f3 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -100,7 +100,10 @@ type Result struct { NotAsserted []string `json:"not_asserted,omitempty"` // Refused is every manifest the indexer holds that this pass could not read, `manifest of : `: its // module counts as placed nowhere (issue 1945). - Refused []string `json:"refused,omitempty"` + Refused []string `json:"refused,omitempty"` + // RootNotFree is each machine the composition needed a root-free word for and did not get one, `: not + // free, `: a verified sender is composed as from no root-free machine there (issue 1945). + RootNotFree []string `json:"root_not_free,omitempty"` Errors []string `json:"errors,omitempty"` PublishedOrAsserted string `json:"published_or_asserted"` } @@ -172,6 +175,7 @@ func (p *Part) Compose() Result { res.Refused = append(res.Refused, g.Input+": "+g.Why) } recs, left := Records(in) + res.RootNotFree = WithRootFree(&recs, p.Call) st := UserListState{V: 1, Mode: Shadow, LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode, Gaps: append(append([]Gap(nil), in.Gaps...), MembershipGaps...), At: p.now()} if st.LeftOut == nil { diff --git a/modules/mesh-grants/internal/grants/rootfree.go b/modules/mesh-grants/internal/grants/rootfree.go new file mode 100644 index 0000000..b4e1223 --- /dev/null +++ b/modules/mesh-grants/internal/grants/rootfree.go @@ -0,0 +1,122 @@ +package grants + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/buscompose" +) + +// CallRootFree is the controller seat's verb that judges machines root-free (novox/hq ADR 0259 ยง8): the one judgement +// the composer needs and the part cannot make from the bus (issue 1945). Only the controller answers it, on purpose. +const CallRootFree = "seat:mesh-controller.root-free" + +// The controller seat's condition events the part hears (never a timer): a machine's root-free word changes with the +// raising and the clearing of its `root-not-free` condition. +const ( + EventConditionRaised = "mesh-controller.condition-raised" + EventConditionCleared = "mesh-controller.condition-cleared" + // KindRootNotFree is the condition kind whose events wake a pass. + KindRootNotFree = "root-not-free" +) + +// Consumes is every event the part hears, as its manifest's consumes name them. +var Consumes = []string{EventConditionRaised, EventConditionCleared} + +// EventSubject is the bus subject of a controller seat event named `mesh-controller.`. +func EventSubject(event string) string { + return "mesh.seat.mesh-controller.event." + strings.TrimPrefix(event, "mesh-controller.") +} + +// IsRootNotFree says a condition event's body names the `root-not-free` kind (its `kind`, or the last part of its +// `key`, `..`). An unreadable body wakes a pass too: a pass is cheap, a missed change is not. +func IsRootNotFree(body []byte) bool { + var c struct { + Kind string `json:"kind"` + Key string `json:"key"` + } + if json.Unmarshal(body, &c) != nil { + return true + } + return c.Kind == KindRootNotFree || (c.Kind == "" && strings.HasSuffix(c.Key, "."+KindRootNotFree)) || (c.Kind == "" && c.Key == "") +} + +// RootFreeNodes are the machines a composition needs a root-free word for: where the router's seat is held and where a +// kinded seat's holder declares `verified-sender`. None when no holder declares it. Sorted. Pure. +func RootFreeNodes(r buscompose.Records) []string { + var nodes []string + need := false + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Name == "operator-channel" && !slices.Contains(nodes, node) { + nodes = append(nodes, node) + } + if s.Kinded && s.Kind != "" && slices.Contains(s.Capabilities, "verified-sender") { + need = true + if !slices.Contains(nodes, node) { + nodes = append(nodes, node) + } + } + } + } + } + if !need { + return nil + } + sort.Strings(nodes) + return nodes +} + +// verdict is one machine's answer of the controller's root-free verb. +type verdict struct { + Machine string `json:"machine"` + Free bool `json:"free"` + Why string `json:"why"` +} + +// WithRootFree fills r.RootFree for the machines the composition needs by asking the controller once, and answers a +// line for each machine that is not free, `: `. It fails closed: an error, no answer or a machine +// left out is not free, and says so. Pure but for the one call; the answer is kept for this pass alone. +func WithRootFree(r *buscompose.Records, call Caller) []string { + nodes := RootFreeNodes(*r) + if len(nodes) == 0 { + return nil + } + free := map[string]verdict{} + var failure string + if call == nil { + failure = "no way to ask the controller here" + } else if raw, err := call.Call(CallRootFree, map[string]any{"machines": nodes}); err != nil { + failure = "the controller's root-free word could not be asked: " + err.Error() + } else { + var body struct { + Machines []verdict `json:"machines"` + } + if err := json.Unmarshal(raw, &body); err != nil { + failure = "the controller's root-free answer could not be read: " + err.Error() + } + for _, v := range body.Machines { + free[v.Machine] = v + } + } + r.RootFree = map[string]bool{} + var notes []string + for _, n := range nodes { + v, answered := free[n] + switch { + case answered && v.Free: + r.RootFree[n] = true + case failure != "": + notes = append(notes, fmt.Sprintf("%s: not free, %s", n, failure)) + case !answered: + notes = append(notes, fmt.Sprintf("%s: not free, the controller named no verdict for it", n)) + default: + notes = append(notes, fmt.Sprintf("%s: not free, %s", n, v.Why)) + } + } + return notes +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index bbc7f2a..8ddd43f 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -70,8 +70,9 @@ func (p *Part) MembershipOf(node, module string) (any, error) { } in := ReadInputs(p.Read, &p.cache) recs, left := Records(in) + notFree := WithRootFree(&recs, p.Call) m, ok := Memberships(recs)[node+"."+module] - out := map[string]any{"node": node, "module": module, "gaps": MembershipGaps} + out := map[string]any{"node": node, "module": module, "gaps": MembershipGaps, "root_free": recs.RootFree, "root_not_free": notFree} if ok { out["composed"] = m } else { diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 97cbb27..eca256f 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -13,10 +13,15 @@ "node-engine.have", "mesh-vault.bus-accounts" ], + "consumes": [ + "mesh-controller.condition-raised", + "mesh-controller.condition-cleared" + ], "invokes": [ "nats.nats_streams", "nats.nats_user_can", - "nats.nats_users" + "nats.nats_users", + "seat:mesh-controller.root-free" ], "emits": [ "asserted", From ce72b67cef7dbdd1e894c829d4fac5d3b77c6566 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:20:42 +0100 Subject: [PATCH 03/11] mesh-grants: hear the condition events through the controller's durable consumer and acknowledge them; the provided mesh-wireguard manifest always wins (issue 1945) --- modules/mesh-grants/cmd/mesh-grants/main.go | 17 ++++++++----- modules/mesh-grants/grants_test.go | 7 ++++++ modules/mesh-grants/internal/grants/inputs.go | 25 +++++++++++-------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index 093e67c..e232745 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -125,14 +125,19 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return nil }) } + // The controller makes this module's durable consumer on EVENTS from the manifest's consumes (filtered on the two + // condition subjects); it is pulled and every message acknowledged, so its pending never grows. Where it is not there + // yet the pass says so and hears again at the next change. f.Hear = func(handle func(string, []byte)) error { - for _, event := range grants.Consumes { - event := event - if _, err := nc.Subscribe(grants.EventSubject(event), func(m *nats.Msg) { handle(event, m.Data) }); err != nil { - return fmt.Errorf("hearing %s: %w", event, err) - } + cons, err := js.Consumer(ctx, "EVENTS", account.Node+"_"+account.Module) + if err != nil { + return fmt.Errorf("the consumer %s_%s on EVENTS: %w", account.Node, account.Module, err) } - return nil + _, err = cons.Consume(func(m jetstream.Msg) { + handle(m.Subject(), m.Data()) + _ = m.Ack() + }, jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { logger.Printf("hearing the bus: %v", err) })) + return err } logger.Printf("started on the account %s: publishing memberships and creating the declared buckets, logs and streams the bus lacks; the user list, grants and any change to a held stream in shadow", account.User) f.Run(ctx) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 5496f93..377a2ed 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -529,6 +529,13 @@ func TestGrants(t *testing.T) { // no gap, and its manifest is the one mesh-sdk provides (issue 1945). b.assign("b", "mesh-wireguard") in := grants.ReadInputs(b, &grants.Manifests{}) + // An indexer row of the same name never wins: the provided manifest is used and the row is said as a gap. + bw := fixture() + bw.manifestOf("mesh-wireguard", `{"module":"mesh-wireguard","version":"9","own-secrets":{"broker":"/x"}}`) + bw.assign("b", "mesh-wireguard") + if iw := grants.ReadInputs(bw, &grants.Manifests{}); !strings.HasPrefix(iw.Digests["mesh-wireguard"], "provided:") || !gapNamed(iw.Gaps, "manifest of mesh-wireguard") || len(iw.Refused) != 0 { + t.Errorf("an indexer manifest of mesh-wireguard won or went unsaid: %v %v", iw.Digests["mesh-wireguard"], iw.Gaps) + } if _, ok := in.Manifests["mesh-wireguard"]; !ok || gapNamed(in.Gaps, "manifest of mesh-wireguard") || !strings.HasPrefix(in.Digests["mesh-wireguard"], "provided:") { t.Errorf("mesh-wireguard's manifest is not taken from mesh-sdk: gaps %v", in.Gaps) } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 49b747a..1d3a685 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -291,25 +291,30 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.Manifests[module] = m in.Digests[module] = v.Manifest } - // A module the mesh provides itself has no repository the indexer reads: its manifest comes with mesh-sdk, as the - // controller's composer takes it (issue 1945, the private network on every machine). - for _, module := range modules { - if _, ok := in.Manifests[module]; ok || module != overlay.Name { - continue - } + // A module the mesh provides itself has no repository the indexer reads: its manifest comes with mesh-sdk, and the + // controller's Provide overwrites any row of it at every start, so the provided one always wins here too (issue 1945, + // the private network on every machine). An indexer manifest of the same name is said as a gap, never used. + if slices.Contains(modules, overlay.Name) { raw, err := json.Marshal(overlay.Manifest()) var m catalogue.Manifest if err == nil { m, err = catalogue.ParseManifestForInputs(raw) } + in.Gaps = slices.DeleteFunc(in.Gaps, func(g Gap) bool { return g.Input == "manifest of "+overlay.Name }) + in.Refused = slices.DeleteFunc(in.Refused, func(g Gap) bool { return g.Input == "manifest of "+overlay.Name }) + if _, held := in.Digests[overlay.Name]; held { + in.Gaps = append(in.Gaps, Gap{Input: "manifest of " + overlay.Name, Why: "the indexer also holds one, which is not used: the mesh provides this module's manifest itself, and the controller overwrites any row of it"}) + } if err != nil { - g := Gap{Input: "manifest of " + module, Why: "the manifest mesh-sdk provides is refused by its own reader: " + err.Error()} + g := Gap{Input: "manifest of " + overlay.Name, Why: "the manifest mesh-sdk provides is refused by its own reader: " + err.Error()} in.Refused = append(in.Refused, g) in.Gaps = append(in.Gaps, g) - continue + delete(in.Manifests, overlay.Name) + delete(in.Digests, overlay.Name) + } else { + in.Manifests[overlay.Name] = m + in.Digests[overlay.Name] = fmt.Sprintf("provided:sha256:%x", sha256.Sum256(raw)) } - in.Manifests[module] = m - in.Digests[module] = fmt.Sprintf("provided:sha256:%x", sha256.Sum256(raw)) } for _, module := range modules { if _, ok := in.Manifests[module]; !ok && !gapNamed(in.Gaps, "manifest of "+module) { From 75d0eb32be413fed684dfb535746a5590e1eb5f8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:26:37 +0100 Subject: [PATCH 04/11] mesh-grants: take-over and hand-back are asked of the operator on their channel and run on their yes after the refusals; take-over no longer always refused; grants_take_over_state (issue 1945) --- modules/mesh-grants/cmd/mesh-grants/asking.go | 80 +++++ modules/mesh-grants/cmd/mesh-grants/main.go | 11 +- modules/mesh-grants/cmd/mesh-grants/tools.go | 21 +- modules/mesh-grants/grants_test.go | 153 +++++--- modules/mesh-grants/internal/grants/inputs.go | 2 +- modules/mesh-grants/internal/grants/part.go | 115 +----- .../mesh-grants/internal/grants/takeover.go | 327 ++++++++++++++++++ modules/mesh-grants/internal/grants/tools.go | 17 +- modules/mesh-grants/module.json | 4 + 9 files changed, 569 insertions(+), 161 deletions(-) create mode 100644 modules/mesh-grants/cmd/mesh-grants/asking.go create mode 100644 modules/mesh-grants/internal/grants/takeover.go diff --git a/modules/mesh-grants/cmd/mesh-grants/asking.go b/modules/mesh-grants/cmd/mesh-grants/asking.go new file mode 100644 index 0000000..15db062 --- /dev/null +++ b/modules/mesh-grants/cmd/mesh-grants/asking.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// The three bus adapters for mesh-sdk go/asker on the part's own credential, as mesh-delivery-teller has them (copied: the +// SDK's asker reaches the bus through a bundle's runtime; moving the adapters into the SDK is a change of its own). What +// the account may do is the controller's composed grant for a module that uses the seat operator-channel: publish its own +// ask and condition, hear its own decided event, and read the router's record of its own asks. + +const recordWithin = 30 * time.Second + +func publishVia(ctx context.Context, js jetstream.JetStream) func(subject string, body any, id string) error { + return func(subject string, body any, id string) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + var opts []jetstream.PublishOpt + if id != "" { + opts = append(opts, jetstream.WithMsgID(id)) + } + _, err = js.Publish(ctx, subject, data, opts...) + return err + } +} + +func recordVia(ctx context.Context, conn *nats.Conn, module string) func(id string) (*stdio.StateEntry, error) { + return func(id string) (*stdio.StateEntry, error) { + ctx, cancel := context.WithTimeout(ctx, recordWithin) + defer cancel() + msg, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_asks.$KV.asks."+module+"."+id, nil) + if errors.Is(err, nats.ErrNoResponders) { + return nil, nil + } + if err != nil { + return nil, err + } + if status := msg.Header.Get("Status"); status == "404" || len(msg.Data) == 0 { + return nil, nil + } + return &stdio.StateEntry{Key: id, Value: msg.Data}, nil + } +} + +func decidedVia(conn *nats.Conn, module string, logf func(string, ...any)) func(pattern string, handle func(stdio.Envelope) error) error { + return func(pattern string, handle func(stdio.Envelope) error) error { + _, err := conn.Subscribe(asks.DecidedSubject(module), func(m *nats.Msg) { + if err := handle(stdio.Envelope{Key: pattern, Body: m.Data}); err != nil { + logf("a warrant could not be handled and is read again from the router's record at the next start: %v", err) + } + }) + return err + } +} + +func sayVia(ctx context.Context, js jetstream.JetStream, module string) func(c asks.Condition) error { + return func(c asks.Condition) error { + subject, err := asks.ConditionSubject(module) + if err != nil { + return err + } + data, err := asks.EncodeCondition(c) + if err != nil { + return err + } + _, err = js.Publish(ctx, subject, data) + return err + } +} diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index e232745..7a9741f 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -9,7 +9,7 @@ // created** (the operator, 2026-10-26 08:54; issue 1790), once per manifest, with the manifest's limits, on the event // that brought the manifest; one the bus holds is never changed. **The user list, the rest of the assertions and the // grants stay in shadow** until -// the operator's take-over, which this milestone always refuses: they are composed, compared with what the bus loaded +// the operator's take-over, asked on their channel and run on their yes after its refusals: they are composed, compared with what the bus loaded // and holds (through the bus module's own read tools) and kept in the part's own state, and take no effect. // // It holds no timer, and never exits for a refused read, write or publish: a refusal is said and tried again at the next @@ -39,6 +39,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/process" "mesh-grants/internal/grants" @@ -117,6 +118,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn}, Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}} f.Watch = bus + // The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on + // the warrant once (mesh-sdk asker), after its refusals. + client := &asker.Client{Module: account.Module, State: conn.State(grants.StateTakeover), Publish: publishVia(ctx, js), + Record: recordVia(ctx, nc, account.Module), Subscribe: decidedVia(nc, account.Module, logger.Printf)} + f.Part.Asker, f.Part.Say = client, sayVia(ctx, js, account.Module) + if err := client.Listen(f.Part.Warranted, f.Part.Said); err != nil { + logger.Printf("the operator's answers cannot be heard yet, so take-over and hand-back wait: %v", err) + } f.Asked = func(wake func()) error { return conn.State(grants.StateUserList).Watch(grants.KeyAsked, func(c stdio.StateChange) error { if !c.Current { diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index b4eac35..cfd34bf 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -97,20 +97,25 @@ func toolsOf(p *grants.Part) []stdio.Tool { "changed, a difference is only said. Answers the comparison and the ask; call again to see what the bus holds.", Input: schema(map[string]string{"module": "the module"}, "module"), Run: func(a map[string]any) (any, error) { return p.AssertOne(grants.Answer(a, "module")) }}, - {Name: grants.ToolTakeOver, Description: "The operator's switch to the grants part's list (ADR 0547 clause 8), refused without the " + - "operator's credential (a person's account), refused while any user of the shadow list differs from the loaded list (but " + - "those placement no longer names), naming them; in this milestone always refused, since the enrolment users and the " + - "persons cannot be composed. Every ask is recorded in the part's state takeover.", + {Name: grants.ToolTakeOver, Description: "Ask the operator to switch the bus node to the grants part's list (ADR 0547 clause 8). " + + "The operator's word is their yes on their channel (a warrant), never a caller's name: this tool only asks, once at a time. " + + "After the yes, the switch is refused while the loaded list could not be read, a user differs between the lists " + + "(an enrolment user or a person the part cannot compose among them), or the vault holds no hash for a user, and the " + + "refusal is said to the operator naming the users. Every act is recorded in the part's state takeover.", Input: schema(map[string]string{"why": "why, kept with the act"}, "why"), Run: func(a map[string]any) (any, error) { - return nil, p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why")) + return p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why")) }}, - {Name: grants.ToolHandBack, Description: "The operator's switch back to the controller's list (ADR 0547 clause 8), refused without " + - "the operator's credential; in shadow there is nothing to hand back, and it says so. Every ask is recorded.", + {Name: grants.ToolHandBack, Description: "Ask the operator to switch the bus node back to the controller's list (ADR 0547 clause 8), " + + "by their yes on their channel; in shadow there is nothing to hand back, and it says so. Every act is recorded.", Input: schema(map[string]string{"why": "why, kept with the act"}, "why"), Run: func(a map[string]any) (any, error) { - return nil, p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why")) + return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why")) }}, + {Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " + + "every act with what became of it (asked, switched, declined, expired, refused and why).", + Input: schema(nil), + Run: func(map[string]any) (any, error) { return p.TakeOverState() }}, } } diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 377a2ed..0f0fa18 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -16,6 +16,8 @@ import ( "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -340,6 +342,8 @@ func TestGrants(t *testing.T) { // in create.go (issue 1790). publishOnly := filepath.Join("cmd", "mesh-grants", "publish.go") createOnly := filepath.Join("cmd", "mesh-grants", "create.go") + // And the operator's asks and conditions, on the seat operator-channel's subjects, in asking.go (ADR 0547 clause 8). + asksOnly := filepath.Join("cmd", "mesh-grants", "asking.go") files := 0 for _, root := range []string{"cmd", "internal"} { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { @@ -348,8 +352,11 @@ func TestGrants(t *testing.T) { } files++ src, err := os.ReadFile(path) - if path != publishOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) { - t.Errorf("%s publishes: only publish.go publishes, and only memberships", path) + if path != publishOnly && path != asksOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) { + t.Errorf("%s publishes: only publish.go publishes memberships, and asking.go the operator's asks", path) + } + if path == asksOnly && (strings.Count(string(src), ".Publish(") != 2 || strings.Contains(string(src), "mesh.assignment")) { + t.Errorf("asking.go publishes other than an ask, a cancel and a condition, through js.Publish twice") } if path == publishOnly && strings.Count(string(src), ".Publish(") != 1 { t.Errorf("publish.go publishes other than one membership") @@ -620,9 +627,9 @@ func TestGrants(t *testing.T) { !slices.Contains(cr.LeftOut, "b.mod-y") { t.Errorf("a hash heard = %+v", after.UserList.Credentials) } - err := p.TakeOver("person.operator", "hashes") - if err == nil || !strings.Contains(err.Error(), "no hash for") || strings.Contains(err.Error(), "a.mod-x,") { - t.Errorf("take-over = %v, want the users with no hash named", err) + why := strings.Join(p.Decide(), "; ") + if !strings.Contains(why, "no hash for") || strings.Contains(why, "a.mod-x,") { + t.Errorf("take-over is refused for %q, want the users with no hash named", why) } for state, keys := range b.own { for k, v := range keys { @@ -734,54 +741,118 @@ func TestGrants(t *testing.T) { } b.loaded = append(b.loaded, grants.User{User: "b.gone"}) + fa := &fakeAsker{} + var said []asks.Condition + p.Asker, p.Say = fa, func(c asks.Condition) error { said = append(said, c); return nil } + warrant := func(id string) asks.Warrant { return asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen} } + last := func() grants.Act { + raw, _, _, _ := b.ownGet(grants.StateTakeover, grants.KeySwitch) + var s grants.Switch + _ = json.Unmarshal(raw, &s) + if s.Open != "" && s.Acts[len(s.Acts)-1].Said != "asked" { + t.Errorf("an ask is open and its act is not asked: %+v", s) + } + return s.Acts[len(s.Acts)-1] + } + asked := func(act string) string { + t.Helper() + var res struct{ Asked string } + var err error + var out any + if act == "take-over" { + out, err = p.TakeOver("shanks.node-tools", "a row") + } else { + out, err = p.HandBack("shanks.node-tools", "a row") + } + if err != nil { + t.Fatalf("%s: %v", act, err) + } + raw, _ := json.Marshal(out) + _ = json.Unmarshal(raw, &res) + return res.Asked + } + yes, no := asks.Option{ID: "yes"}, asks.Option{ID: "no"} for _, row := range []struct { - name, act, caller string - says, never []string + name, act string + option asks.Option + setup func() + said, never []string // the refusal's words and the users it must not name + state string + outcome string }{ - {"take-over without the operator's credential", "take-over", "shanks.node-tools", []string{"operator's alone"}, nil}, - {"hand-back without the operator's credential", "hand-back", "unproven", []string{"operator's alone"}, nil}, - {"take-over while a user differs names it", "take-over", "person.operator", []string{"users differ", "b.mod-x", grants.NotComposable}, []string{"b.gone"}}, - {"hand-back in shadow", "hand-back", "person.operator", []string{"nothing to hand back"}, nil}, + {"a yes while a user differs is refused and said, naming it", "take-over", yes, func() {}, []string{"users differ", "b.mod-x"}, []string{"b.gone"}, grants.Shadow, "refused"}, + {"a no leaves the switch", "take-over", no, func() {}, nil, nil, grants.Shadow, "declined"}, + {"a yes with the lists agreeing and every hash held switches", "take-over", yes, func() { + b.states[grants.ReadAssignments] = nil + b.assign("a", "nats") + b.assign("a", "mod-x") + b.assign("b", "mod-y") + b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"}) + for _, u := range shadowOf(t, b) { + b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1}) + } + }, nil, nil, grants.TookOver, "switched"}, + {"a yes to hand back switches back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "switched"}, } { t.Run(row.name, func(t *testing.T) { - var err error - if row.act == "take-over" { - err = p.TakeOver(row.caller, "a row") - } else { - err = p.HandBack(row.caller, "a row") + row.setup() + before := len(fa.asks) + id := asked(row.act) + if _, err := p.TakeOver("shanks.node-tools", "again"); err == nil || !strings.Contains(err.Error(), "already asked") { + t.Errorf("a second ask while one is open = %v", err) } - if err == nil { - t.Fatalf("%s by %s was not refused", row.act, row.caller) + p.Compose() + p.Compose() + if len(fa.asks) != before+1 || fa.asks[before].ID != id { + t.Fatalf("the operator was asked %d times, want once: %+v", len(fa.asks)-before, fa.asks) } - for _, s := range row.says { - if !strings.Contains(err.Error(), s) { - t.Errorf("refusal %q does not say %q", err, s) + ask := fa.asks[before] + if err := ask.Check(time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC)); err != nil { + t.Errorf("the router would refuse the ask: %v", err) + } + if yesOpt, ok := ask.Option("yes"); !ok || yesOpt.Level != asks.Approve || !strings.HasPrefix(yesOpt.Binds, "sha256:") { + t.Errorf("the yes does not approve and bind its act: %+v", yesOpt) + } + said = nil + if err := p.Warranted(row.option, warrant(id)); err != nil { + t.Fatal(err) + } + l := last() + if l.Said != row.outcome || l.Ask != id || p.SwitchState() != row.state { + t.Errorf("the act = %+v, the switch %s; want %s and %s", l, p.SwitchState(), row.outcome, row.state) + } + for _, w := range row.said { + if !strings.Contains(l.Refused, w) || (row.outcome == "refused" && (len(said) != 1 || !strings.Contains(said[0].Explanation, w))) { + t.Errorf("the refusal %q (said %v) does not say %q", l.Refused, said, w) } } - for _, s := range row.never { - if strings.Contains(err.Error(), s) { - t.Errorf("refusal %q names %q", err, s) + for _, w := range row.never { + if strings.Contains(l.Refused, w) { + t.Errorf("the refusal names %q", w) } } - raw, _, _, _ := b.ownGet(grants.StateTakeover, grants.KeySwitch) - var s grants.Switch - _ = json.Unmarshal(raw, &s) - last := s.Acts[len(s.Acts)-1] - if s.State != grants.Shadow || last.Act != row.act || last.By != row.caller || last.Refused == "" { - t.Errorf("the act is not recorded: %+v", s) + if row.outcome == "switched" && len(said) != 0 { + t.Errorf("a switch said a refusal: %v", said) } }) } - t.Run("take-over with only a user placement no longer names is refused for want of records alone", func(t *testing.T) { - b.states[grants.ReadAssignments] = nil - b.assign("a", "nats") - b.assign("a", "mod-x") - b.assign("b", "mod-y") - b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"}) - err := p.TakeOver("person.operator", "lists agree") - if err == nil || strings.Contains(err.Error(), "differ") || !strings.Contains(err.Error(), grants.NotComposable) { - t.Errorf("take-over = %v, want refused for the enrolment users and persons alone", err) + t.Run("hand-back in shadow is refused at once, with nothing asked", func(t *testing.T) { + n := len(fa.asks) + if _, err := p.HandBack("shanks.node-tools", "a row"); err == nil || !strings.Contains(err.Error(), "nothing to hand back") { + t.Errorf("hand-back in shadow = %v", err) } + p.Compose() + if len(fa.asks) != n { + t.Errorf("a refused hand-back asked the operator") + } + }) + t.Run("an ask that ends with no answer is closed and can be asked again", func(t *testing.T) { + id := asked("take-over") + p.Said(asker.Recorded, asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired}, nil) + if l := last(); l.Said != string(asks.OutcomeExpired) { + t.Errorf("the act = %+v", l) + } + asked("take-over") }) t.Run("only memberships published, nothing asserted", func(t *testing.T) { @@ -1092,6 +1163,10 @@ func TestGrants(t *testing.T) { }) } +type fakeAsker struct{ asks []asks.Ask } + +func (f *fakeAsker) Ask(a asks.Ask) error { f.asks = append(f.asks, a); return nil } + func nonNil(s []string) []string { if s == nil { return []string{} diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 1d3a685..ac46c88 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -4,7 +4,7 @@ // state it takes effect in two things alone, each by a grant the operator allowed: it publishes memberships (2026-10-25 // 22:31), and creates a bucket, log or stream a manifest declares that the bus lacks (2026-10-26 08:54, issue 1790). It // changes no stream the bus holds, writes no grant and no user list the bus node reads, until the operator's take-over, -// which this milestone always refuses. +// which the operator asks on their channel and the part runs on their yes, after its refusals. package grants import ( diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index aa5f6f3..eef23e7 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -6,12 +6,12 @@ import ( "encoding/hex" "encoding/json" "fmt" - "slices" "sort" "strings" "sync" "time" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -42,9 +42,6 @@ const ( // Emits is every event the part says. var Emits = []string{EventAsserted, EventMembership, EventUserList} -// Shadow is the switch's one state in this milestone: the bus node follows the controller's list. -const Shadow = "shadow" - // Store is the part's own state, by local name: the only thing the part writes. type Store interface { Get(state, key string) ([]byte, uint64, bool, error) @@ -121,7 +118,12 @@ type Part struct { Publish Publisher // Create creates a declared stream the bus lacks: the process's connection; nil in the tools, which ask the process. Create Creator - Now func() time.Time + // Asker asks the operator (take-over, hand-back): the process's client of the router; nil in the tools, which write + // the request and let the process ask it. + Asker Asker + // Say says a condition to the operator: the process's; nil in the tools. + Say func(asks.Condition) error + Now func() time.Time cache Manifests mu sync.Mutex @@ -176,7 +178,7 @@ func (p *Part) Compose() Result { } recs, left := Records(in) res.RootNotFree = WithRootFree(&recs, p.Call) - st := UserListState{V: 1, Mode: Shadow, LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode, + st := UserListState{V: 1, Mode: p.SwitchState(), LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode, Gaps: append(append([]Gap(nil), in.Gaps...), MembershipGaps...), At: p.now()} if st.LeftOut == nil { st.LeftOut = []Gap{} @@ -259,6 +261,9 @@ func (p *Part) Compose() Result { res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be written: %v", StateTakeover, err)) } } + if err := p.RaiseRequested(); err != nil { + res.Errors = append(res.Errors, err.Error()) + } return res } @@ -314,101 +319,3 @@ func sortedKeys[V any](m map[string]V) []string { sort.Strings(out) return out } - -// Switch is the value of `takeover`: which list the bus node follows, and every act on the switch. -type Switch struct { - V int `json:"v"` - State string `json:"state"` - Acts []Act `json:"acts"` -} - -// Act is one take-over or hand-back asked, done or refused. -type Act struct { - Act string `json:"act"` - By string `json:"by"` - Why string `json:"why,omitempty"` - At time.Time `json:"at"` - Refused string `json:"refused,omitempty"` -} - -// keptActs is how many acts the switch keeps, newest last. -const keptActs = 50 - -// Operator says whether a caller is the operator's credential: a person's own bus account (`person.`), which no -// session of an agent holds (an agent calls as its machine's tool runner). -func Operator(caller string) bool { - name, ok := strings.CutPrefix(caller, "person.") - return ok && name != "" -} - -// NotComposable is why take-over is refused in this milestone, whatever the lists say. -const NotComposable = "the enrolment users and the persons cannot be composed: they live only in the controller's store, and take-over waits until they have records on the bus (ADR 0547 clause 9, version 26)" - -// record keeps an act on the switch; a refused write is said in the answer. -func (p *Part) record(a Act) string { - raw, _, found, err := p.Store.Get(StateTakeover, KeySwitch) - if err != nil { - return fmt.Sprintf("; the act is not recorded: %v", err) - } - s := Switch{V: 1, State: Shadow} - if found { - _ = json.Unmarshal(raw, &s) - } - s.Acts = append(s.Acts, a) - if len(s.Acts) > keptActs { - s.Acts = s.Acts[len(s.Acts)-keptActs:] - } - data, _ := json.Marshal(s) - if _, err := p.Store.Put(StateTakeover, KeySwitch, data); err != nil { - return fmt.Sprintf("; the act is not recorded: %v", err) - } - return "" -} - -// TakeOver is the operator's switch to the grants part's list. It is refused without the operator's credential, while -// any user of the shadow list differs from the loaded list (but those both agree are gone), and in this milestone -// always, since the enrolment users and the persons cannot be composed. Every ask is recorded. -func (p *Part) TakeOver(caller, why string) error { - a := Act{Act: "take-over", By: caller, Why: why, At: p.now()} - if !Operator(caller) { - a.Refused = fmt.Sprintf("take-over is the operator's alone: %s is not a person's account", caller) - return fmt.Errorf("%s%s", a.Refused, p.record(a)) - } - res := p.Compose() - var reasons []string - if c := res.UserList.Compare; !c.Equal() { - if c.Unread != "" { - reasons = append(reasons, "the loaded list could not be read: "+c.Unread) - } - if d := c.Differing(); len(d) > 0 { - reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list: %s", len(d), strings.Join(d, ", "))) - } - } - cr, gone := res.UserList.Credentials, res.UserList.Compare.Gone - var unhashed []string - for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) { - if !slices.Contains(gone, u) { - unhashed = append(unhashed, u) - } - } - if len(unhashed) > 0 { - sort.Strings(unhashed) - reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s", - len(unhashed), strings.Join(unhashed, ", "))) - } - reasons = append(reasons, NotComposable) - a.Refused = "take-over refused: " + strings.Join(reasons, "; ") - return fmt.Errorf("%s%s", a.Refused, p.record(a)) -} - -// HandBack is the operator's switch back to the controller's list. Refused without the operator's credential, and -// while the part is in shadow, since the bus node follows the controller's list already. Every ask is recorded. -func (p *Part) HandBack(caller, why string) error { - a := Act{Act: "hand-back", By: caller, Why: why, At: p.now()} - if !Operator(caller) { - a.Refused = fmt.Sprintf("hand-back is the operator's alone: %s is not a person's account", caller) - return fmt.Errorf("%s%s", a.Refused, p.record(a)) - } - a.Refused = "nothing to hand back: the grants part is in shadow and never took over, so the bus node follows the controller's list already" - return fmt.Errorf("%s%s", a.Refused, p.record(a)) -} diff --git a/modules/mesh-grants/internal/grants/takeover.go b/modules/mesh-grants/internal/grants/takeover.go new file mode 100644 index 0000000..939e122 --- /dev/null +++ b/modules/mesh-grants/internal/grants/takeover.go @@ -0,0 +1,327 @@ +package grants + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/asker" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// The switch between the controller's user list and the grants part's (ADR 0547 clause 8). The operator's word is a +// warrant: the operator's answer to an ask on a channel that proves who answered, never a caller's name, since an +// operator's session and every agent's call as the same machine account (the operator, 2026-10-26). + +// Shadow is the switch's state until take-over: the bus node follows the controller's list. TookOver is its state after. +const ( + Shadow = "shadow" + TookOver = "took-over" +) + +// KeyRequest is the key in StateTakeover where a tool leaves its request for the process to ask. +const KeyRequest = "request" + +// The two acts the operator may warrant. +const ( + ActTakeOver = "take-over" + ActHandBack = "hand-back" +) + +// Switch is the value of `takeover`: which list the bus node follows, every act on the switch and the ask open now. +type Switch struct { + V int `json:"v"` + State string `json:"state"` + // Open is the id of the ask the operator has not answered yet, empty when none. + Open string `json:"open,omitempty"` + Acts []Act `json:"acts"` +} + +// Act is one take-over or hand-back asked, answered, done or refused. +type Act struct { + Act string `json:"act"` + By string `json:"by"` + Why string `json:"why,omitempty"` + At time.Time `json:"at"` + // Ask is the router's ask of the act; Said what became of it: asked, switched, declined, expired, refused. + Ask string `json:"ask,omitempty"` + Said string `json:"said,omitempty"` + Refused string `json:"refused,omitempty"` +} + +// Request is what a tool leaves for the process to ask: written once per act, marked raised when asked. +type Request struct { + ID string `json:"id"` + Act string `json:"act"` + By string `json:"by"` + Why string `json:"why,omitempty"` + At time.Time `json:"at"` + Raised bool `json:"raised,omitempty"` +} + +// Asker is the client of the router the part asks through (mesh-sdk asker.Client). +type Asker interface { + Ask(a asks.Ask) error +} + +// keptActs is how many acts the switch keeps, newest last. +const keptActs = 50 + +func (p *Part) readSwitch() (Switch, error) { + s := Switch{V: 1, State: Shadow} + raw, _, found, err := p.Store.Get(StateTakeover, KeySwitch) + if err != nil { + return s, err + } + if found { + _ = json.Unmarshal(raw, &s) + } + return s, nil +} + +func (p *Part) writeSwitch(s Switch) error { + if len(s.Acts) > keptActs { + s.Acts = s.Acts[len(s.Acts)-keptActs:] + } + if s.Acts == nil { + s.Acts = []Act{} + } + data, _ := json.Marshal(s) + _, err := p.Store.Put(StateTakeover, KeySwitch, data) + return err +} + +// SwitchState is which list the bus node follows now. +func (p *Part) SwitchState() string { + if s, err := p.readSwitch(); err == nil && s.State != "" { + return s.State + } + return Shadow +} + +// TakeOver is the tool: it asks the operator to switch the bus node to the grants part's list. It writes the request, +// and the part's process asks it on the operator's channel; only the operator's yes (a warrant) runs the switch, after +// the refusals of Decide. Asking again while an ask is open is refused. +func (p *Part) TakeOver(caller, why string) (any, error) { return p.request(ActTakeOver, caller, why) } + +// HandBack is the tool: it asks the operator to switch the bus node back to the controller's list. +func (p *Part) HandBack(caller, why string) (any, error) { return p.request(ActHandBack, caller, why) } + +func (p *Part) request(act, caller, why string) (any, error) { + s, err := p.readSwitch() + if err != nil { + return nil, fmt.Errorf("the switch cannot be read: %w", err) + } + a := Act{Act: act, By: caller, Why: why, At: p.now()} + if s.Open != "" { + return nil, fmt.Errorf("%s is already asked: the ask %s waits for the operator's answer", act, s.Open) + } + if act == ActHandBack && s.State == Shadow { + a.Refused = "nothing to hand back: the grants part is in shadow and never took over, so the bus node follows the controller's list already" + s.Acts = append(s.Acts, a) + _ = p.writeSwitch(s) + return nil, fmt.Errorf("%s", a.Refused) + } + if act == ActTakeOver && s.State == TookOver { + return nil, fmt.Errorf("the bus node follows the grants part's list already") + } + a.Ask = act + "-" + p.now().Format("20060102t150405") + a.Said = "asked" + req, _ := json.Marshal(Request{ID: a.Ask, Act: act, By: caller, Why: why, At: a.At}) + if _, err := p.Store.Put(StateTakeover, KeyRequest, req); err != nil { + return nil, fmt.Errorf("the request could not be written: %w", err) + } + s.Open = a.Ask + s.Acts = append(s.Acts, a) + if err := p.writeSwitch(s); err != nil { + return nil, fmt.Errorf("the act could not be recorded: %w", err) + } + // The process asks at its next pass: wake it. + _, _ = p.AskCompose(caller) + return map[string]any{"asked": a.Ask, "says": "the part's process asks the operator on their channel; only the operator's yes switches. grants_take_over_state shows where it stands"}, nil +} + +// RaiseRequested is the process's: it asks the operator the request a tool left, once. Where the part has no asker (the +// tools) it does nothing. +func (p *Part) RaiseRequested() error { + if p.Asker == nil { + return nil + } + raw, rev, found, err := p.Store.Get(StateTakeover, KeyRequest) + if err != nil || !found { + if err != nil { + return fmt.Errorf("its own %s %s cannot be read: %v", StateTakeover, KeyRequest, err) + } + return nil + } + var r Request + if json.Unmarshal(raw, &r) != nil || r.Raised || r.ID == "" { + return nil + } + ask, err := p.AskOf(r) + if err != nil { + return err + } + if err := p.Asker.Ask(ask); err != nil { + return fmt.Errorf("the operator could not be asked the %s: %v", r.Act, err) + } + r.Raised = true + data, _ := json.Marshal(r) + if _, err := p.Store.Put(StateTakeover, KeyRequest, data); err != nil { + return fmt.Errorf("the ask %s was asked and its request could not be marked: %v", r.ID, err) + } + _ = rev + return nil +} + +// AskOf is the ask of a request, in the words the operator reads: what changes, why, what it risks. Its yes binds the act. +func (p *Part) AskOf(r Request) (asks.Ask, error) { + verb, headline, does, stays, expl := "grants_take_over", "Switch the bus's user list to the grants part?", "The grants part's list becomes the bus's.", + "The controller's list stays in force.", "The grants part has composed the user list the bus holds today, and it can take over writing it.\n"+ + "If the lists differ in any user, or a user has no hash in the vault, the switch is refused and you are told which." + if r.Act == ActHandBack { + verb, headline, does, stays, expl = "grants_hand_back", "Give the bus's user list back to the controller?", "The controller's list becomes the bus's again.", + "The grants part's list stays in force.", "The bus follows the grants part's list now.\nThe controller still writes its list until it loses its lease, so the bus can follow it again." + } + bind, err := asks.ActDigest(asks.Act{"verb": verb, "module": "mesh-grants", "ask": r.ID}) + if err != nil { + return asks.Ask{}, err + } + return asks.Ask{ID: r.ID, Headline: headline, Explanation: expl, Who: asks.Operator, + Options: []asks.Option{{ID: "yes", Label: "Yes, switch", Does: does, Level: asks.Approve, Binds: bind}, + {ID: "no", Label: "No, leave it", Does: stays, Level: asks.Acknowledge}}, + Expires: p.now().Add(asks.ApproveLasts - time.Minute), OnExpiry: stays + " Nothing changes.", About: "mesh-grants-" + r.Act, + Details: "Asked by " + r.By + ": " + r.Why}, nil +} + +// Warranted is the process's act on the operator's answer (asker.Client.Listen hands it once, after checking the ask, the +// option and its level). A no is recorded; a yes runs the switch after the refusals, and a refusal is said to the operator. +func (p *Part) Warranted(o asks.Option, w asks.Warrant) error { + s, err := p.readSwitch() + if err != nil { + return err + } + act := ActTakeOver + if strings.HasPrefix(w.Ask, ActHandBack+"-") { + act = ActHandBack + } + a := Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask} + if o.ID != "yes" { + a.Said = "declined" + return p.finish(s, a) + } + var reasons []string + if act == ActTakeOver { + reasons = p.Decide() + } else if s.State == Shadow { + reasons = []string{"nothing to hand back: the bus node follows the controller's list already"} + } + if len(reasons) > 0 { + a.Said, a.Refused = "refused", strings.Join(reasons, "; ") + p.sayRefused(act, a.Refused) + return p.finish(s, a) + } + a.Said = "switched" + if act == ActTakeOver { + s.State = TookOver + } else { + s.State = Shadow + } + if p.Emit != nil { + _ = p.Emit.Emit(EventUserList, map[string]any{"switch": s.State}) + } + return p.finish(s, a) +} + +// Said is the process's note of an ask that ended with no yes or no (expired, or refused by the router): the switch +// stays, and the ask is closed so the operator can be asked again. +func (p *Part) Said(h asker.Handled, w asks.Warrant, _ error) { + if h != asker.Recorded { + return + } + s, err := p.readSwitch() + if err != nil || s.Open != w.Ask { + return + } + act := ActTakeOver + if strings.HasPrefix(w.Ask, ActHandBack+"-") { + act = ActHandBack + } + _ = p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask, Said: string(w.Outcome)}) +} + +func (p *Part) finish(s Switch, a Act) error { + if s.Open == a.Ask { + s.Open = "" + } + s.Acts = append(s.Acts, a) + return p.writeSwitch(s) +} + +// Decide is what stands between a take-over and the grants part's list, read from a composition made now: the loaded list +// not read, any user the two lists keep apart (one the loaded list has that the shadow cannot compose among them, such as +// an enrolment user or a person whose records are in the controller's store alone), and any user with no hash in the vault. +// Nothing here but what is true now: a take-over is refused for these and for no other reason. Empty means none. +func (p *Part) Decide() []string { + res := p.Compose() + var reasons []string + if c := res.UserList.Compare; !c.Equal() { + if c.Unread != "" { + reasons = append(reasons, "the loaded list could not be read: "+c.Unread) + } + if d := c.Differing(); len(d) > 0 { + reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list: %s", len(d), strings.Join(d, ", "))) + } + } + cr, gone := res.UserList.Credentials, res.UserList.Compare.Gone + var unhashed []string + for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) { + if !contains(gone, u) { + unhashed = append(unhashed, u) + } + } + if len(unhashed) > 0 { + sort.Strings(unhashed) + reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s", + len(unhashed), strings.Join(unhashed, ", "))) + } + return reasons +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +// sayRefused tells the operator a yes was given and the switch refused, with the reasons (the users that differ named). +func (p *Part) sayRefused(act, why string) { + if p.Say == nil { + return + } + c := asks.Condition{Key: "mesh-grants." + act + "-refused", Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}, + State: asks.StateRaised, Summary: act + " refused: " + why, Headline: "The user list switch was refused", Needs: "a look at why", + Explanation: asks.NeedsYou + "a look at why. You said yes to the switch and it was refused: " + why, Resolved: "the switch is asked again and goes through"} + _ = p.Say(c) +} + +// TakeOverState is the tool take-over-state: the switch, the request, and what became of each ask. +func (p *Part) TakeOverState() (any, error) { + s, err := p.readSwitch() + if err != nil { + return nil, err + } + out := map[string]any{"state": s.State, "open": s.Open, "acts": s.Acts} + if raw, _, found, err := p.Store.Get(StateTakeover, KeyRequest); err == nil && found { + out["request"] = json.RawMessage(raw) + } + if s.Open == "" { + out["says"] = "no ask is open" + } + return out, nil +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 8ddd43f..09700dc 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -13,17 +13,18 @@ import ( // The part's tools, as its manifest lists them. Module tools in this milestone: a `mesh-*` seat must be in mesh-sdk's own // seat list, which the running controller's composer does not know, so the part claims no seat until take-over. const ( - ToolGrants = "grants_grants" - ToolMembership = "grants_membership" - ToolUserList = "grants_user_list" - ToolComposeNow = "grants_compose_now" - ToolAssert = "grants_assert" - ToolTakeOver = "grants_take_over" - ToolHandBack = "grants_hand_back" + ToolGrants = "grants_grants" + ToolMembership = "grants_membership" + ToolUserList = "grants_user_list" + ToolComposeNow = "grants_compose_now" + ToolAssert = "grants_assert" + ToolTakeOver = "grants_take_over" + ToolTakeOverState = "grants_take_over_state" + ToolHandBack = "grants_hand_back" ) // Tools is every tool, in the manifest's order. -var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack} +var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState} // WritesNoGrant is what grants says of the part's own grants in this milestone. const WritesNoGrant = "none: the grants part writes no grants.. before ADR 0547's third milestone (clause 5); the controller's are beside" diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index eca256f..710c0ce 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -17,6 +17,9 @@ "mesh-controller.condition-raised", "mesh-controller.condition-cleared" ], + "uses": [ + "operator-channel" + ], "invokes": [ "nats.nats_streams", "nats.nats_user_can", @@ -35,6 +38,7 @@ "grants_compose_now", "grants_assert", "grants_take_over", + "grants_take_over_state", "grants_hand_back" ], "state": [ From 9a569bfb8dad355249fd4ab9543f927dcd7357d9 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:26:20 +0100 Subject: [PATCH 05/11] mesh-grants: derive that a bundle loads from build.artifacts, as the indexer's manifest has no derived bundles, so route-proxy's helper is carried (issue 1945) --- modules/mesh-grants/grants_test.go | 22 +++++++++++ .../mesh-grants/internal/grants/records.go | 38 ++++++++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 0f0fa18..a4c2805 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -426,6 +426,28 @@ func TestGrants(t *testing.T) { } }) } + // A module whose only runtime-loaded part is a helper bundle (route-proxy's route-provider) says `loads` in + // the repository manifest's build.artifacts; the indexer's manifest has no derived Bundles (issue 1945). + for _, row := range []struct { + name, build string + carried bool + }{ + {"a bundle that says loads is carried by node-tools", `,"build":{"artifacts":[{"name":"helper","kind":"bundle","language":"go","system":"arch","from":"cmd/h","binary":"h","loads":["h"]}]}`, true}, + {"a module with no loading bundle is left to its own account", ``, false}, + } { + t.Run(row.name, func(t *testing.T) { + b := fixture() + b.manifestOf("mod-z", `{"module":"mod-z","version":"1","own-secrets":{"broker":"/var/lib/z/broker"}`+row.build+`}`) + b.assign("a", "mod-z") + b.manifestOf("node-tools", `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/nt/broker"}}`) + b.assign("a", "node-tools") + users := shadowOf(t, b) + tools := users[slices.IndexFunc(users, func(u grants.User) bool { return u.User == "a.node-tools" })] + if got := slices.Contains(tools.Subscribe, "mesh.mod.mod-z.tool.>"); got != row.carried { + t.Errorf("a.node-tools subscribes to mod-z's tools = %v, want %v: %v", got, row.carried, tools.Subscribe) + } + }) + } b := fixture() users := shadowOf(t, b) y := users[slices.IndexFunc(users, func(u grants.User) bool { return u.User == "b.mod-y" })] diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 94beb82..e53beab 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -101,11 +101,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), Checks: catalogue.HealthChecks(m), RunsAs: m.RunsAs, Issued: issuedTools(m), } - for _, b := range m.Bundles { - if len(b.Loads) > 0 { - d.Loads = true - } - } + d.Loads = loadsABundle(m) if _, reads := m.OwnSecrets["broker"]; !reads { d.NoAccount = true } @@ -129,6 +125,38 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio return d } +// loadsABundle says the node's tool runtime loads a bundle of the module. The controller reads it from the resolved +// manifest's Bundles, which a build derives and the indexer's manifest does not carry (the builder's build record names +// the repository's manifest digest), so the same answer is derived here from `build.artifacts` by mesh-sdk's rule +// (the build's own rule for what a bundle loads): a bundle that says `loads`, else every bundle of a module that declares tools and that no +// resource of the module runs. Without it a module whose only runtime-loaded part is a helper (route-proxy's +// route-provider) was left out of node-tools' grants (issue 1945). +func loadsABundle(m catalogue.Manifest) bool { + for _, b := range m.Bundles { + if len(b.Loads) > 0 { + return true + } + } + if m.Build == nil { + return false + } + run := map[string]bool{} + for _, r := range m.Resources { + if a, ok := r["artifact"].(string); ok && a != "" { + run[a] = true + } + } + for _, a := range m.Build.Artifacts { + if a.Kind != catalogue.ArtifactBundle { + continue + } + if len(a.Loads) > 0 || (a.Loads == nil && len(m.Tools) > 0 && !run[a.Name]) { + return true + } + } + return false +} + func waitsOf(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaration) []buscompose.Wait { var out []buscompose.Wait for _, w := range m.Waits { From b0b1cf4f013d98d2ef9d9c46a6ffae8cd985944a Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:34:52 +0100 Subject: [PATCH 06/11] mesh-grants: a retired verb and a multiply claimed mesh seat's added subjects are named, not counted, in the comparison (issue 1945) --- modules/mesh-grants/grants_test.go | 22 +++++ modules/mesh-grants/internal/grants/part.go | 2 +- .../mesh-grants/internal/grants/userlist.go | 96 ++++++++++++++++++- 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index a4c2805..fbcbd74 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -699,6 +699,28 @@ func TestGrants(t *testing.T) { } }) + t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subjects, nothing else", func(t *testing.T) { + const retired, store, storeInfo = "mesh.seat.mesh-delivery.tool.mark-ready", "mesh.seat.mesh-store.tool.databases", "$SRV.INFO.mesh-store.>" + for _, row := range []struct { + name string + shadow, bus []string // the node-tools user's subscribe in the shadow list and in the loaded list + multi []string + differs bool + excusedCount int + }{ + {"a retired verb the shadow dropped", []string{"x"}, []string{"x", retired}, nil, false, 1}, + {"a verb of a live seat the shadow lost", []string{"x"}, []string{"x", store}, []string{"mesh-store"}, true, 0}, + {"a seat claimed on two machines, added", []string{"x", store, storeInfo}, []string{"x"}, []string{"mesh-store"}, false, 2}, + {"the same added for a seat claimed once", []string{"x", store}, []string{"x"}, nil, true, 0}, + } { + c := grants.Compare([]grants.User{{User: "a.node-tools", Subscribe: row.shadow}}, []grants.User{{User: "a.node-tools", Subscribe: row.bus}}, nil). + Excusing(grants.ExcusedBy(row.multi)) + if c.Equal() == row.differs || len(c.Excused) != row.excusedCount { + t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused) + } + } + }) + t.Run("tools", func(t *testing.T) { b := fixture() b.loaded = shadowOf(t, b) diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index eef23e7..6fbf8da 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -190,7 +190,7 @@ func (p *Part) Compose() Result { users = WithHashes(users, in.Hashes) st.Digest, st.Users = Digest(users), len(users) loaded, err := LoadedUsers(p.Call) - st.Compare = Compare(users, loaded, Placed(in)) + st.Compare = Compare(users, loaded, Placed(in)).Excusing(ExcusedBy(MultiHeld(recs))) if err != nil { st.Compare.Unread = err.Error() } diff --git a/modules/mesh-grants/internal/grants/userlist.go b/modules/mesh-grants/internal/grants/userlist.go index 0ccb33c..a9bd151 100644 --- a/modules/mesh-grants/internal/grants/userlist.go +++ b/modules/mesh-grants/internal/grants/userlist.go @@ -5,11 +5,13 @@ import ( "encoding/hex" "encoding/json" "fmt" + "regexp" "slices" "sort" "strings" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // User is one bus user and its grants, as the bus module's nats_users answers them: never a password hash. @@ -234,7 +236,9 @@ type Comparison struct { Dropped []string `json:"dropped"` Gone []string `json:"gone,omitempty"` Differ []UserDiff `json:"differ"` - Same int `json:"same"` + // Excused are the subjects the comparison does not count, each with why (Comparison.Excusing). + Excused []Excuse `json:"excused,omitempty"` + Same int `json:"same"` // Unread says the loaded list could not be read, and why: nothing is compared then. Unread string `json:"unread,omitempty"` } @@ -325,3 +329,93 @@ func Placed(in Inputs) func(user string) bool { return slices.Contains(in.Nodes, parts[0]) && slices.Contains(in.Assigned[parts[0]], parts[1]) } } + +// Excuse is one subject the comparison does not count as a difference between the lists, and why. +type Excuse struct { + User string `json:"user"` + Subject string `json:"subject"` + Why string `json:"why"` +} + +// Excusing removes from the differences the subjects f excuses (added: the shadow grants it and the loaded list does not), +// naming each in Excused; a user left with no difference counts as the same. Pure. +func (c Comparison) Excusing(f func(subject string, added bool) string) Comparison { + var differ []UserDiff + for _, d := range c.Differ { + keep := func(list []string, added bool) []string { + var out []string + for _, s := range list { + if why := f(s, added); why != "" { + c.Excused = append(c.Excused, Excuse{User: d.User, Subject: s, Why: why}) + continue + } + out = append(out, s) + } + return out + } + d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true), keep(d.PublishDropped, false) + d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true), keep(d.SubscribeDropped, false) + if len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped) == 0 && d.Responses == "" { + c.Same++ + continue + } + differ = append(differ, d) + } + if differ == nil { + differ = []UserDiff{} + } + c.Differ = differ + return c +} + +var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.\*)?$`) +var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.>)?$`) + +// ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the +// seat catalogue retired on purpose, which the shadow no longer grants, is no difference; and what the shadow grants more +// for a mesh seat that is claimed on several machines (the standing gap seat holdings: it composes the seat as held on each) +// is named and not counted, since a subscription to a seat's verbs on a machine whose holder does not answer them harms +// nothing and is gone when holdings follow the catalogue. Nothing else is excused: a grant the shadow gives for any other +// reason, or the loss of any other subject, keeps the lists apart. +func ExcusedBy(multiHeld []string) func(subject string, added bool) string { + return func(subject string, added bool) string { + if m := seatSubject.FindStringSubmatch(subject); m != nil { + if !added && retiredVerb(m[1], m[2]) { + return "a verb the seat catalogue retired" + } + if added && slices.Contains(multiHeld, m[1]) { + return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue" + } + } + if m := seatService.FindStringSubmatch(subject); m != nil && added && slices.Contains(multiHeld, m[1]) { + return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue" + } + return "" + } +} + +// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, sorted. +func MultiHeld(r buscompose.Records) []string { + nodes := map[string]map[string]bool{} + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Scope != "" && s.Scope != catalogue.ScopeMesh { + continue + } + if nodes[s.Name] == nil { + nodes[s.Name] = map[string]bool{} + } + nodes[s.Name][node] = true + } + } + } + var out []string + for seat, ns := range nodes { + if len(ns) > 1 { + out = append(out, seat) + } + } + sort.Strings(out) + return out +} From 4b23db28b35309b47fb275f7e9cc16fdc4593bab Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:43:39 +0100 Subject: [PATCH 07/11] mesh-grants: an open take-over ask is always closed (router record, expiry, failed publish, any outcome), the switch is said recorded not switched, the refusal condition is cleared, the excuse is limited to the claiming machines' subscriptions (issue 1945) --- modules/mesh-grants/cmd/mesh-grants/main.go | 1 + modules/mesh-grants/cmd/mesh-grants/tools.go | 2 +- modules/mesh-grants/grants_test.go | 119 +++++++++--- modules/mesh-grants/internal/grants/part.go | 17 +- .../mesh-grants/internal/grants/takeover.go | 180 +++++++++++++++--- .../mesh-grants/internal/grants/userlist.go | 57 +++--- 6 files changed, 296 insertions(+), 80 deletions(-) diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index 7a9741f..365de24 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -123,6 +123,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro client := &asker.Client{Module: account.Module, State: conn.State(grants.StateTakeover), Publish: publishVia(ctx, js), Record: recordVia(ctx, nc, account.Module), Subscribe: decidedVia(nc, account.Module, logger.Printf)} f.Part.Asker, f.Part.Say = client, sayVia(ctx, js, account.Module) + f.Part.Forget = func(id string) error { return conn.State(grants.StateTakeover).Delete("ask." + id) } if err := client.Listen(f.Part.Warranted, f.Part.Said); err != nil { logger.Printf("the operator's answers cannot be heard yet, so take-over and hand-back wait: %v", err) } diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index cfd34bf..bb351b4 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -99,7 +99,7 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.AssertOne(grants.Answer(a, "module")) }}, {Name: grants.ToolTakeOver, Description: "Ask the operator to switch the bus node to the grants part's list (ADR 0547 clause 8). " + "The operator's word is their yes on their channel (a warrant), never a caller's name: this tool only asks, once at a time. " + - "After the yes, the switch is refused while the loaded list could not be read, a user differs between the lists " + + "After the yes, the switch is recorded and takes effect when the bus node loads the part's list; it is refused while the loaded list could not be read, a user differs between the lists " + "(an enrolment user or a person the part cannot compose among them), or the vault holds no hash for a user, and the " + "refusal is said to the operator naming the users. Every act is recorded in the part's state takeover.", Input: schema(map[string]string{"why": "why, kept with the act"}, "why"), diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index fbcbd74..2d7a5a2 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -699,23 +699,27 @@ func TestGrants(t *testing.T) { } }) - t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subjects, nothing else", func(t *testing.T) { + t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subscriptions on its machines, nothing else", func(t *testing.T) { const retired, store, storeInfo = "mesh.seat.mesh-delivery.tool.mark-ready", "mesh.seat.mesh-store.tool.databases", "$SRV.INFO.mesh-store.>" + two := map[string][]string{"mesh-store": {"a", "n"}} for _, row := range []struct { - name string - shadow, bus []string // the node-tools user's subscribe in the shadow list and in the loaded list - multi []string - differs bool - excusedCount int + name string + user string + subShadow, subBus, pubShadow []string + multi map[string][]string + differs bool + excused int }{ - {"a retired verb the shadow dropped", []string{"x"}, []string{"x", retired}, nil, false, 1}, - {"a verb of a live seat the shadow lost", []string{"x"}, []string{"x", store}, []string{"mesh-store"}, true, 0}, - {"a seat claimed on two machines, added", []string{"x", store, storeInfo}, []string{"x"}, []string{"mesh-store"}, false, 2}, - {"the same added for a seat claimed once", []string{"x", store}, []string{"x"}, nil, true, 0}, + {"a retired verb the shadow dropped", "a.node-tools", []string{"x"}, []string{"x", retired}, nil, nil, false, 1}, + {"a verb of a live seat the shadow lost", "a.node-tools", []string{"x"}, []string{"x", store}, nil, two, true, 0}, + {"a seat claimed on two machines, added to a user on one", "a.node-tools", []string{"x", store, storeInfo}, []string{"x"}, nil, two, false, 2}, + {"the same added to a user on a machine that does not claim it", "q.node-tools", []string{"x", store}, []string{"x"}, nil, two, true, 0}, + {"a publish of the seat added is not excused", "a.node-tools", []string{"x"}, []string{"x"}, []string{store}, two, true, 0}, + {"the same added for a seat claimed once", "a.node-tools", []string{"x", store}, []string{"x"}, nil, nil, true, 0}, } { - c := grants.Compare([]grants.User{{User: "a.node-tools", Subscribe: row.shadow}}, []grants.User{{User: "a.node-tools", Subscribe: row.bus}}, nil). + c := grants.Compare([]grants.User{{User: row.user, Subscribe: row.subShadow, Publish: row.pubShadow}}, []grants.User{{User: row.user, Subscribe: row.subBus}}, nil). Excusing(grants.ExcusedBy(row.multi)) - if c.Equal() == row.differs || len(c.Excused) != row.excusedCount { + if c.Equal() == row.differs || len(c.Excused) != row.excused { t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused) } } @@ -785,7 +789,10 @@ func TestGrants(t *testing.T) { } b.loaded = append(b.loaded, grants.User{User: "b.gone"}) - fa := &fakeAsker{} + fa := &fakeAsker{wheres: map[string]*asks.Warrant{}} + var forgot []string + n := 0 + p.Nonce, p.Forget = func() string { n++; return fmt.Sprintf("%04d", n) }, func(id string) error { forgot = append(forgot, id); return nil } var said []asks.Condition p.Asker, p.Say = fa, func(c asks.Condition) error { said = append(said, c); return nil } warrant := func(id string) asks.Warrant { return asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen} } @@ -826,7 +833,7 @@ func TestGrants(t *testing.T) { }{ {"a yes while a user differs is refused and said, naming it", "take-over", yes, func() {}, []string{"users differ", "b.mod-x"}, []string{"b.gone"}, grants.Shadow, "refused"}, {"a no leaves the switch", "take-over", no, func() {}, nil, nil, grants.Shadow, "declined"}, - {"a yes with the lists agreeing and every hash held switches", "take-over", yes, func() { + {"a yes with the lists agreeing and every hash held records the switch", "take-over", yes, func() { b.states[grants.ReadAssignments] = nil b.assign("a", "nats") b.assign("a", "mod-x") @@ -835,8 +842,8 @@ func TestGrants(t *testing.T) { for _, u := range shadowOf(t, b) { b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1}) } - }, nil, nil, grants.TookOver, "switched"}, - {"a yes to hand back switches back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "switched"}, + }, nil, nil, grants.TookOver, "recorded"}, + {"a yes to hand back records the switch back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "recorded"}, } { t.Run(row.name, func(t *testing.T) { row.setup() @@ -875,8 +882,12 @@ func TestGrants(t *testing.T) { t.Errorf("the refusal names %q", w) } } - if row.outcome == "switched" && len(said) != 0 { - t.Errorf("a switch said a refusal: %v", said) + // A recorded switch says no refusal and clears the one a refused yes left raised. + if row.outcome == "recorded" && row.act == "take-over" && (len(said) != 1 || said[0].State != asks.StateCleared || said[0].Key != "mesh-grants.take-over-refused") { + t.Errorf("a recorded switch did not clear the refusal: %v", said) + } + if row.outcome == "recorded" && !strings.Contains(fmt.Sprint(p.TakeOverState()), "bus_node_loaded_grants_list:not yet") { + t.Errorf("the state claims the bus node follows with no loaded list: %v", fmt.Sprint(p.TakeOverState())) } }) } @@ -890,12 +901,52 @@ func TestGrants(t *testing.T) { t.Errorf("a refused hand-back asked the operator") } }) - t.Run("an ask that ends with no answer is closed and can be asked again", func(t *testing.T) { + t.Run("an ask that can stay open is closed every way and can be asked again", func(t *testing.T) { + // A warrant the process missed is caught up from the router's record, whatever its outcome. id := asked("take-over") - p.Said(asker.Recorded, asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired}, nil) - if l := last(); l.Said != string(asks.OutcomeExpired) { - t.Errorf("the act = %+v", l) + p.Compose() + fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen, Option: "no"} + p.Compose() + if l := last(); l.Ask != id || l.Said != "declined" { + t.Errorf("a missed warrant was not caught up: %+v", l) } + // The router ended it with no choice, and the process was not told. + id = asked("take-over") + p.Compose() + fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeCancelled} + p.Compose() + if l := last(); l.Ask != id || !strings.Contains(l.Said, "cancelled") { + t.Errorf("an ask the router ended was not closed: %+v", l) + } + // Said closes on any outcome for the open id, and none for another id. + id = asked("take-over") + p.Said(asker.Refused, asks.Warrant{Ask: "other", Outcome: asks.OutcomeRefused}, nil) + if _, err := p.TakeOver("x", "y"); err == nil { + t.Errorf("a warrant for another ask closed the open one") + } + p.Said(asker.Refused, asks.Warrant{Ask: id, Outcome: asks.OutcomeRefused}, nil) + // A warrant for an ask that is not the open one switches nothing. + if err := p.Warranted(yes, warrant("take-over-other")); err == nil { + t.Errorf("a warrant for an ask that is not open was acted on") + } + // Past its expiry and a margin the part closes it itself; the clock moves, not a timer. + id = asked("take-over") + now := p.Now + p.Now = func() time.Time { return now().Add(25 * time.Hour) } + p.Compose() + p.Now = now + if l := last(); l.Ask != id || l.Said != string(asks.OutcomeExpired) { + t.Errorf("an expired ask was not closed: %+v", l) + } + // An ask that could not be published is closed as refused, its half-made record taken away, and is not retried. + fa.fail = true + id = asked("take-over") + p.Compose() + p.Compose() + if l := last(); l.Ask != id || l.Said != "refused" || !slices.Contains(forgot, id) { + t.Errorf("a failed ask stays open: %+v, forgot %v", l, forgot) + } + fa.fail = false asked("take-over") }) @@ -1207,9 +1258,29 @@ func TestGrants(t *testing.T) { }) } -type fakeAsker struct{ asks []asks.Ask } +type fakeAsker struct { + asks []asks.Ask + wheres map[string]*asks.Warrant // the router's warrants, by ask + fail bool +} -func (f *fakeAsker) Ask(a asks.Ask) error { f.asks = append(f.asks, a); return nil } +func (f *fakeAsker) Ask(a asks.Ask) error { + if f.fail { + return errors.New("the bus refused the publish") + } + f.asks = append(f.asks, a) + return nil +} + +func (f *fakeAsker) Where(id string) (string, *asks.Warrant, error) { return "open", f.wheres[id], nil } + +// Take hands the warrant to act as the SDK's asker does for one that chose, and records any other. +func (f *fakeAsker) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) { + if w.Outcome != asks.OutcomeChosen { + return asker.Recorded, nil + } + return asker.Acted, act(asks.Option{ID: w.Option}, w) +} func nonNil(s []string) []string { if s == nil { diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 6fbf8da..f864fcc 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "git.novox.be/novox/mesh-sdk/go/asks" @@ -123,7 +124,12 @@ type Part struct { Asker Asker // Say says a condition to the operator: the process's; nil in the tools. Say func(asks.Condition) error - Now func() time.Time + // Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix. + Forget func(id string) error + Nonce func() string + // deciding is set while a take-over is decided, so the composition it makes asks and reconciles nothing. + deciding atomic.Bool + Now func() time.Time cache Manifests mu sync.Mutex @@ -261,8 +267,13 @@ func (p *Part) Compose() Result { res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be written: %v", StateTakeover, err)) } } - if err := p.RaiseRequested(); err != nil { - res.Errors = append(res.Errors, err.Error()) + if !p.deciding.Load() { + if err := p.RaiseRequested(); err != nil { + res.Errors = append(res.Errors, err.Error()) + } + if err := p.Reconcile(); err != nil { + res.Errors = append(res.Errors, err.Error()) + } } return res } diff --git a/modules/mesh-grants/internal/grants/takeover.go b/modules/mesh-grants/internal/grants/takeover.go index 939e122..4fe6b41 100644 --- a/modules/mesh-grants/internal/grants/takeover.go +++ b/modules/mesh-grants/internal/grants/takeover.go @@ -1,6 +1,8 @@ package grants import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "sort" @@ -36,7 +38,9 @@ type Switch struct { State string `json:"state"` // Open is the id of the ask the operator has not answered yet, empty when none. Open string `json:"open,omitempty"` - Acts []Act `json:"acts"` + // Refusing are the acts whose refusal condition was said and not cleared yet. + Refusing []string `json:"refusing,omitempty"` + Acts []Act `json:"acts"` } // Act is one take-over or hand-back asked, answered, done or refused. @@ -59,13 +63,27 @@ type Request struct { Why string `json:"why,omitempty"` At time.Time `json:"at"` Raised bool `json:"raised,omitempty"` + // RaisedAt is when it was asked, and Failed why asking it failed (the ask is then closed as refused). + RaisedAt time.Time `json:"raised_at,omitempty"` + Failed string `json:"failed,omitempty"` } // Asker is the client of the router the part asks through (mesh-sdk asker.Client). type Asker interface { Ask(a asks.Ask) error + // Where is the router's record of an ask: its state and its warrant, if any. + Where(id string) (string, *asks.Warrant, error) + // Take hands a warrant to act once (a warrant the process missed is caught up through it). + Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) } +// askMargin is how long past an ask's expiry the router is given to say it ended, before the part closes it itself; +// recordGrace how long a just-asked ask may be missing from the router's record. +const ( + askMargin = 5 * time.Minute + recordGrace = time.Minute +) + // keptActs is how many acts the switch keeps, newest last. const keptActs = 50 @@ -109,6 +127,16 @@ func (p *Part) TakeOver(caller, why string) (any, error) { return p.request(ActT // HandBack is the tool: it asks the operator to switch the bus node back to the controller's list. func (p *Part) HandBack(caller, why string) (any, error) { return p.request(ActHandBack, caller, why) } +// nonce is four hex digits that keep two asks of one second apart. +func (p *Part) nonce() string { + if p.Nonce != nil { + return p.Nonce() + } + var b [2]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + func (p *Part) request(act, caller, why string) (any, error) { s, err := p.readSwitch() if err != nil { @@ -127,7 +155,7 @@ func (p *Part) request(act, caller, why string) (any, error) { if act == ActTakeOver && s.State == TookOver { return nil, fmt.Errorf("the bus node follows the grants part's list already") } - a.Ask = act + "-" + p.now().Format("20060102t150405") + a.Ask = act + "-" + p.now().Format("20060102t150405") + "-" + p.nonce() a.Said = "asked" req, _ := json.Marshal(Request{ID: a.Ask, Act: act, By: caller, Why: why, At: a.At}) if _, err := p.Store.Put(StateTakeover, KeyRequest, req); err != nil { @@ -140,7 +168,7 @@ func (p *Part) request(act, caller, why string) (any, error) { } // The process asks at its next pass: wake it. _, _ = p.AskCompose(caller) - return map[string]any{"asked": a.Ask, "says": "the part's process asks the operator on their channel; only the operator's yes switches. grants_take_over_state shows where it stands"}, nil + return map[string]any{"asked": a.Ask, "says": "the part's process asks the operator on their channel; only the operator's yes records the switch, and it takes effect when the bus node loads the grants part's list. grants_take_over_state shows where it stands"}, nil } // RaiseRequested is the process's: it asks the operator the request a tool left, once. Where the part has no asker (the @@ -157,17 +185,30 @@ func (p *Part) RaiseRequested() error { return nil } var r Request - if json.Unmarshal(raw, &r) != nil || r.Raised || r.ID == "" { + if json.Unmarshal(raw, &r) != nil || r.Raised || r.Failed != "" || r.ID == "" { return nil } + if s, err := p.readSwitch(); err != nil || s.Open != r.ID { + return nil // an ask no longer open is not asked + } ask, err := p.AskOf(r) if err != nil { return err } if err := p.Asker.Ask(ask); err != nil { + // Closed as refused, and the half-made record the asker kept before publishing is taken away, so nothing stays open. + r.Failed = err.Error() + data, _ := json.Marshal(r) + _, _ = p.Store.Put(StateTakeover, KeyRequest, data) + if p.Forget != nil { + _ = p.Forget(r.ID) + } + if s, rerr := p.readSwitch(); rerr == nil { + _ = p.finish(s, Act{Act: r.Act, By: "operator", At: p.now(), Ask: r.ID, Said: "refused", Refused: "the operator could not be asked: " + err.Error()}) + } return fmt.Errorf("the operator could not be asked the %s: %v", r.Act, err) } - r.Raised = true + r.Raised, r.RaisedAt = true, p.now() data, _ := json.Marshal(r) if _, err := p.Store.Put(StateTakeover, KeyRequest, data); err != nil { return fmt.Errorf("the ask %s was asked and its request could not be marked: %v", r.ID, err) @@ -176,13 +217,78 @@ func (p *Part) RaiseRequested() error { return nil } +// Reconcile closes an ask that can stay open for ever, once per pass while one is open: a warrant the process missed is +// caught up from the router's record, one that ended with none is closed, and one past its expiry and a margin is closed +// as expired by the part itself. +func (p *Part) Reconcile() error { + if p.Asker == nil { + return nil + } + s, err := p.readSwitch() + if err != nil || s.Open == "" { + return err + } + act := actOf(s.Open) + at := p.now() + for _, a := range s.Acts { + if a.Ask == s.Open { + at = a.At + break + } + } + state, w, err := p.Asker.Where(s.Open) + if err != nil { + return fmt.Errorf("the router's record of the ask %s cannot be read: %v", s.Open, err) + } + if w != nil { + h, herr := p.Asker.Take(*w, p.Warranted) + p.Said(h, *w, herr) + if cur, rerr := p.readSwitch(); rerr == nil && cur.Open == s.Open { + _ = p.finish(cur, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: string(w.Outcome)}) + } + return nil + } + raw, _, found, _ := p.Store.Get(StateTakeover, KeyRequest) + var r Request + if found { + _ = json.Unmarshal(raw, &r) + } + switch { + case p.now().After(at.Add(asks.ApproveLasts + askMargin)): + return p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: string(asks.OutcomeExpired)}) + case state == "" && r.ID == s.Open && r.Raised && p.now().After(r.RaisedAt.Add(recordGrace)): + return p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: "ended", Refused: "the router holds no record of the ask"}) + } + return nil +} + +func actOf(id string) string { + if strings.HasPrefix(id, ActHandBack+"-") { + return ActHandBack + } + return ActTakeOver +} + +// Following says whether the bus node has loaded the grants part's list: "yes" when its loaded digest equals the part's, +// "not yet" when it loaded another, "unknown" when its have says none. +func (p *Part) Following() string { + st := p.Last().UserList + if st.Loaded == nil || st.Digest == "" { + return "unknown: the bus module's have names no loaded list" + } + if st.Loaded.Wanted == st.Digest || st.Loaded.Digest == st.Digest { + return "yes" + } + return "not yet: the bus node loaded another list" +} + // AskOf is the ask of a request, in the words the operator reads: what changes, why, what it risks. Its yes binds the act. func (p *Part) AskOf(r Request) (asks.Ask, error) { - verb, headline, does, stays, expl := "grants_take_over", "Switch the bus's user list to the grants part?", "The grants part's list becomes the bus's.", - "The controller's list stays in force.", "The grants part has composed the user list the bus holds today, and it can take over writing it.\n"+ + verb, headline, does, stays, expl := "grants_take_over", "Switch the bus's user list to the grants part?", "The switch is recorded; it takes effect when the bus node loads the grants part's list.", + "The controller's list stays in force.", "The grants part has composed the user list the bus holds today. Your yes records the switch; it takes effect when the bus node loads the part's list.\n"+ "If the lists differ in any user, or a user has no hash in the vault, the switch is refused and you are told which." if r.Act == ActHandBack { - verb, headline, does, stays, expl = "grants_hand_back", "Give the bus's user list back to the controller?", "The controller's list becomes the bus's again.", + verb, headline, does, stays, expl = "grants_hand_back", "Give the bus's user list back to the controller?", "The switch back is recorded; it takes effect when the bus node loads the controller's list.", "The grants part's list stays in force.", "The bus follows the grants part's list now.\nThe controller still writes its list until it loses its lease, so the bus can follow it again." } bind, err := asks.ActDigest(asks.Act{"verb": verb, "module": "mesh-grants", "ask": r.ID}) @@ -207,6 +313,9 @@ func (p *Part) Warranted(o asks.Option, w asks.Warrant) error { if strings.HasPrefix(w.Ask, ActHandBack+"-") { act = ActHandBack } + if s.Open != w.Ask { + return fmt.Errorf("the warrant answers the ask %s, and the ask open is %q: nothing is switched", w.Ask, s.Open) + } a := Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask} if o.ID != "yes" { a.Said = "declined" @@ -215,15 +324,19 @@ func (p *Part) Warranted(o asks.Option, w asks.Warrant) error { var reasons []string if act == ActTakeOver { reasons = p.Decide() + if s, err = p.readSwitch(); err != nil { // Decide clears a refusal and writes the switch: read it again + return err + } } else if s.State == Shadow { reasons = []string{"nothing to hand back: the bus node follows the controller's list already"} } if len(reasons) > 0 { a.Said, a.Refused = "refused", strings.Join(reasons, "; ") - p.sayRefused(act, a.Refused) + p.sayRefused(&s, act, a.Refused) return p.finish(s, a) } - a.Said = "switched" + p.clearRefused(&s) + a.Said = "recorded" if act == ActTakeOver { s.State = TookOver } else { @@ -238,18 +351,11 @@ func (p *Part) Warranted(o asks.Option, w asks.Warrant) error { // Said is the process's note of an ask that ended with no yes or no (expired, or refused by the router): the switch // stays, and the ask is closed so the operator can be asked again. func (p *Part) Said(h asker.Handled, w asks.Warrant, _ error) { - if h != asker.Recorded { - return - } s, err := p.readSwitch() if err != nil || s.Open != w.Ask { return } - act := ActTakeOver - if strings.HasPrefix(w.Ask, ActHandBack+"-") { - act = ActHandBack - } - _ = p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask, Said: string(w.Outcome)}) + _ = p.finish(s, Act{Act: actOf(w.Ask), By: "operator", At: p.now(), Ask: w.Ask, Said: string(w.Outcome) + " (" + string(h) + ")"}) } func (p *Part) finish(s Switch, a Act) error { @@ -265,7 +371,9 @@ func (p *Part) finish(s Switch, a Act) error { // an enrolment user or a person whose records are in the controller's store alone), and any user with no hash in the vault. // Nothing here but what is true now: a take-over is refused for these and for no other reason. Empty means none. func (p *Part) Decide() []string { + p.deciding.Store(true) res := p.Compose() + p.deciding.Store(false) var reasons []string if c := res.UserList.Compare; !c.Equal() { if c.Unread != "" { @@ -287,6 +395,12 @@ func (p *Part) Decide() []string { reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s", len(unhashed), strings.Join(unhashed, ", "))) } + if len(reasons) == 0 { + if s, err := p.readSwitch(); err == nil && len(s.Refusing) > 0 { + p.clearRefused(&s) + _ = p.writeSwitch(s) + } + } return reasons } @@ -299,15 +413,33 @@ func contains(s []string, v string) bool { return false } -// sayRefused tells the operator a yes was given and the switch refused, with the reasons (the users that differ named). -func (p *Part) sayRefused(act, why string) { +// sayRefused tells the operator a yes was given and the switch refused, with the reasons (the users that differ named). The +// condition stays raised until a switch is recorded or a later Decide finds no reason (clearRefused). +func (p *Part) sayRefused(s *Switch, act, why string) { if p.Say == nil { return } c := asks.Condition{Key: "mesh-grants." + act + "-refused", Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}, State: asks.StateRaised, Summary: act + " refused: " + why, Headline: "The user list switch was refused", Needs: "a look at why", Explanation: asks.NeedsYou + "a look at why. You said yes to the switch and it was refused: " + why, Resolved: "the switch is asked again and goes through"} - _ = p.Say(c) + if p.Say(c) == nil && !contains(s.Refusing, act) { + s.Refusing = append(s.Refusing, act) + } +} + +// clearRefused clears every refusal condition said and not cleared: on a recorded switch, a hand-back, or a Decide with no reason. +func (p *Part) clearRefused(s *Switch) { + if p.Say == nil { + return + } + var left []string + for _, act := range s.Refusing { + c := asks.Condition{Key: "mesh-grants." + act + "-refused", Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}, State: asks.StateCleared} + if p.Say(c) != nil { + left = append(left, act) + } + } + s.Refusing = left } // TakeOverState is the tool take-over-state: the switch, the request, and what became of each ask. @@ -316,12 +448,10 @@ func (p *Part) TakeOverState() (any, error) { if err != nil { return nil, err } - out := map[string]any{"state": s.State, "open": s.Open, "acts": s.Acts} + out := map[string]any{"state": s.State, "open": s.Open, "acts": s.Acts, "bus_node_loaded_grants_list": p.Following(), + "says": "a recorded switch takes effect when the bus node loads the grants part's list; bus_node_loaded_grants_list says whether it has (after a hand-back the controller's list is meant, and it then reads not yet)"} if raw, _, found, err := p.Store.Get(StateTakeover, KeyRequest); err == nil && found { out["request"] = json.RawMessage(raw) } - if s.Open == "" { - out["says"] = "no ask is open" - } return out, nil } diff --git a/modules/mesh-grants/internal/grants/userlist.go b/modules/mesh-grants/internal/grants/userlist.go index a9bd151..edb44fe 100644 --- a/modules/mesh-grants/internal/grants/userlist.go +++ b/modules/mesh-grants/internal/grants/userlist.go @@ -339,13 +339,13 @@ type Excuse struct { // Excusing removes from the differences the subjects f excuses (added: the shadow grants it and the loaded list does not), // naming each in Excused; a user left with no difference counts as the same. Pure. -func (c Comparison) Excusing(f func(subject string, added bool) string) Comparison { +func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool) string) Comparison { var differ []UserDiff for _, d := range c.Differ { - keep := func(list []string, added bool) []string { + keep := func(list []string, added, subscribe bool) []string { var out []string for _, s := range list { - if why := f(s, added); why != "" { + if why := f(d.User, s, added, subscribe); why != "" { c.Excused = append(c.Excused, Excuse{User: d.User, Subject: s, Why: why}) continue } @@ -353,8 +353,8 @@ func (c Comparison) Excusing(f func(subject string, added bool) string) Comparis } return out } - d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true), keep(d.PublishDropped, false) - d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true), keep(d.SubscribeDropped, false) + d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true, false), keep(d.PublishDropped, false, false) + d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true, true), keep(d.SubscribeDropped, false, true) if len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped) == 0 && d.Responses == "" { c.Same++ continue @@ -374,48 +374,51 @@ var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.> // ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the // seat catalogue retired on purpose, which the shadow no longer grants, is no difference; and what the shadow grants more // for a mesh seat that is claimed on several machines (the standing gap seat holdings: it composes the seat as held on each) -// is named and not counted, since a subscription to a seat's verbs on a machine whose holder does not answer them harms -// nothing and is gone when holdings follow the catalogue. Nothing else is excused: a grant the shadow gives for any other -// reason, or the loss of any other subject, keeps the lists apart. -func ExcusedBy(multiHeld []string) func(subject string, added bool) string { - return func(subject string, added bool) string { +// is named and not counted, for a user on a machine that claims the seat and only as a subscription (what it serves), since +// a subscription to a seat's verbs on a machine whose holder does not answer them harms nothing and is gone when holdings +// follow the catalogue. Nothing else is excused: an added publish, a grant of a user on another machine, or the loss of any +// other subject, keeps the lists apart. +func ExcusedBy(multiHeld map[string][]string) func(user, subject string, added, subscribe bool) string { + // holds says the user is on a machine where the seat is claimed: the machine's tool runner, or a module of it. + holds := func(user, seat string) bool { + node, _, _ := strings.Cut(user, ".") + return slices.Contains(multiHeld[seat], node) + } + return func(user, subject string, added, subscribe bool) string { if m := seatSubject.FindStringSubmatch(subject); m != nil { - if !added && retiredVerb(m[1], m[2]) { + if !added && subscribe && retiredVerb(m[1], m[2]) { return "a verb the seat catalogue retired" } - if added && slices.Contains(multiHeld, m[1]) { + if added && subscribe && holds(user, m[1]) { return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue" } } - if m := seatService.FindStringSubmatch(subject); m != nil && added && slices.Contains(multiHeld, m[1]) { + if m := seatService.FindStringSubmatch(subject); m != nil && added && subscribe && holds(user, m[1]) { return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue" } return "" } } -// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, sorted. -func MultiHeld(r buscompose.Records) []string { - nodes := map[string]map[string]bool{} +// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, each with the +// machines that claim it. +func MultiHeld(r buscompose.Records) map[string][]string { + nodes := map[string][]string{} for node, declared := range r.Assigned { for _, d := range declared { for _, s := range d.Holds { - if s.Scope != "" && s.Scope != catalogue.ScopeMesh { - continue + if (s.Scope == "" || s.Scope == catalogue.ScopeMesh) && !slices.Contains(nodes[s.Name], node) { + nodes[s.Name] = append(nodes[s.Name], node) } - if nodes[s.Name] == nil { - nodes[s.Name] = map[string]bool{} - } - nodes[s.Name][node] = true } } } - var out []string for seat, ns := range nodes { - if len(ns) > 1 { - out = append(out, seat) + if len(ns) < 2 { + delete(nodes, seat) + continue } + sort.Strings(ns) } - sort.Strings(out) - return out + return nodes } From a092ee9ff9df2f496ee3fb7251ad5120f5b6e69d Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:59:41 +0100 Subject: [PATCH 08/11] mesh-grants writes the user list sealed to the bus node and names it in user-list.sealed; the switch turns that key; take-over state says switched only once the bus node loaded it (ADR 0547 clauses 7, 8; issue 1945) The bus node can only take over a list that exists: each pass composes the accounts block with the vault's hashes, seals it to the key the bus node says in its have, puts it in the part's own object bucket by digest and writes the key with digest, revision, user count, users left out and which list to follow. take-over is refused, naming why, while no list is sealed to the bus node's current key or the loaded list does not grant the bus node its read. Review of fix/1945-takeover-blockers: no responders on the router's record is an error, not 'no record'; an answer whose record cannot be reached is not closed; an ask that failed to publish is taken back at the router; an error acting on an answer is logged. Pins mesh-sdk 175a5567. --- modules/mesh-grants/cmd/mesh-grants/asking.go | 5 +- modules/mesh-grants/cmd/mesh-grants/main.go | 10 +- modules/mesh-grants/cmd/mesh-grants/sealed.go | 55 +++++ modules/mesh-grants/go.mod | 2 +- modules/mesh-grants/go.sum | 2 + modules/mesh-grants/grants_test.go | 113 ++++++++- modules/mesh-grants/internal/grants/part.go | 18 +- modules/mesh-grants/internal/grants/sealed.go | 231 ++++++++++++++++++ .../mesh-grants/internal/grants/takeover.go | 48 +++- 9 files changed, 463 insertions(+), 21 deletions(-) create mode 100644 modules/mesh-grants/cmd/mesh-grants/sealed.go create mode 100644 modules/mesh-grants/internal/grants/sealed.go diff --git a/modules/mesh-grants/cmd/mesh-grants/asking.go b/modules/mesh-grants/cmd/mesh-grants/asking.go index 15db062..3095d96 100644 --- a/modules/mesh-grants/cmd/mesh-grants/asking.go +++ b/modules/mesh-grants/cmd/mesh-grants/asking.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" "github.com/nats-io/nats.go" @@ -40,8 +41,10 @@ func recordVia(ctx context.Context, conn *nats.Conn, module string) func(id stri ctx, cancel := context.WithTimeout(ctx, recordWithin) defer cancel() msg, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_asks.$KV.asks."+module+"."+id, nil) + // No responders is the bucket out of reach for a moment, never "no record": read as none, a live ask would be + // closed by Reconcile a minute later and the operator's yes lost. if errors.Is(err, nats.ErrNoResponders) { - return nil, nil + return nil, fmt.Errorf("the router's record of %s cannot be reached: %w", id, err) } if err != nil { return nil, err diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index 365de24..d971edc 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -7,8 +7,9 @@ // module placed since cannot serve its seat or reach a new state. Each is published on the assignments stream only when // it differs from the one last published there. **A bucket, log or stream a manifest declares that the bus lacks is // created** (the operator, 2026-10-26 08:54; issue 1790), once per manifest, with the manifest's limits, on the event -// that brought the manifest; one the bus holds is never changed. **The user list, the rest of the assertions and the -// grants stay in shadow** until +// that brought the manifest; one the bus holds is never changed. **The user list is written sealed to the bus node** +// (ADR 0547 clause 7) as an object of the part's own bucket named in `user-list.sealed`, which the bus node follows only +// from take-over. **The rest of the assertions and the grants stay in shadow** until // the operator's take-over, asked on their channel and run on their yes after its refusals: they are composed, compared with what the bus loaded // and holds (through the bus module's own read tools) and kept in the part's own state, and take no effect. // @@ -116,7 +117,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } bus := newBusReader(ctx, js) f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn}, - Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}} + Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}, + Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf} f.Watch = bus // The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on // the warrant once (mesh-sdk asker), after its refusals. @@ -149,7 +151,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro }, jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { logger.Printf("hearing the bus: %v", err) })) return err } - logger.Printf("started on the account %s: publishing memberships and creating the declared buckets, logs and streams the bus lacks; the user list, grants and any change to a held stream in shadow", account.User) + logger.Printf("started on the account %s: publishing memberships and creating the declared buckets, logs and streams the bus lacks; and writing the user list sealed to the bus node, which the bus node follows from take-over; grants and any change to a held stream in shadow", account.User) f.Run(ctx) return nil } diff --git a/modules/mesh-grants/cmd/mesh-grants/sealed.go b/modules/mesh-grants/cmd/mesh-grants/sealed.go new file mode 100644 index 0000000..1c8156e --- /dev/null +++ b/modules/mesh-grants/cmd/mesh-grants/sealed.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// sealedObjects is the part's object bucket of user lists sealed to the bus node (novox/hq ADR 0547 clause 7), created on +// its first put: the composer grants the part its create, its puts and deletes, and nothing of another bucket. +type sealedObjects struct { + ctx context.Context + js jetstream.JetStream +} + +func (o sealedObjects) store(ctx context.Context) (jetstream.ObjectStore, error) { + s, err := o.js.ObjectStore(ctx, delivery.GrantsUserListObjects) + if errors.Is(err, jetstream.ErrBucketNotFound) { + s, err = o.js.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: delivery.GrantsUserListObjects, + Description: "the bus's user list composed by the grants part, each sealed to the bus node's key under the hex of its digest (novox/hq ADR 0547 clause 7): written by mesh-grants alone, read by name by the node-engine of the node holding mesh-broker", + Storage: jetstream.FileStorage, MaxBytes: 64 << 20}) + } + if err != nil { + return nil, fmt.Errorf("the object bucket %s: %w", delivery.GrantsUserListObjects, err) + } + return s, nil +} + +func (o sealedObjects) PutObject(name string, data []byte) error { + ctx, cancel := context.WithTimeout(o.ctx, requestWithin) + defer cancel() + s, err := o.store(ctx) + if err != nil { + return err + } + _, err = s.PutBytes(ctx, name, data) + return err +} + +func (o sealedObjects) DeleteObject(name string) error { + ctx, cancel := context.WithTimeout(o.ctx, requestWithin) + defer cancel() + s, err := o.store(ctx) + if err != nil { + return err + } + if err := s.Delete(ctx, name); err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) { + return err + } + return nil +} diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index 393b876..6ef66ab 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026080443-136fa8b2a896 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index 919fd4e..9c3f761 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026080443-136fa8b2a896 h1:5rHfru5NJ7SK20QOt8YUJSglcn1gsjtLc9AGmWYGnRQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026080443-136fa8b2a896/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd h1:ef6y09csev6xY/suFrIFGUXbgvDA29kummFtHcIb3B8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 2d7a5a2..e2f8a60 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -21,6 +21,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seal" "mesh-grants/internal/grants" ) @@ -344,6 +345,7 @@ func TestGrants(t *testing.T) { createOnly := filepath.Join("cmd", "mesh-grants", "create.go") // And the operator's asks and conditions, on the seat operator-channel's subjects, in asking.go (ADR 0547 clause 8). asksOnly := filepath.Join("cmd", "mesh-grants", "asking.go") + sealedOnly := filepath.Join("cmd", "mesh-grants", "sealed.go") files := 0 for _, root := range []string{"cmd", "internal"} { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { @@ -364,6 +366,13 @@ func TestGrants(t *testing.T) { if n := strings.Count(string(src), "CreateStream"); (path == createOnly && n != 1) || (path != createOnly && n != 0) { t.Errorf("%s holds CreateStream %d times: only create.go creates, once", path, n) } + // The one object bucket: the part's own, of the user list sealed to the bus node, in sealed.go (ADR 0547 clause 7). + if path == sealedOnly { + if strings.Count(string(src), "CreateObjectStore(") != 1 || !strings.Contains(string(src), "delivery.GrantsUserListObjects") { + t.Errorf("sealed.go creates other than the part's one object bucket") + } + src = []byte(strings.ReplaceAll(string(src), "CreateObjectStore", "")) + } for _, f := range forbidden { if strings.Contains(string(src), f) { t.Errorf("%s holds %s: the part holds no timer and creates or asserts nothing", path, f) @@ -730,6 +739,30 @@ func TestGrants(t *testing.T) { b.loaded = shadowOf(t, b) b.streams = []map[string]any{{"name": "KV_mod-x_kept", "subjects": []string{"$KV.mod-x_kept.>"}, "retention": "limits", "max_per_subject": 3}} p := partOn(b) + // The bus node says its key in its have of the bus module; the process puts the sealed list (ADR 0547 clause 7). + busKey, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + withKey := func(w delivery.UserListWritten) { + w.Key = busKey.PublicKey + b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, UserList: &w}) + } + loadedBefore := delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Wanted: "sha256:aa", Digest: "sha256:aa", Users: 6} + withKey(loadedBefore) + objs := &fakeObjects{} + p.Objects = objs + open := func(g delivery.GrantsUserList) string { + t.Helper() + list, err := delivery.OpenUserList(objs.objects[g.Object], g.Digest, func(bx seal.Box) ([]byte, error) { + out, err := seal.Open(bx, busKey.PrivateKey) + return []byte(out), err + }) + if err != nil { + t.Fatalf("the sealed list does not open with the bus node's key: %v", err) + } + return string(list) + } got, _ := p.UserList() if !strings.Contains(fmt.Sprint(got), "nothing composed yet") { @@ -739,6 +772,20 @@ func TestGrants(t *testing.T) { if !first.Written || first.Revision != 1 || !first.UserList.Compare.Equal() || first.UserList.Users != 6 { t.Fatalf("first composition = %+v", first) } + // Written in shadow, the bus node told to follow the controller's list; every user left out while the vault holds + // no hash; a tool's part (no object bucket) writes none. + if g, _, had := p.Sealed(); !had || g.Follow != delivery.FollowController || g.Revision != 1 || g.Node != "a" || + g.Users != 0 || len(g.LeftOut) != 6 || len(objs.objects) != 1 || !strings.Contains(open(g), "accounts {") { + t.Errorf("the sealed list in shadow = %+v, %d objects", g, len(objs.objects)) + } + if _, _, had := partOn(fixture()).Sealed(); had { + t.Error("a part read before any pass holds a sealed list") + } + if tp := partOn(fixture()); tp.Compose().Sealed == nil { + t.Error("a tool's pass does not say the sealed list") + } else if _, _, had := tp.Sealed(); had { + t.Error("a tool's part wrote the sealed list") + } if len(first.Published) != 3 || len(b.publishes) != 3 { t.Errorf("the first composition published %v, want the three assignments' memberships", first.Published) } @@ -889,6 +936,26 @@ func TestGrants(t *testing.T) { if row.outcome == "recorded" && !strings.Contains(fmt.Sprint(p.TakeOverState()), "bus_node_loaded_grants_list:not yet") { t.Errorf("the state claims the bus node follows with no loaded list: %v", fmt.Sprint(p.TakeOverState())) } + // The key the bus node follows turns with the switch at once, and the state says switched only once the bus + // node loaded the grants part's written list. + g, _, _ := p.Sealed() + if want := map[string]string{grants.TookOver: delivery.FollowGrants, grants.Shadow: delivery.FollowController}[p.SwitchState()]; g.Follow != want { + t.Errorf("the key says follow %s with the switch %s", g.Follow, p.SwitchState()) + } + effect := func() string { st, _ := p.TakeOverState(); return fmt.Sprint(st.(map[string]any)["effect"]) } + if row.outcome == "recorded" && row.act == "take-over" { + if e := effect(); strings.HasPrefix(e, "switched") || !strings.Contains(e, "not in effect") { + t.Errorf("effect before the bus node loaded = %s", e) + } + if users := strings.Count(open(g), "{ user: "); users != g.Users || g.Users == 0 || len(g.LeftOut) != 0 { + t.Errorf("the sealed list holds %d users, the key says %d, left out %v", users, g.Users, g.LeftOut) + } + withKey(delivery.UserListWritten{Source: delivery.GrantsUserListSource, Revision: g.Revision, Wanted: g.Digest, Digest: g.Digest, Users: g.Users, Follows: delivery.FollowGrants}) + if e := effect(); !strings.HasPrefix(e, "switched") { + t.Errorf("effect once the bus node loaded the written list = %s", e) + } + withKey(loadedBefore) + } }) } t.Run("hand-back in shadow is refused at once, with nothing asked", func(t *testing.T) { @@ -943,10 +1010,25 @@ func TestGrants(t *testing.T) { id = asked("take-over") p.Compose() p.Compose() - if l := last(); l.Ask != id || l.Said != "refused" || !slices.Contains(forgot, id) { - t.Errorf("a failed ask stays open: %+v, forgot %v", l, forgot) + if l := last(); l.Ask != id || l.Said != "refused" || !slices.Contains(forgot, id) || !slices.Contains(fa.cancels, id) { + t.Errorf("a failed ask stays open, or is not taken back at the router: %+v, forgot %v, cancelled %v", l, forgot, fa.cancels) } fa.fail = false + // An answer the part's own record cannot take yet (its state out of reach) leaves the ask open, closed by + // nothing, and is taken at a later pass. + id = asked("take-over") + p.Compose() + fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen, Option: "no"} + fa.takeErr = errors.New("nats: no responders available for request") + p.Compose() + if l := last(); l.Ask != id || l.Said != "asked" { + t.Errorf("an answer not taken closed the ask: %+v", l) + } + fa.takeErr = nil + p.Compose() + if l := last(); l.Ask != id || l.Said != "declined" { + t.Errorf("the answer was not taken once its record could be reached: %+v", l) + } asked("take-over") }) @@ -1259,11 +1341,29 @@ func TestGrants(t *testing.T) { } type fakeAsker struct { - asks []asks.Ask - wheres map[string]*asks.Warrant // the router's warrants, by ask - fail bool + asks []asks.Ask + wheres map[string]*asks.Warrant // the router's warrants, by ask + fail bool + cancels []string + // takeErr makes Take fail as the asker does when the part's own record cannot be reached: no handling, an error. + takeErr error } +func (f *fakeAsker) Cancel(id string) error { f.cancels = append(f.cancels, id); return nil } + +// fakeObjects is the part's object bucket of sealed lists. +type fakeObjects struct{ objects map[string][]byte } + +func (o *fakeObjects) PutObject(name string, data []byte) error { + if o.objects == nil { + o.objects = map[string][]byte{} + } + o.objects[name] = data + return nil +} + +func (o *fakeObjects) DeleteObject(name string) error { delete(o.objects, name); return nil } + func (f *fakeAsker) Ask(a asks.Ask) error { if f.fail { return errors.New("the bus refused the publish") @@ -1276,6 +1376,9 @@ func (f *fakeAsker) Where(id string) (string, *asks.Warrant, error) { return "op // Take hands the warrant to act as the SDK's asker does for one that chose, and records any other. func (f *fakeAsker) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) { + if f.takeErr != nil { + return "", f.takeErr + } if w.Outcome != asks.OutcomeChosen { return asker.Recorded, nil } diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index f864fcc..0c074e0 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -101,9 +101,11 @@ type Result struct { Refused []string `json:"refused,omitempty"` // RootNotFree is each machine the composition needed a root-free word for and did not get one, `: not // free, `: a verified sender is composed as from no root-free machine there (issue 1945). - RootNotFree []string `json:"root_not_free,omitempty"` - Errors []string `json:"errors,omitempty"` - PublishedOrAsserted string `json:"published_or_asserted"` + RootNotFree []string `json:"root_not_free,omitempty"` + // Sealed is the list sealed to the bus node as the key `user-list.sealed` names it (ADR 0547 clause 7). + Sealed *SealedState `json:"sealed,omitempty"` + Errors []string `json:"errors,omitempty"` + PublishedOrAsserted string `json:"published_or_asserted"` } // NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships. @@ -122,6 +124,10 @@ type Part struct { // Asker asks the operator (take-over, hand-back): the process's client of the router; nil in the tools, which write // the request and let the process ask it. Asker Asker + // Logf logs what no state or condition says (an error of acting on an answer): the process's; nil in the tools. + Logf func(string, ...any) + // Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools. + Objects Objects // Say says a condition to the operator: the process's; nil in the tools. Say func(asks.Condition) error // Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix. @@ -219,6 +225,12 @@ func (p *Part) Compose() Result { } } } + // The list itself, sealed to the bus node and named in `user-list.sealed` (ADR 0547 clause 7). + if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil { + res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error()) + } else { + p.writeSealed(&res, list, n, leftOut, in) + } ms := Memberships(recs) res.Memberships = len(ms) for _, key := range sortedKeys(ms) { diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go new file mode 100644 index 0000000..82c047a --- /dev/null +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -0,0 +1,231 @@ +package grants + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// **The list the bus node loads from take-over** (novox/hq ADR 0547 clause 7, issue 1945). Each pass composes the bus's +// user list with the vault's hashes (the accounts block, buscompose.ComposeAccounts, as the bus module's file holds it), +// seals it to the bus node's key (the key the node says in its have of the bus module) and puts it as one object of the +// part's object bucket, named by its digest; then writes the key `user-list.sealed` naming it, with the revision, the user +// count, the users left out for want of a hash, and which list the bus node follows (the switch, clause 8). The object +// and the key are written in shadow too: the bus node reads the key and follows the controller's list until the key says +// FollowGrants, so a take-over has a list to load at once. Only the process writes them (Objects); a tool asks it. + +// KeySealed is the key of the state user-list the bus node reads (delivery.GrantsUserListKey). +const KeySealed = delivery.GrantsUserListKey + +// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools. +type Objects interface { + PutObject(name string, data []byte) error + DeleteObject(name string) error +} + +// ComposeList is the bus's user list as the file holds it, with each user's hash from the vault, and the users left out +// for want of one. Pure. +func ComposeList(r buscompose.Records, hashes map[string]HeldHash) ([]byte, int, []string, error) { + principals, err := buscompose.Users(r) + if err != nil { + return nil, 0, nil, err + } + held := map[string]string{} + for user, h := range hashes { + held[user] = h.Hash + } + filled, missing := buscompose.WithPasswords(principals, held) + list, err := buscompose.ComposeAccounts(filled) + if err != nil { + return nil, 0, nil, err + } + slices.Sort(missing) + return []byte(list), len(filled), missing, nil +} + +// Sealed is the part's key `user-list.sealed` as written, and whether there is one. +func (p *Part) Sealed() (delivery.GrantsUserList, uint64, bool) { + raw, rev, found, err := p.Store.Get(StateUserList, KeySealed) + if err != nil || !found { + return delivery.GrantsUserList{}, 0, false + } + g, err := delivery.DecodeGrantsUserList(raw) + if err != nil { + return delivery.GrantsUserList{}, 0, false + } + return g, rev, true +} + +// followOf is which list the switch says the bus node follows. +func followOf(state string) string { + if state == TookOver { + return delivery.FollowGrants + } + return delivery.FollowController +} + +// writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in +// res what it wrote, or why it wrote nothing. +func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs) { + cur, _, had := p.Sealed() + next := cur + next.Follow = followOf(p.SwitchState()) + key := "" + if in.Loaded != nil { + key = in.Loaded.Key + } + digest := delivery.UserListDigest(list) + why := "" + switch { + case p.Objects == nil: + // The tools: the process writes; the follow alone may change here, from a tool's view of the switch, and is not. + res.Sealed = sealedSays(cur, had, "") + return + case in.BusNode == "": + why = "no node runs the bus's module, so the list is sealed to nobody" + case key == "": + why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)" + case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode: + object, err := delivery.SealUserList(list, key) + if err == nil { + err = p.Objects.PutObject(delivery.UserListObjectName(digest), object) + } + if err != nil { + res.Errors = append(res.Errors, "the sealed user list could not be put: "+err.Error()) + res.Sealed = sealedSays(cur, had, "") + return + } + if cur.Digest != digest { + next.Revision++ + } + next.Digest, next.Object, next.SealedTo, next.Node = digest, delivery.UserListObjectName(digest), delivery.SealingKeyID(key), in.BusNode + } + next.Users, next.LeftOut = users, leftOut + if next.Digest == "" { + next.Users, next.LeftOut = 0, nil + } + if had && sameSealed(cur, next) { + res.Sealed = sealedSays(cur, had, why) + return + } + next.At = p.now() + data, err := delivery.EncodeGrantsUserList(next) + if err == nil { + _, err = p.Store.Put(StateUserList, KeySealed, data) + } + if err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeySealed, err)) + res.Sealed = sealedSays(cur, had, "") + return + } + // The object the key named before is taken away once the key names the new one: the bucket keeps the list in force. + if had && cur.Object != "" && cur.Object != next.Object { + if err := p.Objects.DeleteObject(cur.Object); err != nil { + res.Errors = append(res.Errors, "the sealed list before could not be taken away: "+err.Error()) + } + } + res.Sealed = sealedSays(next, true, why) + p.say(res, EventUserList, map[string]any{"digest": next.Digest, "revision": next.Revision, "users": next.Users, + "left_out": len(next.LeftOut), "follow": next.Follow, "sealed_to": next.Node}) +} + +func sameSealed(a, b delivery.GrantsUserList) bool { + a.At, b.At = b.At, a.At + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) +} + +// SealedState is what a pass says of the sealed list: the key as written, and why nothing was written when nothing was. +type SealedState struct { + Written *delivery.GrantsUserList `json:"written,omitempty"` + Why string `json:"why,omitempty"` +} + +func sealedSays(g delivery.GrantsUserList, had bool, why string) *SealedState { + s := &SealedState{Why: why} + if had { + s.Written = &g + } + return s +} + +// syncFollow writes the switch into the key at once, so the bus node turns on the operator's yes, not at the next pass. +func (p *Part) syncFollow(state string) error { + cur, _, had := p.Sealed() + if had && cur.Follow == followOf(state) { + return nil + } + if !had { + cur = delivery.GrantsUserList{} + } + cur.Follow, cur.At = followOf(state), p.now() + data, err := delivery.EncodeGrantsUserList(cur) + if err != nil { + return err + } + _, err = p.Store.Put(StateUserList, KeySealed, data) + return err +} + +// Effect is where the switch stands on the bus node: "switched" only once the bus node's loaded list is the grants part's +// written one (its source the part's key, its digest the written digest); otherwise what it waits for. +func (p *Part) Effect(state string, loaded *delivery.UserListWritten) string { + g, _, had := p.Sealed() + fromGrants := loaded != nil && loaded.Source == delivery.GrantsUserListSource + switch { + case state == TookOver && had && fromGrants && g.Digest != "" && loaded.Digest == g.Digest && loaded.Error == "": + return "switched: the bus node loaded the grants part's list " + short(g.Digest) + " (revision " + fmt.Sprint(g.Revision) + ")" + case state == TookOver && !had || state == TookOver && g.Digest == "": + return "recorded, not in effect: the grants part has written no sealed list for the bus node yet" + case state == TookOver && loaded == nil: + return "recorded, not in effect: the bus module's have names no loaded list" + case state == TookOver && loaded.Error != "": + return "recorded, not in effect: the bus node could not load the grants part's list: " + loaded.Error + case state == TookOver: + return "recorded, not in effect yet: the bus node loaded " + short(loaded.Digest) + " from " + loaded.Source + ", the grants part wrote " + short(g.Digest) + case fromGrants: + return "handed back, not in effect yet: the bus node still follows the grants part's list" + } + return "shadow: the bus node follows the controller's list" +} + +func short(d string) string { + d = strings.TrimPrefix(d, "sha256:") + if len(d) > 12 { + d = d[:12] + } + return d +} + +// followReasons is what a take-over waits for on the sealed list itself: a list written for the bus node, of the list +// composed now, and the loaded list granting the bus node its read (the controller's composer carries ADR 0547 clause 7's +// grants from the operator's second exception; until its delivery the bus node could not read the first list). +func (p *Part) followReasons(res Result, in Inputs) []string { + var out []string + g, _, had := p.Sealed() + switch { + case !had || g.Digest == "": + why := "no list is written" + if res.Sealed != nil && res.Sealed.Why != "" { + why = res.Sealed.Why + } + out = append(out, "the grants part has written no sealed list for the bus node: "+why) + case in.Loaded == nil || in.Loaded.Key == "" || g.SealedTo != delivery.SealingKeyID(in.Loaded.Key): + out = append(out, "the sealed list is not sealed to the key the bus node says now") + } + if in.BusNode != "" { + node := "node." + in.BusNode + for _, d := range res.UserList.Compare.Differ { + if d.User == node && slices.ContainsFunc(d.PublishAdded, func(s string) bool { return strings.Contains(s, delivery.GrantsUserListBucket) }) { + out = append(out, "the loaded list does not grant "+node+" reading the grants part's sealed list (ADR 0547 clause 7): "+ + "the controller's composer carries it from its delivery of the operator's second exception") + } + } + } + return out +} diff --git a/modules/mesh-grants/internal/grants/takeover.go b/modules/mesh-grants/internal/grants/takeover.go index 4fe6b41..6ee843d 100644 --- a/modules/mesh-grants/internal/grants/takeover.go +++ b/modules/mesh-grants/internal/grants/takeover.go @@ -11,6 +11,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // The switch between the controller's user list and the grants part's (ADR 0547 clause 8). The operator's word is a @@ -75,6 +76,8 @@ type Asker interface { Where(id string) (string, *asks.Warrant, error) // Take hands a warrant to act once (a warrant the process missed is caught up through it). Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) + // Cancel takes an ask back at the router, so a copy the operator may still see ends there too. + Cancel(id string) error } // askMargin is how long past an ask's expiry the router is given to say it ended, before the part closes it itself; @@ -203,6 +206,11 @@ func (p *Part) RaiseRequested() error { if p.Forget != nil { _ = p.Forget(r.ID) } + // A publish that timed out may still have reached the router: taken back there, so a yes to it cannot land on an + // ask the part has forgotten. + if cerr := p.Asker.Cancel(r.ID); cerr != nil && p.Logf != nil { + p.Logf("the ask %s that could not be published was not taken back at the router: %v", r.ID, cerr) + } if s, rerr := p.readSwitch(); rerr == nil { _ = p.finish(s, Act{Act: r.Act, By: "operator", At: p.now(), Ask: r.ID, Said: "refused", Refused: "the operator could not be asked: " + err.Error()}) } @@ -242,6 +250,10 @@ func (p *Part) Reconcile() error { } if w != nil { h, herr := p.Asker.Take(*w, p.Warranted) + if h == "" && herr != nil { + // The part's own record of the ask could not be reached: nothing was taken, and the next pass tries again. + return fmt.Errorf("the operator's answer to %s could not be taken yet: %v", s.Open, herr) + } p.Said(h, *w, herr) if cur, rerr := p.readSwitch(); rerr == nil && cur.Open == s.Open { _ = p.finish(cur, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: string(w.Outcome)}) @@ -272,11 +284,12 @@ func actOf(id string) string { // Following says whether the bus node has loaded the grants part's list: "yes" when its loaded digest equals the part's, // "not yet" when it loaded another, "unknown" when its have says none. func (p *Part) Following() string { - st := p.Last().UserList - if st.Loaded == nil || st.Digest == "" { - return "unknown: the bus module's have names no loaded list" + loaded := ReadInputs(p.Read, &p.cache).Loaded + g, _, had := p.Sealed() + if loaded == nil || !had || g.Digest == "" { + return "unknown: the bus module's have names no loaded list, or the grants part wrote none" } - if st.Loaded.Wanted == st.Digest || st.Loaded.Digest == st.Digest { + if loaded.Source == delivery.GrantsUserListSource && loaded.Digest == g.Digest { return "yes" } return "not yet: the bus node loaded another list" @@ -342,15 +355,25 @@ func (p *Part) Warranted(o asks.Option, w asks.Warrant) error { } else { s.State = Shadow } + if err := p.finish(s, a); err != nil { + return err + } + // The bus node turns on the key: written now, so the switch reaches it on the operator's yes (ADR 0547 clause 8). + if err := p.syncFollow(s.State); err != nil { + return fmt.Errorf("the switch is recorded and the key the bus node follows could not be written (the next pass writes it): %w", err) + } if p.Emit != nil { _ = p.Emit.Emit(EventUserList, map[string]any{"switch": s.State}) } - return p.finish(s, a) + return nil } // Said is the process's note of an ask that ended with no yes or no (expired, or refused by the router): the switch // stays, and the ask is closed so the operator can be asked again. -func (p *Part) Said(h asker.Handled, w asks.Warrant, _ error) { +func (p *Part) Said(h asker.Handled, w asks.Warrant, err error) { + if err != nil && p.Logf != nil { + p.Logf("the answer to %s (%s) ended in an error: %v", w.Ask, h, err) + } s, err := p.readSwitch() if err != nil || s.Open != w.Ask { return @@ -383,6 +406,7 @@ func (p *Part) Decide() []string { reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list: %s", len(d), strings.Join(d, ", "))) } } + reasons = append(reasons, p.followReasons(res, ReadInputs(p.Read, &p.cache))...) cr, gone := res.UserList.Credentials, res.UserList.Compare.Gone var unhashed []string for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) { @@ -448,8 +472,18 @@ func (p *Part) TakeOverState() (any, error) { if err != nil { return nil, err } + loaded := ReadInputs(p.Read, &p.cache).Loaded out := map[string]any{"state": s.State, "open": s.Open, "acts": s.Acts, "bus_node_loaded_grants_list": p.Following(), - "says": "a recorded switch takes effect when the bus node loads the grants part's list; bus_node_loaded_grants_list says whether it has (after a hand-back the controller's list is meant, and it then reads not yet)"} + "effect": p.Effect(s.State, loaded), + "says": "a recorded switch takes effect when the bus node loads the grants part's list; effect says switched only once the bus node's loaded digest is the grants part's written one"} + if g, rev, had := p.Sealed(); had { + out["written"] = map[string]any{"digest": g.Digest, "revision": g.Revision, "key_revision": rev, "follow": g.Follow, + "users": g.Users, "left_out": g.LeftOut, "sealed_to": g.Node} + } + if loaded != nil { + out["loaded"] = map[string]any{"source": loaded.Source, "digest": loaded.Digest, "revision": loaded.Revision, + "users": loaded.Users, "follows": loaded.Follows, "at": loaded.At, "error": loaded.Error} + } if raw, _, found, err := p.Store.Get(StateTakeover, KeyRequest); err == nil && found { out["request"] = json.RawMessage(raw) } From 29cfb8c17740360a0ca74203a9fc5501fada47df Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 12:06:58 +0100 Subject: [PATCH 09/11] Pin mesh-sdk at the group's head 83ee7769 (issue 1945) --- modules/mesh-grants/go.mod | 2 +- modules/mesh-grants/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index 6ef66ab..c1e165d 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index 9c3f761..20e4653 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026080443-136fa8b2a896 h1:5rHfru5N git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026080443-136fa8b2a896/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd h1:ef6y09csev6xY/suFrIFGUXbgvDA29kummFtHcIb3B8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee h1:ZdBMkF4WYJi82jb3xYi9Z2lXN/iMTn9r3SFC122ROY4= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From f55f1508964a9a7bba023b5880e5c51ef444d673 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 12:31:31 +0100 Subject: [PATCH 10/11] mesh-grants: after take-over the list in force is not replaced by one composed past a failed read or one dropping its users for want of a hash; the switch's effect says when the bus node does not watch the key (review of the switch, issue 1945). Pins mesh-sdk c2a410bb. --- modules/mesh-grants/go.mod | 2 +- modules/mesh-grants/go.sum | 2 + modules/mesh-grants/grants_test.go | 24 +++++++ modules/mesh-grants/internal/grants/inputs.go | 33 ++++++++-- modules/mesh-grants/internal/grants/sealed.go | 62 ++++++++++++++++++- 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index c1e165d..3d6dc66 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index 20e4653..8f26273 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd h1:ef6y09cs git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee h1:ZdBMkF4WYJi82jb3xYi9Z2lXN/iMTn9r3SFC122ROY4= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 h1:by2zBASLb8h5jA1IiYJWykXwLnHYpuZh/zFNa4NsbOU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index e2f8a60..44e582a 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -955,6 +955,30 @@ func TestGrants(t *testing.T) { t.Errorf("effect once the bus node loaded the written list = %s", e) } withKey(loadedBefore) + // After take-over a list composed past a failed read, or one dropping a user of the list in force for + // want of a hash, is not sealed: the list in force stays, and the pass says why. + b.refused[grants.ReadAccounts] = true + if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a read of this pass failed") { + t.Errorf("a pass past a failed read = %+v", res.Sealed) + } + b.refused[grants.ReadAccounts] = false + if now, _, _ := p.Sealed(); now.Digest != g.Digest || now.Object != g.Object { + t.Errorf("the list in force moved past a failed read: %s, was %s", now.Digest, g.Digest) + } + b.mu.Lock() + hash := b.states[grants.ReadAccounts]["a.mod-x"] + delete(b.states[grants.ReadAccounts], "a.mod-x") + b.mu.Unlock() + if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x would be left out") { + t.Errorf("a pass that drops a.mod-x's hash = %+v", res.Sealed) + } + if now, _, _ := p.Sealed(); now.Digest != g.Digest { + t.Errorf("the list in force moved without a.mod-x: %s", now.Digest) + } + b.put(grants.ReadAccounts, "a.mod-x", json.RawMessage(hash)) + if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest != g.Digest }() { + t.Errorf("the list in force moved with every read and hash back") + } } }) } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index ac46c88..fdb6c03 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -66,6 +66,9 @@ type Caller interface { type Gap struct { Input string `json:"input"` Why string `json:"why"` + // Failed says the gap is a read that failed this pass, not one that stands (ADR 0547 clause 7: after take-over a + // list composed past a failed read is not sealed). + Failed bool `json:"failed,omitempty"` } // Standing gaps: what the controller composes from its own store, which no bus value carries in this milestone. @@ -193,14 +196,14 @@ func (c *Manifests) get(r Reader, digest string) (catalogue.Manifest, error) { func each(r Reader, state string, gaps *[]Gap) map[string][]byte { keys, err := r.Keys(state) if err != nil { - *gaps = append(*gaps, Gap{Input: state, Why: "refused or absent: " + err.Error()}) + *gaps = append(*gaps, Gap{Input: state, Why: "refused or absent: " + err.Error(), Failed: true}) return map[string][]byte{} } out := map[string][]byte{} for _, k := range keys { v, found, err := r.Get(state, k) if err != nil { - *gaps = append(*gaps, Gap{Input: state + " " + k, Why: err.Error()}) + *gaps = append(*gaps, Gap{Input: state + " " + k, Why: err.Error(), Failed: true}) continue } if found { @@ -229,7 +232,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { } haveKeys, err := r.Keys(ReadHave) if err != nil { - in.Gaps = append(in.Gaps, Gap{Input: ReadHave, Why: "refused or absent: " + err.Error()}) + in.Gaps = append(in.Gaps, Gap{Input: ReadHave, Why: "refused or absent: " + err.Error(), Failed: true}) } for _, k := range haveKeys { if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode { @@ -243,7 +246,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { sort.Strings(in.Nodes) if in.BusNode != "" { if raw, found, err := r.Get(ReadHave, in.BusNode+"."+BusModule); err != nil { - in.Gaps = append(in.Gaps, Gap{Input: "the loaded list's have", Why: err.Error()}) + in.Gaps = append(in.Gaps, Gap{Input: "the loaded list's have", Why: err.Error(), Failed: true}) } else if found { if h, err := delivery.DecodeHave(raw); err == nil { in.Loaded = h.UserList @@ -266,11 +269,15 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { // assigned ones, since a seat is declared by one module and held by another. indexed, err := r.Keys(ReadModules) if err != nil { - in.Gaps = append(in.Gaps, Gap{Input: ReadModules, Why: "refused or absent: " + err.Error()}) + in.Gaps = append(in.Gaps, Gap{Input: ReadModules, Why: "refused or absent: " + err.Error(), Failed: true}) } for _, module := range indexed { raw, found, err := r.Get(ReadModules, module) - if err != nil || !found { + if err != nil { + in.Gaps = append(in.Gaps, Gap{Input: ReadModules + " " + module, Why: err.Error(), Failed: true}) + continue + } + if !found { continue } v, err := delivery.DecodeModule(raw) @@ -388,3 +395,17 @@ func gapNamed(gaps []Gap, input string) bool { func sortGaps(g []Gap) { sort.SliceStable(g, func(i, j int) bool { return strings.Compare(g[i].Input, g[j].Input) < 0 }) } + +// ReadFailures names every read this pass could not make and every manifest refused: what a list composed now may lack. +func (in Inputs) ReadFailures() []string { + var out []string + for _, g := range in.Gaps { + if g.Failed { + out = append(out, g.Input+": "+g.Why) + } + } + for _, g := range in.Refused { + out = append(out, g.Input+": "+g.Why) + } + return out +} diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 82c047a..8038135 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -3,6 +3,7 @@ package grants import ( "encoding/json" "fmt" + "regexp" "slices" "strings" @@ -89,6 +90,10 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string why = "no node runs the bus's module, so the list is sealed to nobody" case key == "": why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)" + case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut) != "": + // After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user + // of it for want of a hash: that user's module would be locked out of the bus (review of the switch). + why = p.holdsBack(in, leftOut) case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode: object, err := delivery.SealUserList(list, key) if err == nil { @@ -104,7 +109,9 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string } next.Digest, next.Object, next.SealedTo, next.Node = digest, delivery.UserListObjectName(digest), delivery.SealingKeyID(key), in.BusNode } - next.Users, next.LeftOut = users, leftOut + if next.Digest == digest { + next.Users, next.LeftOut = users, leftOut + } if next.Digest == "" { next.Users, next.LeftOut = 0, nil } @@ -122,6 +129,9 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string res.Sealed = sealedSays(cur, had, "") return } + if next.Digest == digest && cur.Digest != digest { + p.keepWrittenUsers(res, list) + } // The object the key named before is taken away once the key names the new one: the bucket keeps the list in force. if had && cur.Object != "" && cur.Object != next.Object { if err := p.Objects.DeleteObject(cur.Object); err != nil { @@ -184,6 +194,8 @@ func (p *Part) Effect(state string, loaded *delivery.UserListWritten) string { return "recorded, not in effect: the grants part has written no sealed list for the bus node yet" case state == TookOver && loaded == nil: return "recorded, not in effect: the bus module's have names no loaded list" + case state == TookOver && loaded.Unwatched != "" && !fromGrants: + return "recorded, not in effect: the bus node does not watch the grants part's key: " + loaded.Unwatched case state == TookOver && loaded.Error != "": return "recorded, not in effect: the bus node could not load the grants part's list: " + loaded.Error case state == TookOver: @@ -229,3 +241,51 @@ func (p *Part) followReasons(res Result, in Inputs) []string { } return out } + +// KeyWrittenUsers is the key of the state user-list naming the users of the list the key `sealed` names: what a list +// composed after take-over must keep. +const KeyWrittenUsers = "sealed-users" + +func (p *Part) keepWrittenUsers(res *Result, list []byte) { + data, _ := json.Marshal(listUsers(list)) + if _, err := p.Store.Put(StateUserList, KeyWrittenUsers, data); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenUsers, err)) + } +} + +var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`) + +// listUsers names the users of a list as the composer writes it. +func listUsers(list []byte) []string { + out := []string{} + for _, m := range listUser.FindAllSubmatch(list, -1) { + out = append(out, string(m[1])) + } + return out +} + +// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, or +// a user of the list in force is left out for want of a hash. Empty when it may. +func (p *Part) holdsBack(in Inputs, leftOut []string) string { + if f := in.ReadFailures(); len(f) > 0 { + return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" + } + raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers) + if err != nil { + return "held: the users of the list in force cannot be read: " + err.Error() + } + var had []string + if found { + _ = json.Unmarshal(raw, &had) + } + var lost []string + for _, u := range leftOut { + if slices.Contains(had, u) { + lost = append(lost, u) + } + } + if len(lost) > 0 { + return "held: the list in force is kept, since " + strings.Join(lost, ", ") + " would be left out for want of a hash in the vault" + } + return "" +} From 5c76c9d3ac84a3c5bc2bf322938a00129520b5fa Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 12:54:55 +0100 Subject: [PATCH 11/11] mesh-grants: a list in force with no users kept is checked against the users the bus loaded, so the first list after take-over keeps every user (review of the switch, issue 1945) --- modules/mesh-grants/grants_test.go | 11 +++++++++++ modules/mesh-grants/internal/grants/part.go | 15 ++++++++++++++- modules/mesh-grants/internal/grants/sealed.go | 19 +++++++++++++------ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 44e582a..369943e 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -975,6 +975,17 @@ func TestGrants(t *testing.T) { if now, _, _ := p.Sealed(); now.Digest != g.Digest { t.Errorf("the list in force moved without a.mod-x: %s", now.Digest) } + // A list in force written before the part kept its users (no sealed-users): checked against the users the + // bus loaded, so the first list after take-over has the same protection. + b.mu.Lock() + delete(b.own[grants.StateUserList], grants.KeyWrittenUsers) + b.mu.Unlock() + if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x would be left out") { + t.Errorf("a pass with no kept users that drops a.mod-x's hash = %+v", res.Sealed) + } + if now, _, _ := p.Sealed(); now.Digest != g.Digest { + t.Errorf("the list in force moved without a.mod-x and no kept users: %s", now.Digest) + } b.put(grants.ReadAccounts, "a.mod-x", json.RawMessage(hash)) if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest != g.Digest }() { t.Errorf("the list in force moved with every read and hash back") diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 0c074e0..348c9f7 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -229,7 +229,12 @@ func (p *Part) Compose() Result { if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil { res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error()) } else { - p.writeSealed(&res, list, n, leftOut, in) + // The users the bus loaded: what a first list after take-over must keep where the part has not yet written one. + var bus []string + if st.Compare.Unread == "" { + bus = names(loaded) + } + p.writeSealed(&res, list, n, leftOut, in, bus) } ms := Memberships(recs) res.Memberships = len(ms) @@ -342,3 +347,11 @@ func sortedKeys[V any](m map[string]V) []string { sort.Strings(out) return out } + +func names(us []User) []string { + out := make([]string, 0, len(us)) + for _, u := range us { + out = append(out, u.User) + } + return out +} diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 8038135..f960094 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -71,7 +71,7 @@ func followOf(state string) string { // writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in // res what it wrote, or why it wrote nothing. -func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs) { +func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs, bus []string) { cur, _, had := p.Sealed() next := cur next.Follow = followOf(p.SwitchState()) @@ -90,10 +90,10 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string why = "no node runs the bus's module, so the list is sealed to nobody" case key == "": why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)" - case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut) != "": + case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus) != "": // After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user // of it for want of a hash: that user's module would be locked out of the bus (review of the switch). - why = p.holdsBack(in, leftOut) + why = p.holdsBack(in, leftOut, bus) case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode: object, err := delivery.SealUserList(list, key) if err == nil { @@ -265,8 +265,10 @@ func listUsers(list []byte) []string { } // holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, or -// a user of the list in force is left out for want of a hash. Empty when it may. -func (p *Part) holdsBack(in Inputs, leftOut []string) string { +// a user of the list in force is left out for want of a hash. The list in force is the one the part wrote, by the users +// it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when +// they could not be read). Empty when it may. +func (p *Part) holdsBack(in Inputs, leftOut, bus []string) string { if f := in.ReadFailures(); len(f) > 0 { return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" } @@ -275,8 +277,13 @@ func (p *Part) holdsBack(in Inputs, leftOut []string) string { return "held: the users of the list in force cannot be read: " + err.Error() } var had []string - if found { + switch { + case found: _ = json.Unmarshal(raw, &had) + case bus == nil: + return "held: the users of the list in force are not known: the part kept none, and the loaded list could not be read" + default: + had = bus } var lost []string for _, u := range leftOut {