One rule for the state a membership issues and a manifest keeps, in both forms (issue 1966, recovery) #177

Merged
mesh-tools merged 1 commits from fix/1966-requires-state into main 2026-11-09 12:56:31 +00:00
4 changed files with 93 additions and 0 deletions
+20
View File
@@ -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)
}
@@ -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())
+37
View File
@@ -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)
}
+25
View File
@@ -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"} {