Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go
T
jochen eadf301f9f An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)
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.
2026-10-13 17:16:50 +02:00

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
}