feat(watchdog): quarantine escalates to ask_owner; reinstate/retire resolve fail-safe (#45)

This commit is contained in:
Smokey committed 2026-10-28 09:20:34 -06:00
1 parent bf4844c010
commit 720352c45b
6 files changed
+207 -23

No files matched your search

+147 -18
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"net/http"
"os"
"slices"
"strings"
"time"
@@ -87,16 +88,33 @@ func (s *Server) nestRecord(m Message) map[string]any {
return rec
}
// askPhrases lists the exact owner replies that resolve an ask: its approval
// phrase, plus — for a Sentry reinstate ask — the retire twin (#45): the
// owner's explicit call to remove the crow is a decision, not a non-answer.
func askPhrases(m Message) []string {
phrase := strings.ToLower(strings.TrimSpace(m.ApprovalPhrase))
if phrase == "" {
return nil
}
out := []string{phrase}
if tail, isRe := strings.CutPrefix(phrase, "reinstate "); isRe {
if name := strings.TrimSpace(tail); name != "" {
out = append(out, "retire "+name)
}
}
return out
}
// askResolved computes an ask_owner's state: approved only when the owner
// replied to it with the exact approval phrase (trimmed and lowercased on both
// sides); rejected when nothing answered it before the timeout; "" while open.
// replied to it with an exact phrase (the approval phrase or, on a watchdog
// reinstate ask, its retire twin; trimmed and lowercased on both sides);
// rejected when nothing answered it before the timeout; "" while open.
// Anything but an explicit yes is a no.
func (s *Server) askResolved(m Message) string {
if m.Kind != kindAskOwner {
return ""
}
phrase := strings.ToLower(strings.TrimSpace(m.ApprovalPhrase))
if phrase != "" {
for _, phrase := range askPhrases(m) {
for _, r := range s.msgs.All(m.NestID) {
if r.Author == "owner" && r.RefersTo == m.MsgID() && r.ApprovalPhrase == "" &&
strings.ToLower(strings.TrimSpace(r.Text)) == phrase {
@@ -104,7 +122,7 @@ func (s *Server) askResolved(m Message) string {
}
}
}
if ts, err := time.Parse(time.RFC3339, m.Timestamp); err == nil && time.Since(ts) > s.askTimeout {
if ts, err := time.Parse(time.RFC3339, m.Timestamp); err == nil && time.Since(ts) > time.Duration(s.askTimeout.Load()) {
return "rejected"
}
return ""
@@ -153,6 +171,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
askOwner = false
}
// Watchdog enforcement (E3): a crow the Sentry flagged is silenced until
// the owner lifts the quarantine. This gate runs before the membership
// check so a retired crow (#45) whose quarantine was never lifted still
// reads 403, not 400. The owner and the service roles never pass through.
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
}
if body.Author == "owner" {
// owner can always post
} else if !askOwner && !slices.Contains(nest.Crows, body.Author) {
@@ -170,13 +196,6 @@ 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
@@ -251,10 +270,97 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
s.emitNestMessage(record)
s.triggerResponse(nest, record)
s.applyQuarantineDecision(record)
writeJSON(w, http.StatusCreated, s.nestRecord(record))
}
// applyQuarantineDecision is the acting side of the #45 escalation: when the
// owner's reply refers to a Sentry reinstate/retire ask and matches one of its
// exact phrases, the decision runs — reinstate lifts the quarantine, retire
// removes the crow from the registry (spun) or the nest (persona) while the
// quarantine mark stays. Any other reply does nothing (fail safe). Effects
// are idempotent: the lift re-checks the quarantine, removals are no-ops when
// already gone, and the Sentry's triage/watch approval fires at most once per
// ask — only on the first exact-phrase owner reply.
func (s *Server) applyQuarantineDecision(record Message) {
if record.Author != "owner" || record.RefersTo == "" || record.ApprovalPhrase != "" {
return
}
ask, found := s.msgs.ByMsgID(record.RefersTo)
if !found || ask.Kind != kindAskOwner || ask.RefersTo == "" {
return
}
phrases := askPhrases(ask)
reply := strings.ToLower(strings.TrimSpace(record.Text))
matched := ""
for _, p := range phrases {
if reply == p {
matched = p
break
}
}
if matched == "" {
return
}
fl, found := s.msgs.ByMsgID(ask.RefersTo)
if !found || fl.Kind != "flag" || fl.RefersTo == "" {
return
}
breach, found := s.msgs.ByMsgID(fl.RefersTo)
if !found || !s.p.Ledger.IsQuarantined(breach.Author) {
return // fail safe: no live quarantine, no action
}
crow := breach.Author
if strings.HasPrefix(matched, "retire ") {
s.retireCrow(record.NestID, crow)
} else {
s.p.Ledger.LiftQuarantine(crow)
s.p.save()
}
if s.firstAskDecision(ask, record, phrases) {
// Either exact phrase is the owner's explicit call: the Sentry's
// watch record takes one approval for it, once per ask.
s.p.Ledger.Judge([]string{"Sentry"}, "triage/watch", true, s.p.G.PromoteAfter)
s.p.save()
}
}
// firstAskDecision reports whether this reply is the ask's first exact-phrase
// owner answer: the trust bump must not fire twice for one decision.
func (s *Server) firstAskDecision(ask, decision Message, phrases []string) bool {
for _, m := range s.msgs.All(ask.NestID) {
if m.ID >= decision.ID || m.Author != "owner" || m.RefersTo != ask.MsgID() || m.ApprovalPhrase != "" {
continue
}
t := strings.ToLower(strings.TrimSpace(m.Text))
for _, p := range phrases {
if t == p {
return false
}
}
}
return true
}
// retireCrow takes a crow off the flock on the owner's explicit word (the
// only hard stop the Sentry's watchdog has; CHATROOM_SPEC section 6): a spun
// crow is deleted from the registry, a persona crow is only removed from the
// nest's membership — the persona itself never dies.
func (s *Server) retireCrow(nestID, crow string) {
if s.crows != nil {
if _, found := s.crows.Get(crow); found {
if err := s.crows.Remove(crow); err != nil {
fmt.Fprintln(os.Stderr, "could not retire crow:", err)
}
return
}
}
if err := s.nests.RemoveCrow(nestID, crow); err != nil {
fmt.Fprintln(os.Stderr, "could not retire crow:", err)
}
}
// triggerResponse is the event-driven response rule (no idle loops): a
// conspiracy invites several crows, each from its own lens; any other request
// gets exactly one responder through the matching path; a deliver gets one
@@ -511,10 +617,10 @@ 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).
// answers. A verdict at or above the band posts a flag, quarantines the crow
// and opens the reinstate/retire ask_owner escalation (#45). Owner messages
// are never inspected; at most one flag per crow per referent; quarantine
// sticks until the owner's exact phrase resolves the ask.
func (s *Server) checkWatchdog(nest Nest, m Message) {
if m.Author == "owner" || m.RefersTo == "" {
return
@@ -565,11 +671,34 @@ func (s *Server) checkWatchdog(nest Nest, m Message) {
TaggedBy: "harness",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if record, err := s.msgs.Append(flag); err == nil {
s.emitNestMessage(record)
flagRecord, err := s.msgs.Append(flag)
if err == nil {
s.emitNestMessage(flagRecord)
}
// Quarantine, then escalate (#45): the Sentry never hard-kills. The flag
// is followed by an ask_owner offering the two exact phrases — reinstate
// lifts, retire removes; anything else keeps the quarantine.
s.p.Ledger.Quarantine(m.Author)
s.p.save()
if err != nil {
return // no flag in the log, nothing for the ask to refer to
}
reinstate := "reinstate " + m.Author
retire := "retire " + m.Author
ask := Message{
NestID: nest.ID,
Kind: kindAskOwner,
Author: "Sentry",
Text: fmt.Sprintf("Reinstate or retire %s? Reply exactly %q to let it back, or %q to remove it. Anything else keeps the quarantine. Evidence: %s", m.Author, reinstate, retire, flagRecord.Text),
To: "owner",
RefersTo: flagRecord.MsgID(),
TaggedBy: "harness",
ApprovalPhrase: reinstate,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if a, err := s.msgs.Append(ask); err == nil {
s.emitNestMessage(a)
}
}
// nestReply asks one crow once and reads its KIND header. ok means the reply