GR12: the seat mesh-grants claims its seat and serves grants-for

The part claims mesh-grants, serving grants-for: a run by its group run-<id> 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
This commit is contained in:
2026-11-12 06:02:44 +01:00
parent 41ecd473da
commit a4fcf3de51
8 changed files with 271 additions and 8 deletions
@@ -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.
@@ -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"
@@ -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. */
@@ -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"),
+58 -6
View File
@@ -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")
}
}
@@ -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-<id>`, 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.<seat>.<kind>.<name>…` and `mesh.mod.<module>.<kind>.<name>…`.
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
}
+2 -2
View File
@@ -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"
+9
View File
@@ -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",