RTR4: the router keeps a refused ask as a closed record, the oldest removed past refused-kept

Builds: design 47 G §570
This commit is contained in:
2026-11-12 05:01:57 +01:00
parent 177115d6cd
commit f8160922c9
7 changed files with 156 additions and 8 deletions
@@ -5,6 +5,8 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"sync"
"testing"
@@ -71,6 +73,13 @@ func (m *memAsks) UpdateAsk(k string, r AskRecord, rev uint64) (uint64, error) {
m.asks[k], m.revs[k] = clone(r), m.rev
return m.rev, nil
}
func (m *memAsks) DeleteAsk(k string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.asks, k)
delete(m.revs, k)
return nil
}
func (m *memAsks) AskKeys() ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -601,8 +610,17 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) {
if strings.Contains(all, "d-6") || store.asks["mesh-delivery.d-6"].State != StateOpen || store.asks["mesh-delivery.d-3"].State != StateHeldBack {
t.Errorf("the fourth ask was refused or the oldest not held back: d-6 %s, d-3 %s", store.asks["mesh-delivery.d-6"].State, store.asks["mesh-delivery.d-3"].State)
}
if _, kept := store.asks["mesh-delivery.d-1"]; kept {
t.Error("a refused ask was recorded")
// A refused ask is kept as a closed record with its outcome and words (design 47 G §570), and `asks` lists it refused.
if rec, kept := store.asks["mesh-delivery.d-1"]; !kept || rec.State != string(occ.OutcomeRefused) || rec.Warrant == nil ||
!strings.Contains(rec.Warrant.Words, "an ask that destroys lasts at most") || liveState(rec.State) {
t.Errorf("a refused ask was not kept as a closed record: %+v", rec)
}
listed := false
for _, v := range h.AsksNow() {
listed = listed || (v.Key == "mesh-delivery.d-1" && v.State == "refused" && strings.HasPrefix(v.Answer, "refused: "))
}
if !listed {
t.Error("asks does not list the refused ask")
}
// A node's engine is an asker under node-engine-<node> (novox/hq ADR 0563 §3, changing ADR 0259 §2): its ask is
// recorded under that name, with its own bound of open asks; a name that is no asker's is dropped.
@@ -1073,3 +1091,38 @@ func TestAnAsksMessageWithOneAnswerIsNotFoldedIntoADigest(t *testing.T) {
t.Errorf("the ask's message was folded with another: %q", asked.Body)
}
}
// Design 47 G §570: a refusal is written by a create only, so an open ask of the same id keeps its record; the twenty-first
// refusal of one asker removes its oldest, as the setting refused-kept says, each removal written in the journal.
func TestRefusedAsksAreKeptByACreateOnlyAndTheOldestGoesPastTheSetting(t *testing.T) {
h, _, _, c, store, _ := newRouter(t)
h.RefusedKept = func() int { return 3 }
open := heldAsk(c, "open-1")
submit(t, h, "mesh-delivery", open)
before := store.asks["mesh-delivery.open-1"]
same := heldAsk(c, "open-1")
same.Options[0].Level = occ.Destroy
submit(t, h, "mesh-delivery", same)
if got := store.asks["mesh-delivery.open-1"]; got.State != before.State || got.Warrant != nil {
t.Fatalf("a refusal of an open ask's id overwrote it: %+v", got)
}
for i := 1; i <= 5; i++ {
bad := heldAsk(c, fmt.Sprintf("bad-%d", i))
bad.Options[0].Level = occ.Destroy
submit(t, h, "mesh-delivery", bad)
c.pass(time.Minute)
}
var kept []string
for k, r := range store.asks {
if r.State == string(occ.OutcomeRefused) {
kept = append(kept, k)
}
}
sort.Strings(kept)
if !reflect.DeepEqual(kept, []string{"mesh-delivery.bad-3", "mesh-delivery.bad-4", "mesh-delivery.bad-5"}) {
t.Fatalf("kept %v: the three newest refused asks of the asker", kept)
}
if h.askOf("mesh-delivery.bad-1") != nil {
t.Error("the router's own view still holds the removed refusal")
}
}
+64 -2
View File
@@ -95,6 +95,8 @@ type Identity struct {
type AskStore interface {
GetAsk(key string) (*AskRecord, uint64, error)
CreateAsk(key string, r AskRecord) (uint64, error)
// DeleteAsk removes one ask's record: the oldest refused one past the setting refused-kept (design 47 G §570).
DeleteAsk(key string) error
UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error)
AskKeys() ([]string, error)
GetIdentity(key string) (*Identity, error)
@@ -864,10 +866,70 @@ func (h *Holder) offerAsk(r *Record) {
h.flush(now, false)
}
// refuseAsk says to its asker that an ask was not taken, and why.
// DefaultRefusedKept is how many refused asks of one asker the router keeps when the setting refused-kept gives none.
const DefaultRefusedKept = 20
// refuseAsk says to its asker that an ask was not taken, and why, and keeps it under `<asker>.<id>` as a closed record with the
// outcome refused (design 47 G §570), by a create only: an id that holds a record already, an open ask with its tickets, is
// left as it is and the refusal is said on `decided` alone.
func (h *Holder) refuseAsk(asker string, a oc.Ask, why string) {
h.Logf("[messenger] refused an ask from %s (%s): %s", asker, a.ID, why)
h.tell(oc.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: occ.OutcomeRefused, Words: why, At: occ.FormatTime(h.Now())})
now := h.Now()
w := oc.Warrant{Ask: a.ID, Asker: asker, About: a.About, Outcome: occ.OutcomeRefused, Words: why, At: occ.FormatTime(now)}
told := h.tell(w) == nil
if h.Asks == nil || !occ.UsableID(a.ID) {
return
}
key := askKey(asker, a.ID)
if h.askOf(key) != nil {
return
}
// Only its id and subject are kept: a refused ask's words may carry what may not leave the mesh (a secret's shape), so
// none of them is written to the record; its warrant says why in the router's words.
rec := AskRecord{Asker: asker, Ask: oc.Ask{ID: a.ID, About: a.About}, State: string(occ.OutcomeRefused), Opened: now, Closed: now, Warrant: &w, Told: told}
rev, err := h.Asks.CreateAsk(key, rec)
switch {
case errors.Is(err, ErrChanged):
return
case err != nil:
h.Logf("[messenger] the refusal of %s could not be kept: %v", key, err)
return
}
h.keepAsk(key, rec, rev)
h.pruneRefused(asker)
}
// pruneRefused removes the oldest refused records of an asker past the setting refused-kept, each removal written in the
// journal (design 47 G §570).
func (h *Holder) pruneRefused(asker string) {
most := DefaultRefusedKept
if h.RefusedKept != nil {
most = h.RefusedKept()
}
h.mu.Lock()
var keys []string
for k, e := range h.asks {
if e.rec.Asker == asker && e.rec.State == string(occ.OutcomeRefused) {
keys = append(keys, k)
}
}
sort.Slice(keys, func(i, j int) bool {
a, b := h.asks[keys[i]].rec.Closed, h.asks[keys[j]].rec.Closed
return a.Before(b) || (a.Equal(b) && keys[i] < keys[j])
})
h.mu.Unlock()
for len(keys) > most {
oldest := keys[0]
keys = keys[1:]
if err := h.Asks.DeleteAsk(oldest); err != nil {
h.Logf("[messenger] the refused ask %s could not be removed: %v", oldest, err)
continue
}
h.mu.Lock()
delete(h.asks, oldest)
h.mu.Unlock()
h.Logf("[messenger] removed the oldest refused ask of %s, %s: %d are kept at most (refused-kept)", asker, oldest, most)
}
}
// tell publishes a warrant, or an ask's end, to its asker alone, deduplicated by the ask.
@@ -330,6 +330,9 @@ type Holder struct {
// CapPerHour is the most messages one channel gets an hour (the setting messages-per-hour, issue 1476);
// nil, or a value below zero, is DefaultCapPerHour; 0 is no cap at all. TellCapPerHour is its own and stays fixed.
CapPerHour func() int
// RefusedKept is how many refused asks of one asker are kept (the setting refused-kept, design 47 G §570); nil is
// DefaultRefusedKept.
RefusedKept func() int
// Settle is how long a delivered walk holds what it touched after its end (the setting settle-seconds, novox/hq
// ADR 0505 §3); nil is DefaultSettle.
Settle func() time.Duration
+20
View File
@@ -39,6 +39,8 @@ type Settings struct {
// SettleSeconds is how long a delivered walk still holds what it touched after its end (novox/hq ADR 0505 §3):
// nil, not given, is DefaultSettle; 0 holds nothing after the end.
SettleSeconds *int `json:"settle-seconds"`
// RefusedKept is how many refused asks of one asker the router keeps (design 47 G §570): nil, not given, is DefaultRefusedKept.
RefusedKept *int `json:"refused-kept"`
}
// zoneOf is the location a time-zone setting names, or this machine's own when it names none or one
@@ -105,6 +107,17 @@ func readSettings(path string) (Settings, error) {
default:
return s, fmt.Errorf("settle-seconds is not a number")
}
switch v := loose["refused-kept"].(type) {
case nil:
case float64:
n := int(v)
if v < 1 || v != float64(n) {
return s, fmt.Errorf("refused-kept is not a whole number of one or more")
}
s.RefusedKept = &n
default:
return s, fmt.Errorf("refused-kept is not a number")
}
return s, nil
}
@@ -242,6 +255,7 @@ func (askStore) UpdateAsk(key string, r AskRecord, revision uint64) (uint64, err
rev, err := stdio.State("asks").Update(key, r, revision)
return rev, changed(err)
}
func (askStore) DeleteAsk(key string) error { return stdio.State("asks").Delete(key) }
func (askStore) AskKeys() ([]string, error) { return stdio.State("asks").Keys() }
func (askStore) GetIdentity(key string) (*Identity, error) {
e, err := stdio.State("identities").Get(key)
@@ -366,6 +380,12 @@ func main() {
}
return DefaultCapPerHour
},
RefusedKept: func() int {
if n := settings().RefusedKept; n != nil {
return *n
}
return DefaultRefusedKept
},
Settle: func() time.Duration {
if n := settings().SettleSeconds; n != nil && *n >= 0 {
return time.Duration(*n) * time.Second
@@ -149,8 +149,9 @@ func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) {
if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != occ.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") {
t.Errorf("%q: sent %d, told %+v", secret, len(tg.sends), got)
}
if _, kept := store.asks["mesh-controller.s-4"]; kept {
t.Errorf("%q: the refused ask was recorded", secret)
if rec, kept := store.asks["mesh-controller.s-4"]; !kept || rec.State != "refused" || rec.Ask.Whole != "" || rec.Ask.Headline != "" ||
strings.Contains(fmt.Sprint(rec), "ghp_") || strings.Contains(fmt.Sprint(rec), "hunter2") {
t.Errorf("%q: the refused ask was not kept as a closed record without its words: %+v", secret, rec)
}
}
}
@@ -204,7 +205,7 @@ func TestEveryKeyAndItsWholeValueIsInTheNoteWhereTheSenderIsProven(t *testing.T)
b.WholeDetails = "+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789"
submit(t, h, "mesh-controller", b)
settle(h, c)
if _, kept := store.asks["mesh-controller.s-10"]; kept || len(tg.sends) != 1 {
if rec, kept := store.asks["mesh-controller.s-10"]; (kept && rec.State != "refused") || rec.Ask.WholeDetails != "" || len(tg.sends) != 1 {
t.Errorf("an ask whose whole details carry a secret was taken")
}
}
+1
View File
@@ -3,5 +3,6 @@
"only-what-needs-me": [],
"messages-per-hour": 100,
"settle-seconds": 120,
"refused-kept": 20,
"readers": {"offered": ["mesh-knowledge"], "closed": ["mesh-knowledge"]}
}
+9 -1
View File
@@ -164,6 +164,14 @@
"trusted": false,
"default": 100
},
"refused-kept": {
"type": "number",
"description": "how many refused asks of one asker the router keeps as closed records, a whole number from 1 to 1000: the twenty-first refusal of an asker removes its oldest (design 47 G section 570).",
"minimum": 1,
"maximum": 1000,
"trusted": false,
"default": 20
},
"settle-seconds": {
"type": "number",
"description": "seconds a delivery that ended well still holds the messages about what it changed, so a problem that passes as the delivery settles is not sent (ADR 0505 §3); 0 holds nothing after the end.",
@@ -194,7 +202,7 @@
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds", "readers"],
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour", "settle-seconds", "refused-kept", "readers"],
"merge": "json",
"template": "files/settings.json",
"owner": "messenger"