From 204320020107ab4474bfa63cc92689f351b32072 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 28 Oct 2026 09:57:51 +0100 Subject: [PATCH] =?UTF-8?q?messenger:=20ask=20each=20machine's=20own=20eng?= =?UTF-8?q?ine=20whether=20it=20is=20root-free,=20the=20node=20seat=20node?= =?UTF-8?q?-engine's=20root-free,=20not=20the=20controller's=20(issue=2077?= =?UTF-8?q?4,=20ADR=200563=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/messenger/cmd/messenger/answers.go | 2 +- .../messenger/cmd/messenger/answers_test.go | 10 +++ modules/messenger/cmd/messenger/asks.go | 88 +++++++++++-------- modules/messenger/cmd/messenger/bus_test.go | 12 +-- modules/messenger/cmd/messenger/holder.go | 3 +- .../messenger/cmd/messenger/manifest_test.go | 2 +- .../messenger/cmd/messenger/review_test.go | 26 ++++-- modules/messenger/module.json | 2 +- 8 files changed, 87 insertions(+), 58 deletions(-) diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go index d92bf1af..6a3fbb5a 100644 --- a/modules/messenger/cmd/messenger/answers.go +++ b/modules/messenger/cmd/messenger/answers.go @@ -133,7 +133,7 @@ func pruneGrants(grants map[string]Grant, kind string, now time.Time, keep int) // stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked. // Anything else is refused, and said there. The same answer is acted on once. func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) { - // The controller's root-free word is asked before the work is taken (the confirmation review of 2026-10-09): + // The machines' engines' root-free word is asked before the work is taken (the confirmation review of 2026-10-09): // up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer, // and judged with in it. root := h.rootWordFor(kind) diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index cdffa473..c1ff0d0f 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -600,6 +600,16 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) { if _, kept := store.asks["mesh-delivery.d-1"]; kept { t.Error("a refused ask was recorded") } + // A node's engine is an asker under node-engine- (novox/hq ADR 0563 §3, changing ADR 0259 §2): its ask is + // recorded under that name, with its own bound of open asks; a name that is no asker's is dropped. + submit(t, h, "node-engine-ace", heldAsk(c, "hand-over-1")) + submit(t, h, "node.ace", heldAsk(c, "hand-over-2")) + if rec, kept := store.asks["node-engine-ace.hand-over-1"]; !kept || rec.Asker != "node-engine-ace" { + t.Errorf("a node's engine's ask was not recorded under its name: %+v", rec) + } + if _, kept := store.asks["node.ace.hand-over-2"]; kept { + t.Error("an ask under a name that is no asker's was recorded") + } // No channel can carry anything: held with the reason (ADR 0397), never queued in silence. h2, _, _, c2, store2, w2 := newRouter(t) h2.Bench.Stand("telegram", Standing{Ready: false, Why: "no bot token", At: c2.now()}) diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 08a53a95..b719e68f 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -242,8 +242,9 @@ func (h *Holder) carriers(level asks.Level) []string { return out } -// RootVerdict is the controller's word on one machine (its verb root-free, novox/hq ADR 0259 §8): free only -// when it measured, recently, that no agent can become root there without a person. +// RootVerdict is a machine's engine's word on that machine (the node seat node-engine's root-free, novox/hq ADR 0259 +// §8 as ADR 0563 §6 changes it): free only when it measured, recently, that no agent can become root there without a +// person. type RootVerdict struct { Machine string `json:"machine"` Free bool `json:"free"` @@ -251,51 +252,64 @@ type RootVerdict struct { Judged string `json:"judged,omitempty"` } -// RootFreeVerb is the controller's verb the router asks, as this module, which machines are root-free. -const RootFreeVerb = "seat:" + ControllerSeat + ".root-free" +// EngineSeat is the node seat each machine's engine holds, and RootFreeVerb its verb the router asks of a machine, as +// this module: `seat:node-engine.root-free@`, which that machine's engine alone answers (ADR 0563 §6). +const ( + EngineSeat = "node-engine" + RootFreeVerb = "seat:" + EngineSeat + ".root-free" +) -// RootFreeWithin is how long the router waits for the controller's word; RootFreeKept how long a word is -// used to render a message's buttons. An answer is always judged on a word asked then. +// RootFreeWithin is how long the router waits for the machines' words; RootFreeKept how long a word is used to render a +// message's buttons. An answer is always judged on a word asked then. const ( RootFreeWithin = 10 * time.Second RootFreeKept = 30 * time.Second ) -// askRootFree asks the controller about machines, failing on no answer in time. +// askRootFree asks each machine's own engine about that machine, at once, failing on no answer in time. A machine's +// word is the verdict its engine gives on it; a verdict an engine gives on another machine is not taken. func (h *Holder) askRootFree(machines []string) (map[string]RootVerdict, error) { if h.RootFree != nil { return h.RootFree(machines) } if h.Ask == nil { - return nil, errors.New("this router cannot ask the controller") + return nil, errors.New("this router cannot ask a machine's engine") } type answer struct { - raw json.RawMessage - err error + machine string + raw json.RawMessage + err error } - done := make(chan answer, 1) - go func() { - raw, err := h.Ask(RootFreeVerb, map[string]any{"machines": machines}) - done <- answer{raw, err} - }() - var a answer - select { - case a = <-done: - case <-time.After(RootFreeWithin): - return nil, fmt.Errorf("the controller did not answer within %s", RootFreeWithin) - } - if a.err != nil { - return nil, a.err - } - var body struct { - Machines []RootVerdict `json:"machines"` - } - if err := json.Unmarshal(a.raw, &body); err != nil { - return nil, fmt.Errorf("the controller's answer could not be read: %v", err) + done := make(chan answer, len(machines)) + for _, m := range machines { + go func(m string) { + raw, err := h.Ask(RootFreeVerb+"@"+m, map[string]any{}) + done <- answer{m, raw, err} + }(m) } out := map[string]RootVerdict{} - for _, v := range body.Machines { - out[v.Machine] = v + deadline := time.After(RootFreeWithin) + for range machines { + var a answer + select { + case a = <-done: + case <-deadline: + return nil, fmt.Errorf("a machine's engine did not answer within %s", RootFreeWithin) + } + if a.err != nil { + return nil, fmt.Errorf("%s's engine: %w", a.machine, a.err) + } + var body struct { + Machines []RootVerdict `json:"machines"` + } + if err := json.Unmarshal(a.raw, &body); err != nil { + return nil, fmt.Errorf("%s's engine's answer could not be read: %v", a.machine, err) + } + for _, v := range body.Machines { + if v.Machine == a.machine { + out[v.Machine] = v + } + } } return out, nil } @@ -329,7 +343,7 @@ func (h *Holder) rootFree(k KindInfo, cached bool) string { case cached: // Read inside the work, which never waits on the controller: no recent word is no pass. warmRootFree // asks outside it. - return "the controller's word on whether an agent can become root there is not recent" + return "the machines' engines' word on whether an agent can become root there is not recent" default: got, err := h.askRootFree(machines) if err != nil { @@ -351,7 +365,7 @@ func (h *Holder) rootFree(k KindInfo, cached bool) string { v, said := verdicts[m] switch { case !said: - return "the controller said nothing of whether an agent can become root on " + m + return "its engine said nothing of whether an agent can become root on " + m case !v.Free: why := v.Why if why == "" { @@ -364,7 +378,7 @@ func (h *Holder) rootFree(k KindInfo, cached bool) string { return "" } -// warmRootFree asks the controller's root-free word for every kind that proves its sender, outside the work, +// warmRootFree asks the machines' engines' root-free word for every kind that proves its sender, outside the work, // so what is rendered and offered inside it reads a recent word. // force asks even a recent word again: taking an ask, whose refusal its asker keeps until something changes. func (h *Holder) warmRootFree(force bool) { @@ -390,7 +404,7 @@ func (h *Holder) warmRootFree(force bool) { } } -// rootWordFor asks the controller's root-free word for a kind fresh, before the work is taken, and answers +// rootWordFor asks the machines' engines' root-free word for a kind fresh, before the work is taken, and answers // what judging an answer of that kind then reads: the word for that very kind's placement, or why there is none. // A kind that does not prove its sender is asked nothing: its answers never approve. func (h *Holder) rootWordFor(kind string) func(KindInfo) string { @@ -453,7 +467,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { if verb == ConditionVerb { // Before the work lock: Condition takes it, and decides as for the controller's conditions. if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) { - h.Logf("[messenger] a condition from %q was taken and dropped: not a module's name", asker) + h.Logf("[messenger] a condition from %q was taken and dropped: not an asker's name (a module's, or a node's engine's)", asker) return nil } h.takeCondition(asker, body) @@ -466,7 +480,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { h.init() h.mu.Unlock() if !asks.UsableID(strings.ReplaceAll(asker, "-", "_")) { - h.Logf("[messenger] an ask from %q was taken and dropped: not a module's name", asker) + h.Logf("[messenger] an ask from %q was taken and dropped: not an asker's name (a module's, or a node's engine's)", asker) return nil } if verb == "cancel" { diff --git a/modules/messenger/cmd/messenger/bus_test.go b/modules/messenger/cmd/messenger/bus_test.go index 9c67bda6..c42fb278 100644 --- a/modules/messenger/cmd/messenger/bus_test.go +++ b/modules/messenger/cmd/messenger/bus_test.go @@ -256,16 +256,8 @@ func (r *busRuntime) runtime(id json.RawMessage, method string, params json.RawM case "mesh/seat.publish": return map[string]any{}, r.nc.Publish(p.Subject, p.Body) case "mesh/ask": - if p.Key == RootFreeVerb { - var a struct { - Machines []string `json:"machines"` - } - _ = json.Unmarshal(p.Body, &a) - var out []RootVerdict - for _, m := range a.Machines { - out = append(out, RootVerdict{Machine: m, Free: true}) - } - return map[string]any{"machines": out}, nil + if m, ok := strings.CutPrefix(p.Key, RootFreeVerb+"@"); ok { + return map[string]any{"machines": []RootVerdict{{Machine: m, Free: true}}}, nil } return nil, fmt.Errorf("the test runtime answers nothing for %s", p.Key) } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 120daa75..b3dff885 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -315,7 +315,8 @@ type Holder struct { Emit func(event string, body any) error // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. Ask Asker - // RootFree asks the controller's verb root-free about machines; nil asks it through Ask (askRootFree). + // RootFree asks each machine's engine's root-free (the node seat node-engine's, ADR 0563 §6); nil asks it through Ask + // (askRootFree). RootFree func(machines []string) (map[string]RootVerdict, error) // Zone is the operator's time zone, which every time in a message is said in; nil is this machine's. Zone func() *time.Location diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 2281501b..9fe112a0 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -87,7 +87,7 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { // // It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues // track which conditions. if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions", - "seat:" + ControllerSeat + ".root-free", TrackingVerb}) { + "seat:" + EngineSeat + ".root-free", TrackingVerb}) { t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) } var state []string diff --git a/modules/messenger/cmd/messenger/review_test.go b/modules/messenger/cmd/messenger/review_test.go index 3409c3a6..9c042edc 100644 --- a/modules/messenger/cmd/messenger/review_test.go +++ b/modules/messenger/cmd/messenger/review_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "sync" "testing" @@ -66,26 +67,37 @@ func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) { } } -// H3: the word is asked of the controller's verb, as this module, and read from its answer. -func TestTheRootWordIsAskedOfTheControllersVerb(t *testing.T) { +// H3, as ADR 0563 §6 changes it: the word on each machine is asked of that machine's own engine, the node seat +// node-engine's root-free there, as this module, and read from its answer; a machine's word is that machine's alone. +func TestTheRootWordIsAskedOfEachMachinesEngine(t *testing.T) { h, _, _, _, _, _ := newRouter(t) h.RootFree = nil var asked []string h.Ask = func(key string, body any) (json.RawMessage, error) { asked = append(asked, key) - raw, _ := json.Marshal(body) - if !strings.Contains(string(raw), `"machines":["anchor"]`) { - t.Errorf("asked about %s", raw) + machine := strings.TrimPrefix(key, "seat:node-engine.root-free@") + if machine == "elsewhere" { + machine = "anchor" // an engine that answers for another machine is not believed } - return json.RawMessage(`{"machines":[{"machine":"anchor","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil + return json.RawMessage(`{"machines":[{"machine":"` + machine + `","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil } info, _ := h.Bench.Kind("telegram") if why := h.rootFree(info, false); why != "" { t.Fatalf("free not read: %s", why) } - if len(asked) != 1 || asked[0] != "seat:mesh-controller.root-free" { + if len(asked) != 1 || asked[0] != "seat:node-engine.root-free@anchor" { t.Errorf("asked %v", asked) } + asked = nil + h.Node = "elsewhere" + if why := h.rootFree(info, false); !strings.Contains(why, "said nothing of whether an agent can become root on elsewhere") { + t.Errorf("one machine's word was taken for another's: %q", why) + } + sort.Strings(asked) + if len(asked) != 2 || asked[0] != "seat:node-engine.root-free@anchor" || asked[1] != "seat:node-engine.root-free@elsewhere" { + t.Errorf("the router's machine and the channel's were asked as %v", asked) + } + h.Node = "anchor" h.Ask = func(string, any) (json.RawMessage, error) { return json.RawMessage(`not json`), nil } if why := h.rootFree(info, false); !strings.Contains(why, "could not be read") { t.Errorf("an unreadable answer was a pass: %q", why) diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 4aceaebe..26ec11a3 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -90,7 +90,7 @@ ], "invokes": [ "seat:mesh-controller.conditions", - "seat:mesh-controller.root-free", + "seat:node-engine.root-free", "seat:issue-tracker.tracking" ], "state": [