diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index 9e51e68d..fe6b8a86 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -514,6 +514,73 @@ func (k *KB) judgeSet(ctx context.Context) []Finding { return findings } +// findingPrefix keys the notice of one whole-set finding: the prefix, the rule, and the records it names. +const findingPrefix = "knowledge.finding." + +func findingKey(f Finding) string { + names := make([]string, len(f.Records)) + for i, r := range f.Records { + names[i] = strings.ReplaceAll(strings.ToLower(r), " ", "-") + } + return findingPrefix + f.Rule + "." + strings.Join(names, ".") +} + +// TellFindings, called by the background work and never under the write lock, tells the operator, once, each finding of the rules that judge the whole record set, as a warning +// naming the rule and the records, and clears the notice once the rule holds for them again. A finding is a fault +// of the records, which their authors repair; it is not the module failing, so it leaves the module's health as it +// is and never holds the module's own rollouts (the operator's decision of 2026-10-12 on novox/hq issue 504, as +// issue 508 decided for a proposal never reviewed). A notice that cannot be sent is not kept as told, and is sent +// again at the next step. +func (k *KB) TellFindings() { + if k.Notify == nil || k.Clear == nil { + return + } + ctx := context.Background() + findings, err := k.Store.Findings(ctx) + if err != nil { + k.logf("[mesh-knowledge] the whole-set findings cannot be read to tell them: %v", err) + return + } + now := map[string]bool{} + for _, f := range findings { + key := findingKey(f) + now[key] = true + first, err := k.Store.Told(ctx, key) + if err != nil || !first { + continue + } + records := strings.Join(f.Records, ", ") + summary := fmt.Sprintf("The rule %s, which judges the whole record set, fails: %s.", f.Rule, f.Says) + explanation := "The knowledge base works; its records break a rule. The acceptance stands. The author of " + records + + " repairs it: a decision cited by nothing names its home in a progressive insight (propose with homes and insight), " + + "or a record that cites it is accepted. get and links show the record." + resolved := fmt.Sprintf("The rule %s holds again for %s", f.Rule, records) + if err := k.Notify(key, summary, "A rule of the record set fails: "+records, explanation, resolved); err != nil { + k.logf("[mesh-knowledge] the operator could not be told that %s fails for %s: %v; told again at the next step", f.Rule, records, err) + if uerr := k.Store.Untold(ctx, key); uerr != nil { + k.logf("[mesh-knowledge] and that it was not told could not be kept: %v", uerr) + } + } + } + told, err := k.Store.ToldOf(ctx, findingPrefix) + if err != nil { + k.logf("[mesh-knowledge] the notices of whole-set findings cannot be read: %v", err) + return + } + for _, key := range told { + if now[key] { + continue + } + if err := k.Clear(key, ""); err != nil { + k.logf("[mesh-knowledge] the notice %s could not be cleared: %v; it is cleared again at the next step", key, err) + continue + } + if err := k.Store.Untold(ctx, key); err != nil { + k.logf("[mesh-knowledge] the notice %s was cleared, and that could not be kept: %v", key, err) + } + } +} + // ---- reservations that were never proposed ------------------------------------------------------------------- // Withdraw withdraws every number reserved longer ago than the settings allow and never proposed: it stays spent, diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index d4f20e7f..c3573e2c 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -164,6 +164,8 @@ func background(k *KB) { logf("[mesh-knowledge] withdrew %d reservation(s) never proposed", n) } review() + // The keeper judges the record set in a process of its own, which tells nobody: its findings are told here. + k.TellFindings() for { said, err := k.DraftStep() if err != nil { @@ -552,10 +554,11 @@ type HealthAnswer struct { At time.Time `json:"at,omitempty"` } -// Health is whether the knowledge base works: unhealthy when its database cannot serve, or when a rule that judges -// the whole record set fails — the mesh raises a condition naming the rule and the records. A proposal that ran out -// of review tries waits on its author, not on the module: it is told to its author and the operator, and named -// here, but leaves the module healthy, so it never holds the module's own rollouts (novox/hq issue 508). +// Health is whether the knowledge base works: unhealthy when its database cannot serve. A rule that judges the whole +// record set and fails is a fault of the records, which their authors repair, not of the module: it is told to the +// operator as a warning naming the rule and the records (TellFindings), and named here, but leaves the module +// healthy (the operator's decision of 2026-10-12 on novox/hq issue 504). So does a proposal that ran out of review +// tries (novox/hq issue 508). Neither holds the module's own rollouts. func (k *KB) Health(ready Healther) HealthAnswer { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() @@ -586,17 +589,14 @@ func (k *KB) Health(ready Healther) HealthAnswer { waiting = fmt.Sprintf("%d proposed record(s) could not be reviewed in %d tries and wait for their authors to propose them again: %s", n, MostReviewTries, strings.Join(unreviewed, "; ")) } + why := "the database serves and every rule that judges the whole record set holds" if len(says) > 0 { if len(says) > 8 { says = append(says[:8], fmt.Sprintf("and %d more", len(says)-8)) } - why := fmt.Sprintf("%d rule(s) that judge the whole record set fail: %s", len(findings), strings.Join(says, "; ")) - if waiting != "" { - why += ". " + waiting - } - return HealthAnswer{State: "unhealthy", Why: why} + why = fmt.Sprintf("the database serves; %d rule(s) that judge the whole record set fail, told to the operator as a warning "+ + "for the records' authors to repair: %s", len(findings), strings.Join(says, "; ")) } - why := "the database serves and every rule that judges the whole record set holds" if waiting != "" { why += "; " + waiting } @@ -605,10 +605,11 @@ func (k *KB) Health(ready Healther) HealthAnswer { func healthTool(status string, now func() time.Time) stdio.Tool { return stdio.Tool{Name: HealthTool, - Description: "How the knowledge base is, as its watch last saw it: healthy when its database serves this version and every " + - "rule that judges the whole record set holds; unhealthy otherwise, naming the rule and the records — a decision accepted " + - "that nothing cites, a supersession or a change said on one end only, a record extending a superseded one. A " + - "proposed record that ran out of review tries is named but leaves it healthy: it waits on its author (novox/hq issue 508).", + Description: "How the knowledge base is, as its watch last saw it: healthy when its database serves this version, unhealthy " + + "otherwise. A rule that judges the whole record set and fails — a decision accepted that nothing cites, a supersession " + + "or a change said on one end only, a record extending a superseded one — is named but leaves it healthy: it is told " + + "to the operator as a warning for the records' authors (novox/hq issue 504). So is a proposed record that ran out of " + + "review tries: it waits on its author (novox/hq issue 508).", Run: func(map[string]any) (any, error) { return ReadStatus(status, now()), nil }} } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/wholeset_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/wholeset_test.go index 2597eac6..5fa8a517 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/wholeset_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/wholeset_test.go @@ -1,6 +1,8 @@ package main import ( + "context" + "errors" "strings" "testing" ) @@ -45,9 +47,9 @@ func TestADecisionNothingWouldCiteIsNotAccepted(t *testing.T) { // A new decision is cited by a record proposed after it: no record can link a number only reserved. Once the // citing record is accepted, the new one proposed again is accepted. A correction of the citing record that drops -// the link still leaves the decision cited by nothing, and that is loud: to-be 55 §10, "an acceptance that leaves a -// decision cited by nothing raises the condition", the condition the mesh raises when the module's health answers -// unhealthy. +// the link still leaves the decision cited by nothing, and that is loud: told to the operator once, as a warning +// naming the rule and the record, while the module stays healthy and its rollouts are not held (the operator's +// decision of 2026-10-12 on novox/hq issue 504; to-be 55 §10). A record that cites it clears the notice. func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLoud(t *testing.T) { eachStore(t, func(t *testing.T, s Store) { w := imported(t, s) @@ -66,14 +68,33 @@ func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLo if h := w.k.Health(nil); !h.Healthy { t.Fatalf("unhealthy with both accepted, one citing the other: %s", h.Why) } + w.k.TellFindings() + if len(w.told) != 0 { + t.Fatalf("told with every rule holding: %v", w.told) + } // A correction of the citing record drops its link: the rule, run after the acceptance, says so. b.Links = b.Links[:len(b.Links)-1] b.Insight = "it cited " + itoaKey(n) w.accepted(t, "author@g14", b) h := w.k.Health(nil) - if h.Healthy || h.State != "unhealthy" || !strings.Contains(h.Why, "the rule cited") || !strings.Contains(h.Why, itoaKey(n)) { - t.Fatalf("a decision left cited by nothing left the module %+v", h) + if !h.Healthy || !strings.Contains(h.Why, "the rule cited") || !strings.Contains(h.Why, itoaKey(n)) { + t.Fatalf("a decision left cited by nothing: the health should stay healthy and name it, not %+v", h) } + w.k.TellFindings() + w.k.TellFindings() + if len(w.told) != 1 || !strings.Contains(w.told[0], "rule cited") || !strings.Contains(w.told[0], itoaKey(n)) || + !strings.HasPrefix(w.toldKeys[0], findingPrefix) { + t.Fatalf("the operator is not told once, naming the rule and the record: %v %v", w.told, w.toldKeys) + } + // A restart tells nothing again: what was told is kept in the store. + told := w.toldKeys[0] + restarted := newWorld(t, s, fixture()) + restarted.clock = w.clock + restarted.k.TellFindings() + if len(restarted.told) != 0 { + t.Fatalf("told again after a restart: %v", restarted.told) + } + w.told = nil if mustGet(t, s, Key{KindDecision, n}).Current.Status != StatusAccepted { t.Fatal("the acceptance was undone") } @@ -85,6 +106,14 @@ func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLo if h := w.k.Health(nil); strings.Contains(h.Why, itoaKey(n)+" is accepted and nothing cites it") { t.Fatalf("still said after a record cited it: %s", h.Why) } + w.k.TellFindings() + if len(w.cleared) != 1 || w.cleared[0][0] != told { + t.Fatalf("the notice is not cleared once the rule holds again: %v", w.cleared) + } + w.k.TellFindings() + if len(w.cleared) != 1 || len(w.told) != 0 { + t.Fatalf("after the clear: cleared %v, told %v", w.cleared, w.told) + } }) } @@ -135,3 +164,29 @@ func TestEachWholeSetRuleFails(t *testing.T) { t.Errorf("a consistent set fails: %+v", f) } } + +// A finding's notice the operator's channel refused is not kept as told: it is sent again at the next step. +func TestAFindingsNoticeThatFailsIsSentAgain(t *testing.T) { + eachStore(t, func(t *testing.T, s Store) { + w := newWorld(t, s, fixture()) + f := Finding{Rule: "cited", Records: []string{"ADR 0300"}, Says: "ADR 0300 is accepted and nothing cites it"} + if err := s.SetFindings(context.Background(), []Finding{f}); err != nil { + t.Fatal(err) + } + tries := 0 + w.k.Notify = func(key, summary, headline, explanation, resolved string) error { + tries++ + if tries == 1 { + return errors.New("nothing answers operator-channel.notify") + } + w.told = append(w.told, summary) + return nil + } + w.k.TellFindings() + w.k.TellFindings() + w.k.TellFindings() + if tries != 2 || len(w.told) != 1 { + t.Fatalf("a refused notice: %d tries, told %v", tries, w.told) + } + }) +}