Files
mesh-catalog/modules/messenger/cmd/messenger/selfact.go
T
jochen 020c05fe65
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
messenger, telegram: show the operator at once what a tap did, and change the question to its outcome (hq issue 372)
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.
2026-10-10 13:59:54 +02:00

371 lines
15 KiB
Go

package main
// The router's own asks (novox/hq ADR 0259 §7, as revised after the security review of 2026-10-08): who the
// operator is on a channel changes only with the word of the account it is now.
//
// - **Unlinking is an approve ask, put to the account that is linked.** Whoever could unlink the
// operator's account without it could link their own in its place. The notice of the unlink reaches that
// account first, by name, and then every channel.
// - **A link within a day of an unlink waits for the replaced account's approval**, asked of it by name.
// - **When that account is lost with its phone**, the operator, as root at the router's machine, leaves a
// file in the router's directory — `unlink.<kind>` or `allow-link.<kind>`, owned by root — and the router
// does it, announced as done at the machine. A file of any other owner is refused and said: an agent
// without root cannot make one.
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// SelfAsker is the name the router asks its own questions under.
const SelfAsker = "messenger"
// RelinkWaits is how long after an unlink a new link waits for the replaced account's approval.
const RelinkWaits = 24 * time.Hour
// SelfAct is what the router does on its own ask's warrant. It names the account it concerns: an option
// binds that account, so a warrant for unlinking one account never unlinks another linked in its place
// (the review of 2026-10-09).
type SelfAct struct {
Do string `json:"do"` // "unlink" or "link"
Kind string `json:"kind"`
Identity string `json:"identity"` // the account unlinked, or the account a link makes the operator's
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
}
// act is what an option of the router's own ask binds: its named fields (asks.ActDigest).
func (a SelfAct) act() asks.Act {
return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
}
// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it.
type Unlinked struct {
Identity Identity `json:"identity"`
At time.Time `json:"at"`
// Early is an unlink at the desk in the account's first hour: it bars nothing (the confirmation review of
// 2026-10-09, N1-router). A desk click is anyone's at the desk machine, and a bar it set — the account not
// linked again for a day, the next link waiting for it — let a forged click lock the operator out, again
// and again. The bar is for an account removed deliberately: by its own approval, or by root.
Early bool `json:"early,omitempty"`
}
// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask.
func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
// Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone.
binds, err := asks.ActDigest(act.act())
if err != nil {
return err
}
for i := range a.Options {
a.Options[i].Binds = binds
}
key := askKey(SelfAsker, a.ID)
now := h.Now()
rec := AskRecord{Asker: SelfAsker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{},
Only: &only, Act: &act, Record: "ask." + key}
for _, o := range a.Options {
rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: only.Kind}
}
if h.Asks != nil {
rev, err := h.Asks.CreateAsk(key, rec)
if err != nil {
return err
}
h.keepAsk(key, rec, rev)
} else {
h.keepAsk(key, rec, 0)
}
r := Record{Key: rec.Record, Kind: "ask", Subject: SelfAsker, Severity: Urgent, Summary: a.Headline,
Headline: a.Headline, Explanation: a.Explanation, Origin: "ask", More: "operator-channel.asks",
Raised: now, Sent: map[string]string{}, Seen: now, Count: 1, Ask: key}
h.mu.Lock()
h.open[r.Key] = &r
h.mu.Unlock()
m := h.words(&r, "raised")
m.Actions = h.offers(&rec, only.Kind)
m.To = only.Identity
handle, err := h.showOnHandle(only.Kind, m)
if err == nil {
h.mu.Lock()
r.Sent[only.Kind] = handle
r.FirstSent = now
h.mu.Unlock()
}
h.persist(&r)
return err
}
// selfAct does what the router's own ask decided.
func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
if rec.Act == nil || w.Outcome != asks.OutcomeChosen {
return
}
o, err := w.For(SelfAsker, rec.Ask)
if err == nil {
err = o.Performs(rec.Act.act())
}
if err != nil {
h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err)
return
}
switch rec.Act.Do {
case "unlink":
if o.Level == asks.Acknowledge {
// Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked
// this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself.
id := h.identity(rec.Act.Kind, rec.Act.Identity)
if id == nil || !h.Now().Before(id.ApproveFrom) {
h.Logf("[messenger] an unlink chosen at the desk was not done: the account's first hour has passed")
h.announce(Message{Title: "An unlink chosen at the desk was not done",
Body: "The account's first hour had passed, so only the account itself, or root at the router's " +
"machine, can unlink it now.", Urgent: true})
return
}
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked", true)
return
}
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself", false)
case "link":
if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity {
h.linkNow(*rec.Act.Link, "approved by the account it replaces")
}
}
}
// Unlink asks the account linked on a kind whether it is to be unlinked: an approve ask put to it alone.
func (h *Holder) Unlink(kind string) (map[string]any, error) {
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
h.work.Lock()
defer h.work.Unlock()
ids := h.identitiesOf(kind)
if len(ids) == 0 {
return map[string]any{"kind": kind, "linked": false, "asked": false}, nil
}
id := ids[0]
now := h.Now()
a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+
"from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity),
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked",
Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}}
if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil {
return nil, err
}
h.announceExcept(kind, Message{Title: "An unlink of your " + Display(kind) + " account was asked",
Body: fmt.Sprintf("It waits for the account itself (%s, number %s) to approve it within the hour. If you "+
"cannot answer there, you unlink it as root at the router's machine.", id.Display, id.Identity), Urgent: true})
return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil
}
// unlinkNow unlinks one account of a kind: told first, by name, then every channel. It unlinks the account it
// is named, and nothing when another is linked now.
func (h *Holder) unlinkNow(kind, identity, how string, early bool) {
if h.Asks == nil {
return
}
id := h.identity(kind, identity)
if id == nil {
h.Logf("[messenger] the unlink of %s on %s was not done: that account is not linked", identity, kind)
return
}
_ = h.showOn(kind, Message{Title: "This account is no longer linked as you",
Body: "Unlinked, " + how + ". Answers from here no longer count as yours.", Urgent: true, To: id.Identity})
if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil {
h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err)
return
}
h.unlinked(kind, Unlinked{Identity: *id, At: h.Now(), Early: early})
after := "A new link within a day waits for that account's approval, and that account itself can be linked " +
"again only after a day."
if early {
after = "Any account, this one too, can be linked again at once with a code shown on your desk."
}
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
Body: fmt.Sprintf("Unlinked: %s (number %s), %s. %s", id.Display, id.Identity, how, after), Urgent: true})
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how})
}
// linkNow links an account as the operator's: acknowledges at once, approves after the hour.
func (h *Holder) linkNow(id Identity, how string) {
if h.Asks == nil {
return
}
// One account per kind, checked again as it is linked: a link approved, or allowed by root, while
// another account was linked meanwhile is not made (the review of 2026-10-09).
for _, other := range h.identitiesOf(id.Kind) {
if other.Identity != id.Identity {
h.Logf("[messenger] the link of %s on %s was not made: %s is linked there", id.Display, id.Kind, other.Display)
h.announce(Message{Title: "A " + Display(id.Kind) + " link was not made",
Body: fmt.Sprintf("%s (number %s) was not linked, %s: the account %s (number %s) is linked there now. "+
"One account is linked per channel.", id.Display, id.Identity, how, other.Display, other.Identity),
Urgent: true})
return
}
return // already linked
}
now := h.Now()
id.Linked, id.ApproveFrom = now, now.Add(LinkedApproveAfter)
if err := h.Asks.PutIdentity(identityKey(id.Kind, id.Identity), id); err != nil {
h.Logf("[messenger] the link of %s on %s could not be recorded: %v", id.Display, id.Kind, err)
return
}
_ = h.Asks.DeleteLink(id.Kind)
from := id.ApproveFrom.In(h.zone()).Format("15:04")
h.announce(Message{Title: "A " + Display(id.Kind) + " account is now linked as you",
Body: fmt.Sprintf("The %s account %s (number %s) is linked, %s. It can answer acknowledgements now, and "+
"approve from %s. If this was not you, unlink it before then.", Display(id.Kind), id.Display, id.Identity, how, from),
Urgent: true})
h.record(Sent{At: now, Channel: id.Kind, Key: "(link)", What: "linked " + id.Display, Outcome: how})
}
// breakGlass does what root asked at the router's machine: a file `unlink.<kind>` or `allow-link.<kind>` in
// the router's directory, owned by root. Called every minute from Tick, which holds the work: it takes no
// lock of its own (the review of 2026-10-09 found it took the work again, and so hung the router's minute
// the first time root used it). Loud: what root did, or why nothing was done, is said on every channel, and
// every open question of the router's own ends, since root decided in its place.
func (h *Holder) breakGlass() {
if h.StateDir == "" {
return
}
for _, pattern := range []string{"unlink.*", "allow-link.*"} {
files, _ := filepath.Glob(filepath.Join(h.StateDir, pattern))
for _, f := range files {
name := filepath.Base(f)
what, kind, _ := strings.Cut(name, ".")
info, err := os.Lstat(f)
if err != nil {
continue
}
owner := fileOwner(info)
_ = os.Remove(f)
if !info.Mode().IsRegular() || owner != 0 {
h.Logf("[messenger] %s was refused: it is not a file root made", name)
h.announce(Message{Title: "A request to change who is you was refused",
Body: "A file asking it was left at the router's machine by someone other than root. Nothing changed.",
Urgent: true})
continue
}
where := "at the router's machine"
if h.Node != "" {
where = "at " + h.Node
}
done := ""
switch what {
case "unlink":
if ids := h.identitiesOf(kind); len(ids) > 0 {
h.unlinkNow(kind, ids[0].Identity, where+", as root", false)
done = fmt.Sprintf("the %s account %s (number %s) was unlinked", Display(kind), ids[0].Display, ids[0].Identity)
} else {
done = "nothing was unlinked: no " + Display(kind) + " account is linked"
}
case "allow-link":
if p, err := h.Asks.GetLink(kind); err == nil && p != nil && p.Confirmed {
h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed "+where+", as root")
done = fmt.Sprintf("the waiting link of %s (number %s) was allowed", p.Display, p.Identity)
} else {
done = "nothing was linked: no link with its code given waits on " + Display(kind)
}
// And whatever bar an unlink set is lifted: root decided who may be linked.
if h.recentUnlink(kind) != nil {
h.liftBar(kind)
done += "; the bar on linking an account again after an unlink was lifted"
}
}
ended := h.endSelfAsks("root decided " + where)
body := "Root " + where + " asked it: " + done + "."
if ended > 0 {
body += fmt.Sprintf(" %d open question(s) of the router's own were ended.", ended)
}
h.Logf("[messenger] break-glass %s: %s", name, done)
h.announce(Message{Title: "Root changed who is you", Body: body, Urgent: true})
}
}
}
// endSelfAsks ends every open ask of the router's own: root decided in their place.
func (h *Holder) endSelfAsks(why string) int {
h.mu.Lock()
var keys []string
for k, e := range h.asks {
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen {
keys = append(keys, k)
}
}
h.mu.Unlock()
sort.Strings(keys)
n := 0
for _, k := range keys {
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, "", nil); err == nil {
n++
}
}
return n
}
// fileOwner is the uid owning a file; a variable so a test, which is not root, can stand for root.
var fileOwner = func(info os.FileInfo) uint32 {
if st, ok := info.Sys().(*syscall.Stat_t); ok {
return st.Uid
}
return 1
}
// unlinked keeps the last unlink of a kind, a day.
func (h *Holder) unlinked(kind string, u Unlinked) {
h.mu.Lock()
defer h.mu.Unlock()
if h.lastUnlink == nil {
h.lastUnlink = map[string]Unlinked{}
}
h.lastUnlink[kind] = u
if h.Asks != nil {
_ = h.Asks.PutUnlinked(kind, u)
}
}
// liftBar forgets the last unlink of a kind: root's allow-link at the router's machine.
func (h *Holder) liftBar(kind string) {
h.mu.Lock()
delete(h.lastUnlink, kind)
h.mu.Unlock()
if h.Asks != nil {
_ = h.Asks.PutUnlinked(kind, Unlinked{})
}
}
// recentUnlink is the account unlinked deliberately on a kind less than a day ago, if any: an early desk
// unlink bars nothing.
func (h *Holder) recentUnlink(kind string) *Unlinked {
h.mu.Lock()
u, ok := h.lastUnlink[kind]
h.mu.Unlock()
if !ok && h.Asks != nil {
if got, err := h.Asks.GetUnlinked(kind); err == nil && got != nil {
u, ok = *got, true
}
}
if !ok || u.Early || u.At.IsZero() || h.Now().Sub(u.At) >= RelinkWaits {
return nil
}
return &u
}
// announceExcept says a message on every kind that can send but one.
func (h *Holder) announceExcept(kind string, m Message) {
if h.Bench == nil {
return
}
for _, k := range h.Bench.Kinds() {
if k.Kind != kind && h.Bench.Ready(k.Kind) == nil {
_ = h.showOn(k.Kind, m)
}
}
}