From 684e8801c4200f64a659878bae7d0e81a245625d Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 18 Oct 2026 00:09:16 +0200 Subject: [PATCH] Take the ask back on the router when withdrawing a record (issue 1081, review round 1) WithdrawRecord only ended the waiting ask locally; the router kept it open, so the operator still saw Approve/Decline for a withdrawn record and an answer to it was dropped silently. It now also publishes a cancel on the router's subject once the withdraw is saved, as the controller's asker does; a cancel the router cannot be told of is logged and the withdraw still stands. The module's existing "uses": ["operator-channel"] already grants this; no manifest change needed, since the ask and cancel subjects are both under that seat. --- .../mesh-knowledge/cmd/mesh-knowledge/kb.go | 18 +++++++++--- .../cmd/mesh-knowledge/kit_test.go | 29 ++++++++++++------- .../cmd/mesh-knowledge/withdraw_test.go | 3 ++ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index c21d2b52..09ccd688 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -17,6 +17,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" ) // Unproven is who acted, as recorded, where the runtime proves no caller (hq issue 365): the SDK's word. The caller @@ -650,9 +651,10 @@ type Withdrawn struct { // WithdrawRecord withdraws a reserved or proposed record on the caller's decision, with why in one line: the // number stays spent (novox/hq ADR 0290 rule 5), as a new version by the caller. A pending review is cleared, and -// a waiting operator's ask is ended, both as part of that one version. Refused: a record this holds none of, an -// empty why, and a record already accepted (a research effort accepted at any status), superseded or withdrawn — -// only a reserved or a proposed record takes it. +// a waiting operator's ask is ended here and taken back on the router (a cancel on its subject, novox/hq ADR +// 0259), both as part of that one version; a cancel the router cannot be told of is logged, and the withdraw +// still stands. Refused: a record this holds none of, an empty why, and a record already accepted (a research +// effort accepted at any status), superseded or withdrawn — only a reserved or a proposed record takes it. func (k *KB) WithdrawRecord(caller string, key Key, why string) (*Withdrawn, error) { why = strings.TrimSpace(why) if why == "" { @@ -675,7 +677,8 @@ func (k *KB) WithdrawRecord(caller string, key Key, why string) (*Withdrawn, err r := cur.Clone() r.Status = StatusWithdrawn r.Judging = nil - if r.Ask != nil && r.Ask.Said == AskWaiting { + waiting := r.Ask != nil && r.Ask.Said == AskWaiting + if waiting { r.Ask.Said = "ended: the record was withdrawn" } full := fmt.Sprintf("withdrawn by %s: %s", caller, why) @@ -683,6 +686,13 @@ func (k *KB) WithdrawRecord(caller string, key Key, why string) (*Withdrawn, err return nil, fmt.Errorf("%s could not be written (%v); nothing was withdrawn", key, err) } k.emit(EventChanged, r, caller, full, e.Versions+1) + if waiting && k.Asks != nil { + id := cur.Ask.ID + 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: StatusWithdrawn, Says: fmt.Sprintf("%s is withdrawn: %s. Its number stays spent, and is never handed out again.", key, why)}, nil } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go index 07915e59..ca15ee58 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go @@ -320,11 +320,13 @@ func (j *judge) judge(question string, schema map[string]any, model string) (jso return raw, "the agent on test (" + model + ")", nil } -// router is the operator channel, played: the asks published, and its own record of each, which only it writes. +// router is the operator channel, played: the asks published, its own record of each, which only it writes, and +// the cancels taken back on its subject. type router struct { - mu sync.Mutex - asked []asks.Ask - records map[string]routerRecord + mu sync.Mutex + asked []asks.Ask + records map[string]routerRecord + cancelled []string } func newRouter() *router { return &router{records: map[string]routerRecord{}} } @@ -332,14 +334,19 @@ func newRouter() *router { return &router{records: map[string]routerRecord{}} } func (r *router) Publish(subject string, body any, id string) error { r.mu.Lock() defer r.mu.Unlock() - if subject != asks.AskSubject(Module) { - return fmt.Errorf("published on %s", subject) + switch subject { + case asks.AskSubject(Module): + a := body.(asks.Ask) + r.asked = append(r.asked, a) + cp := a + r.records[a.ID] = routerRecord{State: "open", Ask: &cp} + return nil + case asks.CancelSubject(Module): + m := body.(map[string]string) + r.cancelled = append(r.cancelled, m["id"]) + return nil } - a := body.(asks.Ask) - r.asked = append(r.asked, a) - cp := a - r.records[a.ID] = routerRecord{State: "open", Ask: &cp} - return nil + return fmt.Errorf("published on %s", subject) } func (r *router) Record(id string) (*stdio.StateEntry, error) { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go index aff1d3b9..26a59d21 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/withdraw_test.go @@ -62,6 +62,9 @@ func TestAProposedRecordNeedingTheOperatorIsWithdrawnWithItsAskEndedAndItsReview if after.Current.Ask == nil || after.Current.Ask.Said == AskWaiting { t.Fatalf("the waiting ask was not ended: %+v", after.Current.Ask) } + if !contains(w.router.cancelled, before.Current.Ask.ID) { + t.Fatalf("the router was not told to take back the ask %s: cancelled %v", before.Current.Ask.ID, w.router.cancelled) + } if after.Current.Judging != nil { t.Fatalf("a pending review was not cleared: %+v", after.Current.Judging) }