feat(ask-owner): owner-only fail-safe decisions in the nest log (#41) #49
No files matched your search
+96
-34
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user