From c23fcdd73c7c7dd60d1dbb954380e0acdc93356e Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:46:38 -0600 Subject: [PATCH 1/2] =?UTF-8?q?test(trust):=20red=20for=20#27=20=E2=80=94?= =?UTF-8?q?=20stranger=20gating,=20open-request=20claim,=20directed=20clai?= =?UTF-8?q?ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- conspiracy_test.go | 6 ++- nestapi_test.go | 8 +++ trustgate_test.go | 123 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 trustgate_test.go diff --git a/conspiracy_test.go b/conspiracy_test.go index 20f1609..edd3251 100644 --- a/conspiracy_test.go +++ b/conspiracy_test.go @@ -32,8 +32,9 @@ func newRecordServer(t *testing.T) (*httptest.Server, *Server, *recordBrain) { } func TestConspiracyInvitesDistinctLenses(t *testing.T) { - ts, _, rb := newRecordServer(t) + ts, srv, rb := newRecordServer(t) id := newNest(t, ts, "Lab", `"Hugin","Munin","Soot"`) + promote(srv, "Hugin", "Munin", "Soot") // earned the right to take open work code, req := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","kind":"request","mode":"conspiracy","text":"is this launch risky"}`) if code != http.StatusCreated || req["mode"] != "conspiracy" { @@ -68,8 +69,9 @@ func TestConspiracyInvitesDistinctLenses(t *testing.T) { } func TestConspiracyInviteCapped(t *testing.T) { - ts, _, rb := newRecordServer(t) + ts, srv, rb := newRecordServer(t) id := newNest(t, ts, "Wide", strings.Join(quoteAll(roster), ",")) + promote(srv, roster...) code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","kind":"request","mode":"conspiracy","invited":4,"text":"risk"}`) if code != http.StatusCreated { diff --git a/nestapi_test.go b/nestapi_test.go index fd9d720..ed5b3f9 100644 --- a/nestapi_test.go +++ b/nestapi_test.go @@ -206,3 +206,11 @@ func TestDeliverAddressesRequester(t *testing.T) { t.Fatalf("deliver must go back to the requester, got to=%v", d["to"]) } } + +// promote earns crows the right to speak unprompted (trust ladder): their own +// record on task type "other" reaches Regular. +func promote(s *Server, crows ...string) { + for _, c := range crows { + s.p.Ledger.Crows[c+"|other"] = &Record{Approved: 10, Streak: 10, Level: Regular} + } +} diff --git a/trustgate_test.go b/trustgate_test.go new file mode 100644 index 0000000..b8887ff --- /dev/null +++ b/trustgate_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +type gateBrain struct { + mu sync.Mutex + calls []string +} + +func (b *gateBrain) Name() string { return "gate" } +func (b *gateBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + b.mu.Lock() + b.calls = append(b.calls, system+"\n"+prompt) + b.mu.Unlock() + return "KIND: claim\nOn it.", 10, nil +} + +func newGateServer(t *testing.T) (*httptest.Server, *Server, *gateBrain) { + t.Helper() + ts, s := newTestNestServer(t) + gb := &gateBrain{} + s.p.Flock.brain = gb + return ts, s, gb +} + +func TestStrangerOnlyWhenAddressed(t *testing.T) { + ts, s, gb := newGateServer(t) + id := newNest(t, ts, "Lab", `"Hugin","Munin","Soot"`) + promote(s, "Munin", "Soot") // Hugin stays a stranger + code, _ := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","mode":"conspiracy","text":"is this risky"}`) + if code != http.StatusCreated { + t.Fatalf("post: %d", code) + } + awaitMessages(t, ts, id, 3) // request + 2 claims (Munin, Soot) + + gb.mu.Lock() + defer gb.mu.Unlock() + for _, call := range gb.calls { + if strings.Contains(call, "You are Hugin,") { + t.Fatal("a stranger must not be invited for open work") + } + } + names := 0 + for _, n := range []string{"You are Munin,", "You are Soot,"} { + for _, call := range gb.calls { + if strings.Contains(call, n) { + names++ + break + } + } + } + if names != 2 { + t.Fatalf("both regular crows should be invited, calls: %v", gb.calls) + } +} + +func TestRegularMayClaimOpen(t *testing.T) { + ts, s, gb := newGateServer(t) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + promote(s, "Hugin") // only Hugin is a regular + post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"tidy the branch notes"}`) // no to, no mode header + awaitMessages(t, ts, id, 2) + + gb.mu.Lock() + defer gb.mu.Unlock() + if len(gb.calls) != 1 { + t.Fatalf("an open request invites exactly one eligible crow, calls: %v", gb.calls) + } + if !strings.Contains(gb.calls[0], "You are Hugin,") { + t.Fatalf("the eligible crow should be invited, got %q", gb.calls[0]) + } +} + +func TestAllStrangersStayOpen(t *testing.T) { + ts, _, gb := newGateServer(t) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"do the thing"}`) + if code != http.StatusCreated { + t.Fatalf("post: %d %v", code, out) + } + time.Sleep(300 * time.Millisecond) + gb.mu.Lock() + defer gb.mu.Unlock() + if len(gb.calls) != 0 { + t.Fatalf("a nest of only strangers must not be summoned, calls: %v", gb.calls) + } + if len(nestMessages(t, ts, id)) != 1 { + t.Fatal("the request must stay open until addressed") + } +} + +func TestClaimAuthorHolds(t *testing.T) { + ts, s := newScriptedServer(t, "hmm idle chatter", "still not an answer") // addressee stays silent + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + code, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","to":"Hugin","text":"count the eggs"}`) + if code != http.StatusCreated { + t.Fatalf("request: %d", code) + } + // Munin cannot claim what owner asked Hugin. + c2, out := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"Munin","kind":"claim","refers_to":"`+req["msg_id"].(string)+`","text":"mine"}`) + // give the addressee's must-respond (and its flag) time to settle first + awaitMessages(t, ts, id, 2) + if c2 != http.StatusConflict { + t.Fatalf("directed assignment must refuse a non-addressee claim, got %d %v", c2, out) + } + if msg, _ := out["message"].(string); !strings.Contains(msg, "Hugin") { + t.Fatalf("refusal must name the asked crow, got %q", msg) + } + _ = s +} From a2d69507e75de7110bdefa984d619f428c03b1dd Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:46:38 -0600 Subject: [PATCH 2/2] feat(trust): trust-gated invites, open-request claim opportunity, claim-author rule (#27) --- nestapi.go | 41 ++++++++++++++++++++++++++++++++++++----- nestlog.go | 12 ++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/nestapi.go b/nestapi.go index 7638639..b0a2fca 100644 --- a/nestapi.go +++ b/nestapi.go @@ -146,9 +146,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { } // Claim arbitration (#5, Epic C): first claim wins unless the referent is a - // conspiracy, which expects multiple claims. + // conspiracy, which expects multiple claims. Directed assignment holds: + // while the asked crow has not answered, only it may claim. if kind == "claim" && body.RefersTo != "" { if origin, found := s.msgs.ByMsgID(body.RefersTo); found && origin.Mode != "conspiracy" { + if slices.Contains(nest.Crows, origin.To) && origin.To != body.Author && !s.msgs.Answered(origin.NestID, origin.MsgID(), origin.To) { + httpError(w, http.StatusConflict, origin.To+" was asked to take this; it answers or declines first.") + return + } if owner, claimed := s.msgs.FirstClaim(origin.NestID, origin.MsgID()); claimed { httpError(w, http.StatusConflict, origin.MsgID()+" is claimed by "+owner+"; review it instead of redoing it.") return @@ -188,6 +193,8 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { go s.respondElder(nest, record) case slices.Contains(nest.Crows, record.To): go s.respondToRequest(nest, record) + default: + go s.respondOpenRequest(nest, record) } } @@ -257,15 +264,39 @@ func (s *Server) inviteCount(nest Nest, requested int) int { return cap } -// respondConspiracy invites N crows; each answers from its own persona lens so -// distinct strengths produce distinct readings. +// respondConspiracy invites N eligible crows; each answers from its own +// persona lens so distinct strengths produce distinct readings. Strangers are +// skipped: unprompted speech is earned (trust ladder, section 10). func (s *Server) respondConspiracy(nest Nest, req Message, invited int) { - crows := s.p.Flock.pick(nest, invited) - for _, crow := range crows { + n := 0 + for _, crow := range nest.Crows { + if n >= invited { + break + } + if !s.maySpeakUnprompted(crow) { + continue + } + n++ go s.respondOne(nest, req, crow, true) } } +// respondOpenRequest surfaces an unaddressed request to one regular-and-up +// crow as a claim opportunity (section 5: a regular may claim open requests). +func (s *Server) respondOpenRequest(nest Nest, req Message) { + for _, crow := range nest.Crows { + if s.maySpeakUnprompted(crow) { + go s.respondOne(nest, req, crow, false) + return + } + } +} + +// maySpeakUnprompted is the trust gate for open (unaddressed) work. +func (s *Server) maySpeakUnprompted(crow string) bool { + return s.p.Ledger.Level(crow, "other") > Stranger +} + // conspiracyLine is the lens directive added to every invited crow's prompt. const conspiracyLine = "You are one lens among several on this request; answer from your own strength, not the flock's consensus." diff --git a/nestlog.go b/nestlog.go index e0201e8..976d165 100644 --- a/nestlog.go +++ b/nestlog.go @@ -119,6 +119,18 @@ func (s *MessageStore) ByMsgID(msgID string) (Message, bool) { return Message{}, false } +// Answered reports whether crow has claimed, delivered or declined the referent. +func (s *MessageStore) Answered(nestID, referentID, crow string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, m := range s.log[nestID] { + if m.Author == crow && m.RefersTo == referentID && responsiveKind[m.Kind] { + return true + } + } + return false +} + // FirstClaim returns the author of the first claim against a message, and // whether any claim exists. func (s *MessageStore) FirstClaim(nestID, referentID string) (string, bool) {