From 8052fd14f4fd6f86d9db80214e529c6a5cfb2717 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 9 Nov 2026 12:39:04 +0100 Subject: [PATCH] Issue a key-value entry asked in requires as a state, by its local name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bundle's state call on a key-value entry its module asks for in requires (ADR 0595 §1) was refused "keeps and reads no state called", because the tool runner read the old state list alone (issue 1966). It now reads mesh-sdk's one rule, membership.StateIssued. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- node-tools/internal/bus/state.go | 20 ++++++++++--------- node-tools/internal/runtime/state_test.go | 24 ++++++++++++++++++++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/node-tools/internal/bus/state.go b/node-tools/internal/bus/state.go index 1911720..ed6c196 100644 --- a/node-tools/internal/bus/state.go +++ b/node-tools/internal/bus/state.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/pernode" "github.com/nats-io/nats.go/jetstream" ) @@ -71,21 +72,22 @@ func (c *Conn) issuedState(module, name string) (StateIssued, error) { return StateIssued{}, fmt.Errorf("%s has no membership issued on %s yet, so no state of it is reachable "+ "until the mesh issues one (novox/hq ADR 0201)", module, c.node) } - var names []string + // What a membership issues by a local name is mesh-sdk's one rule (membership.StateIssued, issue 1966): the old + // `state` list, then a key-value entry the module asks for in `requires` (ADR 0595 §1), by its local name. + issuing := membership.Membership{Bindings: m.Bindings} for _, s := range m.State { - if s.Name == name { - return s, nil - } - names = append(names, s.Name) + issuing.State = append(issuing.State, membership.StateIssued{Name: s.Name, Bucket: s.Bucket, Writes: s.Writes, PerNode: s.PerNode}) + } + if s, ok := issuing.StateIssued(name); ok { + return StateIssued{Name: s.Name, Bucket: s.Bucket, Writes: s.Writes, PerNode: s.PerNode}, nil } - sort.Strings(names) issued := "none" - if len(names) > 0 { + if names := issuing.StateNames(); len(names) > 0 { issued = strings.Join(names, ", ") } return StateIssued{}, fmt.Errorf("%s keeps and reads no state called %q: it declares what it keeps under "+ - "`state` and what it reads under `reads` as ., and was issued: %s (novox/hq ADR 0201)", - module, name, issued) + "`state` or as a key-value entry in `requires`, and what it reads under `reads` as ., and was "+ + "issued: %s (novox/hq ADR 0201, ADR 0595 §1)", module, name, issued) } func (c *Conn) bucket(ctx context.Context, s StateIssued) (jetstream.KeyValue, error) { diff --git a/node-tools/internal/runtime/state_test.go b/node-tools/internal/runtime/state_test.go index b88888b..78c2ba5 100644 --- a/node-tools/internal/runtime/state_test.go +++ b/node-tools/internal/runtime/state_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/membership" "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -35,16 +37,24 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) { peeker.State = []bus.StateIssued{{Name: "keeper.servers", Bucket: "keeper_servers"}} mesh.Issue(t, keeper) mesh.Issue(t, peeker) + // Issue 1966 (F1): a module that asks for its state as a key-value entry of `requires` (ADR 0595 §1) is issued it by + // its binding alone, under its local name, as its own; no other module is. + mesh.Bucket(t, "binder_holds") + binder := mt.MembershipOf("binder", "anchor", false, nil) + binder.Bindings = []membership.BusBinding{{Local: "holds", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "binder_holds"}} + mesh.Issue(t, binder) nodeTools := connect(t, "node-tools", "anchor") asker := connect(t, "console", "workstation") dir := t.TempDir() - keeperLog, peekerLog := filepath.Join(dir, "keeper.log"), filepath.Join(dir, "peeker.log") + keeperLog, peekerLog, binderLog := filepath.Join(dir, "keeper.log"), filepath.Join(dir, "peeker.log"), filepath.Join(dir, "binder.log") said := &lines{} stop, err := Run(nodeTools, []Served{ {Module: "keeper", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}}, {Module: "peeker", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}}, + {Module: "binder", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}}, }, map[string]map[string]string{ + "binder": {"STATE_NAME": "holds", "STATE_LOG": binderLog}, "keeper": {"STATE_NAME": "servers", "STATE_LOG": keeperLog}, "peeker": {"STATE_NAME": "keeper.servers", "STATE_LOG": peekerLog}, }, said.logf) @@ -91,6 +101,16 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) { } same(t, got, `["all.one","anchor.two"]`) + // The bound module puts, gets and watches its key-value entry by its local name. + waitFor(t, binderLog, "current") + if _, err := call(t, asker, "binder.put@anchor", map[string]any{"state": "holds", "key": "h1", "value": 1}); err != nil { + t.Fatalf("a put on a bound key-value entry: %v", err) + } + waitFor(t, binderLog, "now put h1 1") + if got, err := call(t, asker, "binder.get@anchor", map[string]any{"state": "holds", "key": "h1"}); err != nil || !strings.Contains(got, `"value":1`) { + t.Fatalf("a get on a bound key-value entry answered %s, %v", got, err) + } + // Refused, with the reason, before anything is sent. for _, c := range []struct { key, why string @@ -100,6 +120,8 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) { map[string]any{"state": "keeper.servers", "key": "x", "value": 1}}, {"keeper.get@anchor", `keeper keeps and reads no state called "other"`, map[string]any{"state": "other", "key": "x"}}, + {"keeper.get@anchor", `keeper keeps and reads no state called "holds"`, + map[string]any{"state": "holds", "key": "h1"}}, {"keeper.put@anchor", `carries a field "accessToken", which names a credential`, map[string]any{"state": "servers", "key": "x", "value": map[string]any{"headers": map[string]any{"accessToken": "s"}}}}, {"keeper.put@anchor", "is not a key the bus can hold",