diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index afa8482..822fb2b 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -86,7 +86,8 @@ func toolsOf(p *grants.Part) []stdio.Tool { "the assertions, publish each membership that differs from the one last published, and keep what changed in its own " + "state (one revision when an input changed, none when not), saying user-list-changed and membership-changed. Answers " + "the ask and the last pass; grants_user_list shows the pass that answers it. A membership is published unless it would " + - "leave a seat a placed module claims with no holder, which stays a shadow difference, named. The user list, assertions and " + + "leave a seat a placed module claims with no holder, or drop a capability its holder still asks for (verified-sender), " + + "which stays a shadow difference, named. The user list, assertions and " + "grants stay in shadow. It cannot ask the controller to write a module's grant again: the controller serves no such " + "verb, and the answer says so.", Run: func(a map[string]any) (any, error) { return p.AskCompose(stdio.CallerOf(a)) }}, diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 726ac69..f039e24 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1314,9 +1314,16 @@ func TestGrants(t *testing.T) { }) }) - t.Run("memberships: published unless a seat would lose its last holder", func(t *testing.T) { + t.Run("memberships: published unless a seat would lose its last holder or a holder a capability it asks for", func(t *testing.T) { const key = "a.mod-x" brokerName, brokerVerb := brokerSeat() + // The telegram module's claim of its channel, as mesh-catalog's telegram manifest has it (c05aa5e6), on mod-x. + const telegramClaim = `[{"name":"channel","scope":"mesh","kind":"telegram","capabilities":["deliver","verified-sender"]}]` + verified := func(m map[string]any) map[string]any { + m["seat-traffic"] = map[string]any{"kinds": []any{map[string]any{"seat": "channel", "kind": "telegram", "module": "mod-x", "node": "a", + "capabilities": []any{"deliver", "verified-sender"}}}} + return m + } composed := func(b *fakeBus) map[string]any { recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{})) raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership) @@ -1329,38 +1336,46 @@ func TestGrants(t *testing.T) { last func(map[string]any) map[string]any published bool names string + // claims, when set, are mod-x's claims in its manifest for the row. + claims string }{ - {"a first membership is published", nil, true, ""}, + {"a first membership is published", nil, true, "", ""}, {"an additive change is published, the private network carried", func(m map[string]any) map[string]any { delete(m, "state") m["mesh"] = []any{"10.0.0.1"} return m - }, true, ""}, - // The lead's decision in review round 1 of the fix of issue 1945: only a seat left with no holder holds a - // membership back; a dropped tool, field or capability is published. + }, true, "", ""}, + // The lead's decision in review round 1 of the fix of issue 1945: only a seat left with no holder, or a + // capability its holder still asks for (verified-sender, dropped by a root-free word that failed), holds a + // membership back; a dropped tool or field is published. {"a change that drops a tool is published", func(m map[string]any) map[string]any { m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.mod-x.tool.gone"}) m["mesh"] = []any{"10.0.0.1"} return m - }, true, ""}, + }, true, "", ""}, + {"a change that drops verified-sender its holder still asks for is not published, and named", verified, false, "channel telegram verified-sender of a/mod-x", telegramClaim}, + {"a change that drops verified-sender its holder no longer asks for is published", verified, true, `"published":"published"`, `[{"name":"channel","scope":"mesh","kind":"telegram"}]`}, {"a change that takes the last holder from a seat a placed module claims is not published, the seat named", func(m map[string]any) map[string]any { m["seats"] = []any{map[string]any{"seat": brokerName, "verb": brokerVerb, "subject": "mesh.seat." + brokerName + ".tool." + brokerVerb}} return m - }, false, "the last holder of " + brokerName}, + }, false, "the last holder of " + brokerName, ""}, // A verb the seat catalogue retired on purpose (mark-ready, mesh-sdk d506f21, ADR 0421) is dropped and named; a // verb of a seat the catalogue does not define is still a loss (issue 1945). {"a change that drops only a retired seat verb is published, the verb named", func(m map[string]any) map[string]any { m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}} return m - }, true, "mesh-delivery mark-ready"}, + }, true, "mesh-delivery mark-ready", ""}, {"a seat no placed module claims dropped is published", func(m map[string]any) map[string]any { m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}, map[string]any{"seat": "telegram-sender", "verb": "send", "subject": "mesh.seat.telegram-sender.tool.send"}} return m - }, true, "mesh-delivery mark-ready"}, + }, true, "mesh-delivery mark-ready", ""}, } { t.Run(row.name, func(t *testing.T) { b := fixture() + if row.claims != "" { + b.manifestOf("mod-x", strings.Replace(xManifest, `"own-secrets"`, `"claims":`+row.claims+`,"own-secrets"`, 1)) + } b.loaded = shadowOf(t, b) if row.last != nil { raw, _ := json.Marshal(row.last(composed(b))) diff --git a/modules/mesh-grants/internal/grants/memberships.go b/modules/mesh-grants/internal/grants/memberships.go index fbbcb24..af81735 100644 --- a/modules/mesh-grants/internal/grants/memberships.go +++ b/modules/mesh-grants/internal/grants/memberships.go @@ -104,7 +104,8 @@ func publish(p Publisher, m Membership, h Holdings, held map[string]bool) (Membe } // A membership is held back only when it would take the last holder from a seat some placed module still claims (the // 09:21 kind of issue 1945; the lead's decision in review round 1 of its fix, replacing the hold on any loss of review - // 1ff5049): a dropped tool or verb, or a seat handed to another node, is published. + // 1ff5049), or drop a capability a kind's holder still asks for (verified-sender, dropped by a root-free word that + // failed): a dropped tool or verb, or a seat handed to another node, is published. if found { kept, retired := WithoutRetired(was) m.Retired = retired @@ -115,6 +116,10 @@ func publish(p Publisher, m Membership, h Holdings, held map[string]bool) (Membe m.Published = ShadowDifference + "the last holder of " + strings.Join(lost, ", ") return m, false } + if lost := h.CapabilitiesLost(last, m.Membership); len(lost) > 0 { + m.Published = CapabilityDifference + strings.Join(lost, "; ") + return m, false + } } } if err := p.PublishMembership(m.Node, m.Module, body, seq); err != nil { @@ -200,12 +205,14 @@ const ShadowDifference = "not published, a shadow difference: publishing would l type Holdings struct { Claimed map[string]bool Scopes map[string]string + // Asked is every capability an assigned module's claim of a kind asks for, `////`. + Asked map[string]bool } // HoldingsOf is the holdings the assigned modules claim now, by their running manifest or the trunk's, each scope as the // seat's declaration says (the seat catalogue, or the manifest defining it), else the claim's. Pure. func HoldingsOf(in Inputs) Holdings { - h := Holdings{Claimed: map[string]bool{}, Scopes: map[string]string{}} + h := Holdings{Claimed: map[string]bool{}, Scopes: map[string]string{}, Asked: map[string]bool{}} for _, m := range in.Manifests { for _, d := range m.DefinesSeats { if d.Scope != "" { @@ -228,6 +235,9 @@ func HoldingsOf(in Inputs) Holdings { h.Scopes[seat] = c.At() } h.Claimed[h.key(seat, node)] = true + for _, capability := range c.Capabilities { + h.Asked[askedKey(node, module, seat, c.Kind, capability)] = true + } } } } @@ -301,3 +311,40 @@ func (h Holdings) LastHolders(before, now map[string]bool) []string { sort.Strings(out) return out } + +func askedKey(node, module, seat, kind, capability string) string { + return node + "/" + module + "/" + canonicalSeat(seat) + "/" + kind + "/" + capability +} + +// CapabilitiesLost is every capability a kind's holder carries in the membership in force (its seat traffic's kinds) that +// the composed one lacks while that holder is still assigned and its manifest's claim still asks for it, ` +// of /`, sorted. Such a drop comes from a word that failed for one pass (the controller's +// root-free verb drops verified-sender), not from a release, and losing verified-sender stops the operator's approvals by +// a channel (issue 1945; review 1ff5049's protection, kept by the lead in review round 1 of its fix). Pure. +func (h Holdings) CapabilitiesLost(last, now membership.Membership) []string { + if last.SeatTraffic == nil { + return nil + } + has := map[string]bool{} + if now.SeatTraffic != nil { + for _, k := range now.SeatTraffic.Kinds { + for _, c := range k.Capabilities { + has[askedKey(k.Node, k.Module, k.Seat, k.Kind, c)] = true + } + } + } + var out []string + for _, k := range last.SeatTraffic.Kinds { + for _, c := range k.Capabilities { + if key := askedKey(k.Node, k.Module, k.Seat, k.Kind, c); !has[key] && h.Asked[key] { + out = append(out, k.Seat+" "+k.Kind+" "+c+" of "+k.Node+"/"+k.Module) + } + } + } + sort.Strings(out) + return slices.Compact(out) +} + +// CapabilityDifference opens what a membership's pass says when it was not published because it would drop a capability +// its holder still asks for. +const CapabilityDifference = ShadowDifference + "a capability its holder still asks for: " diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 371f873..7756720 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -110,7 +110,7 @@ type Result struct { } // NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships. -const NothingTakesEffect = "memberships (on the assignments stream, unless one would leave a seat a placed module claims with no holder, which stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over" +const NothingTakesEffect = "memberships (on the assignments stream, unless one would leave a seat a placed module claims with no holder, or drop a capability its holder still asks for, which stays a shadow difference), and each bucket, log or stream a manifest declares that the bus lacks, created once with the manifest's limits (the operator, 2026-10-26 08:54); the user list, the grants and any change to a stream the bus holds stay in shadow until take-over" // Part is the grants part: it reads, calls and writes through these, and nothing else. type Part struct {