mesh-knowledge: a research effort's new version may leave out its sections

Closing a long research effort (its status and became links) meant retyping
its whole text through propose. A research effort's new version that gives no
sections now keeps the served version's, and its title, date and deciders when
none are given; the version so composed is judged and reviewed as any other.
A decision, or an effort with no served version, still needs its text.

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 11:48:17 +01:00
parent 80a7cee6f3
commit 285099a940
4 changed files with 125 additions and 41 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
| `links` | `number`, `kind` | the record's typed links, out and in |
| `history` | `number`, `kind`, `from`, `to`, `bound` | every version; with `from` and `to`, the difference between the two, line by line |
| `reserve` | `kind`, `for` | the next decision or research number, asked of the issue tracker (`issue-tracker.reserve`); spent whether or not its record is accepted |
| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`), but an active research effort takes new versions freely, each reviewed as a proposal. Each version proposed is then reviewed by the knowledge base itself (below) |
| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`), but an active research effort takes new versions freely, each reviewed as a proposal; such a version may leave out its sections (and title, date, deciders), keeping the served version's, as when the effort is closed with its status and became links. Each version proposed is then reviewed by the knowledge base itself (below) |
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
| `withdraw` | `number`, `kind`, `why`, `retraction` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn. With `retraction` true, the retraction waiting on the record is withdrawn instead, and the record holds as it did |
@@ -1,6 +1,7 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
@@ -13,44 +14,91 @@ func effortAsk(n int, status string, docs ...Section) ProposeAsk {
Links: []LinkAsk{{Kind: LinkTouches, To: "0290"}}}
}
// novox/hq issue 844: an active research effort takes new versions freely, each reviewed as a proposal.
// novox/hq issue 844: an active research effort takes new versions freely, each reviewed as a proposal. Issue 1966:
// a new version that leaves out its sections keeps the served version's text, title, date and deciders, and takes
// the status, links and why given; the rules still judge the version so composed.
func TestAnActiveEffortTakesANewVersionWithANewDocument(t *testing.T) {
w := imported(t, newMemStore())
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
if err != nil {
t.Fatal(err)
}
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
w.accepted(t, "author@g14", effortAsk(r.Number, StatusActive, overview))
key := Key{KindResearch, r.Number}
reviews := len(w.judge.reviews)
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."})
next.Why = "findings added"
if _, err := w.k.Propose("author@g14", next); err != nil {
t.Fatalf("an active effort refused a new version: %v", err)
}
e := mustGet(t, w.k.Store, key)
if e.Pending == nil || e.Current.Section("01-findings.md") != "" {
t.Fatalf("the new version was served before its review")
}
w.reviewAll(t)
q := w.judge.reviews[reviews]
if strings.Contains(q, "a correction of fact (a progressive insight)") {
t.Fatalf("the review judged a new version as an insight:\n%.400s", q)
}
e = mustGet(t, w.k.Store, key)
if e.Pending != nil || e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "A new finding." ||
len(e.Current.Insights) != 0 {
t.Fatalf("after the review: %s, pending %v, insights %v", e.Current.Status, e.Pending != nil, e.Current.Insights)
}
h, err := w.k.History(key, 0, 0, 0)
if err != nil {
t.Fatal(err)
}
for _, v := range h.Versions {
if v.Insight {
t.Errorf("version %d is marked an insight", v.N)
}
findings := Section{Name: "01-findings.md", Text: "A new finding."}
became := []LinkAsk{{Kind: LinkTouches, To: "0290"}, {Kind: LinkBecame, To: "0290"}}
for _, c := range []struct {
name string
next func(n int) ProposeAsk
refused string // the rule a refusal names; empty when the version is taken
status string
docs []Section
became bool
}{
{"a new document", func(n int) ProposeAsk { return effortAsk(n, StatusActive, overview, findings) },
"", StatusActive, []Section{overview, findings}, false},
{"closed with its sections left out", func(n int) ProposeAsk {
return ProposeAsk{Kind: KindResearch, Number: n, Status: StatusGraduated, Links: became}
}, "", StatusGraduated, []Section{overview}, true},
{"abandoned with its sections left out", func(n int) ProposeAsk {
return ProposeAsk{Kind: KindResearch, Number: n, Status: StatusAbandoned, Links: effortAsk(n, "").Links}
}, "", StatusAbandoned, []Section{overview}, false},
{"closed with its sections left out and no became link", func(n int) ProposeAsk {
return ProposeAsk{Kind: KindResearch, Number: n, Status: StatusGraduated, Links: effortAsk(n, "").Links}
}, "became", "", nil, false},
} {
t.Run(c.name, func(t *testing.T) {
w := imported(t, newMemStore())
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
if err != nil {
t.Fatal(err)
}
first := effortAsk(r.Number, StatusActive, overview)
w.accepted(t, "author@g14", first)
key := Key{KindResearch, r.Number}
reviews, versions := len(w.judge.reviews), mustGet(t, w.k.Store, key).Versions
next := c.next(r.Number)
next.Why = c.name
_, err = w.k.Propose("author@g14", next)
if c.refused != "" {
if err == nil || !strings.Contains(err.Error(), c.refused) {
t.Fatalf("want a refusal naming %s, got %v", c.refused, err)
}
if e := mustGet(t, w.k.Store, key); e.Pending != nil || e.Versions != versions {
t.Fatalf("a refused version was written: pending %v, %d versions", e.Pending != nil, e.Versions)
}
return
}
if err != nil {
t.Fatalf("an active effort refused a new version: %v", err)
}
e := mustGet(t, w.k.Store, key)
if e.Pending == nil || e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "" {
t.Fatalf("the new version was served before its review")
}
w.reviewAll(t)
q := w.judge.reviews[reviews]
if strings.Contains(q, "a correction of fact (a progressive insight)") {
t.Fatalf("the review judged a new version as an insight:\n%.400s", q)
}
if !strings.Contains(q, overview.Text) {
t.Fatalf("the review did not see the kept text:\n%.400s", q)
}
e = mustGet(t, w.k.Store, key)
cur := e.Current
if e.Pending != nil || cur.Status != c.status || len(cur.Insights) != 0 || !reflect.DeepEqual(cur.Sections, c.docs) {
t.Fatalf("after the review: %s, pending %v, insights %v, sections %+v", cur.Status, e.Pending != nil, cur.Insights, cur.Sections)
}
if cur.Title != first.Title || cur.Date != first.Date || !reflect.DeepEqual(cur.Deciders, first.Deciders) {
t.Fatalf("title, date or deciders not kept: %q %s %v", cur.Title, cur.Date, cur.Deciders)
}
if got := hasLink(cur, Link{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 290}}); got != c.became {
t.Fatalf("became link %v, want %v: %+v", got, c.became, cur.Links)
}
h, err := w.k.History(key, 0, 0, 0)
if err != nil {
t.Fatal(err)
}
for _, v := range h.Versions {
if v.Insight {
t.Errorf("version %d is marked an insight", v.N)
}
}
})
}
}
@@ -76,6 +124,25 @@ func TestAGraduatedEffortAndADecisionStillTakeOnlyAnInsight(t *testing.T) {
if _, err := w.k.Propose("author@g14", p); err == nil || !strings.Contains(err.Error(), "progressive insight") {
t.Fatalf("a decision took a plain version: %v", err)
}
// Issue 1966: only a research effort's new version keeps the served sections when it leaves them out. A
// decision's insight, and an effort with no served version, still need their text.
m, err := w.k.ReserveNumber("author@g14", KindResearch, "another effort")
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
name, rule string
ask ProposeAsk
}{
{"a decision's insight with no sections", "context", ProposeAsk{Kind: KindDecision, Number: n, Title: p.Title, Topic: p.Topic,
Deciders: p.Deciders, Clauses: p.Clauses, Links: p.Links, Homes: p.Homes, Insight: "the count was 3"}},
{"an effort with no served version and no sections", "overview", ProposeAsk{Kind: KindResearch, Number: m.Number,
Title: "An effort", Deciders: []string{"jochen"}, Status: StatusActive}},
} {
if _, err := w.k.Propose("author@g14", c.ask); err == nil || !strings.Contains(err.Error(), c.rule) {
t.Errorf("%s: want a refusal naming %s, got %v", c.name, c.rule, err)
}
}
}
// An old active effort's new text is held to the rules as of the day it is proposed, not the day the effort began.
@@ -389,6 +389,21 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
default:
return nil, fmt.Errorf("%s is %s and takes no proposal; nothing was proposed", key, held.Working().Status)
}
// A research effort's new version that gives no sections keeps the served version's, and its title, date and
// deciders where none are given: closing a long effort (its status and its became links) needs no retyping of
// its text (novox/hq issue 1966). The version so composed is judged and reviewed as any other.
if s := held.Current; a.Kind == KindResearch && len(a.Sections) == 0 && s.Status != StatusReserved {
r.Sections = append([]Section(nil), s.Sections...)
if r.Title == "" {
r.Title = s.Title
}
if strings.TrimSpace(a.Date) == "" {
r.Date = s.Date
}
if len(a.Deciders) == 0 {
r.Deciders = append([]string(nil), s.Deciders...)
}
}
}
if current && held != nil && !held.Current.ReservedAt.IsZero() && r.Date < held.Current.ReservedAt.Format("2006-01-02") {
// A new record is of its own day: a date before its reservation would put it under rules older records keep.
@@ -975,18 +975,20 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
"every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " +
"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 (an active research effort excepted, which takes new versions) 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 " +
"stood before). A research effort's new version may leave out its sections, and its title, date and deciders: it then " +
"keeps the served version's and takes the status, links and why given, as when an effort is closed (graduated or " +
"abandoned, with its became links). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " +
"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{
"kind": kindArg(), "number": integer("the number reserve gave"), "title": text("one line"),
Input: schema([]string{"number"}, map[string]any{
"kind": kindArg(), "number": integer("the number reserve gave"), "title": text("one line; a research effort's new version keeps the served title when absent"),
"topic": text("for a decision: a topic of the reading order, such as the mesh"),
"date": text("the day, YYYY-MM-DD (today when absent)"),
"deciders": texts("who decided"),
"clauses": texts("a decision's clauses in order, each \"**Lead.** the statement\""),
"sections": map[string]any{"type": "array", "description": "the reasoning, in order: each a name and its markdown",
"sections": map[string]any{"type": "array", "description": "the reasoning, in order: each a name and its markdown; a research effort's new version keeps the served version's when absent",
"items": map[string]any{"type": "object", "properties": map[string]any{"name": text("such as Context"), "text": text("markdown")},
"required": []string{"name", "text"}}},
"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)",