Keep holding a membership that would drop a capability its holder still asks for (issue 1945)
mesh/delivery merged: source.main moved to novox/mesh-delivery@da514816204b
mesh/check being carried to ec0b7e2dcc06 on da514816204b | from 24cb379434f5

A dropped verified-sender comes from a root-free word that failed for one
pass, not from a release, and losing it cut the operator's approvals on
2026-10-27. Holdings.CapabilitiesLost holds such a membership with its own
reason while the holder is assigned and its manifest still asks for it; a
dropped tool or field and a moved seat stay published.
This commit is contained in:
2026-10-27 20:56:09 +01:00
parent feb32600bb
commit ec0b7e2dcc
4 changed files with 76 additions and 13 deletions
+2 -1
View File
@@ -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)) }},
+24 -9
View File
@@ -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)))
@@ -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, `<node>/<module>/<seat>/<kind>/<capability>`.
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, `<seat> <kind>
// <capability> of <node>/<module>`, 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: "
+1 -1
View File
@@ -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 {