From 66cdd053f802280810f3dff3ba0fd0c2f46c3c1e Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:52:55 -0600 Subject: [PATCH 1/2] =?UTF-8?q?test(watchdog):=20red=20for=20#44=20?= =?UTF-8?q?=E2=80=94=20Sentry=20flags=20+=20quarantine=20API=20absent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchdog_test.go | 258 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 watchdog_test.go diff --git a/watchdog_test.go b/watchdog_test.go new file mode 100644 index 0000000..994c7bb --- /dev/null +++ b/watchdog_test.go @@ -0,0 +1,258 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// fakeWatchJev answers kind/mode as choices below the band (so the poster's +// header always wins) and restates_earlier low (the divergence path stays +// quiet). The three watchdog noul questions answer with the configured values. +// Same server shape as fakeDispatchJev, kept local to this test (no shared +// helper edits). +func fakeWatchJev(t *testing.T, scores map[string]float64) *JevClient { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Questions map[string]any `json:"questions"` + } + json.NewDecoder(r.Body).Decode(&body) + out := map[string]any{} + for q := range body.Questions { + switch q { + case "kind": + out[q] = map[string]any{"type": "choice", "choice": "say", "confidence": 0.5} + case "mode": + out[q] = map[string]any{"type": "choice", "choice": "other", "confidence": 0.2} + case "restates_earlier": + out[q] = map[string]any{"type": "noul", "noul": 0.05} + default: + if v, ok := scores[q]; ok { + out[q] = map[string]any{"type": "noul", "noul": v} + } + } + } + json.NewEncoder(w).Encode(map[string]any{"model": "jev-watch-fake", "answers": out, + "usage": map[string]int{"input_tokens": 60, "output_tokens": 0}}) + })) + t.Cleanup(srv.Close) + return &JevClient{url: srv.URL, key: "tk", model: "jev-watch-fake", threshold: 0.7, client: srv.Client()} +} + +func watchScores(fact, contra, answers float64) map[string]float64 { + return map[string]float64{ + "states_fact_without_source": fact, + "contradicts_earlier": contra, + "answers_request": answers, + } +} + +// newWatchServer wires a nest test server with a watchdog-capable Jev, in the +// shape of newDispatchServer but local here. +func newWatchServer(t *testing.T, scores map[string]float64, brain Brain) (*httptest.Server, *Server) { + t.Helper() + ts, s := newTestNestServer(t) + if brain != nil { + s.p.Flock.brain = brain + } + s.p.Flock.SetClassifier(fakeWatchJev(t, scores)) + return ts, s +} + +func findFlag(ts *httptest.Server, id, target string) map[string]any { + for _, m := range nestMessagesNoT(ts, id) { + if m["kind"] == "flag" && m["author"] == "Sentry" && m["refers_to"] == target { + return m + } + } + return nil +} + +func waitForFlag(ts *httptest.Server, id, target string) map[string]any { + deadline := time.Now().Add(2 * time.Second) + for { + if f := findFlag(ts, id, target); f != nil { + return f + } + if time.Now().After(deadline) { + return nil + } + time.Sleep(10 * time.Millisecond) + } +} + +func countSentryFlags(ts *httptest.Server, id string) int { + n := 0 + for _, m := range nestMessagesNoT(ts, id) { + if m["kind"] == "flag" && m["author"] == "Sentry" { + n++ + } + } + return n +} + +func TestSentryQuarantinesFabrication(t *testing.T) { + ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + d := postDeliver(t, ts, id, "Hugin", req["msg_id"].(string), "twelve eggs, from last spring's ledger") + + f := waitForFlag(ts, id, d) + if f == nil { + t.Fatalf("an unsourced-fact deliver must draw a Sentry flag, log: %+v", s.msgs.All(id)) + } + if f["to"] != "owner" { + t.Fatalf("the flag must be handed to the owner, got %v", f["to"]) + } + if !s.p.Ledger.IsQuarantined("Hugin") { + t.Fatal("a flagged crow must be quarantined") + } + + code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"Hugin","kind":"claim","refers_to":"`+req["msg_id"].(string)+`","text":"I will take the next one"}`) + if code != http.StatusForbidden { + t.Fatalf("a quarantined crow's claim must get 403, got %d %v", code, out) + } + if msg, _ := out["message"].(string); !strings.Contains(msg, "Hugin") || !strings.Contains(msg, "quarantined") { + t.Fatalf("the 403 must name the crow and the quarantine, got %q", msg) + } +} + +func TestSentryBelowBandSilent(t *testing.T) { + ts, s := newWatchServer(t, watchScores(0.4, 0.4, 0.9), nil) // nothing reaches the 0.7 band + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + postDeliver(t, ts, id, "Hugin", req["msg_id"].(string), "I think there are about a dozen") + time.Sleep(400 * time.Millisecond) + + if n := countSentryFlags(ts, id); n != 0 { + t.Fatalf("below-band verdicts must stay silent, got %d flags", n) + } + if s.p.Ledger.IsQuarantined("Hugin") { + t.Fatal("a below-band deliver must not quarantine the crow") + } + code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"Hugin","kind":"claim","refers_to":"`+req["msg_id"].(string)+`","text":"mine"}`) + if code == http.StatusForbidden { + t.Fatalf("an unflagged crow must not be silenced, got 403 %v", out) + } + if code != http.StatusCreated { + t.Fatalf("claim should post, got %d %v", code, out) + } + time.Sleep(400 * time.Millisecond) + if n := countSentryFlags(ts, id); n != 0 { + t.Fatalf("the claim path stays below band too, got %d flags", n) + } +} + +func TestSentryOneFlagPerExpectation(t *testing.T) { + ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + d := postDeliver(t, ts, id, "Hugin", req["msg_id"].(string), "twelve eggs, sourced nowhere") + if waitForFlag(ts, id, d) == nil { + t.Fatal("setup: first fabrication must flag") + } + + // The quarantined crow's next POST is turned away at the door (403), + // so it cannot generate a second flag through the API. + code, _ := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"Hugin","kind":"deliver","refers_to":"`+req["msg_id"].(string)+`","text":"also twelve"}`) + if code != http.StatusForbidden { + t.Fatalf("quarantined re-post must get 403, got %d", code) + } + + // A later message already in the log (posted before quarantine, e.g. by + // the harness path) must reuse the one flag: later breaches are logged, + // not posted. + m2, err := s.msgs.Append(Message{NestID: id, Kind: "deliver", Author: "Hugin", To: "owner", + Text: "twelve again", RefersTo: req["msg_id"].(string), Timestamp: time.Now().UTC().Format(time.RFC3339)}) + if err != nil { + t.Fatal(err) + } + s.checkWatchdog(nestOf(s, id), m2) + if n := countSentryFlags(ts, id); n != 1 { + t.Fatalf("at most one flag per crow per expectation, got %d", n) + } +} + +func TestSentryNeverSeesOwner(t *testing.T) { + ts, s := newWatchServer(t, watchScores(0.95, 0.9, 0.05), nil) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"deliver","refers_to":"`+req["msg_id"].(string)+`","text":"twelve eggs, from last spring's ledger"}`) + if code != http.StatusCreated { + t.Fatalf("owner post: %d %v", code, out) + } + time.Sleep(400 * time.Millisecond) + if n := countSentryFlags(ts, id); n != 0 { + t.Fatalf("the Sentry never inspects owner messages, got %d flags: %+v", n, s.msgs.All(id)) + } +} + +func TestQuarantineSilencesUnprompted(t *testing.T) { + rb := &recordBrain{} + ts, s := newWatchServer(t, watchScores(0.05, 0.05, 0.95), rb) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + promote(s, "Hugin", "Munin") // both may take open work + s.p.Ledger.Quarantine("Hugin") + if err := s.p.Ledger.Save(); err != nil { + t.Fatal(err) + } + + post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"tidy the branch notes"}`) + awaitMessages(t, ts, id, 2) + + rb.mu.Lock() + defer rb.mu.Unlock() + for _, pr := range rb.pairs { + if strings.Contains(pr[0], "You are Hugin,") { + t.Fatalf("a quarantined crow must not be summoned for open work: %q", pr[0]) + } + } + invited := false + for _, pr := range rb.pairs { + if strings.Contains(pr[0], "You are Munin,") { + invited = true + } + } + if !invited { + t.Fatalf("the unquarantined regular should still be invited, calls: %v", rb.pairs) + } +} + +func TestWatchdogGoesThroughFlock(t *testing.T) { + ts, s := newWatchServer(t, watchScores(0.95, 0.9, 0.05), nil) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + + // A saturated flock must block the verdict: it goes through Flock.reserve + // (a direct client call would still flag). Adapted from the literal + // criterion (zero-capacity sem alone): the POST path itself tags through + // Flock.ClassifyKind, so with a live ctx a saturated flock would hang the + // POST forever; ending the server ctx makes every wrapper call fail fast + // (header fallback) while proving the watchdog can never obtain a slot. + s.p.Flock.sem = make(chan struct{}, 0) + canceled, cancel := context.WithCancel(context.Background()) + cancel() + s.ctx = canceled + + d := postDeliver(t, ts, id, "Hugin", req["msg_id"].(string), "twelve eggs, unsourced") // 201: the message is unaffected + time.Sleep(400 * time.Millisecond) + if n := countSentryFlags(ts, id); n != 0 { + t.Fatalf("a saturated flock must not flag (watchdog must go through Flock), got %d flags: %+v", n, s.msgs.All(id)) + } + _ = d +} -- 2.54.0 From 084a4309d46c36545d71aaf008959c3d32547380 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:55:12 -0600 Subject: [PATCH 2/2] feat(watchdog): Sentry flags + quarantine on above-band Jev verdicts (#44) --- EVENTS.md | 6 ++-- jev.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++ ledger.go | 36 +++++++++++++++++++++-- nestapi.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 198 insertions(+), 7 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index 414a4b4..4d7968a 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -44,7 +44,7 @@ A nest is a conversation. Members (the owner and crows) post messages of a **kin | `decline` | a crow | Refuses a request with a reason. Counts as responding. | | `ask_owner` | owner-facing service roles only: Quill, Sentry, harness | A decision only the owner can make, parked in the nest with an exact `approval_phrase`. An owner reply in the nest whose trimmed lowercased text equals that phrase (`refers_to` the ask) resolves it `approved`; anything else — including any other reply, silence, or the 10-minute timeout — is not a yes. Crows may not post it: a crow's `ask_owner` header is dropped and the message tags as it otherwise would. Fail safe, like every approval. | | `nudge` | harness only | A Jev verdict handed to one crow to proceed on a missed expectation. Not a chat message from a crow. | -| `flag` | Sentry only | A suspected contract breach. Quarantines the crow and opens an `ask_owner`. | +| `flag` | Sentry only | A suspected contract breach. Quarantines the crow and opens an `ask_owner`. Posting a flag also quarantines the crow until the owner lifts it (the `ask_owner` escalation lands with #45); a quarantined crow's POSTs get 403 (see Guardrails). The Sentry's watchdog verdicts are logged as `watch` flight events carrying the three noul scores (`answers_request`, `states_fact_without_source`, `contradicts_earlier`). | ### Payload @@ -78,7 +78,7 @@ Reconnect works the same as the list stream: each SSE message carries an `id:` l | Method | Path | Body / query | Returns | |---|---|---|---| | GET | `/api/nests/{nest_id}/messages` | | The nest's records (payload table above), each with `approval_phrase` and a computed `resolved`. 404 unknown nest. | -| POST | `/api/nests/{nest_id}/messages` | `{"author", "text", "to"?, "kind"? (crow header), "refers_to"?, "approval_phrase"? (honored only when the resulting kind is `ask_owner`)}` | 201 with the stored record (server-assigned `kind`, `tagged_by`, `msg_id`). A fresh `ask_owner` reads `resolved: ""`. 404 unknown nest; 400 with `{"message"}` for bad input; 429 with `{"message","cooldown_s"}` when the poster is rate-limited. | +| POST | `/api/nests/{nest_id}/messages` | `{"author", "text", "to"?, "kind"? (crow header), "refers_to"?, "approval_phrase"? (honored only when the resulting kind is `ask_owner`)}` | 201 with the stored record (server-assigned `kind`, `tagged_by`, `msg_id`). A fresh `ask_owner` reads `resolved: ""`. 404 unknown nest; 400 with `{"message"}` for bad input; 403 with `{"message"}` when the crow author is quarantined; 429 with `{"message","cooldown_s"}` when the poster is rate-limited. | | GET | `/api/events` | `?nest_id=` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. | ### Tagging [decided 2026-10-27] @@ -90,6 +90,8 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev - `text` 1–2,000 characters after trimming (same cap as a list item). - `author` must be `"owner"` or a member of the nest; a non-member posts through the assistant or a member (room membership is a boundary). Exception: the owner-facing service roles `"Quill"` and `"Sentry"` may post `ask_owner` (they serve every nest and are never members). The owner may also park an `ask_owner` for themselves. - `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`, `decline`). Crows may not post `nudge`, `flag` or `ask_owner`: an `ask_owner` header from a crow is dropped and the message is tagged as it otherwise would be (only an owner reply can answer an ask, so the attempt is harmless talk), while `nudge` and `flag` headers are rejected with 400. +- Watchdog (E3): every crow `claim` or `deliver` that refers to a request gets one Jev call (`Flock.ClassifyWatch`, strategies 4/8 in `JEV_STRATEGIES.md`) asking three noul questions about it against its request. Any score at or above the band, or `answers_request` at or below `1 − band` (band = `CROWS_JEV_THRESHOLD`), is a breach: the Sentry posts a `flag` naming the scores and the crow is quarantined. At most one flag per crow per referent (later breaches are logged, not posted). Owner messages are never inspected. +- A quarantined crow's POSTs (any crow kind) return 403 with `{"message": " is quarantined pending the owner's decision."}`, and it is skipped by the open-, conspiracy- and directed-response selectors. Quarantine is fail-safe: it sticks until the owner lifts it (`ask_owner` escalation lands with #45). - Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`. ## Settled details diff --git a/jev.go b/jev.go index 80b8660..b2c131e 100644 --- a/jev.go +++ b/jev.go @@ -313,6 +313,86 @@ func (f *Flock) ClassifyDivergence(ctx context.Context, state any, budgets ...*B return p, ok } +// watchdogQuestions is the Sentry's off-task check (E3, JEV_STRATEGIES +// strategies 4 and 8): three crisp yes/no noul questions about one crow +// message against the request it answers. noul carries no confidence, so the +// probability itself is band-thresholded (strategy 3). +var watchdogQuestions = map[string]any{ + "answers_request": map[string]any{ + "type": "noul", + "instructions": "Does `message` actually deliver what `request` asked of `crow`?", + "criteria": map[string]string{ + "true": "It answers the request, or plainly takes it on.", + "false": "It talks around it, answers a different question, or is off-task.", + }, + }, + "states_fact_without_source": map[string]any{ + "type": "noul", + "instructions": "Does `message` assert a checkable fact while showing no source for it?", + "criteria": map[string]string{ + "true": "It states a specific, checkable fact (a number, a quote, an event) with no stated source.", + "false": "It is an opinion, a plan, or the facts it states are sourced.", + }, + }, + "contradicts_earlier": map[string]any{ + "type": "noul", + "instructions": "Does `message` contradict what the `earlier` delivers on the same request say?", + "criteria": map[string]string{ + "true": "It disagrees with an earlier deliver on the same request.", + "false": "It agrees with, or simply adds to, what `earlier` said.", + }, + }, +} + +// ClassifyWatch asks the watchdog questions of one crow message and returns +// each answer's noul probability. ok is false on transport error or a missing +// answer (a noul answer always carries a probability). +func (j *JevClient) ClassifyWatch(ctx context.Context, state any) (map[string]float64, bool) { + if j == nil { + return nil, false + } + answers, _, _, err := j.SystemOne(ctx, state, watchdogQuestions) + if err != nil { + return nil, false + } + scores := make(map[string]float64, len(watchdogQuestions)) + for name := range watchdogQuestions { + a := answers[name] + if a.Noul == nil { + return nil, false + } + scores[name] = *a.Noul + } + return scores, true +} + +// ClassifyWatch runs the watchdog check under the flock guardrails (cap, +// timeout, flight log with the three noul probabilities recorded for +// calibration, strategy 5). +func (f *Flock) ClassifyWatch(ctx context.Context, state any, budgets ...*Budget) (map[string]float64, bool) { + if f.jev == nil { + return nil, false + } + for _, b := range budgets { + if err := b.Check(); err != nil { + return nil, false + } + } + release, slot := f.reserve(ctx) + if !slot { + return nil, false + } + defer release() + cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout) + defer cancel() + start := time.Now() + scores, ok := f.jev.ClassifyWatch(cctx, state) + if f.log != nil { + f.log.Write(Event{Kind: "watch", Crow: "Sentry", Model: f.jev.model, Ms: time.Since(start).Milliseconds(), Prob: scores}) + } + return scores, ok +} + // ClassifyKind tags a nest message under the same guardrails as any call: // flock cap, timeout and flight log, with the probabilities recorded. func (f *Flock) ClassifyKind(ctx context.Context, text string, budgets ...*Budget) (string, map[string]float64, bool) { diff --git a/ledger.go b/ledger.go index 296e19b..9758355 100644 --- a/ledger.go +++ b/ledger.go @@ -55,9 +55,10 @@ type Requester struct { } type Ledger struct { - Crows map[string]*Record `json:"crows"` // key: crow|task_type - Flock map[string]*Record `json:"flock"` // key: task_type - Requesters map[string]*Requester `json:"requesters"` + Crows map[string]*Record `json:"crows"` // key: crow|task_type + Flock map[string]*Record `json:"flock"` // key: task_type + Requesters map[string]*Requester `json:"requesters"` + Quarantined map[string]bool `json:"quarantined,omitempty"` // crow -> silenced by the Sentry watchdog (E3) path string mu sync.Mutex @@ -83,6 +84,9 @@ func LoadLedger(path string) (*Ledger, error) { if l.Requesters == nil { l.Requesters = map[string]*Requester{} } + if l.Quarantined == nil { + l.Quarantined = map[string]bool{} + } return l, nil } @@ -156,6 +160,32 @@ func (l *Ledger) MarshalSnapshot() ([]byte, error) { return json.MarshalIndent(l, "", " ") } +// Quarantine silences a crow the Sentry flagged (E3): it may not claim, +// deliver or speak unprompted until the owner lifts it. Fail-safe — the mark +// sticks. Callers pair this with Save to persist it. +func (l *Ledger) Quarantine(crow string) { + l.mu.Lock() + defer l.mu.Unlock() + if l.Quarantined == nil { + l.Quarantined = map[string]bool{} + } + l.Quarantined[crow] = true +} + +// LiftQuarantine restores a quarantined crow to the flock (owner decision, #45). +func (l *Ledger) LiftQuarantine(crow string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.Quarantined, crow) +} + +// IsQuarantined reports whether the Sentry watchdog has silenced a crow. +func (l *Ledger) IsQuarantined(crow string) bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.Quarantined[crow] +} + func (l *Ledger) requester(id string) *Requester { r := l.Requesters[id] if r == nil { diff --git a/nestapi.go b/nestapi.go index 9a41fe0..2fe0fa7 100644 --- a/nestapi.go +++ b/nestapi.go @@ -170,6 +170,13 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind)) return } + // Watchdog enforcement (E3): a crow the Sentry flagged is silenced until + // the owner lifts the quarantine. The owner and the service roles never + // pass through this gate. + if body.Author != "owner" && !slices.Contains(serviceRoles, body.Author) && s.p.Ledger.IsQuarantined(body.Author) { + httpError(w, http.StatusForbidden, body.Author+" is quarantined pending the owner's decision.") + return + } // Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort. // A validated ask_owner header bypasses the classifier: Jev's kind @@ -273,6 +280,11 @@ func (s *Server) triggerResponse(nest Nest, record Message) { if record.Kind == "deliver" { go s.checkDivergence(nest, record) } + + // Sentry watchdog (E3): watch every crow claim/deliver that answers a request. + if slices.Contains(nest.Crows, record.Author) && (record.Kind == "claim" || record.Kind == "deliver") { + go s.checkWatchdog(nest, record) + } } var modePostable = map[string]bool{"solo": true, "conspiracy": true, "split": true} @@ -362,15 +374,19 @@ func (s *Server) respondOpenRequest(nest Nest, req Message) { } } -// maySpeakUnprompted is the trust gate for open (unaddressed) work. +// maySpeakUnprompted is the trust gate for open (unaddressed) work. A crow the +// Sentry quarantined (E3) stays silent regardless of its level. func (s *Server) maySpeakUnprompted(crow string) bool { - return s.p.Ledger.Level(crow, "other") > Stranger + return s.p.Ledger.Level(crow, "other") > Stranger && !s.p.Ledger.IsQuarantined(crow) } // 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." func (s *Server) respondToRequest(nest Nest, req Message) { + if s.p.Ledger.IsQuarantined(req.To) { + return // a quarantined crow waits for the owner (E3) + } s.respondOne(nest, req, req.To, false) } @@ -491,6 +507,69 @@ func (s *Server) checkDivergence(nest Nest, m Message, budgets ...*Budget) { } } +// checkWatchdog is the Sentry's off-task check (E3, JEV_STRATEGIES strategies +// 4 and 8): one Jev call per crow claim or deliver against the request it +// answers. A verdict at or above the band posts a flag and quarantines the +// crow. Owner messages are never inspected; at most one flag per crow per +// referent; quarantine sticks until the owner lifts it (ask_owner lands in +// #45). +func (s *Server) checkWatchdog(nest Nest, m Message) { + if m.Author == "owner" || m.RefersTo == "" { + return + } + target, found := s.msgs.ByMsgID(m.RefersTo) + if !found { + return + } + for _, x := range s.msgs.All(nest.ID) { + if x.Kind != "flag" { + continue + } + if flagged, ok := s.msgs.ByMsgID(x.RefersTo); ok && flagged.Author == m.Author && flagged.RefersTo == m.RefersTo { + return // this crow is already flagged on this expectation + } + } + var earlier []string + for _, d := range s.msgs.ReportsTo(nest.ID, m.RefersTo, "deliver") { + if d.ID != m.ID { + earlier = append(earlier, d.Text) + } + } + state := map[string]any{"request": target.Text, "crow": m.Author, "message": m.Text, "earlier": earlier} + budget := &Budget{name: "watchdog", limit: s.p.G.TokenBudgetPerItem} + scores, ok := s.p.Flock.ClassifyWatch(s.ctx, state, budget) + if !ok { + return + } + thr := s.p.Flock.jev.threshold + answers, hasAnswers := scores["answers_request"] + if scores["states_fact_without_source"] < thr && scores["contradicts_earlier"] < thr && + (!hasAnswers || answers > 1-thr) { + return // below band on every question: say nothing, do nothing + } + var parts []string + for _, q := range []string{"answers_request", "states_fact_without_source", "contradicts_earlier"} { + if v, has := scores[q]; has { + parts = append(parts, fmt.Sprintf("%s %.2f", q, v)) + } + } + flag := Message{ + NestID: nest.ID, + Kind: "flag", + Author: "Sentry", + Text: fmt.Sprintf("%s looks off-task: %s (request: %.60s)", m.Author, strings.Join(parts, ", "), target.Text), + To: "owner", + RefersTo: m.MsgID(), + TaggedBy: "harness", + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + if record, err := s.msgs.Append(flag); err == nil { + s.emitNestMessage(record) + } + s.p.Ledger.Quarantine(m.Author) + s.p.save() +} + // nestReply asks one crow once and reads its KIND header. ok means the reply // was responsive (claim, deliver or decline). func (s *Server) nestReply(system, prompt, crow string, budget *Budget) (kind, body string, ok bool) { -- 2.54.0