The grants part wrote a binding into a membership on the bus module's created, but granted its subjects to no account but the ledger's, so distribution's program and the tool runner carrying it were refused their own key-value, object store and stream entries (issue 1966, ADR 0595 §1, design 25 § 12.2). The kept bindings now reach Declared in the same pass that writes them into the membership, and one function, boundOf, grants each to the module's own account and the tool runner, as its old section is granted; a process's bus.state names a key-value entry as an old state. The composed membership carries the bindings, so grants_membership's two sides agree. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
327 lines
12 KiB
Go
327 lines
12 KiB
Go
package grants
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
|
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
|
"mesh-grants/internal/buscompose"
|
|
)
|
|
|
|
// The part's tools, as its manifest lists them. The part claims no seat: the seat mesh-grants served the lease write
|
|
// alone, which went with the controller (novox/hq ADR 0590 §3, issue 1126).
|
|
const (
|
|
ToolGrants = "grants_grants"
|
|
ToolMembership = "grants_membership"
|
|
ToolUserList = "grants_user_list"
|
|
ToolComposeNow = "grants_compose_now"
|
|
ToolAssert = "grants_assert"
|
|
ToolAccounts = "grants_accounts"
|
|
ToolConsumerMatch = "grants_consumer_match"
|
|
ToolAcceptHashes = "grants_accept_hashes"
|
|
// ToolFirstViews and ToolFirstViewAgain read and ask again the part's first views (firstviews.go).
|
|
ToolFirstViews = "grants_first_views"
|
|
ToolFirstViewAgain = "grants_first_view_again"
|
|
// ToolSeatList and ToolSeatListAgain read and read again the seat list the part composes from (mesh-sdk seatlist.Tools).
|
|
ToolSeatList = "grants_seat_list"
|
|
ToolSeatListAgain = "grants_seat_list_again"
|
|
)
|
|
|
|
// Tools is every tool, in the manifest's order.
|
|
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch,
|
|
ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolSeatList, ToolSeatListAgain}
|
|
|
|
// WritesGrants is what grants says of the part's own grants: written once the list the bus loaded carries a build's
|
|
// accounts, and the one key the assigner reads (ADR 0571 clause 9, which changes ADR 0547 clause 5).
|
|
const WritesGrants = "the grants part writes grants.<module>.<fp> into its state grants, once the list the bus loaded carries that build's accounts; it is the one key the assigner reads (ADR 0571 clause 9)"
|
|
|
|
// Grants answers the tool grants: the grants the part wrote, one module's or all.
|
|
func (p *Part) Grants(module string) (any, error) {
|
|
out := map[string]any{"writes": WritesGrants}
|
|
read := func(get func() ([]string, func(string) ([]byte, bool, error), error)) (map[string]grantsstate.Grant, error) {
|
|
keys, one, err := get()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m := map[string]grantsstate.Grant{}
|
|
for _, k := range keys {
|
|
mod, _, err := grantsstate.ParsePartGrantKey(k)
|
|
if err != nil || (module != "" && mod != module) {
|
|
continue
|
|
}
|
|
raw, found, err := one(k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
g, err := grantsstate.DecodeGrant(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", k, err)
|
|
}
|
|
m[k] = g
|
|
}
|
|
return m, nil
|
|
}
|
|
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]grantsstate.Grant) []grant {
|
|
out := []grant{}
|
|
for _, k := range sortedKeys(m) {
|
|
mod, _, _ := grantsstate.ParsePartGrantKey(k)
|
|
out = append(out, grant{Key: k, Module: mod, Accounts: m[k].Accounts})
|
|
}
|
|
return out
|
|
}
|
|
if perr != nil {
|
|
out["written_unread"] = perr.Error()
|
|
} else {
|
|
out["written"], out["count"] = list(mine), len(mine)
|
|
}
|
|
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
|
|
// part's process (its state memberships says what became of each at the last pass).
|
|
func (p *Part) MembershipOf(node, module string) (any, error) {
|
|
if node == "" && module == "" {
|
|
return p.Held()
|
|
}
|
|
if node == "" || module == "" {
|
|
return nil, errors.New("membership names a node and a module, or neither for every membership held back")
|
|
}
|
|
in := p.inputs()
|
|
// What the bus module said it created, as the pass reads it, so the composed side carries the grants and bindings the
|
|
// pass writes (issue 1966).
|
|
boundErr := p.withBound(&in)
|
|
recs, left := ListRecords(in)
|
|
notFree := WithRootFree(&recs, p.Call)
|
|
m, ok := Memberships(recs)[node+"."+module]
|
|
out := map[string]any{"node": node, "module": module, "gaps": MembershipGaps, "root_free": recs.RootFree, "root_not_free": notFree}
|
|
if ok {
|
|
out["composed"] = m
|
|
} else {
|
|
why := fmt.Sprintf("%s is not assigned to %s on the bus", module, node)
|
|
for _, g := range left {
|
|
if g.Input == module+" on "+node {
|
|
why = g.Why
|
|
}
|
|
}
|
|
out["composed"] = why
|
|
}
|
|
// The state holds the bare membership last published (mesh-sdk's codec, issue 774); what became of it at the last
|
|
// pass is the pass's, kept in user-list at KeyPass.
|
|
key := node + "." + module
|
|
if raw, rev, found, err := p.Store.Get(StateMemberships, key); err != nil {
|
|
out["published"] = "not readable: " + err.Error()
|
|
} else if !found {
|
|
out["published"] = "no pass of the part's process has published it yet"
|
|
} else if was, err := membership.DecodeGrantsMembership(raw); err != nil {
|
|
out["published"] = fmt.Sprintf("revision %d is not readable: %v", rev, err)
|
|
} else {
|
|
out["published"] = map[string]any{"revision": rev, "digest": digestOf(was), "membership": was}
|
|
}
|
|
out["last_pass"] = p.passOf(key)
|
|
if boundErr != nil {
|
|
out["bindings"] = "not readable: " + boundErr.Error()
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// passOf is what the last pass the part's process kept says of one membership: published, unchanged or why not, and
|
|
// the seat verbs it dropped as retired.
|
|
func (p *Part) passOf(key string) any {
|
|
raw, rev, found, err := p.Store.Get(StateUserList, KeyPass)
|
|
if err != nil {
|
|
return "not readable: " + err.Error()
|
|
}
|
|
if !found {
|
|
return "no pass of the part's process has been kept yet"
|
|
}
|
|
var res Result
|
|
if err := json.Unmarshal(raw, &res); err != nil {
|
|
return "not readable: " + err.Error()
|
|
}
|
|
said := MembershipUnchanged
|
|
if slices.Contains(res.Published, key) {
|
|
said = MembershipPublished
|
|
}
|
|
for _, n := range res.NotPublished {
|
|
if k, why, _ := strings.Cut(n, ": "); k == key {
|
|
said = why
|
|
}
|
|
}
|
|
out := map[string]any{"revision": rev, "published": said}
|
|
if r := res.Retired[key]; len(r) > 0 {
|
|
out["retired"] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Held answers every membership the part's process held back at its last pass, each `<node>.<module>` with why (what
|
|
// publishing would lose), from the pass it keeps (issue 1945).
|
|
func (p *Part) Held() (any, error) {
|
|
raw, rev, found, err := p.Store.Get(StateUserList, KeyPass)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the last pass cannot be read: %w", err)
|
|
}
|
|
if !found {
|
|
return map[string]any{"held": []any{}, "says": "no pass of the part's process has been kept yet"}, nil
|
|
}
|
|
var res Result
|
|
if err := json.Unmarshal(raw, &res); err != nil {
|
|
return nil, fmt.Errorf("the last pass cannot be read: %w", err)
|
|
}
|
|
type held struct {
|
|
Membership string `json:"membership"`
|
|
Why string `json:"why"`
|
|
}
|
|
out := []held{}
|
|
for _, n := range res.NotPublished {
|
|
key, why, _ := strings.Cut(n, ": ")
|
|
out = append(out, held{Membership: key, Why: why})
|
|
}
|
|
refused := res.Refused
|
|
if refused == nil {
|
|
refused = []string{}
|
|
}
|
|
return map[string]any{"held": out, "count": len(out), "pass": rev, "published": res.Published, "refused": refused}, nil
|
|
}
|
|
|
|
// Pass composes and keeps the result as the last pass, for the tools to read.
|
|
func (p *Part) Pass() Result {
|
|
res := p.Compose()
|
|
data, _ := json.Marshal(res)
|
|
if _, err := p.Store.Put(StateUserList, KeyPass, data); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyPass, err))
|
|
}
|
|
return res
|
|
}
|
|
|
|
// AskCompose is the tool compose-now: it asks the part's process to compose and publish now (only the process holds the
|
|
// grant to publish a membership), and answers the last pass, whose revision moves when the process answered.
|
|
func (p *Part) AskCompose(caller string) (any, error) {
|
|
ask, _ := json.Marshal(Ask{By: caller, At: p.now()})
|
|
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the ask could not be written: %w", err)
|
|
}
|
|
out := map[string]any{"asked": rev, "says": "the part's process composes and publishes at this ask; grants_user_list's last pass says what it published"}
|
|
if raw, prev, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
|
|
out["last_pass"] = map[string]any{"revision": prev, "result": json.RawMessage(raw)}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UserList answers the tool user-list: the shadow list as last written, beside what the bus node says it loaded now.
|
|
func (p *Part) UserList() (any, error) {
|
|
out := map[string]any{"mode": InForce}
|
|
raw, rev, found, err := p.Store.Get(StateUserList, KeyList)
|
|
switch {
|
|
case err != nil:
|
|
out["written"] = "not readable: " + err.Error()
|
|
case !found:
|
|
out["written"] = "nothing composed yet: grants_compose_now composes it"
|
|
default:
|
|
var st UserListState
|
|
if err := json.Unmarshal(raw, &st); err != nil {
|
|
return nil, fmt.Errorf("its own user list cannot be read: %w", err)
|
|
}
|
|
out["revision"], out["written"] = rev, st
|
|
if raw, prev, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
|
|
out["last_pass"] = map[string]any{"revision": prev, "result": json.RawMessage(raw)}
|
|
}
|
|
if st.BusNode != "" {
|
|
if raw, found, err := p.Read.Get(ReadHave, st.BusNode+"."+BusModule); err == nil && found {
|
|
if h, err := nodeengineclient.DecodeHave(raw); err == nil {
|
|
out["loaded_now"] = h.UserList
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AssertOne answers the tool assert: one module's buckets, logs and streams as composed from its manifest at the
|
|
// indexer's trunk, beside what the bus holds, and asks the part's process (which alone holds the grant to create) to
|
|
// create what the bus lacks now, even one it created at this manifest and that is gone since (issue 1790), and to raise a
|
|
// held stream's size cap to the manifest's where that is larger (the operator, 2026-11-09). It lowers no limit. Here, in the tools, it creates nothing itself: the answer names the ask.
|
|
func (p *Part) AssertOne(module string) (any, error) {
|
|
if module == "" {
|
|
return nil, errors.New("assert names a module")
|
|
}
|
|
raw, found, err := p.Read.Get(ReadModules, module)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the indexer's %s cannot be read: %w", module, err)
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("the indexer holds no module %s", module)
|
|
}
|
|
v, err := meshcatalogclient.DecodeModule(raw)
|
|
if err != nil || v.Manifest == "" {
|
|
return nil, fmt.Errorf("the indexer names no manifest of %s", module)
|
|
}
|
|
m, err := p.cache.get(p.Read, v.Manifest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
held, heldErr := HeldStreams(p.Call)
|
|
var nodes []string
|
|
if module == buscompose.EngineModule {
|
|
nodes = p.inputs().Nodes // every node's bucket of compositions (ADR 0593 §6)
|
|
}
|
|
a := Assert(module, v.Manifest, m, nodes, held, heldErr)
|
|
out := map[string]any{"assertion": a}
|
|
missing := 0
|
|
for _, it := range a.Items {
|
|
if it.Held == nil {
|
|
missing++
|
|
}
|
|
}
|
|
ask, _ := json.Marshal(Ask{By: "grants_assert", At: p.now(), Assert: module})
|
|
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the ask to create could not be written: %w", err)
|
|
}
|
|
out["asked"] = rev
|
|
if missing == 0 {
|
|
out["says"] = "the bus holds every bucket, log and stream the manifest declares: nothing to create; asked the part's process to raise " +
|
|
"a held stream's size cap to the manifest's where that is larger (never lower), and to add the subjects a manifest adds; any other difference is only said"
|
|
return out, nil
|
|
}
|
|
out["says"] = fmt.Sprintf("%d the bus lacks: the part's process creates them at this ask if %s is placed (its grant names only placed "+
|
|
"modules' streams) and says asserted; grants_assert again shows what the bus holds then", missing, module)
|
|
return out, nil
|
|
}
|
|
|
|
// Answer is a tool's arguments read as text.
|
|
func Answer(args map[string]any, name string) string {
|
|
switch v := args[name].(type) {
|
|
case string:
|
|
return strings.TrimSpace(v)
|
|
case bool:
|
|
return fmt.Sprint(v)
|
|
}
|
|
return ""
|
|
}
|