mesh-grants: an open take-over ask is always closed (router record, expiry, failed publish, any outcome), the switch is said recorded not switched, the refusal condition is cleared, the excuse is limited to the claiming machines' subscriptions (issue 1945)

This commit is contained in:
2026-10-26 11:43:39 +01:00
parent b0b1cf4f01
commit 4b23db28b3
6 changed files with 296 additions and 80 deletions
@@ -123,6 +123,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
client := &asker.Client{Module: account.Module, State: conn.State(grants.StateTakeover), Publish: publishVia(ctx, js),
Record: recordVia(ctx, nc, account.Module), Subscribe: decidedVia(nc, account.Module, logger.Printf)}
f.Part.Asker, f.Part.Say = client, sayVia(ctx, js, account.Module)
f.Part.Forget = func(id string) error { return conn.State(grants.StateTakeover).Delete("ask." + id) }
if err := client.Listen(f.Part.Warranted, f.Part.Said); err != nil {
logger.Printf("the operator's answers cannot be heard yet, so take-over and hand-back wait: %v", err)
}
+1 -1
View File
@@ -99,7 +99,7 @@ func toolsOf(p *grants.Part) []stdio.Tool {
Run: func(a map[string]any) (any, error) { return p.AssertOne(grants.Answer(a, "module")) }},
{Name: grants.ToolTakeOver, Description: "Ask the operator to switch the bus node to the grants part's list (ADR 0547 clause 8). " +
"The operator's word is their yes on their channel (a warrant), never a caller's name: this tool only asks, once at a time. " +
"After the yes, the switch is refused while the loaded list could not be read, a user differs between the lists " +
"After the yes, the switch is recorded and takes effect when the bus node loads the part's list; it is refused while the loaded list could not be read, a user differs between the lists " +
"(an enrolment user or a person the part cannot compose among them), or the vault holds no hash for a user, and the " +
"refusal is said to the operator naming the users. Every act is recorded in the part's state takeover.",
Input: schema(map[string]string{"why": "why, kept with the act"}, "why"),
+95 -24
View File
@@ -699,23 +699,27 @@ func TestGrants(t *testing.T) {
}
})
t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subjects, nothing else", func(t *testing.T) {
t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subscriptions on its machines, nothing else", func(t *testing.T) {
const retired, store, storeInfo = "mesh.seat.mesh-delivery.tool.mark-ready", "mesh.seat.mesh-store.tool.databases", "$SRV.INFO.mesh-store.>"
two := map[string][]string{"mesh-store": {"a", "n"}}
for _, row := range []struct {
name string
shadow, bus []string // the node-tools user's subscribe in the shadow list and in the loaded list
multi []string
differs bool
excusedCount int
name string
user string
subShadow, subBus, pubShadow []string
multi map[string][]string
differs bool
excused int
}{
{"a retired verb the shadow dropped", []string{"x"}, []string{"x", retired}, nil, false, 1},
{"a verb of a live seat the shadow lost", []string{"x"}, []string{"x", store}, []string{"mesh-store"}, true, 0},
{"a seat claimed on two machines, added", []string{"x", store, storeInfo}, []string{"x"}, []string{"mesh-store"}, false, 2},
{"the same added for a seat claimed once", []string{"x", store}, []string{"x"}, nil, true, 0},
{"a retired verb the shadow dropped", "a.node-tools", []string{"x"}, []string{"x", retired}, nil, nil, false, 1},
{"a verb of a live seat the shadow lost", "a.node-tools", []string{"x"}, []string{"x", store}, nil, two, true, 0},
{"a seat claimed on two machines, added to a user on one", "a.node-tools", []string{"x", store, storeInfo}, []string{"x"}, nil, two, false, 2},
{"the same added to a user on a machine that does not claim it", "q.node-tools", []string{"x", store}, []string{"x"}, nil, two, true, 0},
{"a publish of the seat added is not excused", "a.node-tools", []string{"x"}, []string{"x"}, []string{store}, two, true, 0},
{"the same added for a seat claimed once", "a.node-tools", []string{"x", store}, []string{"x"}, nil, nil, true, 0},
} {
c := grants.Compare([]grants.User{{User: "a.node-tools", Subscribe: row.shadow}}, []grants.User{{User: "a.node-tools", Subscribe: row.bus}}, nil).
c := grants.Compare([]grants.User{{User: row.user, Subscribe: row.subShadow, Publish: row.pubShadow}}, []grants.User{{User: row.user, Subscribe: row.subBus}}, nil).
Excusing(grants.ExcusedBy(row.multi))
if c.Equal() == row.differs || len(c.Excused) != row.excusedCount {
if c.Equal() == row.differs || len(c.Excused) != row.excused {
t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused)
}
}
@@ -785,7 +789,10 @@ func TestGrants(t *testing.T) {
}
b.loaded = append(b.loaded, grants.User{User: "b.gone"})
fa := &fakeAsker{}
fa := &fakeAsker{wheres: map[string]*asks.Warrant{}}
var forgot []string
n := 0
p.Nonce, p.Forget = func() string { n++; return fmt.Sprintf("%04d", n) }, func(id string) error { forgot = append(forgot, id); return nil }
var said []asks.Condition
p.Asker, p.Say = fa, func(c asks.Condition) error { said = append(said, c); return nil }
warrant := func(id string) asks.Warrant { return asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen} }
@@ -826,7 +833,7 @@ func TestGrants(t *testing.T) {
}{
{"a yes while a user differs is refused and said, naming it", "take-over", yes, func() {}, []string{"users differ", "b.mod-x"}, []string{"b.gone"}, grants.Shadow, "refused"},
{"a no leaves the switch", "take-over", no, func() {}, nil, nil, grants.Shadow, "declined"},
{"a yes with the lists agreeing and every hash held switches", "take-over", yes, func() {
{"a yes with the lists agreeing and every hash held records the switch", "take-over", yes, func() {
b.states[grants.ReadAssignments] = nil
b.assign("a", "nats")
b.assign("a", "mod-x")
@@ -835,8 +842,8 @@ func TestGrants(t *testing.T) {
for _, u := range shadowOf(t, b) {
b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1})
}
}, nil, nil, grants.TookOver, "switched"},
{"a yes to hand back switches back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "switched"},
}, nil, nil, grants.TookOver, "recorded"},
{"a yes to hand back records the switch back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "recorded"},
} {
t.Run(row.name, func(t *testing.T) {
row.setup()
@@ -875,8 +882,12 @@ func TestGrants(t *testing.T) {
t.Errorf("the refusal names %q", w)
}
}
if row.outcome == "switched" && len(said) != 0 {
t.Errorf("a switch said a refusal: %v", said)
// A recorded switch says no refusal and clears the one a refused yes left raised.
if row.outcome == "recorded" && row.act == "take-over" && (len(said) != 1 || said[0].State != asks.StateCleared || said[0].Key != "mesh-grants.take-over-refused") {
t.Errorf("a recorded switch did not clear the refusal: %v", said)
}
if row.outcome == "recorded" && !strings.Contains(fmt.Sprint(p.TakeOverState()), "bus_node_loaded_grants_list:not yet") {
t.Errorf("the state claims the bus node follows with no loaded list: %v", fmt.Sprint(p.TakeOverState()))
}
})
}
@@ -890,12 +901,52 @@ func TestGrants(t *testing.T) {
t.Errorf("a refused hand-back asked the operator")
}
})
t.Run("an ask that ends with no answer is closed and can be asked again", func(t *testing.T) {
t.Run("an ask that can stay open is closed every way and can be asked again", func(t *testing.T) {
// A warrant the process missed is caught up from the router's record, whatever its outcome.
id := asked("take-over")
p.Said(asker.Recorded, asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired}, nil)
if l := last(); l.Said != string(asks.OutcomeExpired) {
t.Errorf("the act = %+v", l)
p.Compose()
fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen, Option: "no"}
p.Compose()
if l := last(); l.Ask != id || l.Said != "declined" {
t.Errorf("a missed warrant was not caught up: %+v", l)
}
// The router ended it with no choice, and the process was not told.
id = asked("take-over")
p.Compose()
fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeCancelled}
p.Compose()
if l := last(); l.Ask != id || !strings.Contains(l.Said, "cancelled") {
t.Errorf("an ask the router ended was not closed: %+v", l)
}
// Said closes on any outcome for the open id, and none for another id.
id = asked("take-over")
p.Said(asker.Refused, asks.Warrant{Ask: "other", Outcome: asks.OutcomeRefused}, nil)
if _, err := p.TakeOver("x", "y"); err == nil {
t.Errorf("a warrant for another ask closed the open one")
}
p.Said(asker.Refused, asks.Warrant{Ask: id, Outcome: asks.OutcomeRefused}, nil)
// A warrant for an ask that is not the open one switches nothing.
if err := p.Warranted(yes, warrant("take-over-other")); err == nil {
t.Errorf("a warrant for an ask that is not open was acted on")
}
// Past its expiry and a margin the part closes it itself; the clock moves, not a timer.
id = asked("take-over")
now := p.Now
p.Now = func() time.Time { return now().Add(25 * time.Hour) }
p.Compose()
p.Now = now
if l := last(); l.Ask != id || l.Said != string(asks.OutcomeExpired) {
t.Errorf("an expired ask was not closed: %+v", l)
}
// An ask that could not be published is closed as refused, its half-made record taken away, and is not retried.
fa.fail = true
id = asked("take-over")
p.Compose()
p.Compose()
if l := last(); l.Ask != id || l.Said != "refused" || !slices.Contains(forgot, id) {
t.Errorf("a failed ask stays open: %+v, forgot %v", l, forgot)
}
fa.fail = false
asked("take-over")
})
@@ -1207,9 +1258,29 @@ func TestGrants(t *testing.T) {
})
}
type fakeAsker struct{ asks []asks.Ask }
type fakeAsker struct {
asks []asks.Ask
wheres map[string]*asks.Warrant // the router's warrants, by ask
fail bool
}
func (f *fakeAsker) Ask(a asks.Ask) error { f.asks = append(f.asks, a); return nil }
func (f *fakeAsker) Ask(a asks.Ask) error {
if f.fail {
return errors.New("the bus refused the publish")
}
f.asks = append(f.asks, a)
return nil
}
func (f *fakeAsker) Where(id string) (string, *asks.Warrant, error) { return "open", f.wheres[id], nil }
// Take hands the warrant to act as the SDK's asker does for one that chose, and records any other.
func (f *fakeAsker) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) {
if w.Outcome != asks.OutcomeChosen {
return asker.Recorded, nil
}
return asker.Acted, act(asks.Option{ID: w.Option}, w)
}
func nonNil(s []string) []string {
if s == nil {
+14 -3
View File
@@ -9,6 +9,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
@@ -123,7 +124,12 @@ type Part struct {
Asker Asker
// Say says a condition to the operator: the process's; nil in the tools.
Say func(asks.Condition) error
Now func() time.Time
// Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix.
Forget func(id string) error
Nonce func() string
// deciding is set while a take-over is decided, so the composition it makes asks and reconciles nothing.
deciding atomic.Bool
Now func() time.Time
cache Manifests
mu sync.Mutex
@@ -261,8 +267,13 @@ func (p *Part) Compose() Result {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be written: %v", StateTakeover, err))
}
}
if err := p.RaiseRequested(); err != nil {
res.Errors = append(res.Errors, err.Error())
if !p.deciding.Load() {
if err := p.RaiseRequested(); err != nil {
res.Errors = append(res.Errors, err.Error())
}
if err := p.Reconcile(); err != nil {
res.Errors = append(res.Errors, err.Error())
}
}
return res
}
+155 -25
View File
@@ -1,6 +1,8 @@
package grants
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
@@ -36,7 +38,9 @@ type Switch struct {
State string `json:"state"`
// Open is the id of the ask the operator has not answered yet, empty when none.
Open string `json:"open,omitempty"`
Acts []Act `json:"acts"`
// Refusing are the acts whose refusal condition was said and not cleared yet.
Refusing []string `json:"refusing,omitempty"`
Acts []Act `json:"acts"`
}
// Act is one take-over or hand-back asked, answered, done or refused.
@@ -59,13 +63,27 @@ type Request struct {
Why string `json:"why,omitempty"`
At time.Time `json:"at"`
Raised bool `json:"raised,omitempty"`
// RaisedAt is when it was asked, and Failed why asking it failed (the ask is then closed as refused).
RaisedAt time.Time `json:"raised_at,omitempty"`
Failed string `json:"failed,omitempty"`
}
// Asker is the client of the router the part asks through (mesh-sdk asker.Client).
type Asker interface {
Ask(a asks.Ask) error
// Where is the router's record of an ask: its state and its warrant, if any.
Where(id string) (string, *asks.Warrant, error)
// Take hands a warrant to act once (a warrant the process missed is caught up through it).
Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error)
}
// askMargin is how long past an ask's expiry the router is given to say it ended, before the part closes it itself;
// recordGrace how long a just-asked ask may be missing from the router's record.
const (
askMargin = 5 * time.Minute
recordGrace = time.Minute
)
// keptActs is how many acts the switch keeps, newest last.
const keptActs = 50
@@ -109,6 +127,16 @@ func (p *Part) TakeOver(caller, why string) (any, error) { return p.request(ActT
// HandBack is the tool: it asks the operator to switch the bus node back to the controller's list.
func (p *Part) HandBack(caller, why string) (any, error) { return p.request(ActHandBack, caller, why) }
// nonce is four hex digits that keep two asks of one second apart.
func (p *Part) nonce() string {
if p.Nonce != nil {
return p.Nonce()
}
var b [2]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func (p *Part) request(act, caller, why string) (any, error) {
s, err := p.readSwitch()
if err != nil {
@@ -127,7 +155,7 @@ func (p *Part) request(act, caller, why string) (any, error) {
if act == ActTakeOver && s.State == TookOver {
return nil, fmt.Errorf("the bus node follows the grants part's list already")
}
a.Ask = act + "-" + p.now().Format("20060102t150405")
a.Ask = act + "-" + p.now().Format("20060102t150405") + "-" + p.nonce()
a.Said = "asked"
req, _ := json.Marshal(Request{ID: a.Ask, Act: act, By: caller, Why: why, At: a.At})
if _, err := p.Store.Put(StateTakeover, KeyRequest, req); err != nil {
@@ -140,7 +168,7 @@ func (p *Part) request(act, caller, why string) (any, error) {
}
// The process asks at its next pass: wake it.
_, _ = p.AskCompose(caller)
return map[string]any{"asked": a.Ask, "says": "the part's process asks the operator on their channel; only the operator's yes switches. grants_take_over_state shows where it stands"}, nil
return map[string]any{"asked": a.Ask, "says": "the part's process asks the operator on their channel; only the operator's yes records the switch, and it takes effect when the bus node loads the grants part's list. grants_take_over_state shows where it stands"}, nil
}
// RaiseRequested is the process's: it asks the operator the request a tool left, once. Where the part has no asker (the
@@ -157,17 +185,30 @@ func (p *Part) RaiseRequested() error {
return nil
}
var r Request
if json.Unmarshal(raw, &r) != nil || r.Raised || r.ID == "" {
if json.Unmarshal(raw, &r) != nil || r.Raised || r.Failed != "" || r.ID == "" {
return nil
}
if s, err := p.readSwitch(); err != nil || s.Open != r.ID {
return nil // an ask no longer open is not asked
}
ask, err := p.AskOf(r)
if err != nil {
return err
}
if err := p.Asker.Ask(ask); err != nil {
// Closed as refused, and the half-made record the asker kept before publishing is taken away, so nothing stays open.
r.Failed = err.Error()
data, _ := json.Marshal(r)
_, _ = p.Store.Put(StateTakeover, KeyRequest, data)
if p.Forget != nil {
_ = p.Forget(r.ID)
}
if s, rerr := p.readSwitch(); rerr == nil {
_ = p.finish(s, Act{Act: r.Act, By: "operator", At: p.now(), Ask: r.ID, Said: "refused", Refused: "the operator could not be asked: " + err.Error()})
}
return fmt.Errorf("the operator could not be asked the %s: %v", r.Act, err)
}
r.Raised = true
r.Raised, r.RaisedAt = true, p.now()
data, _ := json.Marshal(r)
if _, err := p.Store.Put(StateTakeover, KeyRequest, data); err != nil {
return fmt.Errorf("the ask %s was asked and its request could not be marked: %v", r.ID, err)
@@ -176,13 +217,78 @@ func (p *Part) RaiseRequested() error {
return nil
}
// Reconcile closes an ask that can stay open for ever, once per pass while one is open: a warrant the process missed is
// caught up from the router's record, one that ended with none is closed, and one past its expiry and a margin is closed
// as expired by the part itself.
func (p *Part) Reconcile() error {
if p.Asker == nil {
return nil
}
s, err := p.readSwitch()
if err != nil || s.Open == "" {
return err
}
act := actOf(s.Open)
at := p.now()
for _, a := range s.Acts {
if a.Ask == s.Open {
at = a.At
break
}
}
state, w, err := p.Asker.Where(s.Open)
if err != nil {
return fmt.Errorf("the router's record of the ask %s cannot be read: %v", s.Open, err)
}
if w != nil {
h, herr := p.Asker.Take(*w, p.Warranted)
p.Said(h, *w, herr)
if cur, rerr := p.readSwitch(); rerr == nil && cur.Open == s.Open {
_ = p.finish(cur, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: string(w.Outcome)})
}
return nil
}
raw, _, found, _ := p.Store.Get(StateTakeover, KeyRequest)
var r Request
if found {
_ = json.Unmarshal(raw, &r)
}
switch {
case p.now().After(at.Add(asks.ApproveLasts + askMargin)):
return p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: string(asks.OutcomeExpired)})
case state == "" && r.ID == s.Open && r.Raised && p.now().After(r.RaisedAt.Add(recordGrace)):
return p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: s.Open, Said: "ended", Refused: "the router holds no record of the ask"})
}
return nil
}
func actOf(id string) string {
if strings.HasPrefix(id, ActHandBack+"-") {
return ActHandBack
}
return ActTakeOver
}
// Following says whether the bus node has loaded the grants part's list: "yes" when its loaded digest equals the part's,
// "not yet" when it loaded another, "unknown" when its have says none.
func (p *Part) Following() string {
st := p.Last().UserList
if st.Loaded == nil || st.Digest == "" {
return "unknown: the bus module's have names no loaded list"
}
if st.Loaded.Wanted == st.Digest || st.Loaded.Digest == st.Digest {
return "yes"
}
return "not yet: the bus node loaded another list"
}
// AskOf is the ask of a request, in the words the operator reads: what changes, why, what it risks. Its yes binds the act.
func (p *Part) AskOf(r Request) (asks.Ask, error) {
verb, headline, does, stays, expl := "grants_take_over", "Switch the bus's user list to the grants part?", "The grants part's list becomes the bus's.",
"The controller's list stays in force.", "The grants part has composed the user list the bus holds today, and it can take over writing it.\n"+
verb, headline, does, stays, expl := "grants_take_over", "Switch the bus's user list to the grants part?", "The switch is recorded; it takes effect when the bus node loads the grants part's list.",
"The controller's list stays in force.", "The grants part has composed the user list the bus holds today. Your yes records the switch; it takes effect when the bus node loads the part's list.\n"+
"If the lists differ in any user, or a user has no hash in the vault, the switch is refused and you are told which."
if r.Act == ActHandBack {
verb, headline, does, stays, expl = "grants_hand_back", "Give the bus's user list back to the controller?", "The controller's list becomes the bus's again.",
verb, headline, does, stays, expl = "grants_hand_back", "Give the bus's user list back to the controller?", "The switch back is recorded; it takes effect when the bus node loads the controller's list.",
"The grants part's list stays in force.", "The bus follows the grants part's list now.\nThe controller still writes its list until it loses its lease, so the bus can follow it again."
}
bind, err := asks.ActDigest(asks.Act{"verb": verb, "module": "mesh-grants", "ask": r.ID})
@@ -207,6 +313,9 @@ func (p *Part) Warranted(o asks.Option, w asks.Warrant) error {
if strings.HasPrefix(w.Ask, ActHandBack+"-") {
act = ActHandBack
}
if s.Open != w.Ask {
return fmt.Errorf("the warrant answers the ask %s, and the ask open is %q: nothing is switched", w.Ask, s.Open)
}
a := Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask}
if o.ID != "yes" {
a.Said = "declined"
@@ -215,15 +324,19 @@ func (p *Part) Warranted(o asks.Option, w asks.Warrant) error {
var reasons []string
if act == ActTakeOver {
reasons = p.Decide()
if s, err = p.readSwitch(); err != nil { // Decide clears a refusal and writes the switch: read it again
return err
}
} else if s.State == Shadow {
reasons = []string{"nothing to hand back: the bus node follows the controller's list already"}
}
if len(reasons) > 0 {
a.Said, a.Refused = "refused", strings.Join(reasons, "; ")
p.sayRefused(act, a.Refused)
p.sayRefused(&s, act, a.Refused)
return p.finish(s, a)
}
a.Said = "switched"
p.clearRefused(&s)
a.Said = "recorded"
if act == ActTakeOver {
s.State = TookOver
} else {
@@ -238,18 +351,11 @@ func (p *Part) Warranted(o asks.Option, w asks.Warrant) error {
// Said is the process's note of an ask that ended with no yes or no (expired, or refused by the router): the switch
// stays, and the ask is closed so the operator can be asked again.
func (p *Part) Said(h asker.Handled, w asks.Warrant, _ error) {
if h != asker.Recorded {
return
}
s, err := p.readSwitch()
if err != nil || s.Open != w.Ask {
return
}
act := ActTakeOver
if strings.HasPrefix(w.Ask, ActHandBack+"-") {
act = ActHandBack
}
_ = p.finish(s, Act{Act: act, By: "operator", At: p.now(), Ask: w.Ask, Said: string(w.Outcome)})
_ = p.finish(s, Act{Act: actOf(w.Ask), By: "operator", At: p.now(), Ask: w.Ask, Said: string(w.Outcome) + " (" + string(h) + ")"})
}
func (p *Part) finish(s Switch, a Act) error {
@@ -265,7 +371,9 @@ func (p *Part) finish(s Switch, a Act) error {
// an enrolment user or a person whose records are in the controller's store alone), and any user with no hash in the vault.
// Nothing here but what is true now: a take-over is refused for these and for no other reason. Empty means none.
func (p *Part) Decide() []string {
p.deciding.Store(true)
res := p.Compose()
p.deciding.Store(false)
var reasons []string
if c := res.UserList.Compare; !c.Equal() {
if c.Unread != "" {
@@ -287,6 +395,12 @@ func (p *Part) Decide() []string {
reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s",
len(unhashed), strings.Join(unhashed, ", ")))
}
if len(reasons) == 0 {
if s, err := p.readSwitch(); err == nil && len(s.Refusing) > 0 {
p.clearRefused(&s)
_ = p.writeSwitch(s)
}
}
return reasons
}
@@ -299,15 +413,33 @@ func contains(s []string, v string) bool {
return false
}
// sayRefused tells the operator a yes was given and the switch refused, with the reasons (the users that differ named).
func (p *Part) sayRefused(act, why string) {
// sayRefused tells the operator a yes was given and the switch refused, with the reasons (the users that differ named). The
// condition stays raised until a switch is recorded or a later Decide finds no reason (clearRefused).
func (p *Part) sayRefused(s *Switch, act, why string) {
if p.Say == nil {
return
}
c := asks.Condition{Key: "mesh-grants." + act + "-refused", Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"},
State: asks.StateRaised, Summary: act + " refused: " + why, Headline: "The user list switch was refused", Needs: "a look at why",
Explanation: asks.NeedsYou + "a look at why. You said yes to the switch and it was refused: " + why, Resolved: "the switch is asked again and goes through"}
_ = p.Say(c)
if p.Say(c) == nil && !contains(s.Refusing, act) {
s.Refusing = append(s.Refusing, act)
}
}
// clearRefused clears every refusal condition said and not cleared: on a recorded switch, a hand-back, or a Decide with no reason.
func (p *Part) clearRefused(s *Switch) {
if p.Say == nil {
return
}
var left []string
for _, act := range s.Refusing {
c := asks.Condition{Key: "mesh-grants." + act + "-refused", Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}, State: asks.StateCleared}
if p.Say(c) != nil {
left = append(left, act)
}
}
s.Refusing = left
}
// TakeOverState is the tool take-over-state: the switch, the request, and what became of each ask.
@@ -316,12 +448,10 @@ func (p *Part) TakeOverState() (any, error) {
if err != nil {
return nil, err
}
out := map[string]any{"state": s.State, "open": s.Open, "acts": s.Acts}
out := map[string]any{"state": s.State, "open": s.Open, "acts": s.Acts, "bus_node_loaded_grants_list": p.Following(),
"says": "a recorded switch takes effect when the bus node loads the grants part's list; bus_node_loaded_grants_list says whether it has (after a hand-back the controller's list is meant, and it then reads not yet)"}
if raw, _, found, err := p.Store.Get(StateTakeover, KeyRequest); err == nil && found {
out["request"] = json.RawMessage(raw)
}
if s.Open == "" {
out["says"] = "no ask is open"
}
return out, nil
}
+30 -27
View File
@@ -339,13 +339,13 @@ type Excuse struct {
// Excusing removes from the differences the subjects f excuses (added: the shadow grants it and the loaded list does not),
// naming each in Excused; a user left with no difference counts as the same. Pure.
func (c Comparison) Excusing(f func(subject string, added bool) string) Comparison {
func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool) string) Comparison {
var differ []UserDiff
for _, d := range c.Differ {
keep := func(list []string, added bool) []string {
keep := func(list []string, added, subscribe bool) []string {
var out []string
for _, s := range list {
if why := f(s, added); why != "" {
if why := f(d.User, s, added, subscribe); why != "" {
c.Excused = append(c.Excused, Excuse{User: d.User, Subject: s, Why: why})
continue
}
@@ -353,8 +353,8 @@ func (c Comparison) Excusing(f func(subject string, added bool) string) Comparis
}
return out
}
d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true), keep(d.PublishDropped, false)
d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true), keep(d.SubscribeDropped, false)
d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true, false), keep(d.PublishDropped, false, false)
d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true, true), keep(d.SubscribeDropped, false, true)
if len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped) == 0 && d.Responses == "" {
c.Same++
continue
@@ -374,48 +374,51 @@ 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
// seat catalogue retired on purpose, which the shadow no longer grants, is no difference; and what the shadow grants more
// for a mesh seat that is claimed on several machines (the standing gap seat holdings: it composes the seat as held on each)
// is named and not counted, since a subscription to a seat's verbs on a machine whose holder does not answer them harms
// nothing and is gone when holdings follow the catalogue. Nothing else is excused: a grant the shadow gives for any other
// reason, or the loss of any other subject, keeps the lists apart.
func ExcusedBy(multiHeld []string) func(subject string, added bool) string {
return func(subject string, added bool) string {
// is named and not counted, for a user on a machine that claims the seat and only as a subscription (what it serves), since
// a subscription to a seat's verbs on a machine whose holder does not answer them harms nothing and is gone when holdings
// follow the catalogue. Nothing else is excused: an added publish, a grant of a user on another machine, or the loss of any
// other subject, keeps the lists apart.
func ExcusedBy(multiHeld map[string][]string) func(user, subject string, added, subscribe bool) string {
// holds says the user is on a machine where the seat is claimed: the machine's tool runner, or a module of it.
holds := func(user, seat string) bool {
node, _, _ := strings.Cut(user, ".")
return slices.Contains(multiHeld[seat], node)
}
return func(user, subject string, added, subscribe bool) string {
if m := seatSubject.FindStringSubmatch(subject); m != nil {
if !added && retiredVerb(m[1], m[2]) {
if !added && subscribe && retiredVerb(m[1], m[2]) {
return "a verb the seat catalogue retired"
}
if added && slices.Contains(multiHeld, m[1]) {
if added && subscribe && holds(user, m[1]) {
return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue"
}
}
if m := seatService.FindStringSubmatch(subject); m != nil && added && slices.Contains(multiHeld, m[1]) {
if m := seatService.FindStringSubmatch(subject); m != nil && added && subscribe && holds(user, m[1]) {
return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue"
}
return ""
}
}
// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, sorted.
func MultiHeld(r buscompose.Records) []string {
nodes := map[string]map[string]bool{}
// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, each with the
// machines that claim it.
func MultiHeld(r buscompose.Records) map[string][]string {
nodes := map[string][]string{}
for node, declared := range r.Assigned {
for _, d := range declared {
for _, s := range d.Holds {
if s.Scope != "" && s.Scope != catalogue.ScopeMesh {
continue
if (s.Scope == "" || s.Scope == catalogue.ScopeMesh) && !slices.Contains(nodes[s.Name], node) {
nodes[s.Name] = append(nodes[s.Name], node)
}
if nodes[s.Name] == nil {
nodes[s.Name] = map[string]bool{}
}
nodes[s.Name][node] = true
}
}
}
var out []string
for seat, ns := range nodes {
if len(ns) > 1 {
out = append(out, seat)
if len(ns) < 2 {
delete(nodes, seat)
continue
}
sort.Strings(ns)
}
sort.Strings(out)
return out
return nodes
}