mesh-grants: write grants.<module>.<fp> in shadow into the state grants, say grant-written, and show every difference from the controller's in grants_grants (issue 774, ADR 0547 §5)

Why: the assigner switches its grants source when the controller's lease is handed away; until then the written keys are compared with the controller's, so the switch rests on a measured equality. Needs the group's mesh-sdk pin (PartGrantKey) before it builds.
This commit is contained in:
2026-10-28 21:54:47 +01:00
parent 843a1589d7
commit 3618831992
6 changed files with 346 additions and 38 deletions
+65 -2
View File
@@ -1208,7 +1208,7 @@ func TestGrants(t *testing.T) {
t.Errorf("membership = %v", ms)
}
gr, _ := p.Grants("mod-x")
if g, _ := gr.(map[string]any); g["writes"] != grants.WritesNoGrant || g["count"] != 1 {
if g, _ := gr.(map[string]any); g["writes"] != grants.WritesGrants || g["controller_count"] != 1 {
t.Errorf("grants = %v", gr)
}
@@ -1568,7 +1568,7 @@ func TestGrants(t *testing.T) {
}
}
for _, w := range b.writes {
if !slices.Contains(grants.States, w) {
if !slices.Contains(grants.States, strings.TrimSuffix(w, " delete")) {
t.Errorf("wrote %s, which is not the part's own state", w)
}
}
@@ -1888,6 +1888,69 @@ func TestGrants(t *testing.T) {
}
})
// 774/L3a (novox/hq ADR 0547 §5, ADR 0561 §2): the part writes grants.<module>.<fp> in shadow, once each and only for a
// module whose accounts the vault holds a hash for, says grant-written for each, deletes one only when a module has no
// account with a hash and every read was made, and the tool says every difference from the controller's.
t.Run("grants: written in shadow once each, only for hashed accounts, deleted only when read whole, compared with the controller's", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
p := partOn(b)
fp := "src2:" + strings.Repeat("ab", 32)
key := func(module string) string { k, _ := delivery.PartGrantKey(module, fp); return k }
hash := func(user string) {
b.put(grants.ReadAccounts, user, map[string]any{"user": user, "hash": "$2a$10$x", "generation": 1})
}
held := func() []string { return slices.Sorted(maps.Keys(b.own[grants.StateGrants])) }
// The controller's grant for this fingerprint, beside the stale one the fixture holds for another.
delete(b.states[grants.ReadControllerGrant], "mod-x.0123456789ab0123456789ab0123456789ab0123456789ab0123456789ab0123")
b.put(grants.ReadControllerGrant, key("mod-x"), delivery.ControllerGrant{V: delivery.Version, Accounts: []string{"a.mod-x"}})
hash("a.mod-x")
hash("a.nats")
res := p.Compose()
if res.Grants == nil || !slices.Equal(held(), []string{key("mod-x"), key("nats")}) || len(res.Grants.Written) != 2 {
t.Fatalf("grants of the hashed modules: held %v, %+v", held(), res.Grants)
}
if g, err := delivery.DecodeControllerGrant(b.own[grants.StateGrants][key("mod-x")]); err != nil || !slices.Equal(g.Accounts, []string{"a.mod-x"}) {
t.Errorf("the grant of mod-x is %+v, %v", g, err)
}
said := func() int {
n := 0
for _, e := range b.events {
if e == grants.EventGrantWritten {
n++
}
}
return n
}
if said() != 2 {
t.Errorf("grant-written said %d times for two grants: %v", said(), b.events)
}
if res := p.Compose(); len(res.Grants.Written) != 0 || said() != 2 {
t.Errorf("a second pass wrote %v and said %d", res.Grants.Written, said())
}
// The tool: equal on mod-x, and nats is the part's alone (the controller's fixture holds none).
got, err := p.Grants("")
gm, _ := got.(map[string]any)
diff, _ := gm["differences"].([]grants.GrantDifference)
if err != nil || gm["equal"] != false || len(diff) != 1 || diff[0].Key != key("nats") || diff[0].Kind != "only the part's" {
t.Errorf("grants = %+v, %v", got, err)
}
// A user with a hash later: written once. Without it again: deleted. Unread hashes: nothing deleted.
hash("b.mod-y")
if res := p.Compose(); !slices.Equal(res.Grants.Written, []string{key("mod-y")}) {
t.Errorf("mod-y was granted %v when its account got a hash", res.Grants.Written)
}
b.refused[grants.ReadAccounts] = true
if res := p.Compose(); res.Grants == nil || len(res.Grants.Deleted)+len(res.Grants.Written) != 0 || len(res.Grants.Kept) != 1 || len(held()) != 3 {
t.Errorf("a pass that could not read the hashes changed grants: %+v, held %v", res.Grants, held())
}
delete(b.refused, grants.ReadAccounts)
delete(b.states[grants.ReadAccounts], "b.mod-y")
if res := p.Compose(); !slices.Equal(res.Grants.Deleted, []string{key("mod-y")}) || len(held()) != 2 {
t.Errorf("a module with no hashed account kept its grant: %+v, held %v", res.Grants, held())
}
})
// 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.
@@ -0,0 +1,180 @@
package grants
import (
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **The grants the part writes** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). A module's grant is the record
// that its bus credential exists for the fingerprint the catalogue's trunk names for it: `<module>.<fingerprint hex>` in
// the part's state grants, naming the accounts (the module's own and each process's) the vault holds a hash for. It is the
// controller's grant as the controller composes it (cmd/mesh-controller grants.go grantsFrom), so the two compare by key.
// Until the controller's lease is handed away nobody reads the part's keys: the assigner reads the controller's
// (delivery.GrantSource), and the tool grants says every difference, so the switch is made on a measured equality.
//
// A module of which an account has no hash yet is granted the accounts that have one; a module none of whose accounts has
// one has no grant, and one it was written for is deleted (an older grant naming a node whose account is gone would let the
// assigner want it, and a grant naming no account names every node). Nothing else is ever deleted, and nothing is written
// or deleted in a pass that could not read the vault's hashes, the indexer's modules or a manifest of the module.
// GrantsWritten is what one pass did to the state grants.
type GrantsWritten struct {
Wanted int `json:"wanted"`
Written []string `json:"written,omitempty"`
Deleted []string `json:"deleted,omitempty"`
// Kept says why a pass wrote and deleted nothing, or left a module out.
Kept []string `json:"kept,omitempty"`
}
// GrantsOf is the grant of every module at the fingerprint the indexer's trunk names for it, by key, and the keys that
// have none (drop): see the comment above. A module in skip (its manifest was refused) is neither. Pure.
func GrantsOf(in Inputs, recs buscompose.Records, skip map[string]bool) (map[string]delivery.ControllerGrant, []string, error) {
principals, err := buscompose.Users(recs)
if err != nil {
return nil, nil, err
}
accounts, missing := map[string][]string{}, map[string]bool{}
for _, p := range principals {
if p.Module == "" || (p.Kind != buscompose.KindModule && p.Kind != buscompose.KindProcess) {
continue
}
if in.Hashes[p.Username()].Hash == "" {
missing[p.Module] = true
continue
}
accounts[p.Module] = append(accounts[p.Module], p.Username())
}
out := map[string]delivery.ControllerGrant{}
var drop []string
for module, fp := range in.Fingerprints {
if skip[module] || !strings.HasPrefix(fp, delivery.SourcePrefix2) {
continue
}
key, err := delivery.PartGrantKey(module, fp)
if err != nil {
continue
}
names := accounts[module]
if missing[module] && len(names) == 0 {
drop = append(drop, key)
continue
}
sort.Strings(names)
out[key] = delivery.ControllerGrant{Accounts: names}
}
sort.Strings(drop)
return out, drop, nil
}
// grantsReadFailed names a read of this pass without which no grant is written: the hashes and the indexer's modules and
// manifests. A manifest refused for one module only leaves that module out (writeGrants).
func grantsReadFailed(in Inputs) string {
for _, g := range in.Gaps {
if !g.Failed {
continue
}
for _, input := range []string{ReadAccounts, ReadModules, ReadManifests} {
if strings.HasPrefix(g.Input, input) {
return g.Input + ": " + g.Why
}
}
}
return ""
}
// writeGrants writes each grant whose key is absent or differs and says grant-written for each, and deletes the key of a
// module that has none now.
func (p *Part) writeGrants(res *Result, in Inputs, recs buscompose.Records) {
if why := grantsReadFailed(in); why != "" {
res.Grants = &GrantsWritten{Kept: []string{"no grant is written or deleted in a pass that could not read " + why}}
return
}
skip := map[string]bool{}
for _, g := range in.Refused {
skip[strings.TrimPrefix(g.Input, "manifest of ")] = true
}
want, drop, err := GrantsOf(in, recs, skip)
if err != nil {
res.Errors = append(res.Errors, "the grants could not be composed: "+err.Error())
return
}
done := &GrantsWritten{Wanted: len(want)}
res.Grants = done
for _, key := range sortedKeys(want) {
g := want[key]
data, err := delivery.EncodeControllerGrant(g)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("the grant %s cannot be encoded: %v", key, err))
continue
}
if raw, _, found, err := p.Store.Get(StateGrants, key); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateGrants, key, err))
continue
} else if found {
if was, err := delivery.DecodeControllerGrant(raw); err == nil && delivery.SameControllerGrant(was, withVersion(g)) {
continue
}
}
if _, err := p.Store.Put(StateGrants, key, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateGrants, key, err))
continue
}
done.Written = append(done.Written, key)
module, fp, _ := delivery.ParsePartGrantKey(key)
p.say(res, EventGrantWritten, map[string]any{"module": module, "fingerprint": fp, "accounts": g.Accounts})
}
for _, key := range drop {
if _, _, found, err := p.Store.Get(StateGrants, key); err != nil || !found {
continue
}
if err := p.Store.Delete(StateGrants, key); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateGrants, key, err))
continue
}
done.Deleted = append(done.Deleted, key)
}
}
func withVersion(g delivery.ControllerGrant) delivery.ControllerGrant {
g.V = delivery.Version
return g
}
// GrantDifference is one key on which the part's grants and the controller's differ.
type GrantDifference struct {
Key string `json:"key"`
// Kind is `only the controller's`, `only the part's` or `accounts differ`.
Kind string `json:"kind"`
Controller []string `json:"controller_accounts,omitempty"`
Part []string `json:"part_accounts,omitempty"`
}
// CompareGrants lists every key the two sets differ on, in key order: a key held by one alone, or held by both with other
// accounts. Pure.
func CompareGrants(controller, part map[string]delivery.ControllerGrant) []GrantDifference {
keys := map[string]bool{}
for k := range controller {
keys[k] = true
}
for k := range part {
keys[k] = true
}
out := []GrantDifference{}
for _, k := range sortedKeys(keys) {
c, inC := controller[k]
g, inP := part[k]
switch {
case inC && !inP:
out = append(out, GrantDifference{Key: k, Kind: "only the controller's", Controller: c.Accounts})
case inP && !inC:
out = append(out, GrantDifference{Key: k, Kind: "only the part's", Part: g.Accounts})
case !delivery.SameControllerGrant(withVersion(c), withVersion(g)):
out = append(out, GrantDifference{Key: k, Kind: "accounts differ", Controller: c.Accounts, Part: g.Accounts})
}
}
return out
}
@@ -115,6 +115,9 @@ type Inputs struct {
Manifests map[string]catalogue.Manifest
// Digests is each module's manifest digest, by module.
Digests map[string]string
// 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
// 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
@@ -237,7 +240,7 @@ 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{},
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...)}
nodes := map[string]bool{}
@@ -338,6 +341,9 @@ 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 != "" {
in.Fingerprints[module] = v.FP2
}
var m catalogue.Manifest
if err == nil {
m, err = cache.get(r, v.Manifest)
+18 -8
View File
@@ -23,32 +23,36 @@ const (
StateMemberships = "memberships"
StateAssertions = "assertions"
StateTakeover = "takeover"
KeyList = "list"
KeySwitch = "switch"
// StateGrants is the part's grants by module and fingerprint, the key layout of the controller's (grants.go).
StateGrants = delivery.PartGrantsState
KeyList = "list"
KeySwitch = "switch"
// KeyPass is the last pass's result, and KeyAsked a tool's ask to compose again now, both in StateUserList.
KeyPass = "pass"
KeyAsked = "asked"
)
// States is every state the part keeps.
var States = []string{StateAccounts, StateAssertions, StateMemberships, StateTakeover, StateUserList}
var States = []string{StateAccounts, StateAssertions, StateGrants, StateMemberships, StateTakeover, StateUserList}
// The events the part says.
const (
EventUserList = "user-list-changed"
EventMembership = "membership-changed"
// EventGrantWritten is said when the part wrote a grant of its state grants (ADR 0547 §5, ADR 0561 §2).
EventGrantWritten = "grant-written"
// EventAsserted is said when the part created a bucket, log or stream a manifest declares (issue 1790).
EventAsserted = "asserted"
)
// Emits is every event the part says.
var Emits = []string{EventAsserted, EventHashesAccepted, EventMembership, EventUserList}
var Emits = []string{EventAsserted, EventGrantWritten, EventHashesAccepted, EventMembership, EventUserList}
// Store is the part's own state, by local name: the only thing the part writes.
type Store interface {
Get(state, key string) ([]byte, uint64, bool, error)
Put(state, key string, value []byte) (uint64, error)
// Keys and Delete are for the wanted accounts alone (accounts.go): every other state is only put.
// Keys and Delete are for the wanted accounts (accounts.go) and the grants (grants.go): every other state is only put.
Keys(state string) ([]string, error)
Delete(state, key string) error
}
@@ -118,9 +122,11 @@ type Result struct {
// Consumers is what the pass found and created of the durable consumers an account needs (ADR 0561 §6).
Consumers *ConsumersState `json:"consumers,omitempty"`
// Accounts is what the pass wrote of the wanted accounts the vault mints from (ADR 0561 §2).
Accounts *AccountsWritten `json:"accounts,omitempty"`
Errors []string `json:"errors,omitempty"`
PublishedOrAsserted string `json:"published_or_asserted"`
Accounts *AccountsWritten `json:"accounts,omitempty"`
// Grants is what the pass wrote of its grants (grants.go).
Grants *GrantsWritten `json:"grants,omitempty"`
Errors []string `json:"errors,omitempty"`
PublishedOrAsserted string `json:"published_or_asserted"`
}
// NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships.
@@ -265,6 +271,10 @@ func (p *Part) Compose() Result {
if p.Store != nil {
p.writeAccounts(&res, in, recs)
}
// The grants the assigner reads once the lease is handed away, in shadow until then (ADR 0547 §4, §5).
if p.Store != nil {
p.writeGrants(&res, in, recs)
}
// The list itself, sealed to the bus node and named in `user-list.sealed` (ADR 0547 clause 7).
if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil {
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
+71 -27
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -30,38 +29,83 @@ const (
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState,
ToolAccounts, ToolConsumerMatch, ToolAcceptHashes}
// WritesNoGrant is what grants says of the part's own grants in this milestone.
const WritesNoGrant = "none: the grants part writes no grants.<module>.<fp> before ADR 0547's third milestone (clause 5); the controller's are beside"
// WritesGrants is what grants says of the part's own grants in this milestone: written, and read by nobody until the
// controller's lease is handed away (delivery.GrantSource).
const WritesGrants = "in shadow: the grants part writes grants.<module>.<fp> into its state grants, which the assigner reads only once the controller's lease is handed away (ADR 0547 clause 5); the controller's are beside, with every difference"
// Grants answers the tool grants: what the part writes of grants (none), and the controller's, one module's or all.
// Grants answers the tool grants: the grants the part wrote, the controller's, and every difference between them, one
// module's or all.
func (p *Part) Grants(module string) (any, error) {
keys, err := p.Read.Keys(ReadControllerGrant)
if err != nil {
return map[string]any{"writes": WritesNoGrant, "controller_unread": err.Error()}, nil
}
type grant struct {
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Accounts []string `json:"accounts,omitempty"`
}
out := []grant{}
for _, k := range keys {
m, fp, err := delivery.ParseControllerGrantKey(k)
if err != nil || (module != "" && m != module) {
continue
out := map[string]any{"writes": WritesGrants}
read := func(get func() ([]string, func(string) ([]byte, bool, error), error)) (map[string]delivery.ControllerGrant, error) {
keys, one, err := get()
if err != nil {
return nil, err
}
g := grant{Module: m, Fingerprint: fp}
if module != "" {
if raw, found, err := p.Read.Get(ReadControllerGrant, k); err == nil && found {
if v, err := delivery.DecodeControllerGrant(raw); err == nil {
g.Accounts = v.Accounts
}
m := map[string]delivery.ControllerGrant{}
for _, k := range keys {
mod, _, err := delivery.ParseControllerGrantKey(k)
if err != nil || (module != "" && mod != module) {
continue
}
raw, found, err := one(k)
if err != nil {
return nil, err
}
if !found {
continue
}
g, err := delivery.DecodeControllerGrant(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
m[k] = g
}
out = append(out, g)
return m, nil
}
sort.Slice(out, func(i, j int) bool { return out[i].Module+out[i].Fingerprint < out[j].Module+out[j].Fingerprint })
return map[string]any{"writes": WritesNoGrant, "controller": out, "count": len(out)}, nil
controller, cerr := read(func() ([]string, func(string) ([]byte, bool, error), error) {
keys, err := p.Read.Keys(ReadControllerGrant)
return keys, func(k string) ([]byte, bool, error) { return p.Read.Get(ReadControllerGrant, k) }, err
})
mine, perr := read(func() ([]string, func(string) ([]byte, bool, error), error) {
keys, err := p.Store.Keys(StateGrants)
return keys, func(k string) ([]byte, bool, error) {
raw, _, found, err := p.Store.Get(StateGrants, k)
return raw, found, err
}, err
})
type grant struct {
Key string `json:"key"`
Module string `json:"module"`
Accounts []string `json:"accounts,omitempty"`
}
list := func(m map[string]delivery.ControllerGrant) []grant {
out := []grant{}
for _, k := range sortedKeys(m) {
mod, _, _ := delivery.ParseControllerGrantKey(k)
out = append(out, grant{Key: k, Module: mod, Accounts: m[k].Accounts})
}
return out
}
if cerr != nil {
out["controller_unread"] = cerr.Error()
} else {
out["controller"], out["controller_count"] = list(controller), len(controller)
}
if perr != nil {
out["written_unread"] = perr.Error()
} else {
out["written"], out["count"] = list(mine), len(mine)
}
if cerr == nil && perr == nil {
diff := CompareGrants(controller, mine)
out["differences"], out["differing"] = diff, len(diff)
out["equal"] = len(diff) == 0
}
if last := p.Last(); last.Grants != nil {
out["last_pass"] = last.Grants
}
return out, nil
}
// MembershipOf answers the tool membership: one module on one node as composed now, and as last published by the
+5
View File
@@ -31,6 +31,7 @@
],
"emits": [
"asserted",
"grant-written",
"hashes-accepted",
"membership-changed",
"user-list-changed"
@@ -57,6 +58,10 @@
"name": "assertions",
"history": 5
},
{
"name": "grants",
"history": 5
},
{
"name": "memberships",
"history": 5