Merge pull request 'claude-code: leave the judging of a proposal's channel to the router, and hear its refusal (issue 370, hq ADR 0274)' (#173) from fix/claude-code-asks-without-the-channel-kinds into main

This commit was merged in pull request #173.
This commit is contained in:
2026-10-10 11:18:27 +00:00
3 changed files with 230 additions and 12 deletions
@@ -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 })
+120 -11
View File
@@ -448,10 +448,13 @@ type ProposalDecision struct {
Says string `json:"says,omitempty"`
// Recorded says the controller's hand-act log holds the warrant; Record is why not, while it does not.
// Warranted says a person's choice held here: Approve or Decline, recorded in the hand-act log.
Warranted bool `json:"warranted,omitempty"`
Recorded bool `json:"recorded,omitempty"`
Record string `json:"record,omitempty"`
LastTry time.Time `json:"last-try,omitempty"`
Warranted bool `json:"warranted,omitempty"`
Recorded bool `json:"recorded,omitempty"`
// Heard says the decision came from the router's word on the decided subject, not its record: a record of a
// choice found later is still taken.
Heard bool `json:"heard,omitempty"`
Record string `json:"record,omitempty"`
LastTry time.Time `json:"last-try,omitempty"`
}
// Approved is this node's own record: the sections it renders, and every proposal it decided.
@@ -620,13 +623,26 @@ type ProposeInput struct {
const AtTheTerminalInstead = "the operator may set it at the controller's terminal instead: mesh-cli settings set claude-code " +
"'{\"instructions\": {\"<name>\": [\"line\", …]}}' [--node <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 +748,22 @@ 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"
defer func() { endedMu.Lock(); delete(ended, prop.ID); endedMu.Unlock() }()
for deadline := time.Now().Add(RouterAnswersWithin); time.Now().Before(deadline); {
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
}
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 +804,64 @@ 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
}
reconcileMu.Lock()
defer reconcileMu.Unlock()
prop, known := readProposal(store, w.Ask)
if !known || (w.About != "" && w.About != prop.Ask().About) {
return "", nil
}
endedMu.Lock()
ended[w.Ask] = w
endedMu.Unlock()
approved, err := ReadApproved(p)
if err != nil {
return "", err
}
if _, done := approved.Decided[w.Ask]; done {
return "", nil
}
// Heard, not read from a record: Reconcile still reads the router's record of it, so an ask the router took after
// all (its queue offered it again once a channel was ready) is answered as any other.
d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true}
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,15 +893,35 @@ 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 {
continue
}
if _, done := approved.Decided[prop.ID]; done {
d, done := approved.Decided[prop.ID]
if done && !d.Heard {
continue
}
entry, err := bus.Record(prop.ID)
if done {
// Discarded on the router's word alone: only a record of a person's choice reopens it.
var rec routerRecord
if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.Warrant != nil &&
rec.Warrant.Outcome == asks.OutcomeChosen {
ready = append(ready, closed{prop, *rec.Warrant})
}
continue
}
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 +932,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)
@@ -642,3 +642,100 @@ 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}
within, every := RouterAnswersWithin, RouterAnswersEvery
RouterAnswersWithin, RouterAnswersEvery = time.Second, 10*time.Millisecond
t.Cleanup(func() { RouterAnswersWithin, RouterAnswersEvery = within, every })
_, 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
}
// A refusal heard, and then the same ask taken after all and approved: the approval is taken, not lost in silence.
func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
p, _ := node(t, "novox")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
if _, err := HeardEnd(p, store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, Words: "no channel", At: t0}, t0); err != nil {
t.Fatal(err)
}
if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded") {
t.Fatalf("decided %q", got)
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); !changed {
t.Fatal("the approval of an ask taken after its refusal was lost")
}
if got := outcomeHere(t, p, id); got != "approved" {
t.Errorf("decided %q", got)
}
}