From 9118aabb85beb2dfdcdd3cc0c1def841a6161431 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:49:38 -0600 Subject: [PATCH] =?UTF-8?q?test(askowner):=20red=20for=20#41=20=E2=80=94?= =?UTF-8?q?=20ask=5Fowner=20kind,=20approval=5Fphrase,=20fail-safe=20resol?= =?UTF-8?q?ved=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EVENTS.md contract first: ask_owner kind row, approval_phrase and computed resolved payload fields, owner-facing service roles (Quill/Sentry) may post ask_owner, crow ask_owner headers drop to the default tag, 10-minute fail-safe window shared with list approvals. Tests fail: Message has no ApprovalPhrase, Server has no askTimeout. --- EVENTS.md | 16 ++++-- askowner_test.go | 141 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 askowner_test.go 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"]) + } +}