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
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,3 +207,21 @@ func setAsideAlso(unknown string, paths []string) string {
|
|||||||
}
|
}
|
||||||
return unknown + " (read without " + strings.Join(paths, ", ") + ")"
|
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.
|
// 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},
|
{"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024},
|
||||||
"types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil, ""},
|
"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 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", `[], "requires": ["postgres-database"]`, "", nil, nil, "the field requires is given twice"},
|
||||||
{"a field given twice deeper", `[{"name":"records","storage":"ledger","name":"other","template":"ledger"}]`,
|
{"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) {
|
if !reflect.DeepEqual(got.BusState, c.busState) {
|
||||||
t.Errorf("%s: bus state read as %+v, want %+v", c.name, 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 {
|
for _, path := range c.aside {
|
||||||
if !strings.Contains(got.SetAside(), path) {
|
if !strings.Contains(got.SetAside(), path) {
|
||||||
t.Errorf("%s: %s is not named among what was set aside: %q", c.name, path, got.SetAside())
|
t.Errorf("%s: %s is not named among what was set aside: %q", c.name, path, got.SetAside())
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package membership
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
)
|
)
|
||||||
@@ -171,3 +173,38 @@ type WaitIssued struct {
|
|||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
Subject string `json:"subject"`
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}]}` {
|
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)
|
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
|
var wire map[string]json.RawMessage
|
||||||
_ = json.Unmarshal(raw, &wire)
|
_ = json.Unmarshal(raw, &wire)
|
||||||
for _, k := range []string{"receives", "mesh", "seat-traffic"} {
|
for _, k := range []string{"receives", "mesh", "seat-traffic"} {
|
||||||
|
|||||||
Reference in New Issue
Block a user