messenger: approve only on the controller's recent word that root is free, and close the review's gaps (hq ADR 0259)
The review of 2026-10-09: the router read the absence of an agent-root condition as a pass, and its minute hung on a break-glass file, taking the work it already held. An approval now rests on the controller's verb root-free, asked live for the router's machine and the channel's, failing closed on every other answer, and a stale word on the kinds promises no verified sender. Break-glass runs in the minute, says on every channel what root did, and ends the router's own open questions. An ask offering an approval is answered only where it could be approved; a message says who asks, what each answer does and what expiry does. The router's own asks bind the account they are about; a link is checked against one per kind as it is made; an unlinked account does not come back by a code within a day; a fresh link is unlinked from the desk; no link begins while the desk cannot show its code, and a code is marked secret on every path. Links are bounded per account in the router's state; a warrant's message id cannot be foreseen. Moves to mesh-sdk 76902998 (canonical digests).
This commit is contained in:
@@ -38,17 +38,32 @@ channel's client and no channel's secret.
|
||||
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
|
||||
- **Linking** (`/start` to the bot): a six-digit code is shown, with the account's name and number, on a
|
||||
channel whose words stay private (the desk) for ten minutes; the operator sends it to the bot, and the
|
||||
account is linked. It acknowledges at once and approves after an hour. At most three links are asked of a
|
||||
kind in an hour. Every link and unlink is said on every channel.
|
||||
account is linked. It acknowledges at once and approves after an hour. **The code is shown now or never**:
|
||||
while the desk cannot show it (its notifications paused, nobody there), no link is begun and no code waits
|
||||
in a queue. At most three links an hour per account, and ten per kind, counted in the router's state. Every
|
||||
link and unlink is said on every channel. An account unlinked less than a day ago is not linked again by a
|
||||
code; one account per kind is checked again as a link is made.
|
||||
- **Unlinking is asked of the account that is linked** (`messenger_unlink` asks it): an approve question put
|
||||
to that account alone. Its approval unlinks it, told to it first, by name, then everywhere. **A link within
|
||||
a day of an unlink waits for the replaced account's approval**, asked of it by name.
|
||||
a day of an unlink waits for the replaced account's approval**, asked of it by name. Each unlink or relink
|
||||
question binds the account it is about, so its approval never acts on another account linked meanwhile.
|
||||
**An account still in its first hour**, which cannot approve yet, is unlinked from the desk: when another
|
||||
account sends `/start`, the desk is asked whether to unlink it.
|
||||
- **When that account is lost with its phone**, root at the router's machine leaves a file in this module's
|
||||
state directory — `unlink.<kind>`, or `allow-link.<kind>` for a link that waits — and the router does it,
|
||||
announced as done at the machine. A file not made by root is refused and said.
|
||||
state directory — `unlink.<kind>`, or `allow-link.<kind>` for a link that waits, a regular file owned by
|
||||
uid 0 — and the router does it within the minute, said on every channel with what was done, and every open
|
||||
question of the router's own ends. A file not made by root is refused and said.
|
||||
- **A channel proving its sender says only to the linked account**: none linked, it is not ready, and asks
|
||||
for approval are not offered on it. While the controller says an agent can become root without a person
|
||||
where the router or the channel runs (`agent-root`), no approval is offered or taken there.
|
||||
for approval are not offered on it. **An approval rests on the controller's positive word**, asked of its
|
||||
verb `root-free` for the router's machine and the channel's, live when an answer arrives (a word at most
|
||||
30 s old renders the buttons): only a machine it says is free carries an approval. A router that does not
|
||||
know its machine, a controller that does not answer, a machine its answer leaves out, or a word on the
|
||||
channel kinds older than two minutes all fail closed.
|
||||
- **An ask that offers an approval is answered only where it could be approved**, whichever option is
|
||||
chosen: the desk, which proves nothing of who clicked, is not offered it, and an acknowledgement from a
|
||||
fresh link does not end it. An asker wanting an acknowledgement at the desk asks it alone.
|
||||
- **Each ask's message says who asks** (as the bus names it), what each answer does, and what happens if
|
||||
nobody answers. A warrant is published under a message id nobody can foresee.
|
||||
- **The router runs as its own account `messenger`, on a bus account of its own**, never carried by the
|
||||
machine's runtime (ADR 0259 §8): its state and its bus credential are that account's.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
|
||||
// memAsks is the router's state in memory, with the compare-and-set the bus gives.
|
||||
type memAsks struct {
|
||||
mu sync.Mutex
|
||||
asked map[string][]time.Time
|
||||
asks map[string]AskRecord
|
||||
revs map[string]uint64
|
||||
ids map[string]Identity
|
||||
@@ -27,7 +30,7 @@ type memAsks struct {
|
||||
|
||||
func newMemAsks() *memAsks {
|
||||
return &memAsks{asks: map[string]AskRecord{}, revs: map[string]uint64{}, ids: map[string]Identity{},
|
||||
links: map[string]PendingLink{}, unlinked: map[string]Unlinked{}}
|
||||
links: map[string]PendingLink{}, unlinked: map[string]Unlinked{}, asked: map[string][]time.Time{}}
|
||||
}
|
||||
|
||||
func clone(r AskRecord) AskRecord {
|
||||
@@ -38,6 +41,8 @@ func clone(r AskRecord) AskRecord {
|
||||
}
|
||||
|
||||
func (m *memAsks) GetAsk(k string) (*AskRecord, uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.asks[k]
|
||||
if !ok {
|
||||
return nil, 0, nil
|
||||
@@ -46,6 +51,8 @@ func (m *memAsks) GetAsk(k string) (*AskRecord, uint64, error) {
|
||||
return &c, m.revs[k], nil
|
||||
}
|
||||
func (m *memAsks) CreateAsk(k string, r AskRecord) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.asks[k]; ok {
|
||||
return 0, ErrChanged
|
||||
}
|
||||
@@ -54,6 +61,8 @@ func (m *memAsks) CreateAsk(k string, r AskRecord) (uint64, error) {
|
||||
return m.rev, nil
|
||||
}
|
||||
func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.revs[k] != rev {
|
||||
return 0, ErrChanged
|
||||
}
|
||||
@@ -62,6 +71,8 @@ func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) {
|
||||
return m.rev, nil
|
||||
}
|
||||
func (m *memAsks) AskKeys() ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []string
|
||||
for k := range m.asks {
|
||||
out = append(out, k)
|
||||
@@ -69,15 +80,29 @@ func (m *memAsks) AskKeys() ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
func (m *memAsks) GetIdentity(k string) (*Identity, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
id, ok := m.ids[k]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
func (m *memAsks) PutIdentity(k string, id Identity) error { m.ids[k] = id; return nil }
|
||||
func (m *memAsks) DeleteIdentity(k string) error { delete(m.ids, k); return nil }
|
||||
func (m *memAsks) PutIdentity(k string, id Identity) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.ids[k] = id
|
||||
return nil
|
||||
}
|
||||
func (m *memAsks) DeleteIdentity(k string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.ids, k)
|
||||
return nil
|
||||
}
|
||||
func (m *memAsks) IdentityKeys() ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []string
|
||||
for k := range m.ids {
|
||||
out = append(out, k)
|
||||
@@ -85,22 +110,55 @@ func (m *memAsks) IdentityKeys() ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
func (m *memAsks) GetLink(kind string) (*PendingLink, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
l, ok := m.links[kind]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
func (m *memAsks) PutLink(kind string, l PendingLink) error { m.links[kind] = l; return nil }
|
||||
func (m *memAsks) DeleteLink(kind string) error { delete(m.links, kind); return nil }
|
||||
func (m *memAsks) PutLink(kind string, l PendingLink) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.links[kind] = l
|
||||
return nil
|
||||
}
|
||||
func (m *memAsks) DeleteLink(kind string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.links, kind)
|
||||
return nil
|
||||
}
|
||||
func (m *memAsks) GetUnlinked(kind string) (*Unlinked, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.unlinked[kind]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
func (m *memAsks) PutUnlinked(kind string, u Unlinked) error { m.unlinked[kind] = u; return nil }
|
||||
func (m *memAsks) PutUnlinked(kind string, u Unlinked) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.unlinked[kind] = u
|
||||
return nil
|
||||
}
|
||||
func (m *memAsks) GetLinksAsked(key string) ([]time.Time, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]time.Time(nil), m.asked[key]...), nil
|
||||
}
|
||||
func (m *memAsks) PutLinksAsked(key string, times []time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.asked[key] = append([]time.Time(nil), times...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rootFreeOf is each test router's machines said not root-free, by why.
|
||||
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 }
|
||||
@@ -129,6 +187,18 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA
|
||||
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
|
||||
h.Bench, h.Asks = bench, store
|
||||
h.Node = "anchor"
|
||||
// The controller's word on root, as its verb root-free answers it: free unless a test says otherwise.
|
||||
notFree := map[string]string{}
|
||||
rootFreeOf[h] = notFree
|
||||
h.RootFree = func(machines []string) (map[string]RootVerdict, error) {
|
||||
out := map[string]RootVerdict{}
|
||||
for _, m := range machines {
|
||||
why, no := notFree[m]
|
||||
out[m] = RootVerdict{Machine: m, Free: !no, Why: why}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
h.Zone = func() *time.Location { return time.UTC }
|
||||
h.Publish = func(subject string, body any, id string) error {
|
||||
if wt, ok := body.(asks.Warrant); ok {
|
||||
@@ -141,13 +211,15 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA
|
||||
|
||||
// stand keeps the channels' word fresh as the clock moves.
|
||||
func stand(h *Holder, c *clock) {
|
||||
// The controller's word on the kinds, read again as the router does every half minute.
|
||||
h.Bench.SetKinds(h.Bench.Kinds())
|
||||
h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
h.Bench.Stand("desktop", Standing{Ready: true, At: c.now()})
|
||||
}
|
||||
|
||||
// bound is what an option of the held delivery's ask binds: the asker's act, digested.
|
||||
func bound(verb string) string {
|
||||
d, err := asks.ActDigest(map[string]string{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"})
|
||||
d, err := asks.ActDigest(asks.Act{"verb": "mesh-delivery." + verb, "id": "novox/hq@0555"})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -200,6 +272,17 @@ func labelsOf(m Message) string {
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// ackAsk is an ask of acknowledgements alone: silencing the held delivery.
|
||||
func ackAsk(c *clock, id string) asks.Ask {
|
||||
a := heldAsk(c, id)
|
||||
a.Options = a.Options[2:]
|
||||
a.Explanation = "Needs you: silence it, or leave it. A delivery of hq has been held for 36 hours."
|
||||
return a
|
||||
}
|
||||
|
||||
// An ask that offers an approval is answered only where it could be approved, whichever option is chosen (the
|
||||
// review of 2026-10-09): the desk, which proves nothing of who clicked, is offered none of it. An ask of
|
||||
// acknowledgements alone is offered on every kind that carries a choice.
|
||||
func TestAnAskIsOfferedOnEachKindAsFarAsItCanCarryIt(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
@@ -207,26 +290,27 @@ func TestAnAskIsOfferedOnEachKindAsFarAsItCanCarryIt(t *testing.T) {
|
||||
a.Urgent = true
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
settle(h, c)
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 0 {
|
||||
t.Fatalf("telegram %d, desk %d", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
if got := labelsOf(tg.sends[0]); got != "Release, Stop, Silence for a week" {
|
||||
t.Errorf("telegram offers %q", got)
|
||||
}
|
||||
// The desk proves nothing about who clicked: acknowledgements only, and the words say where the rest go.
|
||||
if got := labelsOf(dt.sends[0]); got != "Silence for a week" {
|
||||
t.Errorf("the desk offers %q", got)
|
||||
}
|
||||
if !strings.Contains(dt.sends[0].Body, "Release or Stop: answer on Telegram, or from the mesh MCP server.") {
|
||||
t.Errorf("the desk does not say where to answer: %q", dt.sends[0].Body)
|
||||
}
|
||||
if rec := store.asks["mesh-delivery.d-1"]; rec.State != StateOpen || len(rec.Tickets) != 4 {
|
||||
if rec := store.asks["mesh-delivery.d-1"]; rec.State != StateOpen || len(rec.Tickets) != 3 {
|
||||
t.Errorf("recorded %+v", rec)
|
||||
}
|
||||
b := ackAsk(c, "d-2")
|
||||
b.Urgent = true
|
||||
b.About = "other"
|
||||
submit(t, h, "mesh-delivery", b)
|
||||
settle(h, c)
|
||||
if len(dt.sends) != 1 || labelsOf(dt.sends[0]) != "Silence for a week" {
|
||||
t.Fatalf("the desk was not offered an acknowledgement: %+v", dt.sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) {
|
||||
h, tg, dt, c, _, _ := newRouter(t)
|
||||
h, tg, dt, c, _, w := newRouter(t)
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
// Telegram says only to the account linked as the operator: none linked, it is not used at all.
|
||||
@@ -236,17 +320,18 @@ func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) {
|
||||
if err := h.Bench.Ready("telegram"); err == nil || !strings.Contains(err.Error(), "send /start to the bot") {
|
||||
t.Errorf("an unlinked Telegram is ready: %v", err)
|
||||
}
|
||||
if len(dt.sends) == 0 {
|
||||
t.Fatal("nothing was said on the desk")
|
||||
// The ask offers an approval no channel can carry: refused to its asker, in words.
|
||||
if got := w.of("mesh-delivery"); len(got) != 1 || got[0].Outcome != asks.OutcomeRefused ||
|
||||
!strings.Contains(got[0].Words, "no channel can carry") {
|
||||
t.Fatalf("not refused: %+v", got)
|
||||
}
|
||||
m := dt.sends[0]
|
||||
if got := labelsOf(m); got != "Silence for a week" || !strings.Contains(m.Body, "Release or Stop: from the mesh MCP server.") {
|
||||
t.Errorf("the desk without a link offers %q and says %q", got, m.Body)
|
||||
if len(dt.sends) != 0 {
|
||||
t.Errorf("the desk was offered an ask with an approval: %+v", dt.sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
|
||||
h, tg, dt, c, store, w := newRouter(t)
|
||||
h, tg, _, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
asked := heldAsk(c, "d-1")
|
||||
submit(t, h, "mesh-delivery", asked)
|
||||
@@ -275,7 +360,7 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
|
||||
t.Errorf("the record: %+v", rec)
|
||||
}
|
||||
// Every copy says how it ended, its buttons gone.
|
||||
for _, ch := range []*fakeChannel{tg, dt} {
|
||||
for _, ch := range []*fakeChannel{tg} {
|
||||
var edited Message
|
||||
for _, m := range ch.edits {
|
||||
edited = m
|
||||
@@ -316,7 +401,7 @@ func TestEveryAnswerThatIsNotTheOperatorsIsRefusedAndSaid(t *testing.T) {
|
||||
}{
|
||||
"an account not linked": {"telegram", "666", true, 2 * time.Hour, "Release", 0, "not linked as the operator"},
|
||||
"a sender the service did not say": {"telegram", "42", false, 2 * time.Hour, "Release", 0, "did not authenticate"},
|
||||
"a link younger than an hour": {"telegram", "42", true, 59 * time.Minute, "Release", 0, "can approve from"},
|
||||
"a link younger than an hour": {"telegram", "42", true, 59 * time.Minute, "Release", 0, "can answer this question from"},
|
||||
"an ask that expired": {"telegram", "42", true, 2 * time.Hour, "Release", 3 * time.Hour, "expired"},
|
||||
"a ticket shown on another kind": {"desktop", "g14", true, 2 * time.Hour, "Release", 0, "not offered here"},
|
||||
} {
|
||||
@@ -373,10 +458,10 @@ func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) {
|
||||
if out, err := h.Chosen("telegram", late); err != nil || out != "chosen" {
|
||||
t.Fatalf("refused at 60 minutes: %s %v", out, err)
|
||||
}
|
||||
// And an acknowledgement is taken at once from a fresh link.
|
||||
// An acknowledgement is taken at once from a fresh link, on an ask of acknowledgements alone.
|
||||
h2, tg2, _, c2, store2, w2 := newRouter(t)
|
||||
link(h2, store2, c2, "42", c2.now())
|
||||
submit(t, h2, "mesh-delivery", heldAsk(c2, "d-2"))
|
||||
submit(t, h2, "mesh-delivery", ackAsk(c2, "d-2"))
|
||||
settle(h2, c2)
|
||||
silence = ticketFor(tg2.sends[0], "Silence for a week")
|
||||
if out, err := h2.Chosen("telegram", choiceOf(silence, "42", c2.now())); err != nil || out != "chosen" {
|
||||
@@ -388,9 +473,40 @@ func TestALinkApprovesAfterAnHourAndAcknowledgesAtOnce(t *testing.T) {
|
||||
_ = w
|
||||
}
|
||||
|
||||
// An acknowledgement does not end an ask that waits for an approval from where an approval is not proven
|
||||
// (the review of 2026-10-09): a fresh link's Silence on the held delivery's ask is refused, and the ask stays
|
||||
// open for Release or Stop once the hour has passed.
|
||||
func TestAnAcknowledgementDoesNotEndAnAskThatWaitsForAnApproval(t *testing.T) {
|
||||
h, tg, _, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now())
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
silence := ticketFor(tg.sends[0], "Silence for a week")
|
||||
if out, err := h.Chosen("telegram", choiceOf(silence, "42", c.now())); err == nil || out != "refused" {
|
||||
t.Fatalf("a fresh link's acknowledgement ended an ask that offers an approval: %s %v", out, err)
|
||||
}
|
||||
if len(w.of("mesh-delivery")) != 0 || store.asks["mesh-delivery.d-1"].State != StateOpen {
|
||||
t.Fatal("the ask ended")
|
||||
}
|
||||
// Nor while the controller does not say root is free there.
|
||||
c.pass(time.Hour)
|
||||
stand(h, c)
|
||||
rootFreeOf[h]["anchor"] = "not measured"
|
||||
again := choiceOf(silence, "42", c.now())
|
||||
again.ID = "again"
|
||||
if out, _ := h.Chosen("telegram", again); out != "refused" || store.asks["mesh-delivery.d-1"].State != StateOpen {
|
||||
t.Fatalf("an acknowledgement ended the ask while root is not free: %s", out)
|
||||
}
|
||||
delete(rootFreeOf[h], "anchor")
|
||||
release := choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())
|
||||
if out, err := h.Chosen("telegram", release); err != nil || out != "chosen" {
|
||||
t.Fatalf("the approval after the hour: %s %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) {
|
||||
h, _, dt, c, _, w := newRouter(t)
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
submit(t, h, "mesh-delivery", ackAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
silence := ticketFor(dt.sends[0], "Silence for a week")
|
||||
ch := choiceOf(silence, "g14", c.now())
|
||||
@@ -415,9 +531,20 @@ func TestTheDeskAcknowledgesAndNeverApproves(t *testing.T) {
|
||||
store2.asks["mesh-delivery.d-1"] = rec
|
||||
h2.keepAsk("mesh-delivery.d-1", rec, store2.revs["mesh-delivery.d-1"])
|
||||
if out, err := h2.Chosen("desktop", choiceOf(release, "g14", c2.now())); err == nil || out != "refused" ||
|
||||
!strings.Contains(err.Error(), "can only acknowledge") {
|
||||
!strings.Contains(err.Error(), "cannot answer a question that asks for an approval") {
|
||||
t.Errorf("the desk approved: %s %v", out, err)
|
||||
}
|
||||
// Nor does an acknowledgement forged onto the desk end that ask.
|
||||
silence = ticketFor(tg2.sends[0], "Silence for a week")
|
||||
tk = rec.Tickets[silence]
|
||||
tk.Kind = "desktop"
|
||||
rec.Tickets[silence] = tk
|
||||
store2.asks["mesh-delivery.d-1"] = rec
|
||||
h2.keepAsk("mesh-delivery.d-1", rec, store2.revs["mesh-delivery.d-1"])
|
||||
if out, _ := h2.Chosen("desktop", choiceOf(silence, "g14", c2.now())); out != "refused" ||
|
||||
store2.asks["mesh-delivery.d-1"].State != StateOpen {
|
||||
t.Errorf("a desk click ended an ask that waits for an approval: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
|
||||
@@ -429,7 +556,7 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
|
||||
carrying.Explanation = "Needs you: see /etc/passwd on the machine."
|
||||
submit(t, h, "mesh-delivery", carrying)
|
||||
for i := 3; i <= 6; i++ {
|
||||
a := heldAsk(c, "d-"+string(rune('0'+i)))
|
||||
a := ackAsk(c, "d-"+string(rune('0'+i)))
|
||||
a.About = "about-" + string(rune('0'+i))
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
}
|
||||
@@ -483,10 +610,10 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
|
||||
|
||||
func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T) {
|
||||
h, _, _, c, store, w := newRouter(t)
|
||||
a := heldAsk(c, "d-1")
|
||||
a := ackAsk(c, "d-1")
|
||||
a.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", a)
|
||||
b := heldAsk(c, "d-2")
|
||||
b := ackAsk(c, "d-2")
|
||||
b.About = "delivery.hq"
|
||||
submit(t, h, "mesh-delivery", b)
|
||||
if store.asks["mesh-delivery.d-1"].State != string(asks.OutcomeReplaced) {
|
||||
@@ -500,7 +627,7 @@ func TestANewerAskAboutTheSameThingReplacesTheOlderAndACancelEndsIt(t *testing.T
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
// Another asker cannot cancel it: its cancel names its own ask, under its own name.
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-3"))
|
||||
submit(t, h, "mesh-delivery", ackAsk(c, "d-3"))
|
||||
_ = h.TakeAsk("cancel", "mesh-controller", json.RawMessage(`{"id":"d-3"}`))
|
||||
if store.asks["mesh-delivery.d-3"].State != StateOpen {
|
||||
t.Error("another asker's cancel ended it")
|
||||
@@ -514,7 +641,7 @@ func TestAnAskAboutAConditionIsAnsweredOnItsMessage(t *testing.T) {
|
||||
k.Raised = c.now()
|
||||
h.Condition(EventRaised, k)
|
||||
settle(h, c)
|
||||
a := heldAsk(c, "d-1")
|
||||
a := ackAsk(c, "d-1")
|
||||
a.About = "delivery.hq.held"
|
||||
submit(t, h, "mesh-controller", a)
|
||||
settle(h, c)
|
||||
@@ -703,29 +830,61 @@ func TestARelinkWithinADayWaitsForTheReplacedAccount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Root at the router's machine unlinks, or allows a waiting link, by a file only root makes.
|
||||
func TestBreakGlassIsAFileRootMade(t *testing.T) {
|
||||
h, _, dt, c, store, _ := newRouter(t)
|
||||
// Root at the router's machine unlinks, or allows a waiting link, by a file only root makes. It acts from the
|
||||
// router's minute (Tick), which holds the work: the review of 2026-10-09 found the minute took the work again
|
||||
// there and hung for ever the first time root used it. It is said on every channel, and the router's own open
|
||||
// questions end.
|
||||
func TestBreakGlassIsAFileRootMadeAndTheMinuteGoesOn(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
h.StateDir = t.TempDir()
|
||||
tick := func() {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() { h.Tick(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the router's minute hung on a break-glass file")
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(h.StateDir, "unlink.telegram"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.BreakGlass()
|
||||
tick()
|
||||
if len(store.ids) != 1 || !strings.Contains(dt.sends[len(dt.sends)-1].Title, "was refused") {
|
||||
t.Fatalf("a file not made by root unlinked: %v", store.ids)
|
||||
}
|
||||
// An open question of the router's own: root's act ends it.
|
||||
if _, err := h.Unlink("telegram"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was := fileOwner
|
||||
t.Cleanup(func() { fileOwner = was })
|
||||
fileOwner = func(os.FileInfo) uint32 { return 0 }
|
||||
_ = os.WriteFile(filepath.Join(h.StateDir, "unlink.telegram"), nil, 0o600)
|
||||
h.BreakGlass()
|
||||
tick()
|
||||
if len(store.ids) != 0 {
|
||||
t.Fatal("root's file did not unlink")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(h.StateDir, "unlink.telegram")); err == nil {
|
||||
t.Error("the file was left to act again")
|
||||
}
|
||||
for _, ch := range []*fakeChannel{tg, dt} {
|
||||
said := false
|
||||
for _, m := range ch.sends {
|
||||
said = said || (m.Title == "Urgent: Root changed who is you" || strings.Contains(m.Title, "Root changed who is you")) &&
|
||||
strings.Contains(m.Body, "was unlinked") && strings.Contains(m.Body, "1 open question")
|
||||
}
|
||||
if !said && ch == dt {
|
||||
t.Errorf("%s was not told what root did: %+v", ch.name, ch.sends[len(ch.sends)-1])
|
||||
}
|
||||
}
|
||||
for k, rec := range store.asks {
|
||||
if rec.Asker == SelfAsker && rec.State == StateOpen {
|
||||
t.Errorf("the router's own question %s is still open after root decided", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// While an agent can become root where the router or the channel runs, nothing proven there approves.
|
||||
@@ -736,12 +895,9 @@ func TestNoApprovalWhileAnAgentCanBecomeRootThere(t *testing.T) {
|
||||
submit(t, h, "mesh-delivery", asked)
|
||||
settle(h, c)
|
||||
release := ticketFor(tg.sends[0], "Release")
|
||||
h.mu.Lock()
|
||||
h.open["machine.anchor.agent-root"] = &Record{Key: "machine.anchor.agent-root", Kind: KindAgentRoot, Severity: Urgent,
|
||||
Origin: "condition", Raised: c.now()}
|
||||
h.mu.Unlock()
|
||||
rootFreeOf[h]["anchor"] = "the agent account is not judged"
|
||||
if out, err := h.Chosen("telegram", choiceOf(release, "42", c.now())); err == nil || out != "refused" ||
|
||||
!strings.Contains(err.Error(), "can become root on anchor") {
|
||||
!strings.Contains(err.Error(), "may become root on anchor") {
|
||||
t.Fatalf("approved while an agent can become root there: %s %v", out, err)
|
||||
}
|
||||
if len(w.of("mesh-delivery")) != 0 {
|
||||
@@ -840,9 +996,8 @@ func codeOf(t *testing.T, m Message) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// An ask's message goes alone even when only one of its answers can be given on a channel: folded into a burst's
|
||||
// digest, its one button was lost (found by the lab's proof, mesh-lab asks/, with approval held by an open
|
||||
// agent-root condition and that condition's message in the same burst).
|
||||
// An ask's message goes alone even when it offers one answer: folded into a burst's digest, its one button was
|
||||
// lost (found by the lab's proof, mesh-lab asks/, with a condition's message in the same burst).
|
||||
func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
@@ -850,15 +1005,13 @@ func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) {
|
||||
// Something was just said on the channel, so what follows waits for the burst.
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-0"))
|
||||
settle(h, c)
|
||||
h.mu.Lock()
|
||||
h.open["machine.anchor.agent-root"] = &Record{Key: "machine.anchor.agent-root", Kind: KindAgentRoot, Severity: Urgent,
|
||||
Origin: "condition", Raised: c.now()}
|
||||
h.mu.Unlock()
|
||||
k := cond("machine.anchor.disk", Urgent, "the disk on anchor is full")
|
||||
k.Raised = c.now()
|
||||
h.Condition(EventRaised, k)
|
||||
before := len(tg.sends)
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
one := heldAsk(c, "d-1")
|
||||
one.Options = one.Options[2:] // Silence alone: one answer
|
||||
submit(t, h, "mesh-delivery", one)
|
||||
settle(h, c)
|
||||
var asked *Message
|
||||
for i := range tg.sends[before:] {
|
||||
@@ -870,7 +1023,7 @@ func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) {
|
||||
if asked == nil {
|
||||
t.Fatalf("the ask's one answer was not offered on Telegram: %+v", tg.sends[before:])
|
||||
}
|
||||
if ticketFor(*asked, "Release") != "" {
|
||||
t.Errorf("an approval was offered while an agent can become root there: %q", labelsOf(*asked))
|
||||
if strings.Contains(asked.Body, "disk on anchor") {
|
||||
t.Errorf("the ask's message was folded with another: %q", asked.Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ type AskStore interface {
|
||||
DeleteLink(kind string) error
|
||||
GetUnlinked(kind string) (*Unlinked, error)
|
||||
PutUnlinked(kind string, u Unlinked) error
|
||||
// GetLinksAsked and PutLinksAsked keep when links were asked, by account and by kind (allowLink).
|
||||
GetLinksAsked(key string) ([]time.Time, error)
|
||||
PutLinksAsked(key string, times []time.Time) error
|
||||
}
|
||||
|
||||
type askEntry struct {
|
||||
@@ -161,7 +164,8 @@ func (h *Holder) openAsk(r *Record) *AskRecord {
|
||||
}
|
||||
|
||||
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
|
||||
// approve verifying their sender with an account of the operator's linked.
|
||||
// approve verifying their sender with an account of the operator's linked, where the controller says now
|
||||
// that no agent can become root (rootFree, asked live).
|
||||
func (h *Holder) carriers(level asks.Level) []string {
|
||||
if h.Bench == nil {
|
||||
return nil
|
||||
@@ -176,7 +180,7 @@ func (h *Holder) carriers(level asks.Level) []string {
|
||||
case asks.Acknowledge:
|
||||
out = append(out, k.Kind)
|
||||
case asks.Approve:
|
||||
if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFreeFor(k) == "" {
|
||||
if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFree(k, false) == "" {
|
||||
out = append(out, k.Kind)
|
||||
}
|
||||
}
|
||||
@@ -185,32 +189,155 @@ func (h *Holder) carriers(level asks.Level) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// rootFreeFor is why a kind's proof is not believed now, or "": an agent can become root without a person
|
||||
// where the router or the kind's holder runs, which the controller's self-check says as an open condition
|
||||
// (novox/hq ADR 0259 §8, rule 2).
|
||||
func (h *Holder) rootFreeFor(k KindInfo) string {
|
||||
// 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.
|
||||
type RootVerdict struct {
|
||||
Machine string `json:"machine"`
|
||||
Free bool `json:"free"`
|
||||
Why string `json:"why"`
|
||||
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"
|
||||
|
||||
// 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.
|
||||
const (
|
||||
RootFreeWithin = 10 * time.Second
|
||||
RootFreeKept = 30 * time.Second
|
||||
)
|
||||
|
||||
// askRootFree asks the controller about machines, failing on no answer in time.
|
||||
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")
|
||||
}
|
||||
type answer struct {
|
||||
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)
|
||||
}
|
||||
out := map[string]RootVerdict{}
|
||||
for _, v := range body.Machines {
|
||||
out[v.Machine] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rootFree is why a kind's proof is not believed now, or "" when the controller says, now, that no agent can
|
||||
// become root without a person on the router's machine and on the kind's (novox/hq ADR 0259 §8, rule 2, as
|
||||
// the review of 2026-10-09 asked: a positive, recent pass, never the absence of a condition). Anything else
|
||||
// fails: a router that does not know its machine, a kind whose machine is not said, a controller that does
|
||||
// not answer, a machine missing from its answer, a word that is not free. cached allows a word up to
|
||||
// RootFreeKept old, for rendering; an answer is always judged with cached false.
|
||||
func (h *Holder) rootFree(k KindInfo, cached bool) string {
|
||||
if h.Node == "" {
|
||||
return "this router does not know which machine it runs on, so it cannot ask whether an agent can become root there"
|
||||
}
|
||||
if k.Node == "" {
|
||||
return "the mesh has not said which machine " + Display(k.Kind) + " runs on"
|
||||
}
|
||||
machines := []string{h.Node}
|
||||
if k.Node != h.Node {
|
||||
machines = append(machines, k.Node)
|
||||
}
|
||||
now := h.Now()
|
||||
key := strings.Join(machines, ",")
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, r := range h.open {
|
||||
if r.Kind != KindAgentRoot || !r.Cleared.IsZero() {
|
||||
continue
|
||||
kept, ok := h.rootWord[key]
|
||||
h.mu.Unlock()
|
||||
var verdicts map[string]RootVerdict
|
||||
if cached && ok && now.Sub(kept.at) < RootFreeKept && now.Sub(kept.at) >= 0 {
|
||||
verdicts = kept.verdicts
|
||||
} else {
|
||||
got, err := h.askRootFree(machines)
|
||||
if err != nil {
|
||||
return "whether an agent can become root where it runs could not be asked: " + err.Error()
|
||||
}
|
||||
machine := strings.TrimPrefix(strings.TrimSuffix(r.Key, "."+KindAgentRoot), "machine.")
|
||||
if machine == k.Node || (h.Node != "" && machine == h.Node) {
|
||||
return "a program working for you can become root on " + machine + " without asking you, so an answer " +
|
||||
"proven there proves nothing until that changes"
|
||||
verdicts = got
|
||||
h.mu.Lock()
|
||||
if h.rootWord == nil {
|
||||
h.rootWord = map[string]rootWord{}
|
||||
}
|
||||
h.rootWord[key] = rootWord{at: now, verdicts: got}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
for _, m := range machines {
|
||||
v, said := verdicts[m]
|
||||
switch {
|
||||
case !said:
|
||||
return "the controller said nothing of whether an agent can become root on " + m
|
||||
case !v.Free:
|
||||
why := v.Why
|
||||
if why == "" {
|
||||
why = "it is not measured that it cannot"
|
||||
}
|
||||
return "a program working for you may become root on " + m + " without asking you (" + why +
|
||||
"), so an answer proven there proves nothing until that changes"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rootWord is the controller's last word on a set of machines, and when it was asked.
|
||||
type rootWord struct {
|
||||
at time.Time
|
||||
verdicts map[string]RootVerdict
|
||||
}
|
||||
|
||||
// approveHolds is why an answer on a kind could not approve now, or "": the kind verifies its sender, an
|
||||
// account is linked there, and the controller says no agent can become root where it runs.
|
||||
func (h *Holder) approveHolds(kind string, cached bool) string {
|
||||
info, held := h.Bench.Kind(kind)
|
||||
switch {
|
||||
case !held:
|
||||
return "this channel is not one the mesh holds for answers"
|
||||
case !h.Bench.Promises(kind, "verified-sender"):
|
||||
return Display(kind) + " cannot prove who answered"
|
||||
case !h.linked(kind):
|
||||
return "no " + Display(kind) + " account is linked as you"
|
||||
}
|
||||
return h.rootFree(info, cached)
|
||||
}
|
||||
|
||||
// answerable is whether an option of an ask is answered on a kind, by the ask's highest level: an ask that
|
||||
// offers an approval is answered only where it could be approved, whichever of its options is chosen, so an
|
||||
// acknowledgement from where approval is not proven never ends a question that waits for one (the review of
|
||||
// 2026-10-09).
|
||||
func answerLevel(a asks.Ask) asks.Level {
|
||||
if a.Highest().Rank() > asks.Acknowledge.Rank() {
|
||||
return a.Highest()
|
||||
}
|
||||
return asks.Acknowledge
|
||||
}
|
||||
|
||||
// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which
|
||||
// raises the conditions.
|
||||
const ConditionOwner = "mesh-controller"
|
||||
|
||||
// KindAgentRoot is the controller's condition while an agent can become root without a person on a machine.
|
||||
const KindAgentRoot = "agent-root"
|
||||
|
||||
// TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus
|
||||
// grants that module alone. An error is returned only when the ask could not be recorded, so the queue
|
||||
// offers it again; a refusal is said to the asker and the work is taken.
|
||||
@@ -274,8 +401,10 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
rec := AskRecord{Asker: asker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{}}
|
||||
for _, o := range a.Options {
|
||||
for _, kind := range h.carriers(o.Level) {
|
||||
// Every option on the kinds that carry the ask's highest level: an ask with an approval is answered only
|
||||
// where it could be approved (answerLevel).
|
||||
for _, kind := range h.carriers(answerLevel(a)) {
|
||||
for _, o := range a.Options {
|
||||
rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: kind}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +529,10 @@ func (h *Holder) tell(w asks.Warrant) error {
|
||||
if h.Publish == nil {
|
||||
return errors.New("this router cannot reach the bus")
|
||||
}
|
||||
err := h.Publish(asks.DecidedSubject(w.Asker), w, askKey(w.Asker, w.Ask)+"."+string(w.Outcome))
|
||||
// A new message id each time (the review of 2026-10-09): an id anyone could foresee lets a publisher on
|
||||
// the same stream take it first, and the bus would drop the warrant as a duplicate. The asker acts on its
|
||||
// own record of the ask once, however many times it hears a warrant.
|
||||
err := h.Publish(asks.DecidedSubject(w.Asker), w, "warrant."+ticket())
|
||||
if err != nil {
|
||||
h.Logf("[messenger] could not tell %s how its ask %s ended (%v); told again in a minute", w.Asker, w.Ask, err)
|
||||
}
|
||||
@@ -547,6 +679,16 @@ func (h *Holder) sweepAsks(now time.Time) {
|
||||
// offers are an open ask's buttons on one kind: each option offered there, by its ticket.
|
||||
func (h *Holder) offers(rec *AskRecord, kind string) []Choice {
|
||||
var out []Choice
|
||||
if answerLevel(rec.Ask) != asks.Acknowledge && rec.Asker != SelfAsker {
|
||||
// Rendered only while approval holds there now, by the controller's recent word.
|
||||
if h.approveHolds(kind, true) != "" {
|
||||
return nil
|
||||
}
|
||||
} else if rec.Asker == SelfAsker && answerLevel(rec.Ask) != asks.Acknowledge {
|
||||
if info, held := h.Bench.Kind(kind); !held || h.rootFree(info, true) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, o := range rec.Ask.Options {
|
||||
for t, tk := range rec.Tickets {
|
||||
if tk.Kind == kind && tk.Option == o.ID && !tk.Used {
|
||||
@@ -657,23 +799,36 @@ func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) {
|
||||
}
|
||||
person.Verified = "user id verified"
|
||||
} else {
|
||||
if rec.Only != nil && rec.Only.Kind != kind {
|
||||
return h.refuseChoice(kind, c, "this question was put to another channel")
|
||||
}
|
||||
person.Verified = "a desk click: whoever was at the operator's session on " + c.Sender.Identity
|
||||
}
|
||||
// Judged at the ask's highest level, whichever option was chosen (answerLevel): an acknowledgement on an
|
||||
// ask that offers an approval needs what the approval needs, so it never ends that ask from where an
|
||||
// approval is not proven.
|
||||
level := o.Level
|
||||
if answerLevel(rec.Ask).Rank() > level.Rank() {
|
||||
level = answerLevel(rec.Ask)
|
||||
}
|
||||
proofs := []string{}
|
||||
switch o.Level {
|
||||
switch level {
|
||||
case asks.Acknowledge:
|
||||
case asks.Approve:
|
||||
if !verifies {
|
||||
return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it can only acknowledge")
|
||||
return h.refuseChoice(kind, c, Display(kind)+" cannot prove who answered, so it cannot answer a question "+
|
||||
"that asks for an approval")
|
||||
}
|
||||
if now.Before(id.ApproveFrom) {
|
||||
return h.refuseChoice(kind, c, "this account was linked at "+id.Linked.In(h.zone()).Format("15:04")+
|
||||
" and can approve from "+id.ApproveFrom.In(h.zone()).Format("15:04"))
|
||||
" and can answer this question from "+id.ApproveFrom.In(h.zone()).Format("15:04"))
|
||||
}
|
||||
if why := h.rootFreeFor(info); why != "" {
|
||||
if why := h.rootFree(info, false); why != "" {
|
||||
return h.refuseChoice(kind, c, why)
|
||||
}
|
||||
proofs = append(proofs, "P1")
|
||||
if o.Level == asks.Approve {
|
||||
proofs = append(proofs, "P1")
|
||||
}
|
||||
default:
|
||||
return h.refuseChoice(kind, c, "it needs a code from the authenticator app, which the mesh does not take yet")
|
||||
}
|
||||
@@ -731,7 +886,8 @@ func (h *Holder) showOnHandle(kind string, m Message) (string, error) {
|
||||
if h.Publish == nil {
|
||||
return "", errors.New("this router cannot reach the bus")
|
||||
}
|
||||
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To}
|
||||
out := asks.Message{Handle: newHandle(), Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply, To: m.To,
|
||||
Secret: m.Secret}
|
||||
if out.To == "" && h.Bench != nil && h.Bench.To != nil {
|
||||
out.To = h.Bench.To(kind)
|
||||
}
|
||||
|
||||
@@ -244,6 +244,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 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
|
||||
|
||||
@@ -279,9 +281,10 @@ type Holder struct {
|
||||
// asks are the asks this router holds, by `<asker>.<id>`, and tickets each ask's tickets to it.
|
||||
asks map[string]*askEntry
|
||||
tickets map[string]string
|
||||
// linksAsked are when links were asked, by kind, for the hourly bound; lastUnlink the last unlink of each.
|
||||
linksAsked map[string][]time.Time
|
||||
// lastUnlink is the last unlink of each kind.
|
||||
lastUnlink map[string]Unlinked
|
||||
// rootWord is the controller's last word on whether an agent can become root, by the machines asked.
|
||||
rootWord map[string]rootWord
|
||||
}
|
||||
|
||||
func (h *Holder) init() {
|
||||
@@ -563,7 +566,7 @@ func (h *Holder) Tick() {
|
||||
h.mu.Unlock()
|
||||
sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) })
|
||||
h.sweepAsks(now)
|
||||
h.BreakGlass()
|
||||
h.breakGlass()
|
||||
for _, r := range recs {
|
||||
switch {
|
||||
case !r.Cleared.IsZero():
|
||||
@@ -640,6 +643,13 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
||||
return m
|
||||
}
|
||||
if rec := h.openAsk(r); rec != nil {
|
||||
// Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does,
|
||||
// and what happens if nobody answers (the review of 2026-10-09).
|
||||
for _, line := range h.askLines(rec) {
|
||||
if _, ok := Check(line); ok {
|
||||
m.Body = strings.TrimSpace(m.Body + "\n" + line)
|
||||
}
|
||||
}
|
||||
if line := h.elsewhere(rec, kind); line != "" {
|
||||
if _, ok := Check(line); ok {
|
||||
m.Body = strings.TrimSpace(m.Body + "\n" + line)
|
||||
@@ -650,6 +660,24 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
||||
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
|
||||
if asker == SelfAsker {
|
||||
asker = "the router itself"
|
||||
}
|
||||
lines := []string{"Asked by " + asker + "."}
|
||||
for _, o := range rec.Ask.Options {
|
||||
if o.Does != "" {
|
||||
lines = append(lines, o.Label+": "+o.Does+".")
|
||||
}
|
||||
}
|
||||
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
|
||||
lines = append(lines, "If nobody answers by "+rec.Ask.Expires.In(h.zone()).Format("15:04")+": "+rec.Ask.OnExpiry+".")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// words is a record's message under the content rule: refused, it is composed again with less of it,
|
||||
// until what remains may leave. The refusal is recorded and stated once per record.
|
||||
func (h *Holder) words(r *Record, what string) Message {
|
||||
|
||||
@@ -95,8 +95,93 @@ func (h *Holder) identitiesOf(kind string) []Identity {
|
||||
|
||||
func (h *Holder) linked(kind string) bool { return len(h.identitiesOf(kind)) > 0 }
|
||||
|
||||
// LinksAnHour is how many links may be asked of one kind in an hour.
|
||||
const LinksAnHour = 3
|
||||
// LinksAnHour is how many links one account may ask in an hour, and KindLinksAnHour how many all accounts
|
||||
// together may ask of one kind: kept in the router's state, so a restart forgets neither (the review of
|
||||
// 2026-10-09). Per account, so a stranger's tries do not stop the operator's.
|
||||
const (
|
||||
LinksAnHour = 3
|
||||
KindLinksAnHour = 10
|
||||
)
|
||||
|
||||
// allowLink counts a link asked by an account on a kind, and says why not when either bound is reached, or
|
||||
// the count cannot be kept: an uncounted link is not begun.
|
||||
func (h *Holder) allowLink(kind, identity string, now time.Time) string {
|
||||
for _, b := range []struct {
|
||||
key string
|
||||
most int
|
||||
words string
|
||||
}{
|
||||
{"asked." + kind + "." + kvKey(identity), LinksAnHour, fmt.Sprintf("This account asked to be linked %d times this hour. Try again in an hour.", LinksAnHour)},
|
||||
{"asked." + kind, KindLinksAnHour, fmt.Sprintf("Links were asked %d times this hour on %s. Try again in an hour.", KindLinksAnHour, Display(kind))},
|
||||
} {
|
||||
times, err := h.Asks.GetLinksAsked(b.key)
|
||||
if err != nil {
|
||||
return "How often links were asked cannot be read, so none is begun: " + err.Error()
|
||||
}
|
||||
var recent []time.Time
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < time.Hour && now.Sub(t) >= 0 {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if len(recent) >= b.most {
|
||||
return b.words
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"asked." + kind + "." + kvKey(identity), "asked." + kind} {
|
||||
times, _ := h.Asks.GetLinksAsked(key)
|
||||
var recent []time.Time
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < time.Hour && now.Sub(t) >= 0 {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if err := h.Asks.PutLinksAsked(key, append(recent, now)); err != nil {
|
||||
return "How often links were asked cannot be kept, so none is begun: " + err.Error()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// offerDeskUnlink asks at the desk whether an account still in its first hour is unlinked: it cannot approve
|
||||
// yet, so whoever is at the desk may undo it (ADR 0259 §7, the review of 2026-10-09). One such question per
|
||||
// account at a time. Answers whether it was asked.
|
||||
func (h *Holder) offerDeskUnlink(id Identity) bool {
|
||||
desk := ""
|
||||
for _, k := range h.Bench.Kinds() {
|
||||
if k.Kind != id.Kind && h.Bench.Promises(k.Kind, "private") && !h.Bench.Promises(k.Kind, "verified-sender") &&
|
||||
h.Bench.Ready(k.Kind) == nil {
|
||||
desk = k.Kind
|
||||
break
|
||||
}
|
||||
}
|
||||
if desk == "" {
|
||||
return false
|
||||
}
|
||||
h.mu.Lock()
|
||||
for _, e := range h.asks {
|
||||
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen && e.rec.Act != nil && e.rec.Act.Do == "unlink" &&
|
||||
e.rec.Act.Identity == id.Identity && e.rec.Only != nil && e.rec.Only.Kind == desk {
|
||||
h.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
now := h.Now()
|
||||
until := id.ApproveFrom
|
||||
a := asks.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?",
|
||||
Explanation: fmt.Sprintf("The %s account %s (number %s) was linked as you at %s. Another account just asked "+
|
||||
"to be linked. If the linked one is not yours, unlink it here before %s; after that only that account, "+
|
||||
"or root at the router's machine, can.", Display(id.Kind), id.Display, id.Identity,
|
||||
id.Linked.In(h.zone()).Format("15:04"), until.In(h.zone()).Format("15:04")),
|
||||
Who: asks.Operator, Expires: until, OnExpiry: "the account stays linked",
|
||||
Options: []asks.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: asks.Acknowledge}}}
|
||||
if err := h.askSelf(a, Identity{Kind: desk}, SelfAct{Do: "unlink", Kind: id.Kind, Identity: id.Identity}); err != nil {
|
||||
h.Logf("[messenger] the desk could not be asked to unlink %s: %v", id.Display, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on
|
||||
// the kind.
|
||||
@@ -122,38 +207,45 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string {
|
||||
if id.Identity == l.Sender.Identity {
|
||||
return reply("Already linked", "This account is already linked as the operator.")
|
||||
}
|
||||
return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+
|
||||
"with that account's own approval, and then this one can be linked.")
|
||||
}
|
||||
h.mu.Lock()
|
||||
if h.linksAsked == nil {
|
||||
h.linksAsked = map[string][]time.Time{}
|
||||
}
|
||||
var recent []time.Time
|
||||
for _, t := range h.linksAsked[kind] {
|
||||
if now.Sub(t) < time.Hour {
|
||||
recent = append(recent, t)
|
||||
if now.Before(id.ApproveFrom) && h.offerDeskUnlink(id) {
|
||||
return reply("Another account is linked", "Another account was linked here less than an hour ago. If "+
|
||||
"that was not you, unlink it at your desk, where you were just asked, or as root at the router's "+
|
||||
"machine; then send /start again.")
|
||||
}
|
||||
return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+
|
||||
"with that account's own approval, or as root at the router's machine, and then this one can be linked.")
|
||||
}
|
||||
full := len(recent) >= LinksAnHour
|
||||
if !full {
|
||||
recent = append(recent, now)
|
||||
if u := h.recentUnlink(kind); u != nil && u.Identity.Identity == l.Sender.Identity {
|
||||
// An account unlinked less than a day ago does not come back by a code at once (the review of
|
||||
// 2026-10-09): whoever unlinked it meant it, and an account taken with a phone must not walk back in.
|
||||
return reply("Not linked", "This account was unlinked less than a day ago. It can be linked again from "+
|
||||
u.At.Add(RelinkWaits).In(h.zone()).Format("2006-01-02 15:04")+", or allowed sooner as root at the router's machine.")
|
||||
}
|
||||
h.linksAsked[kind] = recent
|
||||
h.mu.Unlock()
|
||||
if full {
|
||||
return reply("Not linked", fmt.Sprintf("Links were asked %d times this hour. Try again in an hour.", LinksAnHour))
|
||||
if why := h.allowLink(kind, l.Sender.Identity, now); why != "" {
|
||||
return reply("Not linked", why)
|
||||
}
|
||||
// A code is shown only where the words stay on the operator's machines: a kind declaring `private`.
|
||||
// It is shown now or never: a desk that cannot show it now (its notifications paused, nobody there) begins
|
||||
// no link, so no code waits in a queue to be shown to whoever comes later (the review of 2026-10-09).
|
||||
shows := ""
|
||||
var whyNot []string
|
||||
for _, k := range h.Bench.Kinds() {
|
||||
if k.Kind != kind && h.Bench.Promises(k.Kind, "private") && h.Bench.Ready(k.Kind) == nil {
|
||||
shows = k.Kind
|
||||
break
|
||||
if k.Kind == kind || !h.Bench.Promises(k.Kind, "private") {
|
||||
continue
|
||||
}
|
||||
if err := h.Bench.Ready(k.Kind); err != nil {
|
||||
whyNot = append(whyNot, err.Error())
|
||||
continue
|
||||
}
|
||||
shows = k.Kind
|
||||
break
|
||||
}
|
||||
if shows == "" {
|
||||
return reply("Not linked", "No private place can show the code now. Try again when you are at your machine.")
|
||||
why := "No private place can show the code now."
|
||||
if len(whyNot) > 0 {
|
||||
why = "Your desk cannot show the code now: " + strings.Join(whyNot, "; ") + "."
|
||||
}
|
||||
return reply("Not linked", why+" Nothing was begun. Try again when you are at your machine.")
|
||||
}
|
||||
code, err := sixDigits()
|
||||
if err != nil {
|
||||
@@ -217,7 +309,16 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
|
||||
return no("That code is not right.")
|
||||
}
|
||||
id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display}
|
||||
if u := h.recentUnlink(kind); u != nil && u.Identity.Identity != p.Identity {
|
||||
u := h.recentUnlink(kind)
|
||||
if u != nil && u.Identity.Identity == p.Identity {
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
return no("This account was unlinked less than a day ago, and is not linked again by a code before a day has passed.")
|
||||
}
|
||||
if others := h.identitiesOf(kind); len(others) > 0 {
|
||||
_ = h.Asks.DeleteLink(kind)
|
||||
return no("Another account was linked here meanwhile. Nothing was linked.")
|
||||
}
|
||||
if u != nil {
|
||||
// Within a day of an unlink: the account it replaces decides (novox/hq ADR 0259 §7).
|
||||
p.Confirmed = true
|
||||
p.Expires = now.Add(RelinkWaits)
|
||||
@@ -230,7 +331,7 @@ func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
|
||||
Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}}
|
||||
prior := u.Identity
|
||||
prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked
|
||||
if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Link: &id}); err != nil {
|
||||
if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Identity: id.Identity, Link: &id}); err != nil {
|
||||
return no("The link could not be asked of the account it replaces: " + err.Error())
|
||||
}
|
||||
h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you",
|
||||
|
||||
@@ -171,7 +171,7 @@ func (askStore) IdentityKeys() ([]string, error) {
|
||||
keys, err := stdio.State("identities").Keys()
|
||||
var out []string
|
||||
for _, k := range keys {
|
||||
if !strings.HasPrefix(k, "pending.") && !strings.HasPrefix(k, "unlinked.") {
|
||||
if !strings.HasPrefix(k, "pending.") && !strings.HasPrefix(k, "unlinked.") && !strings.HasPrefix(k, "asked.") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,19 @@ func (askStore) PutUnlinked(kind string, u Unlinked) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (askStore) GetLinksAsked(key string) ([]time.Time, error) {
|
||||
e, err := stdio.State("identities").Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var times []time.Time
|
||||
return times, json.Unmarshal(e.Value, ×)
|
||||
}
|
||||
func (askStore) PutLinksAsked(key string, times []time.Time) error {
|
||||
_, err := stdio.State("identities").Put(key, times)
|
||||
return err
|
||||
}
|
||||
|
||||
// listening is whether condition events reach this holder, in words.
|
||||
type listening struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -84,8 +84,9 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
|
||||
"intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
|
||||
t.Fatalf("consumes %v", m.Consumes)
|
||||
}
|
||||
// It performs nothing: the one verb it calls reads the open conditions.
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions"}) {
|
||||
// It performs nothing: the verbs it calls read the open conditions and whether root is free.
|
||||
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions",
|
||||
"seat:" + ControllerSeat + ".root-free"}) {
|
||||
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
|
||||
}
|
||||
var state []string
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package main
|
||||
|
||||
// The findings of the review of 2026-10-09, each held here by a test that failed before its fix.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// H3: approval rests on the controller's positive, recent word that no agent can become root where the router
|
||||
// and the channel run (its verb root-free), asked live. Every other answer fails closed.
|
||||
func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
setup func(h *Holder)
|
||||
want string
|
||||
}{
|
||||
"a router that does not know its machine": {func(h *Holder) { h.Node = "" }, "does not know which machine"},
|
||||
"a channel whose machine is not said": {func(h *Holder) {
|
||||
k, _ := h.Bench.Kind("telegram")
|
||||
k.Node = ""
|
||||
h.Bench.SetKinds([]KindInfo{k})
|
||||
}, "has not said which machine"},
|
||||
"a controller that does not answer": {func(h *Holder) {
|
||||
h.RootFree = func([]string) (map[string]RootVerdict, error) { return nil, errors.New("no responders") }
|
||||
}, "could not be asked: no responders"},
|
||||
"a machine the answer leaves out": {func(h *Holder) {
|
||||
h.RootFree = func([]string) (map[string]RootVerdict, error) { return map[string]RootVerdict{}, nil }
|
||||
}, "said nothing of whether an agent can become root on anchor"},
|
||||
"a machine not free": {func(h *Holder) { rootFreeOf[h]["anchor"] = "the agent account is not judged" },
|
||||
"may become root on anchor"},
|
||||
"the router's own machine not free": {func(h *Holder) {
|
||||
h.Node = "router-machine"
|
||||
rootFreeOf[h]["router-machine"] = "execute is served there"
|
||||
}, "may become root on router-machine"},
|
||||
} {
|
||||
t.Run(name, 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)
|
||||
release := ticketFor(tg.sends[0], "Release")
|
||||
if release == "" {
|
||||
t.Fatal("not offered while root was free")
|
||||
}
|
||||
tc.setup(h)
|
||||
out, err := h.Chosen("telegram", choiceOf(release, "42", c.now()))
|
||||
if out != "refused" || err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("%s %v, want refused for %q", out, err, tc.want)
|
||||
}
|
||||
if len(w.of("mesh-delivery")) != 0 {
|
||||
t.Error("a warrant was issued")
|
||||
}
|
||||
if got := h.carriers(asks.Approve); len(got) != 0 {
|
||||
t.Errorf("approval still carried on %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// H3: the word is asked of the controller's verb, as this module, and read from its answer.
|
||||
func TestTheRootWordIsAskedOfTheControllersVerb(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)
|
||||
}
|
||||
return json.RawMessage(`{"machines":[{"machine":"anchor","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" {
|
||||
t.Errorf("asked %v", asked)
|
||||
}
|
||||
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)
|
||||
}
|
||||
h.Ask = nil
|
||||
if why := h.rootFree(info, false); why == "" {
|
||||
t.Error("no way to ask was a pass")
|
||||
}
|
||||
}
|
||||
|
||||
// H3: a message rendered while root is not free offers no approval; a word kept for rendering is never the
|
||||
// word an answer is judged on.
|
||||
func TestAMessageRenderedWhileRootIsNotFreeOffersNoApproval(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)
|
||||
rec := h.openAsk(h.open["ask.mesh-delivery.d-1"])
|
||||
if got := h.offers(rec, "telegram"); len(got) != 3 {
|
||||
t.Fatalf("offers %v", got)
|
||||
}
|
||||
rootFreeOf[h]["anchor"] = "not judged"
|
||||
c.pass(RootFreeKept)
|
||||
if got := h.offers(rec, "telegram"); len(got) != 0 {
|
||||
t.Errorf("rendered with an approval while root is not free: %v", got)
|
||||
}
|
||||
// Free again, but the kept word is not: the answer asks anew and is taken.
|
||||
delete(rootFreeOf[h], "anchor")
|
||||
out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now()))
|
||||
if err != nil || out != "chosen" {
|
||||
t.Errorf("judged on a kept word: %s %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// H3: a capability the controller's word no longer says stays no longer than the word is fresh: a router that
|
||||
// cannot read the kinds again promises no verified sender.
|
||||
func TestAStaleWordOnTheKindsPromisesNoVerifiedSender(t *testing.T) {
|
||||
h, _, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
if !h.Bench.Promises("telegram", "verified-sender") {
|
||||
t.Fatal("not promised while fresh")
|
||||
}
|
||||
c.pass(KindsFresh + time.Second)
|
||||
h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(asks.Approve)) != 0 {
|
||||
t.Error("a stale word still promises a verified sender")
|
||||
}
|
||||
if !h.Bench.Promises("telegram", "choice") {
|
||||
t.Error("a stale word stopped the channel sending")
|
||||
}
|
||||
}
|
||||
|
||||
// C6: a link's code is marked secret on every path to a channel, the desk's own role included, so the channel
|
||||
// keeps no copy (the review found it dropped where the desk's role sends).
|
||||
func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
|
||||
c := start()
|
||||
bench := NewBench(c.now)
|
||||
bench.SetKinds([]KindInfo{
|
||||
{Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"reaches-away", "choice", "verified-sender", "exact-render"}},
|
||||
{Kind: "desktop", Module: "desk-channel", Node: "anchor", Capabilities: []string{"choice", "exact-render", "private"}},
|
||||
})
|
||||
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
|
||||
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
|
||||
var mu sync.Mutex
|
||||
put := map[string][]asks.Message{}
|
||||
publish := func(subject string, body any, id string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if m, ok := body.(asks.Message); ok {
|
||||
put[subject] = append(put[subject], m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
h := &Holder{Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish},
|
||||
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, Bench: bench, Publish: publish,
|
||||
Asks: newMemAsks(), Node: "anchor", Now: c.now, Logf: t.Logf, Zone: func() *time.Location { return time.UTC }}
|
||||
h.init()
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
shown := put[asks.ChannelSubject(asks.Send, "desktop")]
|
||||
if len(shown) != 1 || !strings.Contains(shown[0].Body, "send this code") {
|
||||
t.Fatalf("the code was not shown on the desk: %+v", put)
|
||||
}
|
||||
if !shown[0].Secret {
|
||||
t.Error("the code went to the desk not marked secret: the channel would keep a copy")
|
||||
}
|
||||
}
|
||||
|
||||
// L1: a warrant's message id is new each time: one anyone could foresee lets another publisher on the stream
|
||||
// take it first, and the bus would drop the warrant as a duplicate.
|
||||
func TestAWarrantsMessageIdCannotBeForeseen(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
var ids []string
|
||||
h.Publish = func(subject string, body any, id string) error {
|
||||
if _, ok := body.(asks.Warrant); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h, c)
|
||||
if out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())); out != "chosen" {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
_ = h.tell(*store.asks["mesh-delivery.d-1"].Warrant)
|
||||
if len(ids) != 2 || ids[0] == ids[1] {
|
||||
t.Fatalf("ids %v", ids)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if strings.Contains(id, "d-1") || strings.Contains(id, "mesh-delivery") {
|
||||
t.Errorf("the id %q is made of what anyone knows of the ask", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M2: an ask's message says who asks, as the bus proves it, what each answer does, and what happens if nobody
|
||||
// answers.
|
||||
func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(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)
|
||||
body := tg.sends[0].Body
|
||||
for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.",
|
||||
"Silence for a week: nothing more is said of it for a week.",
|
||||
"If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the message does not say %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M3: the router's own unlink binds the account it asks about: an approval from that account, after another was
|
||||
// linked in its place, unlinks nothing.
|
||||
func TestAnUnlinkUnlinksOnlyTheAccountItNamed(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
if _, err := h.Unlink("telegram"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlink := ticketFor(tg.sends[len(tg.sends)-1], "Unlink")
|
||||
delete(store.ids, identityKey("telegram", "42"))
|
||||
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Display: "Al",
|
||||
Linked: c.now().Add(-2 * time.Hour), ApproveFrom: c.now().Add(-time.Hour)}
|
||||
_, _ = h.Chosen("telegram", choiceOf(unlink, "42", c.now()))
|
||||
if _, still := store.ids[identityKey("telegram", "77")]; !still {
|
||||
t.Error("an approval to unlink 42 unlinked 77")
|
||||
}
|
||||
if got := store.asks; len(got) == 0 {
|
||||
t.Fatal("no ask")
|
||||
}
|
||||
for _, rec := range store.asks {
|
||||
if rec.Act != nil && rec.Act.Identity != "42" {
|
||||
t.Errorf("the ask binds %q", rec.Act.Identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// M3: a link is checked again as it is made: an approved relink does not link a second account of a kind.
|
||||
func TestALinkIsNotMadeWhileAnotherAccountIsLinked(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
_, _ = h.Unlink("telegram")
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
|
||||
code := codeOf(t, dt.sends[len(dt.sends)-1])
|
||||
_ = h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code})
|
||||
asked := tg.sends[len(tg.sends)-1]
|
||||
// Meanwhile root linked another account (allowed at the machine).
|
||||
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Linked: c.now(), ApproveFrom: c.now().Add(time.Hour)}
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "42", c.now()))
|
||||
if _, linked := store.ids[identityKey("telegram", "666")]; linked {
|
||||
t.Errorf("a second account of a kind was linked: %v", store.ids)
|
||||
}
|
||||
}
|
||||
|
||||
// M3: an account unlinked less than a day ago is not linked again by a code: no code is shown for it.
|
||||
func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
_, _ = h.Unlink("telegram")
|
||||
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
|
||||
shown := len(dt.sends)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("a link was begun for the account unlinked an hour ago")
|
||||
}
|
||||
for _, m := range dt.sends[shown:] {
|
||||
if strings.Contains(m.Body, "send this code") {
|
||||
t.Error("a code was shown for it")
|
||||
}
|
||||
}
|
||||
if len(store.ids) != 0 {
|
||||
t.Error("linked again")
|
||||
}
|
||||
}
|
||||
|
||||
// H4: an account still in its first hour cannot approve yet, so whoever is at the desk may unlink it — asked
|
||||
// there when another account asks to be linked — and past its hour the desk no longer can.
|
||||
func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
|
||||
h, tg, dt, c, store, w := newRouter(t)
|
||||
link(h, store, c, "666", c.now().Add(-10*time.Minute)) // an account the operator did not link
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if reply := tg.sends[len(tg.sends)-1]; !strings.Contains(reply.Body, "unlink it at your desk") {
|
||||
t.Fatalf("the sender was not told how: %q", reply.Body)
|
||||
}
|
||||
asked := dt.sends[len(dt.sends)-1]
|
||||
if labelsOf(asked) != "Unlink it" || !strings.Contains(asked.Body, "number 666") {
|
||||
t.Fatalf("the desk was not asked: %+v", asked)
|
||||
}
|
||||
ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now())
|
||||
ch.Sender.Authenticated = false
|
||||
if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
if len(store.ids) != 0 {
|
||||
t.Errorf("not unlinked: %v", store.ids)
|
||||
}
|
||||
if len(w.of(SelfAsker)) != 0 {
|
||||
t.Error("the router's own act was told to an asker")
|
||||
}
|
||||
// Past its hour, no question goes to the desk.
|
||||
h2, tg2, dt2, c2, store2, _ := newRouter(t)
|
||||
link(h2, store2, c2, "666", c2.now().Add(-2*time.Hour))
|
||||
h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: asks.Sender{Identity: "42", Authenticated: true}})
|
||||
for _, m := range dt2.sends {
|
||||
if labelsOf(m) == "Unlink it" {
|
||||
t.Error("the desk was asked to unlink an account past its first hour")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "own approval, or as root") {
|
||||
t.Errorf("%q", tg2.sends[len(tg2.sends)-1].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// H4 (C4): while the desk cannot show a code now — its notifications paused — no link is begun and no code is
|
||||
// made or queued; the sender is told why.
|
||||
func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
h.Bench.Stand("desktop", Standing{Ready: false, Why: "notifications are paused on the desk", At: c.now()})
|
||||
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if _, pending := store.links["telegram"]; pending {
|
||||
t.Error("a link was begun")
|
||||
}
|
||||
if len(dt.sends) != 0 {
|
||||
t.Errorf("something was put to the desk: %+v", dt.sends)
|
||||
}
|
||||
if reply := tg.sends[len(tg.sends)-1].Body; !strings.Contains(reply, "notifications are paused on the desk") {
|
||||
t.Errorf("the sender was not told why: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// L5: links are bounded per account, and kept in the router's state: a stranger's tries do not stop the
|
||||
// operator's link, and a restart forgets no count.
|
||||
func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
|
||||
h, tg, dt, c, store, _ := newRouter(t)
|
||||
eve := asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}
|
||||
for i := 0; i < LinksAnHour; i++ {
|
||||
h.Link("telegram", asks.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
|
||||
}
|
||||
shown := len(dt.sends)
|
||||
h.Link("telegram", asks.LinkAsked{ID: "jo", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
|
||||
if len(dt.sends) != shown+1 || !strings.Contains(dt.sends[len(dt.sends)-1].Body, "(number 42)") {
|
||||
t.Fatalf("a stranger's tries stopped the operator's link: %q", tg.sends[len(tg.sends)-1].Body)
|
||||
}
|
||||
// A router started again on the same state still counts the stranger's tries.
|
||||
h2, tg2, dt2, c2, _, _ := newRouter(t)
|
||||
h2.Asks = store
|
||||
c2.pass(c.now().Sub(c2.now()))
|
||||
stand(h2, c2)
|
||||
before := len(dt2.sends)
|
||||
h2.Link("telegram", asks.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
|
||||
if len(dt2.sends) != before || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "Try again in an hour") {
|
||||
t.Error("a restart forgot the count")
|
||||
}
|
||||
}
|
||||
|
||||
// L7: two answers to one ask arriving at once — on two routers sharing the state — make exactly one warrant.
|
||||
func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) {
|
||||
h1, tg, _, c, store, w1 := newRouter(t)
|
||||
link(h1, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h1, "mesh-delivery", heldAsk(c, "d-1"))
|
||||
settle(h1, c)
|
||||
release, stop := ticketFor(tg.sends[0], "Release"), ticketFor(tg.sends[0], "Stop")
|
||||
h2, _, _, _, _, w2 := newRouter(t)
|
||||
h2.Asks = store
|
||||
h2.Now = h1.Now
|
||||
if err := h2.loadAsks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
outs := make([]string, 2)
|
||||
for i, pick := range []struct {
|
||||
h *Holder
|
||||
ticket string
|
||||
}{{h1, release}, {h2, stop}} {
|
||||
wg.Add(1)
|
||||
go func(i int, h *Holder, ticket string) {
|
||||
defer wg.Done()
|
||||
outs[i], _ = h.Chosen("telegram", choiceOf(ticket, "42", c.now()))
|
||||
}(i, pick.h, pick.ticket)
|
||||
}
|
||||
wg.Wait()
|
||||
n := len(w1.of("mesh-delivery")) + len(w2.of("mesh-delivery"))
|
||||
if n != 1 {
|
||||
t.Fatalf("%d warrants for one ask (%v)", n, outs)
|
||||
}
|
||||
if (outs[0] == "chosen") == (outs[1] == "chosen") {
|
||||
t.Errorf("not exactly one chosen: %v", outs)
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,14 @@ type Bench struct {
|
||||
kinds map[string]KindInfo
|
||||
standing map[string]Standing
|
||||
known bool
|
||||
knownAt time.Time
|
||||
}
|
||||
|
||||
// KindsFresh is how long the controller's word on the kinds is believed for a proof: the router reads it every
|
||||
// half minute, so an older word is one it could not read again. A stale word keeps the kinds for sending, and
|
||||
// promises no verified sender (the review of 2026-10-09: a capability the controller withdrew must not stay).
|
||||
const KindsFresh = 2 * time.Minute
|
||||
|
||||
// NewBench is a bench that knows no kind yet.
|
||||
func NewBench(now func() time.Time) *Bench {
|
||||
return &Bench{Now: now, kinds: map[string]KindInfo{}, standing: map[string]Standing{}}
|
||||
@@ -73,6 +79,7 @@ func (b *Bench) SetKinds(kinds []KindInfo) {
|
||||
b.kinds[k.Kind] = k
|
||||
}
|
||||
b.known = true
|
||||
b.knownAt = b.Now()
|
||||
}
|
||||
|
||||
// Stand takes what a kind said of itself.
|
||||
@@ -108,6 +115,14 @@ func (b *Bench) Promises(kind, capability string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if capability == "verified-sender" {
|
||||
b.mu.Lock()
|
||||
at := b.knownAt
|
||||
b.mu.Unlock()
|
||||
if age := b.Now().Sub(at); age > KindsFresh || age < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, c := range k.Capabilities {
|
||||
if c == capability {
|
||||
return true
|
||||
@@ -255,7 +270,10 @@ func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
|
||||
case len(m.Actions) > 0:
|
||||
verb = asks.Show
|
||||
}
|
||||
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply}
|
||||
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
|
||||
// dropped here, where the desk's own role sends).
|
||||
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
|
||||
Secret: m.Secret}
|
||||
if c.Bench.To != nil {
|
||||
out.To = c.Bench.To(kind)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -29,11 +30,19 @@ 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.
|
||||
// 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"`
|
||||
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
|
||||
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.
|
||||
@@ -45,7 +54,7 @@ type Unlinked struct {
|
||||
// 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)
|
||||
binds, err := asks.ActDigest(act.act())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,7 +104,7 @@ func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
|
||||
}
|
||||
o, err := w.For(SelfAsker, rec.Ask)
|
||||
if err == nil {
|
||||
err = o.Performs(*rec.Act)
|
||||
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)
|
||||
@@ -103,9 +112,23 @@ func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
|
||||
}
|
||||
switch rec.Act.Do {
|
||||
case "unlink":
|
||||
h.unlinkNow(rec.Act.Kind, "approved by the account itself")
|
||||
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")
|
||||
return
|
||||
}
|
||||
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself")
|
||||
case "link":
|
||||
if rec.Act.Link != nil {
|
||||
if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity {
|
||||
h.linkNow(*rec.Act.Link, "approved by the account it replaces")
|
||||
}
|
||||
}
|
||||
@@ -126,7 +149,7 @@ func (h *Holder) Unlink(kind string) (map[string]any, error) {
|
||||
"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}); err != nil {
|
||||
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",
|
||||
@@ -135,23 +158,27 @@ func (h *Holder) Unlink(kind string) (map[string]any, error) {
|
||||
return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil
|
||||
}
|
||||
|
||||
// unlinkNow unlinks a kind's account: told first, by name, then every channel.
|
||||
func (h *Holder) unlinkNow(kind, how string) {
|
||||
ids := h.identitiesOf(kind)
|
||||
if len(ids) == 0 || h.Asks == 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) {
|
||||
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
|
||||
}
|
||||
id := ids[0]
|
||||
_ = 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()})
|
||||
h.unlinked(kind, Unlinked{Identity: *id, At: h.Now()})
|
||||
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
|
||||
Body: fmt.Sprintf("Unlinked: %s (number %s), %s. A new link within a day waits for that account's approval.",
|
||||
id.Display, id.Identity, how), Urgent: true})
|
||||
Body: fmt.Sprintf("Unlinked: %s (number %s), %s. A new link within a day waits for that account's approval, "+
|
||||
"and that account itself can be linked again only after a day.", id.Display, id.Identity, how), Urgent: true})
|
||||
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how})
|
||||
}
|
||||
|
||||
@@ -160,6 +187,19 @@ 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 {
|
||||
@@ -175,9 +215,12 @@ func (h *Holder) linkNow(id Identity, how string) {
|
||||
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.
|
||||
func (h *Holder) BreakGlass() {
|
||||
// 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
|
||||
}
|
||||
@@ -199,20 +242,58 @@ func (h *Holder) BreakGlass() {
|
||||
Urgent: true})
|
||||
continue
|
||||
}
|
||||
h.work.Lock()
|
||||
where := "at the router's machine"
|
||||
if h.Node != "" {
|
||||
where = "at " + h.Node
|
||||
}
|
||||
done := ""
|
||||
switch what {
|
||||
case "unlink":
|
||||
h.unlinkNow(kind, "at the router's machine, as root")
|
||||
if ids := h.identitiesOf(kind); len(ids) > 0 {
|
||||
h.unlinkNow(kind, ids[0].Identity, where+", as root")
|
||||
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 at the router's machine, as root")
|
||||
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)
|
||||
}
|
||||
}
|
||||
h.work.Unlock()
|
||||
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, ""); 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 {
|
||||
|
||||
@@ -2,4 +2,4 @@ module messenger
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 h1:WHW6CgbuTxP7M+qRBOgzsiG9vT49xdkZ/rarc9/vKMA=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
|
||||
@@ -82,7 +82,8 @@
|
||||
"refused"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.conditions"
|
||||
"seat:mesh-controller.conditions",
|
||||
"seat:mesh-controller.root-free"
|
||||
],
|
||||
"state": [
|
||||
"open",
|
||||
|
||||
Reference in New Issue
Block a user