Merge pull request 'Nothing reads decision records from hq: records_decisions retired, reserve asks the knowledge base, an empty import refuses (hq issue 488)' (#214) from fix/488-nothing-reads-records-from-hq into main

This commit was merged in pull request #214.
This commit is contained in:
2026-10-11 10:55:19 +00:00
18 changed files with 403 additions and 330 deletions
+7 -1
View File
@@ -28,6 +28,11 @@ operator, it is accepted only on the operator's Approve, given on a channel that
`ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut
and how to read the rest. The module's own tool `knowledge_health` answers its health (below).
Its tool `knowledge_highest` answers the highest decision and research number it holds, every number spent
counted (accepted, proposed, reserved or withdrawn): the issue tracker's `reserve` starts and raises its counters
above them (hq issue 488). It refuses while nothing is held. It is a tool of the module, not a verb of the seat,
which serves the verbs ADR 0290 rule 6 names.
**Who acted** is the caller as the runtime proves it, never an argument: no verb takes one. The tool runner takes
the caller's bus user from the caller-named subject a call arrived on and hands it through the SDK
(`stdio.CallerOf`, hq issue 365). A call on the subject that names no caller (served beside it for one release, hq
@@ -123,7 +128,8 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel
## The import
`import` reads hq through the records module at the commit its checkout stands at. With `commit`, it refuses a
checkout standing elsewhere. It writes every decision record and research effort whole, as version 1, naming the
checkout standing elsewhere. At a commit where hq holds no decision record and no research effort (the records
have left it, hq issue 426), it refuses and says so, rather than answering "nothing created" as a success. It writes every decision record and research effort whole, as version 1, naming the
commit and path it came from. It creates only what is absent and never overwrites. It never asks for a number, so
no counter moves. A second run changes nothing and says so. `dry-run` writes nothing. From a terminal,
`mesh-knowledge dry-run <hq checkout>` gives the same dry run over a checkout on disk, against an empty knowledge
@@ -0,0 +1,62 @@
package main
// The highest number held per kind (hq issue 488): what the issue tracker's reserve starts and raises its decision
// and research counters above (ADR 0275 rule 8, ADR 0290 rule 5), now that the records live here and hq's folders
// empty. Every number held counts — accepted, proposed, reserved or withdrawn — since each is spent.
import (
"context"
"errors"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// HighestTool is the module's tool the issue tracker asks.
const HighestTool = "knowledge_highest"
// Highest is the highest number held of each kind, and how many records are held.
type Highest struct {
Decision int `json:"decision"`
Research int `json:"research"`
Records int `json:"records"`
Says string `json:"says"`
}
// HighestHeld is the highest number held per kind. A knowledge base that holds no record answers none: a counter
// started from it would start at 1.
func (k *KB) HighestHeld() (*Highest, error) {
all, err := k.Store.All(context.Background())
if err != nil {
return nil, fmt.Errorf("the knowledge base cannot be read (%v); it answers no highest number", err)
}
h := &Highest{Records: len(all)}
for _, e := range all {
switch r := e.Current; r.Kind {
case KindDecision:
h.Decision = max(h.Decision, r.Number)
case KindResearch:
h.Research = max(h.Research, r.Number)
}
}
if h.Records == 0 {
return nil, errors.New("the knowledge base holds no record yet (it is imported from hq once): it answers no highest number")
}
h.Says = fmt.Sprintf("The highest numbers held, of %d records, every number spent counted: decision %04d, research %03d.",
h.Records, h.Decision, h.Research)
return h, nil
}
func highestTool(k *KB) stdio.Tool {
return stdio.Tool{Name: HighestTool,
Description: "The highest decision number and the highest research number the knowledge base holds, every number spent " +
"counted: accepted, proposed, reserved or withdrawn. The issue tracker starts and raises its counters above them. " +
"Refused while the knowledge base holds no record, or cannot be read: a number is never guessed.",
Input: schema(nil, map[string]any{}),
Run: func(m map[string]any) (any, error) {
if _, err := readArgs(HighestTool, m); err != nil {
return nil, err
}
return k.HighestHeld()
}}
}
@@ -0,0 +1,88 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// hq issue 488: the issue tracker's reserve takes the highest decision and research number from the knowledge base,
// which holds every record — reserved, withdrawn and proposed numbers included, since each is spent — rather than
// reading hq's folders, which empty when the records leave.
func TestTheHighestToolAnswersTheHighestNumberHeldPerKind(t *testing.T) {
eachStore(t, func(t *testing.T, s Store) {
w := imported(t, s)
tool := highestTool(w.k)
answer := func() Highest {
t.Helper()
out, err := tool.Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(out)
var h Highest
if err := json.Unmarshal(raw, &h); err != nil {
t.Fatal(err)
}
return h
}
if h := answer(); h.Decision != 290 || h.Research != 45 || h.Records != 11 {
t.Fatalf("%+v", h)
}
// A number reserved, and later withdrawn, is spent: it is the highest.
w.reserved(t, "author@g14") // 300
w.clock = w.clock.Add(DefaultReservedFor + time.Hour)
if _, err := w.k.Withdraw(); err != nil {
t.Fatal(err)
}
if h := answer(); h.Decision != 300 || h.Research != 45 {
t.Fatalf("after a reservation withdrawn: %+v", h)
}
if _, err := tool.Run(map[string]any{"kind": "decision"}); err == nil {
t.Fatal("an argument it does not take was taken")
}
})
// A knowledge base that holds nothing answers nothing: a 0 would start a counter at 1.
empty := newWorld(t, newMemStore(), fixture())
if _, err := highestTool(empty.k).Run(map[string]any{}); err == nil || !strings.Contains(err.Error(), "holds no record") {
t.Fatalf("an empty knowledge base: %v", err)
}
}
// hq issue 488: once the records have left hq, an import at a later commit finds none. It refuses, saying so,
// rather than reporting nothing created as a success.
func TestTheImportRefusesACommitWhereHqHoldsNoRecords(t *testing.T) {
dir := t.TempDir()
write := func(path, text string) {
t.Helper()
if err := writeFileAll(dir+"/"+path, text); err != nil {
t.Fatal(err)
}
}
write("COMMIT", "0123456789abcdef0123456789abcdef01234567\n")
write("02-DECISIONS/README.md", "# Decisions\n\nThey live in the knowledge base.\n")
write("01-RESEARCH/README.md", "# Research\n")
w := newWorld(t, newMemStore(), dirRecords{dir: dir})
for _, dry := range []bool{true, false} {
out, err := w.k.Import("import@test", ImportAsk{DryRun: dry}, dirRecords{dir: dir})
if err == nil || !strings.Contains(err.Error(), "holds no decision record or research effort") ||
!strings.Contains(err.Error(), "0123456789ab") {
t.Fatalf("dry %v: %+v %v", dry, out, err)
}
}
if all, _ := w.k.Store.All(context.Background()); len(all) != 0 {
t.Fatal("written")
}
}
func writeFileAll(path, text string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(text), 0o644)
}
@@ -127,6 +127,12 @@ func (k *KB) Import(caller string, a ImportAsk, records Records) (*Imported, err
cands = append(cands, &importCandidate{path: ResearchFolder + "/" + f})
}
}
if len(cands) == 0 {
// The records left hq (hq issue 426): what it held is here, and an import from it would say "nothing
// created" as a success (hq issue 488).
return nil, fmt.Errorf("hq holds no decision record or research effort at commit %s: its %s and %s hold none. "+
"The records live in the knowledge base now, and nothing was imported", short(commit), DecisionsFolder, ResearchFolder)
}
// Read and parse, a few at a time, every document at the one commit.
var moved error
var movedMu sync.Mutex
@@ -98,7 +98,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))); err != nil {
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k))); err != nil {
logf("%v", err)
os.Exit(1)
}
@@ -118,7 +118,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}) {
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
t.Fatalf("watch %v, tools %v", watch, m.Tools)
}
if watch["env"].(map[string]any)["MESH_KNOWLEDGE_STATUS"] != m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] {
+2 -1
View File
@@ -51,7 +51,8 @@
}
],
"tools": [
"knowledge_health"
"knowledge_health",
"knowledge_highest"
],
"emits": [
"reserved",