From 0d868331235c8d3b490021d5a6a283209829f915 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:34:18 +0100 Subject: [PATCH] CAT-MS1: the router says when an ask is offered and how it ended offered once when an ask is offered, closed once for each of the five ends (answered, expired, cancelled by its asker, replaced, ended by the router), with the key, asker and headline and never an option, ticket or proof; on a stream of the events kept seven days, 64 MiB, granted by the trusted value readers, which names the knowledge base alone. Issue 725's runtime change is delivered (resolved 2026-10-26). Issue 1968. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/messenger/cmd/messenger/asks.go | 54 +++++++++++ .../cmd/messenger/asks_events_test.go | 93 +++++++++++++++++++ modules/messenger/cmd/messenger/holder.go | 2 + .../messenger/cmd/messenger/manifest_test.go | 2 +- modules/messenger/files/settings.json | 3 +- modules/messenger/module.json | 27 +++++- 6 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 modules/messenger/cmd/messenger/asks_events_test.go diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index bc8dfc344..70116c231 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -670,6 +670,7 @@ func (h *Holder) showAsk(key string, rec AskRecord, now time.Time) { target := h.open[rec.Record] h.mu.Unlock() h.Logf("[messenger] %s asks %q (%s), shown on %s", asker, a.Headline, a.ID, rec.Record) + h.sayOffered(key, rec) if target != nil { h.mu.Lock() target.Ask = key @@ -917,6 +918,7 @@ func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *ask rev = newRev } h.keepAsk(key, rec, rev) + h.sayClosed(key, rec, outcome, words) if rec.Asker == SelfAsker { // The router's own ask: it does what was decided, and tells nobody else (selfact.go). h.markTold(key) @@ -1416,3 +1418,55 @@ func (h *Holder) AsksNow() []AskView { }) return out } + +// sayOffered says that an ask was offered, once: its id, asker, headline and the kinds of channel it was offered on; never an +// option, a ticket or a warrant's proof, so it says no more than `asks` already answers (novox/hq ADR 0616 §7). A held ask is +// said when it is released and shown, not before. +func (h *Holder) sayOffered(key string, rec AskRecord) { + if h.Emit == nil || rec.State != StateOpen { + return + } + h.mu.Lock() + if h.offeredSaid == nil { + h.offeredSaid = map[string]bool{} + } + done := h.offeredSaid[key] + h.offeredSaid[key] = true + h.mu.Unlock() + if done { + return + } + kinds := map[string]bool{} + for _, t := range rec.Tickets { + kinds[t.Kind] = true + } + var on []string + for k := range kinds { + on = append(on, k) + } + sort.Strings(on) + if err := h.Emit("offered", map[string]any{"key": key, "asker": rec.Asker, "headline": rec.Ask.Headline, "offered_on": on, "at": h.Now()}); err != nil { + h.Logf("[messenger] that the ask %s was offered could not be said: %v", key, err) + } +} + +// sayClosed says how an ask ended, once per ask (its end is recorded by compare-and-set, so a second close never gets here): answered, +// expired, cancelled by its asker, replaced by a newer ask, or ended by the router itself on an unlink or allow-link. +func (h *Holder) sayClosed(key string, rec AskRecord, outcome asks.Outcome, words string) { + if h.Emit == nil { + return + } + how := string(outcome) + switch { + case outcome == asks.OutcomeChosen: + how = "answered" + case outcome == asks.OutcomeCancelled && words != "its asker took it back": + how = "ended by the router" + } + h.mu.Lock() + delete(h.offeredSaid, key) + h.mu.Unlock() + if err := h.Emit("closed", map[string]any{"key": key, "asker": rec.Asker, "headline": rec.Ask.Headline, "how": how, "at": h.Now()}); err != nil { + h.Logf("[messenger] that the ask %s ended could not be said: %v", key, err) + } +} diff --git a/modules/messenger/cmd/messenger/asks_events_test.go b/modules/messenger/cmd/messenger/asks_events_test.go new file mode 100644 index 000000000..c3f9ca37f --- /dev/null +++ b/modules/messenger/cmd/messenger/asks_events_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// The router says when an ask starts and ends (ADR 0616 §7): `offered` once when an ask is offered, `closed` once for each of the five ends +// (answered, expired, cancelled by its asker, replaced, ended by the router), with how it ended and never an option, a ticket or a +// proof; a held ask is said when it is released. +func TestTheRouterSaysWhenAnAskIsOfferedAndWhenItEnds(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + type said struct { + event string + body map[string]any + } + var got []said + h.Emit = func(event string, body any) error { + raw, _ := json.Marshal(body) + var m map[string]any + _ = json.Unmarshal(raw, &m) + got = append(got, said{event, m}) + return nil + } + ends := func() (out []string) { + for _, s := range got { + if s.event == "closed" { + out = append(out, s.body["key"].(string)+"="+s.body["how"].(string)) + } + } + return + } + // Answered. + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + if len(got) != 1 || got[0].event != "offered" || got[0].body["key"] != "mesh-delivery.d-1" || got[0].body["asker"] != "mesh-delivery" { + t.Fatalf("%+v", got) + } + release := ticketFor(tg.sends[0], "Release") + if _, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err != nil { + t.Fatal(err) + } + // Replaced, then cancelled by its asker. + a := ackAsk(c, "d-2") + a.About = "delivery.hq" + submit(t, h, "mesh-delivery", a) + b := ackAsk(c, "d-3") + b.About = "delivery.hq" + submit(t, h, "mesh-delivery", b) + if err := h.TakeAsk("cancel", "mesh-delivery", json.RawMessage(`{"id":"d-3"}`)); err != nil { + t.Fatal(err) + } + // Expired. + e := heldAsk(c, "d-4") + e.About = "other" + submit(t, h, "mesh-delivery", e) + settle(h, c) + c.pass(3 * time.Hour) + stand(h, c) + h.Tick() + // Ended by the router itself (an unlink or allow-link ends its own asks the same way). + f := ackAsk(c, "d-5") + f.About = "third" + submit(t, h, "mesh-delivery", f) + if err := h.closeAsk("mesh-delivery.d-5", asks.OutcomeCancelled, "root decided to allow a link", nil, "", nil); err != nil { + t.Fatal(err) + } + want := "mesh-delivery.d-1=answered mesh-delivery.d-2=replaced mesh-delivery.d-3=cancelled mesh-delivery.d-4=expired mesh-delivery.d-5=ended by the router" + if gotEnds := strings.Join(ends(), " "); gotEnds != want { + t.Fatalf("the ends %q, want %q", gotEnds, want) + } + // Once each: a second close loses and says nothing; five asks were offered, once each; no option, ticket or proof is in a body. + _ = h.closeAsk("mesh-delivery.d-1", asks.OutcomeCancelled, "again", nil, "", nil) + offered := 0 + for _, s := range got { + if s.event == "offered" { + offered++ + } + for k := range s.body { + if k == "options" || k == "tickets" || k == "token" || k == "proofs" || k == "warrant" { + t.Errorf("%s carries %s", s.event, k) + } + } + } + if offered != 5 || len(ends()) != 5 { + t.Fatalf("%d offered, %d closed", offered, len(ends())) + } +} diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index b3dff885c..39226e865 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -313,6 +313,8 @@ type Holder struct { Logf func(string, ...any) // Emit states a fact as this module (refused); nil states nothing. Emit func(event string, body any) error + // offeredSaid are the asks whose offering was said, so it is said once. + offeredSaid map[string]bool // Ask calls a seat verb through the mesh, as this module: the answers the operator chooses. Ask Asker // RootFree asks each machine's engine's root-free (the node seat node-engine's, ADR 0563 §6); nil asks it through Ask diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 20e660c18..1a631605d 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -94,7 +94,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{ + if !reflect.DeepEqual(m.Emits, []string{"refused", "offered", "closed"}) || !reflect.DeepEqual(m.Invokes, []string{ "seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) { t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) } diff --git a/modules/messenger/files/settings.json b/modules/messenger/files/settings.json index 726c29cb3..d628295dc 100644 --- a/modules/messenger/files/settings.json +++ b/modules/messenger/files/settings.json @@ -2,5 +2,6 @@ "time-zone": "", "only-what-needs-me": [], "messages-per-hour": 100, - "settle-seconds": 120 + "settle-seconds": 120, + "readers": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]} } diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 40221f1b5..f831d641e 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -86,7 +86,24 @@ "intake.standing" ], "emits": [ - "refused" + "refused", + "offered", + "closed" + ], + "streams": [ + { + "name": "asks", + "events": [ + "offered", + "closed" + ], + "max-age-days": 7, + "max-mib": 64, + "grant": { + "config": "readers", + "events": "{key}" + } + } ], "invokes": [ "seat:mesh-conditions.conditions", @@ -117,6 +134,12 @@ "messenger_unlink" ], "config": { + "readers": { + "type": "object", + "description": "Who may hear an ask offered or closed (the stream of the asks, kept seven days), per event: offered and closed, each a list of module names, on every machine. The knowledge base alone by default, for its overview page; an asker's own answers (decided) are never on it (ADR 0616 §7).", + "trusted": true, + "default": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]} + }, "time-zone": { "type": "string", "description": "The time zone every time a message says is said in, an IANA name such as Europe/Brussels; empty is this machine's own zone.", @@ -173,7 +196,7 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", - "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds"], + "takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds", "readers"], "merge": "json", "template": "files/settings.json", "owner": "messenger"