diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index beefe2b..8977fe2 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -159,6 +159,18 @@ func toolsOf(p *grants.Part) []stdio.Tool { "every act with what became of it (asked, switched, declined, expired, refused and why).", Input: schema(nil), Run: func(map[string]any) (any, error) { return p.TakeOverState() }}, + {Name: grants.ToolLeaseCheck, Description: "The controller's lease and the controller's hand-over, in one call from bus state (issue 774, " + + "design 47 L3; novox/hq ADR 0547 §11, ADR 0561 §3, ADR 0571 §13): who holds the controller lease now, since when, and what " + + "the holder and the parts watching the lease do because of it; what still depends on the controller: every module whose " + + "manifest at the indexer's trunk reads a mesh-controller state, invokes a mesh-controller verb or tool, consumes or waits on " + + "its events, with which one and the record that keeps it until the lease write (the named keep list), the unkept ones by module; " + + "the controller's remaining live duties as the bus shows them: each of its streams and buckets someone consumes (with the " + + "durable consumers by name), and its standing assignments; and a dry run of the lease write's check (the take-over check that " + + "hands the lease to a holder that is no controller): whether it would pass now, at the write with only the three kept to the " + + "write and before the write's delivery with the whole list, each with its exact reasons, and the write's other conditions " + + "with the tool that answers each. Asks the controller nothing; writes nothing. Read-only.", + Input: schema(nil), + Run: func(map[string]any) (any, error) { return p.LeaseCheck() }}, } } diff --git a/modules/mesh-grants/internal/grants/leasecheck.go b/modules/mesh-grants/internal/grants/leasecheck.go new file mode 100644 index 0000000..d88bfc5 --- /dev/null +++ b/modules/mesh-grants/internal/grants/leasecheck.go @@ -0,0 +1,438 @@ +package grants + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// **The controller's lease, what still depends on the controller, and a dry run of the lease write's check** (issue 774, +// design 47 L3; novox/hq ADR 0547 §11, ADR 0561 §3, ADR 0571 §13). One read-only answer from bus state: the lease key, +// every manifest the indexer holds, and the bus module's streams. It writes nothing, and asks the controller nothing. +// +// It lives on the grants part because the part is where the lease write takes most of its effect and already reads all +// it needs: the lease (buscompose.NamedReaders names it), every manifest at the indexer's trunk, placement, and the bus's +// streams through the bus module's read tools. No record names the home of the lease write's check; ADR 0571 §13 names +// its test in three repositories, and this is the same judgement over the manifests the mesh holds now. + +// ToolLeaseCheck is the tool's name. +const ToolLeaseCheck = "grants_lease_check" + +// Controller is the module and the seat the check is about. +const Controller = "mesh-controller" + +// The kinds of a dependency on the controller, as a manifest declares them. +const ( + DependsRead = "read" + DependsInvoke = "invoke" + DependsConsume = "consume" + DependsUses = "uses" + DependsWaits = "waits" +) + +// The phases of the lease write's check (ADR 0571 §13): before the lease write's own delivery the whole named list is +// allowed; that delivery drops every entry but the three kept to the write itself, and its check runs with those alone. +const ( + PhaseBefore = "before-delivery" + PhaseWrite = "write" +) + +// Dependency is one declaration of a manifest other than the controller's on the controller: a read of one of its +// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, or a send to its seat. +type Dependency struct { + Module string `json:"module"` + // By is the account declaring it when it is not the module's own: `process ` or `tools `. + By string `json:"by,omitempty"` + Kind string `json:"kind"` + What string `json:"what"` + // Kept is the record that keeps it until the lease write, from the named list; empty when none does. + Kept string `json:"kept,omitempty"` + // ToTheWrite says it is one of the three kept to the write itself, which the lease write's own delivery keeps. + ToTheWrite bool `json:"to_the_write,omitempty"` +} + +// Kept is one entry of the named, temporary list ADR 0571 §13 allows: a read, invoke or consume another accepted record +// keeps until the lease write, with that record. ToTheWrite marks the three kept to the write itself. +type Kept struct { + Module, Kind, What, Record string + ToTheWrite bool +} + +// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. The +// third entry kept to the write, the bus node agent's following of the controller's user list (ADR 0571 §8, ADR 0547 +// §8), is no manifest's declaration but the node-engine's own, so no manifest matches it; it stays named here so the +// list reads as the record has it. +var KeepList = []Kept{ + // Kept to the write itself (ADR 0571 §4, §8, §13). + {Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true}, + {Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true}, + {Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true}, + // Kept until the lease write's own delivery. + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.placement-export", Record: "ADR 0567 §7"}, + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.assign", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.unassign", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.pin", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsInvoke, What: "seat:mesh-controller.unpin", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsRead, What: "mesh-controller.assignments", Record: "ADR 0567 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsRead, What: "mesh-controller.composed", Record: "ADR 0571 §8"}, + {Module: "mesh-delivery-assigner", Kind: DependsRead, What: "mesh-controller.grants", Record: "ADR 0567 §10"}, + {Module: "mesh-grants", Kind: DependsRead, What: "mesh-controller.assignments", Record: "ADR 0549 §5"}, + {Module: "mesh-grants", Kind: DependsRead, What: "mesh-controller.grants", Record: "ADR 0547 §5"}, + {Module: "mesh-grants", Kind: DependsConsume, What: "mesh-controller.condition-raised", Record: "ADR 0564 §9"}, + {Module: "mesh-grants", Kind: DependsConsume, What: "mesh-controller.condition-cleared", Record: "ADR 0564 §9"}, + {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-raised", Record: "ADR 0564 §9"}, + {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-cleared", Record: "ADR 0564 §9"}, + {Module: "messenger", Kind: DependsConsume, What: "mesh-controller.condition-changed", Record: "ADR 0564 §9"}, + {Module: "messenger", Kind: DependsInvoke, What: "seat:mesh-controller.conditions", Record: "ADR 0564 §9"}, + {Module: "mesh-watcher", Kind: DependsConsume, What: "mesh-controller.doctor-heartbeat", Record: "ADR 0564 §9"}, + {Module: "mesh-conditions", Kind: DependsRead, What: "mesh-controller.conditions", Record: "ADR 0564 §9"}, + {Module: "mesh-config", Kind: DependsRead, What: "mesh-controller.declared-config", Record: "ADR 0565 §4"}, + {Module: "mesh-config", Kind: DependsRead, What: "mesh-controller.predefined", Record: "ADR 0565 §4"}, + {Module: "mesh-healers", Kind: DependsRead, What: "mesh-controller.hand-acts", Record: "ADR 0565 §11"}, + {Module: "mesh-hand-acts", Kind: DependsRead, What: "mesh-controller.hand-acts", Record: "ADR 0565 §11"}, +} + +// keptFor is the list's entry for one dependency, or nil. +func keptFor(d Dependency) *Kept { + for i, k := range KeepList { + if k.Module == d.Module && k.Kind == d.Kind && k.What == d.What { + return &KeepList[i] + } + } + return nil +} + +// onController says whether a name `.` is the controller's. +func onController(name string) bool { return strings.HasPrefix(name, Controller+".") } + +// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none: +// it is the console's grant to every tool, judged by no record as a dependency. +func invokesController(invoke string) bool { + if seat, _, ok := catalogue.SeatInvoke(invoke); ok { + return seat == Controller + } + return onController(invoke) +} + +// Dependencies are every declaration on the controller in the manifests given, by module, each with the list's entry +// that keeps it. The controller's own manifest is not judged, nor a read of its lease key: it names the lease's holder, +// whoever that is, and parts read it to learn that the write happened (ADR 0571 §13). +func Dependencies(manifests map[string]catalogue.Manifest) []Dependency { + var out []Dependency + add := func(module, by, kind, what string) { + d := Dependency{Module: module, By: by, Kind: kind, What: what} + if k := keptFor(d); k != nil { + d.Kept, d.ToTheWrite = k.Record, k.ToTheWrite + } + if !slices.Contains(out, d) { + out = append(out, d) + } + } + for name, m := range manifests { + if name == Controller || m.Module == Controller { + continue + } + for _, r := range m.Reads { + if onController(r) && r != buscompose.ControllerLease { + add(name, "", DependsRead, r) + } + } + for _, i := range m.Invokes { + if invokesController(i) { + add(name, "", DependsInvoke, i) + } + } + for _, c := range m.Consumes { + if onController(c) { + add(name, "", DependsConsume, c) + } + } + for _, w := range m.Waits { + if onController(w) { + add(name, "", DependsWaits, w) + } + } + for _, u := range m.Uses { + if u == Controller { + add(name, "", DependsUses, u) + } + } + for _, a := range m.BusAccounts() { + by := "process " + a.ID + if a.Tools { + by = "tools " + a.ID + } + for _, i := range a.Bus.Invokes { + if invokesController(i) { + add(name, by, DependsInvoke, i) + } + } + } + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.Module != b.Module { + return a.Module < b.Module + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + if a.What != b.What { + return a.What < b.What + } + return a.By < b.By + }) + return out +} + +// Verdict is the lease write's check in one phase. +type Verdict struct { + Phase string `json:"phase"` + Passes bool `json:"passes"` + Reasons []string `json:"reasons"` +} + +// Judge runs the lease write's check over the dependencies (ADR 0571 §13) in one phase: before the lease write's own +// delivery, every dependency must be on the named list; at the write, only the three kept to the write itself may stay. +// A manifest that could not be read fails it too, since it could hide a dependency; and a lease already handed away +// leaves nothing to write. +func Judge(deps []Dependency, phase string, unread []Gap, handedAway bool, leaseSays string) Verdict { + v := Verdict{Phase: phase, Reasons: []string{}} + if handedAway { + v.Reasons = append(v.Reasons, "there is nothing to write: "+leaseSays) + } + for _, d := range deps { + who := d.Module + if d.By != "" { + who += " (" + d.By + ")" + } + switch { + case d.Kept == "": + v.Reasons = append(v.Reasons, fmt.Sprintf("%s declares %s %s, which no record keeps until the lease write (ADR 0571 §13)", who, d.Kind, d.What)) + case phase == PhaseWrite && !d.ToTheWrite: + v.Reasons = append(v.Reasons, fmt.Sprintf("%s declares %s %s, kept by %s only until the lease write's own delivery, which drops it", who, d.Kind, d.What, d.Kept)) + } + } + for _, g := range unread { + v.Reasons = append(v.Reasons, fmt.Sprintf("%s could not be read (%s), so what it declares on the controller is not known", g.Input, g.Why)) + } + v.Passes = len(v.Reasons) == 0 + return v +} + +// LeaseNow is who holds the lease and what follows from it. +type LeaseNow struct { + HandedAway bool `json:"handed_away"` + Says string `json:"says"` + Holder *buscompose.LeaseHolder `json:"holder,omitempty"` + Since *time.Time `json:"since,omitempty"` + Does []string `json:"does"` +} + +// While the controller holds the lease, and once it is handed away: what the holder and the parts watching the key do +// because of it, by the records that say so. +var ( + DoesWhileController = []string{ + "the controller mints every new bus account; the vault mints none and says what it holds back (ADR 0561 §3)", + "the grants part creates no durable consumer an account needs, and only compares them (ADR 0561 §6)", + "the grants part publishes memberships on the controller's assignments stream beside its own state (ADR 0571 §10)", + "mesh-healers writes each act it would take in `would`, and acts on none (ADR 0564 §8)", + "the artifact sweep lets go of nothing; the controller's take-in sweeps and says `artifacts-gone` (ADR 0571 §4)", + "the watchman only lists dead letters; the controller keeps DEAD_LETTERS through its consumer of DEAD_LETTER_NOTICES (ADR 0564 §5)", + "placement proposals take only assignments, pins and config values, and the assigner keeps the controller's assignments in step (ADR 0567 §8, §9)", + "the controller keeps composing for the bus node, whose agent follows its user list, so the hand-backs of config, conditions, placement and the user list still answer (ADR 0547 §8, ADR 0564 §9, ADR 0565 §4, ADR 0567 §11)", + } + DoesHandedAway = []string{ + "the vault mints every new bus account (ADR 0561 §1)", + "the grants part creates the durable consumers an account needs, and stops publishing on the assignments stream (ADR 0561 §6, ADR 0571 §10)", + "mesh-healers acts (ADR 0564 §8)", + "the artifact sweep sweeps and records every deletion in the hand-act log (ADR 0571 §4)", + "the watchman keeps the dead letters (ADR 0564 §5)", + "every hand-back is answered as gone (ADR 0547 §11, ADR 0564 §9, ADR 0565 §4, ADR 0567 §11)", + } +) + +// Lease reads the lease key and says who holds it, since when, and what follows. +func Lease(r Reader) LeaseNow { + raw, found, err := r.Get(ReadLease, LeaseKey) + away, says := buscompose.LeaseHandedAway(raw, found, err) + out := LeaseNow{HandedAway: away, Says: says, Does: DoesWhileController} + if away { + out.Does = DoesHandedAway + } + if err == nil && found && len(raw) > 0 { + var h buscompose.LeaseHolder + if json.Unmarshal(raw, &h) == nil { + out.Holder = &h + if !h.Taken.IsZero() { + t := h.Taken + out.Since = &t + } + } + } + return out +} + +// StreamUse is one of the controller's streams (its buckets, the assignments stream, the dead-letter notices) as the bus +// module answers it: who consumes it now, and what it last carried. +type StreamUse struct { + Stream string `json:"stream"` + Consumers int `json:"consumers"` + Durable []string `json:"durable,omitempty"` + Messages int64 `json:"messages"` + Subjects int64 `json:"subjects_held,omitempty"` + Last string `json:"last,omitempty"` +} + +// The streams of the controller's that are not its buckets: the assignments stream it publishes memberships on, and the +// notices its consumer `controller` turns into dead letters until the lease write (ADR 0564 §5). +const ( + StreamAssignments = "ASSIGNMENTS" + StreamDeadLetterNotices = "DEAD_LETTER_NOTICES" +) + +// controllerStream says whether a stream is the controller's. +func controllerStream(name string) bool { + return strings.HasPrefix(name, "KV_"+Controller+"_") || name == StreamAssignments || name == StreamDeadLetterNotices +} + +// Duties reads, from the bus module's streams, every stream of the controller's someone consumes, with its durable +// consumers by name (a watcher of a bucket is ephemeral and names nobody). Streams with no consumer are counted apart. +func Duties(c Caller) (used []StreamUse, idle []string, err error) { + raw, err := c.Call(CallStreams, map[string]any{}) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", CallStreams, err) + } + type consumer struct { + Name string `json:"name"` + Kind string `json:"kind"` + Max int `json:"max_deliver"` + Ack string `json:"ack_policy"` + } + type stream struct { + Name string `json:"name"` + Consumers int `json:"consumers"` + Messages int64 `json:"messages"` + Held int64 `json:"subjects_held"` + Last string `json:"last"` + Detail []consumer `json:"consumer_detail"` + } + var got struct { + Detail []stream `json:"detail"` + } + if err := json.Unmarshal(unwrap(raw), &got); err != nil { + return nil, nil, fmt.Errorf("%s answered no streams: %w", CallStreams, err) + } + for _, s := range got.Detail { + if !controllerStream(s.Name) { + continue + } + if s.Consumers == 0 { + idle = append(idle, s.Name) + continue + } + u := StreamUse{Stream: s.Name, Consumers: s.Consumers, Messages: s.Messages, Subjects: s.Held, Last: s.Last} + if one, err := c.Call(CallStreams, map[string]any{"stream": s.Name}); err == nil { + var d struct { + Detail []stream `json:"detail"` + } + if json.Unmarshal(unwrap(one), &d) == nil && len(d.Detail) > 0 { + for _, cs := range d.Detail[0].Detail { + if durable(cs.Name) { + u.Durable = append(u.Durable, cs.Name) + } + } + } + } + used = append(used, u) + } + sort.Slice(used, func(i, j int) bool { return used[i].Stream < used[j].Stream }) + sort.Strings(idle) + return used, idle, nil +} + +// durable says whether a consumer's name is one a part gave it: the bus names an ephemeral watcher with eight random +// letters and digits, which no part of the mesh uses. +func durable(name string) bool { + if len(name) != 8 { + return true + } + for _, r := range name { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return true + } + } + return false +} + +// LeaseAnswer is the tool's answer. +type LeaseAnswer struct { + Lease LeaseNow `json:"lease"` + Dependencies []Dependency `json:"dependencies"` + // Unkept are the dependencies no record keeps, by module: what the check refuses in every phase. + Unkept map[string][]string `json:"unkept"` + Manifests int `json:"manifests_read"` + Unread []Gap `json:"manifests_unread,omitempty"` + // Streams are the controller's streams someone consumes now; Idle those no one does. + Streams []StreamUse `json:"streams,omitempty"` + Idle []string `json:"streams_idle,omitempty"` + StreamsError string `json:"streams_unread,omitempty"` + // Assignments is how many of the controller's assignments stand, which it publishes memberships and grants for. + Assignments int `json:"assignments_standing"` + // Check is the dry run at the write (the three alone), Before the same over the whole list before the write's + // delivery. + Check Verdict `json:"check"` + Before Verdict `json:"check_before_delivery"` + Beside []string `json:"not_judged_here"` +} + +// NotJudgedHere are the lease write's other conditions, each with the tool that answers it (design 47 L3, ADR 0547 §11). +var NotJudgedHere = []string{ + "L4 to L7 delivered: the issue tracker's milestones 1952, 1953, 1954 and 1955", + "every node's record trusts the vault's key: mesh-delivery-view.node_trusts (ADR 0550 §2)", + "mesh-config's compare empty and its take-over done, and mesh-wants.upgrade compare empty: mesh-config.compare, mesh-wants.upgrade (ADR 0565 §14)", + "the condition keeper's take-over done: mesh-conditions.compare (ADR 0564 §9)", +} + +// LeaseCheck answers the tool: who holds the lease and what follows, every dependency on the controller with what keeps +// it, the controller's streams in use, and the lease write's check run dry. Read-only. +func (p *Part) LeaseCheck() (any, error) { + in := ReadInputs(p.Read, &p.cache) + out := LeaseAnswer{Lease: Lease(p.Read), Unkept: map[string][]string{}, Manifests: len(in.Manifests), Unread: in.Refused, + Beside: NotJudgedHere} + for _, g := range in.Gaps { + if g.Failed && strings.HasPrefix(g.Input, ReadModules) { + out.Unread = append(out.Unread, g) + } + } + out.Dependencies = Dependencies(in.Manifests) + if out.Dependencies == nil { + out.Dependencies = []Dependency{} + } + for _, d := range out.Dependencies { + if d.Kept == "" { + out.Unkept[d.Module] = append(out.Unkept[d.Module], d.Kind+" "+d.What) + } + } + var gaps []Gap + for _, raw := range each(p.Read, ReadAssignments, &gaps) { + var a assignment + if json.Unmarshal(raw, &a) == nil && a.stands() { + out.Assignments++ + } + } + if used, idle, err := Duties(p.Call); err != nil { + out.StreamsError = err.Error() + } else { + out.Streams, out.Idle = used, idle + } + out.Check = Judge(out.Dependencies, PhaseWrite, out.Unread, out.Lease.HandedAway, out.Lease.Says) + out.Before = Judge(out.Dependencies, PhaseBefore, out.Unread, out.Lease.HandedAway, out.Lease.Says) + return out, nil +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 3c0bb57..dcc3911 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -29,7 +29,7 @@ const ( // Tools is every tool, in the manifest's order. var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState, - ToolAccounts, ToolConsumerMatch, ToolAcceptHashes} + ToolAccounts, ToolConsumerMatch, ToolAcceptHashes, ToolLeaseCheck} // WritesGrants is what grants says of the part's own grants in this milestone: written, and read by nobody until the // controller's lease is handed away (delivery.GrantSource). diff --git a/modules/mesh-grants/leasecheck_test.go b/modules/mesh-grants/leasecheck_test.go new file mode 100644 index 0000000..e6cdd12 --- /dev/null +++ b/modules/mesh-grants/leasecheck_test.go @@ -0,0 +1,134 @@ +package manifest + +import ( + "encoding/json" + "slices" + "strings" + "testing" + + "mesh-grants/internal/grants" +) + +// Issue 774, design 47 L3 (ADR 0547 §11, ADR 0561 §3, ADR 0571 §13): grants_lease_check answers from bus state who holds +// the controller's lease, every manifest's dependency on the controller with what keeps it, the controller's streams in +// use, and the lease write's check run dry, at the write (the three kept to it alone) and before its delivery. +func TestTheLeaseCheckJudgesEveryDependencyOnTheController(t *testing.T) { + controllerHolds := map[string]any{"instance": "controller@novox-be pid 1 since 2026-10-30T14:52:10Z", "epoch": 60, + "taken": "2026-10-30T14:52:10Z", "renewed": "2026-10-30T22:00:00Z"} + handedAway := map[string]any{"handed-to": "the operator's hand-over", "handed-by": "operator"} + type want struct { + deps []string // " " with kept as "kept" or "unkept" + writePasses bool + beforePasses bool + reason string // a reason the check at the write must name, or "" + handedAway bool + durable []string // durable consumers of KV_mesh-controller_assignments + } + for _, row := range []struct { + name string + manifests map[string]string + lease map[string]any + want want + }{ + {"a read of the lease alone is no dependency, and the controller's own manifest is not judged", + map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`, + "mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`}, + controllerHolds, want{writePasses: true, beforePasses: true}}, + {"the builder's consume of artifacts-gone is kept to the write itself", + map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`}, + controllerHolds, want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept"}, writePasses: true, beforePasses: true}}, + {"the assigner's read of composed is kept until the write's delivery, which drops it", + map[string]string{"mesh-delivery-assigner": `{"module":"mesh-delivery-assigner","version":"1","reads":["mesh-controller.composed","mesh-controller.lease"]}`}, + controllerHolds, want{deps: []string{"mesh-delivery-assigner read mesh-controller.composed kept"}, beforePasses: true, + reason: "mesh-delivery-assigner declares read mesh-controller.composed, kept by ADR 0571 §8 only until the lease write's own delivery"}}, + {"a read no record keeps fails both phases, named", + map[string]string{"pacman": `{"module":"pacman","version":"1","reads":["mesh-controller.declared-packages"]}`}, + controllerHolds, want{deps: []string{"pacman read mesh-controller.declared-packages unkept"}, + reason: "pacman declares read mesh-controller.declared-packages, which no record keeps until the lease write"}}, + {"a seat verb and a module tool of the controller's, a consume and a wait are each one; every tool (*) is none", + map[string]string{"mod-i": `{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` + + `"consumes":["mesh-controller.checked","mod-x.thing"],"uses":["mesh-controller"]}`, + "node-tools": `{"module":"node-tools","version":"1","waits":["mesh-controller.checked"]}`}, + controllerHolds, want{deps: []string{"mod-i consume mesh-controller.checked unkept", "mod-i invoke mesh-controller.status unkept", + "mod-i invoke seat:mesh-controller.root-free unkept", "mod-i uses mesh-controller unkept", "node-tools waits mesh-controller.checked unkept"}, + reason: "mod-i declares invoke seat:mesh-controller.root-free, which no record keeps"}}, + {"a process's own invoke of the controller is its module's dependency, named by the process", + map[string]string{"mod-p": `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},{"id":"worker","type":"process","run":["./w"],"user":"mod-p",` + + `"bus":{"invokes":["seat:mesh-controller.held"]}}]}`}, + controllerHolds, want{deps: []string{"mod-p invoke seat:mesh-controller.held unkept"}, + reason: "mod-p (process worker) declares invoke seat:mesh-controller.held"}}, + {"a lease already handed away leaves nothing to write", + map[string]string{}, + handedAway, want{handedAway: true, reason: "there is nothing to write: the lease is handed to the operator's hand-over"}}, + } { + t.Run(row.name, func(t *testing.T) { + b := newFakeBus() + for m, raw := range row.manifests { + b.manifestOf(m, raw) + } + b.put(grants.ReadLease, grants.LeaseKey, row.lease) + b.streams = []map[string]any{ + {"name": "KV_mesh-controller_assignments", "consumers": 3, "messages": 2991, "subjects_held": 40, "last": "2026-10-30T12:55:13Z"}, + {"name": "KV_mesh-controller_rollouts", "consumers": 0, "messages": 6652}, + {"name": "KV_mesh-grants_memberships", "consumers": 243, "messages": 1573}, + } + b.consumers = map[string][]map[string]any{"KV_mesh-controller_assignments": {{"name": "0AYfjxta"}, {"name": "1g9XJFUX"}, {"name": "grants-assignments"}}} + raw, err := partOn(b).LeaseCheck() + if err != nil { + t.Fatal(err) + } + var got grants.LeaseAnswer + data, _ := json.Marshal(raw) + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + var deps []string + for _, d := range got.Dependencies { + kept := "unkept" + if d.Kept != "" { + kept = "kept" + } + deps = append(deps, d.Module+" "+d.Kind+" "+d.What+" "+kept) + } + if !slices.Equal(deps, row.want.deps) { + t.Errorf("dependencies %q, want %q", deps, row.want.deps) + } + if got.Check.Phase != grants.PhaseWrite || got.Check.Passes != row.want.writePasses { + t.Errorf("check at the write passes=%v (%v), want %v", got.Check.Passes, got.Check.Reasons, row.want.writePasses) + } + if got.Before.Phase != grants.PhaseBefore || got.Before.Passes != row.want.beforePasses { + t.Errorf("check before the delivery passes=%v (%v), want %v", got.Before.Passes, got.Before.Reasons, row.want.beforePasses) + } + if row.want.reason != "" && !slices.ContainsFunc(got.Check.Reasons, func(r string) bool { return strings.Contains(r, row.want.reason) }) { + t.Errorf("reasons %q name nothing like %q", got.Check.Reasons, row.want.reason) + } + if got.Lease.HandedAway != row.want.handedAway { + t.Errorf("lease handed away %v (%s), want %v", got.Lease.HandedAway, got.Lease.Says, row.want.handedAway) + } + if !row.want.handedAway && (got.Lease.Since == nil || got.Lease.Since.Format("15:04") != "14:52" || + !strings.Contains(got.Lease.Says, "the controller holds the lease") || !slices.Equal(got.Lease.Does, grants.DoesWhileController)) { + t.Errorf("lease %+v: the controller's instance since 14:52, and what it does while it holds it", got.Lease) + } + if len(got.Streams) != 1 || got.Streams[0].Stream != "KV_mesh-controller_assignments" || got.Streams[0].Consumers != 3 || + !slices.Equal(got.Streams[0].Durable, []string{"grants-assignments"}) || !slices.Equal(got.Idle, []string{"KV_mesh-controller_rollouts"}) { + t.Errorf("streams %+v idle %v: the controller's assignments bucket with its one durable consumer, the rollouts idle, no other module's", got.Streams, got.Idle) + } + }) + } +} + +// Every entry of the keep list names the record that keeps it, and exactly three are kept to the write itself (ADR 0571 §13). +func TestTheKeepListNamesItsRecordsAndThreeToTheWrite(t *testing.T) { + toTheWrite := 0 + for _, k := range grants.KeepList { + if !strings.HasPrefix(k.Record, "ADR ") || k.Module == "" || k.What == "" { + t.Errorf("entry %+v names no record, module or declaration", k) + } + if k.ToTheWrite { + toTheWrite++ + } + } + if toTheWrite != 3 { + t.Errorf("%d entries kept to the write itself, ADR 0571 §13 names three", toTheWrite) + } +} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 822b9e3..cc7ecc5 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -52,7 +52,8 @@ "grants_hand_back", "grants_accounts", "grants_consumer_match", - "grants_accept_hashes" + "grants_accept_hashes", + "grants_lease_check" ], "state": [ {