The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
620 lines
24 KiB
Go
620 lines
24 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). Resolved is the line its message says
|
|
// once it is cleared: the channel keeps the one given when it is raised (novox/hq issue 497); "" for none.
|
|
type Notifier func(key, summary, headline, explanation, resolved string) error
|
|
|
|
// Clearer tells the operator's channel that what it was told under key is over, in one resolved line.
|
|
type Clearer func(key, resolved string) error
|
|
|
|
// KB is the knowledge base.
|
|
type KB struct {
|
|
Store Store
|
|
Records Records
|
|
Reserve Reserver
|
|
Judge Judgement
|
|
Asks AskBus
|
|
Notify Notifier
|
|
Clear Clearer
|
|
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
|
|
// Brief is what the operator's ask shows (novox/hq issue 639).
|
|
Brief *Brief
|
|
// 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, Brief: a.Brief, 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
|
|
}
|
|
|
|
// findingPrefix keys the notice of one whole-set finding: the prefix, the rule, and the records it names.
|
|
const findingPrefix = "knowledge.finding."
|
|
|
|
func findingKey(f Finding) string {
|
|
names := make([]string, len(f.Records))
|
|
for i, r := range f.Records {
|
|
names[i] = strings.ReplaceAll(strings.ToLower(r), " ", "-")
|
|
}
|
|
return findingPrefix + f.Rule + "." + strings.Join(names, ".")
|
|
}
|
|
|
|
// TellFindings, called by the background work and never under the write lock, tells the operator, once, each finding of the rules that judge the whole record set, as a warning
|
|
// naming the rule and the records, and clears the notice once the rule holds for them again. A finding is a fault
|
|
// of the records, which their authors repair; it is not the module failing, so it leaves the module's health as it
|
|
// is and never holds the module's own rollouts (the operator's decision of 2026-10-12 on novox/hq issue 504, as
|
|
// issue 508 decided for a proposal never reviewed). A notice that cannot be sent is not kept as told, and is sent
|
|
// again at the next step.
|
|
func (k *KB) TellFindings() {
|
|
if k.Notify == nil || k.Clear == nil {
|
|
return
|
|
}
|
|
ctx := context.Background()
|
|
findings, err := k.Store.Findings(ctx)
|
|
if err != nil {
|
|
k.logf("[mesh-knowledge] the whole-set findings cannot be read to tell them: %v", err)
|
|
return
|
|
}
|
|
now := map[string]bool{}
|
|
for _, f := range findings {
|
|
key := findingKey(f)
|
|
now[key] = true
|
|
first, err := k.Store.Told(ctx, key)
|
|
if err != nil || !first {
|
|
continue
|
|
}
|
|
records := strings.Join(f.Records, ", ")
|
|
summary := fmt.Sprintf("The rule %s, which judges the whole record set, fails: %s.", f.Rule, f.Says)
|
|
explanation := "The knowledge base works; its records break a rule. The acceptance stands. The author of " + records +
|
|
" repairs it: a decision cited by nothing names its home in a progressive insight (propose with homes and insight), " +
|
|
"or a record that cites it is accepted. get and links show the record."
|
|
resolved := fmt.Sprintf("The rule %s holds again for %s", f.Rule, records)
|
|
if err := k.Notify(key, summary, "A rule of the record set fails: "+records, explanation, resolved); err != nil {
|
|
k.logf("[mesh-knowledge] the operator could not be told that %s fails for %s: %v; told again at the next step", f.Rule, records, err)
|
|
if uerr := k.Store.Untold(ctx, key); uerr != nil {
|
|
k.logf("[mesh-knowledge] and that it was not told could not be kept: %v", uerr)
|
|
}
|
|
}
|
|
}
|
|
told, err := k.Store.ToldOf(ctx, findingPrefix)
|
|
if err != nil {
|
|
k.logf("[mesh-knowledge] the notices of whole-set findings cannot be read: %v", err)
|
|
return
|
|
}
|
|
for _, key := range told {
|
|
if now[key] {
|
|
continue
|
|
}
|
|
if err := k.Clear(key, ""); err != nil {
|
|
k.logf("[mesh-knowledge] the notice %s could not be cleared: %v; it is cleared again at the next step", key, err)
|
|
continue
|
|
}
|
|
if err := k.Store.Untold(ctx, key); err != nil {
|
|
k.logf("[mesh-knowledge] the notice %s was cleared, and that could not be kept: %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 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
|
|
}
|