Merge pull request 'Grants composes from the haves while the wants view is unanswered, and its conditions reach the operator (issue 1966, recovery)' (#180) from fix/1966-first-view-one-read into main

This commit was merged in pull request #180.
This commit is contained in:
2026-11-08 22:31:58 +00:00
11 changed files with 139 additions and 27 deletions
+2 -1
View File
@@ -28,6 +28,7 @@ import (
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
@@ -117,7 +118,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// followed on the owners' views families, which the hearing below hands them. // followed on the owners' views families, which the hearing below hands them.
sayCond := sayVia(ctx, js, account.Module) sayCond := sayVia(ctx, js, account.Module)
fv := grants.NewViews(func(tool string) func(context.Context, string) (views.Answer, error) { fv := grants.NewViews(func(tool string) func(context.Context, string) (views.Answer, error) {
return views.AskVia(grants.Unwrapped(conn.Call), tool) return grants.TimedAsk(tool, views.AskVia(grants.Unwrapped(conn.Call), tool), time.Now, logger.Printf)
}, sayCond, f.Wake, logger.Printf) }, sayCond, f.Wake, logger.Printf)
fv.Rest, fv.Watching = bus, bus fv.Rest, fv.Watching = bus, bus
byState := map[string]*views.Follower{} byState := map[string]*views.Follower{}
+51
View File
@@ -801,6 +801,7 @@ func TestGrants(t *testing.T) {
} }
return nil return nil
} }
p.Say = channelTakes(t, p.Say)
for _, s := range b.streams { for _, s := range b.streams {
if s["name"] == "OBJ_mesh-knowledge_sections" { if s["name"] == "OBJ_mesh-knowledge_sections" {
s["bytes"] = int64(3500 << 20) s["bytes"] = int64(3500 << 20)
@@ -1664,6 +1665,7 @@ func TestGrants(t *testing.T) {
var said []oc.Condition var said []oc.Condition
p.Say = func(c oc.Condition) error { said = append(said, c); return nil } p.Say = func(c oc.Condition) error { said = append(said, c); return nil }
p.Say = channelTakes(t, p.Say)
// settled is the assignments of the lists agreeing, every hash held, and the loaded list as edit leaves it. // settled is the assignments of the lists agreeing, every hash held, and the loaded list as edit leaves it.
settled := func(edit func(modX *grants.User)) func() { settled := func(edit func(modX *grants.User)) func() {
return func() { return func() {
@@ -1768,6 +1770,7 @@ func TestGrants(t *testing.T) {
} }
return nil return nil
} }
fp.Say = channelTakes(t, fp.Say)
fp.Compose() fp.Compose()
if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != occ.StateCleared { if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != occ.StateCleared {
t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh) t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh)
@@ -2442,6 +2445,7 @@ func TestGrants(t *testing.T) {
} }
return nil return nil
} }
p.Say = channelTakes(t, p.Say)
// With no lease key (1126/A1). // With no lease key (1126/A1).
// The private network the mesh provides is placed with no fingerprint by construction: never a missing grant. // The private network the mesh provides is placed with no fingerprint by construction: never a missing grant.
b.assign("b", "mesh-wireguard") b.assign("b", "mesh-wireguard")
@@ -2636,6 +2640,14 @@ func TestGrants(t *testing.T) {
b.put(grants.ReadHave, "b.mod-y", runs) b.put(grants.ReadHave, "b.mod-y", runs)
b.put(grants.ReadHave, "a.mesh-delivery-indexer", runs) b.put(grants.ReadHave, "a.mesh-delivery-indexer", runs)
}, ""}, }, ""},
// The wants view unanswered, the assignments answered (issue 1966, the list held from 2026-11-08 16:39): what runs
// comes from the haves, the wants are no failed read, and the pass only adds.
{"the wants view unanswered", func(b *fakeBus, p *grants.Part) {
b.refused[grants.ReadWants] = true
if in := grants.ReadInputs(b, &grants.Manifests{}); in.WantsUnread == "" || len(in.ReadFailed()) != 0 {
t.Errorf("an unanswered wants view is a failed read, or composes no fallback: %q, %v", in.WantsUnread, in.ReadFailed())
}
}, ""},
// The ledger of a held module stays with its module's users, whatever the seat list (held.go). // The ledger of a held module stays with its module's users, whatever the seat list (held.go).
{"the ledger of a module whose manifest is refused", func(b *fakeBus, p *grants.Part) { {"the ledger of a module whose manifest is refused", func(b *fakeBus, p *grants.Part) {
b.refuse("mod-x", ledgerX, "it is held") b.refuse("mod-x", ledgerX, "it is held")
@@ -3074,6 +3086,7 @@ func TestGrants(t *testing.T) {
sayMu.Unlock() sayMu.Unlock()
return nil return nil
} }
say = channelTakes(t, say)
fv := grants.NewViews(owner, say, func() {}, nil) fv := grants.NewViews(owner, say, func() {}, nil)
fv.Rest = b fv.Rest = b
var minutes atomic.Int64 var minutes atomic.Int64
@@ -3467,6 +3480,7 @@ func TestGrants(t *testing.T) {
} }
return nil return nil
} }
p.Say = channelTakes(t, p.Say)
made["max_deliver"] = 9 made["max_deliver"] = 9
res = p.Compose() res = p.Compose()
if len(res.Consumers.Differ) != 1 || !strings.Contains(res.Consumers.Differ[0], "EVENTS.b_mod-c: max deliver 9") || if len(res.Consumers.Differ) != 1 || !strings.Contains(res.Consumers.Differ[0], "EVENTS.b_mod-c: max deliver 9") ||
@@ -3538,6 +3552,7 @@ func TestGrants(t *testing.T) {
p.Objects = &fakeObjects{} p.Objects = &fakeObjects{}
var said []oc.Condition var said []oc.Condition
p.Say = func(c oc.Condition) error { said = append(said, c); return nil } p.Say = func(c oc.Condition) error { said = append(said, c); return nil }
p.Say = channelTakes(t, p.Say)
p.Compose() p.Compose()
inForce, _, _ := p.Sealed() inForce, _, _ := p.Sealed()
if inForce.Digest == "" { if inForce.Digest == "" {
@@ -3708,6 +3723,7 @@ func TestGrants(t *testing.T) {
} }
return nil return nil
} }
p.Say = channelTakes(t, p.Say)
p.Compose() p.Compose()
inForce, _, _ := p.Sealed() inForce, _, _ := p.Sealed()
if inForce.Digest == "" { if inForce.Digest == "" {
@@ -3793,6 +3809,7 @@ func TestGrants(t *testing.T) {
} }
return nil return nil
} }
p.Say = channelTakes(t, p.Say)
p.Compose() p.Compose()
inForce, _, _ := p.Sealed() inForce, _, _ := p.Sealed()
before, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "b.mod-r.mod-r-tools")) before, _, ok, _ := b.ownGet(grants.StateUserList, grants.SealedUserKey(inForce.Digest, "b.mod-r.mod-r-tools"))
@@ -3888,6 +3905,28 @@ func TestGrants(t *testing.T) {
t.Errorf("the kept tool runner was not said: %+v", last) t.Errorf("the kept tool runner was not said: %+v", last)
} }
}) })
// Each first view's page says in the log how long it took (issue 1966: the wants page outgrew the call's bound unseen).
t.Run("a first view's page says how long it took", func(t *testing.T) {
at := time.Date(2026, 11, 8, 22, 0, 0, 0, time.UTC)
now := func() time.Time { at = at.Add(1500 * time.Millisecond); return at }
for _, row := range []struct {
name string
err error
want string
}{
{"answered", nil, `the first view mesh-delivery-assigner.wants, the page after "", took 1.5s: 2 keys at revision 9, next ""`},
{"failed", errors.New("context deadline exceeded"), `the first view mesh-delivery-assigner.wants, the page after "", failed after 1.5s: context deadline exceeded`},
} {
var lines []string
ask := grants.TimedAsk(grants.ReadWants, func(context.Context, string) (views.Answer, error) {
return views.Answer{Revision: 9, Values: map[string]json.RawMessage{"a": nil, "b": nil}}, row.err
}, now, func(f string, a ...any) { lines = append(lines, fmt.Sprintf(f, a...)) })
if _, err := ask(context.Background(), ""); !errors.Is(err, row.err) || len(lines) != 1 || !strings.Contains(lines[0], row.want) {
t.Errorf("%s: said %q (%v), want %q", row.name, lines, err, row.want)
}
}
})
} }
// fakeObjects is the part's object bucket of sealed lists. // fakeObjects is the part's object bucket of sealed lists.
@@ -4084,3 +4123,15 @@ func TestPassComposesMembershipFromTheHaveBuild(t *testing.T) {
} }
}) })
} }
// channelTakes is say, after the operator-channel's own check of each condition: one the channel would refuse (its
// explanation not opening with "Needs you: " and what it needs) fails the test, since the operator would never see it.
func channelTakes(t *testing.T, say func(oc.Condition) error) func(oc.Condition) error {
t.Helper()
return func(c oc.Condition) error {
if _, err := occ.EncodeCondition(c); err != nil {
t.Errorf("the channel refuses the condition %s: %v", c.Key, err)
}
return say(c)
}
}
@@ -224,9 +224,9 @@ func (p *Part) sayDiffer(st *ConsumersState) {
} else { } else {
c.Severity, c.State = occ.SeverityWarning, occ.StateRaised c.Severity, c.State = occ.SeverityWarning, occ.StateRaised
c.Summary = fmt.Sprintf("%d durable consumers differ from their composed configuration", len(st.Differ)) c.Summary = fmt.Sprintf("%d durable consumers differ from their composed configuration", len(st.Differ))
c.Headline, c.Needs = "A module's consumer is not as composed", "a look, and grants_consumer_match for each" c.Headline = "A module's consumer is not as composed"
c.Explanation = occ.NeedsYou + "a look. The bus holds these consumers otherwise than the bus composer composes them, " + needsYou(&c, "a look, and grants_consumer_match for each", "The bus holds these consumers otherwise than the bus composer "+
"and the grants part never changes a held one by itself (its delivery position would be lost): " + why "composes them, and the grants part never changes a held one by itself (its delivery position would be lost): "+why)
c.Resolved = "every account consumer matches, by grants_consumer_match or otherwise" c.Resolved = "every account consumer matches, by grants_consumer_match or otherwise"
} }
if p.Say(c) == nil { if p.Say(c) == nil {
@@ -321,9 +321,9 @@ func (p *Part) sayBehind(st *ConsumersState) {
} else { } else {
c.Severity, c.State = occ.SeverityWarning, occ.StateRaised c.Severity, c.State = occ.SeverityWarning, occ.StateRaised
c.Summary = fmt.Sprintf("%d durable consumers are more than %d messages behind their stream's head", len(st.Behind), BehindBound) c.Summary = fmt.Sprintf("%d durable consumers are more than %d messages behind their stream's head", len(st.Behind), BehindBound)
c.Headline, c.Needs = "Messages pile up for a listener", "a look at the module that reads them" c.Headline = "Messages pile up for a listener"
c.Explanation = occ.NeedsYou + "a look. These consumers stayed far behind their stream's head at two passes " + needsYou(&c, "a look at the module that reads them", "These consumers stayed far behind their stream's head at two passes "+
"running, so what is sent to their modules waits unread (how far each is, in the grants part's last pass): " + why "running, so what is sent to their modules waits unread (how far each is, in the grants part's last pass): "+why)
c.Resolved = "every account consumer is back within the bound" c.Resolved = "every account consumer is back within the bound"
} }
if p.Say(c) == nil { if p.Say(c) == nil {
@@ -354,9 +354,9 @@ func unansweredCondition(fv FirstView, s views.Status, raised bool) oc.Condition
c.Summary = fmt.Sprintf("the grants part has no first view of %s's %s: %s has not answered since %s (%s)", fv.Owner, fv.State, c.Summary = fmt.Sprintf("the grants part has no first view of %s's %s: %s has not answered since %s (%s)", fv.Owner, fv.State,
fv.Tool, s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error)) fv.Tool, s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error))
c.Headline = "The grants part waits for " + fv.Owner c.Headline = "The grants part waits for " + fv.Owner
c.Explanation = "The grants part reads " + fv.Owner + "'s " + fv.State + " only through " + fv.Tool + ", and keeps asking it; " + needsYou(&c, "a look at "+fv.Owner+"'s runtime of its own: is it running and connected?", "The grants part reads "+fv.Owner+
"it never reads the state itself, and keeps what it composed meanwhile (novox/hq ADR 0615 §6). The answer clears this." "'s "+fv.State+" only through "+fv.Tool+", and keeps asking it; it never reads the state itself, and keeps what it "+
c.Needs = "a look at " + fv.Owner + "'s runtime of its own: is it running and connected?" "composed meanwhile (novox/hq ADR 0615 §6). The answer clears this.")
return c return c
} }
@@ -376,10 +376,10 @@ func emptyCondition(fv FirstView, raised bool) oc.Condition {
c.State, c.Severity = occ.StateRaised, occ.SeverityWarning c.State, c.Severity = occ.StateRaised, occ.SeverityWarning
c.Summary = fmt.Sprintf("%s answered %s's %s empty at revision 0, so the grants part keeps the user list in force", fv.Tool, fv.Owner, fv.State) c.Summary = fmt.Sprintf("%s answered %s's %s empty at revision 0, so the grants part keeps the user list in force", fv.Tool, fv.Owner, fv.State)
c.Headline = fv.Owner + "'s " + fv.State + " is empty" c.Headline = fv.Owner + "'s " + fv.State + " is empty"
c.Explanation = occ.NeedsYou + "a step. The owner holds nothing of " + fv.State + " (its state rebuilt by a recreate that could " + needsYou(&c, "let the owner write its state again from its sources ("+step+"), then grants_first_view_again", "The owner holds "+
"not keep the data, or a fresh mesh), so the grants part drops no login and keeps what it holds; a login revoked meanwhile " + "nothing of "+fv.State+" (its state rebuilt by a recreate that could not keep the data, or a fresh mesh), so the grants "+
"stays until the owner's first change (novox/hq design 25 § 12.2)." "part drops no login and keeps what it holds; a login revoked meanwhile stays until the owner's first change "+
c.Needs = "let the owner write its state again from its sources (" + step + "), then grants_first_view_again" "(novox/hq design 25 § 12.2).")
return c return c
} }
@@ -479,6 +479,26 @@ func (p *Part) AskFirstViewAgain(caller, state, why string, record func(what, wh
"says": "the part's process asks it once more at this ask; grants_first_views shows its answer"}, nil "says": "the part's process asks it once more at this ask; grants_first_views shows its answer"}, nil
} }
// TimedAsk is ask, saying in the log how long each page took and how many keys it brought, or after how long it failed:
// a first view that never answers (issue 1966: the wants page outgrew the call's bound) shows how far from its bound it is.
func TimedAsk(tool string, ask func(context.Context, string) (views.Answer, error), now func() time.Time,
logf func(string, ...any)) func(context.Context, string) (views.Answer, error) {
return func(ctx context.Context, after string) (views.Answer, error) {
start := now()
a, err := ask(ctx, after)
took := now().Sub(start).Round(time.Millisecond)
switch {
case logf == nil:
case err != nil:
logf("[mesh-grants] the first view %s, the page after %q, failed after %s: %v", tool, after, took, err)
default:
logf("[mesh-grants] the first view %s, the page after %q, took %s: %d keys at revision %d, next %q", tool, after, took,
len(a.Values), a.Revision, a.Next)
}
return a, err
}
}
// Unwrapped is a call whose answer is read as the tool's own text where the runtime wraps it as content (unwrap), as // Unwrapped is a call whose answer is read as the tool's own text where the runtime wraps it as content (unwrap), as
// a first view's answer is decoded (views.AskVia). // a first view's answer is decoded (views.AskVia).
func Unwrapped(call func(key string, body any) (json.RawMessage, error)) func(key string, body any) (json.RawMessage, error) { func Unwrapped(call func(key string, body any) (json.RawMessage, error)) func(key string, body any) (json.RawMessage, error) {
@@ -339,9 +339,9 @@ func (p *Part) sayMissing(missing []string) {
} else { } else {
c.Severity, c.State = occ.SeverityWarning, occ.StateRaised c.Severity, c.State = occ.SeverityWarning, occ.StateRaised
c.Summary = fmt.Sprintf("%d placed module(s) have no grant, so no want can name them", len(missing)) c.Summary = fmt.Sprintf("%d placed module(s) have no grant, so no want can name them", len(missing))
c.Headline, c.Needs = "A placed module has no bus grant", "a look at why each has none" c.Headline = "A placed module has no bus grant"
c.Explanation = occ.NeedsYou + "a look. The grants part wrote no grant for these modules, so the assigner's " + needsYou(&c, "a look at why each has none", "The grants part wrote no grant for these modules, so the assigner's "+
"credential rule gives them no want: " + why "credential rule gives them no want: "+why)
c.Resolved = "every placed module has a grant at the fingerprint the indexer names" c.Resolved = "every placed module has a grant at the fingerprint the indexer names"
} }
if p.Say(c) == nil { if p.Say(c) == nil {
+4 -3
View File
@@ -143,9 +143,10 @@ func (p *Part) sayHeldModules(held map[string]string, kept map[string]User) {
} else { } else {
c.Severity, c.State = occ.SeverityWarning, occ.StateRaised c.Severity, c.State = occ.SeverityWarning, occ.StateRaised
c.Summary = fmt.Sprintf("%d bus user(s) of a module whose manifest is held are kept in the list: %s", len(users), why) c.Summary = fmt.Sprintf("%d bus user(s) of a module whose manifest is held are kept in the list: %s", len(users), why)
c.Headline, c.Needs = "A module's manifest is held, its bus users are kept", "a manifest the indexer accepts, or a judge that knows its fields" c.Headline = "A module's manifest is held, its bus users are kept"
c.Explanation = occ.NeedsYou + "a manifest the indexer accepts (a refused one mended, or a waiting one judged by an indexer that knows its fields). " + needsYou(&c, "a manifest the indexer accepts, or a judge that knows its fields", "A refused one mended, or a waiting one "+
"The rest of the user list is written as ever; a held module's users stay as the list in force holds them: " + why "judged by an indexer that knows its fields. The rest of the user list is written as ever; a held module's users stay "+
"as the list in force holds them: "+why)
c.Resolved = "every module's manifest is accepted" c.Resolved = "every module's manifest is accepted"
} }
if p.Say(c) == nil { if p.Say(c) == nil {
+18 -2
View File
@@ -180,6 +180,11 @@ type Inputs struct {
// answering (bootstrap only, issue 1966); empty when the assignments placed them. Such a pass retires nothing // answering (bootstrap only, issue 1966); empty when the assignments placed them. Such a pass retires nothing
// (ReadFailures) and seals no list dropping a user of the list in force (holdsBack). // (ReadFailures) and seals no list dropping a user of the list in force (holdsBack).
ByHaves string ByHaves string
// WantsUnread says why this pass composed what runs from each have's fingerprint, the wants view not answering while
// the assignments view did (bootstrap only, issue 1966: a wants page the running assigner cannot answer in a call's
// bound); empty when the wants were read. As ByHaves, such a pass retires nothing (ReadFailures) and only adds
// (keepsLoaded): the list is held only where it would take a permission from a loaded user (holdsBack).
WantsUnread string
// Held is, by module, the manifest a held module's running build was judged on (its have's fingerprint, the builder's // Held is, by module, the manifest a held module's running build was judged on (its have's fingerprint, the builder's
// build of it): what the seats it defines, the streams others hear from it and its reads are composed from while its // build of it): what the seats it defines, the streams others hear from it and its reads are composed from while its
// trunk manifest is refused or waits, so no other principal loses a grant to it (ADR 0601 clause 3). // trunk manifest is refused or waits, so no other principal loses a grant to it (ADR 0601 clause 3).
@@ -519,12 +524,20 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
// What each machine is wanted to run: the want's fingerprint, the builder's build of it, the manifest it built. // What each machine is wanted to run: the want's fingerprint, the builder's build of it, the manifest it built.
var fromTrunk []string var fromTrunk []string
wants := map[string][]byte{} wants := map[string][]byte{}
if in.ByHaves == "" { _, wantsErr := r.Keys(ReadWants)
switch {
case in.ByHaves == "" && wantsErr == nil:
wants = each(r, ReadWants, &in.Gaps) wants = each(r, ReadWants, &in.Gaps)
// What each machine runs now is its have's build, which the want may already have left (issue 1966): the // What each machine runs now is its have's build, which the want may already have left (issue 1966): the
// memberships keep what either build reads, calls and consumes. // memberships keep what either build reads, calls and consumes.
runningByHaves(r, &in, cache, in.Had) runningByHaves(r, &in, cache, in.Had)
} else { case in.ByHaves == "":
// **Bootstrap only** (issue 1966), as the assignments' placement by the haves: while the wants view does not answer,
// what runs is each placed module's have's fingerprint, said as a gap that is no failed read, and the pass only adds.
in.WantsUnread = "the wants view did not answer (" + wantsErr.Error() + "), so what runs is composed from each have's fingerprint"
in.Gaps = append(in.Gaps, Gap{Input: ReadWants, Why: "refused or absent: what runs is composed from each have's fingerprint instead, until it answers (" + wantsErr.Error() + ")"})
fromTrunk = runningByHaves(r, &in, cache, in.Running)
default:
// Placed by the haves, what runs is what each have's fingerprint names, not a want: the assigner's wants are its // Placed by the haves, what runs is what each have's fingerprint names, not a want: the assigner's wants are its
// view too, and not read in such a pass. // view too, and not read in such a pass.
in.Gaps = append(in.Gaps, Gap{Input: ReadWants, Why: "not read: the modules are placed by the node-engines' haves this pass, so what runs is composed from each have's fingerprint"}) in.Gaps = append(in.Gaps, Gap{Input: ReadWants, Why: "not read: the modules are placed by the node-engines' haves this pass, so what runs is composed from each have's fingerprint"})
@@ -716,6 +729,9 @@ func (in Inputs) ReadFailures() []string {
if in.ByHaves != "" { if in.ByHaves != "" {
out = append(out, ReadAssignments+": "+in.ByHaves) out = append(out, ReadAssignments+": "+in.ByHaves)
} }
if in.WantsUnread != "" {
out = append(out, ReadWants+": "+in.WantsUnread)
}
for _, g := range in.Refused { for _, g := range in.Refused {
out = append(out, g.Input+": "+g.Why) out = append(out, g.Input+": "+g.Why)
} }
@@ -21,6 +21,8 @@ func (in Inputs) keepsLoaded() string {
switch { switch {
case in.ByHaves != "": case in.ByHaves != "":
return in.ByHaves return in.ByHaves
case in.WantsUnread != "":
return in.WantsUnread
case in.SeatListUnread: case in.SeatListUnread:
return "the seat list is unread, so only the bootstrapped seats compose" return "the seat list is unread, so only the bootstrapped seats compose"
} }
@@ -0,0 +1,20 @@
package grants
import (
"strings"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
)
// needsYou sets what a raised condition needs of the operator and its explanation: the channel takes a condition whose
// explanation opens with "Needs you: " and those very words (operatorchannelclient.EncodeCondition, novox/hq ADR 0253 §1),
// and refuses any other, so the operator would never see it. rest is the explanation after them.
func needsYou(c *oc.Condition, needs, rest string) {
c.Needs = needs
sep := ". "
if strings.HasSuffix(needs, "?") || strings.HasSuffix(needs, ".") {
sep = " "
}
c.Explanation = occ.NeedsYou + needs + sep + rest
}
@@ -127,9 +127,10 @@ func (p *Part) sayPrincipalsRefused(refused map[string]string, kept map[string]U
} else { } else {
c.Severity, c.State = occ.SeverityWarning, occ.StateRaised c.Severity, c.State = occ.SeverityWarning, occ.StateRaised
c.Summary = fmt.Sprintf("%d principal(s) refused their composed grants: %s", len(refused), why) c.Summary = fmt.Sprintf("%d principal(s) refused their composed grants: %s", len(refused), why)
c.Headline, c.Needs = "A principal's bus grants are refused", "a fix of the manifest or the writers table" c.Headline = "A principal's bus grants are refused"
c.Explanation = occ.NeedsYou + "a fix of what makes it a second writer. The rest of the user list is written as ever; " + needsYou(&c, "a fix of the manifest or the writers table, of what makes it a second writer",
"each refused principal keeps the grants the list in force gives it, or stays out where it holds none: " + why "The rest of the user list is written as ever; each refused principal keeps the grants the list in force gives it, "+
"or stays out where it holds none: "+why)
c.Resolved = "every principal's grants compose again" c.Resolved = "every principal's grants compose again"
} }
if p.Say(c) == nil { if p.Say(c) == nil {
@@ -180,8 +180,8 @@ func (p *Part) sayHeld(why string) {
} else { } else {
reason := strings.TrimPrefix(why, "held: ") reason := strings.TrimPrefix(why, "held: ")
c.Severity, c.State = occ.SeverityWarning, occ.StateRaised c.Severity, c.State = occ.SeverityWarning, occ.StateRaised
c.Summary, c.Headline, c.Needs = "the user list is held: "+reason, "The user list in force is held back", "a look at why" c.Summary, c.Headline = "the user list is held: "+reason, "The user list in force is held back"
c.Explanation = occ.NeedsYou + "a look at why. The bus node keeps following the list written before, not the one composed now: " + reason needsYou(&c, "a look at why", "The bus node keeps following the list written before, not the one composed now: "+reason)
c.Resolved = "a pass finds nothing to hold back and the list advances" c.Resolved = "a pass finds nothing to hold back and the list advances"
} }
if p.Say(c) == nil { if p.Say(c) == nil {