Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/retract.go
T
jschoubben 5d50b952d4
mesh/delivery merged: source.main moved to novox/mesh-catalog@65fad0ae1c31
mesh/check being carried to 5d50b952d4cb on 65fad0ae1c31 | from a68b39f7b512
knowledge-base: keep a retraction from being undone by a later acceptance or a redraft
A record proposed before a retraction and accepted after it marked the
retracted clause changed or the retracted record superseded, serving it again;
and returning a drafted record numbered its clauses anew, bringing retracted
ones back in force or making a waiting Approve mark another clause. accept now
leaves what is retracted alone, Return refuses a record with a retraction, and
retract waits until a record's drafting is done.
2026-10-31 10:16:56 +01:00

469 lines
19 KiB
Go

package main
// Retract (the operator, 2026-10-31: "I'm more a fan of removing incorrect decisions than creating new records,
// especially if I didn't create and verify/approve the decisions myself"): an accepted record, whole or the clauses
// listed, is taken back on the operator's Approve. The ask goes the way a record that needs the operator goes
// (operator.go): on the router, Approve and Decline at the level approve, each binding the act it authorises, and
// the retraction takes effect only on the router's warrant for Approve, read back from the router's own record and
// held to the ask composed again. Until then, and on Decline or expiry, nothing of the record changes but the
// version that says it was asked and how that ended. A retracted record or clause no longer holds; nothing is
// deleted: every version stays in history, and the version that retracts says by whom, why and on what warrant.
// What a retracted record changed or superseded stays so; only its mark says the change is retracted.
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// RetractAsked is the status of a retraction asked and not yet answered.
const RetractAsked = "asked"
// RetractedMark is what a clause another record changed or superseded carries, after the record that changed it
// is named, once that change is retracted.
const RetractedMark = " (retracted)"
// Retracting is the answer to retract.
type Retracting struct {
Record string `json:"record"`
Clauses []int `json:"clauses,omitempty"`
Status string `json:"status"`
Ask string `json:"ask"`
Says string `json:"says"`
}
// What is what a retraction takes back, in words: "the record whole" or "§1, §3".
func (x Retraction) What() string {
if x.Whole() {
return "the record whole"
}
return clauseList(x.Clauses)
}
func clauseList(cs []int) string {
parts := make([]string, len(cs))
for i, n := range cs {
parts[i] = "§" + strconv.Itoa(n)
}
return strings.Join(parts, ", ")
}
// Says is a retraction in one line: who asked it, when, why, and what the operator said.
func (x Retraction) Says() string {
s := fmt.Sprintf("by %s at %s: %s", x.By, x.At.UTC().Format("2006-01-02 15:04 UTC"), x.Why)
if x.Ask != nil {
s += "; " + x.Ask.Said
if x.Ask.Warrant != "" {
s += " (" + x.Ask.Warrant + ")"
}
}
return s
}
// Retract asks the operator to take back an accepted record whole (no clauses) or the clauses listed, with why in
// one line, and keeps the ask on the record as a new version. Nothing of the record changes until the operator's
// Approve (Reconcile). Refused: an empty or multi-line why; a record not held, or neither accepted nor superseded
// (a reserved or proposed one is withdrawn instead); one retracted already; a clause it has none of, listed twice,
// or retracted already; a retraction or a new version already waiting on it; and an operator who cannot be asked.
func (k *KB) Retract(caller string, key Key, clauses []int, why string) (*Retracting, error) {
why = strings.TrimSpace(why)
if why == "" || strings.Contains(why, "\n") || len([]rune(why)) > 200 {
return nil, errors.New("retract needs why: one line of at most 200 characters saying why it no longer holds; nothing was asked")
}
ctx := context.Background()
k.mu.Lock()
defer k.mu.Unlock()
e, err := k.Store.Get(ctx, key)
if err != nil {
return nil, fmt.Errorf("the knowledge base cannot be read (%v); nothing was asked", err)
}
if e == nil {
return nil, fmt.Errorf("the knowledge base holds no %s; nothing was asked", key)
}
cur := e.Current
switch {
case cur.Status == StatusRetracted:
return nil, fmt.Errorf("%s is retracted already; nothing was asked", key)
case cur.Status == StatusReserved || cur.Status == StatusProposed:
return nil, fmt.Errorf("%s is %s: withdraw it instead; only an accepted or superseded record is retracted. Nothing was asked", key, cur.Status)
case !cur.Accepted() && cur.Status != StatusSuperseded:
return nil, fmt.Errorf("%s is %s and takes no retraction; nothing was asked", key, cur.Status)
case cur.Retracting != nil:
return nil, fmt.Errorf("%s has a retraction waiting for the operator (%s): withdraw it first. Nothing was asked", key, cur.Retracting.Says())
case cur.Drafting != nil && cur.Drafting.State != DraftDone:
return nil, fmt.Errorf("%s's clauses are being drafted (%s): retract it once its drafting is done, so the clauses keep the "+
"numbers the operator is shown. Nothing was asked", key, cur.Drafting.State)
case e.Pending != nil:
return nil, fmt.Errorf("%s has version %d waiting for its review: let it be decided first. Nothing was asked", key, e.PendingVersion)
}
seen := map[int]bool{}
var leads []string
for _, n := range clauses {
c := cur.Clause(n)
switch {
case c == nil:
return nil, fmt.Errorf("%s has no clause %d; nothing was asked", key, n)
case seen[n]:
return nil, fmt.Errorf("clause %d is listed twice; nothing was asked", n)
case c.Status == ClauseRetracted:
return nil, fmt.Errorf("%s §%d is retracted already; nothing was asked", key, n)
}
seen[n] = true
leads = append(leads, c.Lead())
}
sorted := append([]int(nil), clauses...)
sort.Ints(sorted)
if k.Asks == nil {
return nil, errors.New("the operator cannot be asked from here; nothing was asked")
}
at := k.now()
x := &Retraction{Clauses: sorted, Why: why, By: caller, At: at, Title: cur.Title, Leads: leads,
Ask: &OperatorAsk{ID: newRetractAskID(cur), At: at, Version: e.CurrentVersion, Said: AskWaiting, Form: AskGlance}}
ask := composeRetractAsk(cur, x)
if err := ask.Check(at); err != nil {
return nil, fmt.Errorf("the ask could not be made (%v); nothing was asked", err)
}
r := cur.Clone()
r.Retracting = x
what := fmt.Sprintf("retraction of %s asked by %s: %s; the operator is asked", x.What(), caller, why)
if err := k.Store.Save(ctx, Version{By: caller, At: at, Why: what, Record: r}, true, false, e.Revision); err != nil {
return nil, fmt.Errorf("%s could not be written (%v); nothing was asked", key, err)
}
if err := k.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
// Taken back: the record says it was not asked, and nothing waits on it.
k.logf("[mesh-knowledge] the retraction of %s could not be asked: %v", key, err)
if e2, gerr := k.Store.Get(ctx, key); gerr == nil && e2 != nil {
back := e2.Current.Clone()
back.Retracting = nil
_ = k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: "retraction not asked: the operator could not be asked (" + err.Error() + ")",
Record: back}, true, false, e2.Revision)
}
return nil, fmt.Errorf("the operator could not be asked (%v); nothing changed", err)
}
k.emit(EventChanged, r, caller, what, e.Versions+1)
return &Retracting{Record: key.String(), Clauses: sorted, Status: RetractAsked, Ask: ask.ID,
Says: fmt.Sprintf("The operator is asked to retract %s of %s (%q). Nothing changes until the operator's Approve; on Decline or "+
"expiry nothing changes. get shows it waiting; withdraw with retraction true takes it back.", x.What(), key, ask.Headline)}, nil
}
// WithdrawRetraction takes back a retraction waiting for the operator, with why in one line: the ask is ended here
// and taken back on the router, and an answer to it is never acted on.
func (k *KB) WithdrawRetraction(caller string, key Key, why string) (*Withdrawn, error) {
why = strings.TrimSpace(why)
if why == "" {
return nil, errors.New("withdraw needs why: one line saying why the retraction is withdrawn; nothing was withdrawn")
}
ctx := context.Background()
k.mu.Lock()
defer k.mu.Unlock()
e, err := k.Store.Get(ctx, key)
if err != nil {
return nil, fmt.Errorf("the knowledge base cannot be read (%v); nothing was withdrawn", err)
}
if e == nil || e.Current.Retracting == nil {
return nil, fmt.Errorf("no retraction of %s waits for the operator; nothing was withdrawn", key)
}
id := e.Current.Retracting.Ask.ID
full := fmt.Sprintf("retraction of %s withdrawn by %s: %s", e.Current.Retracting.What(), caller, why)
if err := k.endRetraction(ctx, e, caller, full); err != nil {
return nil, err
}
if k.Asks != nil {
if err := k.Asks.Publish(asks.CancelSubject(Module), map[string]string{"id": id}, "cancel."+id); err != nil {
k.logf("[mesh-knowledge] the ask %s for %s is taken back here, and the router could not be told (%v): it expires there, "+
"and no answer to it is acted on", id, key, err)
}
}
return &Withdrawn{Record: key.String(), Status: e.Current.Status,
Says: fmt.Sprintf("The retraction of %s is withdrawn: %s. The record holds as it did.", key, why)}, nil
}
// endRetraction drops the waiting retraction as a new version saying why; nothing else of the record changes.
func (k *KB) endRetraction(ctx context.Context, e *Entry, by, why string) error {
r := e.Current.Clone()
r.Retracting = nil
if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
return fmt.Errorf("%s: %s, and that could not be kept (%v)", r.Key(), why, err)
}
k.emit(EventChanged, r, by, why, e.Versions+1)
return nil
}
func newRetractAskID(r *Record) string {
return "kb-retract-" + strings.TrimPrefix(newAskID(r), "kb-")
}
// retractAct is what an answer to a retraction binds: this record, the clauses, why, who asked, and the answer.
func retractAct(r *Record, x *Retraction, option string) asks.Act {
return asks.Act{"verb": "knowledge-base.retract", "option": option, "ask": x.Ask.ID, "kind": r.Kind,
"number": strconv.Itoa(r.Number), "clauses": clauseList(x.Clauses), "why": x.Why, "by": x.By}
}
// composeRetractAsk is the ask for a retraction, composed from the retraction alone, so it is the same when it is
// composed again to hold the warrant to it.
func composeRetractAsk(r *Record, x *Retraction) asks.Ask {
name := r.Key().String()
headline := "Retract " + name + "?"
if !x.Whole() {
if h := "Retract " + name + " " + clauseList(x.Clauses) + "?"; len([]rune(h)) <= asks.HeadlineLength {
headline = h
} else {
headline = fmt.Sprintf("Retract %d clauses of %s?", len(x.Clauses), name)
}
}
what := "It"
if !x.Whole() {
what = "Those clauses"
}
summary := what + " would no longer hold; nothing is deleted, its versions stay in history."
var points []string
if shown(x.Why) {
points = append(points, "Why: "+clip(x.Why, asks.PointLength-6))
} else {
points = append(points, "Why: read it with the knowledge base's get.")
}
if t := strings.TrimSuffix(strings.TrimSpace(x.Title), "."); shown(t) {
points = append(points, "Record: "+clip(t, asks.PointLength-9))
}
if !x.Whole() {
var leads []string
for i, l := range x.Leads {
if shown(l) && l != "" {
leads = append(leads, fmt.Sprintf("§%d %s", x.Clauses[i], l))
}
}
if len(leads) > 0 {
points = append(points, clip("Clauses: "+strings.Join(leads, "; "), asks.PointLength))
}
}
explanation := summary
for _, p := range points {
explanation += "\n• " + p
}
details := fmt.Sprintf("%s, retraction of %s asked by %s: %s\nRead it with the knowledge base's get, number %d.",
name, x.What(), x.By, x.Why, r.Number)
out := asks.Ask{ID: x.Ask.ID, Headline: headline, Explanation: explanation, Details: details,
Who: asks.Operator, Expires: x.Ask.At.Add(AskLasts), OnExpiry: "nothing is retracted; the record holds as it did",
About: "knowledge." + r.Kind + "." + strconv.Itoa(r.Number)}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "it is retracted and no longer holds"},
{OptionDecline, "Decline", "nothing is retracted; it holds as it did"},
} {
binds, _ := asks.ActDigest(retractAct(r, x, o.id))
out.Options = append(out.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
}
return out
}
// reconcileRetraction decides a retraction waiting for the operator whose ask the router has closed, or keeps
// that the router holds it. The caller holds the write lock.
func (k *KB) reconcileRetraction(ctx context.Context, e *Entry) string {
x := e.Current.Retracting
entry, err := k.Asks.Record(x.Ask.ID)
if err != nil {
return ""
}
if entry == nil {
if k.now().After(x.Ask.At.Add(AskLasts + time.Hour)) {
return k.endRetractionSaid(ctx, e, "the knowledge base",
"retraction not made: the router keeps no record of its ask (refused before it was taken, or forgotten)")
}
return ""
}
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) != nil {
return ""
}
if rec.State == "open" || rec.State == "held" {
if held := heldSays(rec); held != x.Ask.Held {
r := e.Current.Clone()
r.Retracting.Ask.Held = held
why := "the operator's ask to retract is offered by the router"
if held != "" {
why = "the operator's ask to retract is held by the router: " + held
}
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 fmt.Sprintf("%s: what became of its ask to retract could not be kept (%v)", r.Key(), err)
}
return fmt.Sprintf("%s: %s", r.Key(), why)
}
return ""
}
if rec.Warrant == nil {
return ""
}
return k.decideRetraction(ctx, e, *rec.Warrant)
}
func (k *KB) endRetractionSaid(ctx context.Context, e *Entry, by, why string) string {
if err := k.endRetraction(ctx, e, by, why); err != nil {
return err.Error()
}
return fmt.Sprintf("%s: %s; it holds as it did", e.Current.Key(), why)
}
// decideRetraction holds one closed ask's warrant to the retraction, and retracts on the operator's Approve.
func (k *KB) decideRetraction(ctx context.Context, e *Entry, warrant asks.Warrant) string {
cur := e.Current
x := cur.Retracting
if warrant.Outcome != asks.OutcomeChosen {
why := "retraction not made: the operator's ask ended " + string(warrant.Outcome)
if warrant.Words != "" {
why += " (" + warrant.Words + ")"
}
return k.endRetractionSaid(ctx, e, "the knowledge base", why)
}
option, err := warrant.For(Module, composeRetractAsk(cur, x))
if err == nil {
err = option.Performs(retractAct(cur, x, option.ID))
}
if err != nil {
k.logf("[mesh-knowledge] REFUSED the answer to the ask to retract %s: %v", cur.Key(), err)
return k.endRetractionSaid(ctx, e, "the knowledge base", "retraction not made: the answer does not hold here: "+err.Error())
}
if option.ID != OptionApprove {
return k.endRetractionSaid(ctx, e, "the operator", "retraction of "+x.What()+" declined by the operator ("+warrant.Says()+")")
}
r := cur.Clone()
done := *r.Retracting
done.Ask.Said, done.Ask.Warrant, done.Ask.Held, done.Applied = AskApproved, warrant.Says(), "", k.now()
r.Retracting = nil
if done.Whole() {
done.Before, r.Status = r.Status, StatusRetracted
} else {
for _, n := range done.Clauses {
if c := r.Clause(n); c != nil {
c.Status = ClauseRetracted
}
}
}
r.Retractions = append(r.Retractions, done)
why := fmt.Sprintf("retracted %s on the operator's Approve, asked by %s: %s; warrant: %s", done.What(), done.By, done.Why, warrant.Says())
if err := k.Store.Save(ctx, Version{By: "the operator", At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
return fmt.Sprintf("%s could not be retracted (%v); it holds as it did", r.Key(), err)
}
k.emit(EventChanged, r, "the operator", why, e.Versions+1)
k.markRetracted(ctx, r, done)
k.judgeSet(ctx)
return fmt.Sprintf("%s: %s", r.Key(), why)
}
// markRetracted marks, on every clause a retracted record or clause changed or superseded, that the change is
// retracted. The clause stays changed or superseded: a retraction takes back what the record said, not what it did
// to another; that record's author corrects it if it should hold again.
func (k *KB) markRetracted(ctx context.Context, r *Record, x Retraction) {
for _, l := range r.Links {
to, ok := l.To.Record()
if !ok || (l.Kind != LinkChanges && l.Kind != LinkSupersedes) || !(x.Whole() || (l.From > 0 && x.Covers(l.From))) {
continue
}
t, err := k.Store.Get(ctx, to)
if err != nil || t == nil {
continue
}
from := r.Key().String()
if l.From > 0 {
from += " §" + strconv.Itoa(l.From)
}
c := t.Current.Clone()
marked := false
for i := range c.Clauses {
cl := &c.Clauses[i]
if l.Kind == LinkChanges && cl.N != l.To.Clause {
continue
}
for j, by := range cl.ChangedBy {
if by == from || (l.Kind == LinkSupersedes && by == r.Key().String()) {
cl.ChangedBy[j], marked = by+RetractedMark, true
}
}
}
if !marked {
continue
}
what := fmt.Sprintf("marked: what %s changed here is retracted; the clause stays as it was changed", from)
if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: what, Record: c}, true, false, t.Revision); err != nil {
k.logf("[mesh-knowledge] %s could not be marked (%v)", to, err)
continue
}
k.emit(EventChanged, c, "the knowledge base", what, t.Versions+1)
}
}
// retractedSays is how a read says a retracted record or clause no longer holds.
func retractedSays(r *Record, n int) string {
x := r.RetractedBy(n)
if x == nil && n > 0 {
x = r.RetractedBy(0)
}
if x == nil {
return "retracted: not in force"
}
return "retracted: not in force (" + x.Says() + ")"
}
// RetractionRow is one approved retraction as get shows it.
type RetractionRow struct {
What string `json:"what"`
By string `json:"by"`
At string `json:"at"`
Why string `json:"why"`
Approval string `json:"approval"`
Applied string `json:"applied,omitempty"`
}
func retractionRows(r *Record) []RetractionRow {
var out []RetractionRow
for _, x := range r.Retractions {
row := RetractionRow{What: x.What(), By: x.By, At: x.At.UTC().Format("2006-01-02 15:04 UTC"), Why: x.Why}
if x.Ask != nil {
row.Approval = x.Ask.Said
if x.Ask.Warrant != "" {
row.Approval += ": " + x.Ask.Warrant
}
}
if !x.Applied.IsZero() {
row.Applied = x.Applied.UTC().Format("2006-01-02 15:04 UTC")
}
out = append(out, row)
}
return out
}
// retractingSays is a retraction waiting for the operator, in words, or "".
func retractingSays(r *Record) string {
x := r.Retracting
if x == nil {
return ""
}
s := fmt.Sprintf("a retraction of %s asked %s; waiting for the operator's Approve (ask %s): until then it holds as it did",
x.What(), x.Says(), x.Ask.ID)
if x.Ask.Held != "" {
s += "; the router holds the ask, not yet shown to the operator: " + x.Ask.Held
}
return s
}
// linkRetracted says, for a link read from either end, whether it no longer holds because its record or clause, or
// what it points at, is retracted; "" when neither.
func linkRetracted(from *Record, l Link, to *Record) string {
switch {
case from != nil && from.Status == StatusRetracted:
return from.Key().String() + " is retracted: the link no longer holds"
case from != nil && l.From > 0 && from.Clause(l.From) != nil && from.Clause(l.From).Status == ClauseRetracted:
return fmt.Sprintf("%s §%d is retracted: the link no longer holds", from.Key(), l.From)
case to != nil && to.Status == StatusRetracted:
return "it points at " + to.Key().String() + ", which is retracted"
case to != nil && l.To.Clause > 0 && to.Clause(l.To.Clause) != nil && to.Clause(l.To.Clause).Status == ClauseRetracted:
return fmt.Sprintf("it points at %s §%d, which is retracted", to.Key(), l.To.Clause)
}
return ""
}