From f074fb8df49e5f36f948f3f473a3feeb03f3787e Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 11:44:22 +0100 Subject: [PATCH] messenger: say each ask taken on the bus as operator-channel.asked., so a session hears an approval request open The operator, 2026-10-30: "you should be able to hook into approval requests so you also get a notification". The router said only an ask's end (decided.); it now says every ask it takes, open or held, on the seat's event asked.: its id, asker, about, headline, state and expiry, never its bindings or whole words. The seat declares asked among its events and names it by caller, as decided. The subject is named in the module until the SDK's asks contract carries it. --- .../messenger/cmd/messenger/answers_test.go | 15 +++++++++-- modules/messenger/cmd/messenger/asks.go | 26 +++++++++++++++++++ .../messenger/cmd/messenger/manifest_test.go | 4 +-- modules/messenger/module.json | 6 +++-- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index cdffa473..808d22b2 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -161,7 +161,11 @@ func (m *memAsks) PutLinksAsked(key string, times []time.Time) error { var rootFreeOf = map[*Holder]map[string]string{} // told is what the router published to askers, by subject. -type told struct{ by map[string][]asks.Warrant } +type told struct { + by map[string][]asks.Warrant + // asked are the asks the router said it took, by subject (sayAsked). + asked map[string][]map[string]any +} func (w *told) of(asker string) []asks.Warrant { return w.by[asks.DecidedSubject(asker)] } @@ -171,7 +175,7 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA t.Helper() h, tg, dt, c, _ := newHolder(t) store := newMemAsks() - w := &told{by: map[string][]asks.Warrant{}} + w := &told{by: map[string][]asks.Warrant{}, asked: map[string][]map[string]any{}} bench := NewBench(c.now) bench.SetKinds([]KindInfo{ {Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"deliver", "reaches-away", "choice", "edit", @@ -204,6 +208,9 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA if wt, ok := body.(asks.Warrant); ok { w.by[subject] = append(w.by[subject], wt) } + if m, ok := body.(map[string]any); ok && strings.HasPrefix(subject, "mesh.seat."+asks.Seat+".event."+AskedEvent+".") { + w.asked[subject] = append(w.asked[subject], m) + } return nil } return h, tg, dt, c, store, w @@ -325,6 +332,10 @@ func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) { !strings.Contains(rec.HeldWhy, "no Telegram account is linked") { t.Fatalf("not held: %+v %+v", rec, w.of("mesh-delivery")) } + // Held or shown, the ask is said taken on the bus for its asker, so a session hears it open (2026-10-30). + if said := w.asked[askedSubject("mesh-delivery")]; len(said) != 1 || said[0]["ask"] != "d-1" || said[0]["state"] != StateHeld { + t.Fatalf("the ask taken is not said on %s: %+v", askedSubject("mesh-delivery"), said) + } if len(dt.sends) != 0 { t.Errorf("the desk was offered an ask with an approval: %+v", dt.sends) } diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 08a53a95..79031de1 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -560,6 +560,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { } else { h.keepAsk(key, rec, 0) } + h.sayAsked(asker, a, rec) if rec.State == StateHeld { h.Logf("[messenger] %s asks %q (%s): held, not shown: %s", asker, a.Headline, a.ID, rec.HeldWhy) return nil @@ -568,6 +569,31 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { return nil } +// AskedEvent is the seat's event that an ask was taken, said for its asker as decided is (ADR 0259 ยง3): a session +// waits on `operator-channel.asked.` to hear an ask open, and on `operator-channel.decided.` to hear +// its answer (the operator, 2026-10-30: "you should be able to hook into approval requests so you also get a +// notification"). The SDK names decided's subject; asked's is named here until the SDK's asks contract carries it. +const AskedEvent = "asked" + +// askedSubject is where the router says an ask of an asker was taken. +func askedSubject(asker string) string { + return "mesh.seat." + asks.Seat + ".event." + AskedEvent + "." + asker +} + +// sayAsked says on the bus that an ask was taken, open or held: its id, asker, what it is about, its headline (the +// words the operator's phone shows, already judged to carry nothing that may not leave the mesh) and when it ends. +// Never its options' bindings or its whole words. A failure is said in the log and holds nothing: the ask stands. +func (h *Holder) sayAsked(asker string, a asks.Ask, rec AskRecord) { + if h.Publish == nil { + return + } + body := map[string]any{"ask": a.ID, "asker": asker, "about": a.About, "headline": a.Headline, "state": rec.State, + "opened": rec.Opened.UTC(), "expires": a.Expires} + if err := h.Publish(askedSubject(asker), body, "asked."+ticket()); err != nil { + h.Logf("[messenger] the ask %s of %s is taken and could not be said on the bus: %v", a.ID, asker, err) + } +} + // ticketsFor is every option's ticket on every kind that can carry the ask's highest level now: an ask with an // approval is answered only where it could be approved (answerLevel). None when no ready channel can. func (h *Holder) ticketsFor(a asks.Ask) map[string]Ticket { diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 2281501b..6e926a9a 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -64,8 +64,8 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { // m, _ := readManifest(t) verbs := []string{"open", "history", "notify", "tell", "asks"} want := []seat{ - {Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"}, - ByCaller: []string{"ask", "cancel", "condition", "decided"}, Records: []string{"asks"}}, + {Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided", "asked"}, + ByCaller: []string{"ask", "cancel", "condition", "decided", "asked"}, Records: []string{"asks"}}, {Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}}, {Name: asks.IntakeSeat, Scope: "mesh", Kinded: true, Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}}, diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 4aceaebe..85c91b26 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -20,13 +20,15 @@ "condition" ], "emits": [ - "decided" + "decided", + "asked" ], "by-caller": [ "ask", "cancel", "condition", - "decided" + "decided", + "asked" ], "records": [ "asks"