diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 79213713..b8955ebf 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -26,7 +26,8 @@ operator, it is accepted only on the operator's Approve, given on a channel that | `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`), but an active research effort takes new versions freely, each reviewed as a proposal. Each version proposed is then reviewed by the knowledge base itself (below) | | `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number | | `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number | -| `withdraw` | `number`, `kind`, `why` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn | +| `withdraw` | `number`, `kind`, `why`, `retraction` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn. With `retraction` true, the retraction waiting on the record is withdrawn instead, and the record holds as it did | +| `retract` | `number`, `kind`, `clauses`, `why` | an accepted or superseded record retracted whole, or the clauses listed, on the operator's Approve only (asked on the router like a record that needs the operator). Until then, and on Decline or expiry, nothing changes. A retracted record or clause no longer holds: `ask` and `holds` leave it out, `get` marks it with by whom, when, why and the warrant, `links` say so both ways, history keeps every version; what it changed or superseded stays so, marked retracted, and the whole-set rules still read its links | | `progress` | `of` (`mesh`, `design `, `milestone /`, `issue `, `decision ` or `ADR `, `research `), `detail` (`minimal` or `detailed`), `bound`, `since` (a window such as `60m`, at most `24h`) | how far it has come (hq issue 1947): each child done ✓, moving ▶, waiting ⏸ or stuck ✗ with when it last moved, worked out from the issue tracker's rows and mesh-delivery's deliveries and groups, never written (ADR 0424 §1). `minimal` is one line per milestone, for a report every five minutes (two or three bus calls); `detailed` the report's tree with each issue's pull requests, the groups holding them and what it depends on. A design's title and state are read through the records module, cached by hq's commit; its milestones and their issues are the issue tracker's milestone records (`issue-tracker.milestones`, issue 1781), asked once per answer; a design with none is refused, naming the seed (`issue-tracker.import` with `milestones`). With `since`, only what changed in the window under `of`, headed "N changes in the last 60 min": milestones moved (from what to what, from `issue-tracker.milestones` `one`), issues opened or moved from what to what (from `issue-tracker.changes`, the tracker's log), noted or linked (from `issue-tracker.get`), pull requests opened, checked, merged, held or closed (from `mesh-delivery.show`'s transitions and check). It keeps no state; its warnings say when the tracker cannot answer `changes` (a move is then said by the status it reached, not from what) and that mesh-delivery keeps no time a merge reached its machines | `ask`, `get`, `holds` and `history` stay within `bound` bytes (4096 unless asked for, at most 65536) and say what they cut diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go b/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go index ae9f7b4d..07856eca 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/answers.go @@ -65,6 +65,8 @@ func clip(s string, n int) string { // held says, for a clause, whether it holds and what changed it, in the words an agent reads. func held(r *Record, c Clause) string { switch { + case r.Status == StatusRetracted || c.Status == ClauseRetracted: + return retractedSays(r, c.N) case c.Source == SourceDrafted: s := "drafted, not confirmed: not the record's word, never in force" if c.Unconfirmed != "" { @@ -142,7 +144,7 @@ func (k *KB) since(ctx context.Context, key Key) []string { continue } from, err := k.Store.Get(ctx, x.From) - if err != nil || from == nil || !from.Current.Accepted() { + if err != nil || from == nil || !from.Current.Accepted() || linkRetracted(from.Current, x.Link, nil) != "" { continue } s := x.From.String() @@ -286,29 +288,32 @@ type GetAsk struct { // Got is the answer to get. type Got struct { - Record string `json:"record"` - Title string `json:"title"` - Status string `json:"status"` - Topic string `json:"topic,omitempty"` - Date string `json:"date,omitempty"` - Deciders []string `json:"deciders,omitempty"` - Version int `json:"version"` - Versions int `json:"versions"` - Clauses []AnswerClause `json:"clauses,omitempty"` - Links []string `json:"links,omitempty"` - LinkedFrom []string `json:"linked-from,omitempty"` - Since []string `json:"changed-since,omitempty"` - Sections []string `json:"sections,omitempty"` - Reasoning []Section `json:"reasoning,omitempty"` - Insights []Insight `json:"insights,omitempty"` - Pending string `json:"pending,omitempty"` - Waiting string `json:"waiting,omitempty"` - Reviews []Review `json:"reviews,omitempty"` - Drafting *Drafting `json:"drafting,omitempty"` - Review string `json:"review,omitempty"` - Source string `json:"source,omitempty"` - Reserved string `json:"reserved,omitempty"` - NeedsOperat bool `json:"needs-operator,omitempty"` + Record string `json:"record"` + Title string `json:"title"` + Status string `json:"status"` + Topic string `json:"topic,omitempty"` + Date string `json:"date,omitempty"` + Deciders []string `json:"deciders,omitempty"` + Version int `json:"version"` + Versions int `json:"versions"` + Clauses []AnswerClause `json:"clauses,omitempty"` + Links []string `json:"links,omitempty"` + LinkedFrom []string `json:"linked-from,omitempty"` + Since []string `json:"changed-since,omitempty"` + Sections []string `json:"sections,omitempty"` + Reasoning []Section `json:"reasoning,omitempty"` + Insights []Insight `json:"insights,omitempty"` + Pending string `json:"pending,omitempty"` + Waiting string `json:"waiting,omitempty"` + // Retracted are the retractions the operator approved; Retracting, one waiting for the operator. + Retracted []RetractionRow `json:"retracted,omitempty"` + Retracting string `json:"retracting,omitempty"` + Reviews []Review `json:"reviews,omitempty"` + Drafting *Drafting `json:"drafting,omitempty"` + Review string `json:"review,omitempty"` + Source string `json:"source,omitempty"` + Reserved string `json:"reserved,omitempty"` + NeedsOperat bool `json:"needs-operator,omitempty"` // Brief is what the operator's ask shows of it (issue 639). Brief *Brief `json:"brief,omitempty"` Cut string `json:"cut,omitempty"` @@ -369,11 +374,16 @@ func (k *KB) Get(a GetAsk) (*Got, error) { if a.Version == 0 { g.Review = reviewSays(e.Working()) } + g.Retracted, g.Retracting = retractionRows(r), retractingSays(r) for _, c := range r.Clauses { g.Clauses = append(g.Clauses, AnswerClause{N: c.N, Text: c.Text, Holds: held(r, c), Source: c.Source}) } for _, l := range r.Links { - g.Links = append(g.Links, l.String()) + s := l.String() + if why := linkRetracted(r, l, k.recordOf(ctx, l.To)); why != "" { + s += " (" + why + ")" + } + g.Links = append(g.Links, s) } if in, err := k.Store.Inbound(ctx, key); err == nil { for _, x := range in { @@ -385,6 +395,9 @@ func (k *KB) Get(a GetAsk) (*Got, error) { if x.Link.To.Clause > 0 { s += fmt.Sprintf(" §%d", x.Link.To.Clause) } + if why := linkRetracted(k.recordOf(ctx, Ref{Kind: x.From.Kind, Number: x.From.Number}), x.Link, r); why != "" { + s += " (" + why + ")" + } g.LinkedFrom = append(g.LinkedFrom, s) } } @@ -516,7 +529,7 @@ func (k *KB) Holds(topic string, key *Key, bound int) (*Holding, error) { return nil, fmt.Errorf("holds takes a topic or a record's number") } out := &Holding{Of: of, Bound: bound, Clauses: []HeldClause{}} - drafted, prose := 0, 0 + drafted, prose, retracted := 0, 0, 0 for _, r := range records { if r.Kind == KindDecision && r.Accepted() && len(r.Clauses) == 0 { prose++ @@ -525,6 +538,9 @@ func (k *KB) Holds(topic string, key *Key, bound int) (*Holding, error) { if c.Source == SourceDrafted && r.Accepted() && c.Status != ClauseSuperseded { drafted++ } + if r.Status == StatusRetracted || c.Status == ClauseRetracted { + retracted++ + } if Served(r, c) { out.Clauses = append(out.Clauses, HeldClause{Record: r.Key().String(), N: c.N, Text: c.Text, Holds: held(r, c)}) } @@ -537,6 +553,9 @@ func (k *KB) Holds(topic string, key *Key, bound int) (*Holding, error) { if prose > 0 { notes = append(notes, fmt.Sprintf("%d accepted decision(s) write their decision as prose and have no clauses yet", prose)) } + if retracted > 0 { + notes = append(notes, fmt.Sprintf("%d retracted clause(s) are not served: they no longer hold (get says by whom and why)", retracted)) + } out.NotYet = strings.Join(notes, "; ") clauses := out.Clauses v, n := fit(len(clauses), bound, func(n int) any { @@ -577,6 +596,8 @@ type LinkRow struct { To string `json:"to"` Source string `json:"source,omitempty"` Unresolved bool `json:"unresolved,omitempty"` + // Retracted says the link no longer holds, or points at what no longer holds, and why. + Retracted string `json:"retracted,omitempty"` } // Links is a record's links, both ways. @@ -597,14 +618,16 @@ func (k *KB) Links(key Key) (*Linked, error) { return k.String() } for _, l := range e.Current.Links { - out.Out = append(out.Out, LinkRow{Kind: l.Kind, From: from(key, l.From), To: l.To.String(), Source: l.Source, Unresolved: l.Unresolved}) + out.Out = append(out.Out, LinkRow{Kind: l.Kind, From: from(key, l.From), To: l.To.String(), Source: l.Source, Unresolved: l.Unresolved, + Retracted: linkRetracted(e.Current, l, k.recordOf(ctx, l.To))}) } in, err := k.Store.Inbound(ctx, key) if err != nil { return nil, err } for _, x := range in { - out.In = append(out.In, LinkRow{Kind: x.Link.Kind, From: from(x.From, x.Link.From), To: x.Link.To.String(), Source: x.Link.Source}) + out.In = append(out.In, LinkRow{Kind: x.Link.Kind, From: from(x.From, x.Link.From), To: x.Link.To.String(), Source: x.Link.Source, + Retracted: linkRetracted(k.recordOf(ctx, Ref{Kind: x.From.Kind, Number: x.From.Number}), x.Link, e.Current)}) } return out, nil } @@ -786,3 +809,16 @@ func reviewSays(r *Record) string { // When it is asked again is said, so a pause is not taken for a review lost (novox/hq issue 497). return fmt.Sprintf("not reviewed: %s (%s; no judgement that could not be had counts as an approval", j.Reason, j.owedSays()) } + +// recordOf is the served version of the record a ref names, or nil when it names none or none is held. +func (k *KB) recordOf(ctx context.Context, ref Ref) *Record { + key, ok := ref.Record() + if !ok { + return nil + } + e, err := k.Store.Get(ctx, key) + if err != nil || e == nil { + return nil + } + return e.Current +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/bus_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/bus_test.go index e0b3249c..06ba0ad6 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/bus_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/bus_test.go @@ -369,6 +369,11 @@ func TestTheKnowledgeBaseOverTheBus(t *testing.T) { if g.Status != StatusProposed || len(g.Clauses) != 2 || g.Clauses[0].Holds != "proposed: not in force" { t.Fatalf("get: %+v", g) } + // retract is served, its arguments read as an agent sends them: a proposed record is withdrawn, not retracted. + if _, err := b.call(Seat+".retract", map[string]any{"number": 300, "clauses": []int{1}, "why": "never verified"}); err == nil || + !strings.Contains(err.Error(), "withdraw it instead") { + t.Fatalf("retract of a proposed record over the bus: %v", err) + } var h Historied _ = json.Unmarshal(must(b.call(Seat+".history", map[string]any{"number": 300, "from": 1, "to": 2})), &h) if len(h.Versions) != 2 || len(h.Diff) == 0 { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index 25bb30ea..0773db51 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -338,6 +338,9 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { if why == "" { why = "revised" } + case held.Current.Accepted() && held.Current.Retracting != nil: + return nil, fmt.Errorf("%s has a retraction waiting for the operator: it takes no new version until that is answered or "+ + "withdrawn. Nothing was proposed", key) 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 "+ @@ -448,12 +451,13 @@ func (k *KB) accept(ctx context.Context, held *Entry, w *Record, by, why, warran r.Insights = append(r.Insights, in) } r.Status = held.Current.Status + r.Retractions = held.Current.Retractions 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) { + if old := held.Current.Clause(c.N); old != nil && (old.Status == ClauseChanged || old.Status == ClauseSuperseded || old.Status == ClauseRetracted) { r.Clauses[i].Status, r.Clauses[i].ChangedBy = old.Status, old.ChangedBy } } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/list.go b/modules/mesh-knowledge/cmd/mesh-knowledge/list.go index c1df234c..473487e7 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/list.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/list.go @@ -28,7 +28,7 @@ const MostListResult = 500 // AllStatuses are every status a record of either kind may hold. var AllStatuses = []string{StatusReserved, StatusProposed, StatusAccepted, StatusSuperseded, StatusWithdrawn, - StatusActive, StatusGraduated, StatusAbandoned} + StatusActive, StatusGraduated, StatusAbandoned, StatusRetracted} // ListAsk narrows a list. type ListAsk struct { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index bf04e2de..82a5b138 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -37,7 +37,7 @@ import ( const Seat = "knowledge-base" // Verbs are the seat's verbs, as the manifest declares and claims them (ADR 0290 rule 6). -var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "progress"} +var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "retract", "progress"} // HealthTool is the module's own tool the node-engine asks how it is. const HealthTool = "knowledge_health" @@ -1105,9 +1105,10 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { "accepted (a research effort accepted at any status), superseded or withdrawn — only a reserved or a proposed record " + "takes it.", Input: schema([]string{"number", "why"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), - "why": text("one line: why the record is withdrawn")}), + "why": text("one line: why the record (or, with retraction, its retraction) is withdrawn"), + "retraction": flag("true: withdraw the retraction waiting for the operator on this record, not the record; the record holds as it did")}), Run: func(m map[string]any) (any, error) { - a, err := readArgs("withdraw", m, "kind", "number", "why") + a, err := readArgs("withdraw", m, "kind", "number", "why", "retraction") if err != nil { return nil, err } @@ -1119,8 +1120,47 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { if err != nil { return nil, err } + retraction, err := a.flag("retraction") + if err != nil { + return nil, err + } + if retraction { + return k.WithdrawRetraction(caller(m), key, why) + } return k.WithdrawRecord(caller(m), key, why) }}, + {Name: Seat + ".retract", + Description: "Retract an accepted (or superseded) record whole, or the clauses listed, with why in one line: the operator is " + + "asked on a channel that proves who answers (Approve or Decline), and it takes effect only on the operator's Approve. " + + "Until then, and on Decline or expiry, nothing of the record changes. A retracted record or clause no longer holds: ask " + + "and holds leave it out, get marks it retracted with by whom, when, why and the operator's warrant, links say so both " + + "ways; nothing is deleted, history keeps every version. What it changed or superseded stays so, marked retracted. get " + + "shows a retraction waiting; withdraw with retraction true takes it back. Refused: an empty why, a reserved or proposed " + + "record (withdraw it), one retracted already, a clause it has none of or retracted already, and a record with a " + + "retraction or a new version already waiting.", + Input: schema([]string{"number", "why"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), + "clauses": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 1}, + "description": "the clauses to retract, by number; none: the record whole"}, + "why": text("one line: why it no longer holds")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("retract", m, "kind", "number", "clauses", "why") + if err != nil { + return nil, err + } + key, err := a.key() + if err != nil { + return nil, err + } + why, err := a.str("why") + if err != nil { + return nil, err + } + var clauses []int + if err := decode(m["clauses"], &clauses, "clauses is a list of clause numbers, such as [2, 3]"); err != nil { + return nil, err + } + return k.Retract(caller(m), key, clauses, why) + }}, {Name: Seat + ".progress", Description: "How far something has come, in one call: of mesh (every design in progress), design (its " + "milestones), milestone / (its issues, such as milestone 47/R6), issue (its pull requests " + diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index ff7e0375..76f77737 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -64,7 +64,7 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) { } // ADR 0290 rule 6's verbs and those added after it; progress says a design's state, which ADR 0424 §1 makes the // knowledge base's to work out (novox/hq issue 1947). - if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "progress"}) { + if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "retract", "progress"}) { t.Fatalf("the verbs are not ADR 0290 rule 6's and those added after it: %v", Verbs) } if !reflect.DeepEqual(m.Emits, Events) { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go b/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go index 6f136a3c..1fa0b8d0 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/operator.go @@ -245,6 +245,12 @@ func (k *KB) Reconcile() ([]string, error) { } var said []string for _, e := range all { + if x := e.Current.Retracting; x != nil && x.Ask != nil && x.Ask.Said == AskWaiting { + if s := k.reconcileRetraction(ctx, e); s != "" { + said = append(said, s) + } + continue + } w := e.Working() if w.Status != StatusProposed || w.Ask == nil || w.Ask.Said != AskWaiting { continue @@ -366,6 +372,10 @@ func (k *KB) HeardEnd(w asks.Warrant) { return } for _, e := range all { + if x := e.Current.Retracting; x != nil && x.Ask != nil && x.Ask.ID == w.Ask { + k.logf("[mesh-knowledge] %s", k.decideRetraction(ctx, e, w)) + continue + } r := e.Working() if r.Ask != nil && r.Ask.ID == w.Ask && r.Ask.Said == AskWaiting { k.logf("[mesh-knowledge] %s", k.decide(ctx, e, r, w)) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/pgstore.go b/modules/mesh-knowledge/cmd/mesh-knowledge/pgstore.go index 6f141c8f..794180e6 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/pgstore.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/pgstore.go @@ -473,10 +473,10 @@ func (s *PgStore) Search(ctx context.Context, question string, limit int) ([]Hit select kind, number, clause, rank from ( select c.kind, c.number, c.n as clause, ts_rank_cd(c.search, q.q) as rank from clause c join record r on r.kind = c.kind and r.number = c.number, q - where c.search @@ q.q and r.status not in ('reserved', 'withdrawn') + where c.search @@ q.q and r.status not in ('reserved', 'withdrawn', 'retracted') and c.status <> 'retracted' union all select r.kind, r.number, 0, ts_rank_cd(r.search, q.q) - from record r, q where r.search @@ q.q and r.status not in ('reserved', 'withdrawn') + from record r, q where r.search @@ q.q and r.status not in ('reserved', 'withdrawn', 'retracted') ) hits order by rank desc, number desc, clause limit $2`, strings.Join(words, " "), limit) if err != nil { return nil, err diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/progress.go b/modules/mesh-knowledge/cmd/mesh-knowledge/progress.go index ff2ff2c9..ae26f2c3 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/progress.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/progress.go @@ -557,6 +557,12 @@ func (k *KB) progressRecord(p *Progress, kind string, n int) error { p.Items = []Item{top} var b strings.Builder fmt.Fprintf(&b, "%s · %s — %s\n", name, g.Title, g.Status) + for _, x := range g.Retracted { + fmt.Fprintf(&b, "├── retracted %s by %s at %s: %s; not in force\n", x.What, x.By, x.At, x.Why) + } + if g.Retracting != "" { + b.WriteString("├── " + g.Retracting + "\n") + } if len(designs) == 0 { b.WriteString("├── no design names it\n") } else { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/progress_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/progress_test.go index 90b171af..1fdb0dc3 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/progress_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/progress_test.go @@ -187,15 +187,6 @@ func TestADesignsMilestonesAreTheTrackersRecords(t *testing.T) { } } -func containsInt(xs []int, n int) bool { - for _, x := range xs { - if x == n { - return true - } - } - return false -} - // 1947/AC6: the designs are read once per commit of hq, and again when it moves; the mesh level names those with // milestones one by one and counts the rest. func TestDesignsAreReadAgainOnlyWhenHqMoves(t *testing.T) { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/record.go b/modules/mesh-knowledge/cmd/mesh-knowledge/record.go index cf4f3d69..e21e2ce1 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/record.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/record.go @@ -37,6 +37,9 @@ const ( StatusActive = "active" StatusGraduated = "graduated" StatusAbandoned = "abandoned" + // StatusRetracted is a record taken back whole on the operator's Approve (retract.go): it no longer holds, and + // its versions stay. + StatusRetracted = "retracted" ) // ResearchStatuses are the statuses an accepted research effort may have. @@ -48,6 +51,8 @@ const ( ClauseInForce = "in force" ClauseChanged = "changed" ClauseSuperseded = "superseded" + // ClauseRetracted is a clause taken back on the operator's Approve (retract.go): it no longer holds. + ClauseRetracted = "retracted" ) // A clause's source (to-be 55 §3). @@ -329,6 +334,51 @@ type Record struct { Judging *Judging `json:"judging,omitempty"` Insights []Insight `json:"insights,omitempty"` Pending *PendingState `json:"pending,omitempty"` + // Retracting is a retraction asked of the operator and not yet answered (retract.go); Retractions, every one + // the operator approved, oldest first. + Retracting *Retraction `json:"retracting,omitempty"` + Retractions []Retraction `json:"retractions,omitempty"` +} + +// Retraction is an accepted record taken back, whole (no Clauses) or the clauses listed, by a caller with why in +// one line, in force only on the operator's Approve (retract.go). Title and Leads are what the operator was shown, +// kept so the ask is composed again the same way to hold the warrant to it. +type Retraction struct { + Clauses []int `json:"clauses,omitempty"` + Why string `json:"why"` + By string `json:"by"` + At time.Time `json:"at"` + Title string `json:"title,omitempty"` + Leads []string `json:"leads,omitempty"` + Ask *OperatorAsk `json:"ask,omitempty"` + // Before is the record's status before it was retracted whole; At applied, when the operator's Approve took effect. + Before string `json:"before,omitempty"` + Applied time.Time `json:"applied,omitempty"` +} + +// Whole says the retraction takes back the record whole. +func (x Retraction) Whole() bool { return len(x.Clauses) == 0 } + +// Covers says the retraction takes back clause n (any clause, for one of the record whole). +func (x Retraction) Covers(n int) bool { return x.Whole() || containsInt(x.Clauses, n) } + +func containsInt(list []int, n int) bool { + for _, v := range list { + if v == n { + return true + } + } + return false +} + +// RetractedBy is the retraction the operator approved that takes back clause n (0: the record whole), or nil. +func (r *Record) RetractedBy(n int) *Retraction { + for i := len(r.Retractions) - 1; i >= 0; i-- { + if x := r.Retractions[i]; (n == 0 && x.Whole()) || (n > 0 && x.Covers(n)) { + return &r.Retractions[i] + } + } + return nil } // Insight is a progressive insight: a correction of fact, dated, saying what stood before. @@ -492,6 +542,12 @@ func Render(r *Record) string { for _, in := range r.Insights { fmt.Fprintf(&b, "insight %s: %s\n", in.Date, in.Before) } + for _, x := range r.Retractions { + fmt.Fprintf(&b, "retracted %s: %s\n", x.What(), x.Says()) + } + if x := r.Retracting; x != nil { + fmt.Fprintf(&b, "retraction asked %s: %s\n", x.What(), x.Says()) + } if len(r.Clauses) > 0 { b.WriteString("\n## Clauses\n\n") for _, c := range r.Clauses { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/retract.go b/modules/mesh-knowledge/cmd/mesh-knowledge/retract.go new file mode 100644 index 00000000..6a1494ce --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/retract.go @@ -0,0 +1,465 @@ +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 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 "" +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go index fbacf6c0..1376c0fc 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go @@ -475,7 +475,9 @@ func JudgeSet(records []*Record) []Finding { l Link }{} for _, r := range records { - if !r.Accepted() && r.Status != StatusSuperseded { + // A retracted record's links stay read here: what it cited stays cited, what it changed or superseded stays + // so, and a retraction alone never makes a rule fail. + if !r.Accepted() && r.Status != StatusSuperseded && r.Status != StatusRetracted { continue } for _, l := range r.Links { @@ -525,14 +527,14 @@ func JudgeSet(records []*Record) []Finding { } switch l.Kind { case LinkSupersedes: - if target.Status != StatusSuperseded { + if target.Status != StatusSuperseded && target.Status != StatusRetracted { add("supersession", fmt.Sprintf("%s supersedes %s, which is %s", k, to, target.Status), k, to) } case LinkChanges: if l.To.Clause == 0 { continue } - if c := target.Clause(l.To.Clause); c != nil && c.Status != ClauseChanged && c.Status != ClauseSuperseded { + if c := target.Clause(l.To.Clause); c != nil && c.Status != ClauseChanged && c.Status != ClauseSuperseded && c.Status != ClauseRetracted { add("partial", fmt.Sprintf("%s changes %s §%d, which is %s", k, to, l.To.Clause, c.Status), k, to) } case LinkExtends, LinkRestsOn: @@ -554,7 +556,7 @@ func JudgeSet(records []*Record) []Finding { } by := false for _, in := range inbound[k] { - by = by || (in.l.Kind == LinkChanges && in.l.To.Clause == c.N && in.from.Accepted()) + by = by || (in.l.Kind == LinkChanges && in.l.To.Clause == c.N && (in.from.Accepted() || in.from.Status == StatusRetracted)) } if !by { add("partial", fmt.Sprintf("%s §%d is changed and no accepted record changes it", k, c.N), k) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/store.go b/modules/mesh-knowledge/cmd/mesh-knowledge/store.go index 9dcc30e1..f3b83a2a 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/store.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/store.go @@ -210,10 +210,13 @@ func (s *memStore) Search(_ context.Context, question string, limit int) ([]Hit, } for k, e := range s.entries { r := e.versions[e.current-1].Record - if r.Status == StatusReserved || r.Status == StatusWithdrawn { + if r.Status == StatusReserved || r.Status == StatusWithdrawn || r.Status == StatusRetracted { continue } for _, c := range r.Clauses { + if c.Status == ClauseRetracted { + continue + } if n := score(c.Text); n > 0 { hits = append(hits, Hit{Key: k, Clause: c.N, Rank: n}) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go index 26a59d21..750d12c3 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go @@ -7,6 +7,8 @@ import ( "context" "strings" "testing" + + "git.novox.be/novox/mesh-sdk/go/asks" ) func TestAReservedRecordIsWithdrawnAtOnce(t *testing.T) { @@ -121,3 +123,185 @@ func TestWithdrawNeedsWhyAndRefusesARecordItHoldsNoneOf(t *testing.T) { } }) } + +// Retract (the operator, 2026-10-31: "I'm more a fan of removing incorrect decisions than creating new records"): an +// accepted record, or listed clauses of it, stops holding on the operator's Approve and on nothing else; its versions +// stay, every read says it is retracted, and the whole-set rules do not fail for it. +func TestARetractionTakesEffectOnlyOnTheOperatorsApprove(t *testing.T) { + const why = "never verified by the operator" + type tc struct { + name string + clauses []int + answer string // approve, decline, or withdraw (the pending retraction withdrawn, then approved too late) + } + for _, c := range []tc{ + {"the record whole, approved", nil, OptionApprove}, + {"one clause, approved", []int{1}, OptionApprove}, + {"the record whole, declined", nil, OptionDecline}, + {"one clause, withdrawn before the answer", []int{1}, "withdraw"}, + } { + t.Run(c.name, func(t *testing.T) { + eachStore(t, func(t *testing.T, s Store) { + ctx := context.Background() + w := imported(t, s) + n := w.reserved(t, "author@test") + key, changed := Key{KindDecision, n}, Key{KindDecision, 276} + w.accepted(t, "author@test", valid(n)) + w.k.judgeSet(ctx) + findings, _ := w.k.Store.Findings(ctx) + before := mustGet(t, w.k.Store, key) + asked := len(w.router.asked) + + out, err := w.k.Retract("lead@shanks", key, c.clauses, why) + if err != nil { + t.Fatal(err) + } + if out.Status != RetractAsked || out.Record != key.String() || out.Ask == "" { + t.Fatalf("%+v", out) + } + // Asked once, at a glance, under the router's bounds; nothing changed yet. + if len(w.router.asked) != asked+1 { + t.Fatalf("asked %d times", len(w.router.asked)-asked) + } + a := w.router.asked[len(w.router.asked)-1] + if err := a.Check(w.clock); err != nil || asks.Glance(a.Explanation) != nil || len([]rune(a.Headline)) > 60 || + !strings.Contains(a.Headline, key.String()) || !strings.Contains(a.Explanation+a.Details, why) { + t.Fatalf("the ask %+v: %v", a, err) + } + g, err := w.k.Get(GetAsk{Kind: KindDecision, Number: n}) + if err != nil || g.Status != StatusAccepted || !strings.Contains(g.Retracting, "lead@shanks") || len(g.Retracted) != 0 { + t.Fatalf("while asked: %+v %v", g, err) + } + if h, _ := w.k.Holds("", &key, 0); len(h.Clauses) != 2 { + t.Fatalf("while asked, holds %+v", h) + } + if _, err := w.k.Retract("lead@shanks", key, nil, "again"); err == nil { + t.Fatal("a second retraction was asked while one waits") + } + if _, err := w.k.Propose("author@test", ProposeAsk{Kind: KindDecision, Number: n, Title: "x", Insight: "y"}); err == nil || + !strings.Contains(err.Error(), "retraction") { + t.Fatalf("an insight over a waiting retraction: %v", err) + } + + switch c.answer { + case "withdraw": + if _, err := w.k.WithdrawRetraction("lead@shanks", key, "asked by mistake"); err != nil { + t.Fatal(err) + } + if !contains(w.router.cancelled, out.Ask) { + t.Fatalf("the router was not told: %v", w.router.cancelled) + } + w.router.answer(out.Ask, OptionApprove, w.clock) + default: + w.router.answer(out.Ask, c.answer, w.clock) + } + if _, err := w.k.Reconcile(); err != nil { + t.Fatal(err) + } + e := mustGet(t, w.k.Store, key) + vs, _ := w.k.Store.Versions(ctx, key) + if len(vs) <= before.Versions || e.Current.Retracting != nil { + t.Fatalf("versions %d (were %d), still retracting %+v", len(vs), before.Versions, e.Current.Retracting) + } + g, _ = w.k.Get(GetAsk{Kind: KindDecision, Number: n}) + h, _ := w.k.Holds("", &key, 0) + l, _ := w.k.Links(key) + lc, _ := w.k.Links(changed) + target := mustGet(t, w.k.Store, changed).Current.Clause(1) + if c.answer != OptionApprove { + // Nothing changed: it holds as it did. + if e.Current.Status != StatusAccepted || len(h.Clauses) != 2 || len(g.Retracted) != 0 || g.Retracting != "" || + strings.Contains(strings.Join(target.ChangedBy, ","), "retracted") { + t.Fatalf("a retraction not approved changed it: %+v, holds %d", g, len(h.Clauses)) + } + return + } + // Approved: it no longer holds, says by whom, when, why and on what, and history keeps it. + if len(g.Retracted) != 1 || g.Retracted[0].By != "lead@shanks" || g.Retracted[0].Why != why || + !strings.Contains(g.Retracted[0].Approval, "telegram") || g.Retracting != "" { + t.Fatalf("get: %+v", g.Retracted) + } + if !strings.Contains(vs[len(vs)-1].Why, "retracted") || vs[len(vs)-1].By != "the operator" { + t.Fatalf("the version: %+v", vs[len(vs)-1]) + } + hit := false + for _, ev := range w.said.of(EventChanged) { + hit = hit || (ev["record"] == key.String() && strings.Contains(ev["what"].(string), "retracted")) + } + if !hit { + t.Fatalf("no changed event says the retraction: %v", w.said.of(EventChanged)) + } + // What it changed stays changed; its mark says the change is retracted; the link says so both ways. + if target.Status != ClauseChanged || !strings.Contains(strings.Join(target.ChangedBy, ","), "retracted") { + t.Fatalf("0276 §1: %+v", target) + } + marked := false + for _, r := range lc.In { + marked = marked || (strings.HasPrefix(r.From, key.String()) && r.Retracted != "") + } + for _, r := range l.Out { + marked = marked && (r.Retracted != "" || (c.clauses != nil && r.From == key.String())) + } + if !marked { + t.Fatalf("links: %+v / %+v", l.Out, lc.In) + } + asked2, _ := w.k.Ask("merge window ninety seconds quiet", MostBound) + if c.clauses == nil { + if e.Current.Status != StatusRetracted || g.Status != StatusRetracted || len(h.Clauses) != 0 || + !strings.HasPrefix(g.Clauses[0].Holds, "retracted") { + t.Fatalf("whole: %s, holds %+v, get %+v", e.Current.Status, h.Clauses, g.Clauses) + } + for _, r := range asked2.Records { + if r.Record == key.String() { + t.Fatalf("ask serves a retracted record: %+v", r) + } + } + } else { + if e.Current.Status != StatusAccepted || len(h.Clauses) != 1 || h.Clauses[0].N != 2 || + !strings.HasPrefix(g.Clauses[0].Holds, "retracted") || g.Clauses[1].Holds != "in force" { + t.Fatalf("clause: %s, holds %+v, get %+v", e.Current.Status, h.Clauses, g.Clauses) + } + for _, r := range asked2.Records { + for _, ac := range r.Clauses { + if r.Record == key.String() && ac.N == 1 { + t.Fatalf("ask serves a retracted clause: %+v", r) + } + } + } + } + // The whole-set rules say no more than they did. + w.k.judgeSet(ctx) + if after, _ := w.k.Store.Findings(ctx); len(after) != len(findings) { + t.Fatalf("findings were %v, are %v", findings, after) + } + if _, err := w.k.Retract("lead@shanks", key, c.clauses, why); err == nil { + t.Fatal("a retracted record or clause was retracted again") + } + }) + }) + } + // Refused, with nothing asked: no why, a record not accepted, a clause it has none of. + eachStore(t, func(t *testing.T, s Store) { + w := imported(t, s) + n := w.reserved(t, "author@test") + key := Key{KindDecision, n} + if _, err := w.k.Retract("lead@shanks", key, nil, why); err == nil || !strings.Contains(err.Error(), "withdraw") { + t.Fatalf("a reserved record: %v", err) + } + w.accepted(t, "author@test", valid(n)) + for _, bad := range []struct { + clauses []int + why string + }{{nil, " "}, {[]int{9}, why}, {[]int{1, 1}, why}} { + if _, err := w.k.Retract("lead@shanks", key, bad.clauses, bad.why); err == nil { + t.Fatalf("retract %v %q was asked", bad.clauses, bad.why) + } + } + if len(w.router.asked) != 0 { + t.Fatalf("asked %d", len(w.router.asked)) + } + if _, err := w.k.WithdrawRetraction("lead@shanks", key, "nothing waits"); err == nil { + t.Fatal("a retraction was withdrawn where none waits") + } + }) +} diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index 335a1a19..f4ed646f 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -33,6 +33,7 @@ "import", "return", "withdraw", + "retract", "progress" ] } @@ -54,6 +55,7 @@ "import", "return", "withdraw", + "retract", "progress" ] }