From 6b8c6e2721adefe4440cc706c5b286c9f25eebdf Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 16:46:25 +0100 Subject: [PATCH] =?UTF-8?q?Conditions=20client:=20Observation,=20Raise,=20?= =?UTF-8?q?Clear,=20Says,=20EncodeFinding=20and=20DecodeFinding=20raise=20?= =?UTF-8?q?and=20clear=20an=20observer's=20finding=20in=20the=20seat's=20l?= =?UTF-8?q?ayout,=20goldens=20from=20go/health's=20output=20(B5l;=20ADR=20?= =?UTF-8?q?0634=20=C2=A74;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../meshconditionsclient/client_test.go | 84 ++++++++++ .../meshconditionsclient/observation.go | 147 ++++++++++++++++++ .../testdata/finding_full.json | 1 + .../testdata/finding_minimal.json | 1 + 4 files changed, 233 insertions(+) create mode 100644 health-and-repair/go/clients/meshconditionsclient/observation.go create mode 100644 health-and-repair/go/clients/meshconditionsclient/testdata/finding_full.json create mode 100644 health-and-repair/go/clients/meshconditionsclient/testdata/finding_minimal.json diff --git a/health-and-repair/go/clients/meshconditionsclient/client_test.go b/health-and-repair/go/clients/meshconditionsclient/client_test.go index c9ba6df..f96ebf8 100644 --- a/health-and-repair/go/clients/meshconditionsclient/client_test.go +++ b/health-and-repair/go/clients/meshconditionsclient/client_test.go @@ -1,6 +1,8 @@ package meshconditionsclient import ( + "os" + "reflect" "strings" "testing" "time" @@ -109,3 +111,85 @@ func when(s string) time.Time { } func ptr(t time.Time) *time.Time { return &t } + +type fakeBucket struct { + kept map[string][]byte + puts, dels int +} + +func (b *fakeBucket) Get(key string) ([]byte, bool, error) { v, ok := b.kept[key]; return v, ok, nil } +func (b *fakeBucket) Put(key string, v []byte) error { + if b.kept == nil { + b.kept = map[string][]byte{} + } + b.kept[key] = v + b.puts++ + return nil +} +func (b *fakeBucket) Delete(key string) error { delete(b.kept, key); b.dels++; return nil } + +// The goldens are what mesh-sdk's go/health EncodeFinding wrote for the same findings: the helpers write the same +// values, read by the seat's strict decoder. +func TestAnObservationIsWrittenAsMeshSDKsHealthWroteItsFinding(t *testing.T) { + at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + for _, c := range []struct { + golden string + o Observation + run string + }{ + {"finding_full.json", Observation{Scope: ScopeMachine, ID: "g14", Kind: "silent-kind", Ref: "silent", Machine: "g14", Also: []string{"ace"}, + Severity: Urgent, Summary: "g14 is silent", Said: "no pulse", Headline: "g14 is silent", Explanation: "It said nothing.", + Resolved: "It speaks.", Needs: "look", Source: "S1", Resolver: "operator"}, "r1"}, + {"finding_minimal.json", Observation{Scope: ScopeModule, ID: "nats.g14", Kind: "build-failed", Summary: "failed", + Source: "builder.build-failed"}, ""}, + } { + raw, err := os.ReadFile("testdata/" + c.golden) + if err != nil { + t.Fatal(err) + } + want, err := seat.DecodeFindingsByKeyValue(raw) + if err != nil { + t.Fatalf("%s: %v", c.golden, err) + } + observer := want.Observer + got, err := EncodeFinding(c.o.Finding(observer, c.run, at)) + if err != nil { + t.Fatalf("%s: %v", c.golden, err) + } + have, err := seat.DecodeFindingsByKeyValue(got) + if err != nil || !reflect.DeepEqual(have, want) { + t.Errorf("%s: wrote %s, want %s (%v)", c.golden, got, raw, err) + } + if back, err := DecodeFinding(raw); err != nil || !reflect.DeepEqual(back, want) { + t.Errorf("%s: read back %v", c.golden, err) + } + } +} + +func TestRaiseWritesWhenTheWordsChangeAndClearDeletesWhatIsThere(t *testing.T) { + b := &fakeBucket{} + o := Observation{Scope: ScopeModule, ID: "m", Kind: "k", Summary: "one", Source: "x.k"} + at := when("2026-11-05T12:00:00Z") + if w, err := Raise(b, "obs", "", o, at); err != nil || !w { + t.Fatalf("first: %v %v", w, err) + } + if w, err := Raise(b, "obs", "r2", o, at.Add(time.Hour)); err != nil || w || b.puts != 1 { + t.Fatalf("the same words later: wrote %v, %d puts, %v", w, b.puts, err) + } + o.Summary = "two" + if w, err := Raise(b, "obs", "", o, at); err != nil || !w || b.puts != 2 { + t.Fatalf("changed: %v %d %v", w, b.puts, err) + } + if _, err := Raise(b, "obs", "", Observation{Scope: "moon", ID: "m", Kind: "k"}, at); err == nil || b.puts != 2 { + t.Errorf("a finding the keeper cannot use was written: %v", err) + } + if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || !c || b.dels != 1 { + t.Fatalf("clear: %v %v %d", c, err, b.dels) + } + if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || c || b.dels != 1 { + t.Errorf("clearing what is not there: %v %v %d", c, err, b.dels) + } + if FindingsBucket("obs") != "obs_findings" { + t.Error(FindingsBucket("obs")) + } +} diff --git a/health-and-repair/go/clients/meshconditionsclient/observation.go b/health-and-repair/go/clients/meshconditionsclient/observation.go new file mode 100644 index 0000000..e8bc379 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/observation.go @@ -0,0 +1,147 @@ +package meshconditionsclient + +import ( + "encoding/json" + "fmt" + "time" + + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" +) + +// What an observer says it sees wrong, raised and cleared as a finding in its own state `findings` (novox/hq ADR 0564 +// clause 2, ADR 0634 §4): one write shared by every observer (the self-check, the healers, the watchman, the builder, +// the indexer, the node-engine), in this seat's layout. The condition keeper raises and clears the conditions from them. + +// FindingsState is the name of an observer's state of findings. +const FindingsState = "findings" + +// FindingsBucket is the bucket of an observer's findings, `_findings`. +func FindingsBucket(observer string) string { return observer + "_" + FindingsState } + +// Observation is one thing an observer sees wrong now: what it is about (Scope, ID, Machine, Also), which kind of fault +// (Kind; Ref when the key's last part is not the kind), and its words. +type Observation struct { + Scope, ID, Kind, Ref, Machine string + Also []string + // Severity is Urgent or Warning; Warning when empty. + Severity string + // Summary is one line; Said the one observation kept as evidence (the summary when empty). + Summary, Said string + Headline, Explanation, Resolved, Needs string + // Source is the row, probe or event that saw it. + Source string + // Resolver is who resolves it, self when empty. + Resolver string + Actions []seat.Action + // Since is when the finding is stamped; the zero value leaves it to the caller's moment. + Since time.Time +} + +// Key is the finding's key: `..`, the token Ref or else Kind. +func (o Observation) Key() string { + token := o.Ref + if token == "" { + token = o.Kind + } + return Key(o.Scope, o.ID, token) +} + +// Finding is the observation as the seat's finding written by observer in run, at the moment at. +func (o Observation) Finding(observer, run string, at time.Time) seat.FindingsByKeyValue { + at = at.UTC() + said := o.Said + if said == "" { + said = o.Summary + } + sev := o.Severity + if sev == "" { + sev = Warning + } + f := seat.FindingsByKeyValue{V: FindingVersion, Key: o.Key(), Kind: o.Kind, + Subject: seat.Subject{Scope: o.Scope, ID: o.ID, Machine: o.Machine, Also: o.Also}, + Severity: sev, Summary: o.Summary, Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved, + Needs: o.Needs, Actions: o.Actions, Evidence: []seat.Evidence{{At: at, Said: said}}, Source: o.Source, + Resolver: o.Resolver, Observer: observer, Run: run, At: at} + if o.Ref != "" && o.Ref != o.Kind { + f.Ref = o.Ref + } + return f +} + +// EncodeFinding is f as JSON at FindingVersion; it refuses what CheckFinding refuses. +func EncodeFinding(f seat.FindingsByKeyValue) ([]byte, error) { + f.V = FindingVersion + if f.Evidence == nil { + f.Evidence = []seat.Evidence{} + } + if err := CheckFinding(f); err != nil { + return nil, err + } + return json.Marshal(f) +} + +// DecodeFinding reads a finding's value; it refuses another version and what CheckFinding refuses. A finding written +// before the field was named `ref` (novox/hq issue 1952) is read by its old name, `token`. +func DecodeFinding(data []byte) (seat.FindingsByKeyValue, error) { + f, err := seat.DecodeFindingsByKeyValue(data) + if err != nil { + return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding is not readable: %w", err) + } + if f.V != FindingVersion { + return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding of version %d, and this reads %d", f.V, FindingVersion) + } + if err := CheckFinding(f); err != nil { + return seat.FindingsByKeyValue{}, err + } + return f, nil +} + +// Says is what a finding says, the moments and the run that wrote it aside: two findings that say the same are one +// write (novox/hq ADR 0289 §1), so the condition moves only when the words do. +func Says(f seat.FindingsByKeyValue) string { + f.At, f.Run, f.V = time.Time{}, "", 0 + ev := make([]seat.Evidence, len(f.Evidence)) + for i, e := range f.Evidence { + ev[i] = seat.Evidence{Said: e.Said} + } + f.Evidence = ev + raw, _ := json.Marshal(f) + return string(raw) +} + +// FindingBucket is the part of an observer's state `findings` that Raise and Clear use. +type FindingBucket interface { + // Get answers the value at key; found is false when there is none. + Get(key string) (value []byte, found bool, err error) + Put(key string, value []byte) error + Delete(key string) error +} + +// Raise writes the observation as observer's finding in run at at, only when what it says differs from the finding kept +// (so the keeper's condition moves only then); wrote says whether it wrote. A finding the keeper could not make a +// condition of is refused, nothing written. +func Raise(b FindingBucket, observer, run string, o Observation, at time.Time) (wrote bool, err error) { + f := o.Finding(observer, run, at) + value, err := EncodeFinding(f) + if err != nil { + return false, err + } + if kept, found, err := b.Get(f.Key); err != nil { + return false, err + } else if found { + if old, err := DecodeFinding(kept); err == nil && Says(old) == Says(f) { + return false, nil + } + } + return true, b.Put(f.Key, value) +} + +// Clear deletes the finding about scope and id of kind (its token); one not there is nothing to clear, and cleared says +// whether one was. +func Clear(b FindingBucket, scope, id, token string) (cleared bool, err error) { + key := Key(scope, id, token) + if _, found, err := b.Get(key); err != nil || !found { + return false, err + } + return true, b.Delete(key) +} diff --git a/health-and-repair/go/clients/meshconditionsclient/testdata/finding_full.json b/health-and-repair/go/clients/meshconditionsclient/testdata/finding_full.json new file mode 100644 index 0000000..54bf2f8 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/testdata/finding_full.json @@ -0,0 +1 @@ +{"v":1,"key":"machine.g14.silent","kind":"silent-kind","ref":"silent","subject":{"scope":"machine","id":"g14","machine":"g14","also":["ace"]},"severity":"urgent","summary":"g14 is silent","headline":"g14 is silent","explanation":"It said nothing.","resolved":"It speaks.","needs":"look","evidence":[{"at":"2026-11-05T12:00:00Z","said":"no pulse"}],"source":"S1","resolver":"operator","observer":"mesh-delivery-watchman","run":"r1","at":"2026-11-05T12:00:00Z"} diff --git a/health-and-repair/go/clients/meshconditionsclient/testdata/finding_minimal.json b/health-and-repair/go/clients/meshconditionsclient/testdata/finding_minimal.json new file mode 100644 index 0000000..e0fef12 --- /dev/null +++ b/health-and-repair/go/clients/meshconditionsclient/testdata/finding_minimal.json @@ -0,0 +1 @@ +{"v":1,"key":"module.nats.g14.build-failed","kind":"build-failed","subject":{"scope":"module","id":"nats.g14"},"severity":"warning","summary":"failed","headline":"","explanation":"","resolved":"","evidence":[{"at":"2026-11-05T12:00:00Z","said":"failed"}],"source":"builder.build-failed","observer":"mesh-delivery-builder","at":"2026-11-05T12:00:00Z"}