From aafda6e870290b4f67c95e2e2a73e69cdb7db294 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 13:04:17 +0200 Subject: [PATCH] claude-code: leave the judging of a proposal's channel to the router, and hear its refusal (hq ADR 0274) A module that only asks is issued no channel kinds, so the empty list was read as 'no channel proves its sender' and every proposal was refused on the live mesh with Telegram linked. The router judges; Propose waits for its word, and a refusal heard on the decided subject discards the proposal on every node. --- .../cmd/claude-code/proposal_tools.go | 14 ++- .../claude-code/cmd/claude-code/proposals.go | 107 +++++++++++++++++- .../cmd/claude-code/proposals_test.go | 75 ++++++++++++ 3 files changed, 189 insertions(+), 7 deletions(-) diff --git a/modules/claude-code/cmd/claude-code/proposal_tools.go b/modules/claude-code/cmd/claude-code/proposal_tools.go index 495b17d2..47967c79 100644 --- a/modules/claude-code/cmd/claude-code/proposal_tools.go +++ b/modules/claude-code/cmd/claude-code/proposal_tools.go @@ -3,6 +3,7 @@ package main // The tools and the watch of the shared instructions proposed for the operator's approval (novox/hq ADR 0274). import ( + "encoding/json" "errors" "strings" "time" @@ -56,7 +57,18 @@ func (d *Proposing) watch(p Paths) { } } go persist("hearing the operator's answers", func() error { - return stdio.Subscribe(asks.Seat+".decided."+Module, func(stdio.Envelope) error { poke(); return nil }) + return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error { + var w asks.Warrant + if json.Unmarshal(e.Body, &w) == nil { + if line, err := HeardEnd(p, d.Store, w, d.Now()); err != nil { + say("taking the router's word on %s: %v", w.Ask, err) + } else if line != "" { + say("%s", line) + } + } + poke() + return nil + }) }, func(n int) { say("hearing the operator's answers%s", refusals(n)) }) go persist("watching the proposals", func() error { return stdio.State(ProposalsState).Watch("", func(stdio.StateChange) error { poke(); return nil }) diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index e9df3083..21df7fff 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -620,13 +620,26 @@ type ProposeInput struct { const AtTheTerminalInstead = "the operator may set it at the controller's terminal instead: mesh-cli settings set claude-code " + "'{\"instructions\": {\"\": [\"line\", …]}}' [--node ] (novox/hq issue 340)" -// approverReady says whether a channel that proves who answered can carry an approval: a kind of the channel -// bench promising a choice, an exact rendering and a verified sender. The router judges again, live; this is -// only so a proposal no channel can carry is refused at once, in words. +// approverReady says why no channel that proves who answered can carry an approval, when this module can tell: +// the channel bench's kinds, where its membership names them, and none promising a choice, an exact rendering and +// a verified sender. **The router is the judge** (novox/hq ADR 0259 §4): it refuses an ask none of its ready +// channels can carry, on the decided subject, and Propose waits for that word. A module that only asks is issued +// no kinds (they are issued for a kinded bench a module uses or watches, and this module uses the operator +// channel alone), so an empty list says nothing — read as "none" it refused every proposal on the live mesh on +// 2026-10-10, with Telegram linked and proven. func approverReady(bus AskBus) string { kinds, err := bus.Kinds() - if err != nil { - return "the channels could not be read (" + err.Error() + ")" + if err != nil || len(kinds) == 0 { + return "" + } + channels := 0 + for _, k := range kinds { + if k.Seat == "channel" { + channels++ + } + } + if channels == 0 { + return "" } for _, k := range kinds { if k.Seat == "channel" && k.Promises("choice") && k.Promises("exact-render") && k.Promises("verified-sender") { @@ -732,7 +745,24 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time "Propose it again, or %s", err, AtTheTerminalInstead) } say("proposal %s (%s %s %s, fingerprint %s) asked of the operator", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint()) + // The router's word, before answering: it refuses at once an ask no channel can carry, and records one it took. + taken := "the router has not said yet whether it took the ask; claude_code_proposals shows it" + for deadline := time.Now().Add(RouterAnswersWithin); ; { + if w, ended := endedAsk(prop.ID); ended { + return nil, fmt.Errorf("the router did not ask the operator: the ask %s (%s). Nothing is active anywhere; %s", + w.Outcome, w.Words, AtTheTerminalInstead) + } + if entry, err := bus.Record(prop.ID); err == nil && entry != nil { + taken = "the router took the ask and shows it on the channels that can carry it" + break + } + if time.Now().After(deadline) { + break + } + time.Sleep(RouterAnswersEvery) + } return map[string]any{ + "router": taken, "proposed": prop.ID, "action": prop.Action, "reaches": prop.where(), "fingerprint": prop.Fingerprint(), "asked": "the operator is asked on a channel that proves who answers: \"" + ask.Headline + "\"", "expires": ask.Expires.Format(time.RFC3339), @@ -773,6 +803,61 @@ func readProposal(store ProposalStore, key string) (Proposal, bool) { return prop, true } +// How long Propose waits for the router's word on a new ask, and how often it looks. +var ( + RouterAnswersWithin = 8 * time.Second + RouterAnswersEvery = 250 * time.Millisecond +) + +// ended is the asks the router ended without a choice, as heard on the decided subject — which only the router may +// publish, for this module alone. A refused ask leaves no record of the router's, so this is where its refusal is +// read. +var ( + endedMu sync.Mutex + ended = map[string]asks.Warrant{} +) + +func endedAsk(id string) (asks.Warrant, bool) { + endedMu.Lock() + defer endedMu.Unlock() + w, ok := ended[id] + return w, ok +} + +// HeardEnd takes the router's word that one of this module's asks ended without a choice: refused, expired, +// cancelled or replaced. The proposal is discarded here at once. Nothing is applied on such a word, so it needs no +// proof beyond its subject; a choice is only ever taken from the router's record (Reconcile). +func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (string, error) { + if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !asks.UsableID(w.Ask) { + return "", nil + } + endedMu.Lock() + ended[w.Ask] = w + endedMu.Unlock() + reconcileMu.Lock() + defer reconcileMu.Unlock() + if _, known := readProposal(store, w.Ask); !known { + return "", nil + } + approved, err := ReadApproved(p) + if err != nil { + return "", err + } + if _, done := approved.Decided[w.Ask]; done { + return "", nil + } + d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome)} + if w.Words != "" { + d.Outcome += " (" + w.Words + ")" + } + approved.Decided[w.Ask] = d + delete(approved.Refused, w.Ask) + if err := writeApproved(p, approved); err != nil { + return "", err + } + return "proposal " + w.Ask + ": " + d.Outcome, nil +} + // ---- deciding, on every node ------------------------------------------------------------------------ // routerRecord is the router's record of one ask, as far as this module reads it. @@ -804,6 +889,7 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti w asks.Warrant } var ready []closed + discarded := false for _, k := range keys { prop, ok := readProposal(store, k) if !ok { @@ -813,6 +899,15 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti continue } entry, err := bus.Record(prop.ID) + if err == nil && entry == nil && now.After(prop.At.Add(ProposalLasts+time.Hour)) { + // Ended, and the router keeps no record of it: refused before it was taken, or kept past its time. Either + // way no answer can be proven, so it is discarded and nothing is applied. + approved.Decided[prop.ID] = ProposalDecision{At: now.UTC(), + Outcome: "discarded: the router keeps no record of its ask (refused before it was taken, or forgotten)"} + delete(approved.Refused, prop.ID) + discarded = true + continue + } if err != nil || entry == nil { continue // open, not yet recorded, or not readable now: asked again on the next look } @@ -823,7 +918,7 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti ready = append(ready, closed{prop, *rec.Warrant}) } sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) }) - changed, wrote := false, false + changed, wrote := false, discarded var said []string for _, c := range ready { d, applied, line, final := decide(p, &approved, c.prop, c.w, now) diff --git a/modules/claude-code/cmd/claude-code/proposals_test.go b/modules/claude-code/cmd/claude-code/proposals_test.go index 5b958d53..6503c713 100644 --- a/modules/claude-code/cmd/claude-code/proposals_test.go +++ b/modules/claude-code/cmd/claude-code/proposals_test.go @@ -642,3 +642,78 @@ func TestANodeSectionIsNotReplacedFromOtherNodes(t *testing.T) { t.Error("a removal for other nodes was taken for the section on ace") } } + +// A module that only asks is issued no channel kinds (they come with a kinded bench a module uses or watches): an +// empty list is no refusal, and the router, which judges, is asked (the live failure of 2026-10-10). +func TestAnAskerIssuedNoKindsLeavesTheJudgingToTheRouter(t *testing.T) { + p, _ := node(t, "novox") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + r.kinds = nil + out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, r, t0) + if err != nil { + t.Fatalf("refused with no kinds issued: %v", err) + } + if !strings.Contains(out["router"].(string), "took the ask") { + t.Errorf("router: %v", out["router"]) + } +} + +// The router's refusal reaches the proposer at once, and discards the proposal on every node that hears it. +func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) { + p, _ := node(t, "novox") + store := &memProposals{m: map[string]json.RawMessage{}} + r := newRouter() + r.kinds = nil + refusing := &refusingRouter{router: r, p: p, store: store} + RouterAnswersWithin, RouterAnswersEvery = time.Second, 10*time.Millisecond + _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, refusing, t0) + if err == nil || !strings.Contains(err.Error(), "no channel can carry any of its answers now") || + !strings.Contains(err.Error(), "mesh-cli settings set claude-code") { + t.Fatalf("refusal: %v", err) + } + if got := outcomeHere(t, p, refusing.id); !strings.HasPrefix(got, "discarded: the ask refused") { + t.Errorf("decided %q", got) + } + if changed, _, _ := Reconcile(p, store, refusing, nil, t0.Add(time.Hour)); changed { + t.Error("a refused proposal changed what renders") + } +} + +// A proposal whose ask ended and that the router keeps no record of is discarded, never left waiting. +func TestAnAskTheRouterKeepsNoRecordOfIsDiscardedAfterItsTime(t *testing.T) { + p, _ := node(t, "novox") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + id := proposeHandover(t, p, store, r) + delete(r.records, id) + if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if got := outcomeHere(t, p, id); got != "" { + t.Fatalf("decided before its time: %q", got) + } + if _, _, err := Reconcile(p, store, r, nil, t0.Add(ProposalLasts+2*time.Hour)); err != nil { + t.Fatal(err) + } + if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded: the router keeps no record") { + t.Errorf("decided %q", got) + } +} + +// refusingRouter refuses every ask as the router does when no channel can carry it: on the decided subject, with +// no record. +type refusingRouter struct { + *router + p Paths + store ProposalStore + id string +} + +func (r *refusingRouter) Publish(subject string, body any, id string) error { + r.id = id + go func() { + time.Sleep(50 * time.Millisecond) + _, _ = HeardEnd(r.p, r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, + Words: "no channel can carry any of its answers now: telegram is not linked", At: t0}, t0) + }() + return nil +}