From 6808a6713b2f416364562160d405ab047a39d750 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:51:28 -0600 Subject: [PATCH] feat(ask-owner): owner-only fail-safe decisions in the nest log (#41) Message gains approval_phrase; ask_owner is postable by the owner and the owner-facing service roles (Quill/Sentry) and bypasses Jev tagging with tagged_by header. An ordinary crow's ask_owner header is dropped and the message tags as it otherwise would. nestRecord (now a Server method) carries approval_phrase and a computed resolved: approved only on an owner reply whose trimmed lowercased text equals the phrase via refers_to; anything else, including an ask unanswered past Server.askTimeout (10 minutes by default, the same fail-safe window as list approvals), is not a yes. --- nestapi.go | 130 +++++++++++++++++++++++++++++++++++++++-------------- nestlog.go | 6 ++- server.go | 17 +++---- 3 files changed, 110 insertions(+), 43 deletions(-) diff --git a/nestapi.go b/nestapi.go index ab1fb86..9a41fe0 100644 --- a/nestapi.go +++ b/nestapi.go @@ -18,6 +18,15 @@ var crowPostable = map[string]bool{ "decline": true, } +// kindAskOwner is a decision only the owner can make (#41). It is postable by +// the owner-facing service roles and the owner, never by an ordinary crow: a +// crow's ask_owner header is dropped and the message tags as it otherwise +// would be. Jev's kind question never proposes it; the header bypasses +// tagging entirely, like nudge and flag. +const kindAskOwner = "ask_owner" + +var ownerFacingRoles = map[string]bool{"Quill": true, "Sentry": true} + // nestReplySystem tells an addressed crow the response contract: its reply // must open with a KIND line the harness reads (must-respond). const markerNestReply = "This is a chatroom request." @@ -47,22 +56,26 @@ func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) { all := s.msgs.All(nestID) out := make([]map[string]any, 0, len(all)) for _, m := range all { - out = append(out, nestRecord(m)) + out = append(out, s.nestRecord(m)) } writeJSON(w, http.StatusOK, out) } -// nestRecord is the EVENTS.md wire shape for one message. -func nestRecord(m Message) map[string]any { +// nestRecord is the EVENTS.md wire shape for one message. resolved is computed +// at read time: an ask_owner approves only on the owner's exact phrase and +// rejects on the fail-safe timeout; every other message reads "". +func (s *Server) nestRecord(m Message) map[string]any { rec := map[string]any{ - "msg_id": m.MsgID(), - "kind": m.Kind, - "author": m.Author, - "text": m.Text, - "to": m.To, - "refers_to": m.RefersTo, - "tagged_by": m.TaggedBy, - "ts": m.Timestamp, + "msg_id": m.MsgID(), + "kind": m.Kind, + "author": m.Author, + "text": m.Text, + "to": m.To, + "refers_to": m.RefersTo, + "tagged_by": m.TaggedBy, + "approval_phrase": m.ApprovalPhrase, + "resolved": s.askResolved(m), + "ts": m.Timestamp, } if m.Mode != "" { rec["mode"] = m.Mode @@ -74,6 +87,29 @@ func nestRecord(m Message) map[string]any { return rec } +// 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. +// 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 _, r := range s.msgs.All(m.NestID) { + if r.Author == "owner" && r.RefersTo == m.MsgID() && r.ApprovalPhrase == "" && + strings.ToLower(strings.TrimSpace(r.Text)) == phrase { + return "approved" + } + } + } + if ts, err := time.Parse(time.RFC3339, m.Timestamp); err == nil && time.Since(ts) > s.askTimeout { + return "rejected" + } + return "" +} + func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { nestID := r.PathValue("nest_id") nest, found := s.nests.Get(nestID) @@ -83,13 +119,14 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { } var body struct { - Author string `json:"author"` - Text string `json:"text"` - Kind string `json:"kind"` - Mode string `json:"mode"` - Invited int `json:"invited"` - To string `json:"to"` - RefersTo string `json:"refers_to"` + Author string `json:"author"` + Text string `json:"text"` + Kind string `json:"kind"` + Mode string `json:"mode"` + Invited int `json:"invited"` + To string `json:"to"` + RefersTo string `json:"refers_to"` + ApprovalPhrase string `json:"approval_phrase"` } if !decode(w, r, &body) { return @@ -106,9 +143,19 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { httpError(w, http.StatusBadRequest, fmt.Sprintf("A message is 1 to %d characters.", s.p.G.MaxItemChars)) return } + // ask_owner is the owner's decision channel (#41). Only the owner and the + // owner-facing service roles may park one; an ordinary crow's header is + // dropped so the message tags as it otherwise would (harmless talk, not + // an error). + askOwner := body.Kind == kindAskOwner + if askOwner && body.Author != "owner" && !ownerFacingRoles[body.Author] { + body.Kind = "" + askOwner = false + } + if body.Author == "owner" { // owner can always post - } else if !slices.Contains(nest.Crows, body.Author) { + } else if !askOwner && !slices.Contains(nest.Crows, body.Author) { httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID)) return } @@ -119,13 +166,21 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { return } } - if body.Kind != "" && body.Author != "owner" && !crowPostable[body.Kind] { + if body.Kind != "" && body.Author != "owner" && !askOwner && !crowPostable[body.Kind] { httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind)) return } // Tagging (A1, EVENTS.md): Jev first, header fallback, say as last resort. - kind, taggedBy := s.tagKind(body.Kind, body.Text) + // A validated ask_owner header bypasses the classifier: Jev's kind + // question never proposes it, and a decision for the owner must not be + // re-tagged away. + var kind, taggedBy string + if askOwner { + kind, taggedBy = kindAskOwner, "header" + } else { + kind, taggedBy = s.tagKind(body.Kind, body.Text) + } // claim/deliver/decline answer a request: with no explicit address they go // back to whoever posted the message they refer to (EVENTS.md deliver rule). @@ -161,18 +216,25 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { } } + // approval_phrase is honored only when the resulting kind is ask_owner. + phrase := strings.TrimSpace(body.ApprovalPhrase) + if kind != kindAskOwner { + phrase = "" + } + msg := Message{ - NestID: nestID, - Kind: kind, - Author: body.Author, - Text: body.Text, - To: body.To, - RefersTo: body.RefersTo, - TaggedBy: taggedBy, - Mode: mode, - ModeTagged: modeBy, - Invited: invited, - Timestamp: time.Now().UTC().Format(time.RFC3339), + NestID: nestID, + Kind: kind, + Author: body.Author, + Text: body.Text, + To: body.To, + RefersTo: body.RefersTo, + TaggedBy: taggedBy, + Mode: mode, + ModeTagged: modeBy, + Invited: invited, + ApprovalPhrase: phrase, + Timestamp: time.Now().UTC().Format(time.RFC3339), } record, err := s.msgs.Append(msg) if err != nil { @@ -183,7 +245,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { s.emitNestMessage(record) s.triggerResponse(nest, record) - writeJSON(w, http.StatusCreated, nestRecord(record)) + writeJSON(w, http.StatusCreated, s.nestRecord(record)) } // triggerResponse is the event-driven response rule (no idle loops): a @@ -251,7 +313,7 @@ func (s *Server) tagKind(header, text string) (string, string) { } func (s *Server) emitNestMessage(m Message) { - rec := nestRecord(m) + rec := s.nestRecord(m) rec["nest_id"] = m.NestID s.hub.PublishNest(m.NestID, "nest.message", rec) } diff --git a/nestlog.go b/nestlog.go index 976d165..6abac4c 100644 --- a/nestlog.go +++ b/nestlog.go @@ -24,7 +24,11 @@ type Message struct { Mode string `json:"mode,omitempty"` // solo | conspiracy | split (requests only, Epic C) ModeTagged string `json:"mode_tagged_by,omitempty"` Invited int `json:"invited,omitempty"` - Timestamp string `json:"ts"` + // ApprovalPhrase is the exact phrase that resolves an ask_owner (#41): + // only an owner reply equal to it is a yes. Anything else, including + // silence and timeout, is not. + ApprovalPhrase string `json:"approval_phrase,omitempty"` + Timestamp string `json:"ts"` } func (m Message) MsgID() string { return fmt.Sprintf("%s-%d", m.NestID, m.ID) } diff --git a/server.go b/server.go index ed1c4e9..a63904d 100644 --- a/server.go +++ b/server.go @@ -28,17 +28,18 @@ const ( // Server is the web front door: the same pipeline as the command line, // with web approvals and an SSE hub instead of stdin and stdout. type Server struct { - p *Pipeline - approver *WebApprover - hub *Hub - nests *NestStore - msgs *MessageStore - ctx context.Context // cancelled on shutdown; lists and streams end with it - beat time.Duration + p *Pipeline + approver *WebApprover + hub *Hub + nests *NestStore + msgs *MessageStore + ctx context.Context // cancelled on shutdown; lists and streams end with it + beat time.Duration + askTimeout time.Duration // an ask_owner unanswered past this reads as rejected (#41, fail safe like WebApprover) } 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} + return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat, askTimeout: 10 * time.Minute} } // WithMessages attaches the nest conversation log (chatroom path).