From e5e3d7dce60cc971b26202a38733bd909e6f9341 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:18:06 +0100 Subject: [PATCH] CAT-K10: the module's tools read each state it keeps on the bus and do by hand what it does itself knowledge_head, _operations, _section, _draft, _parts, _asked, _fill, _rebuild_index, _review_again, _integrity, _move and _move_check, beside the health, highest and marks tools; the manifest lists them and a test compares them with the code. Issue 1966. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../mesh-knowledge/cmd/mesh-knowledge/main.go | 2 +- .../cmd/mesh-knowledge/manifest_test.go | 2 +- .../cmd/mesh-knowledge/tools.go | 397 ++++++++++++++++++ .../cmd/mesh-knowledge/tools_test.go | 136 ++++++ modules/mesh-knowledge/module.json | 14 +- 5 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 modules/mesh-knowledge/cmd/mesh-knowledge/tools.go create mode 100644 modules/mesh-knowledge/cmd/mesh-knowledge/tools_test.go diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index c1793e662..e10b33243 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -106,7 +106,7 @@ func serve(k *KB, status string) { k.kick = make(chan struct{}, 1) go hear(k) go background(k) - if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k), marksTool(k))); err != nil { + if err := stdio.Serve("", append(append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k), marksTool(k)), moduleTools(k, callerOf)...)); err != nil { logf("%v", err) os.Exit(1) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index 643757ca3..26caed928 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -126,7 +126,7 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) { } h, _ := watch["health"].(map[string]any) if watch == nil || watch["run"].([]any)[1] != "watch" || h["kind"] != "tool" || h["tool"] != HealthTool || - !reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool, MarksTool}) { + !reflect.DeepEqual(m.Tools, ModuleTools) { t.Fatalf("watch %v, tools %v", watch, m.Tools) } // The watch's verdict is whether the database serves, so its check names the provision it exercises: while the diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/tools.go b/modules/mesh-knowledge/cmd/mesh-knowledge/tools.go new file mode 100644 index 000000000..6a9ba1a13 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/tools.go @@ -0,0 +1,397 @@ +package main + +// The module's tools (novox/hq to-be 55 §1.1, the instruction tools-are-how-a-module-is-spoken-to): the one way a person or a +// session reads and steers what the module keeps. Every state has a tool that reads it, every act the module does by itself +// can be done by hand, and every recovery has a tool. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// The module's tools beside the seat's verbs. +const ( + HeadTool = "knowledge_head" + OperationsTool = "knowledge_operations" + SectionTool = "knowledge_section" + DraftTool = "knowledge_draft" + PartsTool = "knowledge_parts" + AskedTool = "knowledge_asked" + FillTool = "knowledge_fill" + IndexTool = "knowledge_rebuild_index" + AgainTool = "knowledge_review_again" + IntegrityTool = "knowledge_integrity" + MoveTool = "knowledge_move" + MoveCheckTool = "knowledge_move_check" +) + +// ModuleTools are the module's own tools beside the health tool, in the order the manifest lists them. +var ModuleTools = []string{HealthTool, HighestTool, MarksTool, HeadTool, OperationsTool, SectionTool, DraftTool, PartsTool, + AskedTool, FillTool, IndexTool, AgainTool, IntegrityTool, MoveTool, MoveCheckTool} + +var errNotOnLedger = errors.New("the records are still in the database: this tool reads the ledger, which serves them after the move") + +// ledger is the ledger store the records are served from, or the refusal that they are not. +func (k *KB) ledger() (*ledgerStore, error) { + switch s := k.Store.(type) { + case *ledgerStore: + return s, nil + case *movableStore: + if s.OnLedger() { + return s.led, nil + } + } + return nil, errNotOnLedger +} + +func (k *KB) movable() (*movableStore, error) { + if m, ok := k.Store.(*movableStore); ok { + return m, nil + } + return nil, errors.New("this knowledge base has no database to move from") +} + +func keyArgs(name string, m map[string]any, more ...string) (Key, args, error) { + a, err := readArgs(name, m, append([]string{"kind", "number"}, more...)...) + if err != nil { + return Key{}, args{}, err + } + k, err := a.key() + return k, a, err +} + +func keyProps(more map[string]any) map[string]any { + p := map[string]any{"kind": kindArg(), "number": integer("the record's number")} + for k, v := range more { + p[k] = v + } + return p +} + +// moduleTools are the tools beside the seat's verbs that read and steer the records on the bus. +func moduleTools(k *KB, caller func(map[string]any) string) []stdio.Tool { + return []stdio.Tool{ + {Name: HeadTool, + Description: "One record's head in the ledger, whole: its fields, clauses, links, its served and proposed versions, its sections as " + + "title, digest and the operation that wrote each, and the sequence of the last operation it holds. Refused while the records are in the database.", + Input: schema([]string{"number"}, keyProps(nil)), + Run: func(m map[string]any) (any, error) { + key, _, err := keyArgs(HeadTool, m) + if err != nil { + return nil, err + } + l, err := k.ledger() + if err != nil { + return nil, err + } + l.mu.Lock() + defer l.mu.Unlock() + h, err := l.fresh(key) + if err != nil || h == nil { + return nil, fmt.Errorf("%s is held of none (%v)", key, err) + } + return h, nil + }}, + {Name: OperationsTool, + Description: "A record's history in the ledger, oldest first from a sequence: each operation with its sequence, its name, who it was by " + + "(the module's account and the caller the runtime proved) and when. A section's text is not shown; knowledge_section reads it.", + Input: schema([]string{"number"}, keyProps(map[string]any{"from": integer("the sequence to read from"), "limit": integer("at most this many")})), + Run: func(m map[string]any) (any, error) { + key, a, err := keyArgs(OperationsTool, m, "from", "limit") + if err != nil { + return nil, err + } + from, _ := a.num("from", false) + limit, _ := a.num("limit", false) + l, err := k.ledger() + if err != nil { + return nil, err + } + var page buscalls.HistoryPage + if err := l.pipe.Call(buscalls.LedgerHistory, buscalls.HistoryRequest{Ledger: LedgerName, Instance: InstanceOf(key), From: uint64(from), Limit: limit}, &page); err != nil { + return nil, err + } + for i := range page.Operations { + page.Operations[i].Body = nil // a section's text and a head are read by their own tools + } + return page, nil + }}, + {Name: SectionTool, + Description: "One section's text, by the sequence of the write-section operation that wrote it, checked against its digest. Refused when the " + + "operation is no write-section of the record, or its text does not hash to its digest (the history is damaged).", + Input: schema([]string{"number", "sequence"}, keyProps(map[string]any{"sequence": integer("the sequence of the write-section")})), + Run: func(m map[string]any) (any, error) { + key, a, err := keyArgs(SectionTool, m, "sequence") + if err != nil { + return nil, err + } + seq, err := a.num("sequence", true) + if err != nil { + return nil, err + } + l, err := k.ledger() + if err != nil { + return nil, err + } + var page buscalls.HistoryPage + if err := l.pipe.Call(buscalls.LedgerHistory, buscalls.HistoryRequest{Ledger: LedgerName, Instance: InstanceOf(key), From: uint64(seq), Limit: 1}, &page); err != nil { + return nil, err + } + if len(page.Operations) == 0 || page.Operations[0].Sequence != uint64(seq) || page.Operations[0].Operation != OpWriteSection { + return nil, fmt.Errorf("operation %d of %s is no write-section", seq, key) + } + var b struct{ Title, Text, Digest string } + if err := json.Unmarshal(page.Operations[0].Body, &b); err != nil { + return nil, err + } + if SectionDigest(b.Text) != b.Digest { + return nil, fmt.Errorf("the text of operation %d does not hash to its digest %s: the history is damaged", seq, b.Digest) + } + return map[string]any{"title": b.Title, "digest": b.Digest, "text": b.Text, "author": page.Operations[0].Author, "at": page.Operations[0].At}, nil + }}, + {Name: DraftTool, + Description: "A record's draft: its author, since when, the version it starts from, its fields and its sections as title, digest and the operation that wrote each. " + + "Answers that there is none when none is open.", + Input: schema([]string{"number"}, keyProps(nil)), + Run: func(m map[string]any) (any, error) { + key, _, err := keyArgs(DraftTool, m) + if err != nil { + return nil, err + } + d, _, err := k.readDraft(key) + if err != nil { + return nil, err + } + if d == nil { + return map[string]any{"record": key.String(), "says": "no draft is open"}, nil + } + return d, nil + }}, + {Name: PartsTool, + Description: "The latest verdict of each part of a record's version being worked on or served: the head and each section, the digest it was judged with, " + + "its verdict and findings, who judged and when.", + Input: schema([]string{"number"}, keyProps(nil)), + Run: func(m map[string]any) (any, error) { + key, _, err := keyArgs(PartsTool, m) + if err != nil { + return nil, err + } + e, err := k.Store.Get(context.Background(), key) + if err != nil || e == nil { + return nil, fmt.Errorf("%s is held of none (%v)", key, err) + } + return map[string]any{"record": key.String(), "parts": e.Working().Parts}, nil + }}, + {Name: AskedTool, + Description: "The parts asked of the judge and not answered yet: the record, the part and since when. With clear and a key, takes one mark away " + + "(a part whose answer will not come); the record's review asks it again.", + Input: schema(nil, map[string]any{"clear": text("a key to take away")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs(AskedTool, m, "clear") + if err != nil { + return nil, err + } + kv := k.States.State(askedState) + if c, _ := a.str("clear"); c != "" { + return map[string]any{"cleared": c}, kv.Delete(c) + } + keys, err := kv.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + out := []any{} + for _, key := range keys { + if e, err := kv.Get(key); err == nil && e != nil { + var v any + _ = json.Unmarshal(e.Value, &v) + out = append(out, map[string]any{"key": key, "asked": v}) + } + } + return map[string]any{"asked": out}, nil + }}, + {Name: FillTool, + Description: "How full the module's kept data is against its caps: the stream of the parts' verdicts (messages, bytes and its cap), and the number of keys of each state.", + Input: schema(nil, map[string]any{}), + Run: func(m map[string]any) (any, error) { + if _, err := readArgs(FillTool, m); err != nil { + return nil, err + } + out := map[string]any{} + if l, err := k.ledger(); err == nil { + var f buscalls.StreamFilled + if err := l.pipe.Call(buscalls.StreamFill, buscalls.StreamRequest{Stream: "reviews"}, &f); err != nil { + out["reviews"] = "cannot be read: " + err.Error() + } else { + out["reviews"] = f + } + } + keys := map[string]int{} + for _, s := range []string{draftsState, askedState, findingsState, sentState} { + if ks, err := k.States.State(s).Keys(); err == nil { + keys[s] = len(ks) + } + } + out["keys"] = keys + return out, nil + }}, + {Name: IndexTool, + Description: "Build the search index again from the ledger now, and say how it stands: built, when, and how many documents it holds. ask and holds answer " + + "\"index not built\" meanwhile.", + Input: schema(nil, map[string]any{}), + Run: func(m map[string]any) (any, error) { + if _, err := readArgs(IndexTool, m); err != nil { + return nil, err + } + l, err := k.ledger() + if err != nil { + return nil, err + } + if err := l.BuildIndex(context.Background()); err != nil { + return nil, err + } + return l.IndexState(), nil + }}, + {Name: AgainTool, + Description: "Ask a record's review again, for a version still proposed: its parts that approved keep their verdicts and the others are judged " + + "again; the review that ran out of tries starts its tries anew. Refused for a record that is not proposed.", + Input: schema([]string{"number"}, keyProps(nil)), + Run: func(m map[string]any) (any, error) { + key, _, err := keyArgs(AgainTool, m) + if err != nil { + return nil, err + } + return k.ReviewAgain(caller(m), key) + }}, + {Name: IntegrityTool, + Description: "Check that every section a head or a draft names resolves to a write-section whose text hashes to its digest, for one record or for " + + "all of them, and say each that does not.", + Input: schema(nil, keyProps(nil)), + Run: func(m map[string]any) (any, error) { + a, err := readArgs(IntegrityTool, m, "kind", "number") + if err != nil { + return nil, err + } + l, err := k.ledger() + if err != nil { + return nil, err + } + var keys []Key + if _, has := m["number"]; has { + key, err := a.key() + if err != nil { + return nil, err + } + keys = []Key{key} + } else { + all, err := l.All(context.Background()) + if err != nil { + return nil, err + } + for _, e := range all { + keys = append(keys, e.Current.Key()) + } + } + bad := []string{} + l.mu.Lock() + defer l.mu.Unlock() + for _, key := range keys { + h, err := l.fresh(key) + if err != nil || h == nil { + bad = append(bad, fmt.Sprintf("%s: %v", key, err)) + continue + } + for _, sr := range []*storedRecord{h.Entry.Current, h.Entry.Pending} { + if sr == nil { + continue + } + for _, ref := range sr.Sections { + delete(l.texts, ref.Digest) // read from the history, not from what was held + if _, err := l.text(ref, key); err != nil { + bad = append(bad, fmt.Sprintf("%s: %v", key, err)) + } + } + } + } + return map[string]any{"checked": len(keys), "damaged": bad}, nil + }}, + {Name: MoveTool, + Description: "Move the records from the database to the ledger: with dry-run, say what a copy would write and write nothing; with commit, refuse writes for " + + "its length, copy every version of every record, check the copy record by record, and set the records to be served from the ledger only if the check is " + + "clean. Run again after a copy it changes nothing. Refused once the records are served from the ledger.", + Input: schema(nil, map[string]any{"dry-run": flag("count and write nothing"), "commit": flag("copy, check and switch")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs(MoveTool, m, "dry-run", "commit") + if err != nil { + return nil, err + } + var ask MoveAsk + if ask.DryRun, err = a.flag("dry-run"); err != nil { + return nil, err + } + if ask.Commit, err = a.flag("commit"); err != nil { + return nil, err + } + ms, err := k.movable() + if err != nil { + return nil, err + } + return ms.Move(context.Background(), ask, func(s string) { k.logf("[mesh-knowledge] the move: %s", s) }) + }}, + {Name: MoveCheckTool, + Description: "Compare the database with the ledger record by record now: the versions, the served and the proposed version whole and every section's text. " + + "Answers every difference, at most 50, and where the records are served from; a database that is behind is named.", + Input: schema(nil, map[string]any{}), + Run: func(m map[string]any) (any, error) { + if _, err := readArgs(MoveCheckTool, m); err != nil { + return nil, err + } + ms, err := k.movable() + if err != nil { + return nil, err + } + diffs, err := ms.Check(context.Background()) + if err != nil { + return nil, err + } + return map[string]any{"source": ms.Source(), "differences": diffs, "clean": len(diffs) == 0}, nil + }}, + } +} + +// Review is the record's review asked again. +type ReviewedAgain struct { + Record string `json:"record"` + Says string `json:"says"` +} + +// ReviewAgain asks the review of a proposed version again: a failed review starts its tries anew, and a review whose verdict +// was changes is made due, the parts that approved keeping their verdicts. +func (k *KB) ReviewAgain(caller string, key Key) (*ReviewedAgain, error) { + k.mu.Lock() + defer k.mu.Unlock() + ctx := k.ctxFor(caller) + e, err := k.Store.Get(ctx, key) + if err != nil || e == nil { + return nil, fmt.Errorf("%s is held of none (%v)", key, err) + } + w := e.Working().Clone() + if w.Status != StatusProposed { + return nil, fmt.Errorf("%s is %s, not proposed: there is no review to ask again", key, w.Status) + } + if !proven(caller) { + return nil, fmt.Errorf("review-again needs a caller the runtime proved; this call is by %s", caller) + } + w.Judging = &Judging{State: JudgingDue} + if err := k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: "its review is asked again", Record: w, Insight: e.Pending != nil && e.Pending.IsInsight()}, e.Pending == nil, false, e.Revision); err != nil { + return nil, err + } + k.wake() + return &ReviewedAgain{Record: key.String(), Says: "The review of " + key.String() + " is due again; the parts that approved keep their verdicts."}, nil +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/tools_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/tools_test.go new file mode 100644 index 000000000..b8c9dbf05 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/tools_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func callTool(t *testing.T, tools []toolRun, name string, args map[string]any) (map[string]any, error) { + t.Helper() + for _, tool := range tools { + if tool.name == name { + out, err := tool.run(args) + if err != nil { + return nil, err + } + raw, _ := json.Marshal(out) + var m map[string]any + _ = json.Unmarshal(raw, &m) + return m, nil + } + } + t.Fatalf("no tool %s", name) + return nil, nil +} + +type toolRun struct { + name string + run func(map[string]any) (any, error) +} + +// Every state the module keeps on the bus has a tool that reads it, and every act it does by itself one that does it by +// hand (to-be 55 §1.1, AC54): the head, the history, a section, a draft, the parts' verdicts, the parts in flight, the fill, +// the index, the review asked again, the integrity of the sections, the move and its check. +func TestEveryStateTheModuleKeepsHasAToolThatReadsIt(t *testing.T) { + w, f := ledgerWorld(t) + var tools []toolRun + for _, tool := range moduleTools(w.k, callerOf) { + tools = append(tools, toolRun{tool.Name, tool.Run}) + } + r, _ := w.k.ReserveNumber("author@g14", KindDecision, "a test record") + p := valid(r.Number) + p.Sections[0].Text = "The merge window of ADR 0276 §1 waits too long." + key := map[string]any{"number": float64(r.Number)} + if _, err := w.k.Open("author@g14", ProposeAsk{Kind: KindDecision, Number: r.Number, Title: p.Title, Topic: p.Topic, Date: p.Date, Deciders: p.Deciders, Clauses: p.Clauses, Links: p.Links, Homes: p.Homes}); err != nil { + t.Fatal(err) + } + if _, err := w.k.Write("author@g14", Key{KindDecision, r.Number}, "Context", p.Sections[0].Text); err != nil { + t.Fatal(err) + } + d, err := callTool(t, tools, DraftTool, key) + if err != nil || d["author"] != "author@g14" || len(d["sections"].([]any)) != 1 { + t.Fatalf("%v %v", err, d) + } + for _, s := range p.Sections[1:] { + _, _ = w.k.Write("author@g14", Key{KindDecision, r.Number}, s.Name, s.Text) + } + if _, err := w.k.SubmitDraft("author@g14", Key{KindDecision, r.Number}, ""); err != nil { + t.Fatal(err) + } + w.reviewAll(t) + + h, err := callTool(t, tools, HeadTool, key) + if err != nil || h["entry"] == nil { + t.Fatalf("%v %v", err, h) + } + ops, err := callTool(t, tools, OperationsTool, key) + if err != nil || len(ops["operations"].([]any)) < 6 { + t.Fatalf("%v %v", err, ops) + } + var first map[string]any + for _, o := range ops["operations"].([]any) { + op := o.(map[string]any) + if op["body"] != nil { + t.Fatalf("the history shows a body: %v", op) + } + if first == nil && op["operation"] == OpWriteSection { + first = op + } + } + sec, err := callTool(t, tools, SectionTool, map[string]any{"number": key["number"], "sequence": first["sequence"]}) + if err != nil || sec["text"] != p.Sections[0].Text || sec["title"] != "Context" { + t.Fatalf("%v %v", err, sec) + } + parts, err := callTool(t, tools, PartsTool, key) + if err != nil || len(parts["parts"].([]any)) != 5 { + t.Fatalf("%v %v", err, parts) + } + if _, err := callTool(t, tools, AskedTool, map[string]any{}); err != nil { + t.Fatal(err) + } + if fill, err := callTool(t, tools, FillTool, map[string]any{}); err != nil || fill["keys"] == nil { + t.Fatalf("%v %v", err, fill) + } + if st, err := callTool(t, tools, IndexTool, map[string]any{}); err != nil || st["built"] != true { + t.Fatalf("%v %v", err, st) + } + // The integrity of the sections: all well, then one damaged text in the history is named. + if res, err := callTool(t, tools, IntegrityTool, map[string]any{}); err != nil || len(res["damaged"].([]any)) != 0 { + t.Fatalf("%v %v", err, res) + } + hist := f.hist[InstanceOf(Key{KindDecision, r.Number})] + for i := range hist { + if hist[i].Operation == OpWriteSection && strings.Contains(string(hist[i].Body), "Considered Options") { + hist[i].Body = mustJSON(t, map[string]any{"title": "Considered Options", "text": "damaged", "digest": SectionDigest("1. **Keep it.** Rejected.")}) + } + } + if res, _ := callTool(t, tools, IntegrityTool, key); len(res["damaged"].([]any)) == 0 { + t.Fatalf("a damaged section was not named: %v", res) + } + // An accepted record has no review to ask again; a proposed one does. + if _, err := callTool(t, tools, AgainTool, key); err == nil { + t.Fatal("a review of an accepted record was asked again") + } + q, _ := w.k.ReserveNumber("author@g14", KindDecision, "another") + if _, err := w.k.Propose("author@g14", valid(q.Number)); err != nil { + t.Fatal(err) + } + w.judge.review = `{"verdict":"changes","findings":["x"]}` + w.reviewAll(t) + w.judge.review = "" + if _, err := w.k.ReviewAgain("author@g14", Key{KindDecision, q.Number}); err != nil { + t.Fatal(err) + } + w.reviewAll(t) + if e := mustGet(t, w.k.Store, Key{KindDecision, q.Number}); e.Current.Status != StatusAccepted { + t.Fatalf("a review asked again: %s", e.Current.Status) + } + // The move's tools need the database beside the ledger. + if _, err := callTool(t, tools, MoveCheckTool, map[string]any{}); err == nil { + t.Fatal("a move check without a database") + } + if len(ModuleTools) != 15 { + t.Fatalf("%v", ModuleTools) + } +} diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index 26e1d380a..436559a3f 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -151,7 +151,19 @@ "tools": [ "knowledge_health", "knowledge_highest", - "knowledge_marks" + "knowledge_marks", + "knowledge_head", + "knowledge_operations", + "knowledge_section", + "knowledge_draft", + "knowledge_parts", + "knowledge_asked", + "knowledge_fill", + "knowledge_rebuild_index", + "knowledge_review_again", + "knowledge_integrity", + "knowledge_move", + "knowledge_move_check" ], "emits": [ "reserved",