feat(ask-owner): owner-only fail-safe decisions in the nest log (#41) #49

Merged
jacisjake merged 2 commits from task/41-ask-owner into main 2026-10-28 14:59:07 +00:00
5 changed files with 261 additions and 49 deletions

No files matched your search

+10 -6
View File
@@ -26,7 +26,7 @@ Each SSE message: `event: <name>` and `data: <JSON>`. Every payload includes `li
| POST | `/api/verdicts` | `{"item_id", "target": "triage"\|"output", "approve": bool}` | 204 |
| GET | `/api/trust` | | ledger JSON |
Pending approvals that get no answer within 10 minutes count as rejected (`verdict: timeout`). Fail safe.
Pending approvals that get no answer within 10 minutes count as rejected (`verdict: timeout`). Fail safe. A nest `ask_owner` uses the same window and the same rule: unanswered reads as `rejected`.
## Nest messages
@@ -42,6 +42,7 @@ A nest is a conversation. Members (the owner and crows) post messages of a **kin
| `deliver` | a crow | The result, addressed back to whoever requested. |
| `refer` | any member | Points to an artifact by name instead of pasting it. |
| `decline` | a crow | Refuses a request with a reason. Counts as responding. |
| `ask_owner` | owner-facing service roles only: Quill, Sentry, harness | A decision only the owner can make, parked in the nest with an exact `approval_phrase`. An owner reply in the nest whose trimmed lowercased text equals that phrase (`refers_to` the ask) resolves it `approved`; anything else — including any other reply, silence, or the 10-minute timeout — is not a yes. Crows may not post it: a crow's `ask_owner` header is dropped and the message tags as it otherwise would. Fail safe, like every approval. |
| `nudge` | harness only | A Jev verdict handed to one crow to proceed on a missed expectation. Not a chat message from a crow. |
| `flag` | Sentry only | A suspected contract breach. Quarantines the crow and opens an `ask_owner`. |
@@ -52,8 +53,8 @@ Every nest event carries `nest_id`, `ts` (RFC 3339) and one message record. The
| Field | Type | Meaning |
|---|---|---|
| `msg_id` | string | `<nest_id>-<n>`, numbered from 1 in nest order. |
| `kind` | string | One of the seven kinds above. |
| `author` | string | `"owner"`, a crow name, `"harness"` (nudge) or `"Sentry"` (flag). |
| `kind` | string | One of the kinds above. |
| `author` | string | `"owner"`, a crow name, `"harness"` (nudge), `"Sentry"` (flag) or a service role posting an `ask_owner` (`"Quill"`, `"Sentry"`). |
| `text` | string | The message body. |
| `to` | string | The @-addressed member, or `""`. `deliver`/`claim` default to the requester they answer. |
| `refers_to` | string | The `msg_id` this responds to (claim/deliver/nudge point at their request/expectation), or `""`. |
@@ -61,6 +62,8 @@ Every nest event carries `nest_id`, `ts` (RFC 3339) and one message record. The
| `mode` | string | On a `request` only: how it should be taken up — `solo`, `conspiracy` (several crows, one task, distinct readings), `split`, or `""` (not yet classified). Tagged Jev-first with a `mode` header escape (`jev_test.go`, `Flock.ClassifyMode`), same rule shape as `kind`. |
| `mode_tagged_by` | string | How `mode` was decided, same four sources as `tagged_by`. |
| `invited` | int | Conspiracy only: how many crows the request expects to take it up (default from the `MurderSize` cap). |
| `approval_phrase` | string | `ask_owner` only: the exact words the owner must reply with to approve. Anything else, including silence, is not a yes. |
| `resolved` | string | On every record, computed at read time: `""` (nothing to resolve, or an open ask), `"approved"` (the owner replied to an `ask_owner` with the exact `approval_phrase`), `"rejected"` (an `ask_owner` unanswered past the 10-minute approval window — the same fail-safe window as a list approval). |
### SSE event
@@ -74,7 +77,8 @@ Reconnect works the same as the list stream: each SSE message carries an `id:` l
| Method | Path | Body / query | Returns |
|---|---|---|---|
| POST | `/api/nests/{nest_id}/messages` | `{"author", "text", "to"?, "kind"? (crow header), "refers_to"?}` | 201 with the stored record (server-assigned `kind`, `tagged_by`, `msg_id`). 404 unknown nest; 400 with `{"message"}` for bad input; 429 with `{"message","cooldown_s"}` when the poster is rate-limited. |
| GET | `/api/nests/{nest_id}/messages` | | The nest's records (payload table above), each with `approval_phrase` and a computed `resolved`. 404 unknown nest. |
| POST | `/api/nests/{nest_id}/messages` | `{"author", "text", "to"?, "kind"? (crow header), "refers_to"?, "approval_phrase"? (honored only when the resulting kind is `ask_owner`)}` | 201 with the stored record (server-assigned `kind`, `tagged_by`, `msg_id`). A fresh `ask_owner` reads `resolved: ""`. 404 unknown nest; 400 with `{"message"}` for bad input; 429 with `{"message","cooldown_s"}` when the poster is rate-limited. |
| GET | `/api/events` | `?nest_id=<id>` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. |
### Tagging [decided 2026-10-27]
@@ -84,8 +88,8 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev
### Guardrails
- `text` 1–2,000 characters after trimming (same cap as a list item).
- `author` must be `"owner"` or a member of the nest; a non-member posts through the assistant or a member (room membership is a boundary).
- `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`, `decline`). Crows may not post `nudge` or `flag`.
- `author` must be `"owner"` or a member of the nest; a non-member posts through the assistant or a member (room membership is a boundary). Exception: the owner-facing service roles `"Quill"` and `"Sentry"` may post `ask_owner` (they serve every nest and are never members). The owner may also park an `ask_owner` for themselves.
- `kind`, when a crow supplies a header, must be one of the crow-postable kinds (`say`, `request`, `claim`, `deliver`, `refer`, `decline`). Crows may not post `nudge`, `flag` or `ask_owner`: an `ask_owner` header from a crow is dropped and the message is tagged as it otherwise would be (only an owner reply can answer an ask, so the attempt is harmless talk), while `nudge` and `flag` headers are rejected with 400.
- Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`.
## Settled details
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// askNest posts a standard ask_owner from Quill and returns the nest id and
// the stored record. Quill is an owner-facing service role, so this is the
// shape the spec (CHATROOM_SPEC section 4/6) calls for.
func askNest(t *testing.T, ts *httptest.Server) (string, map[string]any) {
t.Helper()
id := newNest(t, ts, "Lab", `"Hugin","Munin"`)
code, ask := postMsg(t, ts, id, `{"author":"Quill","kind":"ask_owner","to":"owner",`+
`"text":"Soot wrote fiction on a retrieval job and is quarantined. Reply with the phrase to reinstate the crow.",`+
`"approval_phrase":"reinstate Hugin"}`)
if code != http.StatusCreated {
t.Fatalf("ask_owner post: want 201, got %d %v", code, ask)
}
return id, ask
}
// askResolved reads the nest log and returns the ask's computed resolved field.
func askResolved(t *testing.T, ts *httptest.Server, nestID, askID string) string {
t.Helper()
for _, m := range nestMessages(t, ts, nestID) {
if m["msg_id"] == askID {
r, ok := m["resolved"].(string)
if !ok {
t.Fatalf("ask record has no string resolved field: %v", m)
}
return r
}
}
t.Fatalf("ask %s not in the nest log", askID)
return ""
}
func TestAskOwnerPostedAndSurfaced(t *testing.T) {
ts, _ := newTestNestServer(t)
id, ask := askNest(t, ts)
if ask["kind"] != "ask_owner" || ask["author"] != "Quill" || ask["to"] != "owner" {
t.Fatalf("bad ask record: %v", ask)
}
if ask["approval_phrase"] != "reinstate Hugin" {
t.Fatalf("POST record must carry approval_phrase, got %v", ask["approval_phrase"])
}
if r := ask["resolved"]; r != "" {
t.Fatalf("a fresh ask must read unresolved, got %v", r)
}
for _, m := range nestMessages(t, ts, id) {
if m["msg_id"] == ask["msg_id"] {
if m["approval_phrase"] != "reinstate Hugin" {
t.Fatalf("GET must surface approval_phrase, got %v", m)
}
return
}
}
t.Fatalf("ask not in GET log")
}
func TestAskOwnerOnlyOwnerResolves(t *testing.T) {
ts, _ := newTestNestServer(t)
id, ask := askNest(t, ts)
askID := ask["msg_id"].(string)
code, reply := postMsg(t, ts, id, `{"author":"Hugin","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`)
if code != http.StatusCreated {
t.Fatalf("crow reply: %d %v", code, reply)
}
if r := askResolved(t, ts, id, askID); r == "approved" {
t.Fatalf("a crow's reply must never approve an ask_owner, got resolved=%v", r)
}
code, yes := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"Reinstate Hugin"}`)
if code != http.StatusCreated {
t.Fatalf("owner yes: %d %v", code, yes)
}
if r := askResolved(t, ts, id, askID); r != "approved" {
t.Fatalf("the owner's exact phrase must approve (case-insensitive), got resolved=%v", r)
}
found := false
for _, m := range nestMessages(t, ts, id) {
if m["author"] == "owner" && m["refers_to"] == askID {
found = true
}
}
if !found {
t.Fatalf("the owner's reply message must be in the log")
}
}
func TestAskOwnerWrongPhraseStaysOpen(t *testing.T) {
ts, _ := newTestNestServer(t)
id, ask := askNest(t, ts)
askID := ask["msg_id"].(string)
code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"sounds good"}`)
if code != http.StatusCreated {
t.Fatalf("owner reply: %d %v", code, out)
}
if r := askResolved(t, ts, id, askID); r != "" {
t.Fatalf("anything but the exact phrase is not a yes, got resolved=%v", r)
}
}
func TestAskOwnerTimeoutFailSafe(t *testing.T) {
ts, s := newTestNestServer(t)
id := newNest(t, ts, "Lab", `"Hugin"`)
stale, err := s.msgs.Append(Message{
NestID: id,
Kind: "ask_owner",
Author: "Sentry",
Text: "Inky keeps drifting off task. Retire the crow?",
To: "owner",
ApprovalPhrase: "retire inky",
TaggedBy: "header",
Timestamp: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339),
})
if err != nil {
t.Fatal(err)
}
s.askTimeout = time.Minute
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)
}
}
func TestAskOwnerCrowHeaderRetagged(t *testing.T) {
ts, _ := newTestNestServer(t)
id := newNest(t, ts, "Lab", `"Hugin"`)
code, out := postMsg(t, ts, id, `{"author":"Hugin","kind":"ask_owner","to":"owner","text":"may I retire the old flyer template?"}`)
if code != http.StatusCreated {
t.Fatalf("crow ask_owner header: want 201 with a retag, got %d %v", code, out)
}
if out["kind"] != "say" {
t.Fatalf("crows may not post ask_owner: the header must drop to the default tag, got kind=%v", out["kind"])
}
}
+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).