fix9-serves-2: the grants part reads a process's serves beside its bus; a bus-less process's serves are the module's own account's

Builds: design 47 G A-115, A-127

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 01:19:38 +01:00
parent 8fc1997b1b
commit ce8ab8b4aa
7 changed files with 81 additions and 12 deletions
@@ -1399,6 +1399,7 @@ func chunkSubjectsFollowTheAnswerer(t *testing.T) {
Processes: []ProcessPrincipal{{Process: "keeper", Keeps: true, Serves: []string{"seat:mesh-broker.list", "seat:mesh-broker.snapshot-get"}}}},
{Module: "recovery", Invokes: []string{"seat:mesh-broker.snapshot-get"}, Processes: []ProcessPrincipal{{Process: "recover", Invokes: []string{"seat:mesh-broker.snapshot-get"}}}},
{Module: "admin-ish", Invokes: []string{"*"}},
{Module: "plain-serve", ServesOwn: []string{"seat:mesh-broker.snapshot-get"}},
{Module: "mod-x", Streams: []ModuleStream{{Module: "mod-x", Name: "SECRETS", Events: []string{"sealed"}, Restricted: true},
{Module: "mod-x", Name: "PLAIN", Events: []string{"plain"}}}},
}}}
@@ -1422,6 +1423,11 @@ func chunkSubjectsFollowTheAnswerer(t *testing.T) {
!has(k.Subscribe, "mesh.seat.mesh-broker.tool.snapshot-get") || !has(k.Subscribe, "mesh.seat.mesh-broker.tool.list") {
t.Errorf("the keeper process, serving the verbs, may not answer them and send chunks: %v / %v", k.Publish, k.Subscribe)
}
// A process declaring no bus runs on its module's own account, which answers its serves (design 47 G A-115, A-127).
if m := perms["a.plain-serve"]; !has(m.Subscribe, "mesh.seat.mesh-broker.tool.snapshot-get") || !has(m.Subscribe, "mesh.seat.mesh-broker.call.snapshot-get.*") ||
!has(m.Publish, chunks) || !has(m.Subscribe, servedAcks) {
t.Errorf("the module account of a bus-less serving process may not answer the verb and send chunks: %v / %v", m.Publish, m.Subscribe)
}
if rec := perms["a.recovery"]; !has(rec.Subscribe, chunks) || !has(rec.Publish, acks) || has(rec.Publish, chunks) {
t.Errorf("the account naming the verb may not hear chunks and send acks, or may send chunks: %v / %v", rec.Publish, rec.Subscribe)
}
@@ -50,7 +50,7 @@ func chunkGrants(p Principal) (pub, sub []string) {
return nil, nil
}
for _, c := range p.Chunked {
if p.Kind == KindProcess && slices.Contains(p.AnswersSeat, "seat:"+c.Seat+"."+c.Verb) {
if (p.Kind == KindProcess || p.Kind == KindModule) && slices.Contains(p.AnswersSeat, "seat:"+c.Seat+"."+c.Verb) {
pub, sub = append(pub, chunkSubject(c)), append(sub, ackServed(c))
}
if slices.Contains(p.Invokes, "seat:"+c.Seat+"."+c.Verb) {
@@ -192,7 +192,7 @@ type Principal struct {
// What they have is permission to ask. A module that invokes gains exactly the same
// permission and nothing beside it.
Invokes []string
// AnswersSeat are the seat verbs a process answers, each seat:<seat>.<verb> (design 47 G A-109).
// AnswersSeat are the seat verbs a process answers, each seat:<seat>.<verb> (design 47 G A-109), of a process with a bus on its own account, else of the module's own (A-115).
AnswersSeat []string
// AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software
@@ -649,6 +649,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
// The chunk subjects of a verb that sends a file (design 47 G §681, A-60), to the principal that answers it and to an account
// naming it in `invokes`.
pub, sub := chunkGrants(p)
// A module's own account answers the verbs of its processes that declare no bus (design 47 G A-115, A-127).
if p.Kind == KindModule && len(p.AnswersSeat) > 0 {
answered, err := answeredSubjects(p.AnswersSeat)
if err != nil {
return Permissions{}, err
}
sub = append(sub, answered...)
}
perms.Publish, perms.Subscribe = append(perms.Publish, pub...), append(perms.Subscribe, sub...)
return perms, nil
}
@@ -10,9 +10,23 @@ import (
// **Never the module's own account's grants, never the tool runner's.** A process's authority is read
// straight from its bus declaration — its emits, its queue's consumer, its state, whether it reads the
// records of its module's own asks, and the tools and seat verbs it calls — and nothing else: it holds no
// seat, serves no tool and hears no module stream; it answers a seat's verb only where its bus declares `serves` (design 47 G A-109). Modelled on
// seat, serves no tool and hears no module stream; it answers a seat's verb only where its resource declares `serves` beside its bus (design 47 G A-109). Modelled on
// provisionerGrants, the other kind of process account the mesh mints (ADR 0349 clauses 6 and 11).
// answeredSubjects are the subscribes of the seat verbs an account answers, each seat:<seat>.<verb>: the call and tool subjects
// of each (design 47 G A-107, A-109, A-115).
func answeredSubjects(verbs []string) ([]string, error) {
var sub []string
for _, v := range verbs {
seat, verb, ok := strings.Cut(strings.TrimPrefix(v, "seat:"), ".")
if !strings.HasPrefix(v, "seat:") || !ok || seat == "" || verb == "" {
return nil, fmt.Errorf("%q does not name a seat's verb: one serves seat:<seat>.<verb>", v)
}
sub = append(sub, "mesh.seat."+seat+".call."+verb+".*", "mesh.seat."+seat+".tool."+verb)
}
return sub, nil
}
// processGrants is one process's whole authority: to publish, and to subscribe.
func processGrants(p Principal) (pub, sub []string, err error) {
// 1. Its own events, under its module's own namespace — the same subjects the module's own principal
@@ -59,13 +73,11 @@ func processGrants(p Principal) (pub, sub []string, err error) {
// 5b. The seat verbs it answers (design 47 G A-107, A-109): its subscribe of each verb's call and tool subjects (and
// the chunk subjects, chunks.go).
for _, v := range p.AnswersSeat {
seat, verb, ok := strings.Cut(strings.TrimPrefix(v, "seat:"), ".")
if !strings.HasPrefix(v, "seat:") || !ok || seat == "" || verb == "" {
return nil, nil, fmt.Errorf("%q does not name a seat's verb: one serves seat:<seat>.<verb>", v)
}
sub = append(sub, "mesh.seat."+seat+".call."+verb+".*", "mesh.seat."+seat+".tool."+verb)
answered, err := answeredSubjects(p.AnswersSeat)
if err != nil {
return nil, nil, err
}
sub = append(sub, answered...)
// 6. The bus module's process (busstate.go, ADR 0595 §4, §5): every module's bus state it keeps, by name; its own
// consumer of what its module consumes; its module's own stream's events; and its conditions through the seats its
@@ -110,6 +110,9 @@ type Declared struct {
// composed whether or not the module itself has an account (NoAccount), because a process account is
// delivered in its own secret, broker-<process id>, never the module's `broker`.
Processes []ProcessPrincipal
// ServesOwn are the seat verbs of this module's processes that declare no bus, each seat:<seat>.<verb>: such a process
// runs on the module's own account, which is granted their subscribes (design 47 G A-115, A-127).
ServesOwn []string
// Ledgers are the local names of the ledgers it asks for in `requires` (catalogue.StorageLedger); each assignment of a
// module with any gets a ledger principal of its own (issue 1966 D5, ADR 0597 §9).
Ledgers []string
@@ -326,7 +329,7 @@ func Users(r Records) ([]Principal, error) {
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
PerMachine: perMachineNames(d.State), ReadOnly: readOnlyNames(d.State), KeyedReads: d.KeyedReads,
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
Objects: objectNames(d.Objects), ReadsObjects: d.ReadsObjects, Bound: d.Bindings,
Objects: objectNames(d.Objects), ReadsObjects: d.ReadsObjects, Bound: d.Bindings, AnswersSeat: d.ServesOwn,
})
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
+17 -2
View File
@@ -146,7 +146,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
d := buscompose.Declared{
Module: m.Module, ToolRunner: catalogue.IsToolRunner(m), Emits: m.EmitsAll(), Consumes: fromModules, Watches: watches, Serves: m.Tools,
Invokes: m.Invokes, Waits: waitsOf(m, seats), State: bucketsOf(m), Reads: m.Reads, Logs: logsOf(m),
Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), Checks: catalogue.HealthChecks(m),
Streams: streamsOf(m), Queues: queueOwnersOf(m), Processes: processesOf(m), ServesOwn: ownServesOf(m), Checks: catalogue.HealthChecks(m),
RunsAs: m.RunsAs, Issued: issuedTools(m),
// The keys its served values fill, which every node may read where it runs (issue 1126).
Served: catalogue.ServedSettings(m),
@@ -435,6 +435,21 @@ func queueOwnersOf(m catalogue.Manifest) []buscompose.QueueOwner {
return out
}
// ownServesOf is the seat verbs of the module's processes that declare no bus, which run on the module's own account (design
// 47 G A-115, A-127); a process with a bus carries its own serves (processesOf).
func ownServesOf(m catalogue.Manifest) []string {
buses := m.ProcessBuses()
var out []string
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
if _, bus := buses[id]; bus {
continue
}
out = append(out, m.ProcessServes()[id]...)
}
return out
}
func processesOf(m catalogue.Manifest) []buscompose.ProcessPrincipal {
accounts := m.BusAccounts()
if len(accounts) == 0 {
@@ -446,7 +461,7 @@ func processesOf(m catalogue.Manifest) []buscompose.ProcessPrincipal {
// what it may remove from the bus (ADR 0564 Limits 4, issue 1952 S5).
for _, a := range accounts {
pr := buscompose.ProcessPrincipal{Process: a.ID, Emits: a.Bus.Emits, State: a.Bus.State, Asks: a.Bus.Asks,
Invokes: a.Bus.Invokes, Serves: a.Bus.Serves, Removes: a.Bus.Removes, Tools: a.Tools, Keeps: a.Bus.Keeps}
Invokes: a.Bus.Invokes, Serves: a.Serves, Removes: a.Bus.Removes, Tools: a.Tools, Keeps: a.Bus.Keeps}
for _, q := range queues {
if q.Process == a.ID {
pr.Queues = append(pr.Queues, q)
@@ -0,0 +1,25 @@
package grants
import (
"slices"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Design 47 G A-115, A-127: a process's `serves`, beside its bus, is its account's (a process with a bus) or the module's own
// (a process with none); a `serves` inside the bus is no grant.
func TestAProcessesServesAreReadBesideItsBus(t *testing.T) {
m := catalogue.Manifest{Module: "nats", Resources: []map[string]any{
{"id": "keeper", "type": "process", "serves": []any{"seat:mesh-broker.list"}, "bus": map[string]any{"keeps": true}},
{"id": "plain", "type": "process", "serves": []any{"seat:mesh-broker.snapshot-get"}},
{"id": "inside", "type": "process", "bus": map[string]any{"serves": []any{"seat:mesh-broker.remove"}}},
}}
procs := processesOf(m)
if len(procs) != 1 || procs[0].Process != "keeper" || !slices.Equal(procs[0].Serves, []string{"seat:mesh-broker.list"}) {
t.Errorf("the process principals = %+v; want the keeper serving seat:mesh-broker.list", procs)
}
if got := ownServesOf(m); !slices.Equal(got, []string{"seat:mesh-broker.snapshot-get"}) {
t.Errorf("the module's own serves = %v; want the bus-less process's alone", got)
}
}