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.
This commit is contained in:
Smokey committed 2026-10-28 08:59:03 -06:00
1 parent 9118aabb85
commit 6808a6713b
3 files changed
+110 -43

No files matched your search

+96 -34
View File
@@ -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)
}
+5 -1
View File
@@ -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) }
+9 -8
View File
@@ -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).