Hold an ask no channel can carry now and offer it when one can; show held and why (issue 926, ADR 0397)

The router refused such an ask with 'no channel', so the asker waited for an Approve nobody saw. It is now held as an open ask with the per-kind reason, judged again when the kinds or a channel's standing change and every minute, and the knowledge base says held and why.
This commit is contained in:
2026-10-16 17:51:32 +02:00
parent 08188663fa
commit c211762813
10 changed files with 453 additions and 48 deletions
@@ -361,6 +361,10 @@ func (k *KB) Get(a GetAsk) (*Got, error) {
}
if r.Ask != nil {
g.Waiting = "the operator's ask: " + r.Ask.Said
if r.Ask.Said == AskWaiting && r.Ask.Held != "" {
// Held by the router, not shown to the operator yet: said so, with why (ADR 0397, issue 926).
g.Waiting = "the operator's ask is held by the router, not yet shown to the operator: " + r.Ask.Held
}
}
if a.Version == 0 {
g.Review = reviewSays(e.Working())
@@ -367,6 +367,24 @@ func (r *router) answer(id, option string, at time.Time) asks.Warrant {
return w
}
// hold marks an ask as the router does when no channel can carry it yet (ADR 0397): state held, with why.
func (r *router) hold(id, why string) {
r.mu.Lock()
defer r.mu.Unlock()
rec := r.records[id]
rec.State, rec.Held = "held", why
r.records[id] = rec
}
// offer marks a held ask open again, as the router does when a channel can carry it.
func (r *router) offer(id string) {
r.mu.Lock()
defer r.mu.Unlock()
rec := r.records[id]
rec.State, rec.Held = "open", ""
r.records[id] = rec
}
// said is the events said.
type said struct {
mu sync.Mutex
@@ -222,7 +222,9 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
// routerRecord is the router's record of one ask, as far as this module reads it.
type routerRecord struct {
State string `json:"state"`
State string `json:"state"`
// Held is why no channel can carry the ask yet, while its state is held (ADR 0397).
Held string `json:"held"`
Ask *asks.Ask `json:"ask"`
Warrant *asks.Warrant `json:"warrant"`
}
@@ -255,7 +257,15 @@ func (k *KB) Reconcile() ([]string, error) {
continue
}
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
if json.Unmarshal(entry.Value, &rec) != nil {
continue
}
// The router holds an ask no channel can carry yet (ADR 0397, issue 926): the record says so, and why,
// until it is offered, rather than saying it waits for an answer nobody was shown.
if held := heldSays(rec); held != w.Ask.Held && (rec.State == "open" || rec.State == "held") {
said = append(said, k.keepHeld(ctx, e, w, held))
}
if rec.State == "open" || rec.State == "held" || rec.Warrant == nil {
continue
}
said = append(said, k.decide(ctx, e, w, *rec.Warrant))
@@ -263,6 +273,33 @@ func (k *KB) Reconcile() ([]string, error) {
return said, nil
}
// heldSays is why the router holds an ask, in words, or "" when it does not.
func heldSays(rec routerRecord) string {
if rec.State != "held" {
return ""
}
if rec.Held == "" {
return "the router holds it; no channel can carry it yet"
}
return rec.Held
}
// keepHeld records, on the record that waits for the operator, that the router holds its ask and why (or that it
// no longer does). The ask stays waiting.
func (k *KB) keepHeld(ctx context.Context, e *Entry, w *Record, held string) string {
w = w.Clone()
w.Ask.Held = held
why := "the operator's ask is offered by the router"
if held != "" {
why = "the operator's ask is held by the router: " + held
}
if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: why, Record: w, Insight: e.Pending != nil && e.Pending.IsInsight()},
e.Pending == nil, false, e.Revision); err != nil {
return fmt.Sprintf("%s: what became of its ask could not be kept (%v)", w.Key(), err)
}
return fmt.Sprintf("%s: %s", w.Key(), why)
}
// endAsk records an ask ended without the operator's choice: the record stays proposed.
func (k *KB) endAsk(ctx context.Context, e *Entry, w *Record, why string) string {
w = w.Clone()
@@ -277,6 +314,8 @@ func (k *KB) endAsk(ctx context.Context, e *Entry, w *Record, why string) string
// decide holds one closed ask's warrant to the record, and accepts the record when it is the operator's Approve.
func (k *KB) decide(ctx context.Context, e *Entry, w *Record, warrant asks.Warrant) string {
w = w.Clone()
w.Ask.Held = "" // an ask that ended is held no longer
if warrant.Outcome != asks.OutcomeChosen {
why := "ended " + string(warrant.Outcome)
if warrant.Words != "" {
@@ -254,6 +254,9 @@ type OperatorAsk struct {
// Said is what came of it: waiting, approved, declined, expired, refused (and why).
Said string `json:"said"`
Warrant string `json:"warrant,omitempty"`
// Held is why the router holds the ask while it waits: no channel can carry it yet (ADR 0397, issue 926).
// Empty once the router offers it, or when it ended.
Held string `json:"held,omitempty"`
}
// Drafting is where a prose record stands in the drafting and confirming of its clauses (to-be 55 §7).
@@ -418,3 +418,54 @@ func TestReserveAsksTheTrackerAndANumberNeverProposedIsWithdrawn(t *testing.T) {
t.Fatal("a withdrawn number took a proposal")
}
}
// An ask the router holds because no channel can carry it yet is shown as held, with the reason, never as waiting
// for an answer nobody was shown (ADR 0397, issue 926); offered, it waits again; answered, it is decided as ever.
func TestAnAskTheRouterHoldsIsShownHeldWithTheReason(t *testing.T) {
w := imported(t, newMemStore())
n := proposed(t, w, "author@g14", true)
w.reviewAll(t)
id := w.router.asked[0].ID
key := Key{KindDecision, n}
waiting := func() string {
g, err := w.k.Get(GetAsk{Kind: key.Kind, Number: key.Number})
if err != nil {
t.Fatal(err)
}
return g.Waiting
}
if got := waiting(); !strings.Contains(got, AskWaiting) {
t.Fatalf("before it is held: %q", got)
}
w.router.hold(id, "Telegram cannot prove who answered")
if said, err := w.k.Reconcile(); err != nil || len(said) != 1 {
t.Fatalf("%v %v", said, err)
}
got := waiting()
if !strings.Contains(got, "held by the router") || !strings.Contains(got, "Telegram cannot prove who answered") || strings.Contains(got, AskWaiting) {
t.Fatalf("held: %q", got)
}
if e := mustGet(t, w.k.Store, key); e.Current.Status != StatusProposed || e.Current.Ask.Said != AskWaiting {
t.Fatalf("a held ask changed the record: %+v", e.Current.Ask)
}
if said, _ := w.k.Reconcile(); len(said) != 0 {
t.Fatalf("said again while nothing changed: %v", said)
}
w.router.offer(id)
if _, err := w.k.Reconcile(); err != nil {
t.Fatal(err)
}
if got := waiting(); got != "the operator's ask: "+AskWaiting {
t.Fatalf("offered: %q", got)
}
// Held, then answered: decided on the warrant, with nothing of the hold left.
w.router.hold(id, "Telegram cannot prove who answered")
_, _ = w.k.Reconcile()
w.router.answer(id, OptionApprove, w.clock.Add(time.Minute))
if _, err := w.k.Reconcile(); err != nil {
t.Fatal(err)
}
if e := mustGet(t, w.k.Store, key); e.Current.Status != StatusAccepted || e.Current.Ask.Held != "" {
t.Fatalf("%s %+v", e.Current.Status, e.Current.Ask)
}
}