From e9c319c5a03ec1b5bd1b47f8bd4e37b6b0725202 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 13:21:24 +0100 Subject: [PATCH] A membership carries its module's bus-state bindings, by local name (issue 1966 S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0595 §1, §7: the binding travels on the bus module's created and the grants part writes it into the membership; omitted when empty, so a membership without bindings encodes byte for byte as before. The two address tests are one table. --- go/membership/membership.go | 27 +++++++++++++++++++ go/membership/membership_test.go | 45 +++++++++++++++++++------------- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/go/membership/membership.go b/go/membership/membership.go index 8fd93fd..5430239 100644 --- a/go/membership/membership.go +++ b/go/membership/membership.go @@ -57,6 +57,33 @@ type Membership struct { Hears []HeardIssued `json:"hears,omitempty"` // Waits are the event families a session may wait on through the mesh MCP server (novox/hq ADR 0367 §2). Waits []WaitIssued `json:"waits,omitempty"` + // Bindings are the module's bus state as the bus module created it, one per entry its manifest asks for in + // `requires` (novox/hq ADR 0595 §1, §7): written by the grants part when it hears the bus module's `created`. + Bindings []BusBinding `json:"bindings,omitempty"` +} + +// BusBinding is one bus-state entry's names on the bus, by the local name its module asks for it by (novox/hq ADR 0595 +// §1, design 25 § 12.2): the module names only locally, and reaches its own entries, and only those, through these. +// Which names are set follows the storage type: a stream's Stream and Subject, a key-value bucket's Bucket, an object +// store's ObjectStore, and a ledger's History, State, Indexes and Snapshot. +type BusBinding struct { + Local string `json:"local"` + // Storage and Template are the entry's storage type and template (catalogue.Storage…, catalogue.Template…). + Storage string `json:"storage"` + Template string `json:"template,omitempty"` + // Stream and Subject are a stream's name and the subject its messages are on; Subject is also a ledger's history's. + Stream string `json:"stream,omitempty"` + Subject string `json:"subject,omitempty"` + // Bucket is a key-value bucket's name. + Bucket string `json:"bucket,omitempty"` + // ObjectStore is an object store's name. + ObjectStore string `json:"object-store,omitempty"` + // Ledger, for the storage type ledger: its history stream (one subject per instance), its state bucket, each + // declared index's bucket by index name, and the subject of the instances' snapshots (ADR 0597 §1, §4, §8). + History string `json:"history,omitempty"` + State string `json:"state,omitempty"` + Indexes map[string]string `json:"indexes,omitempty"` + Snapshot string `json:"snapshot,omitempty"` } // Served is one address a tool is answered on. diff --git a/go/membership/membership_test.go b/go/membership/membership_test.go index cfce963..6ea76db 100644 --- a/go/membership/membership_test.go +++ b/go/membership/membership_test.go @@ -6,31 +6,22 @@ import ( "testing" ) +// A credential is read out of a bus address, and the bare address carries no credential and a scheme. func TestACredentialIsReadOutOfABusAddress(t *testing.T) { - for _, c := range []struct{ in, user, password, bare string }{ - {"nats://controller:secret@127.0.0.1:4222", "controller", "secret", "nats://127.0.0.1:4222"}, - {"controller:secret@127.0.0.1:4222", "controller", "secret", "127.0.0.1:4222"}, - {"nats://127.0.0.1:4222", "", "", "nats://127.0.0.1:4222"}, - {"127.0.0.1:4222", "", "", "127.0.0.1:4222"}, + for _, c := range []struct{ in, user, password, bare, schemed string }{ + {"nats://controller:secret@127.0.0.1:4222", "controller", "secret", "nats://127.0.0.1:4222", "nats://127.0.0.1:4222"}, + {"controller:secret@127.0.0.1:4222", "controller", "secret", "127.0.0.1:4222", "nats://127.0.0.1:4222"}, + {"nats://127.0.0.1:4222", "", "", "nats://127.0.0.1:4222", "nats://127.0.0.1:4222"}, + {"127.0.0.1:4222", "", "", "127.0.0.1:4222", "nats://127.0.0.1:4222"}, // A password containing an at-sign: split on the last one. - {"nats://controller:a@b@127.0.0.1:4222", "controller", "a@b", "nats://127.0.0.1:4222"}, + {"nats://controller:a@b@127.0.0.1:4222", "controller", "a@b", "nats://127.0.0.1:4222", "nats://127.0.0.1:4222"}, } { user, password, bare := CredentialIn(c.in) if user != c.user || password != c.password || bare != c.bare { t.Errorf("%q read as %q/%q at %q; wanted %q/%q at %q", c.in, user, password, bare, c.user, c.password, c.bare) } - } -} - -func TestABareAddressCarriesNoCredentialAndAScheme(t *testing.T) { - for in, want := range map[string]string{ - "nats://controller:secret@10.0.0.1:4222": "nats://10.0.0.1:4222", - "controller:secret@10.0.0.1:4222": "nats://10.0.0.1:4222", - "nats://10.0.0.1:4222": "nats://10.0.0.1:4222", - "10.0.0.1:4222": "nats://10.0.0.1:4222", - } { - if got := BareAddress(in); got != want { - t.Errorf("BareAddress(%q) = %q, want %q", in, got, want) + if got := BareAddress(c.in); got != c.schemed { + t.Errorf("BareAddress(%q) = %q, want %q", c.in, got, c.schemed) } } } @@ -66,6 +57,24 @@ func TestAMembershipRoundTripsAsIssued(t *testing.T) { if !reflect.DeepEqual(in, out) { t.Fatalf("round trip changed it:\n%+v\n%+v", in, out) } + // 1966/AC4: a membership with a ledger binding round-trips; one without bindings encodes as before, byte for byte. + bound := in + bound.Bindings = []BusBinding{{Local: "records", Storage: "ledger", Template: "ledger", History: "forge_records", + Subject: "forge_records.>", State: "forge_records_state", Indexes: map[string]string{"search": "forge_records_search"}, + Snapshot: "forge_records_snapshot.>"}} + boundRaw, err := json.Marshal(bound) + if err != nil { + t.Fatal(err) + } + var boundBack Membership + if err := json.Unmarshal(boundRaw, &boundBack); err != nil || !reflect.DeepEqual(bound, boundBack) { + t.Fatalf("a membership with a ledger binding read back as %+v (%v)", boundBack, err) + } + before, _ := json.Marshal(Membership{Node: "ace", Module: "forge", Tools: "t", Emits: "e", Serves: []Served{{Subject: "s"}}, + State: []StateIssued{{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) + } var wire map[string]json.RawMessage _ = json.Unmarshal(raw, &wire) for _, k := range []string{"receives", "mesh", "seat-traffic"} {