Files
so-many-crows/nestapi.go
T
Smokey 6808a6713b 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.
2026-10-28 08:59:03 -06:00

525 lines
17 KiB
Go

package main
import (
"fmt"
"net/http"
"slices"
"strings"
"time"
"unicode/utf8"
)
var crowPostable = map[string]bool{
"say": true,
"request": true,
"claim": true,
"deliver": true,
"refer": 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
// must open with a KIND line the harness reads (must-respond).
const markerNestReply = "This is a chatroom request."
const nestReplySystem = crowSystem + `
` + markerNestReply + ` Start your reply on its own first line with exactly:
KIND: claim (you will do it)
KIND: deliver (here is the result)
KIND: decline (with a reason)
Then, on the next line, your message text.`
// serviceRoles are addressable in a nest but never members: they only act when
// a crow's or the owner's message asks them to.
var serviceRoles = []string{"Elder", "Quill"}
// nestMembers lists who may be addressed in a nest: its crows, the owner, and
// the service roles.
func nestMembers(nest Nest) []string {
return append(append(append([]string{}, nest.Crows...), "owner"), serviceRoles...)
}
func (s *Server) getNestMessages(w http.ResponseWriter, r *http.Request) {
nestID := r.PathValue("nest_id")
if _, found := s.nests.Get(nestID); !found {
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
return
}
all := s.msgs.All(nestID)
out := make([]map[string]any, 0, len(all))
for _, m := range all {
out = append(out, s.nestRecord(m))
}
writeJSON(w, http.StatusOK, out)
}
// 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,
"approval_phrase": m.ApprovalPhrase,
"resolved": s.askResolved(m),
"ts": m.Timestamp,
}
if m.Mode != "" {
rec["mode"] = m.Mode
rec["mode_tagged_by"] = m.ModeTagged
}
if m.Invited > 0 {
rec["invited"] = m.Invited
}
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)
if !found {
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", nestID))
return
}
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"`
ApprovalPhrase string `json:"approval_phrase"`
}
if !decode(w, r, &body) {
return
}
body.Text = strings.TrimSpace(body.Text)
body.To = strings.TrimSpace(body.To)
if body.To == "" {
body.To = parseAddressing(body.Text, nestMembers(nest))
}
// Guardrails (EVENTS.md: Nest messages → Guardrails)
if n := utf8.RuneCountInString(body.Text); n < 1 || n > s.p.G.MaxItemChars {
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 !askOwner && !slices.Contains(nest.Crows, body.Author) {
httpError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a member of %s.", body.Author, nestID))
return
}
if body.Author != "owner" {
if ok, msg, wait := s.p.Ledger.Admit(body.Author, time.Now(), s.p.G); !ok {
cooldown := int(wait.Seconds())
writeJSON(w, http.StatusTooManyRequests, map[string]any{"message": msg, "cooldown_s": cooldown})
return
}
}
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.
// 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).
if body.To == "" && (kind == "claim" || kind == "deliver" || kind == "decline") && body.RefersTo != "" {
if origin, found := s.msgs.ByMsgID(body.RefersTo); found {
body.To = origin.Author
}
}
// Mode (Epic C, same rule shape as kind): Jev first, header escape, "" when unsure.
mode, modeBy := s.tagMode(kind, body.Mode, body.Text)
// A conspiracy expects several crows on the one task; the invite count is
// capped by MurderSize and the flock's in-flight limit.
invited := 0
if mode == "conspiracy" {
invited = s.inviteCount(nest, body.Invited)
}
// Claim arbitration (#5, Epic C): first claim wins unless the referent is a
// conspiracy, which expects multiple claims. Directed assignment holds:
// while the asked crow has not answered, only it may claim.
if kind == "claim" && body.RefersTo != "" {
if origin, found := s.msgs.ByMsgID(body.RefersTo); found && origin.Mode != "conspiracy" {
if slices.Contains(nest.Crows, origin.To) && origin.To != body.Author && !s.msgs.Answered(origin.NestID, origin.MsgID(), origin.To) {
httpError(w, http.StatusConflict, origin.To+" was asked to take this; it answers or declines first.")
return
}
if owner, claimed := s.msgs.FirstClaim(origin.NestID, origin.MsgID()); claimed {
httpError(w, http.StatusConflict, origin.MsgID()+" is claimed by "+owner+"; review it instead of redoing it.")
return
}
}
}
// 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,
ApprovalPhrase: phrase,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
record, err := s.msgs.Append(msg)
if err != nil {
httpError(w, http.StatusInternalServerError, "could not save the message: "+err.Error())
return
}
s.emitNestMessage(record)
s.triggerResponse(nest, record)
writeJSON(w, http.StatusCreated, s.nestRecord(record))
}
// triggerResponse is the event-driven response rule (no idle loops): a
// conspiracy invites several crows, each from its own lens; any other request
// gets exactly one responder through the matching path; a deliver gets one
// divergence check.
// It runs for owner-posted messages and for harness-posted ones alike, so a
// Quill relay answers the named crow exactly like a direct request (#40).
func (s *Server) triggerResponse(nest Nest, record Message) {
if record.Kind == "request" {
switch {
case record.Mode == "conspiracy" && record.Invited > 0:
go s.respondConspiracy(nest, record, record.Invited)
case record.To == "Elder":
go s.respondElder(nest, record)
case record.To == "Quill":
go s.respondQuill(nest, record)
case slices.Contains(nest.Crows, record.To):
go s.respondToRequest(nest, record)
default:
go s.respondOpenRequest(nest, record)
}
}
if record.Kind == "deliver" {
go s.checkDivergence(nest, record)
}
}
var modePostable = map[string]bool{"solo": true, "conspiracy": true, "split": true}
// tagMode classifies a request's mode (Jev first, header escape). Only requests
// carry a mode; other kinds get "".
func (s *Server) tagMode(kind, header, text string) (string, string) {
if kind != "request" {
return "", ""
}
if m, ok := s.p.Flock.ClassifyMode(s.ctx, text); ok {
if modePostable[header] && header != m {
return m, "disagreement"
}
return m, "jev"
}
if modePostable[header] {
return header, "header"
}
return "", "default"
}
// tagKind runs the A1 rule: Jev first; Jev unsure -> the poster's header;
// neither -> say. tagged_by records which source won, and a Jev override of a
// header logs as a disagreement (fuel for calibration).
func (s *Server) tagKind(header, text string) (string, string) {
kind, _, ok := s.p.Flock.ClassifyKind(s.ctx, text)
if ok {
if crowPostable[header] && header != kind {
return kind, "disagreement"
}
return kind, "jev"
}
if crowPostable[header] {
return header, "header"
}
return "say", "default"
}
func (s *Server) emitNestMessage(m Message) {
rec := s.nestRecord(m)
rec["nest_id"] = m.NestID
s.hub.PublishNest(m.NestID, "nest.message", rec)
}
// inviteCount is how many crows a conspiracy asks to take the one task, capped
// by MurderSize, the flock's in-flight limit, and the nest's own size.
func (s *Server) inviteCount(nest Nest, requested int) int {
cap := s.p.G.MurderSize
if s.p.G.MaxCrowsInFlight < cap {
cap = s.p.G.MaxCrowsInFlight
}
if requested > 0 && requested < cap {
cap = requested
}
if len(nest.Crows) < cap {
cap = len(nest.Crows)
}
return cap
}
// respondConspiracy invites N eligible crows; each answers from its own
// persona lens so distinct strengths produce distinct readings. Strangers are
// skipped: unprompted speech is earned (trust ladder, section 10).
func (s *Server) respondConspiracy(nest Nest, req Message, invited int) {
n := 0
for _, crow := range nest.Crows {
if n >= invited {
break
}
if !s.maySpeakUnprompted(crow) {
continue
}
n++
go s.respondOne(nest, req, crow, true)
}
}
// respondOpenRequest surfaces an unaddressed request to one regular-and-up
// crow as a claim opportunity (section 5: a regular may claim open requests).
func (s *Server) respondOpenRequest(nest Nest, req Message) {
for _, crow := range nest.Crows {
if s.maySpeakUnprompted(crow) {
go s.respondOne(nest, req, crow, false)
return
}
}
}
// maySpeakUnprompted is the trust gate for open (unaddressed) work.
func (s *Server) maySpeakUnprompted(crow string) bool {
return s.p.Ledger.Level(crow, "other") > Stranger
}
// conspiracyLine is the lens directive added to every invited crow's prompt.
const conspiracyLine = "You are one lens among several on this request; answer from your own strength, not the flock's consensus."
func (s *Server) respondToRequest(nest Nest, req Message) {
s.respondOne(nest, req, req.To, false)
}
// respondOne is the must-respond rule for a single crow: answer with claim,
// deliver or decline. A non-responsive reply is retried once with a stricter
// ask; still nothing responsive and the crow gets a Sentry flag (no reply).
func (s *Server) respondOne(nest Nest, req Message, crow string, lens bool) {
budget := &Budget{name: "nest-reply", limit: s.p.G.TokenBudgetPerItem}
system := systemFor(crow, nestReplySystem, false)
prompt := "NEST: " + nest.Name + "\nREQUEST FROM: " + req.Author + "\nRespond to " + req.Author + ".\nTASK: " + req.Text
if lens {
strength := ""
if p, ok := personaOf(crow); ok {
strength = p.Strength
}
prompt += "\n" + conspiracyLine + "\nYour strength: " + strength + " (" + crow + ")."
}
kind, body, ok := s.nestReply(system, prompt, crow, budget)
if !ok {
nudge := prompt + "\nYour last reply did not claim, deliver or decline this request. Answer with a KIND line now."
kind, body, ok = s.nestReply(system, nudge, crow, budget)
}
if !ok {
s.postFlag(nest, crow, req)
return
}
reply := Message{
NestID: nest.ID,
Kind: kind,
Author: crow,
Text: body,
To: req.Author, // responses go back to whoever requested
RefersTo: req.MsgID(),
TaggedBy: "header",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if record, err := s.msgs.Append(reply); err == nil {
s.emitNestMessage(record)
if record.Kind == "deliver" {
s.checkDivergence(nest, record)
}
}
}
// respondElder merges the reports on a thread: the Elder only speaks when a
// message asks it to (CHATROOM_SPEC section 6 — synthesis is requested, not a
// fixed step). It answers whoever opened the thread it merges.
func (s *Server) respondElder(nest Nest, req Message) {
if req.RefersTo == "" {
return
}
target, found := s.msgs.ByMsgID(req.RefersTo)
if !found {
return
}
var reports []string
for _, m := range s.msgs.ReportsTo(nest.ID, target.MsgID(), "deliver") {
if slices.Contains(nest.Crows, m.Author) {
reports = append(reports, "["+m.Author+"] "+m.Text)
}
}
if len(reports) == 0 {
return
}
budget := &Budget{name: "elder-merge", limit: s.p.G.TokenBudgetPerItem}
system := systemFor("Elder", elderSystem, true)
prompt := "REQUEST: " + target.Text + "\n--- reports to merge ---\n" + strings.Join(reports, "\n--- \n")
answer, err := s.p.Flock.ask(s.ctx, nil, "Elder", system, prompt, budget)
if err != nil {
return
}
reply := Message{
NestID: nest.ID,
Kind: "deliver",
Author: "Elder",
Text: strings.TrimSpace(answer),
To: target.Author,
RefersTo: target.MsgID(),
TaggedBy: "harness",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if record, err := s.msgs.Append(reply); err == nil {
s.emitNestMessage(record)
}
}
// checkDivergence asks Jev whether this deliver restates earlier delivers that
// answer the same request. Above the band the crow gets a review nudge; the
// deliver itself stays in the log (nudges never silence).
func (s *Server) checkDivergence(nest Nest, m Message, budgets ...*Budget) {
if m.Kind != "deliver" {
return
}
earlier := s.msgs.EarlierDelivers(nest.ID, m.RefersTo, m.ID)
if len(earlier) == 0 {
return
}
if _, nudged := s.msgs.NudgeFor(nest.ID, m.MsgID()); nudged {
return // one nudge per deliver; no loop
}
prob, ok := s.p.Flock.ClassifyDivergence(s.ctx, map[string]any{"this": m.Text, "earlier": earlier}, budgets...)
if !ok || prob < s.p.Flock.jev.threshold {
return
}
nudge := Message{
NestID: nest.ID,
Kind: "nudge",
Author: "harness",
Text: "This reads like a point already made in the thread; review the earlier delivers and add your distinct angle or let it rest.",
To: m.Author,
RefersTo: m.MsgID(),
TaggedBy: "harness",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if record, err := s.msgs.Append(nudge); err == nil {
s.emitNestMessage(record)
}
}
// nestReply asks one crow once and reads its KIND header. ok means the reply
// was responsive (claim, deliver or decline).
func (s *Server) nestReply(system, prompt, crow string, budget *Budget) (kind, body string, ok bool) {
answer, err := s.p.Flock.ask(s.ctx, nil, crow, system, prompt, budget)
if err != nil {
return "", "", false
}
kind, body = splitKind(answer)
if !responsiveKind[kind] || strings.TrimSpace(body) == "" {
return "", "", false
}
return kind, strings.TrimSpace(body), true
}
// postFlag records the Sentry's breach note: an addressed crow that would not
// answer. The quarantine/ask_owner loop lands with the watchdog epic.
func (s *Server) postFlag(nest Nest, crow string, req Message) {
flag := Message{
NestID: nest.ID,
Kind: "flag",
Author: "Sentry",
Text: fmt.Sprintf("%s ignored a request from %s and did not answer after one retry. Flagged for contract breach.", crow, req.Author),
To: crow,
RefersTo: req.MsgID(),
TaggedBy: "harness",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if record, err := s.msgs.Append(flag); err == nil {
s.emitNestMessage(record)
}
}