From 09791f5a8219de0af1b3f3c96f89af78ff4cace6 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 23:30:09 +0100 Subject: [PATCH] Answer the controller's lease, what still depends on it and the lease write's check in one call (issue 774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator asked to remove the controller by finishing its hand-over; no tool said who holds the lease, which manifests still read, invoke or consume mesh-controller, or whether the lease write's check (ADR 0571 §13) would pass. grants_lease_check answers all three from bus state on the grants part, which already reads the lease, every manifest at the indexer's trunk and the bus's streams: the holder and what follows from it, every dependency with the record on the named keep list that keeps it, the controller's streams and buckets someone consumes, and the check run dry at the write (the three kept to it alone) and before its delivery, with reasons. Quick fix: the judgement and the keep list in the grants part, as here. Thorough fix: the keep list and the dependency derivation in mesh-sdk, shared by this tool and the §13 manifest tests in mesh-catalog, mesh-delivery and mesh-tools. Chosen quick: the tests do not exist yet and an sdk move needs a group; the move is named for the lead. --- modules/mesh-grants/cmd/mesh-grants/tools.go | 12 + .../mesh-grants/internal/grants/leasecheck.go | 438 ++++++++++++++++++ modules/mesh-grants/internal/grants/tools.go | 2 +- modules/mesh-grants/leasecheck_test.go | 134 ++++++ modules/mesh-grants/module.json | 3 +- 5 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/leasecheck.go create mode 100644 modules/mesh-grants/leasecheck_test.go 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": [ {