feat(watchdog): quarantine escalates to ask_owner; reinstate/retire resolve fail-safe (#45)
This commit is contained in:
1 parent
bf4844c010
commit
720352c45b
6 files changed
+207
-23
No files matched your search
+147
-18
@@ -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
|
||||
|
||||
Reference in new issue
Block a user