From 05fcebf9c5b8d31967ffda5ae593b9a3a995904f Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:58:08 +0100 Subject: [PATCH] COND A-22: the keeper holds findings as the contract's generic Finding and takes its own through the writer's wire form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §434, §437, §439, A-3, A-22 (G-20) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-conditions/asks.go | 7 ++-- .../cmd/mesh-conditions/events.go | 41 ++++++++++-------- .../cmd/mesh-conditions/hold.go | 2 +- .../cmd/mesh-conditions/keeper.go | 18 ++++---- .../cmd/mesh-conditions/keeper_test.go | 36 +++++++++++++--- .../cmd/mesh-conditions/main.go | 42 ++++++++++++++----- .../cmd/mesh-conditions/model.go | 10 ++--- 7 files changed, 103 insertions(+), 53 deletions(-) diff --git a/modules/mesh-conditions/cmd/mesh-conditions/asks.go b/modules/mesh-conditions/cmd/mesh-conditions/asks.go index b9bff6d23..f376a264e 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/asks.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/asks.go @@ -5,7 +5,6 @@ package main // `findings-unstated`, cleared by its next `findings-stated`. import ( - "encoding/json" "errors" "fmt" "sort" @@ -144,17 +143,17 @@ func (k *Keeper) AskPassed(bound string) error { return nil } now := k.Now().UTC() - f := seat.FindingsByKeyValue{V: cc.FindingVersion, Key: unstatedKey(observer, node), Kind: KindFindingsUnstated, + f := seat.Finding{Scope: cc.ScopeModule, ID: observer + "." + node, Kind: KindFindingsUnstated, Since: now, Subject: seat.Subject{Scope: cc.ScopeModule, ID: observer + "." + node, Machine: node}, Severity: cc.Warning, Summary: fmt.Sprintf("%s on %s stated no findings within %s of the ask", observer, node, k.askedWithin()), Headline: observer + " on " + node + " did not state its findings", Explanation: "The keeper asked " + observer + " on " + node + " to state its findings and heard nothing; its conditions stand as they were.", Source: "findings-asked", Observer: Module, At: now, Evidence: []seat.Evidence{{At: now, Said: "ask " + ask + " unanswered"}}} - raw, err := json.Marshal(f) + key, raw, err := storedFinding(f) if err != nil { return err } - return k.Finding(Module, f.Key, raw, false) + return k.Finding(Module, key, raw, false) } func unstatedKey(observer, node string) string { diff --git a/modules/mesh-conditions/cmd/mesh-conditions/events.go b/modules/mesh-conditions/cmd/mesh-conditions/events.go index f2be01310..458469f9f 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/events.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/events.go @@ -94,8 +94,7 @@ func (k *Keeper) FindingWritten(observer, node string, body []byte) error { // take holds one finding an observer says on a node, and raises or changes its condition. func (k *Keeper) take(observer, node string, f seat.Finding) error { - v := storedFinding(f) - value, err := json.Marshal(v) + key, value, err := storedFinding(f) if err != nil { return err } @@ -104,15 +103,15 @@ func (k *Keeper) take(observer, node string, f seat.Finding) error { if k.keyNode[observer] == nil { k.keyNode[observer] = map[string]string{} } - k.keyNode[observer][v.Key] = node - named := k.view(observer, node).Pending[v.Key] - delete(k.view(observer, node).Pending, v.Key) + k.keyNode[observer][key] = node + named := k.view(observer, node).Pending[key] + delete(k.view(observer, node).Pending, key) k.mu.Unlock() if !named.IsZero() { - _ = k.Store.Delete(BoundsState, pendingKey(observer, node, v.Key)) + _ = k.Store.Delete(BoundsState, pendingKey(observer, node, key)) } k.saveView(observer, node) - return k.Finding(observer, v.Key, value, false) + return k.Finding(observer, key, value, false) } // FindingCleared clears the condition of the finding an observer no longer observes. @@ -145,20 +144,26 @@ func (k *Keeper) FindingCleared(observer, node string, body []byte) error { return k.Finding(observer, key, nil, true) } -// storedFinding is the event's finding as the keeper holds one. -func storedFinding(f seat.Finding) seat.FindingsByKeyValue { +// storedFinding is the event's finding as the keeper holds one, with its key: the finding as the contract has it (design 47 +// G §437, A-22), under the key its scope, id and ref or kind make. +func storedFinding(f seat.Finding) (string, []byte, error) { + if f.Evidence == nil { + f.Evidence = []seat.Evidence{} + } + if f.Since.IsZero() { + f.Since = f.At + } + value, err := json.Marshal(f) + return findingKey(f), value, err +} + +// findingKey is the key a finding is held under: its scope, its id, and its ref where it has one, else its kind (ADR 0564). +func findingKey(f seat.Finding) string { token := f.Ref if token == "" { token = f.Kind } - evidence := f.Evidence - if evidence == nil { - evidence = []seat.Evidence{} - } - return seat.FindingsByKeyValue{V: cc.FindingVersion, Key: cc.Key(f.Scope, f.ID, token), Kind: f.Kind, Subject: f.Subject, - Severity: f.Severity, Summary: f.Summary, Headline: f.Headline, Explanation: f.Explanation, Needs: f.Needs, - Actions: f.Actions, Evidence: evidence, Source: f.Source, Resolver: f.Resolver, Observer: f.Observer, Owner: f.Owner, - Ref: f.Ref, Run: f.Run, WaitingOn: f.WaitingOn, At: f.At} + return cc.Key(f.Scope, f.ID, token) } // MachinePresence takes the watchman's word that a machine is away or back (design 47 G §389): an observer on a machine @@ -319,7 +324,7 @@ func (k *Keeper) FindingsStated(observer, node string, body []byte) error { v.Asks = map[string]time.Time{} named := map[string]bool{} for _, f := range s.Findings { - named[storedFinding(f).Key] = true + named[findingKey(f)] = true } oldPending := v.Pending v.Pending = map[string]time.Time{} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/hold.go b/modules/mesh-conditions/cmd/mesh-conditions/hold.go index 8c9247581..14d787778 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/hold.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/hold.go @@ -88,7 +88,7 @@ func (k *Keeper) motion(node, module string) (motion.Motion, motion.Want) { } // holdFor says whether key is held now, and as what. Called with mu held. -func (k *Keeper) holdFor(key string, f *seat.FindingsByKeyValue, open bool) (held, bool) { +func (k *Keeper) holdFor(key string, f *seat.Finding, open bool) (held, bool) { if open || f == nil { return held{}, false } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go index 4b600e93c..f1aeb4a3a 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go @@ -111,7 +111,7 @@ type Keeper struct { AskBound time.Duration mu sync.Mutex - findings map[string]map[string]seat.FindingsByKeyValue + findings map[string]map[string]seat.Finding blind map[string]string attempts map[string]meshhealers.AttemptsByAttemptValue wants map[string]motion.Want @@ -157,7 +157,7 @@ type Keeper struct { func (k *Keeper) init() { if k.findings == nil { - k.findings, k.blind, k.attempts = map[string]map[string]seat.FindingsByKeyValue{}, map[string]string{}, + k.findings, k.blind, k.attempts = map[string]map[string]seat.Finding{}, map[string]string{}, map[string]meshhealers.AttemptsByAttemptValue{} k.wants, k.haves, k.stuck, k.held, k.cleared = map[string]motion.Want{}, map[string]motion.Have{}, map[string]string{}, map[string]held{}, map[string]clearedMemo{} @@ -180,7 +180,7 @@ func (k *Keeper) Finding(observer, key string, value []byte, deleted bool) error k.mu.Lock() k.init() if k.findings[observer] == nil { - k.findings[observer] = map[string]seat.FindingsByKeyValue{} + k.findings[observer] = map[string]seat.Finding{} } if deleted { delete(k.findings[observer], key) @@ -191,9 +191,9 @@ func (k *Keeper) Finding(observer, key string, value []byte, deleted bool) error k.Logf("%s's finding %s is not one the keeper can raise, so it is left as it stood: %v", observer, key, err) return nil } - if f.Key != key { + if fk := findingKey(f); fk != key { k.mu.Unlock() - k.Logf("%s's finding under %s names the key %s, so it is left as it stood", observer, key, f.Key) + k.Logf("%s's finding under %s names the key %s, so it is left as it stood", observer, key, fk) return nil } k.findings[observer][key] = f @@ -314,7 +314,7 @@ func (k *Keeper) settleAll(keys []string) error { // wanted is what the inputs say key should be: a finding of an observer. Nil when nothing says it; nothing is carried // from the controller's bucket (issue 1126). -func (k *Keeper) wanted(key string) *seat.FindingsByKeyValue { +func (k *Keeper) wanted(key string) *seat.Finding { observers := make([]string, 0, len(k.findings)) for o := range k.findings { observers = append(observers, o) @@ -390,7 +390,7 @@ func (k *Keeper) settleOnce(key string) error { } // derive is the condition key should be now, from what was open and what the inputs say. -func (k *Keeper) derive(key string, was Condition, open bool, f *seat.FindingsByKeyValue, now time.Time) Condition { +func (k *Keeper) derive(key string, was Condition, open bool, f *seat.Finding, now time.Time) Condition { var c Condition switch { case open: @@ -430,10 +430,10 @@ func (k *Keeper) derive(key string, was Condition, open bool, f *seat.FindingsBy // fromFinding is what the condition says of its finding: its words, subject, severity and source, and its evidence // newest first (clause 2). A healer's escalation is not undone by the finding saying a warning again. -func (k *Keeper) fromFinding(c *Condition, f seat.FindingsByKeyValue, now time.Time) { +func (k *Keeper) fromFinding(c *Condition, f seat.Finding, now time.Time) { escalated := c.Escalated() c.Kind, c.Subject, c.Summary, c.Source = f.Kind, f.Subject, f.Summary, f.Source - c.Headline, c.Explanation, c.Resolved, c.Needs, c.Actions = f.Headline, f.Explanation, f.Resolved, f.Needs, f.Actions + c.Headline, c.Explanation, c.Needs, c.Actions = f.Headline, f.Explanation, f.Needs, f.Actions if !(escalated && f.Severity != cc.Urgent) { c.Severity = f.Severity } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index 0578a1dd9..a4a3728a0 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" @@ -17,6 +18,7 @@ import ( seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/views" ) // fakeStore is the bus's states in memory, with revisions, as the runtime answers them. @@ -162,12 +164,11 @@ func (r *rig) open(key string) (Condition, bool) { } // encodeFinding writes a finding as an observer does: the seat's layout, checked. -func encodeFinding(f seat.FindingsByKeyValue) ([]byte, error) { - f.V = cc.FindingVersion +func encodeFinding(f seat.Finding) ([]byte, error) { if f.Evidence == nil { f.Evidence = []seat.Evidence{} } - if err := cc.CheckFinding(f); err != nil { + if err := views.CheckFinding(f); err != nil { return nil, err } return json.Marshal(f) @@ -176,14 +177,14 @@ func encodeFinding(f seat.FindingsByKeyValue) ([]byte, error) { // finding is an observer's finding as mesh-sdk's layout writes it (encodeFinding). func finding(t *testing.T, observer, scope, id, kind, source string, sev string, at time.Time) (string, []byte) { t.Helper() - f := seat.FindingsByKeyValue{Key: cc.Key(scope, id, kind), Kind: kind, Subject: seat.Subject{Scope: scope, ID: id}, + f := seat.Finding{Scope: scope, ID: id, Since: at, Kind: kind, Subject: seat.Subject{Scope: scope, ID: id}, Severity: sev, Summary: kind + " of " + id, Headline: kind, Explanation: "It is " + kind + ".", Source: source, Observer: observer, At: at, Evidence: []seat.Evidence{{At: at, Said: kind}}} raw, err := encodeFinding(f) if err != nil { t.Fatal(err) } - return f.Key, raw + return cc.Key(scope, id, kind), raw } func mustJSON(t *testing.T, v any) []byte { @@ -694,3 +695,28 @@ func TestTheKeeperSetsItsViewFromWhatAnObserverStates(t *testing.T) { } } } + +// The keeper's own finding goes in through the generic writer's wire form and is held as the contract's Finding, one that is +// not a finding the contract allows is refused, and a clear removes it (design 47 G A-22, §687). +func TestTheKeepersOwnFindingIsTheContractsFindingByItsWireForm(t *testing.T) { + r := newRig() + own := ownFindings{r.k} + wire := map[string]any{"kind": "view-unanswered", "scope": "module", "id": "mesh-conditions.assigner.wants", "severity": "warning", + "source": "views", "subject": map[string]any{"scope": "module", "id": Module}, "summary": "no first view", "at": r.now} + if err := own.WriteWire(context.Background(), wire); err != nil { + t.Fatal(err) + } + key := cc.Key("module", "mesh-conditions.assigner.wants", "view-unanswered") + if c, ok := r.open(key); !ok || c.Summary != "no first view" || c.Kind != "view-unanswered" { + t.Fatalf("the finding raised no condition: %+v %v", c, ok) + } + if err := own.WriteWire(context.Background(), map[string]any{"scope": "module", "id": "x", "kind": "a.b", "severity": "warning"}); err == nil { + t.Fatal("a kind with a dot was taken") + } + if err := own.Clear(context.Background(), "module", "mesh-conditions.assigner.wants", "view-unanswered", "no longer observed"); err != nil { + t.Fatal(err) + } + if _, ok := r.open(key); ok { + t.Fatal("the condition stands after its finding was cleared") + } +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/main.go b/modules/mesh-conditions/cmd/mesh-conditions/main.go index 90fd771f6..2a72170f7 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/main.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/main.go @@ -18,6 +18,7 @@ import ( "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" catseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -170,24 +171,45 @@ func NewViews(k *Keeper) *views.Reader { return v } -// ownFindings takes the keeper's own finding straight into its picture of findings. +// ownFindings takes the keeper's own finding straight into its picture of findings. It is the WireWriter a views reader +// writes through (design 47 G §687, A-22): the finding's wire form, read as the contract's Finding, as an observer's is. type ownFindings struct{ k *Keeper } -func (o ownFindings) Put(f views.Finding) error { - v := meshconditionsclient.FindingFor(f) - if err := meshconditionsclient.CheckFinding(v); err != nil { - return err - } - value, err := json.Marshal(v) +func (o ownFindings) WriteWire(_ context.Context, wire map[string]any) error { + raw, err := json.Marshal(wire) if err != nil { return err } - return o.k.Finding(Module, v.Key, value, false) + var f seat.Finding + if err := json.Unmarshal(raw, &f); err != nil { + return fmt.Errorf("the keeper's own finding is not readable: %w", err) + } + f.Observer = Module + if f.At.IsZero() { + f.At = o.k.Now().UTC() + } + if f.Since.IsZero() { + f.Since = f.At + } + if f.Evidence == nil { + f.Evidence = []seat.Evidence{} + } + if err := views.CheckFinding(f); err != nil { + return err + } + key, value, err := storedFinding(f) + if err != nil { + return err + } + return o.k.Finding(Module, key, value, false) } -func (o ownFindings) Delete(f views.Finding) error { - return o.k.Finding(Module, meshconditionsclient.Key(meshconditionsclient.ScopeModule, f.ID, f.Kind), nil, true) + +func (o ownFindings) Clear(_ context.Context, scope, id, kind, _ string) error { + return o.k.Finding(Module, meshconditionsclient.Key(scope, id, kind), nil, true) } +func (ownFindings) ModuleNode() (string, string) { return Module, "" } + func main() { k := NewKeeper() v := NewViews(k) diff --git a/modules/mesh-conditions/cmd/mesh-conditions/model.go b/modules/mesh-conditions/cmd/mesh-conditions/model.go index 36d4d0247..cc229449a 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/model.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/model.go @@ -13,6 +13,7 @@ import ( seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" meshhealers "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers" + "git.novox.be/novox/mesh-sdk/go/views" cc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" ) @@ -213,8 +214,8 @@ func order(list []Condition) { // decodeFinding reads an observer's finding of the seat's state `findings`, refusing one the keeper could not make a // condition of. A finding written before `ref` was named has it as `token`. -func decodeFinding(data []byte) (seat.FindingsByKeyValue, error) { - var f seat.FindingsByKeyValue +func decodeFinding(data []byte) (seat.Finding, error) { + var f seat.Finding if err := json.Unmarshal(data, &f); err != nil { return f, fmt.Errorf("a finding is not readable: %w", err) } @@ -225,10 +226,7 @@ func decodeFinding(data []byte) (seat.FindingsByKeyValue, error) { _ = json.Unmarshal(data, &old) f.Ref = old.Token } - if f.V != cc.FindingVersion { - return f, fmt.Errorf("a finding of version %d, and this reads %d", f.V, cc.FindingVersion) - } - return f, cc.CheckFinding(f) + return f, views.CheckFinding(f) } // triedOf is the attempt as the condition carries it in `tried`.