From ce8ab8b4aaf3006140b8548b8537b76e1cb5bca1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:19:38 +0100 Subject: [PATCH] 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 --- .../internal/buscompose/buscompose_test.go | 6 +++++ .../mesh-grants/internal/buscompose/chunks.go | 2 +- .../mesh-grants/internal/buscompose/nats.go | 10 ++++++- .../internal/buscompose/process_grants.go | 26 ++++++++++++++----- .../mesh-grants/internal/buscompose/users.go | 5 +++- .../mesh-grants/internal/grants/records.go | 19 ++++++++++++-- .../internal/grants/serves_test.go | 25 ++++++++++++++++++ 7 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/serves_test.go diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 5678a1e1..880985ac 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -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) } diff --git a/modules/mesh-grants/internal/buscompose/chunks.go b/modules/mesh-grants/internal/buscompose/chunks.go index c1b1fd48..b7bb2ae9 100644 --- a/modules/mesh-grants/internal/buscompose/chunks.go +++ b/modules/mesh-grants/internal/buscompose/chunks.go @@ -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) { diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 926aaff8..febf1457 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -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:. (design 47 G A-109). + // AnswersSeat are the seat verbs a process answers, each seat:. (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 } diff --git a/modules/mesh-grants/internal/buscompose/process_grants.go b/modules/mesh-grants/internal/buscompose/process_grants.go index e4e14c40..97f1ae41 100644 --- a/modules/mesh-grants/internal/buscompose/process_grants.go +++ b/modules/mesh-grants/internal/buscompose/process_grants.go @@ -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:.: 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:.", 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:.", 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 diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 2f9c62a5..5bbff318 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -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-, never the module's `broker`. Processes []ProcessPrincipal + // ServesOwn are the seat verbs of this module's processes that declare no bus, each seat:.: 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. diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index e195923f..6ac6d5d4 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -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) diff --git a/modules/mesh-grants/internal/grants/serves_test.go b/modules/mesh-grants/internal/grants/serves_test.go new file mode 100644 index 00000000..fc31ffa2 --- /dev/null +++ b/modules/mesh-grants/internal/grants/serves_test.go @@ -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) + } +}