Keep no secret-shaped question, and hold the two-question cap across nodes (issue 589, review 105)
mesh/merge-gate head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request

A question was kept on the bus before the router judged its words, so a token in
it reached a bucket every node reads: every word, the options' too, is now held
to the secret shapes and invisible characters before anything is kept, and a
refused question keeps none of its words. The cap was check-then-create and
failed open: each open question now holds one of two slot keys taken by create
or compare-and-set, refusing when a slot cannot be read. A question the router
never recorded frees its slot after two minutes instead of at expiry. by is
required; wording matches ADR 0332.
This commit is contained in:
jochen
2026-10-12 23:15:05 +02:00
parent e4e0ce7619
commit 14999f1d3e
4 changed files with 397 additions and 75 deletions
@@ -6,10 +6,13 @@ package main
import (
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
@@ -27,7 +30,7 @@ func mergeTonight() QuestionInput {
}
}
func askMergeTonight(t *testing.T, p Paths, store *memProposals, r *router, at time.Time) string {
func askMergeTonight(t *testing.T, p Paths, store *memQuestions, r *router, at time.Time) string {
t.Helper()
out, err := AskQuestion(p, mergeTonight(), store, r, at)
if err != nil {
@@ -38,7 +41,7 @@ func askMergeTonight(t *testing.T, p Paths, store *memProposals, r *router, at t
func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store, r := newMemQuestions(), newRouter()
out, err := AskQuestion(p, mergeTonight(), store, r, t0)
if err != nil {
t.Fatal(err)
@@ -78,7 +81,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
out, err := ReadAnswer(store, r, key, t0.Add(time.Minute))
if err != nil || out["state"] != AnswerWaiting {
@@ -100,7 +103,7 @@ func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
w := r.chose(key, "o1", t0.Add(time.Minute))
@@ -115,7 +118,7 @@ func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
}
// A warrant for another ask's digest.
store2, r2 := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store2, r2 := newMemQuestions(), newRouter()
key2 := askMergeTonight(t, p, store2, r2, t0)
w2 := r2.chose(key2, "o1", t0.Add(time.Minute))
w2.AskDigest = "sha256:0000"
@@ -125,7 +128,7 @@ func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
}
// A choice made after the question expired.
store3, r3 := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store3, r3 := newMemQuestions(), newRouter()
key3 := askMergeTonight(t, p, store3, r3, t0)
r3.close(r3.chose(key3, "o1", t0.Add(QuestionDefault+time.Minute)))
if out, _ := ReadAnswer(store3, r3, key3, t0.Add(2*QuestionDefault)); out["state"] != AnswerNotProven {
@@ -150,8 +153,19 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
"too long": func(in *QuestionInput) { in.Expires = 2 * 24 * time.Hour },
"another node": func(in *QuestionInput) { in.By = "fix-589@shanks" },
"an invisible char": func(in *QuestionInput) { in.Context += "\u200b" },
"invisible label": func(in *QuestionInput) { in.Options[0].Label = "Merge\u200bnow" },
"invisible means": func(in *QuestionInput) { in.Options[1].Means += "\u202e" },
"a token, context": func(in *QuestionInput) { in.Context += " The key is ghp_abcdefghijklmnopqrstuvwxyz0123456789." },
"a token, headline": func(in *QuestionInput) { in.Headline = "Use sk-abcdefghijklmnopqrstuv?" },
"a token, why": func(in *QuestionInput) { in.Why = "password: hunter2 works" },
"a token, expiry": func(in *QuestionInput) { in.OnExpiry = "I keep AKIAABCDEFGHIJKLMNOP" },
"a token, label": func(in *QuestionInput) { in.Options[0].Label = "token=abc" },
"a token, means": func(in *QuestionInput) { in.Options[1].Means = "-----BEGIN PRIVATE KEY-----" },
"no by": func(in *QuestionInput) { in.By = "" },
"61 characters": func(in *QuestionInput) { in.Headline = strings.Repeat("x", 60) + "?" },
"1431 minutes": func(in *QuestionInput) { in.Expires = 1431 * time.Minute },
} {
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store, r := newMemQuestions(), newRouter()
in := mergeTonight()
in.Options = append([]QuestionOption(nil), in.Options...)
change(&in)
@@ -166,7 +180,7 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") {
@@ -187,7 +201,7 @@ func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
// subject, with no record.
type questionRefusingRouter struct {
*router
store ProposalStore
store QuestionStore
id string
}
@@ -203,7 +217,7 @@ func (r *questionRefusingRouter) Publish(subject string, body any, id string) er
func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
p, _ := node(t, "g14")
store := &memProposals{m: map[string]json.RawMessage{}}
store := newMemQuestions()
refusing := &questionRefusingRouter{router: newRouter(), store: store}
within, every := RouterAnswersWithin, RouterAnswersEvery
RouterAnswersWithin, RouterAnswersEvery = time.Second, 10*time.Millisecond
@@ -216,14 +230,19 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") {
t.Errorf("read after the refusal: %v %v", out, err)
}
if open := openQuestions(store, refusing, t0.Add(time.Minute)); len(open) != 0 {
t.Errorf("a refused question still counts as waiting: %v", open)
if waits, err := questionOpen(store, refusing, refusing.id, t0.Add(time.Minute)); waits || err != nil {
t.Errorf("a refused question still counts as waiting: %v %v", waits, err)
}
var kept Question
_ = json.Unmarshal(store.m[refusing.id], &kept)
if kept.Headline != "" || kept.Context != "" || len(kept.Options) != 0 || kept.Why != "" || kept.OnExpiry != "" {
t.Errorf("a refused question keeps its words: %+v", kept)
}
}
func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
out, err := ReadAnswer(store, r, key, t0.Add(QuestionDefault+time.Minute))
@@ -235,7 +254,7 @@ func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
func TestTheQuestionToolsAreServed(t *testing.T) {
p, _ := node(t, "g14")
r := newRouter()
questions := &memProposals{m: map[string]json.RawMessage{}}
questions := newMemQuestions()
proposing = &Proposing{Store: &memProposals{m: map[string]json.RawMessage{}}, Bus: r, Now: func() time.Time { return t0 },
Write: writer(map[string]string{}), Questions: questions}
t.Cleanup(func() { proposing = nil })
@@ -271,7 +290,7 @@ func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
t.Fatalf("a read may wait %s; ADR 0332 says 60 s", AnswerWaitsAtMost)
}
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
most, every := AnswerWaitsAtMost, AnswerLooksEvery
AnswerWaitsAtMost, AnswerLooksEvery = 100*time.Millisecond, 10*time.Millisecond
@@ -285,3 +304,165 @@ func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
t.Errorf("a read asked to wait an hour took %s", took)
}
}
// memQuestions is the `questions` state: values with revisions, create-only and compare-and-set, safe for the
// goroutines a test starts.
type memQuestions struct {
mu sync.Mutex
m map[string]json.RawMessage
rev map[string]uint64
n uint64
// failGet fails every read of a key with this prefix.
failGet string
}
func newMemQuestions() *memQuestions {
return &memQuestions{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
}
func (s *memQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.failGet != "" && strings.HasPrefix(key, s.failGet) {
return nil, 0, false, errors.New("the bus did not answer")
}
raw, ok := s.m[key]
return raw, s.rev[key], ok, nil
}
func (s *memQuestions) put(key string, value any) error {
raw, err := json.Marshal(value)
s.n++
s.m[key], s.rev[key] = raw, s.n
return err
}
func (s *memQuestions) Create(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.m[key]; ok {
return stdio.ErrChanged
}
return s.put(key, value)
}
func (s *memQuestions) Update(key string, value any, revision uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.rev[key] != revision {
return stdio.ErrChanged
}
return s.put(key, value)
}
func (s *memQuestions) Put(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.put(key, value)
}
// The edges of ADR 0332 §2 that are allowed: a 60-character headline and 1430 minutes.
func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
p, _ := node(t, "g14")
in := mergeTonight()
in.Headline = strings.Repeat("x", 59) + "?"
in.Expires = 1430 * time.Minute
if _, err := AskQuestion(p, in, newMemQuestions(), newRouter(), t0); err != nil {
t.Fatalf("a 60-character headline lasting 1430 minutes: %v", err)
}
}
// A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it.
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
p, _ := node(t, "g14")
for name, change := range map[string]func(*asks.Warrant){
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
"an option unoffered": func(w *asks.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
} {
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
w := r.chose(key, "o1", t0.Add(time.Minute))
change(&w)
r.records[key], _ = json.Marshal(map[string]any{"state": "chosen", "warrant": w})
if out, _ := ReadAnswer(store, r, key, t0.Add(2*time.Minute)); out["state"] != AnswerNotProven {
t.Errorf("%s read as %v", name, out)
}
}
}
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
nodes := []string{"g14", "shanks", "ace"}
var wg sync.WaitGroup
errs := make([]error, len(nodes))
for i, name := range nodes {
p, _ := node(t, name)
wg.Add(1)
go func(i int, p Paths) {
defer wg.Done()
in := mergeTonight()
in.By = "fix-589"
_, errs[i] = AskQuestion(p, in, store, r, t0)
}(i, p)
}
wg.Wait()
refused := 0
for _, err := range errs {
if err != nil {
if !strings.Contains(err.Error(), "questions already wait") {
t.Errorf("refused for another reason: %v", err)
}
refused++
}
}
if refused != 1 {
t.Errorf("%d of three questions asked at once were refused; one is", refused)
}
}
// A slot that cannot be read refuses the question: the cap fails closed.
func TestTheCapFailsClosed(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
store.failGet = "slot-"
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
t.Fatalf("asked with the slots unreadable: %v", err)
}
if len(r.subjects) != 0 {
t.Error("the operator was asked anyway")
}
}
// A question whose refusal was not heard here, and of which the router holds no record, frees its slot once the
// router has had time to record it, and reads as ended — not open until it expires.
func TestAQuestionTheRouterNeverHeldFreesItsSlot(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
delete(r.records, first) // refused, and the refusal never heard here
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(time.Minute)); err == nil {
t.Fatal("a slot was freed before the router had time to record the question")
}
if out, _ := ReadAnswer(store, r, first, t0.Add(RouterHoldsWithin)); out["state"] != AnswerEnded {
t.Errorf("read as %v", out)
}
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(RouterHoldsWithin)); err != nil {
t.Errorf("the slot of a question the router never held was not freed: %v", err)
}
}
// lockedRouter is the router fake, safe for sessions asking at once.
type lockedRouter struct {
mu sync.Mutex
*router
}
func (l *lockedRouter) Publish(subject string, body any, id string) error {
l.mu.Lock()
defer l.mu.Unlock()
return l.router.Publish(subject, body, id)
}
func (l *lockedRouter) Record(id string) (*stdio.StateEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.router.Record(id)
}