From a4fcf3de5177db3fe5c1f9bb553cc8e92ac5a526 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:02:44 +0100 Subject: [PATCH] GR12: the seat mesh-grants claims its seat and serves grants-for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The part claims mesh-grants, serving grants-for: a run by its group run- or a source revision by the branch and the new optional source of mesh-catalog's revision (contract regenerated), compared with the branch's newest by the permissions the composed accounts gain and lose with the candidate's entries in. Builds: design 47 G §401, §441, §449, A-205 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../go/seats/meshcatalog/contract.go | 2 + .../seats/mesh-catalog/seat.json | 4 + .../ts/seats/meshcatalog/index.d.ts | 2 + modules/mesh-grants/cmd/mesh-grants/tools.go | 6 + modules/mesh-grants/grants_test.go | 64 +++++- .../mesh-grants/internal/grants/grantsfor.go | 188 ++++++++++++++++++ modules/mesh-grants/internal/grants/tools.go | 4 +- modules/mesh-grants/module.json | 9 + 8 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/grantsfor.go diff --git a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go index 504752fc..5919ecb2 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshcatalog/contract.go @@ -1281,6 +1281,8 @@ type RevisionInput struct { Heads string `json:"heads,omitempty"` // a kept revision; absent: the newest of trunk, or of the candidate named Revision int64 `json:"revision,omitempty"` + // a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441) + Source int64 `json:"source,omitempty"` } // RevisionAnswer is what revision answers. diff --git a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json index 0233171e..1c776e5d 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-catalog/seat.json @@ -266,6 +266,10 @@ "type": "string", "description": "a candidate's heads" }, + "source": { + "type": "integer", + "description": "a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441)" + }, "after": { "type": "string", "description": "the last module of the page before; absent: the first page" diff --git a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts index dfe29ba2..9d60bb13 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshcatalog/index.d.ts @@ -731,6 +731,8 @@ export interface RevisionInput { "heads"?: string; /** a kept revision; absent: the newest of trunk, or of the candidate named */ "revision"?: number; + /** a candidate's source revision: the revision of the branch's series taken in from it, with its entries (design 47 G section 441) */ + "source"?: number; } /** is what revision answers. */ diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 93153dfc..0ad31793 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" @@ -269,6 +270,11 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.WhyRefused(grants.Answer(a, "account"), grants.Answer(a, "verb"), grants.Answer(a, "node")) }}, + {Name: meshgrants.Seat + "." + meshgrants.VerbGrantsFor, + Description: "The grants a candidate would compose, per module (design 47 G §441): the grants it would add and remove against the branch's newest.", + Run: func(a map[string]any) (any, error) { + return p.GrantsFor(meshgrants.GrantsForInput{Branch: grants.Answer(a, "branch"), Run: grants.Answer(a, "run"), Source: grants.Answer(a, "source")}) + }}, {Name: grants.ToolRelease, Description: "Release one held granting wait by a person's act with why, recorded as a hand act " + "(the seat mesh-hand-acts) before it is released (design 47 G §276). grants_waits lists them.", Input: schema(map[string]string{"module": "the held entry's module", "why": "why, kept in the hand act"}, "module", "why"), diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 8f722f46..20ac0160 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -29,6 +29,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" @@ -91,8 +92,11 @@ type fakeBus struct { maxValue int // entries are the catalog half's entries by fingerprint, the manifest each holds as the indexer's manifests state has it // (entry); entriesAsked says every one is asked, not answered: the half cannot be read yet. - entries map[string]string - entriesAsked bool + entries map[string]string + entriesAsked bool + // revision is the catalog half of mesh-catalog's `revision`: the branch's `newest` and a `candidate` (a source or a group), each + // module's manifest. + revision map[string]map[string]string catalogWaited []string } @@ -363,6 +367,21 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) { } } return json.Marshal(map[string]any{"users": out}) + case grants.CallRevision: + var in meshcatalog.RevisionInput + raw, _ := json.Marshal(body) + _ = json.Unmarshal(raw, &in) + which := "newest" + if in.Group != "" || in.Source != 0 { + which = "candidate" + } + var out meshcatalog.RevisionAnswer + for module, manifest := range b.revision[which] { + var m map[string]any + _ = json.Unmarshal([]byte(manifest), &m) + out.Entries = append(out.Entries, meshcatalog.CatalogEntry{Module: module, Manifest: m}) + } + return json.Marshal(out) case grants.CallStreams: if stream, _ := args["stream"].(string); stream != "" { return json.Marshal(map[string]any{"detail": []map[string]any{{"name": stream, "consumer_detail": b.consumers[stream]}}}) @@ -602,10 +621,9 @@ func TestGrants(t *testing.T) { if err != nil { t.Fatalf("mesh-sdk cannot decode the manifest: %v", err) } - // 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). - if m.Module != "mesh-grants" || len(m.Claims) != 0 { - t.Errorf("module %q claims %v: the part claims no seat", m.Module, m.Claims) + // The part claims the seat mesh-grants, which serves `grants-for` (design 47 G §401, §441). + if m.Module != "mesh-grants" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-grants" || !slices.Equal(m.Claims[0].Serves, []string{"grants-for"}) { + t.Errorf("module %q claims %v: the part claims the seat mesh-grants alone, serving grants-for", m.Module, m.Claims) } for _, row := range []struct { what string @@ -5095,3 +5113,37 @@ func TestAnOldStateSectionDeclaresItsCap(t *testing.T) { } } } + +// grants-for answers what the composed accounts gain and lose with a candidate's entries in place of the branch's newest (design +// 47 G §441, A-205): a tool the candidate adds is an added grant naming its verb, and an input that names no source nor run is +// refused. +func TestGrantsFor(t *testing.T) { + b := fixture() + candidateX := strings.Replace(strings.Replace(xManifest, `,"logs":[{"name":"history","max-age-days":30}]`, "", 1), `"state"`, `"emits":["hello"],"state"`, 1) + b.revision = map[string]map[string]string{"newest": {"mod-x": xManifest}, "candidate": {"mod-x": candidateX}} + p := partOn(b) + got, err := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main", Run: "r1"}) + if err != nil || got.Error != "" || len(got.Modules) != 1 || got.Modules[0].Module != "mod-x" { + t.Fatalf("grants-for: %+v, %v", got, err) + } + var verbs []string + for _, g := range got.Modules[0].Added { + verbs = append(verbs, g.Verb+" "+g.Subject) + } + if !slices.ContainsFunc(got.Modules[0].Added, func(g meshgrants.Grant) bool { + return g.Verb == "hello" && strings.HasPrefix(g.Subject, "mesh.mod.mod-x.") + }) { + t.Errorf("the event the candidate adds is not an added grant: %v / %+v", verbs, got.Modules[0]) + } + if len(got.Modules[0].Removed) == 0 { + t.Error("the log the candidate drops is no removed grant") + } + b.revision["candidate"] = b.revision["newest"] + same, _ := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main", Source: "7"}) + if len(same.Modules) != 0 { + t.Errorf("a candidate equal to the newest changes grants: %+v", same.Modules) + } + if _, err := p.GrantsFor(meshgrants.GrantsForInput{Branch: "main"}); err == nil { + t.Error("grants-for with neither a source nor a run answered") + } +} diff --git a/modules/mesh-grants/internal/grants/grantsfor.go b/modules/mesh-grants/internal/grants/grantsfor.go new file mode 100644 index 00000000..81985a6c --- /dev/null +++ b/modules/mesh-grants/internal/grants/grantsfor.go @@ -0,0 +1,188 @@ +package grants + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// **The seat mesh-grants' read `grants-for`** (design 47 G §441, §401, A-205): the grants a candidate would compose, per +// module, added and removed against the branch's newest. The candidate's catalog half is taken by mesh-catalog's `revision` (a +// run by its group `run-`, a source revision by the branch and `source`), the branch's newest by `revision` with the branch +// alone; the answer is what the composed memberships' permissions gain and lose with the candidate's entries swapped in for the +// branch's newest. + +// RunGroup is the group of a run's candidate in mesh-catalog's `revision` (A-205). +const RunGroup = "run-" + +// CallRevision is the seat mesh-catalog's `revision`, as the part calls it. +const CallRevision = "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbRevision + +// revisionEntries reads a catalog graph revision's entries, page after page, as manifests by module. none says the branch has +// no revision yet. +func (p *Part) revisionEntries(in meshcatalog.RevisionInput) (manifests map[string]catalogue.Manifest, none bool, err error) { + manifests = map[string]catalogue.Manifest{} + for { + raw, err := p.Call.Call(CallRevision, in) + if err != nil { + return nil, false, fmt.Errorf("%s does not answer: %w", CallRevision, err) + } + var a meshcatalog.RevisionAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, false, fmt.Errorf("%s answered what is not a revision: %w", CallRevision, err) + } + if a.NoneYet { + return nil, true, nil + } + for _, e := range a.Entries { + body, err := json.Marshal(e.Manifest) + if err != nil || len(e.Manifest) == 0 { + continue + } + m, err := catalogue.Decode(body) + if err != nil { + return nil, false, fmt.Errorf("the manifest of %s in the revision does not decode: %w", e.Module, err) + } + manifests[e.Module] = m + } + if a.Next == "" || a.Next == in.After { + return manifests, false, nil + } + in.After = a.Next + } +} + +// grantsOfEntries is, by module, the subjects the composed accounts of that module may publish or subscribe, composed from in with +// the entries' manifests in place of the trunk's and of what runs. +func grantsOfEntries(in Inputs, entries map[string]catalogue.Manifest) (map[string]map[string]bool, error) { + in.Manifests = maps.Clone(in.Manifests) + in.Running = maps.Clone(in.Running) + for module, m := range entries { + in.Manifests[module] = m + for key := range in.Running { + if strings.HasSuffix(key, "."+module) { + delete(in.Running, key) + } + } + } + recs, _ := ListRecords(in) + users, refused, err := ComposeEach(recs) + if err != nil { + return nil, err + } + if len(refused) > 0 { + user := sortedKeys(refused)[0] + return nil, fmt.Errorf("the grants of %s do not compose: %s", user, refused[user]) + } + out := map[string]map[string]bool{} + for _, u := range users { + _, module, ok := strings.Cut(u.User, ".") + if _, named := entries[module]; !ok || !named { + continue + } + if out[module] == nil { + out[module] = map[string]bool{} + } + for _, s := range append(slices.Clone(u.Publish), u.Subscribe...) { + out[module][s] = true + } + } + return out, nil +} + +// verbOf names the verb, event or tool a subject is for: `mesh.seat...…` and `mesh.mod...…`. +func verbOf(subject string) string { + parts := strings.Split(subject, ".") + if len(parts) >= 5 && parts[0] == "mesh" && (parts[1] == "seat" || parts[1] == "mod") { + return parts[4] + } + return "" +} + +func grantList(subjects []string) []meshgrants.Grant { + out := []meshgrants.Grant{} + for _, s := range subjects { + out = append(out, meshgrants.Grant{Subject: s, Verb: verbOf(s)}) + } + return out +} + +// GrantsFor answers the verb grants-for. +func (p *Part) GrantsFor(in meshgrants.GrantsForInput) (meshgrants.GrantsFor, error) { + if in.Branch == "" { + return meshgrants.GrantsFor{}, errors.New("grants-for names the candidate's watched branch") + } + candidate := meshcatalog.RevisionInput{Branch: in.Branch} + switch { + case in.Run != "": + candidate.Group = RunGroup + in.Run + case in.Source != "": + n, err := strconv.ParseInt(in.Source, 10, 64) + if err != nil { + return meshgrants.GrantsFor{}, fmt.Errorf("the source revision %q is not a number", in.Source) + } + candidate.Source = n + default: + return meshgrants.GrantsFor{}, errors.New("grants-for names a source revision or a run") + } + cand, none, err := p.revisionEntries(candidate) + if err != nil || none { + return meshgrants.GrantsFor{Error: orWhy(err, "the candidate's catalog half is not held: the branch has no revision of it")}, nil + } + newest, none, err := p.revisionEntries(meshcatalog.RevisionInput{Branch: in.Branch}) + if err != nil || none { + return meshgrants.GrantsFor{Error: orWhy(err, "the branch's newest catalog half is not held")}, nil + } + base := map[string]catalogue.Manifest{} + for module := range cand { + if m, ok := newest[module]; ok { + base[module] = m + } + } + input := p.inputs() + was, err := grantsOfEntries(input, base) + if err != nil { + return meshgrants.GrantsFor{Error: "the branch's newest does not compose: " + err.Error()}, nil + } + will, err := grantsOfEntries(input, cand) + if err != nil { + return meshgrants.GrantsFor{Error: "the candidate does not compose: " + err.Error()}, nil + } + var out meshgrants.GrantsFor + for _, module := range sortedKeys(will) { + var added, removed []string + for s := range will[module] { + if !was[module][s] { + added = append(added, s) + } + } + for s := range was[module] { + if !will[module][s] { + removed = append(removed, s) + } + } + if len(added)+len(removed) == 0 { + continue + } + sort.Strings(added) + sort.Strings(removed) + out.Modules = append(out.Modules, meshgrants.GrantsForModulesItem{Module: module, Added: grantList(added), Removed: grantList(removed)}) + } + return out, nil +} + +func orWhy(err error, why string) string { + if err != nil { + return err.Error() + } + return why +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 0f2c3fe4..49fd88e0 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -14,8 +14,8 @@ import ( "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). +// The part's tools, as its manifest lists them. The part claims the seat mesh-grants, which serves `grants-for` (design 47 G +// §401, §441); its own tools are named below. const ( ToolGrants = "grants_grants" ToolMembership = "grants_membership" diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 4e112870..a5b98926 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -2,6 +2,15 @@ "module": "mesh-grants", "version": "0.1", "slug": "grants", + "claims": [ + { + "name": "mesh-grants", + "scope": "mesh", + "serves": [ + "grants-for" + ] + } + ], "reads": [ "mesh-config.resolved", "node-engine.have",