From e163aab3d1143b29827c10304398ba989d6fa01d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:01:01 +0100 Subject: [PATCH 1/4] AM-D3 deny: a user's subscribe deny is written, compared and kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grants part composes a subscribe deny where a node's tool runner does not honour the approval mark; the user list must carry it to the bus, and an added deny is a narrowing the user-list guard lets pass as no right lost. Builds: design 46 7 §6 (Permissions.SubscribeDeny, User subscribe_deny, comparison and differences, the guard's narrowing) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-grants/grants_test.go | 9 +++++++++ .../internal/buscompose/buscompose_test.go | 6 ++++++ .../mesh-grants/internal/buscompose/nats.go | 18 ++++++++++++++++-- modules/mesh-grants/internal/grants/grants.go | 1 + .../mesh-grants/internal/grants/keeploaded.go | 11 ++++++++++- modules/mesh-grants/internal/grants/refused.go | 4 ++-- .../mesh-grants/internal/grants/userlist.go | 18 +++++++++++++++--- 7 files changed, 59 insertions(+), 8 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 8f722f46..b5c3de58 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1265,6 +1265,10 @@ func TestGrants(t *testing.T) { for i := range readable { readable[i].PublishDeny = []string{} } + // A subscribe the shadow withholds (design 46 approval mark §6) where the loaded list withholds none, as on the first + // composition after the release: a narrowing, no right lost. Its reverse, a node gaining approval-mark/1: a right added. + withheld := slices.Clone(shadow) + withheld[x].SubscribeDeny = []string{"mesh.mod.m.tool.execute", "mesh.mod.m.tool.execute.*"} // The live comparison the operator's yes was refused on, 2026-10-27 21:19 UTC (issue 1945): 41 users differ, every // difference an added right. Read whole; a row's mutate makes one of the cases that must still refuse. var live struct { @@ -1309,6 +1313,8 @@ func TestGrants(t *testing.T) { {"only a right added", fewer, nil, nil, nil, []string{"a.mod-x"}, false, nil, nil, 1}, {"a deny added refuses", readable, nil, nil, nil, []string{"a.mod-x"}, false, nil, []string{"a.mod-x"}, 1}, {"a deny the loaded list cannot say refuses a user whose grants differ", fewer, nil, nil, nil, []string{"a.mod-x"}, false, nil, []string{"a.mod-x"}, 1}, + {"a subscribe withheld passes as no right lost", shadow, nil, nil, nil, []string{"a.mod-x"}, false, nil, nil, 0}, + {"a subscribe no longer withheld is a right added", withheld, nil, nil, nil, []string{"a.mod-x"}, false, nil, nil, 1}, {"captured 21:19: only added rights", nil, nil, nil, nil, nil, false, captured(nil), nil, 41}, {"captured, a right removed", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) { nodeAce(c).SubscribeDropped = []string{"mesh.mod.mesh-vault.event.secret-changed"} @@ -1333,6 +1339,9 @@ func TestGrants(t *testing.T) { if strings.Contains(row.name, "deny") { from = denied } + if strings.Contains(row.name, "withheld passes") { + from = withheld + } c = grants.Compare(from, row.loaded, grants.Placed(in)) var differ []string for _, d := range c.Differ { diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 53a1bc51..8787267c 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -601,6 +601,12 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { if err != nil || perr != nil || !strings.Contains(list, " users = [\n"+AccountsEntry(filled[0].Username(), "$2a$11$h", perms)+" ]\n") { t.Errorf("the accounts entry is not ComposeAccounts' own: %v %v\n%s", err, perr, list) } + // A subscribe deny (design 46 approval mark §6) is written as the server's deny beside its allow, and outranks it. + withheld := Permissions{Publish: []string{"a"}, Subscribe: []string{"mesh.mod.m.tool.>"}, SubscribeDeny: []string{"mesh.mod.m.tool.execute"}} + if entry := AccountsEntry("u", "h", withheld); !strings.Contains(entry, `subscribe: { allow: ["mesh.mod.m.tool.>"], deny: ["mesh.mod.m.tool.execute"] }`) || + MaySubscribe(withheld, "mesh.mod.m.tool.execute") || !MaySubscribe(withheld, "mesh.mod.m.tool.read") { + t.Errorf("a subscribe deny is written as %s", entry) + } c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}}) cfg := ConsumerConfig(c) if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second || diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 12fd9c8d..8aa23802 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -388,8 +388,14 @@ func MayPublish(perms Permissions, subject string) bool { return false } -// MaySubscribe says whether a principal with these permissions may subscribe to (and so answer) a subject. +// MaySubscribe says whether a principal with these permissions may subscribe to (and so answer) a subject: an allow +// covers it and no deny does. func MaySubscribe(perms Permissions, subject string) bool { + for _, d := range perms.SubscribeDeny { + if SubjectsOverlap(d, subject) { + return false + } + } for _, a := range perms.Subscribe { if SubjectsOverlap(a, subject) { return true @@ -595,6 +601,10 @@ type Permissions struct { // denied to everybody whose grant is wide enough to reach them. The server's deny outranks its allow. PublishDeny []string Subscribe []string + // SubscribeDeny are subjects the principal may not subscribe to although an allow covers them (design 46 approval + // mark §6): every marked subject of a module its runtime carries, where the node's tool runner does not honour the mark. + // The server's deny outranks its allow of `mesh.mod..tool.>`. + SubscribeDeny []string // AllowResponses lets a principal reply to a request it received, on the reply subject that // request carried, once. // @@ -1562,7 +1572,11 @@ func AccountsEntry(user, passwordHash string, perms Permissions) string { } else { fmt.Fprintf(&b, " publish: { allow: [%s] }\n", quoted(perms.Publish)) } - fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe)) + if len(perms.SubscribeDeny) > 0 { + fmt.Fprintf(&b, " subscribe: { allow: [%s], deny: [%s] }\n", quoted(perms.Subscribe), quoted(perms.SubscribeDeny)) + } else { + fmt.Fprintf(&b, " subscribe: { allow: [%s] }\n", quoted(perms.Subscribe)) + } if perms.AllowResponses { fmt.Fprintf(&b, " allow_responses: { max: 1, ttl: \"%dm\" }\n", int(ResponseTTL/time.Minute)) } diff --git a/modules/mesh-grants/internal/grants/grants.go b/modules/mesh-grants/internal/grants/grants.go index 964e095f..4a29447f 100644 --- a/modules/mesh-grants/internal/grants/grants.go +++ b/modules/mesh-grants/internal/grants/grants.go @@ -75,6 +75,7 @@ func GrantWaits(accounts []string, composed, loaded map[string]User, unknown str if l.PublishDeny != nil { n += len(minus(l.PublishDeny, c.PublishDeny)) } + n += len(minus(l.SubscribeDeny, c.SubscribeDeny)) if n > 0 { adds = append(adds, fmt.Sprintf("%s (%d permissions)", user, n)) } diff --git a/modules/mesh-grants/internal/grants/keeploaded.go b/modules/mesh-grants/internal/grants/keeploaded.go index e65dea25..5cadd5fd 100644 --- a/modules/mesh-grants/internal/grants/keeploaded.go +++ b/modules/mesh-grants/internal/grants/keeploaded.go @@ -60,7 +60,8 @@ func KeepLoaded(composed, loaded []User, inForce map[string]User) map[string]Use // Every deny any list says is kept: a deny the composer drops while it composes part of the mesh would widen the // user (the composer gives every composed user a deny, empty or not, so an intersection would drop the list in // force's). - u := User{User: name, Publish: c.Publish, Subscribe: c.Subscribe, AllowResponses: c.AllowResponses, PublishDeny: c.PublishDeny} + u := User{User: name, Publish: c.Publish, Subscribe: c.Subscribe, AllowResponses: c.AllowResponses, PublishDeny: c.PublishDeny, + SubscribeDeny: c.SubscribeDeny} for _, o := range from { u.Publish = set(append(slices.Clone(u.Publish), o.Publish...)) u.Subscribe = set(append(slices.Clone(u.Subscribe), o.Subscribe...)) @@ -68,6 +69,9 @@ func KeepLoaded(composed, loaded []User, inForce map[string]User) map[string]Use if o.PublishDeny != nil || u.PublishDeny != nil { u.PublishDeny = set(append(slices.Clone(u.PublishDeny), o.PublishDeny...)) } + if o.SubscribeDeny != nil || u.SubscribeDeny != nil { + u.SubscribeDeny = set(append(slices.Clone(u.SubscribeDeny), o.SubscribeDeny...)) + } } if u.Publish == nil { u.Publish = []string{} @@ -94,6 +98,11 @@ func Losses(now, had User) []string { if had.AllowResponses && !now.AllowResponses { out = append(out, "responses") } + // A subscribe deny now adds is a narrowing and no word of had lost (design 46 approval mark §6); one had held that now + // drops widens the user past it. + for _, s := range minus(had.SubscribeDeny, now.SubscribeDeny) { + out = append(out, "subscribe deny dropped "+s) + } if had.PublishDeny != nil { for _, s := range minus(now.PublishDeny, had.PublishDeny) { out = append(out, "deny "+s) diff --git a/modules/mesh-grants/internal/grants/refused.go b/modules/mesh-grants/internal/grants/refused.go index 54b32caa..7ea2c0a9 100644 --- a/modules/mesh-grants/internal/grants/refused.go +++ b/modules/mesh-grants/internal/grants/refused.go @@ -39,7 +39,7 @@ func ComposeEach(r buscompose.Records) ([]User, map[string]string, error) { continue } out = append(out, User{User: p.Username(), Publish: set(perms.Publish), Subscribe: set(perms.Subscribe), - AllowResponses: perms.AllowResponses, PublishDeny: set(perms.PublishDeny)}) + AllowResponses: perms.AllowResponses, PublishDeny: set(perms.PublishDeny), SubscribeDeny: set(perms.SubscribeDeny)}) } sort.Slice(out, func(i, j int) bool { return out[i].User < out[j].User }) return out, refused, nil @@ -81,7 +81,7 @@ func withKept(users []User, kept map[string]User) []User { // (buscompose.AccountsEntry), so the bus reads it as it read the list in force. func keptEntry(u User, hash string) string { return buscompose.AccountsEntry(u.User, hash, buscompose.Permissions{Publish: u.Publish, PublishDeny: u.PublishDeny, - Subscribe: u.Subscribe, AllowResponses: u.AllowResponses}) + Subscribe: u.Subscribe, SubscribeDeny: u.SubscribeDeny, AllowResponses: u.AllowResponses}) } // withEntries puts entries at the end of the accounts block's users, before it closes. diff --git a/modules/mesh-grants/internal/grants/userlist.go b/modules/mesh-grants/internal/grants/userlist.go index 7c2bd8a9..85bf01c7 100644 --- a/modules/mesh-grants/internal/grants/userlist.go +++ b/modules/mesh-grants/internal/grants/userlist.go @@ -24,6 +24,9 @@ type User struct { // PublishDeny is what the user may not publish though an allow reaches it (buscompose's ControllerOnly). The bus // module's nats_users answers none today, so a loaded user's is nil, read as unknown; empty and not nil once it says. PublishDeny []string `json:"publish_deny,omitempty"` + // SubscribeDeny is what the user may not subscribe to though an allow reaches it: the marked subjects withheld from a + // runtime whose tool runner does not honour the mark (design 46 approval mark §6). Nil and empty are both none. + SubscribeDeny []string `json:"subscribe_deny,omitempty"` // Hash is the bcrypt hash the vault holds for the user (WithHashes): in memory alone, never in a digest, a compare // or the part's state, and empty for a loaded user. Hash string `json:"-"` @@ -227,11 +230,17 @@ type UserDiff struct { // none): it cannot be compared, so it is no added right either (ADR 0562 §2). DenyAdded []string `json:"deny_added,omitempty"` DenyUnread []string `json:"deny_unread,omitempty"` + // SubscribeDenyAdded is what the shadow list withholds from the user's subscriptions and the loaded list does not: a + // narrowing, which loses no right and passes the guard at once (design 46 approval mark §6). SubscribeDenyDropped is + // what the loaded list withholds and the shadow list no longer does: a right added. + SubscribeDenyAdded []string `json:"subscribe_deny_added,omitempty"` + SubscribeDenyDropped []string `json:"subscribe_deny_dropped,omitempty"` } // same says the user's grants agree in both lists. func (d UserDiff) same() bool { - return len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped)+len(d.DenyAdded) == 0 && d.Responses == "" + return len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped)+len(d.DenyAdded)+ + len(d.SubscribeDenyAdded)+len(d.SubscribeDenyDropped) == 0 && d.Responses == "" } // Comparison is the shadow list against the loaded list. @@ -320,11 +329,12 @@ func (c Comparison) Removing() []string { func (c Comparison) AddedRights() []UserDiff { var out []UserDiff for _, d := range c.Differ { - a := UserDiff{User: d.User, PublishAdded: d.PublishAdded, SubscribeAdded: d.SubscribeAdded} + a := UserDiff{User: d.User, PublishAdded: d.PublishAdded, SubscribeAdded: d.SubscribeAdded, + SubscribeDenyDropped: d.SubscribeDenyDropped} if !d.responsesLost() { a.Responses = d.Responses } - if len(a.PublishAdded)+len(a.SubscribeAdded) > 0 || a.Responses != "" { + if len(a.PublishAdded)+len(a.SubscribeAdded)+len(a.SubscribeDenyDropped) > 0 || a.Responses != "" { out = append(out, a) } } @@ -355,6 +365,8 @@ func Compare(shadow, loaded []User, placed func(user string) bool) Comparison { if l.PublishDeny != nil { d.DenyAdded = minus(s.PublishDeny, l.PublishDeny) } + // A loaded list that says no subscribe deny withholds none: the bus holds none it was not given. + d.SubscribeDenyAdded, d.SubscribeDenyDropped = minus(s.SubscribeDeny, l.SubscribeDeny), minus(l.SubscribeDeny, s.SubscribeDeny) if d.same() { // The same allows compose the same deny: one composer derives it from them. c.Same++ From 5125feabe6d5762f421f356fb01b13d6def4a5cf Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:08:16 +0100 Subject: [PATCH 2/4] AM-D3 path: the assignment half carries each node's readings with the runner's fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grants part reads a node's capability readings and the runner that announced them from the assignment half; the assigner reads both from the node record it already holds, the digest over all of it. Builds: design 46 7 §6 (the assignment half carries, for each node, its capability readings, each with the fingerprint of the runner that announced it), design 47 G §194, lead answer A-203 (readings, fingerprint) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-assigner/graphkept.go | 31 +++++++++++++------ .../mesh-delivery-assigner/graphkept_test.go | 10 ++++++ .../internal/assigngraph/compute.go | 5 ++- .../go/seats/meshwants/contract.go | 8 +++-- .../go/seats/meshwants/contract_test.go | 2 +- .../seats/mesh-wants/fixtures/verbs.json | 7 ++++- .../seats/mesh-wants/seat.json | 13 +++++++- .../ts/seats/meshwants/index.d.ts | 6 +++- 8 files changed, 66 insertions(+), 16 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go index c013f018..d05f9bcd 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go @@ -79,23 +79,36 @@ func (p *passer) keepGraph(ctx context.Context) { } } -// capabilityDigests is the digest of each node's capability reading, the statement its engine made, by node. +// capabilityDigests is each node's capability reading, by node: the statement its engine made of what it reads, and the +// capabilities its tool runner states with the fingerprint of the runner that announced them, as the node's record carries +// them (design 46 approval mark §6, lead answer A-203), the digest over all of it. func (p *passer) capabilityDigests() []meshwants.AssignmentRevisionCapabilitiesItem { - if p.reads == nil { - return nil - } + records := p.w.records() known := p.w.knownNodes() slices.Sort(known) var out []meshwants.AssignmentRevisionCapabilitiesItem for _, node := range known { - if !p.reads.Known(node) { + read := p.reads != nil && p.reads.Known(node) + rec, recorded := records[node] + if !read && !recorded { continue } - reads := slices.Clone(p.reads.Reads([]string{node})[node]) - slices.Sort(reads) - raw, _ := json.Marshal(reads) + var reads []string + if read { + reads = slices.Clone(p.reads.Reads([]string{node})[node]) + slices.Sort(reads) + } + item := meshwants.AssignmentRevisionCapabilitiesItem{Node: node, Readings: slices.Clone(rec.RunnerCapabilities), + Fingerprint: rec.RunnerFingerprint} + slices.Sort(item.Readings) + raw, _ := json.Marshal(struct { + Reads []string `json:"reads"` + Readings []string `json:"readings"` + Fingerprint string `json:"fingerprint"` + }{reads, item.Readings, item.Fingerprint}) sum := sha256.Sum256(raw) - out = append(out, meshwants.AssignmentRevisionCapabilitiesItem{Node: node, Digest: "sha256:" + hex.EncodeToString(sum[:])}) + item.Digest = "sha256:" + hex.EncodeToString(sum[:]) + out = append(out, item) } return out } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept_test.go index ad7637be..e6c69ecf 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept_test.go @@ -3,10 +3,12 @@ package main import ( "context" "encoding/json" + "slices" "strings" "testing" "time" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" @@ -37,6 +39,14 @@ func TestTheWantsAreSaidWantedFromTheKeptAssignmentGraphRevision(t *testing.T) { return nil } placeOn(t, p.w, "ace", "web") + // Design 46 approval mark §6: a node's reading keeps the fingerprint of the runner that announced it, as its record says. + rec, _ := json.Marshal(nodeengine.HaveByNodeNodeValue{V: 1, Record: &nodeengine.NodeRecord{V: 1, Node: "ace", + RunnerCapabilities: []string{"bus-state-calls", "approval-mark/1"}, RunnerFingerprint: "sha256:runner"}}) + p.w.put(buckets.Have, "ace.node", rec) + if c := p.capabilityDigests(); len(c) != 1 || c[0].Node != "ace" || c[0].Fingerprint != "sha256:runner" || + !slices.Equal(c[0].Readings, []string{"approval-mark/1", "bus-state-calls"}) || !strings.HasPrefix(c[0].Digest, "sha256:") { + t.Errorf("the capability readings are %+v", c) + } half := mcc.Half{Revision: meshcatalog.CatalogRevision{Branch: trunkBranch, Kind: "trunk", Revision: 4, Source: 9, Entries: []meshcatalog.EntryRef{{Module: "web", Digest: "sha256:" + strings.Repeat("a", 64)}}}, Entries: map[string]meshcatalog.CatalogEntry{"web": {Module: "web", Fp: fpTest}}} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/compute.go b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go index 2825d6d5..2884df92 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/compute.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go @@ -202,5 +202,8 @@ func EntryDigest(e meshwants.AssignmentEntry) string { // Same says two revisions list the same entries computed from the same inputs, so the second is no new revision. func Same(a, b meshwants.AssignmentRevision) bool { return slices.Equal(a.Entries, b.Entries) && slices.Equal(a.Catalogs, b.Catalogs) && a.Placements == b.Placements && - slices.Equal(a.Capabilities, b.Capabilities) + slices.EqualFunc(a.Capabilities, b.Capabilities, func(x, y meshwants.AssignmentRevisionCapabilitiesItem) bool { + // A reading's digest is over all of it: its readings and its runner's fingerprint among it. + return x.Node == y.Node && x.Digest == y.Digest + }) } diff --git a/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go b/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go index 23b263fc..21a2276b 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go +++ b/modules/mesh-delivery-contracts/go/seats/meshwants/contract.go @@ -481,9 +481,13 @@ type AssignmentEntry struct { // AssignmentRevisionCapabilitiesItem is a type of the contract. type AssignmentRevisionCapabilitiesItem struct { - // the digest of the node's capability reading + // the digest of the node's capability reading: the engine's manifest reads, its tool runner's readings and that runner's fingerprint Digest string `json:"digest"` - Node string `json:"node"` + // the fingerprint of the tool runner that announced the readings; empty when none is known + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + // the capabilities the node's tool runner states of the calls it answers, as the node's record carries them (design 46 approval mark 6) + Readings []string `json:"readings,omitempty"` } // AssignmentRevision is an assignment graph revision: the list of its entries' digests and the inputs it was computed from, so it can be computed again and compared. diff --git a/modules/mesh-delivery-contracts/go/seats/meshwants/contract_test.go b/modules/mesh-delivery-contracts/go/seats/meshwants/contract_test.go index 32f26b23..21bafdcd 100644 --- a/modules/mesh-delivery-contracts/go/seats/meshwants/contract_test.go +++ b/modules/mesh-delivery-contracts/go/seats/meshwants/contract_test.go @@ -117,7 +117,7 @@ func TestFixtures(t *testing.T) { decode: func(b []byte) (any, error) { var v RevisionInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "revision asks a candidate's", subject: "", given: "{\"group\":\"feat/47-G\",\"heads\":\"0123456789ab\"}", wire: "{\"group\":\"feat/47-G\",\"heads\":\"0123456789ab\"}", decode: func(b []byte) (any, error) { var v RevisionInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, - {name: "revision answers one revision naming its inputs", subject: "", given: "{\"revision\":{\"at\":\"2026-11-09T19:00:00Z\",\"capabilities\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"node\":\"n1\"}],\"catalogs\":[{\"branch\":\"main\",\"revision\":12,\"source\":3589}],\"cause\":\"catalog-revision\",\"entries\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"module\":\"xclip\",\"node\":\"n1\"}],\"kind\":\"trunk\",\"placements\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"revision\":40,\"v\":1}}", wire: "{\"revision\":{\"at\":\"2026-11-09T19:00:00Z\",\"capabilities\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"node\":\"n1\"}],\"catalogs\":[{\"branch\":\"main\",\"revision\":12,\"source\":3589}],\"cause\":\"catalog-revision\",\"entries\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"module\":\"xclip\",\"node\":\"n1\"}],\"kind\":\"trunk\",\"placements\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"revision\":40,\"v\":1}}", + {name: "revision answers one revision naming its inputs", subject: "", given: "{\"revision\":{\"at\":\"2026-11-09T19:00:00Z\",\"capabilities\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"fingerprint\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"node\":\"n1\",\"readings\":[\"approval-mark/1\",\"bus-state-calls\"]}],\"catalogs\":[{\"branch\":\"main\",\"revision\":12,\"source\":3589}],\"cause\":\"catalog-revision\",\"entries\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"module\":\"xclip\",\"node\":\"n1\"}],\"kind\":\"trunk\",\"placements\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"revision\":40,\"v\":1}}", wire: "{\"revision\":{\"at\":\"2026-11-09T19:00:00Z\",\"capabilities\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"fingerprint\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"node\":\"n1\",\"readings\":[\"approval-mark/1\",\"bus-state-calls\"]}],\"catalogs\":[{\"branch\":\"main\",\"revision\":12,\"source\":3589}],\"cause\":\"catalog-revision\",\"entries\":[{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"module\":\"xclip\",\"node\":\"n1\"}],\"kind\":\"trunk\",\"placements\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"revision\":40,\"v\":1}}", decode: func(b []byte) (any, error) { var v RevisionAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, {name: "entry asks by node and module in a revision", subject: "", given: "{\"module\":\"xclip\",\"node\":\"n1\",\"revision\":40}", wire: "{\"module\":\"xclip\",\"node\":\"n1\",\"revision\":40}", decode: func(b []byte) (any, error) { var v EntryInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil}, diff --git a/modules/mesh-delivery-contracts/seats/mesh-wants/fixtures/verbs.json b/modules/mesh-delivery-contracts/seats/mesh-wants/fixtures/verbs.json index 3a50a8c5..becd27ca 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-wants/fixtures/verbs.json +++ b/modules/mesh-delivery-contracts/seats/mesh-wants/fixtures/verbs.json @@ -31,7 +31,12 @@ "capabilities": [ { "node": "n1", - "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "readings": [ + "approval-mark/1", + "bus-state-calls" + ], + "fingerprint": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ], "cause": "catalog-revision", diff --git a/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json b/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json index e0564718..827ae6a6 100644 --- a/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json +++ b/modules/mesh-delivery-contracts/seats/mesh-wants/seat.json @@ -899,7 +899,18 @@ }, "digest": { "type": "string", - "description": "the digest of the node's capability reading" + "description": "the digest of the node's capability reading: the engine's manifest reads, its tool runner's readings and that runner's fingerprint" + }, + "readings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the capabilities the node's tool runner states of the calls it answers, as the node's record carries them (design 46 approval mark 6)" + }, + "fingerprint": { + "type": "string", + "description": "the fingerprint of the tool runner that announced the readings; empty when none is known" } }, "required": [ diff --git a/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts b/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts index ee5a58f3..f5de211d 100644 --- a/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts +++ b/modules/mesh-delivery-contracts/ts/seats/meshwants/index.d.ts @@ -150,9 +150,13 @@ export interface AssignmentEntry { } export interface AssignmentRevisionCapabilitiesItem { - /** the digest of the node's capability reading */ + /** the digest of the node's capability reading: the engine's manifest reads, its tool runner's readings and that runner's fingerprint */ "digest": string; + /** the fingerprint of the tool runner that announced the readings; empty when none is known */ + "fingerprint"?: string; "node": string; + /** the capabilities the node's tool runner states of the calls it answers, as the node's record carries them (design 46 approval mark 6) */ + "readings"?: string[]; } /** an assignment graph revision: the list of its entries' digests and the inputs it was computed from, so it can be computed again and compared */ From 7bc4836eb0cb5a7d19f7c56de4445c73d8f7b714 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:17:59 +0100 Subject: [PATCH 3/4] AM-D3: a runtime whose tool runner does not honour the mark is denied every marked subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grants part follows the assignment half, reads each node's reading and the runner its node-engine says running, and where the runner does not state approval-mark/1 or the reading is not the running runner's, composes the subscribe deny of every marked tool and verb the runtime carries, named in grants_user_list's withheld and answered by the seat's verb withheld. Builds: design 46 7 §6 (the deny's subjects, its cases, withheld in grants_user_list and the verb withheld), design 47 G §195, §309, lead answer A-203 (the assignment half followed by revision and assignment-revision; withheld's forms, keyed by node in the user list's state as the lead confirmed) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- modules/mesh-grants/cmd/mesh-grants/main.go | 9 ++ modules/mesh-grants/cmd/mesh-grants/tools.go | 7 ++ modules/mesh-grants/grants_test.go | 57 ++++++++++++- .../internal/buscompose/buscompose_test.go | 59 +++++++++++++ .../mesh-grants/internal/buscompose/nats.go | 19 ++++- .../mesh-grants/internal/buscompose/users.go | 13 ++- .../internal/buscompose/withheld.go | 85 +++++++++++++++++++ .../mesh-grants/internal/grants/catalog.go | 54 +++++++++++- .../mesh-grants/internal/grants/firstviews.go | 2 + modules/mesh-grants/internal/grants/inputs.go | 12 ++- .../mesh-grants/internal/grants/keeploaded.go | 2 + modules/mesh-grants/internal/grants/part.go | 55 +++++++++++- .../mesh-grants/internal/grants/records.go | 28 +++++- modules/mesh-grants/module.json | 5 +- 14 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 modules/mesh-grants/internal/buscompose/withheld.go diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index a98c8d64..d80ad249 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -38,7 +38,9 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/membership" @@ -168,6 +170,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro branches.Changed = func(string, string, json.RawMessage, bool, time.Time) { f.Wake() } branches.Entries.Changed = func(meshcatalogclient.EntryRow) { f.Wake() } fv.Catalog = &grants.CatalogOf{Branches: branches, Context: ctx, Wake: f.Wake} + // The assignment half (design 46 approval mark §6, design 47 G §195): its first view by mesh-wants' revision, then its + // assignment-revision, on the same reader; each revision held composes again, since a node's reading may have changed. + assignment, _, err := meshwantsclient.WireAssignmentProcess(reader, conn.Call, func(meshwants.AssignmentRevision) { f.Wake() }) + if err != nil { + return err + } + fv.Assignment = assignment f.Part = &grants.Part{Read: fv, Call: conn, Store: ownStore{conn}, Emit: emitting{conn}, Views: fv, FirstViewContext: ctx, Create: creating{ctx: ctx, js: js}, Consumers: creating{ctx: ctx, js: js}, Objects: sealedObjects{ctx: ctx, js: js}, Purging: ownPurging{ctx: ctx, js: js, module: account.Module}, Logf: logger.Printf, diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 0ad31793..f20ecdd6 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -275,6 +275,13 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.GrantsFor(meshgrants.GrantsForInput{Branch: grants.Answer(a, "branch"), Run: grants.Answer(a, "run"), Source: grants.Answer(a, "source")}) }}, + {Name: meshgrants.Seat + "." + meshgrants.VerbWithheld, + Description: "The marked subjects withheld from a node's runtime, by module, and why: where its tool runner does not state " + + "approval-mark/1 or its reading is not the running runner's (design 46 approval mark §6). Read-only.", + Input: schema(map[string]string{"node": "the node"}, "node"), + Run: func(a map[string]any) (any, error) { + return p.WithheldOn(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"), diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 98086c00..69005946 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -37,6 +37,7 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/keys" @@ -621,9 +622,10 @@ func TestGrants(t *testing.T) { if err != nil { t.Fatalf("mesh-sdk cannot decode the manifest: %v", err) } - // The part claims the seat mesh-grants, which serves `grants-for` (design 47 G §401, §441). - if m.Module != "mesh-grants" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-grants" || !slices.Equal(m.Claims[0].Serves, []string{"grants-for"}) { - t.Errorf("module %q claims %v: the part claims the seat mesh-grants alone, serving grants-for", m.Module, m.Claims) + // The part claims the seat mesh-grants, which serves `grants-for` (design 47 G §401, §441) and `withheld` (design 46 + // approval mark §6). + if m.Module != "mesh-grants" || len(m.Claims) != 1 || m.Claims[0].Name != "mesh-grants" || !slices.Equal(m.Claims[0].Serves, []string{"grants-for", "withheld"}) { + t.Errorf("module %q claims %v: the part claims the seat mesh-grants alone, serving grants-for and withheld", m.Module, m.Claims) } for _, row := range []struct { what string @@ -1265,6 +1267,45 @@ func TestGrants(t *testing.T) { } }) + // AM-D3 (design 46 approval mark §6, lead answer A-203): the part reads each node's reading from the assignment half it + // holds, none held keeps the list in force; the subjects withheld are kept by node in the user list's state and the + // seat's verb withheld answers them by module. + t.Run("the withheld subjects are kept by node and answered by the verb withheld", func(t *testing.T) { + if _, held := (&grants.Views{Assignment: heldHalf{}}).Readings(); held { + t.Errorf("an assignment half with no revision is said held") + } + half := heldHalf{rev: &meshwants.AssignmentRevision{Capabilities: []meshwants.AssignmentRevisionCapabilitiesItem{ + {Node: "a", Digest: "sha256:x", Readings: []string{"approval-mark/1"}, Fingerprint: "sha256:runner"}}}} + if r, held := (&grants.Views{Assignment: half}).Readings(); !held || r["a"].Fingerprint != "sha256:runner" || + !slices.Equal(r["a"].Capabilities, []string{"approval-mark/1"}) { + t.Errorf("the readings are %+v (%v)", r, held) + } + shell := buscompose.Declared{Module: "shell", Serves: []string{"execute"}, Marked: []string{"execute"}} + recs := buscompose.Records{Nodes: []string{"a", "b"}, Assigned: map[string][]buscompose.Declared{ + "a": {{Module: "node-tools", ToolRunner: true}, shell}, "b": {{Module: "node-tools", ToolRunner: true}, shell}}, + Readings: map[string]buscompose.RunnerReading{"b": {Capabilities: []string{"approval-mark/1"}, Fingerprint: "sha256:runner"}}, + Running: map[string]string{"a.node-tools": "sha256:runner", "b.node-tools": "sha256:runner"}} + byNode := grants.WithheldByNode(recs) + if len(byNode) != 1 || len(byNode["a"]) != 3 || byNode["a"][0].Why != buscompose.WithheldWhy { + t.Fatalf("withheld by node = %+v", byNode) + } + p := partOn(newFakeBus()) + if _, err := p.WithheldOn("a"); err == nil { + t.Errorf("withheld before any composition answered no error") + } + raw, _ := json.Marshal(grants.UserListState{V: 1, Withheld: byNode}) + if _, err := p.Store.Put(grants.StateUserList, grants.KeyList, raw); err != nil { + t.Fatal(err) + } + got, err := p.WithheldOn("a") + if err != nil || got.Node != "a" || len(got.Modules) != 1 || got.Modules[0].Module != "shell" || len(got.Modules[0].Subjects) != 3 || + got.Modules[0].Subjects[0].Subject != "mesh.mod.shell.call.execute.>" { + t.Errorf("withheld on a = %+v (%v)", got, err) + } + if got, err := p.WithheldOn("b"); err != nil || len(got.Modules) != 0 { + t.Errorf("withheld on b = %+v (%v)", got, err) + } + }) t.Run("compare", func(t *testing.T) { b := fixture() shadow := shadowOf(t, b) @@ -5156,3 +5197,13 @@ func TestGrantsFor(t *testing.T) { t.Error("grants-for with neither a source nor a run answered") } } + +// heldHalf is an assignment half holding rev, or none. +type heldHalf struct{ rev *meshwants.AssignmentRevision } + +func (h heldHalf) Revision() (meshwants.AssignmentRevision, bool) { + if h.rev == nil { + return meshwants.AssignmentRevision{}, false + } + return *h.rev, true +} diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 8787267c..a708010c 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -63,6 +63,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Run("a process serving a verb is granted its calls and chunk subjects, and the reverse goes to an account naming it", chunkSubjectsFollowTheAnswerer) t.Run("the tool runner's runtime hears every event subject and nothing wider", theToolRunnerHearsEveryEvent) t.Run("a per-machine state reaches its machine's keys below it and no other machine's", aPerMachineStateReachesItsMachinesKeys) + t.Run("a runtime whose tool runner does not honour the mark is denied every marked subject", aRuntimeWithholdsTheMarks) // 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"), " ") @@ -1564,3 +1565,61 @@ func aPerMachineStateReachesItsMachinesKeys(t *testing.T) { t.Errorf("a's membership issues %+v", issued) } } + +// AM-D3 (design 46 approval mark §6): a runtime whose node's reading lacks approval-mark/1, has no reading, or a reading of +// another runner or of none is denied to subscribe every marked subject of what it carries, a marked tool's three forms, a +// marked verb of a seat held per node with its . form and of a seat held once, each named with why; a reading with +// the capability of the running runner composes none. +func aRuntimeWithholdsTheMarks(t *testing.T) { + shell := Declared{Module: "shell", Serves: []string{"execute", "read"}, Marked: []string{"execute"}, + Holds: []Seat{{Name: "node-shell", Scope: "node", Serves: []string{"run", "look"}, Marked: []string{"run"}}, + {Name: "forge", Scope: "mesh", Serves: []string{"merge"}, Marked: []string{"merge"}}}} + withheld := []string{"mesh.mod.shell.call.execute.>", "mesh.mod.shell.tool.execute", "mesh.mod.shell.tool.execute.*", + "mesh.seat.forge.call.merge.>", "mesh.seat.forge.tool.merge", "mesh.seat.node-shell.call.run.a.>", + "mesh.seat.node-shell.tool.run", "mesh.seat.node-shell.tool.run.a"} + honours := RunnerReading{Capabilities: []string{"bus-state-calls", ApprovalMark}, Fingerprint: "sha256:runner"} + for _, c := range []struct { + name string + readings map[string]RunnerReading + deny bool + }{ + {"a reading lacking the capability", map[string]RunnerReading{"a": {Capabilities: []string{"bus-state-calls"}, Fingerprint: "sha256:runner"}}, true}, + {"no reading", map[string]RunnerReading{}, true}, + {"a reading of another runner", map[string]RunnerReading{"a": {Capabilities: honours.Capabilities, Fingerprint: "sha256:older"}}, true}, + {"a reading of no runner", map[string]RunnerReading{"a": {Capabilities: honours.Capabilities}}, true}, + {"a reading with the capability of the running runner", map[string]RunnerReading{"a": honours}, false}, + } { + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {{Module: "node-tools", ToolRunner: true}, shell}}, + Readings: c.readings, Running: map[string]string{"a.node-tools": "sha256:runner"}}) + if err != nil { + t.Fatal(err) + } + i := slices.IndexFunc(users, func(p Principal) bool { return p.Kind == KindNodeTools }) + if i < 0 { + t.Fatalf("%s: no runtime principal", c.name) + } + perms, err := PermissionsFor(users[i]) + if err != nil { + t.Fatal(err) + } + var named []string + for _, w := range MarksWithheld(users[i]) { + if w.Module != "shell" || w.Why != WithheldWhy { + t.Errorf("%s: withheld %+v", c.name, w) + } + named = append(named, w.Subject) + } + want := []string(nil) + if c.deny { + want = withheld + } + if !slices.Equal(perms.SubscribeDeny, want) || !slices.Equal(named, want) { + t.Errorf("%s: denied %v, named %v, want %v", c.name, perms.SubscribeDeny, named, want) + } + // The deny outranks the allow of the module's tools; an unmarked tool stays served. + if MaySubscribe(perms, "mesh.mod.shell.tool.execute") == c.deny || !MaySubscribe(perms, "mesh.mod.shell.tool.read") { + t.Errorf("%s: execute subscribable %v, read %v", c.name, MaySubscribe(perms, "mesh.mod.shell.tool.execute"), + MaySubscribe(perms, "mesh.mod.shell.tool.read")) + } + } +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index d23950d9..3cb9f387 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -89,6 +89,9 @@ type Seat struct { // holds. Kinded bool Kind string + // Marked are the verbs of the seat that need the operator's approval, as its definition marks them and the holder's + // `needs-approval` adds (design 46 approval mark §2, §6). + Marked []string // ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3). ByCaller []string // Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3). @@ -186,6 +189,9 @@ type Principal struct { // seat's verbs on this node — derived from the same declarations the modules' own principals // are, so the runtime can serve nothing a module could not have served for itself. Carries []Declared + // Withholds says this node's tool runner does not honour the approval mark (design 46 approval mark §6), for a + // KindNodeTools principal: every marked subject of what it carries is denied it (MarksWithheld). + Withholds bool // Invokes are the tools this principal may call, as `.`; a single `*` is every // tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so @@ -1312,10 +1318,17 @@ func permissionsFor(p Principal) (Permissions, error) { } } } + // And the marked subjects of what a runtime carries, where its tool runner does not honour the mark (design 46 approval + // mark §6): a subscribe deny, which outranks the allow of each module's tools. + var subDeny []string + for _, w := range MarksWithheld(p) { + subDeny = append(subDeny, w.Subject) + } return Permissions{ - Publish: pub, - PublishDeny: deny, - Subscribe: sub, + Publish: pub, + PublishDeny: deny, + Subscribe: sub, + SubscribeDeny: unique(subDeny), // A module answers what it was asked — a tool call reaches it on its own namespace, so the // authority is bounded by having been asked. A node is asked one // thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index c861f9d2..a0ce8f4d 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -28,6 +28,9 @@ import ( // Declared is one module on one node, as composing its authority needs it. type Declared struct { Module string + // Marked are the tools it serves that need the operator's approval, as its `needs-approval` names them (design 46 + // approval mark §1, §6); a marked seat verb is its Holds' Marked. + Marked []string // ToolRunner says the module is the node's tool runner, as its manifest says (`tool-runner`, ADR 0601 §4). ToolRunner bool // Objects are its own object buckets, and ReadsObjects the bus names of the object buckets of others its reads name @@ -151,6 +154,10 @@ type ProcessPrincipal struct { // Records is what composing a user list needs to know about the mesh, and nothing more. type Records struct { + // Readings are each node's capability reading, from the assignment half, by node; Running the fingerprint each + // node-engine last said running, by `.` (design 46 approval mark §6, design 47 G §309). + Readings map[string]RunnerReading + Running map[string]string // Kept is every seat event kept in a stream of its own (kept.go): the grants part makes each stream, so its principal is // granted the create of each by name. Kept []KeptEvent @@ -354,7 +361,11 @@ func Users(r Records) ([]Principal, error) { carried = append(carried, d) } } - out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: runner, Carries: carried}) + // Its node's reading and the runner the node-engine says running decide whether the marks are withheld (design 46 + // approval mark §6). + reading, read := r.Readings[node] + out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: runner, Carries: carried, + Withholds: Withholds(reading, read, r.Running[node+"."+runner])}) } } // A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an diff --git a/modules/mesh-grants/internal/buscompose/withheld.go b/modules/mesh-grants/internal/buscompose/withheld.go new file mode 100644 index 00000000..1b233247 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/withheld.go @@ -0,0 +1,85 @@ +package buscompose + +import ( + "slices" + "sort" +) + +// **A machine whose tool runner does not honour the mark serves no marked tool or verb** (design 46 approval mark §6): on +// the account of a node's runtime whose readings lack approval-mark/1, or are not the running runner's, every marked +// subject of every module it carries is denied to subscribe, which outranks its allow of `mesh.mod..tool.>`, and +// each is named with why. + +// ApprovalMark is the capability a tool runner that honours the mark states among its call capabilities (design 46 +// approval mark §6). +const ApprovalMark = "approval-mark/1" + +// WithheldWhy is why a marked subject is withheld (design 46 approval mark §6). +const WithheldWhy = "its tool runner does not state " + ApprovalMark + +// WithheldSubject is one marked subject withheld from a runtime's account: the module it is of, the subject and why +// (lead answer A-203). +type WithheldSubject struct { + Module string `json:"module"` + Subject string `json:"subject"` + Why string `json:"why"` +} + +// RunnerReading is a node's capability reading as the assignment half carries it: what its tool runner states of the +// calls it answers, and the fingerprint of the runner that announced it (design 46 approval mark §6). +type RunnerReading struct { + Capabilities []string + Fingerprint string +} + +// Withholds says whether a node's runtime withholds the marked subjects: where the node has no reading, the reading +// lacks ApprovalMark, or its fingerprint is absent or differs from the one the node-engine last said running (no time is +// compared, failing closed). +func Withholds(reading RunnerReading, read bool, running string) bool { + return !read || !slices.Contains(reading.Capabilities, ApprovalMark) || reading.Fingerprint == "" || + reading.Fingerprint != running +} + +// MarkedSubjects are the subjects of a module's marked tools and of the marked verbs of the seats it holds, on node: a +// tool's `mesh.mod..tool.`, the same followed by `.*`, and the caller-named `mesh.mod..call..>`; +// a verb of a seat held per node `mesh.seat..tool.`, its `.` form and `mesh.seat..call...>`; +// a verb of a seat held once for the mesh `mesh.seat..tool.` and `mesh.seat..call..>`. +func MarkedSubjects(node string, d Declared) []string { + var out []string + for _, t := range d.Marked { + out = append(out, "mesh.mod."+d.Module+".tool."+t, "mesh.mod."+d.Module+".tool."+t+".*", + "mesh.mod."+d.Module+"."+CallKind+"."+t+".>") + } + for _, s := range d.Holds { + for _, v := range s.Marked { + if s.Scope == "node" { + out = append(out, "mesh.seat."+s.Name+".tool."+v, "mesh.seat."+s.Name+".tool."+v+"."+node, + "mesh.seat."+s.Name+"."+CallKind+"."+v+"."+node+".>") + } else { + out = append(out, "mesh.seat."+s.Name+".tool."+v, "mesh.seat."+s.Name+"."+CallKind+"."+v+".>") + } + } + } + return out +} + +// MarksWithheld are the subjects withheld from a principal's account, each with its module and why: for a node's runtime that +// withholds, every marked subject of every module it carries; none for any other. +func MarksWithheld(p Principal) []WithheldSubject { + if p.Kind != KindNodeTools || !p.Withholds { + return nil + } + var out []WithheldSubject + for _, d := range p.Carries { + for _, s := range MarkedSubjects(p.Node, d) { + out = append(out, WithheldSubject{Module: d.Module, Subject: s, Why: WithheldWhy}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Module != out[j].Module { + return out[i].Module < out[j].Module + } + return out[i].Subject < out[j].Subject + }) + return slices.CompactFunc(out, func(a, b WithheldSubject) bool { return a == b }) +} diff --git a/modules/mesh-grants/internal/grants/catalog.go b/modules/mesh-grants/internal/grants/catalog.go index 2852fb10..a3527e09 100644 --- a/modules/mesh-grants/internal/grants/catalog.go +++ b/modules/mesh-grants/internal/grants/catalog.go @@ -8,10 +8,14 @@ import ( "sync" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/views" + + "mesh-grants/internal/buscompose" ) // **The grants part reads the catalog half** (design 47 G §614, §444, §684, A-5, A-52): through meshcatalogclient's branch follower @@ -24,14 +28,16 @@ import ( // long form with its field `branches`, §558, is not read by mesh-sdk's manifest reader yet.) func catalogHalfVerbs() []string { return []string{"seat:" + meshcatalog.Seat + "." + meshcatalog.VerbRevision, "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbRegistrations, - meshcatalogclient.SourceVerb, meshcatalogclient.EntryVerb} + meshcatalogclient.SourceVerb, meshcatalogclient.EntryVerb, + // And the assignment half's revision (design 46 approval mark §6, design 47 G §195). + meshwantsclient.RevisionVerb} } // catalogHalfEvents are the events every such part names in its `consumes`, as `.`: the catalog's revision and // registration-changed, and the gate's source-moved. func catalogHalfEvents() []string { return []string{meshcatalog.Seat + "." + meshcatalog.EventCatalogRevision, meshcatalog.Seat + "." + meshcatalog.EventRegistrationChanged, - meshdelivery.Seat + "." + meshdelivery.EventSourceMoved} + meshdelivery.Seat + "." + meshdelivery.EventSourceMoved, meshwants.Seat + "." + meshwants.EventAssignmentRevision} } // CatalogHalf is what the part reads of the catalog half: the entry by its fingerprint, the branches whose first view is not in, @@ -200,3 +206,47 @@ func catalogWaiting(r Reader) string { slices.Sort(w) return fmt.Sprintf("the first views of the catalog half are not in (%v), so each account is composed from its want's entry by fingerprint and the list in force is kept", w) } + +// **The grants part reads the assignment half** (design 46 approval mark §6, design 47 G §195, lead answer A-203): the view of +// meshwantsclient's Assignment, a first view by mesh-wants' `revision`, then its `assignment-revision`, adopted on the part's +// views reader; from it each node's capability reading and the runner that announced it. + +// AssignmentHalf is what the part reads of the assignment half: the revision it holds, false while it holds none. +type AssignmentHalf interface { + Revision() (meshwants.AssignmentRevision, bool) +} + +// readingsReader is what a Reader that holds the assignment half answers (Views does). +type readingsReader interface { + Readings() (map[string]buscompose.RunnerReading, bool) +} + +// Readings are each node's capability reading of the assignment half the part holds, by node; false where it holds none, +// and true with none where the reader follows no assignment half (a tool's reads nothing of it). +func (v *Views) Readings() (map[string]buscompose.RunnerReading, bool) { + if v == nil || v.Assignment == nil { + return nil, true + } + rev, ok := v.Assignment.Revision() + if !ok { + return nil, false + } + out := map[string]buscompose.RunnerReading{} + for _, c := range rev.Capabilities { + out[c.Node] = buscompose.RunnerReading{Capabilities: c.Readings, Fingerprint: c.Fingerprint} + } + return out, true +} + +// readingsOf is each node's reading through r, and why the pass composes before the assignment half's first view. +func readingsOf(r Reader) (map[string]buscompose.RunnerReading, string) { + rr, ok := r.(readingsReader) + if !ok { + return nil, "" + } + readings, held := rr.Readings() + if !held { + return nil, "the first view of the assignment half is not in, so no node's capability reading is known and the list in force is kept" + } + return readings, "" +} diff --git a/modules/mesh-grants/internal/grants/firstviews.go b/modules/mesh-grants/internal/grants/firstviews.go index 8ed61c88..6bb851bf 100644 --- a/modules/mesh-grants/internal/grants/firstviews.go +++ b/modules/mesh-grants/internal/grants/firstviews.go @@ -81,6 +81,8 @@ type Views struct { Watching Watcher // Catalog is the part's catalog half (catalog.go): nil in a tool, which reads none. Catalog CatalogHalf + // Assignment is the part's assignment half (catalog.go): nil in a tool, which reads none. + Assignment AssignmentHalf said *untilSaid } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 1db94a80..2de53e88 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -158,7 +158,10 @@ type Inputs struct { Ran map[string]catalogue.Manifest // Heard is what each node-engine's `running` said of each `.`: a fingerprint, or stopped (design 47 G §404, // §459); a key absent was not heard since the part's consumer began. RanByFingerprint is the manifest of each fingerprint heard. - Heard map[string]catalogue.Heard + Heard map[string]catalogue.Heard + // Readings are each node's capability reading as the assignment half the part holds carries it, by node (design 46 + // approval mark §6); nil while the part holds no assignment half. + Readings map[string]buscompose.RunnerReading RanByFingerprint map[string]catalogue.Manifest // Had is the manifest of the build each machine runs now (its have's fingerprint, the builder's build of it), by // `.`; the want's Running may already be a newer build. A membership is composed from both and the trunk's. @@ -212,6 +215,9 @@ type Inputs struct { // CatalogUnread says why this pass composed before the first views of the catalog half were in (design 47 G §684); empty once they are. // As WantsUnread, such a pass retires nothing (ReadFailures) and only adds: the list in force is kept. CatalogUnread string + // AssignmentUnread says why this pass composed before the first view of the assignment half was in (design 46 approval + // mark §6, design 47 G §195): no node's capability reading is known, so the list in force is kept; empty once it is. + AssignmentUnread string // Held is, by module, the manifest a held module's running build was judged on (its have's fingerprint, the builder's // build of it): what the seats it defines, the streams others hear from it and its reads are composed from while its // trunk manifest is refused or waits, so no other principal loses a grant to it (ADR 0601 clause 3). @@ -366,6 +372,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)} in.SeatListUnread = gate != nil && !gate.MayAct() in.CatalogUnread = catalogWaiting(r) + in.Readings, in.AssignmentUnread = readingsOf(r) in.Defined = definedEventsOf(nil) // The definitions the reader holds, the bootstrapped ones before the seat list is read (ADR 0636 §2), name the events kept // on a stream of their own, so a durable made at the first pass already hears them there and never on EVENTS (a held @@ -764,6 +771,9 @@ func (in Inputs) ReadFailures() []string { if in.CatalogUnread != "" { out = append(out, "catalog half: "+in.CatalogUnread) } + if in.AssignmentUnread != "" { + out = append(out, "assignment half: "+in.AssignmentUnread) + } for _, g := range in.Refused { out = append(out, g.Input+": "+g.Why) } diff --git a/modules/mesh-grants/internal/grants/keeploaded.go b/modules/mesh-grants/internal/grants/keeploaded.go index 5cadd5fd..27812cdb 100644 --- a/modules/mesh-grants/internal/grants/keeploaded.go +++ b/modules/mesh-grants/internal/grants/keeploaded.go @@ -25,6 +25,8 @@ func (in Inputs) keepsLoaded() string { return in.WantsUnread case in.CatalogUnread != "": return in.CatalogUnread + case in.AssignmentUnread != "": + return in.AssignmentUnread case in.SeatListUnread: return "the seat list is unread, so only the bootstrapped seats compose" } diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 51ea62c9..11c789f2 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" "slices" @@ -15,6 +16,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" "git.novox.be/novox/mesh-sdk/go/membership" @@ -83,7 +85,10 @@ type UserListState struct { Credentials Credentials `json:"credentials"` BusNode string `json:"bus_node,omitempty"` Gaps []Gap `json:"gaps"` - At time.Time `json:"at"` + // Withheld are, by node, the marked subjects withheld from its runtime's account, each with its module and why (design + // 46 approval mark §6, lead answer A-203): what grants_user_list and the seat's verb withheld say. + Withheld map[string][]buscompose.WithheldSubject `json:"withheld,omitempty"` + At time.Time `json:"at"` } func (s UserListState) inputs() string { @@ -328,6 +333,7 @@ func (p *Part) Compose() Result { st.LeftOut = []Gap{} } users, refused, err := ComposeEach(listRecs) + st.Withheld = WithheldByNode(listRecs) if err != nil { res.Errors = append(res.Errors, "the shadow list could not be composed: "+err.Error()) } @@ -642,3 +648,50 @@ func (p *Part) inputs() Inputs { p.readRan(&in) return in } + +// WithheldByNode is, by node, the marked subjects withheld from its runtime's account (design 46 approval mark §6); nil when +// none is. A principal that cannot be derived withholds nothing here: the list is not composed then either. Pure. +func WithheldByNode(r buscompose.Records) map[string][]buscompose.WithheldSubject { + principals, err := buscompose.Users(r) + if err != nil { + return nil + } + var out map[string][]buscompose.WithheldSubject + for _, p := range principals { + if w := buscompose.MarksWithheld(p); len(w) > 0 { + if out == nil { + out = map[string][]buscompose.WithheldSubject{} + } + out[p.Node] = append(out[p.Node], w...) + } + } + return out +} + +// WithheldOn answers the seat mesh-grants' verb withheld (design 46 approval mark §6, lead answer A-203): the marked subjects +// withheld from node's runtime, grouped by module, from the user list the part composed last. +func (p *Part) WithheldOn(node string) (meshgrants.Withheld, error) { + if node == "" { + return meshgrants.Withheld{}, errors.New("withheld names a node") + } + out := meshgrants.Withheld{Node: node, Modules: []meshgrants.WithheldModulesItem{}} + raw, _, found, err := p.Store.Get(StateUserList, KeyList) + if err != nil { + return out, fmt.Errorf("its own user list cannot be read: %w", err) + } + if !found { + return out, errors.New("nothing composed yet: grants_compose_now composes it") + } + var st UserListState + if err := json.Unmarshal(raw, &st); err != nil { + return out, fmt.Errorf("its own user list cannot be read: %w", err) + } + for _, w := range st.Withheld[node] { + if n := len(out.Modules); n == 0 || out.Modules[n-1].Module != w.Module { + out.Modules = append(out.Modules, meshgrants.WithheldModulesItem{Module: w.Module, Subjects: []meshgrants.WithheldSubject{}}) + } + m := &out.Modules[len(out.Modules)-1] + m.Subjects = append(m.Subjects, meshgrants.WithheldSubject{Subject: w.Subject, Why: w.Why}) + } + return out, nil +} diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 2fa9228b..af201b26 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -47,7 +47,14 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { // And each node's logins (ADR 0583 §1): both while it rotates. NodeLogins: in.NodeLogins, Kept: in.Kept, Chunked: in.Chunked, EngineEvents: in.EventSubjects[buscompose.EngineSeat], // What the bus module's process may define: every manifest's bus state, and what it said it created (DLV-G2). - BusState: map[string][]catalogue.BusStateEntry{}, Bound: in.Bound} + BusState: map[string][]catalogue.BusStateEntry{}, Bound: in.Bound, + // Each node's capability reading and what each node-engine said running (design 46 approval mark §6). + Readings: in.Readings, Running: map[string]string{}} + for key, h := range in.Heard { + if !h.Stopped { + out.Running[key] = h.Fingerprint + } + } for module, m := range in.Manifests { if len(m.BusState) > 0 { out.BusState[module] = m.BusState @@ -163,6 +170,12 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Ledgers: ledgersOf(m), } d.Loads = loadsABundle(m) + // Its marked tools (design 46 approval mark §1, §6): a name its `needs-approval` gives that it serves as a tool. + for _, mark := range m.NeedsApproval { + if slices.Contains(m.Tools, mark) { + d.Marked = append(d.Marked, mark) + } + } d.CatchUps = catchUpsOf(m) if _, reads := m.OwnSecrets["broker"]; !reads { d.NoAccount = true @@ -176,6 +189,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio held := asSeat(s, declarers[s.Name]) held.Kind = c.Kind held.Capabilities = c.Capabilities + held.Marked = markedVerbs(m, s) d.Holds = append(d.Holds, held) } } @@ -396,6 +410,18 @@ func logsOf(m catalogue.Manifest) []buscompose.Log { return out } +// markedVerbs are the verbs of a seat the module holds that need the operator's approval: those its definition marks for every +// holder, and those the module's `needs-approval` names as `.` (design 46 approval mark §2, §6). +func markedVerbs(m catalogue.Manifest, s catalogue.SeatDeclaration) []string { + var out []string + for _, v := range s.Serves { + if v.NeedsApproval || slices.Contains(m.NeedsApproval, s.Name+"."+v.Name) { + out = append(out, v.Name) + } + } + return out +} + func asSeat(s catalogue.SeatDeclaration, declarer string) buscompose.Seat { seat := buscompose.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs, DeclaredBy: declarer} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index a5b98926..7228ae8f 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -7,7 +7,8 @@ "name": "mesh-grants", "scope": "mesh", "serves": [ - "grants-for" + "grants-for", + "withheld" ] } ], @@ -37,6 +38,7 @@ "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery.source-moved", + "mesh-wants.assignment-revision", "node-engine.running.*.*" ], "uses": [ @@ -57,6 +59,7 @@ "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", "seat:mesh-delivery.source", + "seat:mesh-wants.revision", "seat:mesh-hand-acts.hand-act", "seat:mesh-broker.list" ], From 611fafb58ea63f458d96342205d1f2848c5815d9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:20:21 +0100 Subject: [PATCH 4/4] AM-D4: the real bus delivers no marked call to a runtime denied the marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deny composed into the user list must hold on the server itself: the runtime keeps its allow of the module's tools, and neither the tool's subject nor the caller-named subject process.Call sends on first reaches it. Builds: design 46 7 §6 (integration, the grants part's real-bus test) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-grants/composed_integration_test.go | 55 +++++++++++++++++++ .../cmd/mesh-grants/integration_test.go | 1 + 2 files changed, 56 insertions(+) diff --git a/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go b/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go index fb8fe2e7..fb847a1f 100644 --- a/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go @@ -623,3 +623,58 @@ func theToolRunnerHearsEveryEventOnTheBus(t *testing.T) { } hears(ledger, "the restricted stream after its grant value changed") } + +// aRuntimeDeniedTheMarksHearsNoMarkedCall, run by TestTheGrantsPartWhole, defends design 46 approval mark §6 on a real +// server (AM-D4): a runtime account composed with the deny, its node's tool runner stating no approval-mark/1, holds +// `mesh.mod..tool.>` and is delivered no call on a marked tool's subject nor on the caller-named subject +// process.Call sends on first; a call of an unmarked tool still reaches it. +func aRuntimeDeniedTheMarksHearsNoMarkedCall(t *testing.T) { + records := buscompose.Records{Nodes: []string{"a"}, Assigned: map[string][]buscompose.Declared{"a": { + {Module: "node-tools", ToolRunner: true}, + {Module: "shell", Serves: []string{"execute", "read"}, Marked: []string{"execute"}}, + {Module: "asker", Invokes: []string{"shell.execute", "shell.read"}}, + }}, Readings: map[string]buscompose.RunnerReading{"a": {Capabilities: []string{"bus-state-calls"}, Fingerprint: "sha256:runner"}}, + Running: map[string]string{"a.node-tools": "sha256:runner"}} + bus := startComposedBus(t, records) + runner, _ := bus.dial("a.node-tools") + heard := make(chan *nats.Msg, 16) + for _, s := range []string{"mesh.mod.shell.tool.>", "mesh.mod.shell.call.>"} { + if _, err := runner.ChanSubscribe(s, heard); err != nil { + t.Fatal(err) + } + } + if err := runner.Flush(); err != nil { + t.Fatal(err) + } + asker, askerRefused := bus.dial("a.asker") + // The caller-named subject is process.Call's first: mesh.mod..call... + for _, subject := range []string{"mesh.mod.shell.tool.execute", "mesh.mod.shell.call.execute.asker~a", "mesh.mod.shell.tool.read"} { + if err := asker.Publish(subject, []byte(`{}`)); err != nil { + t.Fatal(err) + } + } + if err := asker.Flush(); err != nil { + t.Fatal(err) + } + select { + case e := <-askerRefused: + t.Fatalf("the asker may not call: %s", e) + default: + } + select { + case m := <-heard: + if m.Subject != "mesh.mod.shell.tool.read" { + t.Errorf("the runtime denied the marks was delivered %s", m.Subject) + } + case <-time.After(5 * time.Second): + t.Fatal("the unmarked call did not reach the runtime") + } + if err := runner.Flush(); err != nil { + t.Fatal(err) + } + select { + case m := <-heard: + t.Errorf("the runtime denied the marks was delivered %s", m.Subject) + default: + } +} diff --git a/modules/mesh-grants/cmd/mesh-grants/integration_test.go b/modules/mesh-grants/cmd/mesh-grants/integration_test.go index 4b5fc605..4f910327 100644 --- a/modules/mesh-grants/cmd/mesh-grants/integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/integration_test.go @@ -628,6 +628,7 @@ func TestTheGrantsPartWhole(t *testing.T) { // And what the part composes holds on a server that loads it (one integration test per module, the test budget). t.Run("the composed grants hold on a server that loads them", theBusNodeFollowsTheSealedListUnderTheComposedGrants) t.Run("the tool runner hears every event on a server that loads its grants", theToolRunnerHearsEveryEventOnTheBus) + t.Run("a runtime denied the marks is delivered no call on a marked subject", aRuntimeDeniedTheMarksHearsNoMarkedCall) } // lockedLog is the part's log, read by the test while the part writes it.