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:
1 parent
9118aabb85
commit
6808a6713b
3 files changed
+76
-9
No files matched your search
+70
-8
@@ -18,6 +18,15 @@ var crowPostable = map[string]bool{
|
|||||||
"decline": true,
|
"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
|
// nestReplySystem tells an addressed crow the response contract: its reply
|
||||||
// must open with a KIND line the harness reads (must-respond).
|
// must open with a KIND line the harness reads (must-respond).
|
||||||
const markerNestReply = "This is a chatroom request."
|
const markerNestReply = "This is a chatroom request."
|
||||||
@@ -47,13 +56,15 @@ func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
all := s.msgs.All(nestID)
|
all := s.msgs.All(nestID)
|
||||||
out := make([]map[string]any, 0, len(all))
|
out := make([]map[string]any, 0, len(all))
|
||||||
for _, m := range all {
|
for _, m := range all {
|
||||||
out = append(out, nestRecord(m))
|
out = append(out, s.nestRecord(m))
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// nestRecord is the EVENTS.md wire shape for one message.
|
// nestRecord is the EVENTS.md wire shape for one message. resolved is computed
|
||||||
func nestRecord(m Message) map[string]any {
|
// 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{
|
rec := map[string]any{
|
||||||
"msg_id": m.MsgID(),
|
"msg_id": m.MsgID(),
|
||||||
"kind": m.Kind,
|
"kind": m.Kind,
|
||||||
@@ -62,6 +73,8 @@ func nestRecord(m Message) map[string]any {
|
|||||||
"to": m.To,
|
"to": m.To,
|
||||||
"refers_to": m.RefersTo,
|
"refers_to": m.RefersTo,
|
||||||
"tagged_by": m.TaggedBy,
|
"tagged_by": m.TaggedBy,
|
||||||
|
"approval_phrase": m.ApprovalPhrase,
|
||||||
|
"resolved": s.askResolved(m),
|
||||||
"ts": m.Timestamp,
|
"ts": m.Timestamp,
|
||||||
}
|
}
|
||||||
if m.Mode != "" {
|
if m.Mode != "" {
|
||||||
@@ -74,6 +87,29 @@ func nestRecord(m Message) map[string]any {
|
|||||||
return rec
|
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) {
|
func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
nestID := r.PathValue("nest_id")
|
nestID := r.PathValue("nest_id")
|
||||||
nest, found := s.nests.Get(nestID)
|
nest, found := s.nests.Get(nestID)
|
||||||
@@ -90,6 +126,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
Invited int `json:"invited"`
|
Invited int `json:"invited"`
|
||||||
To string `json:"to"`
|
To string `json:"to"`
|
||||||
RefersTo string `json:"refers_to"`
|
RefersTo string `json:"refers_to"`
|
||||||
|
ApprovalPhrase string `json:"approval_phrase"`
|
||||||
}
|
}
|
||||||
if !decode(w, r, &body) {
|
if !decode(w, r, &body) {
|
||||||
return
|
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))
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("A message is 1 to %d characters.", s.p.G.MaxItemChars))
|
||||||
return
|
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" {
|
if body.Author == "owner" {
|
||||||
// owner can always post
|
// 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))
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -119,13 +166,21 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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))
|
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a kind crows may post.", body.Kind))
|
||||||
return
|
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.
|
||||||
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
|
// 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).
|
// back to whoever posted the message they refer to (EVENTS.md deliver rule).
|
||||||
@@ -161,6 +216,12 @@ 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{
|
msg := Message{
|
||||||
NestID: nestID,
|
NestID: nestID,
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
@@ -172,6 +233,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
Mode: mode,
|
Mode: mode,
|
||||||
ModeTagged: modeBy,
|
ModeTagged: modeBy,
|
||||||
Invited: invited,
|
Invited: invited,
|
||||||
|
ApprovalPhrase: phrase,
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
record, err := s.msgs.Append(msg)
|
record, err := s.msgs.Append(msg)
|
||||||
@@ -183,7 +245,7 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.emitNestMessage(record)
|
s.emitNestMessage(record)
|
||||||
s.triggerResponse(nest, 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
|
// 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) {
|
func (s *Server) emitNestMessage(m Message) {
|
||||||
rec := nestRecord(m)
|
rec := s.nestRecord(m)
|
||||||
rec["nest_id"] = m.NestID
|
rec["nest_id"] = m.NestID
|
||||||
s.hub.PublishNest(m.NestID, "nest.message", rec)
|
s.hub.PublishNest(m.NestID, "nest.message", rec)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type Message struct {
|
|||||||
Mode string `json:"mode,omitempty"` // solo | conspiracy | split (requests only, Epic C)
|
Mode string `json:"mode,omitempty"` // solo | conspiracy | split (requests only, Epic C)
|
||||||
ModeTagged string `json:"mode_tagged_by,omitempty"`
|
ModeTagged string `json:"mode_tagged_by,omitempty"`
|
||||||
Invited int `json:"invited,omitempty"`
|
Invited int `json:"invited,omitempty"`
|
||||||
|
// 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"`
|
Timestamp string `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
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).
|
// WithMessages attaches the nest conversation log (chatroom path).
|
||||||
|
|||||||
Reference in new issue
Block a user