From 46993aaef4fd6a845dc76066a0d620c4d1fb22b5 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:29:57 -0600 Subject: [PATCH 1/2] =?UTF-8?q?test(claim):=20red=20for=20#23=20=E2=80=94?= =?UTF-8?q?=20solo/split=20first-claim-wins,=20conspiracy=20multiple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- claim_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 claim_test.go diff --git a/claim_test.go b/claim_test.go new file mode 100644 index 0000000..6bad3a4 --- /dev/null +++ b/claim_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func postMsg(t *testing.T, ts *httptest.Server, id, body string) (int, map[string]any) { + t.Helper() + return post(t, ts.URL+"/api/nests/"+id+"/messages", body) +} + +func postRequest(t *testing.T, ts *httptest.Server, id, mode, text string) string { + t.Helper() + code, out := postMsg(t, ts, id, `{"author":"owner","kind":"request","mode":"`+mode+`","text":"`+text+`"}`) + if code != http.StatusCreated { + t.Fatalf("request: %d %v", code, out) + } + return out["msg_id"].(string) +} + +func TestSoloSecondClaimRefused(t *testing.T) { + ts, s := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + r := postRequest(t, ts, id, "solo", "count the eggs") + + code1, c1 := postMsg(t, ts, id, `{"author":"Hugin","kind":"claim","refers_to":"`+r+`","text":"on it"}`) + if code1 != http.StatusCreated || c1["to"] != "owner" { + t.Fatalf("first claim should win and address requester: %d %v", code1, c1) + } + code2, out := postMsg(t, ts, id, `{"author":"Munin","kind":"claim","refers_to":"`+r+`","text":"mine too"}`) + if code2 != http.StatusConflict { + t.Fatalf("second solo claim must be refused 409, got %d %v", code2, out) + } + if msg, _ := out["message"].(string); !strings.Contains(msg, "Hugin") { + t.Fatalf("refusal must name the owner Hugin, got %q", msg) + } + if len(s.msgs.All(id)) != 2 { // request + Hugin's claim, not Munin's + t.Fatalf("refused claim must not be stored: %+v", s.msgs.All(id)) + } +} + +func TestConspiracyMultipleClaims(t *testing.T) { + ts, s := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + r := postRequest(t, ts, id, "conspiracy", "is this plan risky") + + for _, c := range []string{"Hugin", "Munin"} { + code, out := postMsg(t, ts, id, `{"author":"`+c+`","kind":"claim","refers_to":"`+r+`","text":"lens `+c+`"}`) + if code != http.StatusCreated || out["refers_to"] != r { + t.Fatalf("conspiracy claim by %s refused: %d %v", c, code, out) + } + } + claims := 0 + for _, m := range s.msgs.All(id) { + if m.Kind == "claim" && m.RefersTo == r { + claims++ + } + } + if claims != 2 { + t.Fatalf("want 2 conspiracy claims stored, got %d", claims) + } +} + +func TestSplitSubtaskFirstClaimWins(t *testing.T) { + ts, _ := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + r := postRequest(t, ts, id, "split", "build and test") + subOf := func(text string) string { + code, out := postMsg(t, ts, id, `{"author":"owner","kind":"request","mode":"solo","text":"`+text+`","refers_to":"`+r+`"}`) + if code != http.StatusCreated { + t.Fatalf("subtask: %d %v", code, out) + } + return out["msg_id"].(string) + } + a := subOf("build") + b := subOf("test") + // same subtask: second claim refused + if code, _ := postMsg(t, ts, id, `{"author":"Hugin","kind":"claim","refers_to":"`+a+`","text":"b1"}`); code != http.StatusCreated { + t.Fatalf("first subtask claim: %d", code) + } + if code, _ := postMsg(t, ts, id, `{"author":"Munin","kind":"claim","refers_to":"`+a+`","text":"b2"}`); code != http.StatusConflict { + t.Fatalf("second claim on same subtask must 409, got %d", code) + } + // different subtask: allowed + if code, _ := postMsg(t, ts, id, `{"author":"Munin","kind":"claim","refers_to":"`+b+`","text":"t1"}`); code != http.StatusCreated { + t.Fatalf("claim on other subtask: %d", code) + } +} From 5ff45f355ad1fa8b52b73c0438ec0eb54550a1cc Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:29:57 -0600 Subject: [PATCH 2/2] =?UTF-8?q?feat(claim):=20arbitration=20per=20mode=20(?= =?UTF-8?q?#5)=20=E2=80=94=20first=20claim=20wins=20except=20conspiracy=20?= =?UTF-8?q?(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nestapi.go | 11 +++++++++++ nestlog.go | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/nestapi.go b/nestapi.go index ed47452..5af1c3c 100644 --- a/nestapi.go +++ b/nestapi.go @@ -132,6 +132,17 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { // Mode (Epic C, same rule shape as kind): Jev first, header escape, "" when unsure. mode, modeBy := s.tagMode(kind, body.Mode, body.Text) + // Claim arbitration (#5, Epic C): first claim wins unless the referent is a + // conspiracy, which expects multiple claims. + if kind == "claim" && body.RefersTo != "" { + if origin, found := s.msgs.ByMsgID(body.RefersTo); found && origin.Mode != "conspiracy" { + 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 + } + } + } + msg := Message{ NestID: nestID, Kind: kind, diff --git a/nestlog.go b/nestlog.go index 587c800..82ea705 100644 --- a/nestlog.go +++ b/nestlog.go @@ -119,6 +119,19 @@ func (s *MessageStore) ByMsgID(msgID string) (Message, bool) { return Message{}, 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) { + s.mu.Lock() + defer s.mu.Unlock() + for _, m := range s.log[nestID] { + if m.Kind == "claim" && m.RefersTo == referentID { + return m.Author, true + } + } + return "", false +} + func (s *MessageStore) save(nestID string) error { if err := os.MkdirAll(s.dir, 0o755); err != nil { return err