From e9ba05bd9a3cb6d0bd216e968fafc0cc0a826afd Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 15:15:34 +0100 Subject: [PATCH] close: read the became links the effort already carries, check the record size, drop empty table cases (issue 784, ADR 0630) Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../cmd/mesh-knowledge/close.go | 16 +++++- .../cmd/mesh-knowledge/close_test.go | 51 +++++++++++++++---- .../mesh-knowledge/cmd/mesh-knowledge/main.go | 2 +- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/close.go b/modules/mesh-knowledge/cmd/mesh-knowledge/close.go index d5bf1278..c883b4f3 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/close.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/close.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "sort" @@ -131,7 +132,12 @@ func (k *KB) Close(caller string, a CloseAsk) (*Closed, error) { look := k.lookup(ctx) probe := &Record{Kind: KindResearch, Number: a.Number, Title: "the closing", Date: day, Effort: a.To, Status: StatusProposed, Links: added, Sections: []Section{{Name: "00-overview.md", Text: note}, {Name: name, Text: note}}} - closing := JudgeRecordOn(probe, e, look, ref, day) + var closing []Refusal + for _, x := range JudgeRecordOn(probe, e, look, ref, day) { + if x.Rule != "became" { // the probe holds only the added links; the whole record's became is checked below + closing = append(closing, x) + } + } if a.To == StatusGraduated { became := false for _, l := range r.Links { @@ -141,7 +147,10 @@ func (k *KB) Close(caller string, a CloseAsk) (*Closed, error) { refusals = append(refusals, Refusal{Rule: "became", Says: "a graduated research effort names what it became, as a became link"}) } } - for _, l := range added { + for _, l := range r.Links { + if l.Kind != LinkBecame { + continue + } if target, ok := l.To.Record(); ok && a.To == StatusGraduated && target.Kind == KindDecision { if t := look(target); t != nil && t.Status != StatusAccepted { refusals = append(refusals, Refusal{Rule: "link-target", Says: fmt.Sprintf("a graduated effort's became names accepted records: %s is %s", target, t.Status)}) @@ -149,6 +158,9 @@ func (k *KB) Close(caller string, a CloseAsk) (*Closed, error) { } } refusals = append(refusals, closing...) + if raw, _ := json.Marshal(r); len(raw) > MostRecordBytes { + refusals = append(refusals, Refusal{Rule: "size", Says: fmt.Sprintf("the record is %d bytes; a record holds at most %d", len(raw), MostRecordBytes)}) + } if len(refusals) > 0 { lines := make([]string, len(refusals)) for i, x := range refusals { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/close_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/close_test.go index 031e0beb..65ec7b17 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/close_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/close_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "strings" "testing" "time" @@ -59,12 +60,9 @@ func TestACloseAddsOneSectionAndJudgesOnlyTheClosing(t *testing.T) { } } -// ADR 0630 §4: the moves it allows, a refused move naming the allowed ones; the active limit never fires. +// ADR 0630 §4: the moves it allows, a refused move naming the allowed ones. func TestCloseAllowsOnlyItsMoves(t *testing.T) { w := imported(t, newMemStore()) - for i := 0; i < 3; i++ { - w.effort(t, StatusActive) // the limit is reached: close must not meet it - } becameTo := []LinkAsk{{Kind: LinkBecame, To: "0290"}} citesTo := []LinkAsk{{Kind: LinkCites, To: "0290"}} for _, c := range []struct { @@ -75,20 +73,17 @@ func TestCloseAllowsOnlyItsMoves(t *testing.T) { note string wantErr string }{ - {"an idea to abandoned, at the limit", StatusIdea, StatusAbandoned, citesTo, closeNote("0290"), ""}, + {"an idea to abandoned", StatusIdea, StatusAbandoned, citesTo, closeNote("0290"), ""}, {"an idea to graduated", StatusIdea, StatusGraduated, becameTo, closeNote("0290"), "from idea it allows abandoned"}, {"an idea to an idea", StatusIdea, StatusIdea, nil, closeNote("0290"), "from idea it allows abandoned"}, - {"an active to an idea, at the limit", StatusActive, StatusIdea, nil, closeNote("0290"), ""}, + {"an active to an idea", StatusActive, StatusIdea, nil, closeNote("0290"), ""}, {"graduated needs a became", StatusActive, StatusGraduated, nil, closeNote("0290"), "became"}, {"a wrong link kind", StatusActive, StatusAbandoned, becameTo, closeNote("0290"), "cites"}, {"no note", StatusActive, StatusIdea, nil, " ", "note"}, } { n := w.effort(t, StatusIdea) if c.from == StatusActive { - // make room under the limit for the effort under test, then take it up - w.k.Settings = func() (Settings, error) { return Settings{ResearchActiveAtMost: 9}, nil } w.accepted(t, "author@g14", effortAsk(n, StatusActive, Section{Name: "00-overview.md", Text: "Taken up."})) - w.k.Settings = nil } _, err := w.k.Close("lead@shanks", CloseAsk{Number: n, To: c.to, Links: c.links, Note: c.note}) if (err == nil) != (c.wantErr == "") || (err != nil && !strings.Contains(err.Error(), c.wantErr)) { @@ -136,3 +131,41 @@ func TestACloseEndsTheWaitingVersionAndItsAsk(t *testing.T) { t.Errorf("after the review: %s, the ended version's finding served: %q", got.Status, got.Section("01-findings.md")) } } + +// ADR 0630 §2: a graduated close counts the became links the effort already carries, and holds every one of them +// to naming an accepted record. +func TestAGraduatingCloseReadsTheBecameLinksTheEffortCarries(t *testing.T) { + w := imported(t, newMemStore()) + dec := w.reserved(t, "author@test") + for _, c := range []struct { + name, became, wantErr string + }{ + {"an old became to an accepted decision", "0290", ""}, + {"an old became to a decision not accepted", Key{KindDecision, dec}.String(), "accepted"}, + } { + r, _ := w.k.ReserveNumber("author@g14", KindResearch, "an effort") + a := effortAsk(r.Number, StatusActive, Section{Name: "00-overview.md", Text: "What it was."}) + a.Links = append(a.Links, LinkAsk{Kind: LinkBecame, To: "0290"}) + w.accepted(t, "author@g14", a) + if c.became != "0290" { + if _, err := w.k.Propose("author@test", valid(dec)); err != nil { // proposed, never reviewed: not accepted + t.Fatal(err) + } + // the old link goes to a record not accepted: written into the held record, as an import would + e := mustGet(t, w.k.Store, Key{KindResearch, r.Number}) + cur := e.Current.Clone() + for i := range cur.Links { + if cur.Links[i].Kind == LinkBecame { + cur.Links[i].To = Ref{Kind: KindDecision, Number: dec} + } + } + if err := w.k.Store.Save(context.Background(), Version{By: "import@test", At: time.Now(), Why: "seeded", Record: cur}, true, false, e.Revision); err != nil { + t.Fatal(err) + } + } + _, err := w.k.Close("lead@shanks", CloseAsk{Number: r.Number, To: StatusGraduated, Note: closeNote("0290")}) + if (err == nil) != (c.wantErr == "") || (err != nil && !strings.Contains(err.Error(), c.wantErr)) { + t.Errorf("%s: %v, want %q", c.name, err, c.wantErr) + } + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 2a08a93b..5882e6af 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -1135,7 +1135,7 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { "and one more section (Graduated, Abandoned or Parked as an idea) holding your note of two or three sentences. Only the " + "closing is judged: the rules run on the added links and the note, and the knowledge base's review sees only the closing, " + "so an old text's findings never ask changes; they come back once in the answer as notes. A version of the effort still " + - "waiting for its review, and the operator's ask for it, end with the closing, named in the answer. Allowed: an active effort " + + "waiting for its review, whoever its author is, and the operator's ask for it, end with the closing, named in the answer. Allowed: an active effort " + "to graduated, abandoned or idea; an idea to abandoned; anything else is refused naming the move it allows. An idea is taken up " + "by propose at active.", Input: schema([]string{"number", "to", "note"}, map[string]any{"number": integer("the research effort's number"),