Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go
T
jschoubben 6fb827605a
mesh/merge-gate pass: builds mesh-knowledge → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@1b7f79f76177 (merged as 73367192 into main, walk plan-17917161183…
mesh-knowledge: bound every review failure per record, make one out of tries loud, and let only its author revise a pending insight (review of #211)
2026-10-11 12:41:24 +02:00

546 lines
20 KiB
Go

package main
// The knowledge base's writes (novox/hq ADR 0290 rules 4 and 5, to-be 55 §4, §5 and §6): a number reserved from
// the issue tracker, a record proposed and judged by the 29 rules, reviewed by the knowledge base's own judgement
// (review.go), and accepted — at once, or on the operator's proven warrant when it needs the operator — with every
// clause it changes or supersedes moved accordingly and the whole record set judged after.
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Unproven is who acted, as recorded, where the runtime proves no caller (hq issue 365): the SDK's word. The caller
// is never taken from an argument.
const Unproven = stdio.Unproven
// proven says whether a caller is one the runtime proved.
func proven(caller string) bool { return caller != "" && caller != Unproven }
// The events this module says (ADR 0290 rule 7): every change, with what changed and who changed it.
const (
EventReserved = "reserved"
EventProposed = "proposed"
EventReviewed = "reviewed"
EventAccepted = "accepted"
EventChanged = "changed"
EventImported = "imported"
EventDrafted = "drafted"
EventConfirmed = "confirmed"
EventReturned = "returned"
)
// Events are the events, in the order the manifest declares them.
var Events = []string{EventReserved, EventProposed, EventReviewed, EventAccepted, EventChanged, EventImported,
EventDrafted, EventConfirmed, EventReturned}
// Settings are the operator's values for this module, merged by the mesh into one JSON file.
type Settings struct {
// JudgeModel is the model the drafting and confirming judgements ask; sonnet when empty.
JudgeModel string `json:"judge-model,omitempty"`
// Drafting is "off" to stop drafting and confirming clauses; anything else is on.
Drafting string `json:"drafting,omitempty"`
// ReservedFor is how long a reserved number waits for its proposal before it is withdrawn, as a duration
// such as 168h; DefaultReservedFor when empty.
ReservedFor string `json:"reserved-for,omitempty"`
}
// DefaultReservedFor is how long a reservation waits for its proposal unless the settings say otherwise.
const DefaultReservedFor = 7 * 24 * time.Hour
// Reserver asks the issue tracker for the next number of a kind (issue-tracker.reserve).
type Reserver func(kind, title string) (int, error)
// Judgement asks the agent on the node one question with no tools (claude-code.claude_code_judge), and answers
// the structured answer and who judged.
type Judgement func(question string, schema map[string]any, model string) (json.RawMessage, string, error)
// Notifier tells the operator's channel something (operator-channel.notify).
type Notifier func(key, summary, headline, explanation string) error
// KB is the knowledge base.
type KB struct {
Store Store
Records Records
Reserve Reserver
Judge Judgement
Asks AskBus
Notify Notifier
Emit func(event string, body any) error
Settings func() (Settings, error)
Now func() time.Time
Logf func(format string, a ...any)
// mu serialises the writes: one instance holds the seat, and a write reads, judges and writes as one.
mu sync.Mutex
stuckMu sync.Mutex
stuck map[Key]*stuckReview
// kick wakes the background work when a proposal waits for its review; nil where nothing runs in the
// background (the tests take each step themselves).
kick chan struct{}
refMu sync.Mutex
ref *Reference
refAt time.Time
}
func (k *KB) now() time.Time {
if k.Now != nil {
return k.Now().UTC()
}
return time.Now().UTC()
}
func (k *KB) logf(format string, a ...any) {
if k.Logf != nil {
k.Logf(format, a...)
}
}
func (k *KB) settings() Settings {
if k.Settings == nil {
return Settings{}
}
s, err := k.Settings()
if err != nil {
k.logf("[mesh-knowledge] the settings cannot be read (%v): the defaults hold", err)
}
return s
}
// emit says a change. A change that could not be said is logged and kept: the store holds it whole, and its
// versions are the record of who changed what.
func (k *KB) emit(event string, r *Record, by, what string, version int) {
if k.Emit == nil {
return
}
body := map[string]any{"kind": r.Kind, "number": r.Number, "record": r.Key().String(), "by": by, "at": k.now(),
"what": what, "version": version, "status": r.Status}
if err := k.Emit(event, body); err != nil {
k.logf("[mesh-knowledge] the event %s for %s could not be said: %v", event, r.Key(), err)
}
}
// reference is the reading order and the glossary, read again after ten minutes.
func (k *KB) reference() (*Reference, error) {
k.refMu.Lock()
defer k.refMu.Unlock()
if k.ref != nil && k.now().Sub(k.refAt) < 10*time.Minute {
return k.ref, nil
}
if k.Records == nil {
return nil, errors.New("hq cannot be read: the module has no way to the records module")
}
ref, err := LoadReference(k.Records)
if err != nil {
if k.ref != nil {
return k.ref, nil
}
return nil, err
}
k.ref, k.refAt = ref, k.now()
return ref, nil
}
func (k *KB) lookup(ctx context.Context) Lookup {
return func(key Key) *Record {
e, err := k.Store.Get(ctx, key)
if err != nil || e == nil {
return nil
}
return e.Current
}
}
var kindNumber = regexp.MustCompile(`^\d{1,4}$`)
// ---- reserve -----------------------------------------------------------------------------------------------
// Reserved is the answer to reserve.
type Reserved struct {
Kind string `json:"kind"`
Number int `json:"number"`
Record string `json:"record"`
Says string `json:"says"`
}
// ReserveNumber asks the issue tracker for the next number of a kind and holds it as reserved, with who reserved
// it and when (to-be 55 §6). The knowledge base keeps no counter.
func (k *KB) ReserveNumber(caller, kind, title string) (*Reserved, error) {
if !contains(Kinds, kind) {
return nil, fmt.Errorf("kind is decision or research, not %q; nothing was reserved", kind)
}
title = strings.TrimSpace(title)
if title == "" || strings.Contains(title, "\n") || len([]rune(title)) > 200 {
return nil, errors.New("for is the record's title, one line of at most 200 characters; nothing was reserved")
}
if k.Reserve == nil {
return nil, errors.New("the issue tracker cannot be asked for a number here; nothing was reserved")
}
n, err := k.Reserve(kind, title)
if err != nil {
return nil, fmt.Errorf("the issue tracker did not hand out a %s number (%v); nothing was reserved", kind, err)
}
k.mu.Lock()
defer k.mu.Unlock()
ctx := context.Background()
at := k.now()
r := &Record{Kind: kind, Number: n, Title: title, Status: StatusReserved, ReservedBy: caller, ReservedAt: at, ReservedFor: title}
err = k.Store.Create(ctx, Version{By: caller, At: at, Why: "number reserved from the issue tracker", Record: r})
if errors.Is(err, ErrExists) {
return nil, fmt.Errorf("the issue tracker handed out %s, which the knowledge base already holds: the number is spent, "+
"and its counter stands below what the knowledge base holds. Reserve again, and tell the operator", r.Key())
}
if err != nil {
return nil, fmt.Errorf("%s was handed out and could not be kept (%v): the number is spent; reserve again", r.Key(), err)
}
k.emit(EventReserved, r, caller, "reserved for "+title, 1)
return &Reserved{Kind: kind, Number: n, Record: r.Key().String(),
Says: fmt.Sprintf("%s is reserved for %q. Propose the record under this number; a number never proposed is withdrawn, and never handed out again.", r.Key(), title)}, nil
}
// ---- propose -----------------------------------------------------------------------------------------------
// LinkAsk is a link as a proposal gives it: its kind, its target as written ("0276 §4"), the clause it goes out
// from.
type LinkAsk struct {
Kind string `json:"kind"`
To string `json:"to"`
From int `json:"from,omitempty"`
}
// ProposeAsk is a record proposed whole.
type ProposeAsk struct {
Kind string
Number int
Title string
Topic string
Date string
Deciders []string
Clauses []string
Sections []Section
Links []LinkAsk
Homes []string
NeedsOperator bool
// Status is a research effort's: active, graduated or abandoned.
Status string
// Insight, for an accepted record, is what stood before: the proposal is a progressive insight.
Insight string
Why string
}
// Proposed is the answer to propose.
type Proposed struct {
Record string `json:"record"`
Version int `json:"version"`
Status string `json:"status"`
Says string `json:"says"`
}
var numberedClause = regexp.MustCompile(`^(\d+)\.\s+`)
// MostRecordBytes is the most one record may hold.
const MostRecordBytes = 512 << 10
// Propose writes a record whole as proposed, or refuses it naming every rule it breaks. Every version proposed
// waits for the knowledge base's own review (review.go), which the background work takes at once. A proposal is
// revised as new versions by its author until it is accepted; an accepted record is changed only by a progressive
// insight (Insight), which is reviewed like a proposal while the accepted version stays the one served.
func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
ctx := context.Background()
k.mu.Lock()
defer k.mu.Unlock()
if !contains(Kinds, a.Kind) {
return nil, fmt.Errorf("kind is decision or research, not %q; nothing was proposed", a.Kind)
}
key := Key{a.Kind, a.Number}
held, err := k.Store.Get(ctx, key)
if err != nil {
return nil, fmt.Errorf("the knowledge base cannot be read (%v); nothing was proposed", err)
}
ref, err := k.reference()
if err != nil {
return nil, fmt.Errorf("the rules cannot be judged: %v. Nothing was proposed", err)
}
r := &Record{Kind: a.Kind, Number: a.Number, Title: strings.TrimSpace(a.Title), Topic: strings.TrimSpace(a.Topic),
Status: StatusProposed, Date: strings.TrimSpace(a.Date), Deciders: a.Deciders, Effort: a.Status,
NeedsOperator: a.NeedsOperator, Sections: a.Sections, Homes: a.Homes, Author: caller}
if r.Date == "" {
r.Date = k.now().Format("2006-01-02")
}
var refusals []Refusal
for i, text := range a.Clauses {
n := i + 1
text = strings.TrimSpace(text)
if m := numberedClause.FindStringSubmatch(text); m != nil {
n, _ = strconv.Atoi(m[1])
text = strings.TrimSpace(text[len(m[0]):])
}
r.Clauses = append(r.Clauses, Clause{N: n, Text: text, Status: ClauseProposed, Source: SourceWritten})
}
for _, l := range a.Links {
to, err := ParseRef(l.To)
if err != nil {
refusals = append(refusals, Refusal{Rule: "link-target", Says: err.Error()})
continue
}
r.Links = append(r.Links, Link{Kind: l.Kind, From: l.From, To: to, Source: SourceWritten})
}
current := true
why := strings.TrimSpace(a.Why)
if held != nil {
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"})
}
if why == "" {
why = "revised"
}
case held.Current.Accepted():
if held.Pending != nil && (!proven(caller) || caller != held.Pending.Author) {
return nil, fmt.Errorf("%s has a progressive insight by %s waiting for its review: only its author, proven by the "+
"runtime, revises it (this call is by %s). Nothing was proposed", key, held.Pending.Author, caller)
}
if strings.TrimSpace(a.Insight) == "" {
return nil, fmt.Errorf("%s is %s: it changes only by a new record, or by a progressive insight, a correction of fact "+
"that leaves the decision standing (give insight: what stood before). Nothing was proposed", key, held.Current.Status)
}
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
r.Effort = a.Status
if why == "" {
why = "a progressive insight proposed"
}
default:
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)
}
// Every version proposed is reviewed by the knowledge base itself, once; its earlier reviews stay with the
// versions they reviewed.
r.Judging = judgingOf(r)
if !current && a.Kind == KindResearch && r.Effort == "" {
r.Effort = held.Current.Status
}
refusals = append(refusals, JudgeRecord(r, held, k.lookup(ctx), ref)...)
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 {
lines[i] = x.String()
}
return nil, fmt.Errorf("%s is refused by %d rule(s), and nothing was written:\n- %s", key, len(refusals), strings.Join(lines, "\n- "))
}
v := Version{By: caller, At: k.now(), Why: why, Record: r, Insight: !current}
if err := k.Store.Save(ctx, v, current, false, held.Revision); err != nil {
return nil, fmt.Errorf("%s could not be written (%v); nothing was proposed", key, err)
}
n := held.Versions + 1
k.emit(EventProposed, r, caller, why, n)
k.wake()
if r.Judging.State == JudgingUnproven {
return &Proposed{Record: key.String(), Version: n, Status: StatusProposed,
Says: fmt.Sprintf("%s is proposed as version %d, and %s. Nothing of it is in force.", key, n, r.Judging.Reason)}, nil
}
says := fmt.Sprintf("%s is proposed as version %d. The knowledge base reviews it now, in a judgement of its own; nothing of it "+
"is in force until that judgement approves it", key, n)
if r.NeedsOperator {
says += ", and then the operator's Approve"
}
if !current {
says = fmt.Sprintf("A progressive insight to %s is proposed as version %d. The knowledge base reviews it now; the accepted "+
"version is served until that review approves it", key, n)
}
says += ". get shows the review's verdict and findings once it is made; on changes, you revise it by proposing it again"
return &Proposed{Record: key.String(), Version: n, Status: StatusProposed, Says: says + "."}, nil
}
// ---- acceptance ------------------------------------------------------------------------------------------
// accept makes a reviewed version the one served: accepted, its clauses in force, every clause and record it
// changes or supersedes moved; then the whole record set is judged. by is who acted; warrant, the operator's word
// it rests on.
func (k *KB) accept(ctx context.Context, held *Entry, w *Record, by, why, warrant string) (*Record, error) {
r := w.Clone()
insight := r.Pending != nil
at := k.now()
if insight {
in := r.Pending.Insight
r.Pending = nil
r.Insights = append(r.Insights, in)
r.Status = held.Current.Status
if r.Kind == KindResearch && contains(ResearchStatuses, r.Effort) && held.Current.Status != StatusSuperseded {
r.Status = r.Effort
}
for i, c := range r.Clauses {
r.Clauses[i].Status = ClauseInForce
if old := held.Current.Clause(c.N); old != nil && (old.Status == ClauseChanged || old.Status == ClauseSuperseded) {
r.Clauses[i].Status, r.Clauses[i].ChangedBy = old.Status, old.ChangedBy
}
}
} else {
r.Status = StatusAccepted
if r.Kind == KindResearch {
r.Status = r.Effort
}
r.AcceptedAt = at
for i := range r.Clauses {
r.Clauses[i].Status = ClauseInForce
}
}
r.Effort = ""
if warrant != "" {
why += "; " + warrant
}
if err := k.Store.Save(ctx, Version{By: by, At: at, Why: why, Record: r, Insight: insight}, true, true, held.Revision); err != nil {
return nil, fmt.Errorf("%s could not be accepted (%v); it stays proposed", r.Key(), err)
}
k.emit(EventAccepted, r, by, why, held.Versions+1)
// What it changes and supersedes, each a new version of that record.
for _, l := range r.Links {
to, ok := l.To.Record()
if !ok || (l.Kind != LinkSupersedes && l.Kind != LinkChanges) || (insight && hasLink(held.Current, l)) {
continue
}
t, err := k.Store.Get(ctx, to)
if err != nil || t == nil {
k.logf("[mesh-knowledge] %s %s %s, which cannot be read now (%v): the whole-set rules will say so", r.Key(), l.Kind, to, err)
continue
}
c := t.Current.Clone()
var what string
switch l.Kind {
case LinkSupersedes:
c.Status = StatusSuperseded
c.Extra = addExtra(c.Extra, "superseded-by", r.Key().String())
for i := range c.Clauses {
c.Clauses[i].Status = ClauseSuperseded
c.Clauses[i].ChangedBy = append(c.Clauses[i].ChangedBy, r.Key().String())
}
what = "superseded by " + r.Key().String()
case LinkChanges:
cl := c.Clause(l.To.Clause)
if cl == nil || cl.Status == ClauseSuperseded {
continue
}
cl.Status = ClauseChanged
from := r.Key().String()
if l.From > 0 {
from += " §" + strconv.Itoa(l.From)
}
cl.ChangedBy = append(cl.ChangedBy, from)
what = fmt.Sprintf("§%d changed by %s", l.To.Clause, from)
}
if err := k.Store.Save(ctx, Version{By: by, At: at, Why: what, Record: c}, true, false, t.Revision); err != nil {
k.logf("[mesh-knowledge] %s could not be marked %s (%v): the whole-set rules will say so", to, what, err)
continue
}
k.emit(EventChanged, c, by, what, t.Versions+1)
}
k.judgeSet(ctx)
return r, nil
}
// judgeSet holds the whole record set to its 4 rules and keeps what fails, which the module's health answers.
func (k *KB) judgeSet(ctx context.Context) []Finding {
all, err := k.Store.All(ctx)
if err != nil {
k.logf("[mesh-knowledge] the whole record set cannot be read to judge it: %v", err)
return nil
}
records := make([]*Record, len(all))
for i, e := range all {
records[i] = e.Current
}
findings := JudgeSet(records)
at := k.now()
for i := range findings {
findings[i].At = at
}
if err := k.Store.SetFindings(ctx, findings); err != nil {
k.logf("[mesh-knowledge] the whole-set findings could not be kept: %v", err)
}
for _, f := range findings {
k.logf("[mesh-knowledge] the whole-set rule %s fails: %s", f.Rule, f.Says)
}
return findings
}
// ---- reservations that were never proposed -------------------------------------------------------------------
// Withdraw withdraws every number reserved longer ago than the settings allow and never proposed: it stays spent,
// with who reserved it and when.
func (k *KB) Withdraw() (int, error) {
ctx := context.Background()
k.mu.Lock()
defer k.mu.Unlock()
bound := DefaultReservedFor
if s := strings.TrimSpace(k.settings().ReservedFor); s != "" {
if d, err := time.ParseDuration(s); err == nil && d > 0 {
bound = d
}
}
all, err := k.Store.All(ctx)
if err != nil {
return 0, err
}
n := 0
for _, e := range all {
if e.Current.Status != StatusReserved || k.now().Sub(e.Current.ReservedAt) < bound {
continue
}
r := e.Current.Clone()
r.Status = StatusWithdrawn
why := fmt.Sprintf("withdrawn: reserved by %s at %s and never proposed", r.ReservedBy, r.ReservedAt.Format(time.RFC3339))
if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
return n, err
}
k.emit(EventChanged, r, "the knowledge base", why, e.Versions+1)
n++
}
return n, nil
}