mesh-grants: say every placed module left without a grant, in the pass and as the condition mesh-grants.grant-missing once the lease is handed away (issue 1126, ADR 0547 §5)

Why: the controller's grant writer skipped link2pay, snake, f1-circuits and txt-game in silence (it read the fingerprint by
their unchanged commit, a build of the old form) and the take-in said 'its grant written'. The part already reads the
fingerprint from the indexer's value, so those four are granted at their src2 done key's fingerprint; a row proves it.
What a pass leaves without a grant (no fingerprint of the new form with the indexer's none2, a refused manifest, no
hashed account) is now said rather than passed over. Quick: log it. Thorough (chosen): the pass's Missing, shown by the
grants tool, and a warning condition raised and cleared on change, as consumer-differs is.
This commit is contained in:
2026-10-28 21:54:47 +01:00
parent 354b9ea396
commit 6f721dd536
4 changed files with 162 additions and 6 deletions
+58 -2
View File
@@ -1435,7 +1435,12 @@ func TestGrants(t *testing.T) {
var fresh []asks.Condition
fp := partOn(b)
fp.Objects = p.Objects
fp.Asker, fp.Say = fa, func(c asks.Condition) error { fresh = append(fresh, c); return nil }
fp.Asker, fp.Say = fa, func(c asks.Condition) error {
if c.Key != grants.GrantMissingCondition { // its own row below
fresh = append(fresh, c)
}
return nil
}
fp.Compose()
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)
@@ -1951,6 +1956,52 @@ func TestGrants(t *testing.T) {
}
})
// Issue 1126 (C5): a module whose source commit has not moved since a build of the old form, with a done key of the
// new form at that commit (link2pay, snake, f1-circuits, txt-game), is granted at the indexer's fingerprint, never
// judged by its commit; and a placed module left without a grant is said, never passed over: in the pass, and as the
// condition mesh-grants.grant-missing once the part's grants are the ones read, cleared when it has one.
t.Run("grants: at the indexer's fingerprint whatever the commit, and a placed module without one is said", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
p := partOn(b)
var said []asks.Condition
p.Say = func(c asks.Condition) error {
if c.Key == grants.GrantMissingCondition {
said = append(said, c)
}
return nil
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
fp := "src2:d4afe7a1abab5ccdbef6d8629b2245006a2aec60290f430f7b4756e2930ed60c"
b.put(grants.ReadModules, "mod-x", delivery.Module{V: delivery.Version, FP2: fp, Manifest: delivery.ManifestDigest([]byte(xManifest)),
Repository: "novox/Link2Pay", Commit: "5d20e8bfbc953c51e8ebe3c671fe883df9c36e15"})
b.put(grants.ReadModules, "mod-y", delivery.Module{V: delivery.Version, None2: "its builder's version is not read yet",
Manifest: delivery.ManifestDigest([]byte(yManifest)), Repository: "novox/y", Commit: "c"})
for _, u := range []string{"a.mod-x", "a.nats", "b.mod-y"} {
b.put(grants.ReadAccounts, u, map[string]any{"user": u, "hash": "$2a$10$x", "generation": 1})
}
res := p.Compose()
key, _ := delivery.PartGrantKey("mod-x", fp)
if _, held := b.own[grants.StateGrants][key]; !held {
t.Errorf("mod-x has no grant at the indexer's fingerprint %s: %+v", fp, res.Grants)
}
if res.Grants == nil || len(res.Grants.Missing) != 1 || !strings.HasPrefix(res.Grants.Missing[0],
"mod-y: no fingerprint of the new form: its builder's version is not read yet") {
t.Fatalf("missing = %+v", res.Grants)
}
if len(said) != 1 || said[0].State != asks.StateRaised || !strings.Contains(said[0].Explanation, "mod-y") {
t.Fatalf("said %+v", said)
}
if p.Compose(); len(said) != 1 {
t.Errorf("the same missing grant was said again: %+v", said)
}
b.put(grants.ReadModules, "mod-y", delivery.Module{V: delivery.Version, FP2: fp, Manifest: delivery.ManifestDigest([]byte(yManifest)),
Repository: "novox/y", Commit: "c"})
if res := p.Compose(); len(res.Grants.Missing) != 0 || len(said) != 2 || said[1].State != asks.StateCleared {
t.Errorf("once mod-y has a grant: missing %v, said %+v", res.Grants.Missing, said)
}
})
// 774/AC4 (novox/hq ADR 0561 §2): every account the vault mints that the records compose is written to the state
// accounts once, with the bus's address and a module's claims, read by the vault alone; a key is deleted when placement
// no longer names its user, and never in a pass whose reads failed.
@@ -2092,7 +2143,12 @@ func TestGrants(t *testing.T) {
// A held consumer that differs is never changed by a pass: it is raised as a condition naming it, and brought to
// the composed configuration only by hand (grants_consumer_match), never while the controller holds the lease.
var said []asks.Condition
p.Say = func(c asks.Condition) error { said = append(said, c); return nil }
p.Say = func(c asks.Condition) error {
if c.Key != grants.GrantMissingCondition { // its own row
said = append(said, c)
}
return nil
}
made["max_deliver"] = 9
res = p.Compose()
if len(res.Consumers.Differ) != 1 || !strings.Contains(res.Consumers.Differ[0], "EVENTS.b_mod-c: max deliver 9") ||
+89 -1
View File
@@ -2,9 +2,11 @@ package grants
import (
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -28,6 +30,91 @@ type GrantsWritten struct {
Deleted []string `json:"deleted,omitempty"`
// Kept says why a pass wrote and deleted nothing, or left a module out.
Kept []string `json:"kept,omitempty"`
// Missing is each module the placement or an assignment puts on a machine that has no grant after the pass,
// `<module>: <why>`: the assigner's sixth rule wants none of it (MissingGrants). Never passed over in silence: once
// the part's grants are the ones read, it is the condition mesh-grants.grant-missing.
Missing []string `json:"missing,omitempty"`
}
// GrantMissingCondition is the key of the warning raised while a placed module has no grant (issue 1126).
const GrantMissingCondition = "mesh-grants.grant-missing"
// MissingGrants is each module assigned or placed on a machine that want leaves without a grant, `<module>: <why>`,
// sorted: its manifest refused (skip), no fingerprint of the new form at the indexer's trunk, or no account with a hash
// in the vault (drop). **The fingerprint is the indexer's, never a commit's** (issue 1126: link2pay, snake, f1-circuits
// and txt-game, whose source commit has not moved since a build of the old form, have a done key of the new form at it,
// and that is the fingerprint granted). Pure.
func MissingGrants(in Inputs, want map[string]delivery.ControllerGrant, drop []string, skip map[string]bool) []string {
placed := map[string]bool{}
for _, ms := range in.Assigned {
for _, m := range ms {
placed[m] = true
}
}
var out []string
for _, module := range sortedKeys(placed) {
fp, has := in.Fingerprints[module]
key, err := delivery.PartGrantKey(module, fp)
if has && err == nil {
if _, granted := want[key]; granted {
continue
}
}
why := ""
switch {
case skip[module]:
why = "its manifest at the indexer's trunk is refused"
case !has:
why = "no fingerprint of the new form: " + orSaid(in.NoFingerprint[module], "the indexer holds no value of it")
case err != nil:
why = fmt.Sprintf("no grant key for %s: %v", fp, err)
case slices.Contains(drop, key):
why = "none of its accounts at " + fp + " has a hash in the vault"
default:
why = "no grant composed at " + fp
}
out = append(out, module+": "+why)
}
return out
}
func orSaid(s, none string) string {
if s == "" {
return none
}
return s
}
// sayMissing raises mesh-grants.grant-missing while a placed module has no grant, once the controller's lease is handed
// away (the part's grants are then the ones the assigner reads), and clears it when every one has one; in shadow the
// pass and the tool say them. Said once per set, again when it changes, and always at the first pass after a start.
func (p *Part) sayMissing(missing []string) {
if p.Say == nil {
return
}
why := ""
if on, _ := leaseHandedAway(p.Read); on && len(missing) > 0 {
why = strings.Join(missing, "; ")
}
p.holdMu.Lock()
defer p.holdMu.Unlock()
if p.missingKnown && why == p.missingSaid {
return
}
c := asks.Condition{Key: GrantMissingCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}}
if why == "" {
c.State = asks.StateCleared
} else {
c.Severity, c.State = asks.SeverityWarning, asks.StateRaised
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.Explanation = asks.NeedsYou + "a look. The grants part wrote no grant for these modules, so the assigner's " +
"credential rule gives them no want: " + why
c.Resolved = "every placed module has a grant at the fingerprint the indexer names"
}
if p.Say(c) == nil {
p.missingSaid, p.missingKnown = why, true
}
}
// GrantsOf is the grant of every module at the fingerprint the indexer's trunk names for it, by key, and the keys that
@@ -102,8 +189,9 @@ func (p *Part) writeGrants(res *Result, in Inputs, recs buscompose.Records) {
res.Errors = append(res.Errors, "the grants could not be composed: "+err.Error())
return
}
done := &GrantsWritten{Wanted: len(want)}
done := &GrantsWritten{Wanted: len(want), Missing: MissingGrants(in, want, drop, skip)}
res.Grants = done
p.sayMissing(done.Missing)
for _, key := range sortedKeys(want) {
g := want[key]
data, err := delivery.EncodeControllerGrant(g)
+12 -3
View File
@@ -118,6 +118,9 @@ type Inputs struct {
// Fingerprints is the fingerprint of the new form the indexer's trunk value names for each module, by module: what a
// grant is written for (grants.go).
Fingerprints map[string]string
// NoFingerprint is why the indexer's value of a module names no fingerprint of the new form, by module (its none2, or
// that it names none): a module here gets no grant, and that is said (grants.go MissingGrants).
NoFingerprint map[string]string
// Running is the manifest a machine is wanted to run, by `<node>.<module>`: the one the builder built at the
// fingerprint the assigner's want names. An assignment with none is composed from the trunk's manifest.
Running map[string]catalogue.Manifest
@@ -241,8 +244,8 @@ func each(r Reader, state string, gaps *[]Gap) map[string][]byte {
// ReadInputs reads what the shadow composes from. It never fails: what it cannot read is a gap.
func ReadInputs(r Reader, cache *Manifests) Inputs {
in := Inputs{Assigned: map[string][]string{}, Manifests: map[string]catalogue.Manifest{}, Digests: map[string]string{}, Fingerprints: map[string]string{},
Running: map[string]catalogue.Manifest{},
Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)}
NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{},
Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)}
nodes := map[string]bool{}
for _, raw := range each(r, ReadAssignments, &in.Gaps) {
var a assignment
@@ -341,8 +344,14 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
if err == nil && v.Manifest == "" {
err = fmt.Errorf("the indexer's value names no manifest digest")
}
if err == nil && v.FP2 != "" {
if err == nil && strings.HasPrefix(v.FP2, delivery.SourcePrefix2) {
in.Fingerprints[module] = v.FP2
} else if err == nil {
why := v.None2
if why == "" {
why = "the indexer's value names no fingerprint of the new form"
}
in.NoFingerprint[module] = why
}
var m catalogue.Manifest
if err == nil {
@@ -179,6 +179,9 @@ type Part struct {
// differSaid is the account consumers' differences last said as a condition, once differKnown (consumers.go).
differSaid string
differKnown bool
// missingSaid is the modules without a grant last said as a condition, once missingKnown (grants.go).
missingSaid string
missingKnown bool
// hashHold are the users whose hash changes held the pass's list, or nil (sealed.go).
hashHold []string
}