From f185aedbf5d6e26c4d6ffadf36f3e2c834bd30c6 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 18 Oct 2026 02:38:58 +0200 Subject: [PATCH] The provider helper reads an ended or pending assignment as a removed key (issue 1098) --- go/provider/ended_test.go | 62 +++++++++++++++++++++++++++++++++++++++ go/provider/provider.go | 23 +++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 go/provider/ended_test.go diff --git a/go/provider/ended_test.go b/go/provider/ended_test.go new file mode 100644 index 0000000..c4e25cb --- /dev/null +++ b/go/provider/ended_test.go @@ -0,0 +1,62 @@ +package provider + +import ( + "context" + "encoding/json" + "slices" + "testing" +) + +// Issue 1098: an assignment with `ended` set, or with a `state`, is not a standing assignment: the helper reads it +// as a removed key, so the entry of its consumer is removed and the adapter hears it is gone. The fields the +// controller adds change nothing. +func TestAnEndedAssignmentIsReadAsARemovedKey(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + p := postgres(bus, v, m) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.postgres-database"}) { + t.Fatalf("entries %v", got) + } + for _, mark := range []map[string]any{{"ended": "2026-10-18T01:00:00Z"}, {"state": "pending"}} { + raw := bus.get(AssignmentsBucket, "ace.letta") + var a map[string]any + if err := json.Unmarshal(raw, &a); err != nil { + t.Fatal(err) + } + for k, v := range mark { + a[k] = v + } + raw, _ = json.Marshal(a) + _ = bus.Put(context.Background(), AssignmentsBucket, "ace.letta", raw) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := bus.keys(BucketOf("postgres")); len(got) != 0 || len(out) != 1 || out[0].Status != Removed { + t.Fatalf("%v: entries %v, outcomes %+v", mark, got, out) + } + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + } +} + +func TestStandingIsFalseOnlyForAnEndedOrPendingValue(t *testing.T) { + for raw, want := range map[string]bool{ + `{"node":"a","module":"b"}`: true, + `{"node":"a","module":"b","ended":"","state":"","x":{"y":1}}`: true, + `{"node":"a","module":"b","ended":null}`: true, + `{"node":"a","module":"b","build":"b1","commit":"c","rollout":"r","since":"t","artifact":{"name":"n"}}`: true, + `{"node":"a","module":"b","ended":"2026-10-18T01:00:00Z"}`: false, + `{"node":"a","module":"b","state":"pending"}`: false, + } { + if got := Standing([]byte(raw)); got != want { + t.Errorf("%s: %v, want %v", raw, got, want) + } + } +} diff --git a/go/provider/provider.go b/go/provider/provider.go index 33bf61f..4adee9e 100644 --- a/go/provider/provider.go +++ b/go/provider/provider.go @@ -335,6 +335,9 @@ func (p *Provider) pass(ctx context.Context, assignments, entries map[string][]b var out []Outcome unknown := map[string]bool{} // consumers whose assignment was skipped: their entries are not judged for _, key := range sortedKeys(assignments) { + if !Standing(assignments[key]) { + continue // ended or pending: read as a removed key (issue 1098) + } var a planned if err := json.Unmarshal(assignments[key], &a); err != nil || a.Node+"."+a.Module != key { o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the assignment %s is not the controller's, so "+ @@ -744,3 +747,23 @@ func (b stateBus) Delete(_ context.Context, bucket, key string) error { } return s.Delete(key) } + +// Standing is whether an assignment's value is a standing assignment: false when `ended` is set (the module was +// taken off its node, novox/hq ADR 0408 clause 4) or `state` is not empty (a pending one). Such a value is read +// exactly as a removed key. Every other field, known or not, changes nothing (issue 1098). +func Standing(value []byte) bool { + var v struct { + Ended json.RawMessage `json:"ended"` + State json.RawMessage `json:"state"` + } + if json.Unmarshal(value, &v) != nil { + return true // not decided here: the parse that follows refuses it + } + return !fieldSet(v.Ended) && !fieldSet(v.State) +} + +// fieldSet is whether a field carries something: not absent, null or empty. +func fieldSet(f json.RawMessage) bool { + s := strings.TrimSpace(string(f)) + return s != "" && s != "null" && s != `""` +}