Hold a list or membership only when a seat would lose its last holder (issue 1945)

Holding the user list on any loss would freeze it after take-over at the
first release that drops a tool or moves a seat (ADR 0547 §7), with nothing
to accept a deliberate loss; memberships had the same gap. The lead's decision
in review round 1: one pure guard, Holdings.LastHolders, holds the list and
each membership only when a seat held in force would have no holder in what
is composed now while an assigned module still claims it, the 09:21 kind.
A dropped tool, verb or field, or a seat handed to another node, is written.
This commit is contained in:
2026-10-27 20:54:30 +01:00
parent bc1978ec67
commit feb32600bb
6 changed files with 208 additions and 141 deletions
+2 -2
View File
@@ -85,8 +85,8 @@ func toolsOf(p *grants.Part) []stdio.Tool {
"the part's process (which alone holds the grant to publish a membership) to compose the memberships, the user list and " +
"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 only when it is " +
"the first or only adds to the last one; any loss stays a shadow difference, named. The user list, assertions and " +
"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 " +
"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)) }},
+80 -34
View File
@@ -52,6 +52,8 @@ type fakeBus struct {
// creates are the streams the part asked to create, refused when refuseCreate.
creates []string
refuseCreate bool
// refuseUsers refuses the bus module's nats_users: the loaded list cannot be read.
refuseUsers bool
// rootFree answers the controller's root-free verb: nil is no answerer at all (no responders).
rootFree func(machines []string) (any, error)
}
@@ -155,6 +157,9 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
args, _ := body.(map[string]any)
switch key {
case grants.CallUsers:
if b.refuseUsers {
return nil, errors.New("nats: no responders available for request")
}
part, _ := args["user"].(string)
if part == "" {
var names []map[string]any
@@ -248,6 +253,17 @@ const (
yManifest = `{"module":"mod-y","version":"1","own-secrets":{"broker":"/var/lib/y/broker"},"reads":["mod-x.kept"]}`
)
// brokerSeat is one verb of the bus's seat as a membership serves it: nats claims the seat in fixture and no membership
// composed there serves it, so a membership or list that drops it leaves the seat with no holder (issue 1945).
func brokerSeat() (seat, verb string) {
s, _ := catalogue.SeatNamed(catalogue.BrokerSeat)
verb = "v"
if len(s.Serves) > 0 {
verb = s.Serves[0].Name
}
return s.Name, verb
}
// fixture is a mesh of two machines: a runs the bus and mod-x, b runs mod-y; the bus node's agent loaded a list.
func fixture() *fakeBus {
b := newFakeBus()
@@ -843,25 +859,32 @@ func TestGrants(t *testing.T) {
t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused)
}
}
// The user list's loss guard (issue 1945): the membership's Losses, per user of the list in force.
u := func(user string, responses bool, sub ...string) grants.User {
return grants.User{User: user, Subscribe: sub, Publish: []string{}, AllowResponses: responses}
// The guard of the user list and the memberships (issue 1945, the lead's decision in review round 1 of its fix): a
// seat the list in force holds left with no holder while a placed module still claims it, nothing else.
sm, _ := catalogue.SeatNamed(catalogue.ServiceManagerSeat)
nodeSeat := func(node string) string {
return "mesh.seat." + sm.Name + ".tool." + sm.Serves[0].Name + "." + node
}
h := grants.HoldingsOf(grants.Inputs{Assigned: map[string][]string{"novox": {"postgres", "systemd"}, "ace": {"postgres", "systemd"}},
Manifests: map[string]catalogue.Manifest{
"postgres": {Module: "postgres", Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}},
"systemd": {Module: "systemd", Claims: []catalogue.Claim{{Name: sm.Name, Scope: catalogue.ScopeNode}}}}})
u := func(user string, sub ...string) grants.User { return grants.User{User: user, Subscribe: sub} }
for _, row := range []struct {
name string
inForce, now []grants.User
gone []string
lost int
lost []string
}{
{"a seat grant dropped", []grants.User{u("a.node-tools", false, "x", store)}, []grants.User{u("a.node-tools", false, "x")}, nil, 1},
{"only added", []grants.User{u("a.node-tools", false, "x")}, []grants.User{u("a.node-tools", true, "x", store)}, nil, 0},
{"a retired verb dropped", []grants.User{u("a.node-tools", false, "x", retired)}, []grants.User{u("a.node-tools", false, "x")}, nil, 0},
{"responses taken away", []grants.User{u("a.node-tools", true, "x")}, []grants.User{u("a.node-tools", false, "x")}, nil, 1},
{"a user dropped", []grants.User{u("a.node-tools", false), u("a.mod-x", false)}, []grants.User{u("a.node-tools", false)}, nil, 1},
{"a user gone from placement", []grants.User{u("a.node-tools", false), u("a.mod-x", false)}, []grants.User{u("a.node-tools", false)}, []string{"a.mod-x"}, 0},
{"09:21: the seat's holder lost, its claimant still placed", []grants.User{u("novox.node-tools", "x", store)}, []grants.User{u("novox.node-tools", "x")}, []string{"mesh-store"}},
{"a dropped tool", []grants.User{u("novox.node-tools", "mesh.mod.postgres.tool.gone", store)}, []grants.User{u("novox.node-tools", store)}, nil},
{"a seat moved from novox to ace", []grants.User{u("novox.node-tools", store)}, []grants.User{u("novox.node-tools"), u("ace.node-tools", store)}, nil},
{"a seat no placed module claims any more", []grants.User{u("novox.node-tools", "mesh.seat.unclaimed.tool.v")}, []grants.User{u("novox.node-tools")}, nil},
{"a retired verb dropped", []grants.User{u("novox.node-tools", retired)}, []grants.User{u("novox.node-tools")}, nil},
{"a node seat lost on novox, held on ace", []grants.User{u("novox.node-tools", nodeSeat("novox")), u("ace.node-tools", nodeSeat("ace"))},
[]grants.User{u("novox.node-tools"), u("ace.node-tools", nodeSeat("ace"))}, []string{sm.Name + "@novox"}},
} {
if lost := grants.UserLosses(row.inForce, row.now, row.gone); len(lost) != row.lost {
t.Errorf("%s: lost %v, want %d", row.name, lost, row.lost)
if lost := h.LastHolders(h.ByUsers(row.inForce), h.ByUsers(row.now)); !slices.Equal(lost, row.lost) {
t.Errorf("%s: lost %v, want %v", row.name, lost, row.lost)
}
}
})
@@ -928,11 +951,12 @@ func TestGrants(t *testing.T) {
if third := p.Compose(); third.Written || third.MembershipsWritten != 0 || len(third.Published) != 0 || len(b.publishes) != 3 {
t.Errorf("a membership equal to the one last published was published again, or a state written: %+v", third)
}
// mod-x placed on b too: b.mod-x is added and its first membership published; a.mod-x's would lose the module's
// plain subject (mod-x now runs on two machines), a loss, so it stays a shadow difference.
// mod-x placed on b too: b.mod-x is added and its first membership published; a.mod-x's loses the module's plain
// subject (mod-x now runs on two machines), which leaves no seat without its holder, so it is published too (issue
// 1945, the lead's decision in review round 1 of its fix).
b.assign("b", "mod-x")
moved := p.Compose()
if !moved.Written || moved.Revision != 2 || !slices.Equal(moved.Published, []string{"b.mod-x"}) || len(moved.NotPublished) != 1 || !strings.HasPrefix(moved.NotPublished[0], "a.mod-x: "+grants.ShadowDifference) || moved.UserList.Compare.Equal() ||
if !moved.Written || moved.Revision != 2 || !slices.Equal(moved.Published, []string{"a.mod-x", "b.mod-x"}) || len(moved.NotPublished) != 0 || moved.UserList.Compare.Equal() ||
!slices.Equal(moved.UserList.Compare.Added, []string{"b.mod-x"}) {
t.Errorf("a composition after placement moved = %+v", moved)
}
@@ -1145,13 +1169,14 @@ func TestGrants(t *testing.T) {
if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared {
t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh)
}
// Issue 1945, 2026-10-27 09:21: a list that would lose a seat grant of the list in force is held, as a
// membership that would lose one is: not written, the warning raised naming what it would lose; and a
// list whose loaded grants cannot be read is held too.
// Issue 1945, 2026-10-27 09:21: a list that would leave a seat the list in force holds with no holder,
// while a placed module still claims it, is held, as such a membership is: not written, the warning
// raised naming the seat. A list that only adds is written; one whose loaded grants cannot be read is held.
inForce := slices.Clone(b.loaded)
const seatGrant = "mesh.seat.anthropic-licence-manager.call.licences.*"
seat, verb := brokerSeat()
seatGrant := "mesh.seat." + seat + ".call." + verb + ".*"
for i := range b.loaded {
if b.loaded[i].User == "a.mod-x" {
if b.loaded[i].User == "a.nats" {
b.loaded[i].Subscribe = append(slices.Clone(b.loaded[i].Subscribe), seatGrant)
}
}
@@ -1159,19 +1184,35 @@ func TestGrants(t *testing.T) {
b.put(grants.ReadAccounts, "a.mod-y", map[string]any{"user": "a.mod-y", "hash": "$2a$10$x", "generation": 1})
said = nil
res := p.Compose()
if res.Sealed == nil || !strings.Contains(res.Sealed.Why, "publishing would lose a.mod-x: subscribe") || !strings.Contains(res.Sealed.Why, seatGrant) {
if res.Sealed == nil || !strings.Contains(res.Sealed.Why, "publishing would lose the last holder of "+seat) {
t.Errorf("a list losing a seat grant = %+v", res.Sealed)
}
if now, _, _ := p.Sealed(); now.Digest != g.Digest {
t.Errorf("a list losing a seat grant was written: %s, was %s", now.Digest, g.Digest)
}
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, seatGrant) {
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "the last holder of "+seat) {
t.Errorf("the held list did not raise the warning naming the lost grant: %+v", said)
}
b.loaded = inForce
if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest == g.Digest }() {
t.Errorf("a list that only adds a.mod-y was not written")
}
written, _, _ := p.Sealed()
b.mu.Lock()
b.refuseUsers = true
b.mu.Unlock()
b.mu.Lock()
delete(b.states[grants.ReadAssignments], "a.mod-y")
b.mu.Unlock()
if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "the grants of the list in force cannot be read") {
t.Errorf("a pass whose loaded list cannot be read = %+v", res.Sealed)
}
if now, _, _ := p.Sealed(); now.Digest != written.Digest {
t.Errorf("a list was written while the loaded list could not be read")
}
b.mu.Lock()
b.refuseUsers = false
b.mu.Unlock()
}
})
}
@@ -1273,8 +1314,9 @@ func TestGrants(t *testing.T) {
})
})
t.Run("memberships: a first or an additive one published, a loss never", func(t *testing.T) {
t.Run("memberships: published unless a seat would lose its last holder", func(t *testing.T) {
const key = "a.mod-x"
brokerName, brokerVerb := brokerSeat()
composed := func(b *fakeBus) map[string]any {
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership)
@@ -1294,25 +1336,28 @@ func TestGrants(t *testing.T) {
m["mesh"] = []any{"10.0.0.1"}
return m
}, true, ""},
{"a change that drops verified-sender is not published, and named", func(m map[string]any) map[string]any {
m["seat-traffic"] = map[string]any{"kinds": []any{map[string]any{"seat": "channel", "kind": "telegram", "capabilities": []any{"verified-sender"}}}}
return m
}, false, "verified-sender"},
{"a change that drops any other field is not published", func(m map[string]any) map[string]any {
// 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.
{"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
}, false, "tool.gone"},
}, true, ""},
{"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},
// 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"},
{"a retired verb and a module seat's verb dropped: not published, the module seat's named", func(m map[string]any) map[string]any {
{"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
}, false, "telegram-sender"},
}, true, "mesh-delivery mark-ready"},
} {
t.Run(row.name, func(t *testing.T) {
b := fixture()
@@ -1482,7 +1527,8 @@ func TestGrants(t *testing.T) {
raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership)
var m map[string]any
_ = json.Unmarshal(raw, &m)
m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.lost.tool.gone"})
seat, verb := brokerSeat()
m["seats"] = []any{map[string]any{"seat": seat, "verb": verb, "subject": "mesh.seat." + seat + ".tool." + verb}}
raw, _ = json.Marshal(m)
subject := "mesh.assignment." + key
b.stream[subject], b.seqs[subject] = raw, uint64(i+1)
@@ -1508,7 +1554,7 @@ func TestGrants(t *testing.T) {
<-done
held, err := partOn(b).MembershipOf("", "")
h, _ := held.(map[string]any)
if err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "tool.gone") {
if seat, _ := brokerSeat(); err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "the last holder of "+seat) {
t.Errorf("grants_membership with neither node nor module = %v, %v", held, err)
}
if _, err := partOn(b).MembershipOf("a", ""); err == nil {
@@ -74,8 +74,9 @@ const (
)
// publish publishes m when it differs from the membership last published for its assignment, carrying that one's private
// network and received contributions; it answers m as published (or why not) and whether it was published now.
func publish(p Publisher, m Membership) (Membership, bool) {
// network and received contributions; it answers m as published (or why not) and whether it was published now. held is
// every holding the memberships composed now carry (Holdings.ByMemberships).
func publish(p Publisher, m Membership, h Holdings, held map[string]bool) (Membership, bool) {
if p == nil {
m.Published = "not published: no publisher here (only the part's process publishes)"
return m, false
@@ -101,15 +102,19 @@ func publish(p Publisher, m Membership) (Membership, bool) {
m.Published = MembershipUnchanged
return m, false
}
// Only a first membership, or one that only adds to the last, is published (the lead's decision, review of 1ff5049):
// composed with no root-free judgement, a kind's verified-sender would be dropped, and the operator's approvals by
// a channel would no longer be honoured. Any loss stays a shadow difference, named.
// 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.
if found {
kept, retired := WithoutRetired(was)
m.Retired = retired
if lost := Losses(kept, body); len(lost) > 0 {
m.Published = ShadowDifference + strings.Join(lost, "; ")
return m, false
var last membership.Membership
if json.Unmarshal(kept, &last) == nil {
last.Node = m.Node
if lost := h.LastHolders(h.ByMemberships(last), held); len(lost) > 0 {
m.Published = ShadowDifference + "the last holder of " + strings.Join(lost, ", ")
return m, false
}
}
}
if err := p.PublishMembership(m.Node, m.Module, body, seq); err != nil {
@@ -184,108 +189,115 @@ func sameMembership(was, now []byte) bool {
return reflect.DeepEqual(a, b)
}
// UserLosses is the same guard as a membership's (Losses) over the user list (issue 1945): everything a user of the list
// in force holds that the list composed now drops, `<user>: <what>`. A user the composed list lacks is lost whole unless
// gone (placement no longer names it); a subscription to a verb the seat catalogue retired is no loss, as in a
// membership (WithoutRetired); and a user allowed responses now where it was not is a gain, not a change. Empty means
// the composed list holds all the list in force did, and maybe more. Pure.
func UserLosses(inForce, composed []User, gone []string) []string {
now := map[string]User{}
for _, u := range composed {
now[u.User] = u
}
var out []string
for _, l := range inForce {
c, ok := now[l.User]
if !ok {
if !slices.Contains(gone, l.User) {
out = append(out, l.User+": the user, with every grant it holds")
// ShadowDifference opens what a membership's or the user list's pass says when it was not published because it would
// leave a seat with no holder (LastHolders).
const ShadowDifference = "not published, a shadow difference: publishing would lose "
// Holdings is how the part tells whether a list or a membership would leave a seat with no holder (issue 1945): every
// holding some assigned module claims now, and each seat's scope. A holding is named by its seat for a seat held once
// for the mesh or a site, and `<seat>@<node>` for a seat of every node, so a seat handed to another node keeps its
// holding and one dropped on a node does not.
type Holdings struct {
Claimed map[string]bool
Scopes map[string]string
}
// 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{}}
for _, m := range in.Manifests {
for _, d := range m.DefinesSeats {
if d.Scope != "" {
h.Scopes[canonicalSeat(d.Name)] = d.Scope
}
continue
}
l.Subscribe = slices.DeleteFunc(slices.Clone(l.Subscribe), func(s string) bool {
m := seatSubject.FindStringSubmatch(s)
return m != nil && retiredVerb(m[1], m[2])
})
if c.AllowResponses {
l.AllowResponses = true
}
for node, modules := range in.Assigned {
for _, module := range modules {
m, ok := in.Running[node+"."+module]
if !ok {
m, ok = in.Manifests[module]
}
if !ok {
continue
}
for _, c := range m.Claims {
seat := canonicalSeat(c.Name)
if _, known := h.scope(seat); !known {
h.Scopes[seat] = c.At()
}
h.Claimed[h.key(seat, node)] = true
}
}
was, _ := json.Marshal(l)
is, _ := json.Marshal(c)
for _, lost := range Losses(was, is) {
out = append(out, l.User+": "+lost)
}
return h
}
func canonicalSeat(name string) string {
if s, ok := catalogue.SeatNamed(name); ok {
return s.Name
}
return name
}
func (h Holdings) scope(seat string) (string, bool) {
if s, ok := catalogue.SeatNamed(seat); ok && s.Scope != "" {
return s.Scope, true
}
sc, ok := h.Scopes[seat]
return sc, ok
}
func (h Holdings) key(seat, node string) string {
seat = canonicalSeat(seat)
if sc, _ := h.scope(seat); sc == catalogue.ScopeNode {
return seat + "@" + node
}
return seat
}
// ByUsers is every holding a user list carries: a user subscribed to a seat's verb (seatSubject) holds it on its
// machine; a verb the seat catalogue retired holds nothing. Pure.
func (h Holdings) ByUsers(users []User) map[string]bool {
out := map[string]bool{}
for _, u := range users {
node, _, _ := strings.Cut(u.User, ".")
if node == "node" || node == "provisioner" {
_, node, _ = strings.Cut(u.User, ".")
}
for _, s := range u.Subscribe {
if m := seatSubject.FindStringSubmatch(s); m != nil && !retiredVerb(m[1], m[2]) {
out[h.key(m[1], node)] = true
}
}
}
return out
}
// ShadowDifference opens what a membership's pass says when it was not published because it would lose something.
const ShadowDifference = "not published, a shadow difference: publishing would lose "
// Losses names everything the last membership holds that now drops or weakens: a field gone, a value changed, a list
// element (a capability, a kind, a served address, a seat, a state, a stream) with no element in now that holds all of it.
// Empty means now holds all the last one did, and maybe more. Pure.
func Losses(last, now []byte) []string {
var a, b any
if json.Unmarshal(last, &a) != nil {
return nil
}
if json.Unmarshal(now, &b) != nil {
return []string{"the new membership is not JSON"}
}
return losses("", a, b)
}
func losses(path string, last, now any) []string {
switch l := last.(type) {
case map[string]any:
n, ok := now.(map[string]any)
if !ok {
return []string{fmt.Sprintf("%s: %s", name(path), brief(last))}
}
keys := make([]string, 0, len(l))
for k := range l {
keys = append(keys, k)
}
sort.Strings(keys)
var out []string
for _, k := range keys {
nv, ok := n[k]
if !ok {
out = append(out, fmt.Sprintf("%s: %s", name(path+"."+k), brief(l[k])))
continue
}
out = append(out, losses(path+"."+k, l[k], nv)...)
}
return out
case []any:
n, _ := now.([]any)
var out []string
for i, e := range l {
if !slices.ContainsFunc(n, func(c any) bool { return len(losses("", e, c)) == 0 }) {
out = append(out, fmt.Sprintf("%s[%d]: %s", name(path), i, brief(e)))
// ByMemberships is every holding memberships carry: a seat verb served holds the seat on the membership's node. Pure.
func (h Holdings) ByMemberships(ms ...membership.Membership) map[string]bool {
out := map[string]bool{}
for _, m := range ms {
for _, s := range m.Seats {
if !retiredVerb(s.Seat, s.Verb) {
out[h.key(s.Seat, m.Node)] = true
}
}
return out
}
if !reflect.DeepEqual(last, now) {
return []string{fmt.Sprintf("%s: %s, now %s", name(path), brief(last), brief(now))}
}
return nil
return out
}
func name(path string) string {
if path == "" {
return "the membership"
// LastHolders is every holding before carries that now does not while an assigned module still claims it: the seats a
// list or a membership would leave with no holder, sorted. Empty means none would; a seat no longer claimed, a verb
// dropped, or a seat held elsewhere now, is no loss here. Pure.
func (h Holdings) LastHolders(before, now map[string]bool) []string {
var out []string
for k := range before {
if !now[k] && h.Claimed[k] {
out = append(out, k)
}
}
return strings.TrimPrefix(path, ".")
}
// brief is a value in JSON, cut at 200 bytes.
func brief(v any) string {
raw, _ := json.Marshal(v)
if len(raw) > 200 {
return string(raw[:200]) + "…"
}
return string(raw)
sort.Strings(out)
return out
}
+10 -3
View File
@@ -14,6 +14,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// The part's own states, as its manifest declares them, and the keys of the two that hold one value.
@@ -109,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, a first one or one that only adds to the last published or drops only a verb the seat catalogue retired; any other loss 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, 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 {
@@ -195,6 +196,7 @@ func (p *Part) Compose() Result {
res.Refused = append(res.Refused, g.Input+": "+g.Why)
}
recs, left := Records(in)
holdings := HoldingsOf(in)
res.RootNotFree = WithRootFree(&recs, p.Call)
st := UserListState{V: 1, Mode: p.SwitchState(), LeftOut: left, Loaded: in.Loaded, BusNode: in.BusNode,
Gaps: append(append([]Gap(nil), in.Gaps...), MembershipGaps...), At: p.now()}
@@ -239,14 +241,19 @@ func (p *Part) Compose() Result {
var bus, lost []string
if st.Compare.Unread == "" {
bus = names(loaded)
lost = UserLosses(loaded, users, st.Compare.Gone)
lost = holdings.LastHolders(holdings.ByUsers(loaded), holdings.ByUsers(users))
}
p.writeSealed(&res, list, n, leftOut, in, bus, lost)
}
ms := Memberships(recs)
res.Memberships = len(ms)
composed := make([]membership.Membership, 0, len(ms))
for _, m := range ms {
composed = append(composed, m.Membership)
}
seated := holdings.ByMemberships(composed...)
for _, key := range sortedKeys(ms) {
m, now := publish(p.Publish, ms[key])
m, now := publish(p.Publish, ms[key], holdings, seated)
switch {
case now:
res.Published = append(res.Published, key)
@@ -298,8 +298,9 @@ func listUsers(list []byte) []string {
}
// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, a
// user of the list in force is left out for want of a hash, or the list would lose a grant of the list in force (lost,
// UserLosses: the membership's guard, issue 1945; nil when the loaded list could not be read, which holds it too). The list in force is the one the part wrote, by the users
// user of the list in force is left out for want of a hash, or the list would leave a seat the list in force holds with
// no holder while a placed module still claims it (lost, Holdings.LastHolders: the membership's guard, issue 1945; the
// loaded list not read holds it too, since that cannot be ruled out). The list in force is the one the part wrote, by the users
// it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when
// they could not be read). Empty when it may.
func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string {
@@ -310,7 +311,7 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string {
return "held: the grants of the list in force cannot be read, so a loss of seats cannot be ruled out"
}
if len(lost) > 0 {
return "held, " + ShadowDifference + strings.Join(lost, "; ")
return "held, " + ShadowDifference + "the last holder of " + strings.Join(lost, ", ")
}
raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers)
if err != nil {
@@ -368,7 +368,8 @@ func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool)
return c
}
var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.\*)?$`)
// seatSubject is a seat verb's subject, for any caller (`.*`) or one machine's (`.<node>`, a seat of every node).
var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.[^.]+)?$`)
var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.>)?$`)
// ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the