test(askowner): red for #41 — ask_owner kind, approval_phrase, fail-safe resolved field
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.
This commit is contained in:
1 parent
9781bfa3b3
commit
9118aabb85
2 files changed
+151
-6
No files matched your search
@@ -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 |
|
| POST | `/api/verdicts` | `{"item_id", "target": "triage"\|"output", "approve": bool}` | 204 |
|
||||||
| GET | `/api/trust` | | ledger JSON |
|
| 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
|
## 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. |
|
| `deliver` | a crow | The result, addressed back to whoever requested. |
|
||||||
| `refer` | any member | Points to an artifact by name instead of pasting it. |
|
| `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. |
|
| `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. |
|
| `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`. |
|
| `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 |
|
| Field | Type | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `msg_id` | string | `<nest_id>-<n>`, numbered from 1 in nest order. |
|
| `msg_id` | string | `<nest_id>-<n>`, numbered from 1 in nest order. |
|
||||||
| `kind` | string | One of the seven kinds above. |
|
| `kind` | string | One of the kinds above. |
|
||||||
| `author` | string | `"owner"`, a crow name, `"harness"` (nudge) or `"Sentry"` (flag). |
|
| `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. |
|
| `text` | string | The message body. |
|
||||||
| `to` | string | The @-addressed member, or `""`. `deliver`/`claim` default to the requester they answer. |
|
| `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 `""`. |
|
| `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` | 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`. |
|
| `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). |
|
| `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
|
### 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 |
|
| 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. |
|
| GET | `/api/events` | `?nest_id=<id>` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. |
|
||||||
|
|
||||||
### Tagging [decided 2026-10-27]
|
### Tagging [decided 2026-10-27]
|
||||||
@@ -84,8 +88,8 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev
|
|||||||
### Guardrails
|
### Guardrails
|
||||||
|
|
||||||
- `text` 1–2,000 characters after trimming (same cap as a list item).
|
- `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).
|
- `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` or `flag`.
|
- `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`.
|
- Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`.
|
||||||
|
|
||||||
## Settled details
|
## Settled details
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user