package grants import ( "encoding/json" "errors" "fmt" "regexp" "slices" "strings" "time" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-grants/internal/buscompose" ) // **The list the bus node loads** (novox/hq ADR 0547 clause 7, issue 1945). Each pass composes the bus's user list with // the vault's hashes (the accounts block, buscompose.ComposeAccounts, as the bus module's file holds it), seals it to the // bus node's key (the key the node says in its have of the bus module) and puts it as one object of the part's object // bucket, named by its digest; then writes the key `user-list.sealed` naming it, with the revision, the user count and // the users left out for want of a hash. The key always says FollowGrants: the bus node follows this list alone since the // controller ended by its unassignment (ADR 0590, issue 1126). Only the process writes them (Objects); a tool asks it. // KeySealed is the key of the state user-list the bus node reads (delivery.GrantsUserListKey). const KeySealed = delivery.GrantsUserListKey // Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools. type Objects interface { PutObject(name string, data []byte) error DeleteObject(name string) error } // ComposeList is the bus's user list as the file holds it, with each user's hash from the vault; the users left out // for want of one; and the refused principals (refused, by user) the list carries with their grants kept from the list // in force (kept), or leaves out where kept holds none (dropped, sorted). Pure. func ComposeList(r buscompose.Records, hashes map[string]HeldHash, refused map[string]string, kept map[string]User) (list []byte, users int, leftOut, dropped []string, err error) { principals, err := buscompose.Users(r) if err != nil { return nil, 0, nil, nil, err } held := map[string]string{} for user, h := range hashes { held[user] = h.Hash } composing := slices.DeleteFunc(slices.Clone(principals), func(p buscompose.Principal) bool { _, no := refused[p.Username()]; return no }) filled, missing := buscompose.WithPasswords(composing, held) text, err := buscompose.ComposeAccounts(filled) if err != nil { return nil, 0, nil, nil, err } var entries []string for _, user := range sortedKeys(refused) { u, ok := kept[user] switch { case !ok: dropped = append(dropped, user) case held[user] == "": missing = append(missing, user) default: entries = append(entries, keptEntry(u, held[user])) } } if text, err = withEntries(text, entries); err != nil { return nil, 0, nil, nil, err } slices.Sort(missing) return []byte(text), len(filled) + len(entries), missing, dropped, nil } // Sealed is the part's key `user-list.sealed` as written, and whether there is one. func (p *Part) Sealed() (delivery.GrantsUserList, uint64, bool) { raw, rev, found, err := p.Store.Get(StateUserList, KeySealed) if err != nil || !found { return delivery.GrantsUserList{}, 0, false } g, err := delivery.DecodeGrantsUserList(raw) if err != nil { return delivery.GrantsUserList{}, 0, false } return g, rev, true } // writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in // res what it wrote, or why it wrote nothing. func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut, dropped []string, in Inputs, bus []string, lost []string, composed []User) { cur, _, had := p.Sealed() next := cur // The bus node follows this list alone (ADR 0571 clause 8): always since the controller ended by its unassignment // (novox/hq ADR 0590, issue 1126). next.Follow = delivery.FollowGrants key := "" if in.Loaded != nil { key = in.Loaded.Key } digest := delivery.UserListDigest(list) why, held := "", "" switch { case p.Objects == nil: // The tools: the process writes. res.Sealed = sealedSays(cur, had, "") return case in.BusNode == "": why = "no node runs the bus's module, so the list is sealed to nobody" case key == "": why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)" case cur.Digest != digest && p.holdsBack(in, leftOut, dropped, bus, lost, digest) != "": // The list in force is never replaced by one composed past a failed read, or one that drops a user of it for want // of a hash: that user's module would be locked out of the bus (review of the switch). why = p.holdsBack(in, leftOut, dropped, bus, lost, digest) held = why p.keepHashHold(res, digest) case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode: object, err := delivery.SealUserList(list, key) if err == nil { err = p.Objects.PutObject(delivery.UserListObjectName(digest), object) } if err != nil { res.Errors = append(res.Errors, "the sealed user list could not be put: "+err.Error()) res.Sealed = sealedSays(cur, had, "") return } if cur.Digest != digest { next.Revision++ } next.Digest, next.Object, next.SealedTo, next.Node = digest, delivery.UserListObjectName(digest), delivery.SealingKeyID(key), in.BusNode } p.sayHeld(held) if next.Digest == digest { next.Users, next.LeftOut = users, leftOut } if next.Digest == "" { next.Users, next.LeftOut = 0, nil } if had && sameSealed(cur, next) { res.Sealed = sealedSays(cur, had, why) return } next.At = p.now() data, err := delivery.EncodeGrantsUserList(next) if err == nil { _, err = p.Store.Put(StateUserList, KeySealed, data) } if err != nil { res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeySealed, err)) res.Sealed = sealedSays(cur, had, "") return } if next.Digest == digest && cur.Digest != digest { p.keepWrittenUsers(res, list, in.Hashes) p.keepSealedGrants(res, digest, list, composed) p.acceptedWritten(res, digest) } // The object the key named before is taken away once the key names the new one: the bucket keeps the list in force. if had && cur.Object != "" && cur.Object != next.Object { if err := p.Objects.DeleteObject(cur.Object); err != nil { res.Errors = append(res.Errors, "the sealed list before could not be taken away: "+err.Error()) } } res.Sealed = sealedSays(next, true, why) p.say(res, EventUserList, map[string]any{"digest": next.Digest, "revision": next.Revision, "users": next.Users, "left_out": len(next.LeftOut), "follow": next.Follow, "sealed_to": next.Node}) } // sayHeld raises the warning condition mesh-grants.user-list-held while the list in force is kept (why, which words the pass's // `held: ...`), and clears it on the first pass that holds nothing back: the list advanced, or the reason went. The refusal // condition's way (sayRefused): said once per reason, again when the reason changes, and tried again when a say failed. func (p *Part) sayHeld(why string) { if p.Say == nil { return } p.holdMu.Lock() defer p.holdMu.Unlock() if p.heldKnown && why == p.heldSaid { return } c := asks.Condition{Key: HeldCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}} if why == "" { c.State = asks.StateCleared } else { reason := strings.TrimPrefix(why, "held: ") c.Severity, c.State = asks.SeverityWarning, asks.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.Explanation = asks.NeedsYou + "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" } if p.Say(c) == nil { p.heldSaid, p.heldKnown = why, true } } // HeldCondition is the key of the warning raised while a list in force is held back. const HeldCondition = "mesh-grants.user-list-held" func sameSealed(a, b delivery.GrantsUserList) bool { // Its time is no change, and no user left out is the same written as [] or null: the key read back says [] for the // nil a pass composes, which read as a change wrote the key again at every pass, and each write moved the bus node's // report and woke the next pass (issue 1126: the shadow list's revision 99270 to 99357 in 90 s, 2026-10-28). a.At, b.At = b.At, a.At if len(a.LeftOut) == 0 { a.LeftOut = nil } if len(b.LeftOut) == 0 { b.LeftOut = nil } x, _ := json.Marshal(a) y, _ := json.Marshal(b) return string(x) == string(y) } // SealedState is what a pass says of the sealed list: the key as written, and why nothing was written when nothing was. type SealedState struct { Written *delivery.GrantsUserList `json:"written,omitempty"` Why string `json:"why,omitempty"` } func sealedSays(g delivery.GrantsUserList, had bool, why string) *SealedState { s := &SealedState{Why: why} if had { s.Written = &g } return s } func short(d string) string { d = strings.TrimPrefix(d, "sha256:") if len(d) > 12 { d = d[:12] } return d } // KeyWrittenUsers is the key of the state user-list naming the users of the list the key `sealed` names: what a list // composed must keep. const KeyWrittenUsers = "sealed-users" // KeyWrittenCredentials is the key of the state user-list naming, per user of the list in force, which password its hash // is of (credentialOf: adopted, or the vault's generation and fingerprint), never the hash: what tells a list that changes // several loaded users' hashes at once (ADR 0561 §7). const KeyWrittenCredentials = "sealed-credentials" func (p *Part) keepWrittenUsers(res *Result, list []byte, hashes map[string]HeldHash) { users := listUsers(list) data, _ := json.Marshal(users) if _, err := p.Store.Put(StateUserList, KeyWrittenUsers, data); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenUsers, err)) } creds := map[string]string{} for _, u := range users { if h, ok := hashes[u]; ok { creds[u] = credentialOf(h) } } data, _ = json.Marshal(creds) if _, err := p.Store.Put(StateUserList, KeyWrittenCredentials, data); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenCredentials, err)) } } // changedHashes are the users of the list in force whose hash the list composed now would change with no rotation by the // vault naming them (its announcement on the hash, or a node's login rotating in node-logins, ADR 0583 §2), sorted; none // when the part kept no credentials of the list in force (a list written before). func (p *Part) changedHashes(hashes map[string]HeldHash, rotating map[string]bool) ([]string, error) { raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenCredentials) if err != nil || !found { return nil, err } var had map[string]string if err := json.Unmarshal(raw, &had); err != nil { return nil, err } var changed []string for user, was := range had { h, ok := hashes[user] if !ok || credentialOf(h) == was || h.Rotating != nil || rotating[user] { continue } changed = append(changed, user) } slices.Sort(changed) return changed, nil } var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`) // listUsers names the users of a list as the composer writes it. func listUsers(list []byte) []string { out := []string{} for _, m := range listUser.FindAllSubmatch(list, -1) { out = append(out, string(m[1])) } return out } // holdsBack says why a list composed now may not replace the one in force: a read failed this pass, a // 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, dropped, bus, lost []string, digest string) string { p.hashHold = nil if f := in.ReadFailures(); len(f) > 0 { return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" } if bus == nil { 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 + "the last holder of " + strings.Join(lost, ", ") } raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers) if err != nil { return "held: the users of the list in force cannot be read: " + err.Error() } var had []string switch { case found: _ = json.Unmarshal(raw, &had) default: had = bus } var unhashed []string for _, u := range leftOut { if slices.Contains(had, u) { unhashed = append(unhashed, u) } } if len(unhashed) > 0 { return "held: the list in force is kept, since " + strings.Join(unhashed, ", ") + " would be left out for want of a hash in the vault" } // A refused principal the list in force carries and whose grants there the part did not keep: leaving it out would lock // it out of the bus (refused.go), so the list in force stands until its grants compose or are kept. var unkept []string for _, u := range dropped { if slices.Contains(had, u) { unkept = append(unkept, u) } } if len(unkept) > 0 { return "held: the list in force is kept, since " + strings.Join(unkept, ", ") + " would be left out: its grants are refused and the part kept none of the list in force" } // More than one loaded user's hash changing at once, with no rotation by the vault naming each (ADR 0561 §7): a new // writer of hashes must not lock several holders out in one list, as nine seats were cut on 2026-10-27 at 09:21. changed, err := p.changedHashes(in.Hashes, in.Rotating()) if err != nil { return "held: the credentials of the list in force cannot be read: " + err.Error() } if len(changed) > 1 && !p.accepted(digest, changed) { p.hashHold = changed return "held: the list in force is kept, since the hashes of " + fmt.Sprint(len(changed)) + " loaded users would change at " + "once with no rotation by the vault naming them: " + strings.Join(changed, ", ") } return "" } // **A person accepts a held list's hash changes** (ADR 0561 §7, §9; tools-are-how-a-module-is-spoken-to): the hold of // several loaded users' hashes changing at once is released only by grants_accept_hashes, naming exactly the users held, // with why, bound to the held list's digest. The acceptance releases this guard alone, once: the other holds (a failed // read, a user dropped for want of a hash, a lost right) stand whatever it says; and a list composed otherwise since is // held again, for a new acceptance. // KeyHashHold names the list held for its hash changes (its digest and the users), and KeyHashAccept a person's acceptance. const ( KeyHashHold = "hash-hold" KeyHashAccept = "hash-accept" ) // EventHashesAccepted is said when a list held for its hash changes is written on a person's acceptance: a hand act. const EventHashesAccepted = "hashes-accepted" // HashHold is the value of KeyHashHold, and HashAccept of KeyHashAccept. type HashHold struct { Digest string `json:"digest"` Users []string `json:"users"` At time.Time `json:"at"` } type HashAccept struct { Digest string `json:"digest"` Users []string `json:"users"` By string `json:"by"` Why string `json:"why"` At time.Time `json:"at"` } // keepHashHold writes which list is held for its hash changes, when that is the hold. func (p *Part) keepHashHold(res *Result, digest string) { if p.hashHold == nil { return } data, _ := json.Marshal(HashHold{Digest: digest, Users: p.hashHold, At: p.now()}) if raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold); err == nil && found { var was HashHold if json.Unmarshal(raw, &was) == nil && was.Digest == digest && slices.Equal(was.Users, p.hashHold) { return } } if _, err := p.Store.Put(StateUserList, KeyHashHold, data); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyHashHold, err)) } } // accepted says a person accepted exactly these users' hash changes of the list of this digest. func (p *Part) accepted(digest string, users []string) bool { raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept) if err != nil || !found { return false } var a HashAccept return json.Unmarshal(raw, &a) == nil && a.Digest == digest && slices.Equal(a.Users, users) } // acceptedWritten says, once, that the list a person accepted is written, and forgets the acceptance and the hold. func (p *Part) acceptedWritten(res *Result, digest string) { raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept) if err != nil || !found { return } var a HashAccept if json.Unmarshal(raw, &a) != nil || a.Digest != digest { return } p.say(res, EventHashesAccepted, map[string]any{"digest": digest, "users": a.Users, "by": a.By, "why": a.Why}) for _, k := range []string{KeyHashAccept, KeyHashHold} { if err := p.Store.Delete(StateUserList, k); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be taken away: %v", StateUserList, k, err)) } } } // AcceptHashes is the tool accept-hashes: a person accepts the hash changes of the list held for them, naming exactly its // users, with why. Refused when no list is held for its hashes, or for another set of users. Bound to the held list's // digest: a list composed otherwise since is held again. func (p *Part) AcceptHashes(caller string, users []string, why string) (any, error) { if strings.TrimSpace(why) == "" { return nil, errors.New("accept-hashes says why (`why`)") } raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold) if err != nil { return nil, fmt.Errorf("the held list cannot be read: %w", err) } var h HashHold if !found || json.Unmarshal(raw, &h) != nil || h.Digest == "" { return nil, errors.New("no list is held for its users' hashes changing: there is nothing to accept") } named := slices.Sorted(slices.Values(users)) if !slices.Equal(named, h.Users) { return nil, fmt.Errorf("the list held (%s) changes the hashes of %s: an acceptance names exactly those, and %s is not that", short(h.Digest), strings.Join(h.Users, ", "), strings.Join(named, ", ")) } data, _ := json.Marshal(HashAccept{Digest: h.Digest, Users: h.Users, By: someoneOr(caller), Why: strings.TrimSpace(why), At: p.now()}) rev, err := p.Store.Put(StateUserList, KeyHashAccept, data) if err != nil { return nil, fmt.Errorf("the acceptance could not be written: %w", err) } return map[string]any{"accepted": rev, "digest": h.Digest, "users": h.Users, "says": "the part's process writes the held " + "list at its next pass, once, unless another hold stands or the list composed then is another; it says hashes-accepted"}, nil } func someoneOr(caller string) string { if caller == "" { return "a caller the runtime did not prove" } return caller }