messenger, telegram: show the operator at once what a tap did, and change the question to its outcome (hq issue 372)
mesh/merge-gate pass: builds messenger, telegram → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request

At 13:30 on 2026-10-10 the operator approved two questions on Telegram and saw
nothing happen: the channel acknowledged each tap with no words, before the
router had judged it, and the edit that removed the buttons kept the question's
title, adding only a line under it that rings nothing.

The router now answers a chosen option in reply to the tap, once the ask's end
is recorded and before the question is edited: "You chose Approve: recorded,
and claude-code was told." (or, when the bus did not take the warrant, that it
is told when it does). A refusal already replied with its reason. Telegram
holds a tap's acknowledgement for the router's word and shows it as the tap's
own toast; a word with a body (a refusal's reason, Details) also stays in the
chat. Without a word in 8 seconds the toast claims nothing, and the late word
goes to the chat. A secret is never a toast.

An ask's own message, once answered, carries its outcome as its title:
"You chose Approve at 13:30 on Telegram · <headline>", then whether the asker
was told. The asks protocol carries no result back from the asker, so nothing
more is claimed of what it did.
This commit is contained in:
jochen
2026-10-10 13:59:54 +02:00
parent 7fd1ceb310
commit 020c05fe65
9 changed files with 498 additions and 28 deletions
@@ -365,10 +365,24 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
for _, m := range ch.edits {
edited = m
}
if len(edited.Actions) != 0 || !strings.Contains(edited.Body, "Release: chosen at") {
t.Errorf("%s still offers %q: %q", ch.name, labelsOf(edited), edited.Body)
// The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372).
if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" ||
!strings.HasPrefix(edited.Body, "Told to mesh-delivery, which acts on it itself.\n") {
t.Errorf("%s still offers %q, or does not say how it ended: %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body)
}
}
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
// was edited, so the confirmation is what the operator sees first.
var confirmed []Message
for _, m := range tg.sends {
if m.Reply == choiceOf(release, "42", c.now()).ID {
confirmed = append(confirmed, m)
}
}
if len(confirmed) != 1 || confirmed[0].Title != "You chose Release: recorded, and mesh-delivery was told." ||
confirmed[0].Body != "" || !confirmed[0].Quiet {
t.Errorf("the tap was confirmed with %+v", confirmed)
}
// One warrant: the same answer again, and another answer after it, are refused and said.
if out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now())); out != "chosen" {
t.Errorf("a redelivery of the same answer was acted on again: %s", out)
@@ -603,8 +617,9 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
for _, m := range tg.edits {
edited = m
}
if len(edited.Actions) != 0 || !strings.Contains(edited.Body, "No longer answerable") {
t.Errorf("the copy still offers answers: %+v", edited)
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") ||
!strings.HasPrefix(edited.Body, "The delivery stays held.") {
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
}
}
@@ -794,9 +809,13 @@ func TestUnlinkingIsAskedOfTheLinkedAccountAndToldToItFirst(t *testing.T) {
if len(store.ids) != 0 {
t.Fatal("the account's own approval did not unlink it")
}
notice := tg.sends[before]
if notice.Title != "This account is no longer linked as you" || tg.to[before] != "42" {
t.Errorf("the account was not told first, by name: %q to %q", notice.Title, tg.to[before])
// The tap is confirmed where it was made (issue 372), then the account is told, by name, before it goes.
if confirmed := tg.sends[before]; confirmed.Title != "You chose Unlink: recorded." || confirmed.Reply == "" {
t.Errorf("the tap was not confirmed in reply to it: %+v", confirmed)
}
notice := tg.sends[before+1]
if notice.Title != "This account is no longer linked as you" || tg.to[before+1] != "42" {
t.Errorf("the account was not told first, by name: %q to %q", notice.Title, tg.to[before+1])
}
if last := dt.sends[len(dt.sends)-1]; !strings.Contains(last.Title, "No Telegram account is linked as you") {
t.Errorf("the desk was not told: %q", last.Title)
+61 -9
View File
@@ -415,7 +415,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
h.Logf("[messenger] a cancel from %s names no ask", asker)
return nil
}
return h.closeAsk(askKey(asker, c.ID), asks.OutcomeCancelled, "its asker took it back", nil, "")
return h.closeAsk(askKey(asker, c.ID), asks.OutcomeCancelled, "its asker took it back", nil, "", nil)
}
var a asks.Ask
if err := json.Unmarshal(body, &a); err != nil {
@@ -469,7 +469,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
return nil
}
if replaced != "" {
if err := h.closeAsk(replaced, asks.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, ""); err != nil && !errors.Is(err, ErrChanged) {
if err := h.closeAsk(replaced, asks.OutcomeReplaced, "a newer ask about the same thing replaced it", nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
return err
}
}
@@ -596,8 +596,10 @@ func (h *Holder) tell(w asks.Warrant) error {
}
// closeAsk ends an open ask by compare-and-set, tells its asker, and edits every copy. A second close
// loses: ErrChanged when another outcome was first.
func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *asks.Warrant, used string) error {
// loses: ErrChanged when another outcome was first. confirm, when given, is called once the end is recorded
// and the asker told or not (told), before any copy is edited: the answer's confirmation where it was given
// comes first, and never before the record stands (issue 372).
func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *asks.Warrant, used string, confirm func(told bool)) error {
e := h.askOf(key)
if e == nil {
return nil
@@ -643,13 +645,20 @@ func (h *Holder) closeAsk(key string, outcome asks.Outcome, words string, w *ask
if rec.Asker == SelfAsker {
// The router's own ask: it does what was decided, and tells nobody else (selfact.go).
h.markTold(key)
if confirm != nil {
confirm(true)
}
h.showClosed(rec)
h.selfAct(rec, *w)
return nil
}
if h.tell(*w) == nil {
told := h.tell(*w) == nil
if told {
h.markTold(key)
}
if confirm != nil {
confirm(told)
}
h.showClosed(rec)
return nil
}
@@ -680,12 +689,14 @@ func (h *Holder) showClosed(rec AskRecord) {
if r == nil {
return
}
told := h.toldLine(rec) // read before the lock: it reads the ask's record
h.mu.Lock()
// The first line is how it ended, which an ask's own message shows as its title (sayOn); the rest follows.
switch {
case rec.Warrant != nil && rec.Warrant.Outcome == asks.OutcomeChosen:
r.Answered = rec.Warrant.Label + ": chosen at " + rec.Closed.In(h.zone()).Format("15:04") + " on " + Display(rec.Warrant.By.Kind)
r.Answered = chosenLine(rec.Warrant, rec.Closed.In(h.zone())) + "\n" + told
case rec.State == string(asks.OutcomeExpired):
r.Answered = "No longer answerable: nobody answered in time. " + capital(rec.Ask.OnExpiry)
r.Answered = "No longer answerable: nobody answered in time.\n" + strings.TrimSuffix(capital(rec.Ask.OnExpiry), ".") + "."
case rec.State == string(asks.OutcomeReplaced):
r.Answered = ""
default:
@@ -704,6 +715,46 @@ func (h *Holder) showClosed(rec AskRecord) {
h.persist(r)
}
// chosenLine is how a chosen ask ended, in the operator's words: "You chose Approve at 13:30 on Telegram."
func chosenLine(w *asks.Warrant, at time.Time) string {
who, where := "Somebody", ""
if w.By != nil {
if w.By.Who == asks.Operator {
who = "You"
}
where = " on " + Display(w.By.Kind)
}
return who + " chose " + w.Label + " at " + at.Format("15:04") + where + "."
}
// toldLine is what the router knows of the asker after an answer: whether it was told. The asker says nothing
// back of what it then did (the asks protocol carries no result to the router), so nothing more is claimed.
func (h *Holder) toldLine(rec AskRecord) string {
if rec.Asker == SelfAsker {
return "The router does it itself."
}
if e := h.askOf(askKey(rec.Asker, rec.Ask.ID)); e != nil && e.rec.Told {
return "Told to " + rec.Asker + ", which acts on it itself."
}
return "Told to " + rec.Asker + " as soon as the bus takes it; it acts on it itself."
}
// confirmChoice says, where an answer was given and in reply to it, that it was recorded: on Telegram the
// tap's own acknowledgement (issue 372). Called only once the ask's end is recorded.
func (h *Holder) confirmChoice(kind string, c asks.Choice, label, asker string, told bool) {
words := "You chose " + label + ": recorded"
switch {
case asker == SelfAsker:
words += "."
case told:
words += ", and " + asker + " was told."
default:
words += "; " + asker + " is told as soon as the bus takes it."
}
err := h.showOn(kind, Message{Title: words, Quiet: true, Reply: c.ID})
h.record(Sent{At: h.Now(), Channel: kind, Key: "(answer)", What: "confirmed " + label, Outcome: outcomeOf(err)})
}
// sweepAsks ends asks past their expiry, and tells again an asker not yet told. Called every minute.
func (h *Holder) sweepAsks(now time.Time) {
h.mu.Lock()
@@ -720,7 +771,7 @@ func (h *Holder) sweepAsks(now time.Time) {
sort.Strings(expired)
for _, k := range expired {
e := h.askOf(k)
if err := h.closeAsk(k, asks.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, ""); err != nil && !errors.Is(err, ErrChanged) {
if err := h.closeAsk(k, asks.OutcomeExpired, "nobody answered in time: "+e.rec.Ask.OnExpiry, nil, "", nil); err != nil && !errors.Is(err, ErrChanged) {
h.Logf("[messenger] could not end the expired ask %s: %v", k, err)
}
}
@@ -892,7 +943,8 @@ func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInf
Option: o.ID, Label: o.Label, Level: o.Level, By: &person, Channel: info.Module, Proofs: proofs, At: now,
// The warrant answers this ask as it was taken and shown, with what each option binds (ADR 0259 §6).
AskDigest: rec.Ask.Digest()}
if err := h.closeAsk(key, asks.OutcomeChosen, "", w, c.Ticket); err != nil {
confirm := func(told bool) { h.confirmChoice(kind, c, o.Label, rec.Asker, told) }
if err := h.closeAsk(key, asks.OutcomeChosen, "", w, c.Ticket, confirm); err != nil {
if errors.Is(err, ErrChanged) {
return h.refuseChoice(kind, c, "the question was already answered")
}
@@ -0,0 +1,152 @@
package main
// What the operator sees when they answer a question (issue 372): at once, where they answered, that it was
// recorded — never before it was — or why it was not; then the question itself says how it ended, with no
// answers left on it.
import (
"errors"
"strings"
"testing"
"time"
)
// inOrder is a channel that keeps what it was given in one line, sends and edits alike.
type inOrder struct {
*fakeChannel
log *[]string
}
func (o inOrder) Send(m Message) (string, error) {
*o.log = append(*o.log, "send "+m.Title)
return o.fakeChannel.Send(m)
}
func (o inOrder) Edit(id string, m Message) error {
*o.log = append(*o.log, "edit "+m.Title)
return o.fakeChannel.Edit(id, m)
}
// failingAsks is the router's state refusing to record an ask's end.
type failingAsks struct {
*memAsks
err error
}
func (f failingAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) {
return 0, f.err
}
func TestAnApprovalIsConfirmedWhereItWasGivenAndThenTheQuestionSaysHowItEnded(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
var log []string
h.Away = inOrder{tg, &log}
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
log = nil
ch := choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())
if out, err := h.Chosen("telegram", ch); err != nil || out != "chosen" {
t.Fatalf("%s %v", out, err)
}
want := []string{
"send You chose Release: recorded, and mesh-delivery was told.",
"edit You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours",
}
if strings.Join(log, "\n") != strings.Join(want, "\n") {
t.Fatalf("the operator saw, in order:\n%s\nwant:\n%s", strings.Join(log, "\n"), strings.Join(want, "\n"))
}
if confirmed := tg.sends[len(tg.sends)-1]; confirmed.Reply != ch.ID || confirmed.Body != "" {
t.Errorf("the confirmation is not a reply of one line to the tap: %+v", confirmed)
}
// The same tap redelivered is acted on once: nothing more is said, and the question is edited once.
if out, _ := h.Chosen("telegram", ch); out != "chosen" || len(log) != 2 {
t.Errorf("a redelivered tap was said again: %s %q", out, log)
}
}
// A Decline is confirmed as what it is, in the option's own word.
func TestADeclineIsConfirmedInItsOwnWord(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
if out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Stop"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("%s %v", out, err)
}
if got := tg.sends[len(tg.sends)-1].Title; got != "You chose Stop: recorded, and mesh-delivery was told." {
t.Errorf("confirmed as %q", got)
}
}
// The confirmation never claims what did not happen: an end that could not be recorded is said as refused,
// with why, and nothing says "recorded"; a warrant the bus did not take is said as not yet told.
func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) {
t.Run("not recorded", func(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
h.Asks = failingAsks{store, errors.New("the bucket is away")}
before := len(tg.sends)
out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now()))
if err == nil || out != "refused" {
t.Fatalf("%s %v", out, err)
}
for _, m := range tg.sends[before:] {
if strings.Contains(m.Title+m.Body, "recorded,") || strings.Contains(m.Title, "You chose") {
t.Errorf("an answer not recorded was confirmed: %+v", m)
}
}
last := tg.sends[len(tg.sends)-1]
if last.Title != "That answer was not taken" || !strings.Contains(last.Body, "could not be recorded") || last.Reply == "" {
t.Errorf("the refusal does not say why, in reply to the tap: %+v", last)
}
if len(w.of("mesh-delivery")) != 0 || len(tg.edits) != 0 {
t.Error("an answer not recorded was told or shown as the question's end")
}
})
t.Run("not told", func(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
h.Publish = func(subject string, body any, id string) error { return errors.New("the bus is away") }
if out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("%s %v", out, err)
}
if got := tg.sends[len(tg.sends)-1].Title; got != "You chose Release: recorded; mesh-delivery is told as soon as the bus takes it." {
t.Errorf("confirmed as %q", got)
}
var edited Message
for _, m := range tg.edits {
edited = m
}
if !strings.HasPrefix(edited.Body, "Told to mesh-delivery as soon as the bus takes it") {
t.Errorf("the question claims its asker was told: %q", edited.Body)
}
})
}
// A refused answer says why, in reply to the tap, with nothing claimed done.
func TestARefusedAnswerSaysWhyInReplyToTheTap(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
release := ticketFor(tg.sends[0], "Release")
_, _ = h.Chosen("telegram", choiceOf(release, "42", c.now()))
again := choiceOf(release, "42", c.now())
again.ID = "tg-tap-again"
if out, _ := h.Chosen("telegram", again); out != "refused" {
t.Fatalf("a used ticket was taken: %s", out)
}
last := tg.sends[len(tg.sends)-1]
if last.Reply != "tg-tap-again" || last.Title != "That answer was not taken" ||
!strings.HasSuffix(last.Body, "Nothing was done.") || len(strings.TrimSuffix(last.Body, "Nothing was done.")) < 10 {
t.Errorf("the refusal: %+v", last)
}
if _, ok := Check(last.Text()); !ok {
t.Error("the refusal carries what may not leave")
}
}
+20 -1
View File
@@ -639,7 +639,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
return m
}
if r.Answered != "" {
m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body)
m = h.answeredWords(r, m)
}
if kind == "" {
return m
@@ -675,6 +675,25 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
return m
}
// answeredWords is a message that says what came of its question. An ask's own message is the question
// itself, so its title becomes how it ended — "You chose Approve at 13:30 on Telegram · <headline>" — read at
// a glance on the phone, where the edit rings nothing (issue 372); the rest of what came of it, the
// explanation and when it was asked follow. A condition's message keeps its own title, and says it first.
func (h *Holder) answeredWords(r *Record, m Message) Message {
if r.Origin == "ask" {
first, rest, _ := strings.Cut(r.Answered, "\n")
headline, explanation := wordsOf(r)
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Urgent: m.Urgent, Quiet: m.Quiet,
Body: strings.TrimSpace(rest + "\n" + explanation + "\nAsked " + when(r.Raised, h.Now(), h.zone()) + ".")}
if _, ok := Check(out.Text()); ok {
return out
}
// Its words refused (they are the asker's): the outcome is still said, before what may leave.
}
m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body)
return m
}
// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry.
func (h *Holder) askLines(rec *AskRecord) []string {
asker := rec.Asker
+1 -1
View File
@@ -302,7 +302,7 @@ func (h *Holder) endSelfAsks(why string) int {
sort.Strings(keys)
n := 0
for _, k := range keys {
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, ""); err == nil {
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, "", nil); err == nil {
n++
}
}