diff --git a/EVENTS.md b/EVENTS.md index 66e9b22..414a4b4 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -26,7 +26,7 @@ Each SSE message: `event: ` and `data: `. 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 | `-`, 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=` (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 diff --git a/askowner_test.go b/askowner_test.go new file mode 100644 index 0000000..6762011 --- /dev/null +++ b/askowner_test.go @@ -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"]) + } +} 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).