GR2: grants_callers and grants_why_refused

The first reads the callers each verb and accepted kind of the seat definitions
admits, the second says which call subject an account lacks (the bus's own
nats_user_can) beside the definition's callers.

Builds: design 47 G §83, §391, §395

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 05:58:25 +01:00
parent f8bae4bee8
commit 6182730923
6 changed files with 215 additions and 2 deletions
@@ -257,6 +257,18 @@ func toolsOf(p *grants.Part) []stdio.Tool {
"composed yet by this version of the part, with its step, the acting part, its version, since when, its bound (10 minutes) and " +
"clock-from (the start of this version's first pass), and which have passed their bound. Read-only.",
Run: func(map[string]any) (any, error) { return p.WaitsOf() }},
{Name: grants.ToolCallers, Description: "The callers each verb and each accepted kind of the seat definitions admits (design 47 G §83): " +
"the list the definition names, or the default of the verb's class. Name a seat, or a seat and a verb, or neither for every one. Read-only.",
Input: schema(map[string]string{"seat": "a seat's name (default: every seat)", "verb": "a verb or accepted kind (default: every one)"}),
Run: func(a map[string]any) (any, error) {
return p.Callers(grants.Answer(a, "seat"), grants.Answer(a, "verb"))
}},
{Name: grants.ToolWhyRefused, Description: "Why an account's call of a seat's verb is refused (design 47 G §83): the call subject the account " +
"lacks a grant for (the bus's own answer, nats_user_can) and the definition's callers that leave it out. Read-only.",
Input: schema(map[string]string{"account": "the bus user, e.g. g14.node-tools", "verb": "the verb as <seat>.<verb>", "node": "the node, for a node seat"}, "account", "verb"),
Run: func(a map[string]any) (any, error) {
return p.WhyRefused(grants.Answer(a, "account"), grants.Answer(a, "verb"), grants.Answer(a, "node"))
}},
{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"),
@@ -115,3 +115,6 @@ func callerNamed(user string, pub, sub []string) ([]string, []string) {
}
return unique(pub), unique(sub)
}
// SeatToolSubject is where a seat's verb is served: `mesh.seat.<seat>.tool.<verb>`, with the node last on a node seat.
func SeatToolSubject(s Seat, verb, node string) string { return seatToolSubject(s, verb, node) }
@@ -0,0 +1,139 @@
package grants
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"mesh-grants/internal/buscompose"
)
// The tools that say who may call and why a call is refused (design 47 G §83, GR2).
const (
ToolCallers = "grants_callers"
ToolWhyRefused = "grants_why_refused"
)
// CallerLine is one verb's, or one accepted kind's, callers as a seat definition gives them.
type CallerLine struct {
Seat string `json:"seat"`
// Verb is the verb's name, or the accepted kind's.
Verb string `json:"verb"`
// Kind is `verb` or `accepts`.
Kind string `json:"kind"`
Class string `json:"class,omitempty"`
// Callers are the entries the verb admits: the list the definition names, or its class's default.
Callers []string `json:"callers"`
// Named says the definition names the list; false is the class's default (§391).
Named bool `json:"named"`
}
// definitions are the seat definitions the part composes from, or why it has none.
func (p *Part) definitions() (map[string]seatdef.Definition, error) {
d, ok := p.SeatList.(SeatDefinitions)
if !ok {
return nil, errors.New("the seat list is not read here: the definitions' callers are unknown")
}
defs := d.Definitions()
if len(defs) == 0 {
return nil, errors.New("the seat list holds no definitions yet")
}
return defs, nil
}
// CallerLines are the callers each verb and each accepted kind admits, from the definitions, of the seat named (every seat
// when empty) and the verb named (every verb when empty).
func CallerLines(defs map[string]seatdef.Definition, seat, verb string) []CallerLine {
var out []CallerLine
for _, d := range defs {
if seat != "" && d.Seat != seat {
continue
}
for _, v := range d.Verbs {
if verb == "" || v.Name == verb {
out = append(out, CallerLine{Seat: d.Seat, Verb: v.Name, Kind: "verb", Class: v.Class,
Callers: v.EffectiveCallers(), Named: v.Callers != nil && !v.Callers.IsText()})
}
}
for _, a := range d.Accepts {
if verb == "" || a.Name == verb {
l := CallerLine{Seat: d.Seat, Verb: a.Name, Kind: "accepts"}
if a.Callers != nil && !a.Callers.IsText() {
l.Callers, l.Named = a.Callers.List, true
}
out = append(out, l)
}
}
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
return a.Seat+"\x00"+a.Kind+"\x00"+a.Verb < b.Seat+"\x00"+b.Kind+"\x00"+b.Verb
})
return out
}
// Callers answers the tool grants_callers.
func (p *Part) Callers(seat, verb string) (any, error) {
defs, err := p.definitions()
if err != nil {
return nil, err
}
lines := CallerLines(defs, seat, verb)
if len(lines) == 0 {
return nil, fmt.Errorf("no seat definition names a verb or an accepted kind matching seat %q verb %q", seat, verb)
}
return map[string]any{"callers": lines, "count": len(lines)}, nil
}
// WhyRefused answers the tool grants_why_refused: the grant an account lacks to call address (`<seat>.<verb>`), and the
// definition's line that leaves it out. The bus answers whether the account may publish the verb's call subject
// (nats.nats_user_can, the account's grants as the server holds them); the definition's callers say who was meant to.
func (p *Part) WhyRefused(account, address, node string) (any, error) {
seat, verb, ok := strings.Cut(address, ".")
if account == "" || !ok || seat == "" || verb == "" {
return nil, errors.New("why_refused names an account and a verb as <seat>.<verb>")
}
defs, err := p.definitions()
if err != nil {
return nil, err
}
lines := CallerLines(defs, seat, verb)
if len(lines) == 0 {
return nil, fmt.Errorf("no seat definition names the verb %s", address)
}
def := defs[seat]
s := buscompose.Seat{Name: seat, Scope: def.Scope}
subject := buscompose.CalledSubject(buscompose.SeatToolSubject(s, verb, node), account)
if subject == "" {
return nil, fmt.Errorf("%s is not the name of a bus user", account)
}
out := map[string]any{"account": account, "verb": address, "subject": subject, "definition": lines}
raw, err := p.Call.Call(CallUserCan, map[string]any{"user": account, "action": "publish", "subject": subject})
if err != nil {
out["bus"] = "not read: " + err.Error()
return out, nil
}
var can struct {
Allowed *bool `json:"allowed"`
}
_ = json.Unmarshal(raw, &can)
out["bus"] = json.RawMessage(raw)
switch {
case can.Allowed == nil:
out["says"] = "the bus's answer names no verdict; read it beside the definition's line"
case *can.Allowed:
out["says"] = "not refused at the bus: the account holds the grant to publish " + subject
default:
callers := lines[0].Callers
out["says"] = fmt.Sprintf("the account lacks the grant to publish %s; the definition admits %s (%s), and the grants part grants a call only to a caller one of those entries names",
subject, strings.Join(callers, ", "), map[bool]string{true: "named by the definition", false: "the class's default"}[lines[0].Named])
out["lacks"] = subject
out["admitted_by"] = slices.Clone(callers)
}
return out, nil
}
@@ -0,0 +1,57 @@
package grants
import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
type defsGate struct{ defs map[string]seatdef.Definition }
func (defsGate) MayAct(...string) bool { return true }
func (g defsGate) Definitions() map[string]seatdef.Definition { return g.defs }
type canCall struct {
allowed bool
asked map[string]any
}
func (c *canCall) Call(key string, body any) (json.RawMessage, error) {
c.asked = body.(map[string]any)
return json.Marshal(map[string]any{"allowed": c.allowed})
}
// grants_callers and grants_why_refused read the definitions' callers (design 47 G §83): a verb that names none takes its
// class's default, and a refused account is told the call subject it lacks and the callers the definition admits.
func TestCallersAndWhyRefused(t *testing.T) {
defs := map[string]seatdef.Definition{"mesh-wants": {Seat: "mesh-wants", Scope: "mesh", Verbs: []seatdef.Verb{
{Name: "upgrade", Class: seatdef.ClassAct},
{Name: "entry", Callers: &seatdef.Callers{List: []string{"seat:mesh-pipeline-runner", "operator"}}},
}}}
lines := CallerLines(defs, "mesh-wants", "")
if len(lines) != 2 || lines[0].Verb != "entry" || !lines[0].Named || lines[1].Named ||
strings.Join(lines[1].Callers, ",") != "operator,sessions" {
t.Fatalf("lines: %+v", lines)
}
can := &canCall{}
p := &Part{SeatList: defsGate{defs}, Call: can}
got, err := p.WhyRefused("one.shop", "mesh-wants.upgrade", "")
if err != nil {
t.Fatal(err)
}
out := got.(map[string]any)
if out["lacks"] != "mesh.seat.mesh-wants.call.upgrade.one~shop" || can.asked["subject"] != out["lacks"] ||
!strings.Contains(out["says"].(string), "operator, sessions") {
t.Errorf("a refused caller: %+v", out)
}
can.allowed = true
got, _ = p.WhyRefused("one.shop", "mesh-wants.upgrade", "")
if _, lacks := got.(map[string]any)["lacks"]; lacks {
t.Errorf("an allowed caller lacks a grant: %+v", got)
}
if _, err := p.WhyRefused("one.shop", "mesh-wants.nothing", ""); err == nil {
t.Error("a verb no definition names was explained")
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ const (
// Tools is every tool, in the manifest's order.
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch,
ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain}
ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain, ToolCallers, ToolWhyRefused}
// 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).
+3 -1
View File
@@ -73,7 +73,9 @@
"grants_waits",
"grants_release",
"grants_seat_list",
"grants_seat_list_again"
"grants_seat_list_again",
"grants_callers",
"grants_why_refused"
],
"state": [
{