From 10ab959d0f2e68a8cd53b9ec232090bf9dcebce2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 11 Oct 2026 05:10:02 +0200 Subject: [PATCH] Tighten who writes and reviews a record, date the rules, bound history (review of #206) A revision came from anyone and a review was refused only to the first author, so a co-writer could approve, and a record proposed before callers are proven would later be reviewable by its own unproven author. Now a revision is taken only from the proven author, a reserved number only from whoever reserved it, and a review is refused from any writer of any version or for any record with an unproven writer. The rules held records from no date, so a correction to an older record was refused by rules hq never held it to; each dated rule now holds from hq's own start date, and a new record cannot be dated before its reservation. history and the question to ask are bounded like the other answers. --- modules/mesh-knowledge/README.md | 39 ++++++--- .../cmd/mesh-knowledge/answers.go | 70 ++++++++++++++-- .../cmd/mesh-knowledge/dated_test.go | 79 ++++++++++++++++++ .../cmd/mesh-knowledge/history_test.go | 47 ++++++++++- .../mesh-knowledge/cmd/mesh-knowledge/kb.go | 39 ++++++++- .../mesh-knowledge/cmd/mesh-knowledge/main.go | 22 +++-- .../cmd/mesh-knowledge/record.go | 4 +- .../cmd/mesh-knowledge/review_test.go | 2 +- .../cmd/mesh-knowledge/rules.go | 31 ++++++- .../cmd/mesh-knowledge/writers_test.go | 82 +++++++++++++++++++ 10 files changed, 382 insertions(+), 33 deletions(-) create mode 100644 modules/mesh-knowledge/cmd/mesh-knowledge/dated_test.go create mode 100644 modules/mesh-knowledge/cmd/mesh-knowledge/writers_test.go diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 661d62a6..7bd8c06c 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -15,24 +15,34 @@ accepted only on the operator's Approve, given on a channel that proves who answ | Verb | Takes | Answers | |---|---|---| -| `ask` | `question`, `bound` | the records and clauses bearing on the question, ranked; each clause says whether it holds, what changed it, or that it is only drafted; each record says what changed it since and how to read its reasoning | +| `ask` | `question` (at most 500 characters), `bound` | the records and clauses bearing on the question, ranked; each clause says whether it holds, what changed it, or that it is only drafted; each record says what changed it since and how to read its reasoning | | `get` | `number`, `kind`, `reasoning`, `section`, `version`, `bound` | one record: clauses, links both ways, what changed it since, versions, reviews, the operator's ask; its reasoning only when asked | | `holds` | `topic` or `number`, `bound` | the clauses in force for a topic of the reading order or for one record; never a drafted clause | | `links` | `number`, `kind` | the record's typed links, out and in | -| `history` | `number`, `kind`, `from`, `to` | every version; with `from` and `to`, the difference between the two, line by line | +| `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`) | | `review` | `number`, `verdict` (approve or changes), `findings` | accepted, asked of the operator, or kept proposed with the findings | | `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 | -`ask`, `get` and `holds` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut +`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). -**Who acted** is the caller as the runtime proves it, never an argument: no verb takes one. The runtime does not -hand a module its caller yet (hq issue 365), so every change is recorded as by `unproven`. Until it does, -**every review is refused**. A review by the record's author is refused, and with no proven caller a review -cannot be told from one by the author. +**Who acted** is the caller as the runtime proves it, never an argument: no verb takes one. Today the runtime +does not hand a module its caller. The SDK's tool call carries only a name and arguments, so proving the caller +needs an SDK change first (hq issue 365). Until then every change is recorded as by `unproven`. In this module +the caller is read in one place, `callerOf` in `main.go`, so the SDK change touches that one function. + +The rules about callers: + +- A proposal is revised only by its author, as the runtime proves them. +- A number reserved by a proven caller is proposed only by that caller. +- A review is refused from anyone who wrote any version of the record. +- A review is refused for a record that has a version whose writer was unproven: who wrote it cannot be told + from who reviews it, so its author proposes it again under a new number. + +Until the runtime proves callers, **every review is refused**. ## What a record holds @@ -61,6 +71,16 @@ record it goes out from. superseder, a graduated effort says what it became, and the links in its text resolve; - 4 on the prose: no word the glossary retired, no daemon's line, a deferral names its tracking issue, and a progressive insight in the dated, marked form. + + A rule holds a record from the day hq's checks began to hold it (`RuleFrom` in `rules.go`): + - retired words from 2026-10-07; + - daemons' lines and deferrals from 2026-10-09; + - the clauses from 2026-10-11; + - the decision record's sections and the live-citation rule, which no hq check held on a decision record, + from 2026-10-11, the day the knowledge base takes the write. + + A progressive insight keeps its record's date, so a correction to an older record is not refused by a rule + that never held it. A new record is dated no earlier than the day its number was reserved. 3. Revise it by proposing again; every version is kept. 4. A `review` by someone else either approves it or asks for changes. On approve the record and its clauses are accepted and in force. Every clause it changes moves to `changed`, and every record it supersedes moves to @@ -125,9 +145,8 @@ the consolidated schema for a fresh database is `schema.sql`. A test holds the t ## What it needs at release - assignment to the control-node, and a postgres provider for its `postgres-database` provision; -- `issue-tracker.reserve` (hq issue 424) before `reserve` works; until then reserve says the tracker did not hand - out a number, and writes nothing; -- the runtime proving the caller (hq issue 365) before any review is taken; +- the issue tracker serving `issue-tracker.reserve` (hq issue 424, merged as mesh-catalog#204) on the mesh; +- the SDK and the runtime proving the caller (hq issue 365) before any review is taken; - the import run once, then checked against hq record by record (hq issue 426). ## Tests diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go b/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go index f0dba155..9a21a34e 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go @@ -13,6 +13,9 @@ import ( "strings" ) +// MostQuestion is the most characters a question to ask may hold. +const MostQuestion = 500 + // The bound of an answer. const ( DefaultBound = 4 << 10 @@ -159,6 +162,9 @@ func (k *KB) Ask(question string, bound int) (*Asked, error) { if question == "" { return nil, fmt.Errorf("ask needs a question, in plain words") } + if len([]rune(question)) > MostQuestion { + return nil, fmt.Errorf("a question is at most %d characters; this one is %d: ask it in fewer words", MostQuestion, len([]rune(question))) + } bound = boundOf(bound) hits, err := k.Store.Search(ctx, question, 80) if err != nil { @@ -600,10 +606,12 @@ type Historied struct { To int `json:"to,omitempty"` Diff []string `json:"diff,omitempty"` Same bool `json:"same,omitempty"` + Cut string `json:"cut,omitempty"` + Bound int `json:"bound,omitempty"` } // History is a record's versions, and the difference between two of them, line by line. -func (k *KB) History(key Key, from, to int) (*Historied, error) { +func (k *KB) History(key Key, from, to, bound int) (*Historied, error) { ctx := context.Background() e, err := k.Store.Get(ctx, key) if err != nil { @@ -622,22 +630,72 @@ func (k *KB) History(key Key, from, to int) (*Historied, error) { Status: v.Record.Status, Insight: v.Insight, Served: v.N == e.CurrentVersion, Pending: v.N == e.PendingVersion}) } if from == 0 && to == 0 { + out.Bound = boundOf(bound) + versions := out.Versions + v, m := fit(len(versions), out.Bound-300, func(m int) any { c := *out; c.Versions = versions[len(versions)-m:]; return &c }) + *out = *(v.(*Historied)) + if m < len(versions) { + out.Cut = fmt.Sprintf("the %d oldest versions are not listed within %d bytes: raise bound", len(versions)-m, out.Bound) + } return out, nil } if from < 1 || to < 1 || from > len(vs) || to > len(vs) { return nil, fmt.Errorf("%s has versions 1 to %d; from and to name two of them", key, len(vs)) } - out.From, out.To = from, to - out.Diff = Diff(Render(vs[from-1].Record), Render(vs[to-1].Record)) - out.Same = len(out.Diff) == 0 + out.From, out.To, out.Bound = from, to, boundOf(bound) + diff := Diff(Render(vs[from-1].Record), Render(vs[to-1].Record)) + out.Same = len(diff) == 0 + // Within the bound: the most of the difference's lines that fit, cut at a hunk's line and said; and when the + // versions alone do not fit, the oldest of them left out first. + versions := out.Versions + v, n := fit(len(diff), out.Bound-300, func(n int) any { c := *out; c.Diff = diff[:n]; return &c }) + *out = *(v.(*Historied)) + var cut []string + if n < len(diff) { + cut = append(cut, fmt.Sprintf("%d of the difference's %d lines are not shown within %d bytes: raise bound, or compare nearer versions", + len(diff)-n, len(diff), out.Bound)) + } + if size(out) > out.Bound-300 { + v, m := fit(len(versions), out.Bound-300, func(m int) any { c := *out; c.Versions = versions[len(versions)-m:]; return &c }) + *out = *(v.(*Historied)) + cut = append(cut, fmt.Sprintf("the %d oldest versions are not listed", len(versions)-m)) + } + out.Cut = strings.Join(cut, "; ") return out, nil } +// MostDiffWork bounds the work of a difference: the lines of the two texts' changed middles multiplied. Past it, +// the middle is given whole as removed and added — still exact, only less narrow. +const MostDiffWork = 4_000_000 + // Diff is the difference of two texts, line by line: each hunk headed "@@ -a,n +b,m @@", its lines "-" removed // and "+" added, by the longest common subsequence of their lines. func Diff(a, b string) []string { x, y := strings.Split(a, "\n"), strings.Split(b, "\n") + // The common head and tail cost nothing to find; only the middle between them is compared. + head := 0 + for head < len(x) && head < len(y) && x[head] == y[head] { + head++ + } + tail := 0 + for tail < len(x)-head && tail < len(y)-head && x[len(x)-1-tail] == y[len(y)-1-tail] { + tail++ + } + x, y = x[head:len(x)-tail], y[head:len(y)-tail] n, m := len(x), len(y) + if n*m > MostDiffWork { + var out []string + if n+m > 0 { + out = append(out, fmt.Sprintf("@@ -%d,%d +%d,%d @@", head+1, n, head+1, m)) + } + for _, l := range x { + out = append(out, "-"+l) + } + for _, l := range y { + out = append(out, "+"+l) + } + return out + } lcs := make([][]int, n+1) for i := range lcs { lcs[i] = make([]int, m+1) @@ -670,14 +728,14 @@ func Diff(a, b string) []string { j++ case i < n && (j == m || lcs[i+1][j] >= lcs[i][j+1]): if len(hunk) == 0 { - ha, hb, ca, cb = i+1, j+1, 0, 0 + ha, hb, ca, cb = head+i+1, head+j+1, 0, 0 } hunk = append(hunk, "-"+x[i]) ca++ i++ default: if len(hunk) == 0 { - ha, hb, ca, cb = i+1, j+1, 0, 0 + ha, hb, ca, cb = head+i+1, head+j+1, 0, 0 } hunk = append(hunk, "+"+y[j]) cb++ diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/dated_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/dated_test.go new file mode 100644 index 00000000..45b077aa --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/dated_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "strings" + "testing" +) + +// A rule holds a record from the day hq's checks began to hold it (the review of mesh-catalog#206): a dated +// correction to an older record is judged by the rules that held it then, never by one that came later. + +// insightTo is a progressive insight to a held record: the record as it stands, resent whole, with what stood +// before. +func insightTo(t *testing.T, w *world, k Key) ProposeAsk { + t.Helper() + r := mustGet(t, w.k.Store, k).Current + p := ProposeAsk{Kind: k.Kind, Number: k.Number, Title: r.Title, Topic: r.Topic, Date: r.Date, Deciders: r.Deciders, + Sections: r.Sections, Homes: r.Homes, Insight: "the count said 12 where 13 stood"} + for _, c := range r.Clauses { + p.Clauses = append(p.Clauses, c.Text) + } + for _, l := range r.Links { + p.Links = append(p.Links, LinkAsk{Kind: l.Kind, To: l.To.String(), From: l.From}) + } + return p +} + +// ADR 0011 (2026-04-03) is a real older record: its decision is prose, it has no section saying how it is checked, +// and it was written before the words and clause rules held. A correction of fact to it is taken. +func TestAnInsightToAnOlderRecordIsJudgedByTheRulesThatHeldIt(t *testing.T) { + w := newWorld(t, newMemStore(), hqMain(t)) + if _, err := w.k.Import("import@test", ImportAsk{}, hqMain(t)); err != nil { + t.Fatal(err) + } + p := insightTo(t, w, Key{KindDecision, 11}) + p.Sections[0].Text += "\n\nThe control plane wrote them." + if _, err := w.k.Propose("corrector@ace", p); err != nil { + t.Fatalf("an insight to ADR 0011 was refused by rules that never held it: %v", err) + } + // The same words in a record of today are refused. + n := w.reserved(t, "author@g14") + q := valid(n) + q.Sections[2].Text = "The control plane wrote them." + if _, err := w.k.Propose("author@g14", q); err == nil || !strings.Contains(err.Error(), "- retired-word:") { + t.Fatalf("a retired word in a record of today: %v", err) + } +} + +func TestEachDatedRuleHoldsFromItsDay(t *testing.T) { + for _, c := range []struct { + rule, before, from string + }{ + {"retired-word", "2026-10-06", "2026-10-07"}, + {"daemon-line", "2026-10-08", "2026-10-09"}, + {"deferral", "2026-10-08", "2026-10-09"}, + {"has-clause", "2026-10-10", "2026-10-11"}, + {"clause-lead", "2026-10-10", "2026-10-11"}, + {"checked", "2026-10-10", "2026-10-11"}, + } { + if Holds(c.rule, c.before) || !Holds(c.rule, c.from) { + t.Errorf("%s holds a record of %s: %v, of %s: %v", c.rule, c.before, Holds(c.rule, c.before), c.from, Holds(c.rule, c.from)) + } + } + if !Holds("title", "2026-01-01") { + t.Error("an undated rule does not hold an old record") + } +} + +// A new record cannot be dated back past the rules: it is of the day its number was reserved, or later. +func TestANewRecordIsNotDatedBeforeItsReservation(t *testing.T) { + w := imported(t, newMemStore()) + n := w.reserved(t, "author@g14") + p := valid(n) + p.Date = "2026-01-01" + p.Sections[2].Text = "The control plane wrote them." + _, err := w.k.Propose("author@g14", p) + if err == nil || !strings.Contains(err.Error(), "- date:") { + t.Fatalf("a new record dated before its reservation: %v", err) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/history_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/history_test.go index 54783d32..f17a0bf0 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/history_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/history_test.go @@ -22,7 +22,7 @@ func TestThreeVersionsAreKeptAndTheFirstAndThirdCompareExactly(t *testing.T) { if _, err := w.k.Propose("author@g14", p); err != nil { // version 3 t.Fatal(err) } - h, err := w.k.History(Key{KindDecision, n}, 0, 0) + h, err := w.k.History(Key{KindDecision, n}, 0, 0, 0) if err != nil { t.Fatal(err) } @@ -30,7 +30,7 @@ func TestThreeVersionsAreKeptAndTheFirstAndThirdCompareExactly(t *testing.T) { h.Versions[2].Why != "the bound named" || !h.Versions[2].Served || h.Versions[0].By != "author@g14" { t.Fatalf("history: %+v", h.Versions) } - d, err := w.k.History(Key{KindDecision, n}, 1, 3) + d, err := w.k.History(Key{KindDecision, n}, 1, 3, 0) if err != nil { t.Fatal(err) } @@ -50,7 +50,7 @@ func TestThreeVersionsAreKeptAndTheFirstAndThirdCompareExactly(t *testing.T) { t.Errorf("the title's change is not in the difference: %v", d.Diff) } // Versions 2 and 3 differ in clause 2 alone. - d23, _ := w.k.History(Key{KindDecision, n}, 2, 3) + d23, _ := w.k.History(Key{KindDecision, n}, 2, 3, 0) var changed []string for _, l := range d23.Diff { if !strings.HasPrefix(l, "@@") { @@ -61,7 +61,7 @@ func TestThreeVersionsAreKeptAndTheFirstAndThirdCompareExactly(t *testing.T) { "+2. **A batch is delivered by one walk.** At most one commit per repository."}) { t.Fatalf("2 to 3: %v", d23.Diff) } - if d22, _ := w.k.History(Key{KindDecision, n}, 2, 2); !d22.Same || len(d22.Diff) != 0 { + if d22, _ := w.k.History(Key{KindDecision, n}, 2, 2, 0); !d22.Same || len(d22.Diff) != 0 { t.Fatalf("a version against itself: %+v", d22) } }) @@ -110,3 +110,42 @@ func TestDiffIsExactOnSmallTexts(t *testing.T) { } } } + +// history is bounded like the other answers: its work, and its answer, with a cut notice (the review of #206). +func TestHistoryIsBounded(t *testing.T) { + w := imported(t, newMemStore()) + n := w.reserved(t, "author@g14") + p := valid(n) + big := strings.Repeat("A line of reasoning that is long enough to count.\n", 3000) + p.Sections[0].Text = big + if _, err := w.k.Propose("author@g14", p); err != nil { + t.Fatal(err) + } + p.Sections[0].Text = strings.ReplaceAll(big, "long enough", "longer than") + if _, err := w.k.Propose("author@g14", p); err != nil { + t.Fatal(err) + } + h, err := w.k.History(Key{KindDecision, n}, 2, 3, 0) + if err != nil { + t.Fatal(err) + } + if size(h) > DefaultBound || h.Cut == "" { + t.Fatalf("a large difference: %d bytes, cut %q", size(h), h.Cut) + } + // The difference stays exact when it is not cut, however large: its work is bounded, not its truth. + whole, _ := w.k.History(Key{KindDecision, n}, 2, 3, MostBound) + vs, _ := w.k.Store.Versions(t.Context(), Key{KindDecision, n}) + if whole.Cut == "" && applyDiff(Render(vs[1].Record), whole.Diff) != Render(vs[2].Record) { + t.Fatal("the bounded difference is not exact") + } + if d := Diff(strings.Repeat("a\n", 5000)+"x", strings.Repeat("b\n", 5000)+"y"); applyDiff(strings.Repeat("a\n", 5000)+"x", d) != strings.Repeat("b\n", 5000)+"y" { + t.Fatal("a difference past the work bound is not exact") + } +} + +func TestAQuestionIsBounded(t *testing.T) { + w := imported(t, newMemStore()) + if _, err := w.k.Ask(strings.Repeat("merge window ", 200), 0); err == nil || !strings.Contains(err.Error(), "at most") { + t.Fatalf("a question of 2600 characters: %v", err) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index e675a135..34b353f6 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -293,12 +293,20 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { switch w := held.Working(); { case held.Current.Status == StatusReserved: r.ReservedBy, r.ReservedAt, r.ReservedFor = held.Current.ReservedBy, held.Current.ReservedAt, held.Current.ReservedFor + if proven(r.ReservedBy) && caller != r.ReservedBy { + return nil, fmt.Errorf("%s was reserved by %s: only who reserved a number proposes under it. Nothing was proposed", key, r.ReservedBy) + } if why == "" { why = "proposed" } case w.Status == StatusProposed && held.Pending == nil: // A revision of a proposal: a new version, its earlier reviews kept with the versions they reviewed. + if !proven(caller) || caller != w.Author { + return nil, fmt.Errorf("%s is proposed by %s: only its author, proven by the runtime, revises it (this call is by %s). "+ + "Nothing was proposed", key, w.Author, caller) + } r.Author, r.Reviews, r.ReservedBy, r.ReservedAt, r.ReservedFor = w.Author, w.Reviews, w.ReservedBy, w.ReservedAt, w.ReservedFor + r.Writers = w.Writers if a.Insight != "" { refusals = append(refusals, Refusal{Rule: "reserved", Says: "a proposal not yet accepted is revised, not corrected by an insight"}) } @@ -312,6 +320,10 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { } current = false r.Author = caller + r.Writers = append([]string{}, held.Current.Writers...) + if held.Current.Author != "" && !contains(r.Writers, held.Current.Author) { + r.Writers = append(r.Writers, held.Current.Author) + } r.AcceptedAt = held.Current.AcceptedAt r.Pending = &PendingState{Insight: Insight{Date: k.now().Format("2006-01-02"), Before: strings.TrimSpace(a.Insight), By: caller}} r.Date = held.Current.Date @@ -323,6 +335,14 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { return nil, fmt.Errorf("%s is %s and takes no proposal; nothing was proposed", key, held.Working().Status) } } + 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. + refusals = append(refusals, Refusal{Rule: "date", Says: fmt.Sprintf("a new record is dated from the day its number was reserved, %s, not %s", + held.Current.ReservedAt.Format("2006-01-02"), r.Date)}) + } + if !contains(r.Writers, caller) { + r.Writers = append(r.Writers, caller) + } if !current && a.Kind == KindResearch && r.Effort == "" { r.Effort = held.Current.Status } @@ -401,9 +421,22 @@ func (k *KB) Review(caller string, a ReviewAsk) (*Reviewed, error) { "author, and none is taken until it does. Nothing was reviewed", key) } w := held.Working().Clone() - if caller == w.Author { - return nil, fmt.Errorf("%s was written by %s, who called this review: a record is reviewed by someone who did not write it. "+ - "Nothing was reviewed", key, caller) + // The record's writers; an imported record's are hq's, reviewed there, and none of them is named here. + var writers []string + for _, by := range append([]string{w.Author}, w.Writers...) { + if by != "" { + writers = append(writers, by) + } + } + for _, by := range writers { + if !proven(by) { + return nil, fmt.Errorf("%s has a version written while the runtime proved no caller, so who wrote it cannot be told from "+ + "who reviews it: its author is to propose it again under a new number. Nothing was reviewed", key) + } + } + if contains(writers, caller) { + return nil, fmt.Errorf("%s has a version %s wrote, who called this review: a record is reviewed by someone who wrote none "+ + "of it. Nothing was reviewed", key, caller) } reviewed := held.CurrentVersion if held.Pending != nil { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 0b84ceb0..9b6783f5 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -103,8 +103,10 @@ func serve(k *KB, status string) { } } -// callerOf is who called, as the runtime proves it. The runtime does not hand a module its caller yet (hq issue -// 365), so it is Unproven; it is never read from the call's arguments, so no caller can name another. +// callerOf is who called, as the runtime proves it: the one place the caller is read, so the change that brings it +// is this one function. The runtime does not hand a module its caller yet (hq issue 365): the SDK's tool call carries +// a name and arguments only, so the SDK must first pass the runtime's proven caller to a tool. Until it does, every +// caller is Unproven. It is never read from the call's arguments, so no caller can name another. func callerOf(map[string]any) string { return Unproven } // hear subscribes to the router's word on this module's asks, asked again until the runtime takes it. @@ -638,7 +640,8 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { "it is in force, what changed or superseded it, or that it is only drafted and not the record's word; each record says " + "what changed it since and how to read its reasoning. One call, within a bound it states (4 KiB unless asked for more), " + "saying what it cut.", - Input: schema([]string{"question"}, map[string]any{"question": text("the question, in plain words"), "bound": boundArg()}), + Input: schema([]string{"question"}, map[string]any{"question": map[string]any{"type": "string", "maxLength": MostQuestion, + "description": "the question, in plain words, at most 500 characters"}, "bound": boundArg()}), Run: func(m map[string]any) (any, error) { a, err := readArgs("ask", m, "question", "bound") if err != nil { @@ -736,11 +739,12 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { }}, {Name: Seat + ".history", Description: "A record's versions, every one kept: who made it, when, why, and whether it was a progressive insight; with " + - "from and to, the difference between those two versions, line by line.", + "from and to, the difference between those two versions, line by line. Within a bound it states, 4 KiB unless asked for " + + "more, saying what it cut.", Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), - "from": integer("the earlier version to compare"), "to": integer("the later version to compare")}), + "from": integer("the earlier version to compare"), "to": integer("the later version to compare"), "bound": boundArg()}), Run: func(m map[string]any) (any, error) { - a, err := readArgs("history", m, "kind", "number", "from", "to") + a, err := readArgs("history", m, "kind", "number", "from", "to", "bound") if err != nil { return nil, err } @@ -756,7 +760,11 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { if err != nil { return nil, err } - return k.History(key, from, to) + b, err := a.num("bound", false) + if err != nil { + return nil, err + } + return k.History(key, from, to, b) }}, {Name: Seat + ".reserve", Description: "Reserve the next decision or research number for a record you are about to propose, from the issue " + diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/record.go b/modules/mesh-knowledge/cmd/mesh-knowledge/record.go index b47de3cc..2569f65c 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/record.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/record.go @@ -279,7 +279,9 @@ type Record struct { // Author is who proposed it, as the runtime proves the caller; Source names the commit and path of hq an // imported record was read from. Author string `json:"author,omitempty"` - Source string `json:"source,omitempty"` + // Writers are everyone who wrote a version of it, as the runtime proved them: none of them reviews it. + Writers []string `json:"writers,omitempty"` + Source string `json:"source,omitempty"` // Reserved is who reserved the number, when, and for what title. ReservedBy string `json:"reserved-by,omitempty"` ReservedAt time.Time `json:"reserved-at,omitempty"` diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go index cc8c40e2..bbb81cf8 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/review_test.go @@ -29,7 +29,7 @@ func TestAReviewByTheAuthorIsRefused(t *testing.T) { w := imported(t, s) n := proposed(t, w, "author@g14", false) _, err := w.k.Review("author@g14", ReviewAsk{Kind: KindDecision, Number: n, Verdict: VerdictApprove}) - if err == nil || !strings.Contains(err.Error(), "someone who did not write it") { + if err == nil || !strings.Contains(err.Error(), "someone who wrote none") { t.Fatalf("a review by the author: %v", err) } if e := mustGet(t, s, Key{KindDecision, n}); e.Current.Status != StatusProposed || len(e.Current.Reviews) != 0 { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go index b7b0a36a..f789ef5d 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go @@ -121,7 +121,9 @@ func allText(r *Record) string { func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal { var out []Refusal refuse := func(rule, format string, a ...any) { - out = append(out, Refusal{Rule: rule, Says: fmt.Sprintf(format, a...)}) + if Holds(rule, r.Date) { + out = append(out, Refusal{Rule: rule, Says: fmt.Sprintf(format, a...)}) + } } decision := r.Kind == KindDecision if r.Kind != KindDecision && r.Kind != KindResearch { @@ -531,3 +533,30 @@ func JudgeSet(records []*Record) []Finding { } return out } + +// 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 +// from 2026-10-09; records.py the clauses from 2026-10-11 (ADR 0290 rule 3). The decision record's sections, and +// a superseded record cited alone, were held by no check of hq's (words.py and live-citation never read decision +// records): they hold from the day the knowledge base takes the write, 2026-10-11. Every other rule holds every +// record, as hq's checks held it. +var RuleFrom = map[string]string{ + "retired-word": "2026-10-07", + "daemon-line": "2026-10-09", + "deferral": "2026-10-09", + "has-clause": "2026-10-11", + "clause-numbering": "2026-10-11", + "clause-lead": "2026-10-11", + "clause-size": "2026-10-11", + "context": "2026-10-11", + "consequences": "2026-10-11", + "checked": "2026-10-11", + "live-citation": "2026-10-11", +} + +// Holds says whether a rule holds a record of this date. A record with no day is held to every rule. +func Holds(rule, date string) bool { + from, dated := RuleFrom[rule] + return !dated || !isDay.MatchString(date) || date >= from +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/writers_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/writers_test.go new file mode 100644 index 00000000..2cb123dd --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/writers_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "strings" + "testing" +) + +// Who may write and review (the review of mesh-catalog#206): a proposal is revised only by its author, proven; a +// number reserved by a proven caller is proposed only by that caller; a review is refused from anyone who wrote +// any version of the record, and for a record whose author was never proven. + +func TestARevisionIsTakenOnlyFromTheRecordsAuthor(t *testing.T) { + w := imported(t, newMemStore()) + n := proposed(t, w, "author@g14", false) + p := valid(n) + p.Sections[2].Text = "Merges land a little sooner." + if _, err := w.k.Propose("other@novox", p); err == nil || !strings.Contains(err.Error(), "only its author") { + t.Fatalf("a revision by someone else: %v", err) + } + if _, err := w.k.Propose(Unproven, p); err == nil || !strings.Contains(err.Error(), "only its author") { + t.Fatalf("a revision by an unproven caller: %v", err) + } + if mustGet(t, w.k.Store, Key{KindDecision, n}).Versions != 2 { + t.Fatal("a refused revision wrote a version") + } + if _, err := w.k.Propose("author@g14", p); err != nil { + t.Fatalf("the author's revision: %v", err) + } +} + +func TestAReservedNumberIsProposedOnlyByWhoReservedIt(t *testing.T) { + w := imported(t, newMemStore()) + n := w.reserved(t, "author@g14") + if _, err := w.k.Propose("other@novox", valid(n)); err == nil || !strings.Contains(err.Error(), "reserved by author@g14") { + t.Fatalf("a proposal under another's number: %v", err) + } + if _, err := w.k.Propose("author@g14", valid(n)); err != nil { + t.Fatal(err) + } +} + +// A writer of any version, not only the first, does not review it. +func TestAReviewByAnyoneWhoWroteAVersionIsRefused(t *testing.T) { + w := imported(t, newMemStore()) + n := w.reserved(t, "author@g14") + if _, err := w.k.Propose("author@g14", valid(n)); err != nil { + t.Fatal(err) + } + // An insight to an accepted record: its writers are the record's and the insight's. + if _, err := w.k.Review("reviewer@novox", ReviewAsk{Kind: KindDecision, Number: n, Verdict: VerdictApprove}); err != nil { + t.Fatal(err) + } + p := valid(n) + p.Sections[2].Text = "Merges land sooner, by a minute." + p.Insight = "it said only that merges land sooner" + if _, err := w.k.Propose("corrector@ace", p); err != nil { + t.Fatal(err) + } + for _, writer := range []string{"author@g14", "corrector@ace"} { + if _, err := w.k.Review(writer, ReviewAsk{Kind: KindDecision, Number: n, Verdict: VerdictApprove}); err == nil || + !strings.Contains(err.Error(), "wrote") { + t.Fatalf("a review by %s, who wrote a version: %v", writer, err) + } + } + if _, err := w.k.Review("reviewer@novox", ReviewAsk{Kind: KindDecision, Number: n, Verdict: VerdictApprove}); err != nil { + t.Fatal(err) + } +} + +// A record proposed while callers were unproven is never reviewable, even once callers are proven: its real +// author cannot be told from its reviewer. It is proposed again. +func TestARecordProposedUnprovenIsNeverReviewed(t *testing.T) { + w := imported(t, newMemStore()) + n := proposed(t, w, Unproven, false) + _, err := w.k.Review("reviewer@novox", ReviewAsk{Kind: KindDecision, Number: n, Verdict: VerdictApprove}) + if err == nil || !strings.Contains(err.Error(), "propose it again") { + t.Fatalf("a review of a record whose author is unproven: %v", err) + } + if mustGet(t, w.k.Store, Key{KindDecision, n}).Current.Status != StatusProposed { + t.Fatal("accepted") + } +}