From 0ae600dc977e533abb16943ca3100d4fd16bcff3 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 10:33:53 +0200 Subject: [PATCH] Give a module one subject and one layout to say a condition to the operator (issue 1202) --- go/asks/condition.go | 149 ++++++++++++++++++++++++++++++++++++++ go/asks/condition_test.go | 143 ++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 go/asks/condition.go create mode 100644 go/asks/condition_test.go diff --git a/go/asks/condition.go b/go/asks/condition.go new file mode 100644 index 0000000..43aae39 --- /dev/null +++ b/go/asks/condition.go @@ -0,0 +1,149 @@ +package asks + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" +) + +// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel +// seat, `mesh.seat.operator-channel.accept.condition.` (novox/hq ADR 0437 §9). The router reads the +// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is +// refused when empty or when it holds a dot, a wildcard or whitespace. +func ConditionSubject(publisher string) (string, error) { + if publisher == "" { + return "", errors.New("asks: the condition's publisher is empty") + } + if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) { + return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher) + } + return "mesh.seat." + Seat + ".accept.condition." + publisher, nil +} + +// ConditionVersion is the version of the condition value this package writes and the only one it reads. +const ConditionVersion = 1 + +// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not +// ConditionVersion. Test it with errors.Is. +var ErrUnknownConditionVersion = errors.New("asks: unknown condition version") + +// The words a condition's explanation opens with (novox/hq ADR 0253 §1). +const ( + NothingToDo = "Nothing for you to do." + NeedsYou = "Needs you: " +) + +// The severities and states of a condition. +const ( + SeverityUrgent = "urgent" + SeverityWarning = "warning" + StateRaised = "raised" + StateCleared = "cleared" +) + +// Scope is what a condition is about, under the controller's names. +type Scope struct { + Scope string `json:"scope"` + ID string `json:"id"` + Machine string `json:"machine,omitempty"` +} + +// Action is one answer a notification offers, under the controller's names. +type Action struct { + Label string `json:"label"` + Verb string `json:"verb"` + Machine string `json:"machine,omitempty"` + Arguments map[string]string `json:"arguments,omitempty"` + Level Level `json:"level,omitempty"` +} + +// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key, +// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs +// only its key, subject and state. The router alone chooses the channels. +type Condition struct { + V int `json:"v"` + Key string `json:"key"` + Severity string `json:"severity,omitempty"` + Subject Scope `json:"subject"` + State string `json:"state"` + Summary string `json:"summary,omitempty"` + Headline string `json:"headline,omitempty"` + Needs string `json:"needs,omitempty"` + Explanation string `json:"explanation,omitempty"` + Resolved string `json:"resolved,omitempty"` + Actions []Action `json:"actions,omitempty"` +} + +func (c Condition) check() error { + if c.Key == "" { + return errors.New("asks: a condition has no key") + } + if c.Subject.Scope == "" || c.Subject.ID == "" { + return errors.New("asks: a condition's subject needs its scope and id") + } + switch c.State { + case StateCleared: + return nil + case StateRaised: + default: + return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State) + } + switch c.Severity { + case SeverityUrgent, SeverityWarning: + default: + return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity) + } + if c.Headline == "" { + return errors.New("asks: a raised condition has no headline") + } + if n := len([]rune(c.Headline)); n > HeadlineLength { + return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength) + } + verdict := NothingToDo + if c.Needs != "" { + verdict = NeedsYou + c.Needs + } + if !strings.HasPrefix(c.Explanation, verdict) { + return fmt.Errorf("asks: the condition's explanation must open with %q", verdict) + } + return nil +} + +// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse. +func EncodeCondition(c Condition) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = ConditionVersion + return json.Marshal(c) +} + +// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what +// EncodeCondition refuses. +func DecodeCondition(data []byte) (Condition, error) { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err) + } + if probe.V == nil { + return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion) + } + if *probe.V != ConditionVersion { + return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion) + } + var c Condition + if err := json.Unmarshal(data, &c); err != nil { + return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err) + } + if err := c.check(); err != nil { + return Condition{}, err + } + return c, nil +} + +// SameCondition says whether two conditions are the same value. +func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) } diff --git a/go/asks/condition_test.go b/go/asks/condition_test.go new file mode 100644 index 0000000..05ab6fa --- /dev/null +++ b/go/asks/condition_test.go @@ -0,0 +1,143 @@ +package asks + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestConditionSubject(t *testing.T) { // 1202/AC1 + s, err := ConditionSubject("mesh-delivery-teller") + if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" { + t.Fatalf("subject = %q, %v", s, err) + } + // The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5. + parts := strings.Split(s, ".") + if len(parts) != 6 || parts[4] != "condition" || parts[5] != "mesh-delivery-teller" { + t.Errorf("subject %q does not keep the router's shape: %v", s, parts) + } + for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} { + if _, err := ConditionSubject(bad); err == nil { + t.Errorf("publisher %q accepted", bad) + } + } +} + +func raisedCondition() Condition { + return Condition{ + Key: "delivery.stuck.mesh-mail", Severity: "warning", + Subject: Scope{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised", + Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck", + Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.", + Resolved: "The delivery of mesh-mail moved on.", + Actions: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace", + Arguments: map[string]string{"why": ""}, Level: Approve}}, + } +} + +func clearedCondition() Condition { + return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"} +} + +func TestConditionRoundTrip(t *testing.T) { // 1202/AC2 + for _, c := range []Condition{raisedCondition(), clearedCondition()} { + data, err := EncodeCondition(c) + if err != nil { + t.Fatalf("%s: %v", c.State, err) + } + var probe map[string]any + if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) { + t.Fatalf("%s: %s does not carry v:1", c.State, data) + } + c.V = ConditionVersion + got, err := DecodeCondition(data) + if err != nil || !SameCondition(got, c) { + t.Errorf("%s round trip: %+v %v", c.State, got, err) + } + } + data, _ := EncodeCondition(raisedCondition()) + var probe map[string]any + _ = json.Unmarshal(data, &probe) + for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} { + if _, ok := probe[name]; !ok { + t.Errorf("the JSON has no %q: %s", name, data) + } + } + subject := probe["subject"].(map[string]any) + for _, name := range []string{"scope", "id", "machine"} { + if _, ok := subject[name]; !ok { + t.Errorf("the subject has no %q: %s", name, data) + } + } + action := probe["actions"].([]any)[0].(map[string]any) + for _, name := range []string{"label", "verb", "machine", "arguments", "level"} { + if _, ok := action[name]; !ok { + t.Errorf("the action has no %q: %s", name, data) + } + } + for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} { + if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) { + t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err) + } + } +} + +func TestConditionVerdict(t *testing.T) { // 1202/AC3 + c := raisedCondition() + c.Needs, c.Explanation = "", "The plan waits." + _, err := EncodeCondition(c) + if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") { + t.Errorf("empty needs without its verdict: %v", err) + } + c.Explanation = "Nothing for you to do. The plan waits." + if _, err := EncodeCondition(c); err != nil { + t.Errorf("empty needs with its verdict refused: %v", err) + } + c = raisedCondition() + c.Explanation = "Nothing for you to do. The plan waits." + _, err = EncodeCondition(c) + if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") { + t.Errorf("needs set with the wrong verdict: %v", err) + } + c.Explanation = "Needs you: something else. The plan waits." + if _, err = EncodeCondition(c); err == nil { + t.Error("needs set with other needs in the verdict accepted") + } +} + +func TestConditionRefused(t *testing.T) { // 1202/AC4 + mutate := func(f func(*Condition)) error { + c := raisedCondition() + f(&c) + _, err := EncodeCondition(c) + return err + } + for name, f := range map[string]func(*Condition){ + "severity": func(c *Condition) { c.Severity = "critical" }, + "state": func(c *Condition) { c.State = "open" }, + "key": func(c *Condition) { c.Key = "" }, + "headline": func(c *Condition) { c.Headline = "" }, + "long headline": func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) }, + } { + if err := mutate(f); err == nil { + t.Errorf("a condition with a bad %s accepted", name) + } + } + if err := mutate(func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil { + t.Errorf("a headline of the greatest length refused: %v", err) + } + if _, err := EncodeCondition(clearedCondition()); err != nil { + t.Errorf("a cleared condition with only key, subject and state refused: %v", err) + } + c := clearedCondition() + c.Key = "" + if _, err := EncodeCondition(c); err == nil { + t.Error("a cleared condition without its key accepted") + } + c = clearedCondition() + c.State = "gone" + if _, err := EncodeCondition(c); err == nil { + t.Error("a condition with an unknown state accepted") + } +}