Give every condition a headline, an explanation and a resolved line in plain words
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt (0.00s)
mesh/delivery-group group feat/plain-notifications rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request

The operator could not read the desktop notifications: they carried plan ids,
commits, keys and verb syntax. The words the operator reads now travel with the
condition, so every channel says them (hq ADR 0253).
This commit is contained in:
jochen
2026-10-08 13:22:27 +02:00
parent 950562afe1
commit 1fce541023
12 changed files with 1350 additions and 9 deletions
+13 -1
View File
@@ -125,8 +125,15 @@ type Condition struct {
Kind string `json:"kind"`
Subject Subject `json:"subject"`
Severity Severity `json:"severity"`
// Summary is one line in the mesh's words.
// Summary is one line in the mesh's words, for whoever looks closer: it may name plans, commits and
// the verbs that act.
Summary string `json:"summary"`
// Headline, Explanation and Resolved are what the operator reads, in plain words (novox/hq ADR 0253,
// plain.go): a few words naming the thing and what is wrong; one or two sentences on what happened,
// what it means and whether to act; the one line said when it clears.
Headline string `json:"headline"`
Explanation string `json:"explanation"`
Resolved string `json:"resolved"`
// Evidence is the newest observations, at most KeptEvidence, newest first.
Evidence []Evidence `json:"evidence"`
// Source is the signals-table row, probe or event that raised it: `S1`, `D3`, `provisioner.failing`.
@@ -175,6 +182,11 @@ type Observation struct {
Also []string
Severity Severity
Summary string
// Headline, Explanation and Resolved are the plain words the operator reads (plain.go). Left empty,
// the wording registered for Kind says them.
Headline string
Explanation string
Resolved string
// Said is this observation's evidence, in the mesh's words; Summary when empty. **Detail goes
// here, never in Summary**: an address, a socket's error, a path or a name with its domain is
// kept in the condition's evidence, which stays inside the mesh. The summary leaves it — to the
+273
View File
@@ -0,0 +1,273 @@
package conditions
// Plain words (novox/hq ADR 0253): **a condition carries what the operator reads, in plain words, beside
// what an agent reads.** The summary is one line in the mesh's words for whoever looks closer — it names
// plans, commits and the verbs that act, and stays so. The operator reads a notification between other
// work: a popup that said "the walk of novox/mesh-catalog a6385479 has waited 56m0s for mesh-delivery's
// word to start: `mesh-delivery.show` …" was a wall of identifiers nobody could act on. So every condition
// also carries:
//
// - a **headline**: a few plain words naming the thing and what is wrong ("openrazer not running on
// g14"), the title of every message about it;
// - an **explanation**: one or two plain sentences — what happened, what it means for the operator,
// and whether they need to do anything;
// - a **resolved line**: the one short line said when it clears ("openrazer runs again on g14").
//
// They are made here, where the condition is made, so every channel gets them — the desktop today,
// others later — and none has to guess what a key means. A producer may say them itself; otherwise the
// wording registered for its kind says them; otherwise a plain sentence made from its scope does, and a
// test suite is told (Unworded) so the kind gets words of its own. Each is held to the plain rule (Plain):
// no identifiers, hashes, keys, verb syntax, markup or clock times — a message names no time of its own,
// since the channel says when, in the operator's time.
import (
"fmt"
"regexp"
"strings"
"sync"
"github.com/novox/mesh-controller/internal/outward"
)
// Words are what the operator reads of a condition.
type Words struct {
Headline string
Explanation string
Resolved string
}
// Bounds of the plain words: a headline fits a notification's title line, an explanation two sentences.
const (
HeadlineMax = 60
ExplanationMax = 360
)
var (
wordingsMu sync.RWMutex
wordings = map[string]func(Observation) Words{}
)
// Wording registers the plain words of one condition kind.
func Wording(kind string, words func(Observation) Words) {
wordingsMu.Lock()
defer wordingsMu.Unlock()
wordings[kind] = words
}
// Worded says whether a kind has words of its own.
func Worded(kind string) bool {
wordingsMu.RLock()
defer wordingsMu.RUnlock()
_, ok := wordings[kind]
return ok
}
// Unworded is told of every observation said in borrowed words: its kind has none registered, or the
// words it was given break the plain rule. The keeper says it plainly anyway; a test suite sets this to
// fail the producer.
var Unworded func(o Observation, why string)
// The plain rule's shapes.
var (
hexID = regexp.MustCompile(`\b[0-9a-f]{7,40}\b`)
numberedID = regexp.MustCompile(`\b[a-z]+-[0-9]{6,}\b`)
dotted = regexp.MustCompile(`\b[a-z][a-z0-9_-]*\.[a-z][a-z0-9_-]*\b`)
goDuration = regexp.MustCompile(`\b[0-9]+(h[0-9]+m|m[0-9]+s|h[0-9]+m[0-9]+s)\b|\b[0-9]+(\.[0-9]+)?(ns|ms|µs)\b`)
clockTime = regexp.MustCompile(`\b[0-9]{1,2}:[0-9]{2}\b|\b[0-9]{4}-[0-9]{2}-[0-9]{2}\b|\bUTC\b`)
flag = regexp.MustCompile(`(^|\s)--?[a-z]`)
markup = regexp.MustCompile("[`*<>{}\\[\\]|#]|(^|\\s)_|_(\\s|$)")
)
// Plain says whether text is plain words the operator reads at a glance, and if not, what is not: an
// identifier (a hash, a numbered id, a dotted name such as a key or a verb), a Go duration, a clock time
// or date (the channel says when, in the operator's time), a command's flag, markup, a line break, or
// anything the operator's channel withholds (ADR 0234 §6). machines are names that may appear.
func Plain(text string, machines ...string) (string, bool) {
if strings.TrimSpace(text) == "" {
return "nothing is said", false
}
if strings.ContainsAny(text, "\n\r\t") {
return "a line break", false
}
if m := markup.FindString(text); m != "" {
return "markup (" + m + ")", false
}
for _, shape := range []struct {
re *regexp.Regexp
what string
}{{hexID, "a hash"}, {numberedID, "a numbered id"}, {dotted, "a dotted name"}, {goDuration, "a duration in code"},
{clockTime, "a clock time or date"}, {flag, "a command's flag"}} {
if m := shape.re.FindString(text); m != "" {
if shape.re == hexID && !strings.ContainsAny(m, "0123456789") {
continue // a word of letters a to f only
}
return shape.what + " (" + strings.TrimSpace(m) + ")", false
}
}
if r, ok := outward.Check(text, machines...); !ok {
return r.String(), false
}
return "", true
}
// PlainWords checks all three, with their bounds.
func PlainWords(w Words, machines ...string) (string, bool) {
if why, ok := Plain(w.Headline, machines...); !ok {
return "headline: " + why, false
}
if len(w.Headline) > HeadlineMax {
return fmt.Sprintf("headline: longer than %d characters", HeadlineMax), false
}
if why, ok := Plain(w.Explanation, machines...); !ok {
return "explanation: " + why, false
}
if len(w.Explanation) > ExplanationMax {
return fmt.Sprintf("explanation: longer than %d characters", ExplanationMax), false
}
if why, ok := Plain(w.Resolved, machines...); !ok {
return "resolved: " + why, false
}
if len(w.Resolved) > HeadlineMax+20 {
return fmt.Sprintf("resolved: longer than %d characters", HeadlineMax+20), false
}
return "", true
}
// plainly gives an observation its plain words: its own, its kind's, or its scope's.
func plainly(o Observation) Observation {
machines := append([]string{o.Machine}, o.Also...)
given := Words{o.Headline, o.Explanation, o.Resolved}
var w Words
from := ""
switch {
case given.Headline != "":
w, from = given, "its source"
default:
wordingsMu.RLock()
fn := wordings[o.Kind]
wordingsMu.RUnlock()
if fn != nil {
w, from = fn(o), "the wording of "+o.Kind
if given.Explanation != "" {
w.Explanation = given.Explanation
}
if given.Resolved != "" {
w.Resolved = given.Resolved
}
}
}
if w.Headline != "" && w.Resolved == "" {
w.Resolved = "Resolved: " + lowerFirst(w.Headline)
}
if from == "" {
if Unworded != nil {
Unworded(o, "the kind "+o.Kind+" has no plain words")
}
w = scopeWords(o)
} else if why, ok := PlainWords(w, machines...); !ok {
if Unworded != nil {
Unworded(o, from+" is not plain: "+why)
}
w = scopeWords(o)
}
o.Headline, o.Explanation, o.Resolved = w.Headline, w.Explanation, w.Resolved
return o
}
// Escalated is the sentence a condition's explanation ends with once a healer gave up on it.
const Escalated = "The mesh tried to repair it and could not: it needs you now."
// escalatedWords says in the explanation that a healer gave up, whatever the words said before.
func escalatedWords(c *Condition) {
if c.Escalated() && !strings.HasSuffix(c.Explanation, Escalated) {
c.Explanation = strings.TrimSpace(c.Explanation + " " + Escalated)
}
}
// scopeWords is what is said of a kind with no words of its own: what kind of thing, and which one
// where its name is a name (a machine, a module), never an id.
func scopeWords(o Observation) Words {
thing := ThingWords(o)
what := strings.ReplaceAll(o.Kind, "-", " ")
w := Words{
Headline: Capital(thing) + " needs a look",
Explanation: fmt.Sprintf("The mesh noticed a problem it calls %q with %s. %s", what, thing, ResolverWords(o)),
Resolved: "Resolved: " + thing + " is fine again",
}
if _, ok := PlainWords(w, append([]string{o.Machine}, o.Also...)...); !ok {
w = Words{Headline: "Something in the mesh needs a look",
Explanation: "The mesh noticed a problem it has no plain words for yet. " + ResolverWords(o),
Resolved: "Resolved: the mesh is fine again"}
}
return w
}
// ThingWords is what a condition is about, in plain words: "the machine ace", "openrazer on g14", "a
// delivery". An id that is not a name (a plan's, a delivery's, a call's) is never said.
func ThingWords(o Observation) string {
switch o.Scope {
case ScopeMachine:
if o.Machine != "" {
return o.Machine
}
return "a machine"
case ScopeModule:
module := strings.TrimSuffix(o.ID, "."+o.Machine)
if o.Machine != "" && module != "" && !strings.Contains(module, ".") {
return module + " on " + o.Machine
}
return "a module"
case ScopeProvider:
if module, _, _ := strings.Cut(o.ID, "."); module != "" && o.Machine != "" {
return module + " on " + o.Machine
}
return "a provider"
case ScopePlan:
return "a walk"
case ScopeDelivery:
return "a delivery"
case ScopeBuild:
return "a build"
case ScopeCall:
return "a tool call"
case ScopeMerge:
return "a merge"
case ScopeSeat:
return "a seat's holder"
case ScopeBus:
return "the bus"
case ScopeCore:
return "the controller"
case ScopeProbe:
return "the self-check"
}
return "the mesh"
}
// ResolverWords says whether the operator needs to act, from who resolves it.
func ResolverWords(o Observation) string {
switch {
case o.Resolver == ResolverOperator:
return "It needs you: the mesh does not repair this by itself."
case strings.HasPrefix(o.Resolver, "healer:"):
return "A healer is working on it; nothing to do unless it stays."
case o.Severity == Urgent:
return "It clears by itself once it is fixed, but needs a look now."
}
return "Nothing to do yet: it clears by itself once it is fixed."
}
// Capital is s with its first letter upper case.
func Capital(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func lowerFirst(s string) string {
if s == "" || (len(s) > 1 && strings.ToUpper(s[:2]) == s[:2]) {
return s
}
return strings.ToLower(s[:1]) + s[1:]
}
+137
View File
@@ -0,0 +1,137 @@
package conditions
import (
"encoding/json"
"strings"
"testing"
)
// **The plain rule** (novox/hq ADR 0253): what the operator reads names no identifier, no code and no time
// of its own; the words conditions are made of pass.
func TestThePlainRuleRefusesIdentifiersCodeAndTimes(t *testing.T) {
refused := map[string]string{
"the walk of novox/mesh-catalog a6385479 waits": "a hash",
"plan plan-1791454185265004861 is waiting": "a hash",
"see `mesh-delivery.show` for why": "markup",
"call mesh-delivery.show for why": "a dotted name",
"key plan.x.waiting": "a dotted name",
"waited 56m0s": "a duration in code",
"since 10:40": "a clock time",
"since 2026-10-08": "a clock time",
"raised at noon UTC": "a clock time",
"start it with plans go --why": "a command's flag",
"one line\nand another": "a line break",
"the resolver at 10.77.0.1 is silent": "",
"": "nothing",
"**bold**": "markup",
"storage-media.mount failed": "a dotted name",
"the store at /var/lib/mesh is full": "",
"a commit 0123abc landed": "a hash",
}
for text, want := range refused {
why, ok := Plain(text, "g14")
if ok {
t.Errorf("%q passed", text)
continue
}
if want != "" && !strings.HasPrefix(why, want) {
t.Errorf("%q: refused for %q, want %q", text, why, want)
}
}
for _, text := range []string{
"openrazer delivery waiting to start",
"The change to openrazer is merged and built, and has waited 56 minutes for mesh-delivery to let it start.",
"3 failed services on shanks",
"On shanks, mnt-recalbox (a mount), storage-media (a mount) and greenclip failed.",
"Name lookups fail on g14",
"Retired data of postgres on anchor waits for app_db",
"Push keeps being fixed by hand",
} {
if why, ok := Plain(text, "g14", "shanks", "anchor"); !ok {
t.Errorf("%q refused: %s", text, why)
}
}
}
// **A condition carries its plain words**: a producer's own; else its kind's; else its scope's — and the
// event says them beside the summary, for every channel.
func TestAConditionCarriesItsPlainWords(t *testing.T) {
k, _, told, _ := keeper(t)
before := Unworded
var unworded []string
Unworded = func(o Observation, why string) { unworded = append(unworded, why) }
t.Cleanup(func() { Unworded = before })
Wording("test-silent", func(o Observation) Words {
return Words{Headline: o.Machine + " is not answering", Explanation: "The mesh has not heard from it.",
Resolved: o.Machine + " answers again"}
})
c, err := k.Observe(t.Context(), Observation{Scope: ScopeMachine, ID: "ace", Kind: "test-silent", Machine: "ace",
Severity: Warning, Source: "S1", Summary: "ace has not been heard from since 12:00 UTC (bound 3m0s)"})
if err != nil {
t.Fatal(err)
}
if c.Headline != "ace is not answering" || c.Resolved != "ace answers again" || c.Explanation == "" {
t.Fatalf("the kind's words were not kept: %+v", c)
}
if c.Summary != "ace has not been heard from since 12:00 UTC (bound 3m0s)" {
t.Errorf("the summary was changed: %q", c.Summary)
}
said := settled(t, told, 1)
body, _ := json.Marshal(said[0])
for _, field := range []string{`"headline":"ace is not answering"`, `"explanation":"The mesh has not heard from it."`,
`"resolved":"ace answers again"`} {
if !strings.Contains(string(body), field) {
t.Errorf("the event does not carry %s: %s", field, body)
}
}
// A producer's own words win; a resolved line it leaves out is made from its headline.
c, _ = k.Observe(t.Context(), Observation{Scope: ScopeModule, ID: "openrazer.g14", Kind: "test-silent",
Machine: "g14", Severity: Warning, Source: "health", Summary: "openrazer on g14 is not healthy",
Headline: "openrazer not working on g14", Explanation: "Its service stopped with an error."})
if c.Headline != "openrazer not working on g14" || c.Resolved != "Resolved: openrazer not working on g14" {
t.Errorf("the producer's words: %+v", c)
}
if len(unworded) != 0 {
t.Errorf("words that were plain were reported: %v", unworded)
}
// Words that are not plain, and a kind with none, are said from the scope — and reported.
c, _ = k.Observe(t.Context(), Observation{Scope: ScopeModule, ID: "openrazer.g14", Token: "x", Kind: "test-unworded",
Machine: "g14", Severity: Warning, Source: "health", Summary: "openrazer on g14 is not healthy"})
if c.Headline != "Openrazer on g14 needs a look" || !strings.Contains(c.Explanation, "Nothing to do yet") {
t.Errorf("the scope's words: %+v", c)
}
c, _ = k.Observe(t.Context(), Observation{Scope: ScopePlan, ID: "plan-1791454185265004861", Kind: "test-silent",
Severity: Warning, Source: "S16", Summary: "the walk waits", Headline: "plan-1791454185265004861 waits"})
if strings.Contains(c.Headline, "plan-") || c.Headline != "A walk needs a look" {
t.Errorf("an id reached the headline: %+v", c)
}
if len(unworded) != 2 || !strings.Contains(unworded[0], "no plain words") || !strings.Contains(unworded[1], "not plain") {
t.Errorf("what was said in borrowed words was not reported: %v", unworded)
}
}
// **A healer that gave up says so in the explanation**: the words of the kind said "nothing to do"; once the
// budget is spent, the operator is needed.
func TestAnEscalatedConditionSaysItNeedsTheOperator(t *testing.T) {
k, _, _, _ := keeper(t)
ctx := t.Context()
o := Observation{Scope: ScopeMachine, ID: "ace", Kind: "silent", Machine: "ace", Severity: Warning, Source: "S1",
Summary: "ace is silent", Headline: "ace is not answering", Explanation: "Nothing to do yet."}
if _, err := k.Observe(ctx, o); err != nil {
t.Fatal(err)
}
c, _, err := k.Escalate(ctx, o.Key(), Attempt{What: "budget spent", Outcome: "escalated", By: "healer H1"})
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(c.Explanation, Escalated) {
t.Fatalf("escalated: %q", c.Explanation)
}
c, _ = k.Observe(ctx, o)
if strings.Count(c.Explanation, Escalated) != 1 {
t.Errorf("seen again after escalation: %q", c.Explanation)
}
}
+6 -1
View File
@@ -175,6 +175,7 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
return Condition{}, err
}
o = k.sayable(o)
o = plainly(o)
key := o.Key()
for i := 0; i < tries; i++ {
now := k.now().UTC()
@@ -188,7 +189,8 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
}
if !found {
c := Condition{Key: key, Kind: o.Kind, Subject: Subject{Scope: o.Scope, ID: o.ID, Machine: o.Machine, Also: o.Also},
Severity: o.Severity, Summary: o.Summary, Evidence: []Evidence{{At: now, Said: said}},
Severity: o.Severity, Summary: o.Summary, Headline: o.Headline, Explanation: o.Explanation,
Resolved: o.Resolved, Evidence: []Evidence{{At: now, Said: said}},
Source: o.Source, Raised: now, LastObserved: now, Observations: 1, Count: 1,
Resolver: orSelf(o.Resolver)}
change := ChangeRaised
@@ -238,6 +240,8 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
// The kind as the source says it now: a source that gave the same key a kind of its own since
// (a probe's finding split out for a healer) is read by that kind from its next observation.
c.Kind, c.Summary, c.Source, c.LastObserved = o.Kind, o.Summary, o.Source, now
c.Headline, c.Explanation, c.Resolved = o.Headline, o.Explanation, o.Resolved
escalatedWords(&c)
if o.Machine != "" {
c.Subject.Machine = o.Machine
}
@@ -335,6 +339,7 @@ func (k *Keeper) Escalate(ctx context.Context, key string, a Attempt) (Condition
changes = append(changes, Event{Change: ChangeResolver, Was: c.Resolver, Why: a.Outcome})
c.Resolver = ResolverOperator
}
escalatedWords(c)
return changes
})
}