The bus node follows the grants part's sealed user list, and only the grants part reads the vault's hashes (ADR 0547 clauses 7, 9; issue 1945)

The grants part writes its list as an object of its own bucket, sealed to the bus node's key, and names it in
user-list.sealed; the node-engine of the node holding mesh-broker is granted that one key and an object by name, the
grants part its bucket (the controller's copy carries the same grants, byte for byte, golden regenerated there).
A state may name its readers (announce, NamedReaders): mesh-vault.bus-accounts is read by mesh-grants alone.
This commit is contained in:
2026-10-26 11:53:11 +01:00
parent 85fa8511c4
commit 175a5567b8
13 changed files with 659 additions and 11 deletions
+15
View File
@@ -45,10 +45,25 @@ func ControllerAnnouncement(node string) announce.States {
// announced the state readable, or announced nothing yet (the transition); refused when the owner announced and did
// not announce that state readable.
func ReadsAnnounced(reads []string, announced map[string]announce.States) (granted, refused []string) {
return ReadsAnnouncedBy("", reads, announced)
}
// ReadsAnnouncedBy is ReadsAnnounced for one reader module, which also honours the readers an owner named for a state
// (announce.State.Readers) and the composer's named readers (NamedReaders, ADR 0547 clause 9): a read named for other
// modules is refused. An empty reader is judged by the readable mark alone.
func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) {
for _, r := range reads {
owner, name, ok := strings.Cut(r, ".")
s, said := announced[owner]
switch {
case reader != "" && !ReaderMay(reader, r):
refused = append(refused, r)
case reader != "" && ok && said && s.Keeps(name):
if s.ReadableBy(name, reader) {
granted = append(granted, r)
} else {
refused = append(refused, r)
}
case !ok || !said || s.Readable(name):
granted = append(granted, r)
default:
+41 -4
View File
@@ -4,16 +4,24 @@ import "testing"
// Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream
// of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing
// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy.
// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. And ADR 0547
// clause 7 (issue 1945, the operator's second exception): the grants part writes its own object bucket of sealed user
// lists, and the node-engine of the node holding mesh-broker alone reads its one key and an object of it by name.
func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
records := Records{
Nodes: []string{"novox"},
Assigned: map[string][]Declared{"novox": {
Nodes: []string{"novox", "ace"},
Assigned: map[string][]Declared{"ace": {{Module: "plex"}}, "novox": {
{Module: "nats", SnapshotsTheBus: true},
// mesh-sdk's copy alone (ADR 0547 clause 9, named readers): the vault's hashes are read by the grants part
// alone, whatever another module's reads say; the controller's copy grants any module that declares it.
{Module: "mesh-vault", State: []Bucket{{Module: "mesh-vault", Name: "bus-accounts"}}},
{Module: "spy", Serves: []string{"spy_x"}, Reads: []string{VaultBusAccounts}},
{Module: "mesh-delivery-builder",
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}},
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}},
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}},
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}},
{Module: RuntimeModule},
}},
}
users, err := Users(records)
@@ -22,6 +30,14 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
}
perms := map[string]Permissions{}
for _, u := range users {
if u.Kind == KindNode || u.Kind == KindNodeTools {
p, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", u.Username(), err)
}
perms[u.Username()] = p
continue
}
if u.Kind != KindModule {
continue
}
@@ -48,6 +64,27 @@ func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
{GrantsModule, "$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list", true},
{GrantsModule, "$O.declaration-files-novox.C.abc", false},
{GrantsModule, "$JS.API.STREAM.DELETE.OBJ_mesh-grants-user-list", false},
{"mesh-delivery-builder", "$O.mesh-grants-user-list.C.abc", false},
// The bus node's engine reads the one key and an object by name; nothing of the bucket's list, no write.
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", true},
{"node.novox", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.abc.$KV.mesh-grants_user-list.sealed", true},
{"node.novox", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", true},
{"node.novox", "$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.abc.$O.mesh-grants-user-list.C.abc", true},
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.list", false},
{"node.novox", "$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list", false},
{"node.novox", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", false},
{"node.novox", "$O.mesh-grants-user-list.C.abc", false},
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", false},
{"node.ace", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", false},
{GrantsModule, "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", true},
{"spy", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false},
{"novox.node-tools", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false},
} {
if got := MayPublish(perms[row.who], row.subject); got != row.may {
t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may)
+10
View File
@@ -213,6 +213,9 @@ type Principal struct {
// StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name
// (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new.
StatesItsKeyToTheVault bool
// FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants
// part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows).
FollowsGrantsUserList bool
// BoundBuckets are the providers' buckets of entries, `<module>_bound`, a provisioner client reads its own
// machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places.
BoundBuckets []string
@@ -912,6 +915,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
if p.StatesItsKeyToTheVault {
pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username()))
}
// And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR
// 0547 clause 7): one key, and an object by its name.
if p.FollowsGrantsUserList {
pub = append(pub, GrantsUserListFollows()...)
}
case KindModule:
// 0. The watchman alone hears who connects and disconnects, and asks who is connected (ADR 0431 §2).
@@ -927,6 +935,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
// And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue
// 1790): the create and the read of that one stream, never an update, a delete or a stream not declared.
pub = append(pub, CreateStreamGrants(p.CreatesStreams)...)
// And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7).
pub = append(pub, GrantsUserListWrites()...)
}
// The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a).
if p.Module == ViewModule {
File diff suppressed because one or more lines are too long
+111
View File
@@ -0,0 +1,111 @@
package buscompose
import (
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes
// the list it composed as one object of its own object bucket, sealed to the key of the node holding mesh-broker, and
// names it in one key of its own state `user-list`. The node-engine of that node follows the key when the switch says
// so (clause 8), opens the object and writes the bus's user-list file.
//
// Who may do what, and nothing more:
//
// - the grants part writes its object bucket (create, put, purge of an object's old chunks, delete) and its own state
// as before;
// - the node-engine of the node holding mesh-broker reads that one key of the state, watched, and an object of the
// bucket by its name: never the bucket's list of names, never a write, never another key of the grants part's state;
// - no other principal touches either.
// GrantsUserListBucket, GrantsUserListKey, GrantsUserListObjects and GrantsUserListObjectStream are delivery's names,
// here where the grants are composed.
const (
GrantsUserListBucket = delivery.GrantsUserListBucket
GrantsUserListKey = delivery.GrantsUserListKey
GrantsUserListObjects = delivery.GrantsUserListObjects
GrantsUserListObjectStream = delivery.GrantsUserListObjectStream
)
// GrantsUserListFollows is what the node-engine of the node holding mesh-broker may publish to follow the grants part's
// list: the one key `user-list.sealed` read and watched (its own ordered consumer, filtered on that key alone), and an
// object of the bucket by its name, as a node reads its declaration's files (ADR 0486 §2: never STREAM.INFO of the object
// stream, which lists every name, and never STREAM.MSG.GET).
func GrantsUserListFollows() []string {
kv := "KV_" + GrantsUserListBucket
key := "$KV." + GrantsUserListBucket + "." + GrantsUserListKey
obj, s := GrantsUserListObjects, GrantsUserListObjectStream
return []string{
"$JS.API.DIRECT.GET." + kv + "." + key,
"$JS.API.CONSUMER.CREATE." + kv + ".*." + key,
"$JS.API.CONSUMER.DELETE." + kv + ".*",
"$JS.API.STREAM.INFO." + kv,
"$JS.FC." + kv + ".>",
"$JS.API.DIRECT.GET." + s + ".$O." + obj + ".M.*",
"$JS.API.CONSUMER.CREATE." + s + ".*.$O." + obj + ".C.*",
"$JS.API.CONSUMER.DELETE." + s + ".*",
"$JS.FC." + s + ".>",
}
}
// GrantsUserListWrites is what the grants part may publish to keep its object bucket: create it and read its info back,
// put an object (its chunks and its metadata, the old chunks of a name purged), read an object's metadata, and delete
// one. Its own state `user-list` it writes already, as every module writes its own.
func GrantsUserListWrites() []string {
obj, s := GrantsUserListObjects, GrantsUserListObjectStream
return []string{
"$JS.API.STREAM.CREATE." + s,
"$JS.API.STREAM.INFO." + s,
"$JS.API.STREAM.PURGE." + s,
"$JS.API.DIRECT.GET." + s + ".>",
"$JS.API.STREAM.MSG.GET." + s,
"$O." + obj + ".>",
}
}
// grantsOwnObjects is the grants part writing its own object bucket, and nothing of another.
func grantsOwnObjects(p Principal, pattern string) bool {
return p.Kind == KindModule && p.Module == GrantsModule &&
(pattern == "$O."+GrantsUserListObjects+".>" || pattern == "$JS.API.STREAM.CREATE."+GrantsUserListObjectStream)
}
// **Named readers** (novox/hq ADR 0547 clause 9, progressive insight of 2026-10-26; issue 1945). A state named here is
// read by the modules named and by no other, whatever another module's `reads` says: the vault's `bus-accounts` holds
// every bus account's bcrypt hash, and from take-over the grants part alone may read it. A module's announcement may name
// readers too (announce.State.Readers); both are honoured.
const VaultBusAccounts = "mesh-vault.bus-accounts"
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`.
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}}
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
// that names none.
func ReaderMay(module, read string) bool {
readers, named := NamedReaders[read]
return !named || slices.Contains(readers, module)
}
// readsNamed splits a module's reads by the named readers: those it may read, and those named for others.
func readsNamed(module string, reads []string) (granted, refused []string) {
for _, r := range reads {
if ReaderMay(module, strings.TrimSpace(r)) {
granted = append(granted, r)
} else {
refused = append(refused, r)
}
}
return granted, refused
}
// withNamedReaders is d with the reads named for other modules moved to its refused reads.
func withNamedReaders(d Declared) Declared {
granted, refused := readsNamed(d.Module, d.Reads)
if len(refused) == 0 {
return d
}
d.Reads = granted
d.ReadsRefused = append(append([]string(nil), d.ReadsRefused...), refused...)
return d
}
@@ -0,0 +1,206 @@
//go:build integration
package buscompose
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seal"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Defends novox/hq ADR 0547 clause 7 on a real server (issue 1945): with the list this composer writes, the grants part
// creates its object bucket and its state, puts the sealed list and names it in `user-list.sealed`; the node-engine of
// the node holding mesh-broker watches that one key, reads the object by its name and opens it with its key, and may
// neither list the bucket, nor read another key of the state, nor write; another node reads nothing of either.
func TestTheBusNodeFollowsTheGrantsPartsSealedListUnderTheComposedGrants(t *testing.T) {
records := Records{
Nodes: []string{"novox", "ace"},
Assigned: map[string][]Declared{
"novox": {{Module: "nats", SnapshotsTheBus: true},
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "user-list"}}}},
"ace": {{Module: "plex"}},
},
}
users, err := Users(records)
if err != nil {
t.Fatal(err)
}
for i := range users {
users[i].PasswordHash = "pw-" + users[i].Username()
}
accounts, err := ComposeAccounts(users)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
conf := filepath.Join(dir, "server.conf")
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+dir+"\" }\n"+accounts), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatalf("the composed list is not the server's: %v", err)
}
opts.NoLog, opts.NoSigs = true, true
s, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(30 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(s.Shutdown)
dial := func(user string) *nats.Conn {
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw-"+user), nats.CustomInboxPrefix("_INBOX."+user))
if err != nil {
t.Fatalf("%s: %v", user, err)
}
t.Cleanup(nc.Close)
return nc
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// The grants part: its state and its object bucket, the sealed list put and named.
grants, err := jetstream.New(dial("novox." + GrantsModule))
if err != nil {
t.Fatal(err)
}
kv, err := grants.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: GrantsUserListBucket})
if err != nil {
t.Fatalf("the grants part's state: %v", err)
}
objects, err := grants.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: GrantsUserListObjects})
if err != nil {
t.Fatalf("the grants part's object bucket: %v", err)
}
node, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
list := []byte(accounts)
digest := delivery.UserListDigest(list)
object, err := delivery.SealUserList(list, node.PublicKey)
if err != nil {
t.Fatal(err)
}
name := delivery.UserListObjectName(digest)
for range 2 { // put twice: a second put of a name purges its old chunks
if _, err := objects.PutBytes(ctx, name, object); err != nil {
t.Fatalf("the grants part put its object: %v", err)
}
}
value, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: delivery.FollowGrants, Digest: digest,
Object: name, SealedTo: delivery.SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: len(users)})
if err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, GrantsUserListKey, value); err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, "list", []byte(`{"v":1}`)); err != nil {
t.Fatal(err)
}
// The bus node's engine: the key watched, the object by name (as mesh-host's link reads a declaration's file).
busNode := dial("node.novox")
js, err := busNode.JetStream(nats.MaxWait(5 * time.Second))
if err != nil {
t.Fatal(err)
}
keys, err := js.KeyValue(GrantsUserListBucket)
if err != nil {
t.Fatalf("the bus node binds the grants part's state: %v", err)
}
w, err := keys.Watch(GrantsUserListKey)
if err != nil {
t.Fatalf("the bus node watches the key: %v", err)
}
defer w.Stop()
var got delivery.GrantsUserList
select {
case e := <-w.Updates():
if e == nil {
t.Fatal("the watch found no value")
}
if got, err = delivery.DecodeGrantsUserList(e.Value()); err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("the watch said nothing")
}
read := func(js nats.JetStreamContext, name string) ([]byte, error) {
meta, err := js.GetLastMsg(GrantsUserListObjectStream, "$O."+GrantsUserListObjects+".M."+
base64.URLEncoding.EncodeToString([]byte(name)), nats.DirectGet())
if err != nil {
return nil, err
}
var info struct {
NUID string `json:"nuid"`
Size int `json:"size"`
}
if err := json.Unmarshal(meta.Data, &info); err != nil {
return nil, err
}
sub, err := js.SubscribeSync("$O."+GrantsUserListObjects+".C."+info.NUID, nats.OrderedConsumer(),
nats.BindStream(GrantsUserListObjectStream))
if err != nil {
return nil, err
}
defer sub.Unsubscribe()
var body []byte
for len(body) < info.Size {
m, err := sub.NextMsg(5 * time.Second)
if err != nil {
return nil, err
}
body = append(body, m.Data...)
}
return body, nil
}
body, err := read(js, got.Object)
if err != nil {
t.Fatalf("the bus node reads the object by its name: %v", err)
}
opened, err := delivery.OpenUserList(body, got.Digest, func(b seal.Box) ([]byte, error) {
s, err := seal.Open(b, node.PrivateKey)
return []byte(s), err
})
if err != nil || !bytes.Equal(opened, list) {
t.Fatalf("the bus node opened %d bytes, %v", len(opened), err)
}
// And nothing beyond: no other key, no list of the bucket, no write.
if _, err := keys.Get("list"); err == nil {
t.Error("the bus node read another key of the grants part's state")
}
if _, err := js.StreamInfo(GrantsUserListObjectStream); err == nil {
t.Error("the bus node read the object stream's info, which lists every name")
}
if _, err := js.Publish("$O."+GrantsUserListObjects+".M.x", []byte("x")); err == nil {
t.Error("the bus node wrote the grants part's bucket")
}
// Another node reads nothing of it.
other, err := dial("node.ace").JetStream(nats.MaxWait(2 * time.Second))
if err != nil {
t.Fatal(err)
}
if _, err := read(other, got.Object); err == nil {
t.Error("another node read the object")
}
if _, err := other.GetLastMsg("KV_"+GrantsUserListBucket, "$KV."+GrantsUserListBucket+"."+GrantsUserListKey, nats.DirectGet()); err == nil {
t.Errorf("another node read the key: %v", err)
}
}
+10 -1
View File
@@ -147,14 +147,19 @@ func Users(r Records) ([]Principal, error) {
for _, node := range sortedCopy(r.Nodes) {
witness := false
var checks []string
bus := false
for _, d := range r.Assigned[node] {
if d.Module == controllerModule {
witness = true
}
if d.SnapshotsTheBus {
bus = true
}
checks = append(checks, d.Checks...)
}
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey)})
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
FollowsGrantsUserList: bus})
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
// per machine that runs it, delivered to the module running it.
for _, d := range r.Assigned[node] {
@@ -176,6 +181,9 @@ func Users(r Records) ([]Principal, error) {
}
}
for _, d := range r.Assigned[node] {
// A state with named readers is read by them alone (ADR 0547 clause 9): a read named for another module is
// granted nothing, here and in the runtime carrying the module.
d = withNamedReaders(d)
// **A process account is its own, delivered in its own secret** (broker-<process id>, novox/hq
// ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module
// itself has an account, and is never carried by the machine's runtime, which runs as the
@@ -223,6 +231,7 @@ func Users(r Records) ([]Principal, error) {
var carried []Declared
for _, d := range r.Assigned[node] {
if d.RunsAs == "" && runnerServes(d) {
d = withNamedReaders(d)
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
d.Hears = ComposedHears(d)
carried = append(carried, d)
+7 -1
View File
@@ -44,6 +44,9 @@ func createsDeclared(p Principal, pattern string) bool {
return true
}
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
if ok && p.Kind == KindModule && p.Module == GrantsModule && name == GrantsUserListObjectStream {
return true
}
return ok && p.Kind == KindModule && p.Module == GrantsModule && slices.Contains(p.CreatesStreams, name) &&
safeSubject.MatchString(name)
}
@@ -249,7 +252,10 @@ var WritersTable = []WriterRow{
KeptIn: "the object bucket " + DeclarationFilesBucket + "-<node> of each node, an object removed a day after the " +
"node's latest declaration no longer names it",
Others: "a node-engine reads an object of its own bucket by its name and may neither list the bucket nor write it",
Subjects: []string{"$O.*.>"}, Writes: isControllerOrTheHandPath},
Subjects: []string{"$O.*.>"}, Writes: func(p Principal, pattern string) bool {
return isControllerOrTheHandPath(p, pattern) || grantsOwnObjects(p, pattern)
},
Shared: "the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"},
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},