From 2051d8b80b07c817d72d3c9bdc24bcbfd82af7ad Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 12:51:53 +0200 Subject: [PATCH 1/4] mesh-knowledge: ask changes in the review for a decision nothing would cite, so its acceptance never turns the module unhealthy (hq issue 504) The whole-set rule cited ran only after acceptance: the review approved a decision with no home and no record linking it, the rule then failed, and the module's health turned red for a fault its author could only repair after the fact. hq held the rule at the record's own merge. The review now judges it ahead, before the judgement: such a decision stays proposed with a changes finding naming the rule. It is not refused at propose, because no record can link a number only reserved. --- modules/mesh-knowledge/README.md | 5 ++ .../cmd/mesh-knowledge/kit_test.go | 6 +- .../mesh-knowledge/cmd/mesh-knowledge/main.go | 5 +- .../cmd/mesh-knowledge/review.go | 18 +++- .../cmd/mesh-knowledge/rules.go | 30 +++++++ .../cmd/mesh-knowledge/wholeset_test.go | 84 +++++++++++++++---- 6 files changed, 127 insertions(+), 21 deletions(-) diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 020e1ffb..09a46b4e 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -135,6 +135,11 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel A failure is kept as a finding and makes the module unhealthy, so the mesh raises a condition naming the rule and the records. The acceptance stands. + `cited` is also judged ahead, in the review: a decision that names no home and that no accepted (or + superseded) record links is not judged and not accepted. Its review, by the knowledge base itself, asks changes + naming the rule (hq issue 504). It is not refused at propose, because no record can link a number only + reserved: a record citing a new one is proposed after it and accepted, and the new one is proposed again. + ## The import `import` reads hq through the records module at the commit its checkout stands at. With `commit`, it refuses a diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go index ddcf60f1..570f5c5c 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go @@ -436,9 +436,13 @@ func valid(n int) ProposeAsk { {Name: "Consequences", Text: "Merges land sooner."}, {Name: "How it is checked", Text: "The controller's merge-window tests."}, }, - Links: []LinkAsk{{Kind: LinkExtends, To: "0276"}, {Kind: LinkChanges, To: "0276 §1", From: 1}}} + Links: []LinkAsk{{Kind: LinkExtends, To: "0276"}, {Kind: LinkChanges, To: "0276 §1", From: 1}}, + Homes: []string{testHome}} } +// testHome is the hq design valid names as its home: a decision is accepted only cited (novox/hq issue 504). +const testHome = "03-DESIGN/01-to-be/55-the-knowledge-base.md" + // imported is a world whose knowledge base holds the fixture's records. func imported(t *testing.T, s Store) *world { t.Helper() diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 02a7a349..d4f20e7f 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -918,7 +918,8 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { "link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " + "proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " + "stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " + - "records it links and the rules: on approve it is accepted (a record that needs the operator waits for the operator's " + + "records it links and the rules. A decision that names no home and that no accepted record links is not judged: " + + "its review asks changes for the whole-set rule cited, which its acceptance would break. On approve it is accepted (a record that needs the operator waits for the operator's " + "Approve), on changes it stays proposed with the findings for its author to revise, and when no judgement can be had " + "it stays proposed and says so. Nothing proposed is in force.", Input: schema([]string{"number", "title"}, map[string]any{ @@ -933,7 +934,7 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { "links": map[string]any{"type": "array", "description": "its links: kind, to (\"0276\", \"0276 §4\", \"research 045\", \"issue 365\", a design's path), from (the clause it goes out from)", "items": map[string]any{"type": "object", "properties": map[string]any{"kind": map[string]any{"type": "string", "enum": LinkKinds}, "to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}}, - "homes": texts("hq documents that cite it, such as a design's path"), + "homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"), "needs-operator": flag("it holds only once the operator approves it"), "status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"}, "insight": text("for an accepted record: a progressive insight, saying what stood before"), diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/review.go b/modules/mesh-knowledge/cmd/mesh-knowledge/review.go index 917810f4..0fe414a2 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/review.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/review.go @@ -408,7 +408,10 @@ func (k *KB) reviewOne(key Key) (string, error) { k.mu.Lock() e, err := k.Store.Get(ctx, key) var linked []*Record + var all []*Entry + var allErr error if err == nil && e != nil { + all, allErr = k.Store.All(ctx) seen := map[Key]bool{key: true} for _, l := range e.Working().Links { to, ok := l.To.Record() @@ -430,9 +433,20 @@ func (k *KB) reviewOne(key Key) (string, error) { var verdict string var findings []string by := ReviewBy - if len(question) > MostReviewQuestion { + uncited := "" + if allErr == nil { + uncited = UncitedOnAcceptance(w, all) + } + switch { + case allErr != nil: + err = fmt.Errorf("the record set cannot be read to judge whether a record cites it: %v", allErr) + case uncited != "": + // The whole-set rule cited, judged ahead: changes, asked by the knowledge base itself without a judgement, + // so its acceptance never turns the module's health red for it (novox/hq issue 504). + verdict, findings = VerdictChanges, []string{"the rule cited, which judges the whole record set: " + uncited} + case len(question) > MostReviewQuestion: err = fmt.Errorf("the record is %d bytes as a question, and a judgement takes at most %d: shorten it", len(question), MostReviewQuestion) - } else { + default: var answer json.RawMessage var judge string answer, judge, err = k.Judge(question, reviewSchema, k.judgeModel()) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go index 86c812b6..2b7c5b83 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go @@ -546,6 +546,36 @@ func JudgeSet(records []*Record) []Finding { return out } +// UncitedOnAcceptance is the whole-set rule `cited` judged ahead, on a decision's working version as it would +// stand once accepted, over the records as JudgeSet reads them: "" when it names a home or a record accepted (or +// superseded) links it, else what the rule would say. hq held this rule at the merge of the record's own pull +// request, which carried the design citing it; the knowledge base holds it in the review, before the judgement, so +// an acceptance never leaves a decision cited by nothing (novox/hq issue 504). It is not refused at propose: no +// record can link a number only reserved, so a record that will cite a new one is proposed after it, accepted, and +// the new one proposed again. A citing record changed by a correction after the acceptance is what the rule, run +// after every acceptance, still says. An imported record was held to hq's checks, and its correction is not judged +// here, as JudgeSet does not judge it. +func UncitedOnAcceptance(r *Record, all []*Entry) string { + if r.Kind != KindDecision || len(r.Homes) > 0 || (r.Pending != nil && r.AcceptedAt.IsZero()) { + return "" + } + k := r.Key() + for _, e := range all { + from := e.Current + if from.Key() == k || !(from.Accepted() || from.Status == StatusSuperseded) { + continue + } + for _, l := range from.Links { + if to, ok := l.To.Record(); ok && to == k { + return "" + } + } + } + return fmt.Sprintf("%s would be accepted with nothing citing it: it names no home and no accepted record links it. Name "+ + "its home (homes: the hq design or playbook that cites it), or have the record that links it accepted first; then "+ + "propose this one again", k) +} + // RuleFrom is the day each dated rule began to hold a record, as hq's checks hold it: a record dated earlier was // never held to it, and a dated correction of it (a progressive insight, which keeps the record's date) is not. // words.py held the retired words from 2026-10-07 and the daemons' lines from 2026-10-09; cycle.py the deferrals diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/wholeset_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/wholeset_test.go index e541332a..2597eac6 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/wholeset_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/wholeset_test.go @@ -5,32 +5,84 @@ import ( "testing" ) -// to-be 55 §10: "A whole-set rule that fails is loud — the module's test: an acceptance that leaves a decision -// cited by nothing raises the condition." The condition is the mesh's, raised when the module's health answers -// unhealthy; the test reads the health answer the node-engine reads. -func TestAnAcceptanceThatLeavesADecisionCitedByNothingMakesTheModuleUnhealthy(t *testing.T) { +// A decision nothing would cite is not accepted: its review asks changes itself, without a judgement, naming the +// rule cited, and the module stays healthy (novox/hq issue 504). Before, the review approved it, the acceptance +// broke the rule, and the module turned unhealthy for a record its author could only repair after the fact. +func TestADecisionNothingWouldCiteIsNotAccepted(t *testing.T) { eachStore(t, func(t *testing.T, s Store) { w := imported(t, s) - if h := w.k.Health(nil); !h.Healthy { - t.Fatalf("unhealthy after the import of real records: %s", h.Why) - } n := w.reserved(t, "author@g14") - w.accepted(t, "author@g14", valid(n)) + p := valid(n) + p.Homes = nil + if _, err := w.k.Propose("author@g14", p); err != nil { + t.Fatal(err) + } + judged := len(w.judge.reviews) + w.reviewAll(t) + e := mustGet(t, s, Key{KindDecision, n}) + if e.Current.Status != StatusProposed { + t.Fatalf("a decision nothing cites was %s by its review", e.Current.Status) + } + rv := e.Current.Reviews + if len(rv) == 0 || rv[len(rv)-1].Verdict != VerdictChanges || !strings.Contains(rv[len(rv)-1].Findings, "the rule cited") || + rv[len(rv)-1].By != ReviewBy { + t.Fatalf("its review asked no changes for the rule cited: %+v", rv) + } + if len(w.judge.reviews) != judged { + t.Fatal("a judgement was spent on a record the rule cited refuses") + } + if h := w.k.Health(nil); !h.Healthy { + t.Fatalf("unhealthy for a record not accepted: %s", h.Why) + } + // Named with its home, it is accepted, and the module stays healthy. + p.Homes = []string{testHome} + w.accepted(t, "author@g14", p) + if h := w.k.Health(nil); !h.Healthy { + t.Fatalf("unhealthy after a decision with its home was accepted: %s", h.Why) + } + }) +} + +// 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. +func TestARecordAcceptedCitingANewDecisionLetsItBeAcceptedAndDroppingTheLinkIsLoud(t *testing.T) { + eachStore(t, func(t *testing.T, s Store) { + w := imported(t, s) + n := w.reserved(t, "author@g14") + a := valid(n) + a.Homes = nil + if _, err := w.k.Propose("author@g14", a); err != nil { + t.Fatal(err) + } + w.reviewAll(t) + m := w.reserved(t, "author@g14") + b := valid(m) + b.Links = append(b.Links, LinkAsk{Kind: LinkCites, To: itoa4(n)}) + w.accepted(t, "author@g14", b) + w.accepted(t, "author@g14", a) // its revision: an accepted record links it now + if h := w.k.Health(nil); !h.Healthy { + t.Fatalf("unhealthy with both accepted, one citing the other: %s", h.Why) + } + // 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("an accepted decision nothing cites left the module %+v", h) + t.Fatalf("a decision left cited by nothing left the module %+v", h) } - // The acceptance stands: the condition says it, and undoes nothing. if mustGet(t, s, Key{KindDecision, n}).Current.Status != StatusAccepted { t.Fatal("the acceptance was undone") } // A record that cites it heals it. - m := w.reserved(t, "author@g14") - p := valid(m) - p.Links = append(p.Links, LinkAsk{Kind: LinkCites, To: itoa4(n)}) - w.accepted(t, "author@g14", p) - h = w.k.Health(nil) - if strings.Contains(h.Why, itoaKey(n)+" is accepted and nothing cites it") { + k := w.reserved(t, "author@g14") + c := valid(k) + c.Links = append(c.Links, LinkAsk{Kind: LinkCites, To: itoa4(n)}) + w.accepted(t, "author@g14", c) + 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) } }) From ca3c3e6dfd0e9509af80dbd44e826d8ca657b428 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 13:11:36 +0200 Subject: [PATCH 2/4] mesh-knowledge tests: set the local zone once before any goroutine, not under a running bus server (race found by the repository check on #237) inZone wrote time.Local while the bus test's nats server read it from a goroutine that outlives its test; the race detector the repository check now runs failed TestAReviewOwedOutlivesARestartAndItsLastTryIsSaid. --- .../mesh-knowledge/cmd/mesh-knowledge/kit_test.go | 3 +++ .../cmd/mesh-knowledge/review_owed_test.go | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go index 570f5c5c..422a5597 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go @@ -40,6 +40,9 @@ func TestMain(m *testing.M) { serve(k, os.Getenv("MESH_KNOWLEDGE_STATUS")) os.Exit(0) } + // Every test runs in a zone that is not UTC, so a time said locally is proven to be, wherever the tests run. It + // is set once, before any goroutine starts: a test that set it while a bus test's server read it raced. + time.Local = testZone code := m.Run() stopPostgres() os.Exit(code) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/review_owed_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/review_owed_test.go index c094a35f..2454746b 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/review_owed_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/review_owed_test.go @@ -155,13 +155,16 @@ func TestProposedAgainAfterTheLastTryClearsTheNotice(t *testing.T) { }) } -// inZone sets the local zone to one that is not UTC for one test, so a time said locally is proven to be, wherever -// the test runs. +// testZone is the local zone every test runs in, set once by TestMain: not UTC. +var testZone = time.FixedZone("CEST", 2*60*60) + +// inZone says the test needs the local zone not to be UTC; TestMain set it before any goroutine started. Setting it +// here, under a running bus server's goroutines, was a data race the race detector failed. func inZone(t *testing.T) { t.Helper() - was := time.Local - time.Local = time.FixedZone("CEST", 2*60*60) - t.Cleanup(func() { time.Local = was }) + if time.Local != testZone { + t.Fatal("the local zone is not the tests' own: TestMain sets it") + } } // novox/hq issue 497: a clear the operator's channel refused keeps its notice as told, and is sent again at the next From 8f5e8bcb6e70064c155942964d1ba6565929869b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 13:25:09 +0200 Subject: [PATCH 3/4] mesh-knowledge: tell a failed whole-set rule to the operator as a warning naming the records, and leave the module healthy (hq issue 504) The operator decided on 2026-10-12 that a rule of the record set that fails is a fault of the records, which their authors repair, not the module failing: as issue 508 decided for a proposal never reviewed, health says only whether the module works, and a finding must not hold the module's own rollouts. The findings go through the notice the knowledge base already sends (operator-channel.notify, a warning), once per finding, kept as told across restarts, and cleared when the rule holds again. --- .../mesh-knowledge/cmd/mesh-knowledge/kb.go | 67 +++++++++++++++++++ .../mesh-knowledge/cmd/mesh-knowledge/main.go | 29 ++++---- .../cmd/mesh-knowledge/wholeset_test.go | 65 ++++++++++++++++-- 3 files changed, 142 insertions(+), 19 deletions(-) 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) + } + }) +} From 56a9a1b082cb10ab65930c4f45ca923726a60186 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 13:27:06 +0200 Subject: [PATCH 4/4] mesh-knowledge README: say a failed whole-set rule is a warning to the operator and leaves the module healthy (hq issue 504) --- modules/mesh-knowledge/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 09a46b4e..654c214f 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -132,8 +132,11 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel - `partial`: both ends of a change agree; - `rests-on-live`: no accepted record extends a superseded one. - A failure is kept as a finding and makes the module unhealthy, so the mesh raises a condition naming the rule - and the records. The acceptance stands. + A failure is kept as a finding and told to the operator once, as a warning naming the rule and the records + (`operator-channel.notify`, keyed `knowledge.finding..`, sent again at the next step when it + could not be sent, and cleared once the rule holds for them again). It is a fault of the records, which their + authors repair, not of the module: the module's health names it and stays healthy, so it never holds the + module's own rollouts (the operator's decision of 2026-10-12 on hq issue 504). The acceptance stands. `cited` is also judged ahead, in the review: a decision that names no home and that no accepted (or superseded) record links is not judged and not accepted. Its review, by the knowledge base itself, asks changes @@ -186,14 +189,14 @@ the consolidated schema for a fresh database is `schema.sql`. A test holds the t - **the bundle**: the verbs, `knowledge_health`, the operator's answers heard, and once a minute (and at once when a record is proposed) the router's record read, reservations never proposed withdrawn, the reviews of proposals, - and the drafting worker. It never changes the schema, and + and the drafting worker; and it tells the whole-set findings. It never changes the schema, and refuses to serve while the schema is behind. - **`prepare`** (run once): brings the schema to this version. - **`keep`** (judged by its unit): judges the whole record set every five minutes and keeps the findings. - **`watch`** (judged by `knowledge_health`, run as the machine's account, the one the runtime runs the bundle as, so the tool can read what it writes): every minute, reads whether the database serves and what the findings say, and leaves its verdict in `${dir:state}/health.json`. That is the file the health tool answers - from. A verdict older than three minutes is unhealthy. + from. A verdict older than three minutes is unhealthy; a whole-set finding is named in it but leaves it healthy. ## Settings