grants-serve: a process's serves compose its call and tool subscribes and, for a chunked verb, its chunk publish and ack subscribe

Replaces A-60's grant of chunk subjects to the bundle's principal.

Builds: design 47 G A-107, A-109, A-110
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 00:00:23 +01:00
parent ee30209040
commit 5bed0cd831
6 changed files with 42 additions and 28 deletions
@@ -59,7 +59,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Run("every node's compositions are read by their named readers alone and written by their node", everyNodesCompositions)
t.Run("a module's ledger is written by its ledger account alone", aLedgerIsWrittenByItsLedgerAccountAlone)
t.Run("the bus module's process alone keeps every module's bus state and serves the broker's verbs", theBusModulesProcessKeepsBusState)
t.Run("chunk subjects go to the principal that answers the verb, and the reverse to an account naming it", chunkSubjectsFollowTheAnswerer)
t.Run("a process serving a verb is granted its calls and chunk subjects, and the reverse goes to an account naming it", chunkSubjectsFollowTheAnswerer)
// ADR 0567 §3, §9: a node reads every key of the seat's assignments and its candidates (it composes with the whole
// proposal laid over), and none of its open proposals; it writes neither.
reads := strings.Join(NodeComposeReads("laptop"), " ")
@@ -1392,11 +1392,11 @@ func chunkSubjectsFollowTheAnswerer(t *testing.T) {
if len(chunked) != 1 || chunked[0] != (ChunkVerb{Seat: meshbroker.Seat, Verb: "snapshot-get"}) {
t.Fatalf("the seat definitions' chunked verbs = %v, want the broker's snapshot-get alone", chunked)
}
chunks, acks := "mesh.seat.mesh-broker.chunk.snapshot-get.>", "mesh.seat.mesh-broker.ack.snapshot-get.>"
chunks, acks, servedAcks := "mesh.seat.mesh-broker.chunk.snapshot-get.>", "mesh.seat.mesh-broker.ack.snapshot-get.>", "mesh.seat.mesh-broker.ack.snapshot-get.*.*"
r := Records{Nodes: []string{"a"}, Chunked: chunked, Assigned: map[string][]Declared{"a": {
{Module: "node-tools", ToolRunner: true},
{Module: "nats", SnapshotsTheBus: true, Holds: []Seat{{Name: meshbroker.Seat, Serves: []string{"list", "snapshot-get"}}},
Processes: []ProcessPrincipal{{Process: "keeper", Keeps: true}}},
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: "mod-x", Streams: []ModuleStream{{Module: "mod-x", Name: "SECRETS", Events: []string{"sealed"}, Restricted: true},
@@ -1415,8 +1415,12 @@ func chunkSubjectsFollowTheAnswerer(t *testing.T) {
perms[u.Username()] = p
}
has := func(list []string, s string) bool { return slices.Contains(list, s) }
if runner := perms["a.node-tools"]; !has(runner.Publish, chunks) || !has(runner.Subscribe, acks) {
t.Errorf("the tool runner, answering the verb for the bus module's tools bundle, may not send chunks and hear acks: %v / %v", runner.Publish, runner.Subscribe)
if runner := perms["a.node-tools"]; has(runner.Publish, chunks) || has(runner.Subscribe, servedAcks) {
t.Errorf("the tool runner is granted chunk subjects: %v / %v", runner.Publish, runner.Subscribe)
}
if k := perms["a.nats.keeper"]; !has(k.Publish, chunks) || !has(k.Subscribe, servedAcks) || !has(k.Subscribe, "mesh.seat.mesh-broker.call.snapshot-get.*") ||
!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)
}
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)
@@ -1424,7 +1428,7 @@ func chunkSubjectsFollowTheAnswerer(t *testing.T) {
if rec := perms["a.recovery.recover"]; !has(rec.Subscribe, chunks) || !has(rec.Publish, acks) {
t.Errorf("the process naming the verb may not hear chunks and send acks")
}
for _, u := range []string{"a.admin-ish", "a.nats.keeper"} {
for _, u := range []string{"a.admin-ish", "a.nats"} {
if p := perms[u]; has(p.Subscribe, chunks) || has(p.Publish, chunks) || has(p.Publish, acks) {
t.Errorf("%s, naming no chunked verb, is granted chunk subjects: %v / %v", u, p.Publish, p.Subscribe)
}
@@ -10,7 +10,7 @@ import (
// **The chunk subjects of a verb that sends a file** (design 47 G §681, A-45, A-60): a verb whose seat definition says `chunks: true`
// has its answer's file sent in chunks on `mesh.seat.<seat>.chunk.<verb>.<call>.<n>`, each acknowledged by the caller on
// `mesh.seat.<seat>.ack.<verb>.<call>.<n>`. They are granted to the principal that answers the verb (publish the chunks, subscribe to
// `mesh.seat.<seat>.ack.<verb>.<call>.<n>`. They are granted to the process that serves the verb (publish the chunks, subscribe to
// the acknowledgements) and, reversed, to an account that names the verb by name in `invokes`; a `*` entry grants none.
// ChunkVerb is one verb of a seat whose definition says it sends chunks.
@@ -38,29 +38,20 @@ func ChunkVerbsOf(defs map[string]seatdef.Definition) []ChunkVerb {
func chunkSubject(c ChunkVerb) string { return "mesh.seat." + c.Seat + ".chunk." + c.Verb + ".>" }
func ackSubject(c ChunkVerb) string { return "mesh.seat." + c.Seat + ".ack." + c.Verb + ".>" }
// chunkGrants are the chunk subjects p is granted: as the principal that answers a chunked verb, the publish of its chunks and
// the subscription to their acknowledgements (a module's own account for a seat it holds, the tool runner's for each seat a
// carried module holds; a process serves nothing, ADR 0436 §3), and as an account that names a chunked verb by name in `invokes`,
// the subscription to the chunks and the publish of the acknowledgements.
// ackServed is what the process that answers a chunked verb subscribes: the acknowledgements of every call and chunk (A-109).
func ackServed(c ChunkVerb) string { return "mesh.seat." + c.Seat + ".ack." + c.Verb + ".*.*" }
// chunkGrants are the chunk subjects p is granted: as a process that serves a chunked verb (`serves`, design 47 G A-109), the
// publish of its chunks and the subscription to their acknowledgements, and as an account that names a chunked verb by name in
// `invokes`, the subscription to the chunks and the publish of the acknowledgements. The chunks of a snapshot are a transfer on
// the core bus: no stream, and any caller of the verb hears them.
func chunkGrants(p Principal) (pub, sub []string) {
if len(p.Chunked) == 0 {
return nil, nil
}
var served []Seat
switch p.Kind {
case KindModule:
served = p.Holds
case KindNodeTools:
for _, d := range p.Carries {
served = append(served, d.Holds...)
}
}
for _, c := range p.Chunked {
for _, s := range served {
if s.Name == c.Seat && slices.Contains(s.Serves, c.Verb) {
pub, sub = append(pub, chunkSubject(c)), append(sub, ackSubject(c))
break
}
if p.Kind == KindProcess && 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) {
sub, pub = append(sub, chunkSubject(c)), append(pub, ackSubject(c))
@@ -189,6 +189,8 @@ 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 []string
// AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software
// issued, each `<module>.<tool>` (novox/hq ADR 0305, issue 723, vaultCalls): on the subject that names its caller,
@@ -1,11 +1,16 @@
package buscompose
import (
"fmt"
"strings"
)
// The bus account of one process of a module that declares bus (novox/hq ADR 0436 §1, §2, §3, §5).
//
// **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, hears no module stream, and answers nothing a caller asked (§3). Modelled on
// 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
// provisionerGrants, the other kind of process account the mesh mints (ADR 0349 clauses 6 and 11).
// processGrants is one process's whole authority: to publish, and to subscribe.
@@ -52,6 +57,16 @@ func processGrants(p Principal) (pub, sub []string, err error) {
}
pub = append(pub, invoked...)
// 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)
}
// 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
// module uses (the operator channel's accept.condition in its module's name).
@@ -137,6 +137,8 @@ type ProcessPrincipal struct {
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
// seat:<seat>.<verb>, or * for every tool on the mesh.
Invokes []string
// Serves are the seat verbs this process answers, each seat:<seat>.<verb> (design 47 G A-109).
Serves []string
// Tools says it is a tools bundle's account, run by the node's tool runtime, not a process the node runs.
Tools bool
// Keeps says its bus declares `keeps`: the bus module's keeper (novox/hq ADR 0626 §1), composed the bus's create,
@@ -265,7 +267,7 @@ func Users(r Records) ([]Principal, error) {
out = append(out, Principal{
Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process,
Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks,
Invokes: pr.Invokes, Removes: pr.Removes,
Invokes: pr.Invokes, AnswersSeat: pr.Serves, Removes: pr.Removes,
})
// The process of the module holding mesh-broker whose bus declares `keeps` keeps every module's bus state
// (busstate.go, ADR 0595 §4, ADR 0626 §1); `keeps` on any other module composes nothing (the judge refuses it,
@@ -445,7 +445,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, Removes: a.Bus.Removes, Tools: a.Tools, Keeps: a.Bus.Keeps}
Invokes: a.Bus.Invokes, Serves: a.Bus.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)