From 17475257d15f8fc9d0b082dbd30085e83510f973 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 9 Nov 2026 12:34:36 +0100 Subject: [PATCH] One rule for the state a membership issues and a manifest keeps, in both forms A key-value entry asked in requires (ADR 0595) was bound but never issued as a state, because each reader read the old state list alone (issue 1966). Membership.StateIssued and StateNames, and catalogue.KeptStates, are the one rule the tool runner, the grants part and the manifest validator call. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- go/catalogue/busstate.go | 20 ++++++++++++ go/catalogue/optional_requirements_test.go | 11 +++++++ go/membership/membership.go | 37 ++++++++++++++++++++++ go/membership/membership_test.go | 25 +++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/go/catalogue/busstate.go b/go/catalogue/busstate.go index 21dc379..220d5a4 100644 --- a/go/catalogue/busstate.go +++ b/go/catalogue/busstate.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "slices" + "sort" "strings" ) @@ -205,3 +207,21 @@ func setAsideAlso(unknown string, paths []string) string { } return unknown + " (read without " + strings.Join(paths, ", ") + ")" } + +// KeptStates are the local names of the state a manifest keeps, each a key-value bucket of its own: the old `state` +// section's and every key-value entry it asks for in `requires` (ADR 0595 §1, §11), sorted, once each. It is the one +// rule every reader of what a module keeps calls (novox/hq issue 1966): a process's `bus.state` and a seat +// contribution's state name one of these, in either form. +func KeptStates(m Manifest) []string { + var out []string + for _, s := range m.State { + out = append(out, s.Name) + } + for _, e := range m.BusState { + if e.Storage == StorageKeyValue { + out = append(out, e.Name) + } + } + sort.Strings(out) + return slices.Compact(out) +} diff --git a/go/catalogue/optional_requirements_test.go b/go/catalogue/optional_requirements_test.go index 2f6bcb8..a48a0ae 100644 --- a/go/catalogue/optional_requirements_test.go +++ b/go/catalogue/optional_requirements_test.go @@ -78,6 +78,9 @@ func TestARequirementsLongForm(t *testing.T) { // 1966/AC3: a ledger type with each of the four effects round-trips through JSON unchanged. {"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}, "types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil, ""}, + // Issue 1966: a key-value entry of `requires` is kept state beside the old section's, by its local name. + {"a key-value entry beside an old state", `[{"name":"holds","storage":"key-value","template":"state"}], "state": ["seen"]`, + "", []BusStateEntry{{Name: "holds", Storage: StorageKeyValue, Template: TemplateState}}, nil, ""}, // A field given twice at any depth is refused, named by its path: a decoder would keep the last without a word. {"a field given twice", `[], "requires": ["postgres-database"]`, "", nil, nil, "the field requires is given twice"}, {"a field given twice deeper", `[{"name":"records","storage":"ledger","name":"other","template":"ledger"}]`, @@ -100,6 +103,14 @@ func TestARequirementsLongForm(t *testing.T) { if !reflect.DeepEqual(got.BusState, c.busState) { t.Errorf("%s: bus state read as %+v, want %+v", c.name, got.BusState, c.busState) } + // Issue 1966: a ledger is no kept state; KeptStates names the old section's and the key-value entries' alone. + var keeps []string + if c.name == "a key-value entry beside an old state" { + keeps = []string{"holds", "seen"} + } + if kept := KeptStates(got); !reflect.DeepEqual(kept, keeps) { + t.Errorf("%s: kept states %v, want %v", c.name, kept, keeps) + } for _, path := range c.aside { if !strings.Contains(got.SetAside(), path) { t.Errorf("%s: %s is not named among what was set aside: %q", c.name, path, got.SetAside()) diff --git a/go/membership/membership.go b/go/membership/membership.go index 38e225c..a38d94d 100644 --- a/go/membership/membership.go +++ b/go/membership/membership.go @@ -6,6 +6,8 @@ package membership import ( "encoding/json" + "slices" + "sort" "git.novox.be/novox/mesh-sdk/go/catalogue" ) @@ -171,3 +173,38 @@ type WaitIssued struct { Event string `json:"event"` Subject string `json:"subject"` } + +// StateIssued is the state this membership issues its module's code by a local name, and whether it does: what every +// runtime that serves a module's state calls reads, so no reader keeps a second copy of the rule (novox/hq issue 1966). +// An entry of the old `state` list wins (ADR 0201); otherwise a binding of a key-value entry the module asks for in +// `requires` is issued by its local name, as its own and written (ADR 0595 §1, §11: a moved module keeps its local +// names), on the bucket the bus module named. +func (m Membership) StateIssued(name string) (StateIssued, bool) { + for _, s := range m.State { + if s.Name == name { + return s, true + } + } + for _, b := range m.Bindings { + if b.Local == name && b.Storage == catalogue.StorageKeyValue && b.Bucket != "" { + return StateIssued{Name: b.Local, Bucket: b.Bucket, Writes: true}, true + } + } + return StateIssued{}, false +} + +// StateNames are the local names StateIssued answers, the old list's and the key-value bindings', sorted, once each: +// what a refusal names as issued. +func (m Membership) StateNames() []string { + var out []string + for _, s := range m.State { + out = append(out, s.Name) + } + for _, b := range m.Bindings { + if b.Storage == catalogue.StorageKeyValue && b.Bucket != "" { + out = append(out, b.Local) + } + } + sort.Strings(out) + return slices.Compact(out) +} diff --git a/go/membership/membership_test.go b/go/membership/membership_test.go index 555f19c..f252687 100644 --- a/go/membership/membership_test.go +++ b/go/membership/membership_test.go @@ -59,6 +59,31 @@ func TestAMembershipRoundTripsAsIssued(t *testing.T) { if string(before) != `{"node":"ace","module":"forge","serves":[{"subject":"s"}],"emits":"e","tools":"t","state":[{"name":"repos","bucket":"forge_repos","writes":true}]}` { t.Fatalf("a membership without bindings encodes otherwise than before: %s", before) } + // Issue 1966 (F1): what a membership issues by a local name is one rule. An old `state` entry wins; a key-value + // binding of `requires` is issued by its local name, own and written; any other binding, and a name of neither, is not. + issuing := Membership{State: []StateIssued{{Name: "repos", Bucket: "forge_repos"}}, + Bindings: []BusBinding{{Local: "holds", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "forge_holds"}, + {Local: "repos", Storage: catalogue.StorageKeyValue, Bucket: "forge_repos2"}, + {Local: "blobs", Storage: catalogue.StorageObjectStore, ObjectStore: "forge_blobs"}, + {Local: "events", Storage: catalogue.StorageStream, Stream: "forge_events"}}} + for _, c := range []struct { + name string + want StateIssued + ok bool + }{ + {"holds", StateIssued{Name: "holds", Bucket: "forge_holds", Writes: true}, true}, + {"repos", StateIssued{Name: "repos", Bucket: "forge_repos"}, true}, + {"blobs", StateIssued{}, false}, + {"events", StateIssued{}, false}, + {"other", StateIssued{}, false}, + } { + if got, ok := issuing.StateIssued(c.name); ok != c.ok || got != c.want { + t.Errorf("StateIssued(%q) = %+v, %v; want %+v, %v", c.name, got, ok, c.want, c.ok) + } + } + if got := issuing.StateNames(); !reflect.DeepEqual(got, []string{"holds", "repos"}) { + t.Errorf("StateNames = %v", got) + } var wire map[string]json.RawMessage _ = json.Unmarshal(raw, &wire) for _, k := range []string{"receives", "mesh", "seat-traffic"} {