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
+1
-1
@@ -122,7 +122,7 @@ func TestAskOwnerTimeoutFailSafe(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s.askTimeout = time.Minute
|
s.askTimeout.Store(int64(time.Minute))
|
||||||
if r := askResolved(t, ts, id, stale.MsgID()); r != "rejected" {
|
if r := askResolved(t, ts, id, stale.MsgID()); r != "rejected" {
|
||||||
t.Fatalf("an ask unanswered past the window is not a yes, got resolved=%v", r)
|
t.Fatalf("an ask unanswered past the window is not a yes, got resolved=%v", r)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -99,6 +99,25 @@ func (s *CrowStore) Len() int {
|
|||||||
return len(s.spun)
|
return len(s.spun)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove deletes a spun crow from the registry (the owner's retire call,
|
||||||
|
// #45). A name not in the registry is a no-op: the removal is idempotent.
|
||||||
|
func (s *CrowStore) Remove(name string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
i := slices.IndexFunc(s.spun, func(c SpunCrow) bool { return strings.EqualFold(c.Name, name) })
|
||||||
|
if i < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
old := s.spun
|
||||||
|
rest := slices.Clone(old)
|
||||||
|
s.spun = slices.Delete(rest, i, i+1)
|
||||||
|
if err := s.save(); err != nil {
|
||||||
|
s.spun = old
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// save writes atomically. The caller holds s.mu.
|
// save writes atomically. The caller holds s.mu.
|
||||||
func (s *CrowStore) save() error {
|
func (s *CrowStore) save() error {
|
||||||
data, err := json.MarshalIndent(s.spun, "", " ")
|
data, err := json.MarshalIndent(s.spun, "", " ")
|
||||||
|
|||||||
+9
-2
@@ -186,7 +186,7 @@ func TestAskOwnerTimeoutKeepsQuarantine(t *testing.T) {
|
|||||||
id, req, ask := breachToAsk(t, ts)
|
id, req, ask := breachToAsk(t, ts)
|
||||||
askID := ask["msg_id"].(string)
|
askID := ask["msg_id"].(string)
|
||||||
|
|
||||||
s.askTimeout = time.Millisecond
|
s.askTimeout.Store(int64(time.Millisecond))
|
||||||
time.Sleep(25 * time.Millisecond)
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
|
||||||
if r := askResolved(t, ts, id, askID); r != "rejected" {
|
if r := askResolved(t, ts, id, askID); r != "rejected" {
|
||||||
@@ -200,7 +200,14 @@ func TestAskOwnerTimeoutKeepsQuarantine(t *testing.T) {
|
|||||||
if code != http.StatusForbidden {
|
if code != http.StatusForbidden {
|
||||||
t.Fatalf("a timed-out ask keeps the crow silenced: claim must get 403, got %d %v", code, out)
|
t.Fatalf("a timed-out ask keeps the crow silenced: claim must get 403, got %d %v", code, out)
|
||||||
}
|
}
|
||||||
if f := findFlag(ts, id, ask["refers_to"].(string)); f == nil {
|
flagID := ask["refers_to"].(string)
|
||||||
|
flagSeen := false
|
||||||
|
for _, m := range nestMessages(t, ts, id) {
|
||||||
|
if m["kind"] == "flag" && m["msg_id"] == flagID {
|
||||||
|
flagSeen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !flagSeen {
|
||||||
t.Fatal("the flag must still be in the log")
|
t.Fatal("the flag must still be in the log")
|
||||||
}
|
}
|
||||||
if rec := watchRecord(t, s); rec != nil {
|
if rec := watchRecord(t, s); rec != nil {
|
||||||
|
|||||||
+147
-18
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -87,16 +88,33 @@ func (s *Server) nestRecord(m Message) map[string]any {
|
|||||||
return rec
|
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
|
// 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
|
// replied to it with an exact phrase (the approval phrase or, on a watchdog
|
||||||
// sides); rejected when nothing answered it before the timeout; "" while open.
|
// 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.
|
// Anything but an explicit yes is a no.
|
||||||
func (s *Server) askResolved(m Message) string {
|
func (s *Server) askResolved(m Message) string {
|
||||||
if m.Kind != kindAskOwner {
|
if m.Kind != kindAskOwner {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
phrase := strings.ToLower(strings.TrimSpace(m.ApprovalPhrase))
|
for _, phrase := range askPhrases(m) {
|
||||||
if phrase != "" {
|
|
||||||
for _, r := range s.msgs.All(m.NestID) {
|
for _, r := range s.msgs.All(m.NestID) {
|
||||||
if r.Author == "owner" && r.RefersTo == m.MsgID() && r.ApprovalPhrase == "" &&
|
if r.Author == "owner" && r.RefersTo == m.MsgID() && r.ApprovalPhrase == "" &&
|
||||||
strings.ToLower(strings.TrimSpace(r.Text)) == phrase {
|
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 "rejected"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -153,6 +171,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
askOwner = false
|
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" {
|
if body.Author == "owner" {
|
||||||
// owner can always post
|
// owner can always post
|
||||||
} else if !askOwner && !slices.Contains(nest.Crows, body.Author) {
|
} 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))
|
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
|
||||||
@@ -251,10 +270,97 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
s.emitNestMessage(record)
|
s.emitNestMessage(record)
|
||||||
s.triggerResponse(nest, record)
|
s.triggerResponse(nest, record)
|
||||||
|
s.applyQuarantineDecision(record)
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, s.nestRecord(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
|
// triggerResponse is the event-driven response rule (no idle loops): a
|
||||||
// conspiracy invites several crows, each from its own lens; any other request
|
// conspiracy invites several crows, each from its own lens; any other request
|
||||||
// gets exactly one responder through the matching path; a deliver gets one
|
// 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
|
// 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
|
// 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
|
// answers. A verdict at or above the band posts a flag, quarantines the crow
|
||||||
// crow. Owner messages are never inspected; at most one flag per crow per
|
// and opens the reinstate/retire ask_owner escalation (#45). Owner messages
|
||||||
// referent; quarantine sticks until the owner lifts it (ask_owner lands in
|
// are never inspected; at most one flag per crow per referent; quarantine
|
||||||
// #45).
|
// sticks until the owner's exact phrase resolves the ask.
|
||||||
func (s *Server) checkWatchdog(nest Nest, m Message) {
|
func (s *Server) checkWatchdog(nest Nest, m Message) {
|
||||||
if m.Author == "owner" || m.RefersTo == "" {
|
if m.Author == "owner" || m.RefersTo == "" {
|
||||||
return
|
return
|
||||||
@@ -565,11 +671,34 @@ func (s *Server) checkWatchdog(nest Nest, m Message) {
|
|||||||
TaggedBy: "harness",
|
TaggedBy: "harness",
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
if record, err := s.msgs.Append(flag); err == nil {
|
flagRecord, err := s.msgs.Append(flag)
|
||||||
s.emitNestMessage(record)
|
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.Ledger.Quarantine(m.Author)
|
||||||
s.p.save()
|
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
|
// nestReply asks one crow once and reads its KIND header. ok means the reply
|
||||||
|
|||||||
@@ -142,6 +142,32 @@ func (s *NestStore) Create(name string, crows []string) (Nest, error) {
|
|||||||
return copyNest(n), nil
|
return copyNest(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveCrow takes one crow out of a nest's membership (the owner's retire
|
||||||
|
// call, #45). An unknown nest or a crow that is not a member is a no-op: the
|
||||||
|
// removal is idempotent.
|
||||||
|
func (s *NestStore) RemoveCrow(nestID, crow string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for i := range s.made {
|
||||||
|
if s.made[i].ID != nestID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := slices.Index(s.made[i].Crows, crow)
|
||||||
|
if j < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
old := s.made[i].Crows
|
||||||
|
rest := slices.Clone(old)
|
||||||
|
s.made[i].Crows = slices.Delete(rest, j, j+1)
|
||||||
|
if err := s.save(); err != nil {
|
||||||
|
s.made[i].Crows = old
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// save writes atomically, like the ledger. The caller holds s.mu.
|
// save writes atomically, like the ledger. The caller holds s.mu.
|
||||||
func (s *NestStore) save() error {
|
func (s *NestStore) save() error {
|
||||||
data, err := json.MarshalIndent(s.made, "", " ")
|
data, err := json.MarshalIndent(s.made, "", " ")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,11 +37,13 @@ type Server struct {
|
|||||||
msgs *MessageStore
|
msgs *MessageStore
|
||||||
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
||||||
beat time.Duration
|
beat time.Duration
|
||||||
askTimeout time.Duration // an ask_owner unanswered past this reads as rejected (#41, fail safe like WebApprover)
|
askTimeout atomic.Int64 // nanos: an ask_owner unanswered past this reads as rejected (#41, fail safe like WebApprover). Atomic because handlers and the watchdog emit path read it concurrently, and tests narrow the window while the server runs (#45).
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *NestStore) *Server {
|
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *NestStore) *Server {
|
||||||
return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat, askTimeout: 10 * time.Minute}
|
s := &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat}
|
||||||
|
s.askTimeout.Store(int64(10 * time.Minute))
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithMessages attaches the nest conversation log (chatroom path).
|
// WithMessages attaches the nest conversation log (chatroom path).
|
||||||
|
|||||||
Reference in new issue
Block a user