diff --git a/checks/credentialform/main.go b/checks/credentialform/main.go index e5a15729..324175e3 100644 --- a/checks/credentialform/main.go +++ b/checks/credentialform/main.go @@ -1,6 +1,12 @@ // Package credentialform refuses an offer that does not say the form of its credential: an own secret, an issuer, or // `form` pair or none (the lead, 2026-10-29). It reads every modules/*/module.json through mesh-sdk's // CredentialFormProblems. +// +// It refuses a route received as a file too (issue 774, research 072 row 34, the operator on 2026-10-31: "there is only +// 1 path, the other is simply deprecated/invalid"): a route is a provision, served from the seat mesh-provisioner's +// plans alone, and a manifest's `receives.route` is the deprecated path. A module still moving off it is listed in +// stillReceivingRoutes with why; every refusal names them all, and a listed module that no longer receives one is +// refused too, so the list only shrinks. package credentialform import ( @@ -9,6 +15,7 @@ import ( "os" "path/filepath" "sort" + "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -21,6 +28,7 @@ func Problems(root string) ([]string, error) { } sort.Strings(files) var out []string + var receiving []string reads := map[string]bool{} var binders []string for _, f := range files { @@ -33,6 +41,11 @@ func Problems(root string) ([]string, error) { return nil, fmt.Errorf("%s: %w", f, err) } out = append(out, catalogue.CredentialFormProblems(m)...) + if _, received := m.Receives["route"]; received { + receiving = append(receiving, m.Module) + } else if _, listed := stillReceivingRoutes[m.Module]; listed { + out = append(out, fmt.Sprintf("%s no longer receives route; take it off stillReceivingRoutes", m.Module)) + } var kept struct { Reads []string `json:"reads"` State []json.RawMessage `json:"state"` @@ -52,9 +65,22 @@ func Problems(root string) ([]string, error) { out = append(out, fmt.Sprintf("%s provides and keeps a `bound` state, and mesh-provisioner's manifest does not read %s.bound", module, module)) } } + for _, module := range receiving { + if _, listed := stillReceivingRoutes[module]; !listed { + out = append(out, fmt.Sprintf("%s receives route as a file: a route is a provision, served from the seat "+ + "mesh-provisioner's plans alone (issue 774); still on the deprecated path: %s", module, + strings.Join(receiving, ", "))) + } + } return out, nil } +// stillReceivingRoutes is every module still receiving its routes as a file, and why it has not moved yet. +var stillReceivingRoutes = map[string]string{ + "route-adapter": "migration scaffolding writing into a predecessor's proxy (novox/hq ADR 0104), deleted when that " + + "migration ends; it reads the received file its TypeScript adapter turns into the predecessor's route files", +} + // keepsBound says whether a manifest's `state` names `bound`, as a plain name or an object with a name. func keepsBound(state []json.RawMessage) bool { for _, s := range state { diff --git a/checks/credentialform/main_test.go b/checks/credentialform/main_test.go index 33c1c4f4..aebbb6b3 100644 --- a/checks/credentialform/main_test.go +++ b/checks/credentialform/main_test.go @@ -22,22 +22,27 @@ func aCatalogue(t *testing.T, manifests map[string]string) string { return root } -// An offer that says no form is refused and named; one that says pair, none or an own secret passes; and the real -// catalogue is clean. +// An offer that says no form is refused and named; one that says pair, none or an own secret passes; a route received +// as a file is refused but for the module still moving off it; and the real catalogue is clean. func TestEveryOfferSaysTheFormOfItsCredential(t *testing.T) { root := aCatalogue(t, map[string]string{ "silent": `{"module":"silent","provides":[{"name":"thing","scope":"mesh"}]}`, "binder": `{"module":"binder","provides":[{"name":"d","credential":{"form":"pair"}}],"state":["bound"]}`, "mesh-provisioner": `{"module":"mesh-provisioner","reads":["other.bound"]}`, "says": `{"module":"says","provides":[{"name":"a","credential":{"form":"pair"}},{"name":"b","credential":{"form":"none"}},{"name":"c","credential":{"own":"jwt"}}]}`, + // A route received as a file (issue 774, research 072 row 34): refused, naming every module still on it; the one + // listed as still moving passes. + "old-proxy": `{"module":"old-proxy","receives":{"route":"/var/lib/old-proxy/mesh.json"}}`, + "route-adapter": `{"module":"route-adapter","receives":{"route":"/var/lib/route-adapter/mesh.json"}}`, }) got, err := Problems(root) if err != nil { t.Fatal(err) } - if len(got) != 2 || !strings.Contains(got[0], `silent provides "thing"`) || - !strings.Contains(got[1], "binder") || !strings.Contains(got[1], "mesh-provisioner") { - t.Fatalf("refused %v, wanted the silent offer and the binder the provisioner does not read", got) + if len(got) != 3 || !strings.Contains(got[0], `silent provides "thing"`) || + !strings.Contains(got[1], "binder") || !strings.Contains(got[1], "mesh-provisioner") || + !strings.HasPrefix(got[2], "old-proxy receives route") || !strings.Contains(got[2], "old-proxy, route-adapter") { + t.Fatalf("refused %v, wanted the silent offer, the binder the provisioner does not read and the received route", got) } real, err := Problems(filepath.Join("..", "..")) if err != nil { diff --git a/checks/health/go.mod b/checks/health/go.mod index 5ec7f46c..f008c287 100644 --- a/checks/health/go.mod +++ b/checks/health/go.mod @@ -2,4 +2,4 @@ module checks/health go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a diff --git a/checks/health/go.sum b/checks/health/go.sum index f8bfbd2b..0989a83d 100644 --- a/checks/health/go.sum +++ b/checks/health/go.sum @@ -1,2 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/checks/manifests/go.mod b/checks/manifests/go.mod index 6fbb4f39..98f625dc 100644 --- a/checks/manifests/go.mod +++ b/checks/manifests/go.mod @@ -2,4 +2,4 @@ module checks/manifests go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a diff --git a/checks/manifests/go.sum b/checks/manifests/go.sum index fc5109a2..0989a83d 100644 --- a/checks/manifests/go.sum +++ b/checks/manifests/go.sum @@ -1,2 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0 h1:t04baH2Xz/NUsOEV3ctKY59XjiY5WmEuststvH+XeOs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030181247-4c1384faefa0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold.go b/modules/mesh-conditions/cmd/mesh-conditions/hold.go index 8491812e..d51e4308 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold.go @@ -216,26 +216,26 @@ func (k *Keeper) Have(key string, value []byte, deleted bool) error { return k.judge(node, module, "") } -// Placement takes one change of the placement: which module holds which seat on a node. +// Placement takes one change of the placement: which module holds which seat on a node. The keys are read in either +// form, a node key and its module keys or the form before (delivery.PlacementKeys; research 072 row 37). func (k *Keeper) Placement(key string, value []byte, deleted bool) { - node, err := delivery.ParsePlacementKey(key) - if err != nil { - return - } k.mu.Lock() defer k.mu.Unlock() k.init() + node, err := k.placement.Put(key, value, 0, deleted) + if node == "" { + return + } + if err != nil { + k.Logf("the placement of %s is not readable as it stands, so its seats' holders are kept as last read: %v", node, err) + } for hk := range k.holders { if strings.HasPrefix(hk, node+".") { delete(k.holders, hk) } } - if deleted { - return - } - p, err := delivery.DecodePlacement(value) - if err != nil { - k.Logf("the placement of %s is not readable, so its seats' holders are not known: %v", node, err) + p, _, ok := k.placement.Of(node) + if !ok { return } for _, h := range p.Holds { diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go index fa668544..3a454a94 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold_test.go @@ -113,12 +113,19 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) { // A seat's finding is about the module the placement says holds the seat on that node (the lead, 2026-10-29): held // while the assigner is delivered on ace, though the seat is named mesh-placement. r := newRig() - placed, err := delivery.EncodePlacement(delivery.Placement{Node: "ace", Modules: []delivery.PlacedModule{{Module: "mesh-delivery-assigner"}}, + // Research 072 row 37: the placement in its keys per module and node; a stale legacy key beside them is not read. + stale, err := delivery.EncodePlacement(delivery.Placement{Node: "ace"}) + if err != nil { + t.Fatal(err) + } + r.k.Placement("placement.ace", stale, false) + placed, err := delivery.PlacementValues(delivery.Placement{Node: "ace", Modules: []delivery.PlacedModule{{Module: "mesh-delivery-assigner"}}, Holds: []delivery.Holding{{Claim: "mesh-placement", Scope: "mesh", Module: "mesh-delivery-assigner"}}}) if err != nil { t.Fatal(err) } - r.k.Placement("placement.ace", placed, false) + r.k.Placement("ace.mesh-delivery-assigner", placed["ace.mesh-delivery-assigner"], false) + r.k.Placement("ace.node", placed["ace.node"], false) _ = r.k.Want("want.ace.mesh-delivery-assigner", want(ctrlNext, ctrlNextComp), false) _ = r.k.Have("ace.mesh-delivery-assigner", have(ctrlRunning, ctrlComposed, delivery.PhaseRunning, delivery.HealthOK), false) seat, seatRaw := finding(t, selfCheck, health.ScopeSeat, "mesh-placement.ace", "holder-silent", "D3", health.Warning, r.now) diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go index a167631c..1ab5383c 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go @@ -112,8 +112,10 @@ type Keeper struct { haves map[string]delivery.Have // holders are, by `.`, the module the placement says holds the seat there. holders map[string]string - stuck map[string]string - held map[string]held + // placement is the placement's keys as heard, assembled per node. + placement *delivery.PlacementKeys + stuck map[string]string + held map[string]held // passed are the deliveries whose bound passed, by node and module, at the fingerprint wanted then; outOfHold the // line a key raised out of a hold says (hold.go). passed map[string]string @@ -130,6 +132,7 @@ func (k *Keeper) init() { map[string]string{}, map[string]held{}, map[string]clearedMemo{} k.takeover = health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController} k.passed, k.outOfHold, k.holders = map[string]string{}, map[string]string{}, map[string]string{} + k.placement = delivery.NewPlacementKeys() } if k.Now == nil { k.Now = time.Now diff --git a/modules/mesh-conditions/go.mod b/modules/mesh-conditions/go.mod index b941eeac..49955684 100644 --- a/modules/mesh-conditions/go.mod +++ b/modules/mesh-conditions/go.mod @@ -3,7 +3,7 @@ module mesh-conditions go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-conditions/go.sum b/modules/mesh-conditions/go.sum index a2b8e018..8e0c33a9 100644 --- a/modules/mesh-conditions/go.sum +++ b/modules/mesh-conditions/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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-config/cmd/mesh-config/config_test.go b/modules/mesh-config/cmd/mesh-config/config_test.go index b086ff21..61085cd9 100644 --- a/modules/mesh-config/cmd/mesh-config/config_test.go +++ b/modules/mesh-config/cmd/mesh-config/config_test.go @@ -134,34 +134,58 @@ func (m *memStore) unassign(t *testing.T, node, module string) { m.place(t, node, module, false) } +// place writes a node's placement as the seat writes it: its node key and module keys (research 072 row 37), and for +// ace in the form before, `placement.ace`, which a reader still takes while the change is delivered. func (m *memStore) place(t *testing.T, node, module string, on bool) { t.Helper() - type placed struct { - Module string `json:"module"` + keys := map[string][]byte{} + for _, k := range m.keysOf(PlacementRead) { + e, _ := m.Get(PlacementRead, k) + keys[k] = e.Value } - p := struct { - V int `json:"v"` - Node string `json:"node"` - Modules []placed `json:"modules"` - }{V: 1, Node: node} - if e, _ := m.Get(PlacementRead, "placement."+node); e != nil { - if err := json.Unmarshal(e.Value, &p); err != nil { - t.Fatal(err) - } + all, err := delivery.PlacementsOf(keys) + if err != nil { + t.Fatal(err) } - var kept []placed + p := all[node] + p.Node = node + var kept []delivery.PlacedModule for _, x := range p.Modules { if x.Module != module { kept = append(kept, x) } } if on { - kept = append(kept, placed{Module: module}) + kept = append(kept, delivery.PlacedModule{Module: module}) } p.Modules = kept - if err := m.Put(PlacementRead, "placement."+node, p); err != nil { + for _, k := range m.keysOf(PlacementRead) { + if n, _, _, err := delivery.ParsePlacementKey(k); err == nil && n == node { + _ = m.Delete(PlacementRead, k) + } + } + if node == "ace" { + raw, err := delivery.EncodePlacement(p) + if err != nil { + t.Fatal(err) + } + _ = m.Put(PlacementRead, "placement.ace", json.RawMessage(raw)) + return + } + values, err := delivery.PlacementValues(p) + if err != nil { t.Fatal(err) } + for k, raw := range values { + if err := m.Put(PlacementRead, k, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + } +} + +func (m *memStore) keysOf(state string) []string { + keys, _ := m.Keys(state) + return keys } // manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest. diff --git a/modules/mesh-config/cmd/mesh-config/sources.go b/modules/mesh-config/cmd/mesh-config/sources.go index e32b4dda..e5452593 100644 --- a/modules/mesh-config/cmd/mesh-config/sources.go +++ b/modules/mesh-config/cmd/mesh-config/sources.go @@ -70,32 +70,32 @@ func (c *Config) sourcesFrom(source string) (Sources, error) { return s, nil } -// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9): each node's key -// `placement.`. +// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9), from the state's keys in +// either form: a node key and its module keys, or the form before, `placement.` (delivery.PlacementsOf; +// research 072 row 37). By node in name order, each node's modules in the order its placement names them. func (c *Config) readPlacement(s *Sources) error { - placed := map[string]bool{} - return c.each(PlacementRead, func(key string, raw []byte) error { - node, err := delivery.ParsePlacementKey(key) - if err != nil { - return nil // a key of another kind - } - var p struct { - Node string `json:"node"` - Modules []struct { - Module string `json:"module"` - } `json:"modules"` - } - if err := json.Unmarshal(raw, &p); err != nil || p.Node != node { - return fmt.Errorf("the placement %s is not one", key) - } - for _, m := range p.Modules { - if m.Module != "" && !placed[node+"."+m.Module] { - placed[node+"."+m.Module] = true - s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module}) - } - } + keys := map[string][]byte{} + if err := c.each(PlacementRead, func(key string, raw []byte) error { + keys[key] = raw return nil - }) + }); err != nil { + return err + } + placements, err := delivery.PlacementsOf(keys) + if err != nil { + return fmt.Errorf("the placement is not read whole: %w", err) + } + nodes := make([]string, 0, len(placements)) + for node := range placements { + nodes = append(nodes, node) + } + sort.Strings(nodes) + for _, node := range nodes { + for _, module := range placements[node].Assigned() { + s.Assignments = append(s.Assignments, Assignment{Node: node, Module: module}) + } + } + return nil } // readDeclarations reads each placed module's declarations from its manifests as the delivery holds them: the trunk's diff --git a/modules/mesh-config/go.mod b/modules/mesh-config/go.mod index 191237ac..fa83bf03 100644 --- a/modules/mesh-config/go.mod +++ b/modules/mesh-config/go.mod @@ -2,7 +2,7 @@ module mesh-config go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a require ( github.com/klauspost/compress v1.20.0 // indirect diff --git a/modules/mesh-config/go.sum b/modules/mesh-config/go.sum index 991de7e5..707b6b65 100644 --- a/modules/mesh-config/go.sum +++ b/modules/mesh-config/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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-hand-acts/README.md b/modules/mesh-hand-acts/README.md index b435e08d..3a13fdb4 100644 --- a/modules/mesh-hand-acts/README.md +++ b/modules/mesh-hand-acts/README.md @@ -22,5 +22,12 @@ alone writes the log. - `mesh-hand-acts.hand-acts`: both logs, this one and the controller's `mesh-controller_hand-acts` until its lease ends, newest first, with the causes repaired by hand twice within a fortnight; `warrants=true` lists the warrants. +- `hand_acts_copied` and `hand_acts_copy` (research 072 row 26): the controller's hand acts are copied once into this + seat's log, each with its own key, time and author. It runs at the module's start while the old bucket is readable + and no marker (`copied`, key `controller-hand-acts`) says it was done, and by the tool; an act already in the log is + never appended again, and `hand-acts` counts a copied act once. `hand_acts_copied` says whether, when and how many. + The lease write has no hook to run it from, so the start is the trigger; `hand_acts_copy` with `top_up=true`, asked + after the lease write, appends any act the controller wrote since the copy (a finished copy is otherwise a no-op). + No act is written to both logs: every recorder calls this seat from its delivery, and the controller keeps its own acts in its own bucket until it loses its lease. diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go index 6e8579e8..120a736f 100644 --- a/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "regexp" + "slices" "sort" "strconv" "strings" @@ -287,8 +288,18 @@ func (l *Log) HandActs(days int, cause, who string) Listing { since := now.Add(-time.Duration(days) * 24 * time.Hour) out := Listing{Acts: []Act{}, Repeated: map[string]int{}, Days: days, Kept: map[string]int{}} var all []Act + // What was copied from the controller's log is in this one: it is read from this one only (copy.go). + copied := map[string]bool{} for _, log := range []string{ActsLog, ControllerActs} { acts, kept, err := l.read(log, now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin))) + if log == ActsLog && err == nil { + for _, a := range acts { + copied[a.ID] = a.ID != "" + } + } + if log == ControllerActs { + acts = slices.DeleteFunc(acts, func(a Act) bool { return a.ID != "" && copied[a.ID] }) + } if err != nil { if out.Unread == nil { out.Unread = map[string]string{} diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts_test.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts_test.go index 73d42cef..ea501dad 100644 --- a/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts_test.go +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/acts_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "errors" + "fmt" "sort" "strings" "sync" @@ -284,3 +285,87 @@ func TestHandActsReadsBothLogs(t *testing.T) { t.Errorf("warrants answered %v, %v", w, err) } } + +// The controller's hand acts are copied into this seat's log once, each with its own key, time and author, and +// never twice: a marker makes it once, a key already in the log is not appended again, a log that cannot be read or +// written leaves no marker so the next call copies again, and hand-acts counts a copied act once (research 072 row 26). +func TestTheControllersHandActsAreCopiedOnce(t *testing.T) { + base := time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC) + old := []string{fmt.Sprintf("act-%d-1", base.UnixNano()), fmt.Sprintf("act-%d-2", base.UnixNano()+1), + fmt.Sprintf("act-%d-3", base.UnixNano()+2), "warrant-x-y"} + cases := []struct { + name string + already []string // keys in the new log before the copy + oldErr, newErr error + junk bool + wantErr string + wantCopied, wantAlready int + wantMarker bool + }{ + {"all", nil, nil, nil, false, "", 4, 0, true}, + {"finishes a half copy", []string{old[0], old[1]}, nil, nil, false, "", 2, 2, true}, + {"an entry that is no act is copied unchanged", nil, nil, nil, true, "", 5, 0, true}, + {"the old log cannot be read", nil, errors.New("away"), nil, false, "cannot be read", 0, 0, false}, + {"the new log cannot be written", nil, nil, errors.New("full"), false, "could not be copied", 0, 0, false}, + } + for _, tc := range cases { + l, store, _, _ := newLog() + for _, k := range old { + _ = store.Put(ControllerActs, k, Act{ID: k, At: base, By: "ace.mesh-config", Verb: VerbRecord, Why: "w", Cause: "c"}) + } + if tc.junk { + _ = store.Put(ControllerActs, fmt.Sprintf("act-%d-4", base.UnixNano()+3), "not an act") + } + for _, k := range tc.already { + _ = store.Append(ActsLog, k, Act{ID: k}) + } + store.keysErr[ControllerActs], store.keysErr[ActsLog] = tc.oldErr, tc.newErr + st, err := l.Copy("test", false) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("%s: %v", tc.name, err) + } + } else if err != nil || !st.Done || st.Marker.Copied != tc.wantCopied || st.Marker.Already != tc.wantAlready { + t.Fatalf("%s: %+v, %v", tc.name, st, err) + } + _, marked, _ := store.Get(CopiedState, CopyKey) + if marked != tc.wantMarker || len(store.logKeys(ActsLog)) != map[bool]int{true: tc.wantCopied + tc.wantAlready, false: len(tc.already)}[tc.wantMarker] { + t.Errorf("%s: marker %v, log %v", tc.name, marked, store.logKeys(ActsLog)) + } + if !tc.wantMarker { + continue + } + // Each act is kept with its own time and author, as the controller wrote it. + raw, _, _ := store.Last(ActsLog, old[2]) + var kept Act + _ = json.Unmarshal(raw, &kept) + if kept.By != "ace.mesh-config" || !kept.At.Equal(base) { + t.Errorf("%s: kept %+v", tc.name, kept) + } + // Asked again: nothing more is appended, and hand-acts counts each act once. + before := len(store.logKeys(ActsLog)) + if again, err := l.Copy("test", false); err != nil || !again.Done || !strings.Contains(again.Said, "already copied") || + len(store.logKeys(ActsLog)) != before { + t.Errorf("%s: a second copy: %+v, %v", tc.name, again, err) + } + if got := l.HandActs(30, "", ""); got.Kept[ControllerActs] != len(old)+boolInt(tc.junk) || len(got.Acts) != len(old) { + t.Errorf("%s: %d acts listed from %d kept: %+v", tc.name, len(got.Acts), before, got.Kept) + } + // An act the controller wrote since is appended by a top-up, and only that one. + late := fmt.Sprintf("act-%d-9", base.UnixNano()+9) + _ = store.Put(ControllerActs, late, Act{ID: late, At: base, By: "ace.late", Verb: VerbRecord, Why: "w", Cause: "c"}) + if up, err := l.Copy("test", true); err != nil || up.Marker.Copied != 1 || len(store.logKeys(ActsLog)) != before+1 { + t.Errorf("%s: a top-up: %+v, %v", tc.name, up, err) + } + if s, err := l.Copied(); err != nil || !s.Done || s.Marker == nil { + t.Errorf("%s: copied says %+v, %v", tc.name, s, err) + } + } +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/copy.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/copy.go new file mode 100644 index 00000000..0c289388 --- /dev/null +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/copy.go @@ -0,0 +1,183 @@ +package main + +// The one-time copy of the controller's hand-act log into this seat's log (novox/hq research 072 row 26, ADR 0571 +// §13). The controller kept every act of its life in its bucket `mesh-controller_hand-acts` (574 on 2026-10-31); from +// the lease write only this seat reads them, so they are copied once into `acts`, each as the controller wrote it: the +// same key, the same bytes, so its time and its author are what they were. +// +// **When it runs.** The lease write has no hook to run from (it is a person's step, ADR 0561 §6), so the copy runs at +// this module's start while the old log is readable and no marker says it was copied, and on the tool +// `hand_acts_copy`. Both do the same thing, and neither does it twice (a `top_up` of the tool appends only what is new): the marker `controller-hand-acts` of the state +// `copied` is created once, after every act is in the log; an act already in the log (by its key) is never appended +// again, so a copy that stopped half way is finished by the next one. Until the marker exists `hand-acts` hides an +// old act that is already copied, so the two logs never count one act twice. `hand_acts_copied` says where it stands. + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "time" +) + +// CopiedState is the state that holds the marker, and CopyKey the marker's key. +const ( + CopiedState = "copied" + CopyKey = "controller-hand-acts" +) + +// CopyMarker is what the copy leaves behind once it is whole: when, by what, how many acts, and which entries could not +// be read as acts (copied unchanged all the same, so nothing is lost). +type CopyMarker struct { + At time.Time `json:"at"` + By string `json:"by"` + Source string `json:"source"` + Found int `json:"found"` + Copied int `json:"copied"` + Already int `json:"already-there"` + // Unreadable are the keys whose value is not an act; they were copied as they were. + Unreadable []string `json:"unreadable,omitempty"` + FirstKey string `json:"first-key,omitempty"` + LastKey string `json:"last-key,omitempty"` +} + +// CopyStatus is what the tools answer: the marker if the copy is whole, and how the two logs stand now. +type CopyStatus struct { + Done bool `json:"done"` + Marker *CopyMarker `json:"marker,omitempty"` + // Said is a sentence for a person: what this call did or found. + Said string `json:"said"` + // Pending are the old acts not yet in this seat's log (counted only while the copy is not whole). + Pending *int `json:"pending,omitempty"` + Old *int `json:"old-log-keys,omitempty"` + New *int `json:"new-log-entries,omitempty"` +} + +// Copy copies the old log into this one, once. by says what asked (`start` or a caller). With topUp it also runs after +// the marker exists, appending only the acts the log does not hold yet (the controller's, written since the copy), and +// leaves the marker as it was. An old log that cannot be +// read, or an act that cannot be appended, is an error and leaves no marker, so the next call copies again. +func (l *Log) Copy(by string, topUp bool) (CopyStatus, error) { + marked := false + if raw, found, err := l.Store.Get(CopiedState, CopyKey); err != nil { + return CopyStatus{}, fmt.Errorf("whether the controller's hand acts were copied cannot be read: %w", err) + } else if found { + var m CopyMarker + if err := json.Unmarshal(raw, &m); err != nil { + return CopyStatus{}, fmt.Errorf("the copy's marker is not readable: %w", err) + } + if !topUp { + return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("already copied at %s: %d acts of %s, nothing done", + m.At.Format(time.RFC3339), m.Copied+m.Already, m.Source)}, nil + } + marked = true + } + keys, err := l.Store.Keys(ControllerActs) + if err != nil { + return CopyStatus{}, fmt.Errorf("the controller's hand acts cannot be read, so none were copied: %w", err) + } + sortByTime(keys) + m := CopyMarker{By: by, Source: ControllerActs, Found: len(keys)} + l.Logf("[%s] copying the controller's hand acts into %s: %d found in %s", Module, ActsLog, len(keys), ControllerActs) + for i, k := range keys { + raw, found, err := l.Store.Get(ControllerActs, k) + if err != nil { + return CopyStatus{}, fmt.Errorf("%s of %s cannot be read after %d of %d acts were copied: %w", k, ControllerActs, m.Copied, len(keys), err) + } + if !found { + continue // removed since it was listed + } + if _, there, err := l.Store.Last(ActsLog, k); err != nil { + return CopyStatus{}, fmt.Errorf("whether %s is in %s cannot be read, after %d acts were copied: %w", k, ActsLog, m.Copied, err) + } else if there { + m.Already++ + continue + } + var a Act + if json.Unmarshal(raw, &a) != nil { + m.Unreadable = append(m.Unreadable, k) + l.Logf("[%s] %s of %s is not an act; copied unchanged", Module, k, ControllerActs) + } + if err := l.Store.Append(ActsLog, k, json.RawMessage(raw)); err != nil { + return CopyStatus{}, fmt.Errorf("%s could not be copied into %s after %d acts: %w", k, ActsLog, m.Copied, err) + } + m.Copied++ + if m.FirstKey == "" { + m.FirstKey = k + } + m.LastKey = k + if (i+1)%100 == 0 { + l.Logf("[%s] copied %d of %d", Module, i+1, len(keys)) + } + } + m.At = l.Now().UTC() + if marked { + l.Logf("[%s] topped up the copy: %d acts copied, %d already there, of %d found", Module, m.Copied, m.Already, m.Found) + return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("topped up: %d acts copied that the first copy did not hold (%d already there)", m.Copied, m.Already)}, nil + } + if err := l.Store.Create(CopiedState, CopyKey, m); errors.Is(err, ErrExists) { + return CopyStatus{Said: "another instance finished the copy first; nothing more done"}, nil + } else if err != nil { + return CopyStatus{}, fmt.Errorf("every act was copied but the marker could not be written, so the next call checks them again: %w", err) + } + l.Logf("[%s] copied the controller's hand acts: %d copied, %d already there, %d not acts, of %d found (%s .. %s)", + Module, m.Copied, m.Already, len(m.Unreadable), m.Found, m.FirstKey, m.LastKey) + return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("copied %d acts (%d were already there) of %d found in %s", + m.Copied, m.Already, m.Found, ControllerActs)}, nil +} + +// Copied says where the copy stands without doing anything: the marker, or how many old acts wait. +func (l *Log) Copied() (CopyStatus, error) { + raw, found, err := l.Store.Get(CopiedState, CopyKey) + if err != nil { + return CopyStatus{}, fmt.Errorf("the copy's marker cannot be read: %w", err) + } + var st CopyStatus + if found { + var m CopyMarker + if err := json.Unmarshal(raw, &m); err != nil { + return CopyStatus{}, fmt.Errorf("the copy's marker is not readable: %w", err) + } + st = CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("copied at %s by %s: %d copied, %d already there, of %d in %s", + m.At.Format(time.RFC3339), m.By, m.Copied, m.Already, m.Found, m.Source)} + } + if f, err := l.Store.Fill(ActsLog); err == nil { + n := int(f.Entries) + st.New = &n + } + keys, err := l.Store.Keys(ControllerActs) + if err != nil { + if !st.Done { + st.Said = "not copied yet, and the controller's log cannot be read: " + err.Error() + } + return st, nil + } + n := len(keys) + st.Old = &n + if !st.Done { + pending := 0 + for _, k := range keys { + if _, there, err := l.Store.Last(ActsLog, k); err != nil || !there { + pending++ + } + } + st.Pending = &pending + st.Said = fmt.Sprintf("not copied yet: %d of %d old acts wait; hand_acts_copy copies them", pending, n) + } + return st, nil +} + +// sortByTime orders keys oldest first by the time they name, those that name none after, by key. +func sortByTime(keys []string) { + sort.SliceStable(keys, func(i, j int) bool { + ti, oi := keyTime(keys[i]) + tj, oj := keyTime(keys[j]) + switch { + case oi && oj && !ti.Equal(tj): + return ti.Before(tj) + case oi != oj: + return oi + } + return keys[i] < keys[j] + }) +} diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/main.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/main.go index b103fa23..7c1a07c0 100644 --- a/modules/mesh-hand-acts/cmd/mesh-hand-acts/main.go +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/main.go @@ -77,6 +77,15 @@ func (runtimeStore) Fill(log string) (Fill, error) { func main() { l := &Log{Store: runtimeStore{}, Now: time.Now, Logf: logf, Publish: stdio.SeatPublish} + // No hook of the lease write runs this module, so the one-time copy of the controller's hand acts runs at start + // and on the tool hand_acts_copy; the marker makes it once (copy.go). + go func() { + if st, err := l.Copy("start", false); err != nil { + logf("[%s] the copy of the controller's hand acts is NOT done: %v", Module, err) + } else { + logf("[%s] %s", Module, st.Said) + } + }() if err := stdio.Serve("", Tools(l)); err != nil { logf("%v", err) os.Exit(1) diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/manifest_test.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/manifest_test.go index cd19669a..05c9d53b 100644 --- a/modules/mesh-hand-acts/cmd/mesh-hand-acts/manifest_test.go +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/manifest_test.go @@ -47,7 +47,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { t.Errorf("emits %v, reads %v", m.Emits, m.Reads) } readBy := map[string]string{ActsLog: HandActsTool, WarrantsState: HandActsTool, ControllerActs: HandActsTool, - RouterRecords: WarrantedTool} + RouterRecords: WarrantedTool, CopiedState: CopiedTool} tools := map[string]bool{} for _, tool := range Tools(&Log{}) { tools[tool.Name] = true @@ -58,8 +58,8 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { } for _, raw := range m.State { var name string - if json.Unmarshal(raw, &name) != nil || name != WarrantsState { - t.Errorf("the state %s is not the warrants, or ages", raw) + if json.Unmarshal(raw, &name) != nil || (name != WarrantsState && name != CopiedState) { + t.Errorf("the state %s is neither the warrants nor the copy's marker, or ages", raw) } if !tools[readBy[name]] { t.Errorf("the state %s has no tool that reads it", name) diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/tools.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/tools.go index 833db625..ad51f7ed 100644 --- a/modules/mesh-hand-acts/cmd/mesh-hand-acts/tools.go +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/tools.go @@ -21,6 +21,9 @@ const ( HandActTool = Seat + ".hand-act" HandActsTool = Seat + ".hand-acts" WarrantedTool = Seat + ".warranted" + // CopiedTool reads where the one-time copy of the controller's hand acts stands, CopyTool does it (copy.go). + CopiedTool = "hand_acts_copied" + CopyTool = "hand_acts_copy" ) func obj(props map[string]any, required ...string) map[string]any { @@ -70,6 +73,21 @@ func Tools(l *Log) []stdio.Tool { Run: func(a map[string]any) (any, error) { return l.Warranted(stdio.CallerOf(a), arg(a, "asker"), arg(a, "ask")) }}, + {Name: CopiedTool, + Description: "Where the one-time copy of the controller's hand acts into this seat's log stands (novox/hq research 072 " + + "row 26): done, with when, by what and how many acts were copied, or how many old acts still wait, and how " + + "full both logs are. Changes nothing.", + Input: obj(map[string]any{}), + Run: func(map[string]any) (any, error) { return l.Copied() }}, + {Name: CopyTool, + Description: "Copy the controller's hand acts (its bucket mesh-controller_hand-acts) into this seat's log once, each " + + "with its own key, time and author. Does nothing when the copy is already done; an act already in the log is " + + "never appended again, so a copy that stopped is finished by asking again. With top_up=true it also runs after the " + + "copy is done and appends only the acts written since. This also runs at the module's start.", + Input: obj(map[string]any{"top_up": str("\"true\": after the copy is done, append the acts written since")}), + Run: func(a map[string]any) (any, error) { + return l.Copy("tool, called by "+stdio.CallerOf(a), a["top_up"] == true || strings.EqualFold(arg(a, "top_up"), "true")) + }}, } } diff --git a/modules/mesh-hand-acts/cmd/mesh-hand-acts/whole_integration_test.go b/modules/mesh-hand-acts/cmd/mesh-hand-acts/whole_integration_test.go index 64ec46d2..8977994d 100644 --- a/modules/mesh-hand-acts/cmd/mesh-hand-acts/whole_integration_test.go +++ b/modules/mesh-hand-acts/cmd/mesh-hand-acts/whole_integration_test.go @@ -338,7 +338,7 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) { if err != nil { t.Fatal(err) } - for _, b := range []string{Module + "_" + WarrantsState, "mesh-controller_hand-acts", "messenger_asks"} { + for _, b := range []string{Module + "_" + WarrantsState, Module + "_" + CopiedState, "mesh-controller_hand-acts", "messenger_asks"} { if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil { t.Fatal(err) } @@ -354,6 +354,13 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) { if err != nil { t.Fatal(err) } + // One act of the controller's, kept before the module starts: the start copies it into the log. + oldActs, _ := js.KeyValue(ctx, "mesh-controller_hand-acts") + oldKey := fmt.Sprintf("act-%d-1", time.Now().Add(-time.Hour).UnixNano()) + if _, err := oldActs.Put(ctx, oldKey, []byte(`{"id":"`+oldKey+`","at":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+ + `","by":"ace.old","verb":"hand-act","why":"w","cause":"c"}`)); err != nil { + t.Fatal(err) + } cmd := exec.CommandContext(ctx, bin) cmd.Env = append(os.Environ(), "MESH_NODE=novox", "MESH_MODULE="+Module) stdin, _ := cmd.StdinPipe() @@ -395,8 +402,19 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) { t.Fatal(err) } } - if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 2 { - t.Errorf("the log keeps %v, want the act and one warrant (%v)", info, err) + for i := 0; ; i++ { + if c, err := p.call(CopiedTool, "person.jochen", map[string]any{}); err == nil && strings.Contains(string(c), `"done":true`) { + break + } else if i > 50 { + t.Fatalf("the controller's hand acts were not copied at the start: %s, %v", c, err) + } + time.Sleep(200 * time.Millisecond) + } + if e, err := logStream.GetLastMsgForSubject(ctx, "mesh.log."+Module+"."+ActsLog+"."+oldKey); err != nil || !strings.Contains(string(e.Data), `"by":"ace.old"`) { + t.Errorf("the controller's act is not copied with its author: %v", err) + } + if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 3 { + t.Errorf("the log keeps %v, want the copied act, the act and one warrant (%v)", info, err) } listed, err := p.call(HandActsTool, "person.jochen", map[string]any{}) if err != nil || !strings.Contains(string(listed), "warrant-claude-code-a1") || !strings.Contains(string(listed), r.Act.ID) { diff --git a/modules/mesh-hand-acts/module.json b/modules/mesh-hand-acts/module.json index 96aff8b1..443ea9d3 100644 --- a/modules/mesh-hand-acts/module.json +++ b/modules/mesh-hand-acts/module.json @@ -24,7 +24,8 @@ } ], "state": [ - "warrants" + "warrants", + "copied" ], "own-secrets": { "broker": "${dir:state}/broker" diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go index f697d7d1..16efaeaa 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go @@ -108,6 +108,10 @@ type PlannedProvision struct { // CredentialTool is, for a software-issued offer, the provider's tool the vault asks for the value (issue 928); // the vault acts on it and this client only reads it. CredentialTool string `json:"credential-tool,omitempty"` + // Contributed is what this module gives the provider through the provision, composed, as one opaque payload + // (issue 774): the provider reads it with its own contract (route-proxy's for a route); the client keeps it unread, + // so the plan reads whole and no provision's fields are known here. + Contributed json.RawMessage `json:"contributed,omitempty"` } // issuedForHolder is whether the entry names a value its provider's software issued for this holder alone (issue diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/whole_plan_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/whole_plan_test.go index 1c2efa79..a829277b 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/whole_plan_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/whole_plan_test.go @@ -9,8 +9,9 @@ import ( // A plan provision carries everything the controller's PlanProvision writes (issue 954): what the consumer // contributes, where the provider is and who the consumer is at its end (issue 788), the names the mesh calls it -// (issue 792) and, for a software-issued offer, the tool the vault asks (issue 928). The client still refuses a field -// nobody writes. +// (issue 792), for a software-issued offer, the tool the vault asks (issue 928), and what the consumer contributes as +// one opaque payload, carried unread (issue 774: route-proxy serves its routes from it, read with its own contract). +// The client still refuses a field nobody writes, but never looks inside the payload. func TestAPlanProvisionWithEveryFieldTheControllerWritesIsRead(t *testing.T) { p, err := json.Marshal(contributor(t, "shell:zsh:normal", "alias nox=mesh-cli")) if err != nil { @@ -18,7 +19,9 @@ func TestAPlanProvisionWithEveryFieldTheControllerWritesIsRead(t *testing.T) { } provision := `"provisions":[{"provision":"route","local":"web","provider":"novox","provider-module":"route-proxy",` + `"credential-form":"pair","contributes":{"label":"git"},"at":"10.10.0.1","from":"novox","as":"gitea",` + - `"names":{"name-web":"git.example.org"}},{"name":"gitea-read","provision":"gitea-read","provider":"novox",` + + `"names":{"name-web":"git.example.org"},` + + `"contributed":{"v":1,"at":"10.10.0.2","each":[{"local":"web","values":{"name":"git.example.org","anything":1}}]}},` + + `{"name":"gitea-read","provision":"gitea-read","provider":"novox",` + `"provider-module":"gitea","credential-form":"issued","credential-tool":"gitea_agent_token",` + `"path":"/var/lib/claude-code/gitea-read.secret"}],"items":` value := fmt.Sprintf(`{"node":"g14","module":"mesh-cli","provisioning":%s}`, @@ -29,7 +32,8 @@ func TestAPlanProvisionWithEveryFieldTheControllerWritesIsRead(t *testing.T) { } if len(got.Provisions) != 2 || got.Provisions[0].At != "10.10.0.1" || got.Provisions[0].From != "novox" || got.Provisions[0].As != "gitea" || got.Provisions[0].Names["name-web"] != "git.example.org" || - got.Provisions[1].CredentialTool != "gitea_agent_token" { + got.Provisions[1].CredentialTool != "gitea_agent_token" || string(got.Provisions[0].Contributed) != + `{"v":1,"at":"10.10.0.2","each":[{"local":"web","values":{"name":"git.example.org","anything":1}}]}` { t.Fatalf("the plan's provisions read as %+v", got.Provisions) } spoilt := strings.Replace(value, `"at":`, `"surprise":1,"at":`, 1) diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go index 87f1460d..1242c101 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go @@ -903,9 +903,9 @@ func (h *Holder) put(key string, p provisions.Provision) { // ---- what a change touches -------------------------------------------------------------------------------------- -// NodesOfPlacement is the node a placement key names. +// NodesOfPlacement is the node a placement key names, in either form (research 072 row 37). func NodesOfPlacement(key string) []string { - if n, err := delivery.ParsePlacementKey(key); err == nil { + if n, _, _, err := delivery.ParsePlacementKey(key); err == nil { return []string{n} } return nil @@ -938,25 +938,23 @@ func (h *Holder) NodesOfModule(module string) []string { return sortedKeys(nodes) } -// placedOn is every node a module is placed on, sorted. +// placedOn is every node a module is placed on, sorted, from the placement's keys in either form +// (delivery.PlacementsOf; research 072 row 37). A node whose keys do not read whole is read as they stand. func (h *Holder) placedOn(module string) []string { nodes := map[string]bool{} + raw := map[string][]byte{} if keys, err := h.Store.Keys(PlacementRead); err == nil { for _, k := range keys { - n, err := delivery.ParsePlacementKey(k) - if err != nil { - continue + if e, err := h.Store.Get(PlacementRead, k); err == nil && e != nil { + raw[k] = e.Value } - e, err := h.Store.Get(PlacementRead, k) - if err != nil || e == nil { - continue - } - if p, err := delivery.DecodePlacement(e.Value); err == nil { - for _, m := range p.Modules { - if m.Module == module { - nodes[n] = true - } - } + } + } + placements, _ := delivery.PlacementsOf(raw) + for n, p := range placements { + for _, m := range p.Modules { + if m.Module == module { + nodes[n] = true } } } diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go index f4bb55ad..35cb4626 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go @@ -191,7 +191,17 @@ func TestAKeysStateFollowsItsProvidersOutcome(t *testing.T) { // A provision's credential is the vault's own provision, on the consumer's node for a login of its own, and the // provision depends on it (clauses 3, 6). c := newMesh() - c.s.write(t, PlacementRead, "placement.novox", `{"v":1,"node":"novox","decided":{},"modules":[{"module":"mesh-vault"}]}`) + // The placement in its keys per module and node (research 072 row 37). + novox, err := delivery.PlacementValues(delivery.Placement{Node: "novox", Modules: []delivery.PlacedModule{{Module: "mesh-vault"}}}) + if err != nil { + t.Fatal(err) + } + for k, v := range novox { + c.s.write(t, PlacementRead, k, string(v)) + if n := NodesOfPlacement(k); !slices.Equal(n, []string{"novox"}) { + t.Errorf("NodesOfPlacement(%q) = %v", k, n) + } + } pair := pgDB pair.CredentialForm = "pair" c.atWant["g14"] = map[string]catalogue.Plan{"gitea": planOf(pair)} diff --git a/modules/mesh-provisioner/go.mod b/modules/mesh-provisioner/go.mod index b73dc004..ed2d1f50 100644 --- a/modules/mesh-provisioner/go.mod +++ b/modules/mesh-provisioner/go.mod @@ -3,7 +3,7 @@ module mesh-provisioner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030212912-6c5a8ef893b4 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-provisioner/go.sum b/modules/mesh-provisioner/go.sum index d71e258f..8e0c33a9 100644 --- a/modules/mesh-provisioner/go.sum +++ b/modules/mesh-provisioner/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030182415-754edfae6924 h1:aIiKj066gYFEDokUOq0i2n4vsygu5g0XMKJRRaMI4fM= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030182415-754edfae6924/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030212912-6c5a8ef893b4 h1:uzXzKfjoWB8wCbAPgxUAWSArr+3DKwi8qrB3BUEOVOE= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030212912-6c5a8ef893b4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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-self-check/cmd/mesh-self-check/probes_mesh.go b/modules/mesh-self-check/cmd/mesh-self-check/probes_mesh.go index a25fab0a..efb40831 100644 --- a/modules/mesh-self-check/cmd/mesh-self-check/probes_mesh.go +++ b/modules/mesh-self-check/cmd/mesh-self-check/probes_mesh.go @@ -31,29 +31,26 @@ const ( ScopeMachine = health.ScopeMachine ) -// placements is every node's placement, by node. +// placements is every node's placement, by node, from the state's keys in either form: a node key and its module keys, +// or the form before, `placement.` (delivery.PlacementsOf; research 072 row 37). func (c *Checker) placements() (map[string]delivery.Placement, error) { keys, err := c.Store.Keys(PlacementRead) if err != nil { return nil, fmt.Errorf("the placement cannot be read: %w", err) } - out := map[string]delivery.Placement{} + values := map[string][]byte{} for _, k := range keys { raw, ok, err := c.Store.Get(PlacementRead, k) if err != nil { - return nil, fmt.Errorf("the placement of %s cannot be read: %w", k, err) + return nil, fmt.Errorf("the placement's key %s cannot be read: %w", k, err) } - if !ok { - continue + if ok { + values[k] = raw } - var p delivery.Placement - if err := json.Unmarshal(raw, &p); err != nil { - return nil, fmt.Errorf("the placement of %s is not readable: %w", k, err) - } - if p.Node == "" { - p.Node = k - } - out[p.Node] = p + } + out, err := delivery.PlacementsOf(values) + if err != nil { + return nil, fmt.Errorf("the placement is not readable: %w", err) } return out, nil } diff --git a/modules/mesh-self-check/cmd/mesh-self-check/probes_test.go b/modules/mesh-self-check/cmd/mesh-self-check/probes_test.go index 03eb637a..cb083b0f 100644 --- a/modules/mesh-self-check/cmd/mesh-self-check/probes_test.go +++ b/modules/mesh-self-check/cmd/mesh-self-check/probes_test.go @@ -260,8 +260,12 @@ func machines() *memStore { node, address, endpoint string online bool }{{"novox", "10.10.0.1/16", "203.0.113.5:51820", true}, {"ace", "10.10.0.2/16", "", true}, {"g14", "10.10.0.3/16", "", false}} { - _ = s.Put(PlacementRead, p.node, delivery.Placement{V: 1, Node: p.node, - Decided: delivery.NodeDecided{Address: p.address, Endpoint: p.endpoint, Hub: p.endpoint != ""}}) + pl := delivery.Placement{V: 1, Node: p.node, + Decided: delivery.NodeDecided{Address: p.address, Endpoint: p.endpoint, Hub: p.endpoint != ""}} + if p.node == "ace" { + pl.Modules = []delivery.PlacedModule{{Module: "sshd"}} // a module key beside the node key + } + place(s, pl) _ = s.Put(PresenceRead, p.node, delivery.Presence{V: 1, Online: p.online}) key, _ := delivery.SeatHoldersKey(BannedSeat, p.node) _ = s.Put(SeatHoldersRead, key, delivery.SeatHolders{V: 1, Seat: BannedSeat, Scope: "node", Node: p.node, @@ -270,6 +274,31 @@ func machines() *memStore { return s } +// place writes a node's placement as the seat does, its node key and a key per module (research 072 row 37); g14's in +// the form before, `placement.g14`, which the probes still read while the change is delivered. +func place(s *memStore, p delivery.Placement) { + if p.Node == "g14" { + raw, _ := delivery.EncodePlacement(p) + _ = s.Put(PlacementRead, "placement.g14", json.RawMessage(raw)) + return + } + values, _ := delivery.PlacementValues(p) + for k, raw := range values { + _ = s.Put(PlacementRead, k, json.RawMessage(raw)) + } +} + +// placed is every node's placement the store holds. +func placed(s *memStore) map[string]delivery.Placement { + keys, _ := s.Keys(PlacementRead) + values := map[string][]byte{} + for _, k := range keys { + values[k], _, _ = s.Get(PlacementRead, k) + } + out, _ := delivery.PlacementsOf(values) + return out +} + // filling is a store holding the self-check's written fill finding of a stream: the condition open. func filling(stream string) *memStore { s := newStore() @@ -285,11 +314,9 @@ func filling(stream string) *memStore { func agents(verdicts map[string]string) *memStore { s := machines() for node, v := range verdicts { - raw, _, _ := s.Get(PlacementRead, node) - var p delivery.Placement - _ = json.Unmarshal(raw, &p) + p := placed(s)[node] p.Decided.AgentAccount = "agent" - _ = s.Put(PlacementRead, node, p) + place(s, p) h := delivery.NodeHealth{V: 1, Node: node, Contract: 13} switch v { case "": @@ -303,7 +330,7 @@ func agents(verdicts map[string]string) *memStore { h.Root = []delivery.RootVerdict{{Account: "agent", Module: "claude-code", Resource: "claude-code.agent", State: v, Reason: "can become root without a person: a sudo rule"}} } - raw, _ = delivery.EncodeNodeHealth(h) + raw, _ := delivery.EncodeNodeHealth(h) _ = s.Put(HealthRead, node, json.RawMessage(raw)) } return s diff --git a/modules/mesh-self-check/go.mod b/modules/mesh-self-check/go.mod index 06acc2cf..d345cd2f 100644 --- a/modules/mesh-self-check/go.mod +++ b/modules/mesh-self-check/go.mod @@ -3,7 +3,7 @@ module mesh-self-check go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-self-check/go.sum b/modules/mesh-self-check/go.sum index a2b8e018..8e0c33a9 100644 --- a/modules/mesh-self-check/go.sum +++ b/modules/mesh-self-check/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index 42bb41cd..9e76e0b6 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -323,9 +323,29 @@ func (v *Vault) AcceptTake(caller, call, sealed string) (Changed, error) { if origin == "" { origin = OriginAccepted } + if origin == OriginIssued { + // **The vault's mint wins** (issue 774): during the delivery a controller older than the hand-over may still mint + // a covered account and hand it here; it never replaces an account the vault minted, judged under the store's + // lock, so the value and the hash the bus loaded stay the vault's. + return v.keepWith(c.secret, string(value), someoneOr(c.by, caller), caller, origin, keepOptions{refuse: v.vaultMinted}) + } return v.keep(c.secret, string(value), someoneOr(c.by, caller), caller, origin) } +// vaultMinted refuses a value handed for a secret holding an account the vault minted itself (mint.go): one is never +// replaced but by the vault's own rotation. +func (v *Vault) vaultMinted(before Record) string { + if before.Origin != OriginMade || before.ChangedBy != "the vault" || before.MovedFrom != nil { + return "" + } + if was, err := openAtRest(before, v); err == nil && BusAccountValue(before.Name, was) { + return before.Name + " holds a bus account the vault minted (generation " + fmt.Sprint(before.Generation) + + "): a value the controller issued never replaces it, so nothing was taken; the vault is the one issuer " + + "(issue 774), and a new value is the vault's rotation" + } + return "" +} + // ---- secret-ask --------------------------------------------------------------------------------------------- // AskOf is what an ask is for, and where. diff --git a/modules/mesh-vault/cmd/mesh-vault/lease.go b/modules/mesh-vault/cmd/mesh-vault/lease.go index 3a399d00..85264172 100644 --- a/modules/mesh-vault/cmd/mesh-vault/lease.go +++ b/modules/mesh-vault/cmd/mesh-vault/lease.go @@ -1,28 +1,20 @@ package main -// The switch on the controller's lease (novox/hq ADR 0561 §3): minting is on only while the lease is handed to a holder -// that is no controller, and off for a controller's value, a key deleted or expired, or one that cannot be read. The -// write that hands it away turns it on and mints every wanted account the vault holds no value for; a lease handed back -// turns it off. No clock and no setting turns it. A key's age passing is heard by no watch, so the lease is read afresh -// before every mint (LeaseRead): a lease that expired since mints nothing. +// The controller's lease as the vault reads it (novox/hq ADR 0561 §3): handed away only to a holder that is no +// controller, and not for a controller's value, a key deleted or expired, or one that cannot be read. It turns retirement +// (retire.go) and rotation (rotation.go), never minting: the vault mints every wanted account it holds no value for, +// whoever holds the lease (mint.go, issue 774). No clock and no setting turns it. A key's age passing is heard by no +// watch, so the lease is read afresh before a retirement or a rotation (LeaseRead). import "git.novox.be/novox/mesh-sdk/go/buscompose" -// LeaseHeard is the watch of the controller's lease. When minting turns on or off, every wanted account is judged again: -// minted when on, held back with the new why when off. +// LeaseHeard is the watch of the controller's lease: it records whether it is handed away, and says when that turned. func (v *Vault) LeaseHeard(raw []byte, found bool, readErr error) { if !v.setLease(raw, found, readErr) { return } - on, why := v.mintingOn() - v.log("minting is %s: %s", map[bool]string{true: "ON", false: "off"}[on], why) - for _, user := range v.wantedUsers() { - if w, ok := v.wanted(user); ok { - if _, err := v.mintWanted(w, "the lease write"); err != nil { - v.log("%s is not minted: %v", user, err) - } - } - } + on, why := v.leaseHanded() + v.log("the controller's lease is %s: %s", map[bool]string{true: "HANDED AWAY", false: "not handed away"}[on], why) } // setLease records what the lease says, and answers whether minting turned on or off by it. @@ -39,7 +31,7 @@ func (v *Vault) setLease(raw []byte, found bool, readErr error) bool { return flipped } -// leaseAfresh reads the lease again before a mint, where the vault can: a key that expired since is heard by no watch. +// leaseAfresh reads the lease again before a retirement or a rotation, where the vault can: a key that expired since is heard by no watch. func (v *Vault) leaseAfresh() { if v.LeaseRead == nil { return diff --git a/modules/mesh-vault/cmd/mesh-vault/mint.go b/modules/mesh-vault/cmd/mesh-vault/mint.go index a56b71d1..86f73cfd 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint.go @@ -10,10 +10,14 @@ package main // bcrypt hash into `bus-accounts`; and says `account-issued`, then `secret-changed`. The node's engine asks `own` again // on `secret-changed` and composes. // -// **One issuer at every moment** (§3): minting is on only while the controller's lease is handed to a holder that is no -// controller (mesh-sdk LeaseHandedAway). Until then the controller mints, the vault keeps hashing what it is handed, and -// vault_status names every wanted account it holds back and why. **Nothing is minted anew** (§4): an account whose value -// the vault holds, or whose hash it adopted, is never minted; the node accounts and the controller's own are never wanted. +// **The vault is the one issuer** (issue 774, research 072's gap "vault mints after the lease"): it mints every wanted +// account it holds no value for, whoever holds the controller's lease, and the controller mints none once a vault is +// installed (mesh-controller forwards `module issue` and its assignment's issue to issue-account). No account depends +// on a node reporting to the controller. The lease still turns retirement and rotation (retire.go, rotation.go), and +// vault_status names it. **Never two issuers of one value** (§3, §4): an account whose value the vault holds, or whose +// hash it adopted, is never minted; the node accounts and the controller's own are never wanted; a value the controller +// moves in never replaces one held (move.go), and one it hands with origin issued never replaces an account the vault +// minted (AcceptTake): during the delivery an older controller may still mint, and the vault's value wins. // **Never two values for one user** (§7): a user whose hash the vault already wrote from another of its secrets is held // back, said, and never replaced but by a rotation. @@ -43,8 +47,9 @@ const ( // AccountIssuedEvent is what the vault says when it minted an account (mesh-sdk buscompose.AccountIssued). const AccountIssuedEvent = buscompose.AccountIssuedEvent -// minting is the switch and what the vault knows of the wanted accounts, kept in memory and read again from the bus at -// every start: the grants part's state and the lease are the record, never this. +// minting is what the vault knows of the wanted accounts and of the controller's lease, kept in memory and read again +// from the bus at every start: the grants part's state and the lease are the record, never this. on, why and since are +// the lease (handed away or not), which turns retirement and rotation, never minting. type minting struct { mu sync.Mutex on bool @@ -76,41 +81,77 @@ type Minted struct { Hashed bool `json:"hashed"` } -// MintState is what vault_status says of minting. +// MintState is what vault_status says of minting: always on (the vault is the one issuer, issue 774), and the lease, +// which turns retirement and rotation. type MintState struct { - On bool `json:"on"` - Why string `json:"why"` + On bool `json:"on"` + Why string `json:"why"` + // Lease is whether the controller's lease is handed away, LeaseWhy why, and Since when that last turned. + Lease bool `json:"lease_handed_away"` + LeaseWhy string `json:"lease_why"` Since time.Time `json:"since,omitzero"` Wanted int `json:"wanted"` HeldBack map[string]string `json:"held_back"` Minted []Minted `json:"minted"` } -// MintState answers how minting stands. +// mintingWhy is why the vault mints, whoever holds the lease. +const mintingWhy = "the vault is the one issuer of bus accounts: it mints every wanted account it holds no value for " + + "and adopted no hash of, whoever holds the controller's lease (issue 774)" + +// MintState answers how minting stands. A user held back whose account the vault holds a value for meanwhile (moved in, +// handed, or minted by another path) is not said held back: it is not (issue 774, 2026-10-31). func (v *Vault) MintState() MintState { m := &v.minting m.mu.Lock() - defer m.mu.Unlock() - out := MintState{On: m.on, Why: m.why, Since: m.since, Wanted: len(m.wants), HeldBack: map[string]string{}, Minted: []Minted{}} - if out.Why == "" { - out.Why = "the controller's lease has not been read yet, so nothing is minted" + out := MintState{On: true, Why: mintingWhy, Lease: m.on, LeaseWhy: m.why, Since: m.since, Wanted: len(m.wants), + HeldBack: map[string]string{}, Minted: []Minted{}} + if out.LeaseWhy == "" { + out.LeaseWhy = leaseUnread } + wants := map[string]buscompose.WantedAccount{} for u, why := range m.heldBack { out.HeldBack[u] = why + if w, ok := m.wants[u]; ok { + wants[u] = w + } } for _, x := range m.minted { out.Minted = append(out.Minted, x) } + m.mu.Unlock() + for u, w := range wants { + if v.holdsAccount(w) { + delete(out.HeldBack, u) + } + } sort.Slice(out.Minted, func(i, j int) bool { return out.Minted[i].User < out.Minted[j].User }) return out } -// mintingOn says whether the vault mints now, and why. -func (v *Vault) mintingOn() (bool, string) { +// holdsAccount says the vault holds a bus account as w's own secret: a value that is an account. +func (v *Vault) holdsAccount(w buscompose.WantedAccount) bool { + secret, err := AccountSecret(w) + if err != nil { + return false + } + rec, held, err := v.Store.Read(secret) + if err != nil || !held { + return false + } + value, err := openAtRest(rec, v) + return err == nil && BusAccountValue(secret, value) +} + +// leaseUnread is why nothing is retired or rotated before the lease is read. +const leaseUnread = "the controller's lease has not been read yet, so nothing is retired or rotated" + +// leaseHanded says whether the controller's lease is handed away, and why: what retirement and rotation wait for. +func (v *Vault) leaseHanded() (bool, string) { v.minting.mu.Lock() defer v.minting.mu.Unlock() if v.minting.why == "" { - return false, "the controller's lease has not been read yet, so nothing is minted" + return false, leaseUnread } return v.minting.on, v.minting.why } @@ -248,11 +289,6 @@ func (v *Vault) mintOnce(w buscompose.WantedAccount, by string) (Minted, error) v.holdBack(w.User, why) return Minted{}, errHeldBack{why} } - v.leaseAfresh() - if on, why := v.mintingOn(); !on { - v.holdBack(w.User, "held back: "+why) - return Minted{}, errHeldBack{why} - } raw := make([]byte, 30) if _, err := rand.Read(raw); err != nil { return Minted{}, err @@ -360,8 +396,9 @@ func heldBackOrMinted(m Minted, err error) (any, error) { } // IssueAccount is the seat's verb issue-account (ADR 0561 §9): one wanted user minted now, its key read afresh from the -// grants part's state. The recovery when a want was not heard: it mints nothing a want would not, and is refused while -// minting is off, for a node's account and the controller's (never minted here, §4), and for a user nobody wants. +// grants part's state. The recovery when a want was not heard, and what the controller's `module issue` forwards to +// (issue 774): it mints nothing a want would not, answers "held already" for an account it holds, and is refused for a +// node's account and the controller's (never minted here, §4) and for a user nobody wants. func (v *Vault) IssueAccount(caller, user string) (any, error) { user = strings.TrimSpace(user) if user == "" { @@ -371,10 +408,6 @@ func (v *Vault) IssueAccount(caller, user string) (any, error) { return nil, fmt.Errorf("%s is never minted by the vault: a node's account and the controller's keep the hash they "+ "have (ADR 0561 §4)", user) } - v.leaseAfresh() - if on, why := v.mintingOn(); !on { - return nil, fmt.Errorf("not minted: %s", why) - } raw, found, err := []byte(nil), false, error(nil) if v.WantRead != nil { raw, found, err = v.WantRead(user) diff --git a/modules/mesh-vault/cmd/mesh-vault/mint_integration_test.go b/modules/mesh-vault/cmd/mesh-vault/mint_integration_test.go index 133c3131..821a03c7 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint_integration_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint_integration_test.go @@ -5,9 +5,9 @@ package main // The vault whole against a real bus (novox/hq ADR 0561 §1 to §4, issue 774's acceptance 12): the built binary, played // over its real stdin and stdout as the node's tool runtime plays it (tools/call with the caller in _meta; mesh/state.* // and mesh/publish answered from a real nats-server's key-value buckets and subjects), its store in a directory of its -// own. A want written to the grants part's state is minted only once the controller's lease is handed away: the hash is -// in bus-accounts, own answers the value sealed to the node, the value is the controller's shape and its hash's -// password, account-issued is said; an adopted user is never minted, and a lease handed back mints nothing. +// own. A want written to the grants part's state is minted whoever holds the controller's lease (issue 774: the vault is +// the one issuer): the hash is in bus-accounts, own answers the value sealed to the node, the value is the controller's +// shape and its hash's password, account-issued is said; an adopted user is never minted. import ( "bufio" @@ -297,7 +297,7 @@ func eventually(t *testing.T, what string, ok func() bool) { } // 774/AC12. -func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) { +func TestTheVaultWholeMintsOnTheBusWhoeverHoldsTheLease(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) dir := t.TempDir() @@ -358,21 +358,16 @@ func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) { _ = json.Unmarshal(raw, &s) return s.Minting } - // The controller holds the lease: a want is held back, nothing is minted. + // The controller holds the lease, and the want is minted all the same. p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"instance": "novox/1/2026-10-27", "epoch": 57}) - x, y, z := aWant("mod-x"), aWant("mod-y"), aWant("mod-z") + x, y := aWant("mod-x"), aWant("mod-y") p.put(t, "mesh-vault.bus-accounts", y.User, AccountHash{User: y.User, Hash: "$2a$04$adoptedadoptedadoptedadoptedadoptedadoptedadopte", Adopted: true}) p.put(t, "mesh-grants.accounts", x.User, x) p.put(t, "mesh-grants.accounts", y.User, y) - eventually(t, "the want held back under the controller's lease", func() bool { - return strings.Contains(status().HeldBack[x.User], "controller holds the lease") - }) - if p.hashOf(t, x.User) != nil { - t.Fatal("minted under the controller's lease") + eventually(t, "the account minted under the controller's lease", func() bool { return p.hashOf(t, x.User) != nil }) + if st := status(); !st.On || st.Lease || len(st.HeldBack) != 0 { + t.Errorf("vault_status says %+v", st) } - // The write that hands the lease away mints it. - p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) - eventually(t, "the account minted at the lease write", func() bool { return p.hashOf(t, x.User) != nil }) nodePub, nodePriv := aSealingKey(t) if _, err := p.call(Seat+".node-key", "node.ace", map[string]any{"key": nodePub}); err != nil { t.Fatal(err) @@ -410,13 +405,4 @@ func TestTheVaultWholeMintsOnTheBusOnlyFromTheLeaseWrite(t *testing.T) { if !strings.Contains(said, AccountIssuedEvent+` {"`) || !strings.Contains(said, `"user":"ace.mod-x"`) || strings.Contains(said, held.Password) { t.Errorf("the vault said: %s", said) } - // Handed back to a controller: a new want mints nothing. - p.put(t, "mesh-controller.lease", LeaseKey, map[string]any{"instance": "novox/2/2026-10-28", "epoch": 58}) - p.put(t, "mesh-grants.accounts", z.User, z) - eventually(t, "the new want held back once the lease is the controller's again", func() bool { - return strings.Contains(status().HeldBack[z.User], "controller holds the lease") - }) - if p.hashOf(t, z.User) != nil { - t.Error("minted after the lease was handed back") - } } diff --git a/modules/mesh-vault/cmd/mesh-vault/mint_test.go b/modules/mesh-vault/cmd/mesh-vault/mint_test.go index 9f7d2687..7ec6151c 100644 --- a/modules/mesh-vault/cmd/mesh-vault/mint_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/mint_test.go @@ -106,51 +106,55 @@ func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) { } } -// 774/AC8 (novox/hq ADR 0561 §3): while the controller holds the lease nothing is minted and vault_status says why and -// names each wanted account held back (never an adopted one); the write that hands the lease away mints them; a lease -// handed back, expired or unreadable turns minting off again, read afresh before every mint. -func TestNothingIsMintedUntilTheLeaseIsHandedAway(t *testing.T) { +// 774/AC8, after research 072's gap "vault mints after the lease": the vault is the one issuer, so a wanted account is +// minted while the controller still holds the lease (never an adopted one), vault_status says minting is on and the +// lease apart, and a want held back is not said held back once the vault holds its value. During the delivery an older +// controller may still mint: its value handed with origin issued never replaces the account the vault minted. +func TestTheVaultMintsWhoeverHoldsTheLease(t *testing.T) { v, accounts, _ := mintingVault(t, false) accounts.keys = map[string]AccountHash{"ace.mod-y": {User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true}} for _, m := range []string{"mod-x", "mod-y"} { w := aWant(m) v.WantHeard(w.User, wantRaw(t, w), false) } - st := v.MintState() - if st.On || !strings.Contains(st.Why, "controller holds the lease") || len(st.HeldBack) != 1 || - !strings.Contains(st.HeldBack["ace.mod-x"], "controller holds the lease") { - t.Fatalf("under the controller's lease vault_status says %+v", st) - } - if _, held, _ := v.Store.Read("own/ace/mod-x/broker"); held { - t.Fatal("minted while the controller holds the lease") - } - // Handed away: the wanted account is minted at that write, the adopted one is not. - lease := []byte(`{"handed-to":"the operator's hand-over"}`) - v.LeaseRead = func() ([]byte, bool, error) { return lease, true, nil } - v.LeaseHeard(lease, true, nil) - if _, held, _ := v.Store.Read("own/ace/mod-x/broker"); !held { - t.Fatalf("not minted at the lease write: %+v", v.MintState()) + minted, held, _ := v.Store.Read("own/ace/mod-x/broker") + if !held { + t.Fatalf("not minted under the controller's lease: %+v", v.MintState()) } if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held { - t.Error("an adopted account was minted at the lease write") + t.Error("an adopted account was minted") } - if st := v.MintState(); !st.On || len(st.HeldBack) != 0 { - t.Errorf("after the lease write vault_status says %+v", st) + st := v.MintState() + if !st.On || st.Lease || !strings.Contains(st.LeaseWhy, "controller holds the lease") || len(st.HeldBack) != 0 { + t.Errorf("under the controller's lease vault_status says %+v", st) } - // The key expired (no watch hears a key's age pass): read afresh before the next mint, nothing is minted. - v.LeaseRead = func() ([]byte, bool, error) { return nil, false, nil } + // An older controller's covered account, handed with origin issued: refused, the vault's value kept. + x := aWant("mod-x") + theirs, _ := buscompose.HeldCredential(x, "the-controllers-password") + if _, err := handIssued(t, v, ControllerCaller, "own/ace/mod-x/broker", string(theirs)); err == nil || + !strings.Contains(err.Error(), "the vault minted") { + t.Errorf("a controller's issued value over the vault's mint: %v", err) + } + if rec, _, _ := v.Store.Read("own/ace/mod-x/broker"); rec.Generation != minted.Generation || rec.ChangedBy != "the vault" { + t.Errorf("the vault's minted account was replaced: %+v", rec) + } + // Held back, and then held (moved in, handed): no longer said held back. z := aWant("mod-z") - v.WantHeard(z.User, wantRaw(t, z), false) - if _, held, _ := v.Store.Read("own/ace/mod-z/broker"); held { - t.Error("minted after the lease's key expired") + v.minting.mu.Lock() + v.minting.wants[z.User] = z + v.minting.mu.Unlock() + v.holdBack(z.User, "held back for a reason that has passed") + value, _ := buscompose.HeldCredential(z, "moved-in") + if _, err := v.keep("own/ace/mod-z/broker", string(value), "the move", ControllerCaller, OriginMade); err != nil { + t.Fatal(err) } - if st := v.MintState(); st.On || !strings.Contains(st.HeldBack["ace.mod-z"], "no lease is written") { - t.Errorf("after the key expired vault_status says %+v", st) + if st := v.MintState(); st.HeldBack[z.User] != "" { + t.Errorf("an account the vault holds is said held back: %+v", st.HeldBack) } } -// 774/AC10 issue-account (novox/hq ADR 0561 §9): the verb mints one wanted user now, read afresh; it is refused while -// minting is off, for a node's account and the controller's, and for a user nobody wants; a user held already is said so. +// 774/AC10 issue-account (novox/hq ADR 0561 §9): the verb mints one wanted user now, read afresh, whoever holds the lease; +// it is refused for a node's account and the controller's, and for a user nobody wants; a user held already is said so. func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) { v, _, _ := mintingVault(t, false) w := aWant("mod-x") @@ -162,7 +166,6 @@ func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) { return nil, false, nil } for user, says := range map[string]string{ - "ace.mod-x": "controller holds the lease", "node.ace": "never minted", "controller": "never minted", } { @@ -170,7 +173,7 @@ func TestIssueAccountMintsOneWantedUserNowAndRefusesTheRest(t *testing.T) { t.Errorf("%s: %v, want a refusal saying %q", user, err, says) } } - v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil) + // Minted while the controller still holds the lease (issue 774): `module issue` forwards here. if _, err := v.IssueAccount("person.jochen", "ace.nobody"); err == nil || !strings.Contains(err.Error(), "no wanted account") { t.Errorf("a user nobody wants: %v", err) } diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode.go b/modules/mesh-vault/cmd/mesh-vault/ownnode.go index 648fc6fe..84fec61e 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode.go @@ -8,7 +8,6 @@ package main // provisioner is left out: its client fills its secrets from `get` (ADR 0349 §17). import ( - "encoding/json" "errors" "fmt" "slices" @@ -38,6 +37,11 @@ type OwnAnswer struct { Own []NodeOwnSecret `json:"own"` Credentials []NodeCredential `json:"credentials,omitempty"` Missing []CredentialAsked `json:"missing,omitempty"` + // Handed is, by module of the machine, whether the vault judged it handed to the provisioner (catalogue.AssignmentHands + // of the plan in its assignment), and so left its own secrets out of Own: the node composing itself hands exactly + // those (mesh-sdk compose/frombus HandedReader, issue 774). Every module it holds a secret of, and every module + // placed there whose plan reads; one it could not judge is not named, and the node judges it as before. + Handed map[string]bool `json:"handed,omitempty"` } // CredentialAsked names one pair credential by its ends, as a node composing its own asks for it (mesh-sdk @@ -163,6 +167,15 @@ func (p *planReader) placedOn(module string) (bool, error) { return slices.Contains(p.placed, module), p.placedErr } +// placedModules is the machine's placed modules, read once for the answer. +func (p *planReader) placedModules() ([]string, error) { + if p.placement == nil { + return nil, errors.New("the machine's placement is not read here") + } + p.placedOn("") + return p.placed, p.placedErr +} + // read is module's assignment on the machine, nil when there is none, or why the vault reads none. func (p *planReader) read(module string) ([]byte, string, error) { if p.why != "" { @@ -283,7 +296,7 @@ func (v *Vault) ownOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e } plan := v.plansOf(node) var handings []Handing - out := OwnAnswer{Node: node, Key: key} + out := OwnAnswer{Node: node, Key: key, Handed: map[string]bool{}} for _, r := range all { parts := strings.Split(r.Name, "/") if len(parts) != 4 || parts[0] != "own" || parts[1] != node { @@ -293,6 +306,7 @@ func (v *Vault) ownOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e if err != nil { return OwnAnswer{}, err } + out.Handed[parts[2]] = handed if handed { continue } @@ -308,6 +322,17 @@ func (v *Vault) ownOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin), Generation: r.Generation, Sealed: sealed}) } + // And every module placed there that it holds no secret of yet, where its plan reads: the node hands it as judged here. + if placed, err := plan.placedModules(); err == nil { + for _, module := range placed { + if _, judged := out.Handed[module]; judged { + continue + } + if handed, err := plan.handed(module); err == nil { + out.Handed[module] = handed + } + } + } sort.Slice(out.Own, func(i, j int) bool { if out.Own[i].Module != out.Own[j].Module { return out.Own[i].Module < out.Own[j].Module @@ -392,15 +417,7 @@ func (p *planReader) handed(module string) (bool, error) { // handed) hands nothing, and that module's files and own secrets stay its node's composition. Whether a plan exists // is never the rule; a reader that asks only who binds whom reads the provisions and says so ("not handing", which // the catalogue check checks/planhands holds every module to). -func handsOf(raw []byte) (bool, error) { - var a struct { - Provisioning *catalogue.Plan `json:"provisioning"` - } - if err := json.Unmarshal(raw, &a); err != nil { - return false, err - } - return a.Provisioning != nil && a.Provisioning.Hands(), nil -} +func handsOf(raw []byte) (bool, error) { return catalogue.AssignmentHands(raw) } // hands is handsOf for a value already read as the controller's: one that cannot be read hands nothing. func hands(raw []byte) bool { diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go index 7a957ff1..461d7203 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "testing" @@ -286,4 +287,8 @@ func TestOwnLeavesOutOnlyAModuleWhosePlanHandsIt(t *testing.T) { if len(got.Own) != 1 || got.Own[0].Module != "grafana" || got.Own[0].Name != "admin" { t.Errorf("anchor was answered %+v; want grafana's admin alone (its plan keys a route and hands nothing)", got.Own) } + // And it says how it judged each, so the node composing itself hands exactly those (issue 774, postgres on novox). + if want := map[string]bool{"grafana": false, "web": true, "db": true}; !reflect.DeepEqual(got.Handed, want) { + t.Errorf("own judged %v handed, want %v", got.Handed, want) + } } diff --git a/modules/mesh-vault/cmd/mesh-vault/placed.go b/modules/mesh-vault/cmd/mesh-vault/placed.go index d7ca91a5..a51059f2 100644 --- a/modules/mesh-vault/cmd/mesh-vault/placed.go +++ b/modules/mesh-vault/cmd/mesh-vault/placed.go @@ -25,7 +25,8 @@ type PlacementReader struct { Read func(key string) ([]byte, error) } -// placements is every node's placement, by node; none when the vault reads no placement. +// placements is every node's placement, by node, from the state's keys in either form (delivery.PlacementsOf; research +// 072 row 37); none when the vault reads no placement. A node whose keys do not read whole is read as they stand. func (v *Vault) placements() (map[string]delivery.Placement, error) { out := map[string]delivery.Placement{} if v.Placement.Keys == nil || v.Placement.Read == nil { @@ -35,52 +36,73 @@ func (v *Vault) placements() (map[string]delivery.Placement, error) { if err != nil { return nil, fmt.Errorf("the placement's keys could not be read: %w", err) } + values := map[string][]byte{} for _, k := range keys { - node, err := delivery.ParsePlacementKey(k) - if err != nil { + if _, _, _, err := delivery.ParsePlacementKey(k); err != nil { continue } raw, err := v.Placement.Read(k) if err != nil { - return nil, fmt.Errorf("the placement of %s could not be read: %w", node, err) + return nil, fmt.Errorf("the placement's key %s could not be read: %w", k, err) } - if raw == nil { - continue + if raw != nil { + values[k] = raw } - p, err := delivery.DecodePlacement(raw) - if err != nil || p.Node != node { - continue - } - out[node] = p } + out, _ = delivery.PlacementsOf(values) return out, nil } +// nodePlaced is what node's own key of the placement says: the modules placed there and what a person decided of it, +// read from its node key `.node` alone (which names every module placed, research 072 row 37), or, for a node not +// converted yet, from its key in the form before, `placement.`. found is false when it has neither. +func (v *Vault) nodePlaced(node string) (modules []string, decided delivery.NodeDecided, found bool, err error) { + key, err := delivery.PlacementNodeKey(node) + if err != nil { + return nil, decided, false, err + } + raw, err := v.Placement.Read(key) + if err != nil { + return nil, decided, false, err + } + if raw != nil { + n, err := delivery.DecodePlacementNode(raw) + if err != nil { + return nil, decided, false, fmt.Errorf("the placement of %s is not readable: %w", node, err) + } + for _, r := range n.Modules { + modules = append(modules, r.Module) + } + return modules, n.Decided, true, nil + } + legacy, err := delivery.LegacyPlacementKey(node) + if err != nil { + return nil, decided, false, err + } + if raw, err = v.Placement.Read(legacy); err != nil || raw == nil { + return nil, decided, false, err + } + p, err := delivery.DecodePlacement(raw) + if err != nil { + return nil, decided, false, fmt.Errorf("the placement of %s is not readable: %w", node, err) + } + return p.Assigned(), p.Decided, true, nil +} + // placedOn says module is placed on node by the catalogue. func (v *Vault) placedOn(node, module string) (bool, error) { placed, err := v.placedModules(node) return slices.Contains(placed, module), err } -// placedModules is every module the catalogue places on node, read from the node's one key of the placement; none when -// the vault reads no placement or the node has no key. +// placedModules is every module the catalogue places on node, read from the node's own key of the placement +// (nodePlaced); none when the vault reads no placement or the node has no key. func (v *Vault) placedModules(node string) ([]string, error) { if v.Placement.Read == nil { return nil, nil } - key, err := delivery.PlacementKey(node) - if err != nil { - return nil, err - } - raw, err := v.Placement.Read(key) - if err != nil || raw == nil { - return nil, err - } - p, err := delivery.DecodePlacement(raw) - if err != nil { - return nil, fmt.Errorf("the placement of %s is not readable: %w", node, err) - } - return p.Assigned(), nil + modules, _, _, err := v.nodePlaced(node) + return modules, err } // standsOn says module stands on node: placed there, or assigned there while the assignments stand. A read that fails is diff --git a/modules/mesh-vault/cmd/mesh-vault/placed_test.go b/modules/mesh-vault/cmd/mesh-vault/placed_test.go index dfae8948..4a49a4c2 100644 --- a/modules/mesh-vault/cmd/mesh-vault/placed_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/placed_test.go @@ -16,12 +16,14 @@ func placementsOf(t *testing.T, nodes map[string][]string) PlacementReader { for _, m := range modules { p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) } - raw, err := delivery.EncodePlacement(p) + // Research 072 row 37: the node key and a key per module. + kv, err := delivery.PlacementValues(p) if err != nil { t.Fatal(err) } - key, _ := delivery.PlacementKey(node) - values[key] = raw + for k, raw := range kv { + values[k] = raw + } } return PlacementReader{ Keys: func() ([]string, error) { diff --git a/modules/mesh-vault/cmd/mesh-vault/retire.go b/modules/mesh-vault/cmd/mesh-vault/retire.go index ea694aa9..e0ea2960 100644 --- a/modules/mesh-vault/cmd/mesh-vault/retire.go +++ b/modules/mesh-vault/cmd/mesh-vault/retire.go @@ -136,7 +136,7 @@ func (v *Vault) RetirePass() { return } v.leaseAfresh() - on, leaseWhy := v.mintingOn() + on, leaseWhy := v.leaseHanded() m := &v.minting m.mu.Lock() read := m.retiring.wantsRead @@ -239,7 +239,7 @@ func (v *Vault) RetireAccounts(caller string, users []string, why string) (any, // As the passes: never while the controller holds the lease (it forgets the accounts it mints), nor before the wanted // accounts are read whole (an account not read yet is not known to be gone). v.leaseAfresh() - if on, why := v.mintingOn(); !on { + if on, why := v.leaseHanded(); !on { return nil, fmt.Errorf("nothing was retired: %s", why) } v.minting.mu.Lock() diff --git a/modules/mesh-vault/cmd/mesh-vault/rotation.go b/modules/mesh-vault/cmd/mesh-vault/rotation.go index 31244f97..9f1d6312 100644 --- a/modules/mesh-vault/cmd/mesh-vault/rotation.go +++ b/modules/mesh-vault/cmd/mesh-vault/rotation.go @@ -42,7 +42,7 @@ func (v *Vault) BeginRotation(user string) (Rotating, error) { if nodeOrController(user) { return Rotating{}, fmt.Errorf("%s does not rotate here: a node's account and the controller's keep the hash they have (ADR 0561 §4)", user) } - if on, why := v.mintingOn(); !on { + if on, why := v.leaseHanded(); !on { return Rotating{}, fmt.Errorf("no rotation begins: %s", why) } v.minting.mu.Lock() diff --git a/modules/mesh-vault/cmd/mesh-vault/signing.go b/modules/mesh-vault/cmd/mesh-vault/signing.go index 7e05eb14..a4c66d9a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/signing.go +++ b/modules/mesh-vault/cmd/mesh-vault/signing.go @@ -303,22 +303,14 @@ func (v *Vault) internalName(node string) (string, error) { if v.Placement.Read == nil { return "", errors.New("the vault reads no placement, so it cannot tell a node's internal name") } - key, err := delivery.PlacementKey(node) - if err != nil { - return "", err - } - raw, err := v.Placement.Read(key) + _, decided, found, err := v.nodePlaced(node) if err != nil { return "", fmt.Errorf(notYetPrefix+"the placement of %s could not be read: %w", node, err) } - if raw == nil { + if !found { return "", fmt.Errorf("%s has no placement of the seat mesh-placement, so it has no name inside the mesh", node) } - p, err := delivery.DecodePlacement(raw) - if err != nil { - return "", fmt.Errorf("the placement of %s is not readable: %w", node, err) - } - if p.Decided.Address == "" { + if decided.Address == "" { return "", fmt.Errorf("%s is not on the private network (its placement names no address), so it has no name inside the mesh to be certified for", node) } return overlay.InternalName(node), nil diff --git a/modules/mesh-vault/cmd/mesh-vault/signing_test.go b/modules/mesh-vault/cmd/mesh-vault/signing_test.go index 68ef9f6d..815cf5bc 100644 --- a/modules/mesh-vault/cmd/mesh-vault/signing_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/signing_test.go @@ -40,16 +40,21 @@ func TestTheVaultSigns(t *testing.T) { "g14": {Node: "g14", Decided: delivery.NodeDecided{Address: "10.77.0.4"}}, "plain": {Node: "plain"}, } + // Research 072 row 37: ace and plain in their keys per module and node, g14 still in the form before. v.Placement = PlacementReader{Read: func(key string) ([]byte, error) { - node, err := delivery.ParsePlacementKey(key) + node, _, kind, err := delivery.ParsePlacementKey(key) if err != nil { return nil, err } p, ok := placements[node] - if !ok { + if !ok || (node == "g14") != (kind == delivery.PlacementLegacyKind) { return nil, nil } - return delivery.EncodePlacement(p) + if kind == delivery.PlacementLegacyKind { + return delivery.EncodePlacement(p) + } + values, err := delivery.PlacementValues(p) + return values[key], err }} records := map[string]string{"ace": base64.StdEncoding.EncodeToString(servingPub), "plain": base64.StdEncoding.EncodeToString(servingPub)} v.Haves = func(key string) ([]byte, error) { diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index 0f575e8f..ee282f7d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -306,9 +306,9 @@ func definitions(env *Env) []toolDef { }}, {Name: Seat + ".issue-account", Description: "Mint one bus account the grants part wants, now (novox/hq ADR 0561 §9): the recovery when the vault did not " + - "hear a want. Reads the wanted key afresh from mesh-grants.accounts and mints it as a want would: only while " + - "the controller's lease is handed away, never for a node's account or the controller's, never a user nobody " + - "wants, and never anew for one whose value the vault holds or whose hash it adopted (it says so). Answers " + + "hear a want, and what the controller's `module issue` forwards to (issue 774). Reads the wanted key afresh from " + + "mesh-grants.accounts and mints it as a want would, whoever holds the controller's lease: never for a node's " + + "account or the controller's, never a user nobody wants, and never anew for one whose value the vault holds or whose hash it adopted (it says so). Answers " + "whether it minted, the secret and generation, never the value or the hash.", Input: map[string]any{"user": str("the bus user, e.g. ace.mesh-build-go")}, run: func(caller string, a map[string]any) (any, error) { @@ -633,8 +633,9 @@ func definitions(env *Env) []toolDef { {Name: "vault_status", Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " + "key the mesh's export holds if the vault refused it, how often the record of hand-outs was rolled, and " + - "minting (novox/hq ADR 0561 §3, §9): whether the vault mints bus accounts now and why (only while the " + - "controller's lease is handed away), every wanted account it holds back with why, and those it minted.", + "minting (novox/hq ADR 0561 §9, issue 774): the vault mints every wanted bus account it holds no value for, " + + "whoever holds the controller's lease; whether that lease is handed away (it turns retirement and rotation), " + + "every wanted account it holds back with why (never one whose value it holds), and those it minted.", run: func(string, map[string]any) (any, error) { if env.Vault == nil { return map[string]any{"ready": false, "error": fmt.Sprint(env.Broken)}, nil diff --git a/modules/mesh-vault/go.mod b/modules/mesh-vault/go.mod index b934e88b..49715173 100644 --- a/modules/mesh-vault/go.mod +++ b/modules/mesh-vault/go.mod @@ -3,7 +3,7 @@ module mesh-vault go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/mesh-vault/go.sum b/modules/mesh-vault/go.sum index 78414b54..c925fcba 100644 --- a/modules/mesh-vault/go.sum +++ b/modules/mesh-vault/go.sum @@ -1,5 +1,9 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115 h1:ezKg8aAKQoZ5XEAWQQVk4y/x0h2qC73GcUKBmbqY5jw= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082917-2d1d8def7535 h1:Bgrm3YfWCxr+FEm03kEP5fDIe9hNbe+j0CDVatGAGKg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082917-2d1d8def7535/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082933-085f17e935db h1:PJ1V9tDjNqp/aogm/r52RRePxcHyW58f7lYBceMMY9c= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031082933-085f17e935db/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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/nats/cmd/nats-tools/manifest_test.go b/modules/nats/cmd/nats-tools/manifest_test.go index 2007b1ef..dc9f453a 100644 --- a/modules/nats/cmd/nats-tools/manifest_test.go +++ b/modules/nats/cmd/nats-tools/manifest_test.go @@ -112,12 +112,12 @@ func TestTheBussManifestMountsItsProgramsIntoTheUpstreamImage(t *testing.T) { } // The tools that change the bus (issue 1952 N1-N3): listed, their audit events declared, and the credential // they dial with the module's own secret `broker`. - for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove"} { + for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove", "nats_stream_remove"} { if !slices.Contains(m.Tools, want) { t.Errorf("tools do not list %s", want) } } - for _, want := range []string{"consumer-removed", "bucket-keys-removed"} { + for _, want := range []string{"consumer-removed", "bucket-keys-removed", "stream-removed"} { if !slices.Contains(m.Emits, want) { t.Errorf("emits do not list %s: the removal tools say it as the audit line", want) } diff --git a/modules/nats/cmd/nats-tools/removal.go b/modules/nats/cmd/nats-tools/removal.go index 3aff66e5..c4ac48de 100644 --- a/modules/nats/cmd/nats-tools/removal.go +++ b/modules/nats/cmd/nats-tools/removal.go @@ -1,7 +1,7 @@ package main -// The two tools of this module that change the bus (novox/hq ADR 0564 clause 5, issue 1952 notes 6, 7 and 9): -// remove one named consumer, and remove one node's keys under a prefix of a key-value bucket. Everything else +// The three tools of this module that change the bus (novox/hq ADR 0564 clause 5, issue 1952 notes 6, 7 and 9): +// remove one named consumer, remove one named stream, and remove one node's keys under a prefix of a key-value bucket. Everything else // here reads. // // **Narrow by what the tool needs, and narrower by what the bus grants.** The tools reach the bus on the @@ -34,6 +34,7 @@ type Said func(event string, body any) error const ( consumerRemoved = "consumer-removed" bucketKeysRemoved = "bucket-keys-removed" + streamRemoved = "stream-removed" // maxKeysShown bounds the keys an answer lists; the count is always whole. maxKeysShown = 200 ) @@ -120,9 +121,13 @@ func RemoveConsumer(ctx context.Context, bus Bus, said Said, a map[string]any) ( sctx, cancel = step(ctx) _, err = js.Consumer(sctx, stream, consumer) cancel() + // Only the bus saying it is not there is a removal; a read that failed otherwise confirms nothing. if err == nil { return nil, fmt.Errorf("consumer %s of %s is still there after the removal was accepted", consumer, stream) } + if !errors.Is(err, jetstream.ErrConsumerNotFound) && !errors.Is(err, jetstream.ErrStreamNotFound) { + return nil, unanswered(err, fmt.Sprintf("the removal of consumer %s of %s was accepted but could not be confirmed", consumer, stream)) + } out := ConsumerRemoved{Stream: stream, Consumer: consumer, Why: w, Was: was, Removed: true, At: time.Now().UTC()} if err := said(consumerRemoved, out); err != nil { return nil, fmt.Errorf("consumer %s of %s WAS removed (%s) but the audit event could not be said: %w", consumer, stream, w, err) @@ -227,7 +232,89 @@ func RemoveKeys(ctx context.Context, bus Bus, said Said, a map[string]any) (any, return out, nil } -// removalTools are the module's two tools that change the bus; every other tool here reads. +// StreamRemoved is what nats_stream_remove answers and says. +type StreamRemoved struct { + Stream string `json:"stream"` + Why string `json:"why"` + DryRun bool `json:"dry_run"` + Present bool `json:"present"` + Messages uint64 `json:"messages"` + Bytes uint64 `json:"bytes"` + Consumers int `json:"consumers"` + Removed bool `json:"removed"` + Said string `json:"said"` + At time.Time `json:"at"` +} + +// RemoveStream removes one named stream whole, after reading it, and says so (the lease write's step for the stream +// ASSIGNMENTS, novox/hq research 072 row 33). **A stream already gone is no error**: the removal is the same act +// however often it is asked, and says it found nothing. A stream with a consumer is refused, naming how many: a reader +// is still attached, and that is for a person to look at before the stream goes. +func RemoveStream(ctx context.Context, bus Bus, said Said, a map[string]any) (any, error) { + w, err := why(a) + if err != nil { + return nil, err + } + stream := arg(a, "stream") + if !name.MatchString(stream) { + return nil, errors.New("stream is required, one stream's name (letters, digits, - and _): nothing was removed") + } + dry, _ := a["dry_run"].(bool) + js, closeBus, err := bus(ctx) + if err != nil { + return nil, err + } + defer closeBus() + out := StreamRemoved{Stream: stream, Why: w, DryRun: dry, At: time.Now().UTC()} + sctx, cancel := step(ctx) + s, err := js.Stream(sctx, stream) + cancel() + if errors.Is(err, jetstream.ErrStreamNotFound) { + out.Said = stream + " is not there: nothing to remove" + return out, nil + } + if err != nil { + return nil, unanswered(err, fmt.Sprintf("the stream %s could not be read, so it was not removed", stream)) + } + sctx, cancel = step(ctx) + info, err := s.Info(sctx) + cancel() + if err != nil { + return nil, unanswered(err, fmt.Sprintf("the stream %s could not be read, so it was not removed", stream)) + } + out.Present, out.Messages, out.Bytes, out.Consumers = true, info.State.Msgs, info.State.Bytes, info.State.Consumers + if info.State.Consumers > 0 { + return nil, fmt.Errorf("%s has %d consumers: a reader is attached, so it was not removed", stream, info.State.Consumers) + } + if dry { + out.Said = fmt.Sprintf("%s holds %d messages (%d bytes) and no consumer; it would be removed", stream, out.Messages, out.Bytes) + return out, nil + } + sctx, cancel = step(ctx) + err = js.DeleteStream(sctx, stream) + cancel() + if err != nil { + return nil, unanswered(err, fmt.Sprintf("stream %s was not removed", stream)) + } + sctx, cancel = step(ctx) + _, err = js.Stream(sctx, stream) + cancel() + // Only the bus saying it is not there is a removal; a read that failed otherwise confirms nothing. + if err == nil { + return nil, fmt.Errorf("stream %s is still there after the removal was accepted", stream) + } + if !errors.Is(err, jetstream.ErrStreamNotFound) { + return nil, unanswered(err, fmt.Sprintf("the removal of stream %s was accepted but could not be confirmed", stream)) + } + out.Removed = true + out.Said = fmt.Sprintf("%s removed: %d messages (%d bytes) and no consumer", stream, out.Messages, out.Bytes) + if err := said(streamRemoved, out); err != nil { + return nil, fmt.Errorf("stream %s WAS removed (%s) but the audit event could not be said: %w", stream, w, err) + } + return out, nil +} + +// removalTools are the module's three tools that change the bus; every other tool here reads. func removalTools(bus Bus, said Said) []stdio.Tool { run := func(f func(context.Context, Bus, Said, map[string]any) (any, error)) func(map[string]any) (any, error) { return func(a map[string]any) (any, error) { return f(context.Background(), bus, said, a) } @@ -237,6 +324,10 @@ func removalTools(bus Bus, said Said) []stdio.Tool { Description: "Removes (deletes) one named JetStream consumer of one stream, e.g. the consumer `controller` of DEAD_LETTER_NOTICES at the lease write (novox/hq ADR 0564 clause 5). Needs `why`, kept in the audit trail; refuses an unknown stream or consumer; reads the consumer first and answers what it was (filters, pending, unacknowledged); the stream and its other consumers are not touched. The bus refuses any consumer the module's credential is not granted.", Input: map[string]any{"stream": str("the stream, e.g. DEAD_LETTER_NOTICES"), "consumer": str("the consumer's name, e.g. controller"), "why": str("why it is removed, for the audit trail (required)")}, Run: run(RemoveConsumer)}, + {Name: "nats_stream_remove", + Description: "Removes (deletes) one named JetStream stream whole, e.g. ASSIGNMENTS at the lease write, beside the consumer of DEAD_LETTER_NOTICES (novox/hq research 072 row 33). Needs `why`, kept in the audit trail; reads the stream first and answers its messages, bytes and consumers; refuses a stream that still has a consumer; a stream already gone is answered as such and is no error, so asking again is safe. `dry_run` only reads. The bus refuses any stream the module's credential is not granted.", + Input: map[string]any{"stream": str("the stream, e.g. ASSIGNMENTS"), "why": str("why it is removed, for the audit trail (required)"), "dry_run": map[string]any{"type": "boolean", "description": "read the stream, remove nothing"}}, + Run: run(RemoveStream)}, {Name: "nats_bucket_remove", Description: "Removes (purges) keys from a key-value bucket: one node's keys (`node`, the keys starting `.`) or the keys under a `prefix`, e.g. a full acts bucket of the node-engine. Needs `why`, kept in the audit trail; never the whole bucket. Counts the keys first and lists them (`dry_run` only counts; `expect` refuses when the count differs); removes only those keys, and their history. See the buckets and their keys with nats_buckets. The bus refuses any bucket the module's credential is not granted.", Input: map[string]any{"bucket": str("the bucket as nats_buckets lists it, e.g. mesh-host_acts"), "node": str("a node whose keys (`.`) go"), "prefix": str("key prefix instead of node"), diff --git a/modules/nats/cmd/nats-tools/removal_test.go b/modules/nats/cmd/nats-tools/removal_test.go index 2ed7799d..eb81522a 100644 --- a/modules/nats/cmd/nats-tools/removal_test.go +++ b/modules/nats/cmd/nats-tools/removal_test.go @@ -31,6 +31,8 @@ accounts { MESH { jetstream: enabled, users: [ publish: { allow: [ "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", + "$JS.API.STREAM.INFO.ASSIGNMENTS", + "$JS.API.STREAM.DELETE.ASSIGNMENTS", "$JS.API.STREAM.INFO.KV_mesh-host_acts", "$KV.mesh-host_acts.>" ] }, subscribe: { allow: ["_INBOX.nats-tools.>"] } } } @@ -73,6 +75,14 @@ accounts { MESH { jetstream: enabled, users: [ if err != nil { t.Fatal(err) } + if _, err := ajs.CreateStream(ctx, jetstream.StreamConfig{Name: "ASSIGNMENTS", Subjects: []string{"mesh.assignment.*.*"}, MaxMsgsPerSubject: 1}); err != nil { + t.Fatal(err) + } + for _, sub := range []string{"mesh.assignment.g14.a", "mesh.assignment.ace.b"} { + if _, err := ajs.Publish(ctx, sub, []byte("{}")); err != nil { + t.Fatal(err) + } + } other, _ := ajs.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "other_state"}) for _, k := range []string{"g14.1", "g14.2", "g14.sub.3", "g14x.4", "ace.5"} { acts.Put(ctx, k, []byte("v")) @@ -131,6 +141,30 @@ accounts { MESH { jetstream: enabled, users: [ t.Fatalf("a consumer already gone: %v", err) } + // Research 072 row 33: the stream ASSIGNMENTS goes whole, once; read first, and asking again is no error; a stream + // outside the grant is not touched. + if _, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "ASSIGNMENTS"}); err == nil || !strings.Contains(err.Error(), "why is required") { + t.Fatalf("a stream without why: %v", err) + } + if _, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "DEAD_LETTER_NOTICES", "why": "x"}); err == nil || !strings.Contains(err.Error(), "grant") { + t.Fatalf("a stream outside the grant: %v", err) + } + if d, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "ASSIGNMENTS", "why": "look", "dry_run": true}); err != nil || d.(StreamRemoved).Messages != 2 || d.(StreamRemoved).Removed { + t.Fatalf("a dry run: %+v %v", d, err) + } + if _, err := ajs.Stream(ctx, "ASSIGNMENTS"); err != nil { + t.Fatalf("a dry run removed the stream: %v", err) + } + if r, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "ASSIGNMENTS", "why": "the lease write"}); err != nil || !r.(StreamRemoved).Removed || r.(StreamRemoved).Messages != 2 { + t.Fatalf("the removal: %+v %v", r, err) + } + if _, err := ajs.Stream(ctx, "ASSIGNMENTS"); err == nil { + t.Fatal("ASSIGNMENTS is still there") + } + if r, err := RemoveStream(ctx, bus, say, map[string]any{"stream": "ASSIGNMENTS", "why": "again"}); err != nil || r.(StreamRemoved).Removed || r.(StreamRemoved).Present { + t.Fatalf("a second removal: %+v %v", r, err) + } + // N3: only the node's keys go; counted first; refused without why, for a whole bucket, and outside the grant. if _, err := RemoveKeys(ctx, bus, say, map[string]any{"bucket": "mesh-host_acts", "node": "g14"}); err == nil || !strings.Contains(err.Error(), "why is required") { t.Fatalf("without why: %v", err) @@ -167,7 +201,7 @@ accounts { MESH { jetstream: enabled, users: [ if k := keys(other); k != "g14.1" { t.Fatalf("another bucket changed: %s", k) } - if strings.Join(said, ",") != "consumer-removed,bucket-keys-removed" { + if strings.Join(said, ",") != "consumer-removed,stream-removed,bucket-keys-removed" { t.Fatalf("the audit events said: %v", said) } } diff --git a/modules/nats/cmd/nats-tools/tools_test.go b/modules/nats/cmd/nats-tools/tools_test.go index 2a2eca4a..7d1a8cdc 100644 --- a/modules/nats/cmd/nats-tools/tools_test.go +++ b/modules/nats/cmd/nats-tools/tools_test.go @@ -234,6 +234,10 @@ func TestTheRemovalToolsRefuseBeforeTheyReachTheBus(t *testing.T) { {"nats_bucket_remove", map[string]any{"bucket": "mesh-host_acts", "node": "g14", "prefix": "g", "why": "x"}, "exactly one of node"}, {"nats_bucket_remove", map[string]any{"bucket": "mesh-host_acts", "prefix": "g*", "why": "x"}, "without wildcards"}, {"nats_bucket_remove", map[string]any{"bucket": "", "node": "g14", "why": "x"}, "bucket is required"}, + {"nats_stream_remove", map[string]any{"stream": "ASSIGNMENTS"}, "why is required"}, + {"nats_stream_remove", map[string]any{"stream": "ASSIGNMENTS", "why": " "}, "why is required"}, + {"nats_stream_remove", map[string]any{"stream": "A.>", "why": "x"}, "stream is required"}, + {"nats_stream_remove", map[string]any{"stream": "", "why": "x"}, "stream is required"}, } { var run func(map[string]any) (any, error) for _, tl := range removalTools(bus, said) { diff --git a/modules/nats/go.mod b/modules/nats/go.mod index 8e261655..d17f3a20 100644 --- a/modules/nats/go.mod +++ b/modules/nats/go.mod @@ -3,7 +3,7 @@ module nats-tools go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a github.com/klauspost/compress v1.20.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/modules/nats/go.sum b/modules/nats/go.sum index a2b8e018..8e0c33a9 100644 --- a/modules/nats/go.sum +++ b/modules/nats/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/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/nats/module.json b/modules/nats/module.json index 7224731d..81bfc619 100644 --- a/modules/nats/module.json +++ b/modules/nats/module.json @@ -30,7 +30,8 @@ ], "emits": [ "consumer-removed", - "bucket-keys-removed" + "bucket-keys-removed", + "stream-removed" ], "consumes": [], "listens": [ @@ -54,7 +55,8 @@ "nats_user_can", "nats_snapshot_verify", "nats_consumer_remove", - "nats_bucket_remove" + "nats_bucket_remove", + "nats_stream_remove" ], "guards": [ 8222 @@ -208,7 +210,8 @@ ], "bus": { "removes": [ - "DEAD_LETTER_NOTICES.controller" + "DEAD_LETTER_NOTICES.controller", + "ASSIGNMENTS" ] }, "env": {