Conditions client: Observation, Raise, Clear, Says, EncodeFinding and DecodeFinding raise and clear an observer's finding in the seat's layout, goldens from go/health's output (B5l; ADR 0634 §4; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 16:46:25 +01:00
parent 396ccc0e11
commit 6b8c6e2721
4 changed files with 233 additions and 0 deletions
@@ -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"))
}
}
@@ -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, `<module>_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: `<scope>.<id>.<token>`, 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)
}
@@ -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"}
@@ -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"}