E3: Sentry watchdog detection + quarantine (#44) #50
No files matched your search
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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
|
### 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 |
|
| 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. |
|
| 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=<id>` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. |
|
| GET | `/api/events` | `?nest_id=<id>` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. |
|
||||||
|
|
||||||
### Tagging [decided 2026-10-27]
|
### 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).
|
- `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.
|
- `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.
|
- `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": "<author> 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`.
|
- Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`.
|
||||||
|
|
||||||
## Settled details
|
## Settled details
|
||||||
|
|||||||
@@ -313,6 +313,86 @@ func (f *Flock) ClassifyDivergence(ctx context.Context, state any, budgets ...*B
|
|||||||
return p, ok
|
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:
|
// ClassifyKind tags a nest message under the same guardrails as any call:
|
||||||
// flock cap, timeout and flight log, with the probabilities recorded.
|
// 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) {
|
func (f *Flock) ClassifyKind(ctx context.Context, text string, budgets ...*Budget) (string, map[string]float64, bool) {
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ type Requester struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Ledger struct {
|
type Ledger struct {
|
||||||
Crows map[string]*Record `json:"crows"` // key: crow|task_type
|
Crows map[string]*Record `json:"crows"` // key: crow|task_type
|
||||||
Flock map[string]*Record `json:"flock"` // key: task_type
|
Flock map[string]*Record `json:"flock"` // key: task_type
|
||||||
Requesters map[string]*Requester `json:"requesters"`
|
Requesters map[string]*Requester `json:"requesters"`
|
||||||
|
Quarantined map[string]bool `json:"quarantined,omitempty"` // crow -> silenced by the Sentry watchdog (E3)
|
||||||
|
|
||||||
path string
|
path string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -83,6 +84,9 @@ func LoadLedger(path string) (*Ledger, error) {
|
|||||||
if l.Requesters == nil {
|
if l.Requesters == nil {
|
||||||
l.Requesters = map[string]*Requester{}
|
l.Requesters = map[string]*Requester{}
|
||||||
}
|
}
|
||||||
|
if l.Quarantined == nil {
|
||||||
|
l.Quarantined = map[string]bool{}
|
||||||
|
}
|
||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +160,32 @@ func (l *Ledger) MarshalSnapshot() ([]byte, error) {
|
|||||||
return json.MarshalIndent(l, "", " ")
|
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 {
|
func (l *Ledger) requester(id string) *Requester {
|
||||||
r := l.Requesters[id]
|
r := l.Requesters[id]
|
||||||
if r == nil {
|
if r == nil {
|
||||||
|
|||||||
+81
-2
@@ -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))
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind))
|
||||||
return
|
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.
|
// Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort.
|
||||||
// A validated ask_owner header bypasses the classifier: Jev's kind
|
// 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" {
|
if record.Kind == "deliver" {
|
||||||
go s.checkDivergence(nest, record)
|
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}
|
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 {
|
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.
|
// 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."
|
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) {
|
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)
|
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
|
// nestReply asks one crow once and reads its KIND header. ok means the reply
|
||||||
// was responsive (claim, deliver or decline).
|
// was responsive (claim, deliver or decline).
|
||||||
func (s *Server) nestReply(system, prompt, crow string, budget *Budget) (kind, body string, ok bool) {
|
func (s *Server) nestReply(system, prompt, crow string, budget *Budget) (kind, body string, ok bool) {
|
||||||
|
|||||||
Reference in new issue
Block a user