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) } })