From b5db9466c22462676969d0a5e319df493334e2d2 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:13:43 +0100 Subject: [PATCH] =?UTF-8?q?ADR=200627=20=C2=A71:=20operator-channel's=20cl?= =?UTF-8?q?ient=20holds=20the=20asking=20and=20the=20warrant's=20checks=20?= =?UTF-8?q?over=20the=20contract's=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-sdk's asks logic (an ask's check and digest, the act's digest, a warrant's For and Says, the condition's encoding) and its asker client move here, over operatorchannel's generated Ask, Option, Warrant and Condition; the digests are those asks gave (a row holds two of them), so a router and an asker of either build agree. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../containerimageregistryclient/go.mod | 5 - go/clients/gopackageregistryclient/go.mod | 5 - go/clients/npmpackageregistryclient/go.mod | 5 - go/clients/ociartifactregistryclient/go.mod | 5 - go/clients/operatorchannelclient/ask.go | 374 ++++++++++++++++++ go/clients/operatorchannelclient/ask_test.go | 368 +++++++++++++++++ go/clients/operatorchannelclient/client.go | 197 +++++++++ .../operatorchannelclient/client_test.go | 139 +++++++ go/clients/operatorchannelclient/condition.go | 117 ++++++ .../operatorchannelclient/condition_test.go | 145 +++++++ go/clients/operatorchannelclient/go.mod | 3 + module.json | 1 + 12 files changed, 1344 insertions(+), 20 deletions(-) create mode 100644 go/clients/operatorchannelclient/ask.go create mode 100644 go/clients/operatorchannelclient/ask_test.go create mode 100644 go/clients/operatorchannelclient/client.go create mode 100644 go/clients/operatorchannelclient/client_test.go create mode 100644 go/clients/operatorchannelclient/condition.go create mode 100644 go/clients/operatorchannelclient/condition_test.go create mode 100644 go/clients/operatorchannelclient/go.mod diff --git a/go/clients/containerimageregistryclient/go.mod b/go/clients/containerimageregistryclient/go.mod index a58584d..ea5bb60 100644 --- a/go/clients/containerimageregistryclient/go.mod +++ b/go/clients/containerimageregistryclient/go.mod @@ -1,8 +1,3 @@ module git.novox.be/novox/mesh-contracts/go/clients/containerimageregistryclient go 1.26.0 - -require ( - git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0 - git.novox.be/novox/mesh-sdk/go v0.0.0 -) diff --git a/go/clients/gopackageregistryclient/go.mod b/go/clients/gopackageregistryclient/go.mod index 59bef72..7678fd3 100644 --- a/go/clients/gopackageregistryclient/go.mod +++ b/go/clients/gopackageregistryclient/go.mod @@ -1,8 +1,3 @@ module git.novox.be/novox/mesh-contracts/go/clients/gopackageregistryclient go 1.26.0 - -require ( - git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry v0.0.0 - git.novox.be/novox/mesh-sdk/go v0.0.0 -) diff --git a/go/clients/npmpackageregistryclient/go.mod b/go/clients/npmpackageregistryclient/go.mod index a7815fe..94d8c30 100644 --- a/go/clients/npmpackageregistryclient/go.mod +++ b/go/clients/npmpackageregistryclient/go.mod @@ -1,8 +1,3 @@ module git.novox.be/novox/mesh-contracts/go/clients/npmpackageregistryclient go 1.26.0 - -require ( - git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry v0.0.0 - git.novox.be/novox/mesh-sdk/go v0.0.0 -) diff --git a/go/clients/ociartifactregistryclient/go.mod b/go/clients/ociartifactregistryclient/go.mod index a08e021..a37d128 100644 --- a/go/clients/ociartifactregistryclient/go.mod +++ b/go/clients/ociartifactregistryclient/go.mod @@ -1,8 +1,3 @@ module git.novox.be/novox/mesh-contracts/go/clients/ociartifactregistryclient go 1.26.0 - -require ( - git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry v0.0.0 - git.novox.be/novox/mesh-sdk/go v0.0.0 -) diff --git a/go/clients/operatorchannelclient/ask.go b/go/clients/operatorchannelclient/ask.go new file mode 100644 index 0000000..a886bb4 --- /dev/null +++ b/go/clients/operatorchannelclient/ask.go @@ -0,0 +1,374 @@ +// Package operatorchannelclient is the operator-channel seat's client (novox/hq ADR 0627 §1; ADR 0259): the logic +// every asker and the router share over the seat's contract. An asker checks its ask (Check, Glance), binds what each +// option performs (ActDigest), publishes it under its own name and acts on the warrant it hears only when the +// warrant answers that ask, as the person was shown it (Digest, For, Performs). A module says a condition to the +// operator by the seat's condition accept (EncodeCondition). The asker's keeping of its asks and warrants is Client. +// The shapes are the seat's contract's (operatorchannel); this package holds no type of its own for them. +package operatorchannelclient + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" +) + +// The levels an option is answered at: how much proof its answer needs (novox/hq ADR 0234 §8, the glossary's +// assurance level). +const ( + // Acknowledge performs only what any granted principal may already do: silencing, details. No proof. + Acknowledge = "acknowledge" + // Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code. + Approve = "approve" + // Destroy needs two proofs, one of them a code. + Destroy = "destroy" +) + +// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less. +func Rank(level string) int { + switch level { + case Acknowledge: + return 0 + case Approve: + return 1 + case Destroy: + return 2 + } + return 3 +} + +// Operator is the one role an ask may be answered by today. +const Operator = "operator" + +// How an ask ended (a warrant's outcome). +const ( + OutcomeChosen = "chosen" // a person chose an option: a warrant + OutcomeExpired = "expired" // nobody answered in time + OutcomeCancelled = "cancelled" // its asker took it back + OutcomeReplaced = "replaced" // a newer ask about the same thing replaced it + OutcomeRefused = "refused" // it was never shown: Words says why +) + +// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each +// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and +// values alone, never over how a language happens to encode a struct. +type Act map[string]string + +// ActDigest is the digest an asker puts in an option's Binds: SHA-256 over the act's canonical encoding +// (canonical), written "sha256:". The same names and values give the same digest in any language, whatever +// order they were set in; a field renamed, added or emptied gives another. +func ActDigest(act Act) (string, error) { + if len(act) == 0 { + return "", fmt.Errorf("the act cannot be digested: it names nothing") + } + keys := make([]string, 0, len(act)) + for k := range act { + if k == "" { + return "", fmt.Errorf("the act cannot be digested: a field has no name") + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString("novox.act.v1\n") + for _, k := range keys { + canonical(&b, k) + canonical(&b, act[k]) + } + sum := sha256.Sum256([]byte(b.String())) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read +// as another's end or start, so two different sequences of values never encode the same. +func canonical(b *strings.Builder, v string) { + b.WriteString(strconv.Itoa(len(v))) + b.WriteByte(':') + b.WriteString(v) + b.WriteByte('\n') +} + +// Time reads an RFC 3339 time of the contract (an ask's expiry, a warrant's time); empty is the zero time. +func Time(s string) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339Nano, s) +} + +// FormatTime is a time as the contract carries it: UTC, RFC 3339 to the nanosecond; the zero time is empty. +func FormatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339Nano) +} + +// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each +// binds, who answers, and its expiry. The router puts it in the warrant (AskDigest), and an asker acts only on a +// warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the person was shown it, and +// nothing published under the same id before or after. +// +// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC RFC 3339 to +// the nanosecond — never over a language's encoding of the struct, so a field added to the ask later changes no +// digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow the options only when the +// ask gives either: an ask without them digests as it did before they existed, so a router and an asker of +// different builds still agree on every such ask. An expiry that is no RFC 3339 time is digested as written. +func Digest(a seat.Ask) string { + expires := a.Expires + if t, err := Time(a.Expires); err == nil { + expires = t.UTC().Format(time.RFC3339Nano) + } + var b strings.Builder + b.WriteString("novox.ask.v1\n") + for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who, expires, a.OnExpiry, a.About, + strconv.FormatBool(a.Urgent), strconv.Itoa(len(a.Options))} { + canonical(&b, v) + } + for _, o := range a.Options { + for _, v := range []string{o.ID, o.Label, o.Does, o.Level, o.Binds} { + canonical(&b, v) + } + } + // Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever + // it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes — + // none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks + // encode the same. An ask with none of them digests as before they existed, and one without WholeDetails as + // before it existed. + if a.Whole != "" || a.Details != "" || a.WholeDetails != "" { + canonical(&b, a.Whole) + canonical(&b, a.Details) + } + if a.WholeDetails != "" { + canonical(&b, a.WholeDetails) + } + sum := sha256.Sum256([]byte(b.String())) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary +// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at +// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when, +// the fingerprint and how to read it whole are Details, never the explanation. +const ( + SummaryLength = 300 + MostPoints = 3 + PointLength = 160 +) + +// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than +// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not +// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that +// does not hold to it. +func Glance(explanation string) error { + var lines []string + for _, l := range strings.Split(explanation, "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, strings.TrimSpace(l)) + } + } + var problems []string + switch { + case len(lines) == 0: + problems = append(problems, "it has no summary") + case len([]rune(lines[0])) > SummaryLength: + problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength)) + } + if len(lines) > 1+MostPoints { + problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints)) + } + for i, l := range lines { + if i > 0 && len([]rune(l)) > PointLength { + problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength)) + } + } + if len(problems) > 0 { + return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; ")) + } + return nil +} + +// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4). +const ( + MostOptions = 4 + MostOpen = 3 + ApproveLasts = 24 * time.Hour + DestroyLasts = 10 * time.Minute + HeadlineLength = 60 + LabelLength = 24 +) + +var usableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`) + +// UsableID says whether a name can be an ask's or an option's id: a key and a subject token both. +func UsableID(id string) bool { return usableID.MatchString(id) } + +// Highest is the highest level among the ask's options. +func Highest(a seat.Ask) string { + high := Acknowledge + for _, o := range a.Options { + if Rank(o.Level) > Rank(high) { + high = o.Level + } + } + return high +} + +// OptionOf is the ask's option of this id, or false. +func OptionOf(a seat.Ask, id string) (seat.Option, bool) { + for _, o := range a.Options { + if o.ID == id { + return o, true + } + } + return seat.Option{}, false +} + +// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on. +func Check(a seat.Ask, now time.Time) error { + var problems []string + say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } + if !UsableID(a.ID) { + say("its id %q is not letters, digits, - and _, at most 64", a.ID) + } + if strings.TrimSpace(a.Headline) == "" || len([]rune(a.Headline)) > HeadlineLength { + say("its headline is empty or longer than %d characters", HeadlineLength) + } + if strings.TrimSpace(a.Explanation) == "" { + say("it explains nothing") + } + if a.Who != Operator { + say("it is answered by %q, and only the operator answers today", a.Who) + } + if len(a.Options) == 0 || len(a.Options) > MostOptions { + say("it offers %d options, and an ask offers one to %d", len(a.Options), MostOptions) + } + seen := map[string]bool{} + for _, o := range a.Options { + switch { + case !UsableID(o.ID): + say("an option's id %q is not letters, digits, - and _", o.ID) + case seen[o.ID]: + say("the option %s is offered twice", o.ID) + } + seen[o.ID] = true + if strings.TrimSpace(o.Label) == "" || len([]rune(o.Label)) > LabelLength { + say("the option %s's label is empty or longer than %d characters", o.ID, LabelLength) + } + if strings.TrimSpace(o.Does) == "" { + say("the option %s does not say what it does", o.ID) + } + if Rank(o.Level) > Rank(Destroy) { + say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level) + } + if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") { + say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID) + } + } + expires, err := Time(a.Expires) + if err != nil { + say("its expiry %q is not an RFC 3339 time", a.Expires) + } + if !expires.After(now) { + say("it expires before it is asked") + } + switch Highest(a) { + case Approve: + if expires.After(now.Add(ApproveLasts)) { + say("an ask that approves lasts at most %s", ApproveLasts) + } + case Destroy: + if expires.After(now.Add(DestroyLasts)) { + say("an ask that destroys lasts at most %s", DestroyLasts) + } + } + if Highest(a) != Acknowledge && strings.TrimSpace(a.OnExpiry) == "" { + say("it does not say what happens when nobody answers, and an ask that authorises never defaults") + } + if a.About != "" && strings.ContainsAny(a.About, " \n") { + say("what it is about is a key, without spaces") + } + if len(problems) > 0 { + return errors.New("the ask is refused: " + strings.Join(problems, "; ")) + } + return nil +} + +// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id +// verified), chose Release". +func Says(w seat.Warrant) string { + if w.Outcome != OutcomeChosen || w.By == nil { + return fmt.Sprintf("no person chose: the ask %s %s", w.Ask, w.Outcome) + } + via := w.By.Kind + if w.By.Verified != "" { + via += " (" + w.By.Verified + ")" + } + return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label) +} + +// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the +// same words, options and binds), a choice, an option the ask offered, at that option's level, before the +// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names. +func For(w seat.Warrant, asker string, a seat.Ask) (seat.Option, error) { + if w.Asker != asker || w.Ask != a.ID { + return seat.Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID) + } + if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who { + return seat.Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome) + } + if d := Digest(a); w.AskDigest != d { + return seat.Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+ + "it was not the ask the person was shown", w.AskDigest, a.ID, d) + } + o, offered := OptionOf(a, w.Option) + if !offered { + return seat.Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option) + } + if w.Level != o.Level { + return seat.Option{}, fmt.Errorf("the option %s is %s, and the warrant says %s", o.ID, o.Level, w.Level) + } + // A choice made after the ask expired is no answer to it, whatever the router said. An ask that says no + // expiry, or a warrant that says no time, is no answer either: neither can be shown to be in time. + expires, err := Time(a.Expires) + if err != nil || expires.IsZero() { + return seat.Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID) + } + at, err := Time(w.At) + if err != nil || at.IsZero() { + return seat.Option{}, fmt.Errorf("the warrant for the ask %s says no time it was given, so it cannot be shown to be in time", a.ID) + } + if at.After(expires) { + return seat.Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s", + at.UTC().Format(time.RFC3339), a.ID, expires.UTC().Format(time.RFC3339)) + } + return o, nil +} + +// Performs checks that the act an asker is about to perform is the one the chosen option bound when it +// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes. +func Performs(o seat.Option, act Act) error { + if o.Binds == "" && o.Level == Acknowledge { + return nil + } + d, err := ActDigest(act) + if err != nil { + return err + } + if d != o.Binds { + return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d) + } + return nil +} + +// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR +// 0259 §2 to admit a node's engine as an asker): `node-engine-`, the last token of the by-caller subjects +// granted to that node's engine account alone, so no engine acts on another node's warrant. +func EngineAsker(node string) string { return "node-engine-" + node } diff --git a/go/clients/operatorchannelclient/ask_test.go b/go/clients/operatorchannelclient/ask_test.go new file mode 100644 index 0000000..1a68435 --- /dev/null +++ b/go/clients/operatorchannelclient/ask_test.go @@ -0,0 +1,368 @@ +package operatorchannelclient + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" +) + +var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) + +func held() seat.Ask { + return seat.Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.", + Who: Operator, Expires: FormatTime(now.Add(time.Hour)), OnExpiry: "the delivery stays held", About: "delivery.openrazer", + Options: []seat.Option{ + {ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")}, + {ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")}, + {ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge}, + }} +} + +func bind(verb, what string) string { + d, err := ActDigest(Act{"verb": verb, "delivery": what}) + if err != nil { + panic(err) + } + return d +} + +func TestASoundAskPasses(t *testing.T) { + if err := Check(held(), now); err != nil { + t.Fatal(err) + } +} + +func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) { + for want, change := range map[string]func(*seat.Ask){ + "is not letters": func(a *seat.Ask) { a.ID = "a.b" }, + "headline is empty": func(a *seat.Ask) { a.Headline = "" }, + "explains nothing": func(a *seat.Ask) { a.Explanation = " " }, + "only the operator answers": func(a *seat.Ask) { a.Who = "agent" }, + "offers 0 options": func(a *seat.Ask) { a.Options = nil }, + "offers 5 options": func(a *seat.Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) }, + "is offered twice": func(a *seat.Ask) { a.Options[1].ID = "release" }, + "does not say what it does": func(a *seat.Ask) { a.Options[0].Does = "" }, + "none of acknowledge": func(a *seat.Ask) { a.Options[0].Level = "admin" }, + "expires before it is asked": func(a *seat.Ask) { a.Expires = FormatTime(now) }, + "approves lasts at most": func(a *seat.Ask) { a.Expires = FormatTime(now.Add(25 * time.Hour)) }, + "destroys lasts at most": func(a *seat.Ask) { a.Options[0].Level = Destroy }, + "never defaults": func(a *seat.Ask) { a.OnExpiry = "" }, + "is a key, without spaces": func(a *seat.Ask) { a.About = "a b" }, + "does not bind what it": func(a *seat.Ask) { a.Options[0].Binds = "" }, + } { + a := held() + change(&a) + if err := Check(a, now); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("want a refusal saying %q, got %v", want, err) + } + } + ack := held() + ack.Options = ack.Options[2:] + ack.OnExpiry = "" + ack.Expires = FormatTime(now.Add(7 * 24 * time.Hour)) + if err := Check(ack, now); err != nil { + t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err) + } +} + +func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) { + w := seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", Label: "Release", + Level: Approve, By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, + AskDigest: Digest(held()), At: FormatTime(now.Add(time.Minute))} + if o, err := For(w, "an-asker", held()); err != nil || o.ID != "release" { + t.Fatalf("a sound warrant was refused: %v", err) + } + if got := Says(w); got != "the operator, via telegram (user id verified), chose Release" { + t.Errorf("the warrant says %q", got) + } + for why, change := range map[string]func(*seat.Warrant){ + "another asker": func(w *seat.Warrant) { w.Asker = "mesh-controller" }, + "another ask": func(w *seat.Warrant) { w.Ask = "d-2" }, + "no choice": func(w *seat.Warrant) { w.Outcome = OutcomeExpired }, + "nobody": func(w *seat.Warrant) { w.By = nil }, + "not offered": func(w *seat.Warrant) { w.Option = "delete" }, + "a lower level": func(w *seat.Warrant) { w.Level = Acknowledge }, + "another role": func(w *seat.Warrant) { w.By = &seat.Person{Who: "agent"} }, + "no digest": func(w *seat.Warrant) { w.AskDigest = "" }, + "no time given": func(w *seat.Warrant) { w.At = "" }, + "after expiry": func(w *seat.Warrant) { w.At = FormatTime(now.Add(2 * time.Hour)) }, + "another ask's digest": func(w *seat.Warrant) { + other := held() + other.Options[0].Binds = bind("release", "another") + w.AskDigest = Digest(other) + }, + } { + c := w + by := *w.By + c.By = &by + change(&c) + if _, err := For(c, "an-asker", held()); err == nil { + t.Errorf("a warrant with %s was acted on", why) + } + } +} + +// A warrant answers one ask as it was shown, and authorises the act its option bound and no other +// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments). +func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) { + a := held() + // The digest survives the trip through the bus: what the router decodes and digests is what was sent. + raw, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var decoded seat.Ask + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if Digest(decoded) != Digest(a) { + t.Fatalf("the ask's digest changed on the way: %s, %s", Digest(decoded), Digest(a)) + } + local := a + local.Expires = inZone(a.Expires, time.FixedZone("CEST", 2*3600)) + if Digest(local) != Digest(a) { + t.Error("the same instant in another zone digests differently") + } + // Changing anything the person was shown, or what an option binds, changes the digest. + for what, change := range map[string]func(*seat.Ask){ + "the headline": func(a *seat.Ask) { a.Headline += "!" }, + "the explanation": func(a *seat.Ask) { a.Explanation += " " }, + "a label": func(a *seat.Ask) { a.Options[0].Label = "Go" }, + "what it binds": func(a *seat.Ask) { a.Options[0].Binds = bind("release", "another") }, + "the expiry": func(a *seat.Ask) { a.Expires = shift(a.Expires, time.Second) }, + "what it is about": func(a *seat.Ask) { a.About = "delivery.other" }, + } { + c := held() + change(&c) + if Digest(c) == Digest(a) { + t.Errorf("changing %s leaves the digest", what) + } + } + o, _ := OptionOf(a, "release") + if err := Performs(o, Act{"verb": "release", "delivery": "openrazer"}); err != nil { + t.Errorf("the bound act was refused: %v", err) + } + if err := Performs(o, Act{"verb": "release", "delivery": "another"}); err == nil { + t.Error("an act the option did not bind was let through") + } + ack, _ := OptionOf(a, "silence") + if err := Performs(ack, Act{"anything": "x"}); err != nil { + t.Errorf("an acknowledgement that bound nothing: %v", err) + } +} + +// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's +// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another +// order changes nothing it should not — and every field that is shown or bound is in it. +func TestDigestsAreCanonicalOverNamedFields(t *testing.T) { + // The digests are those mesh-sdk's asks gave before the move to the contract's types (ADR 0627 §1): a router and + // an asker of either build agree on every ask. + gold := held() + gold.Whole, gold.Details, gold.WholeDetails = "w", "d", "wd" + if Digest(held()) != "sha256:088e7e69e1783b78c1b7e8310e0a6d0504a88a37982b293bc1e667da4565bb0f" || + Digest(gold) != "sha256:997af29a80663c5eb7d9dd38fc029c4a15fd726220b25e3c7cdc54d5a60bbbae" { + t.Errorf("the digests changed with the move: %s, %s", Digest(held()), Digest(gold)) + } + a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"}) + b := Act{} + b["arg.delivery"] = "hq" + b["verb"] = "release" + a2, _ := ActDigest(b) + if a1 != a2 { + t.Error("the order an act was built in changed its digest") + } + // A value cannot be read as two: "a"+"b=c" is not "a=b"+"c". + x, _ := ActDigest(Act{"a": "b\n1:c"}) + y, _ := ActDigest(Act{"a": "b", "c": ""}) + if x == y { + t.Error("two different acts encode the same") + } + if _, err := ActDigest(Act{}); err == nil { + t.Error("an empty act was digested") + } + // The encoding, by hand: any language reproduces it. + sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n")) + if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) { + t.Errorf("the act's encoding is not the one written down: %s", got) + } + + // Every exported field of Ask and Option is in the digest: changing any one changes it. + base := held() + mutations := map[string]func(*seat.Ask){ + "ID": func(a *seat.Ask) { a.ID = "other" }, + "Headline": func(a *seat.Ask) { a.Headline += "!" }, + "Explanation": func(a *seat.Ask) { a.Explanation += "!" }, + "Who": func(a *seat.Ask) { a.Who = "someone" }, + "Expires": func(a *seat.Ask) { a.Expires = shift(a.Expires, time.Nanosecond) }, + "OnExpiry": func(a *seat.Ask) { a.OnExpiry += "!" }, + "About": func(a *seat.Ask) { a.About += "!" }, + "Urgent": func(a *seat.Ask) { a.Urgent = !a.Urgent }, + "Options": func(a *seat.Ask) { a.Options = a.Options[:1] }, + "Option.ID": func(a *seat.Ask) { a.Options = append([]seat.Option(nil), a.Options...); a.Options[0].ID = "x" }, + "Option.Label": func(a *seat.Ask) { + a.Options = append([]seat.Option(nil), a.Options...) + a.Options[0].Label += "!" + }, + "Option.Does": func(a *seat.Ask) { a.Options = append([]seat.Option(nil), a.Options...); a.Options[0].Does += "!" }, + "Option.Level": func(a *seat.Ask) { + a.Options = append([]seat.Option(nil), a.Options...) + a.Options[0].Level = Destroy + }, + "Option.Binds": func(a *seat.Ask) { + a.Options = append([]seat.Option(nil), a.Options...) + a.Options[0].Binds = "sha256:00" + }, + } + for name, mutate := range mutations { + c := base + mutate(&c) + if Digest(c) == Digest(base) { + t.Errorf("changing %s left the digest the same", name) + } + } + // Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests + // exactly as before they existed — the encoding by hand, so no build can drift from it unseen. + plain := seat.Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: FormatTime(now), OnExpiry: "o", About: "a", + Options: []seat.Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}} + byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" + + "2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n")) + if Digest(plain) != "sha256:"+hex.EncodeToString(byHand[:]) { + t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", Digest(plain)) + } + withWhole, withDetails := plain, plain + withWhole.Whole = "e, whole" + withDetails.Details = "fingerprint 1234" + if Digest(withWhole) == Digest(plain) || Digest(withDetails) == Digest(plain) || Digest(withWhole) == Digest(withDetails) { + t.Error("Whole or Details left the digest the same") + } + both := withWhole + both.Details = "fingerprint 1234" + changed := both + changed.Whole += "!" + if Digest(changed) == Digest(both) { + t.Error("changing Whole beside Details left the digest the same") + } + // A field added to Ask or Option without being added to Digest fails here. + if n := reflect.TypeOf(seat.Ask{}).NumField(); n != 12 { + t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n) + } + if n := reflect.TypeOf(seat.Option{}).NumField(); n != 5 { + t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n) + } + // The same instant in another zone is the same ask. + z := base + z.Expires = inZone(z.Expires, time.FixedZone("x", 3600)) + if Digest(z) != Digest(base) { + t.Error("the expiry's zone changed the digest") + } +} + +// An ask that says no expiry is answered by no warrant (the confirmation review of 2026-10-09): nothing could +// show the answer was in time. +func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) { + a := held() + a.Expires = "" + w := seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", Label: "Release", + Level: Approve, By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: Digest(a), + At: FormatTime(now.Add(time.Minute))} + if _, err := For(w, "an-asker", a); err == nil { + t.Error("a warrant for an ask with no expiry was taken") + } +} + +// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639). +func TestAnExplanationReadsAtAGlance(t *testing.T) { + if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil { + t.Fatalf("a summary and two points: %v", err) + } + var wall strings.Builder + wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.") + for i := 1; i <= 13; i++ { + wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3)) + } + err := Glance(wall.String()) + if err == nil || !strings.Contains(err.Error(), "13 points") { + t.Fatalf("thirteen clause lines pass as a glance: %v", err) + } + if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") { + t.Fatalf("a long summary passes: %v", err) + } + if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") { + t.Fatalf("a long point passes: %v", err) + } + if err := Glance(" \n"); err == nil { + t.Fatal("an empty explanation passes") + } +} + +// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker +// of different builds still agree on every such ask. +func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) { + a := seat.Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"} + before := Digest(a) + a.WholeDetails = "+ shares: media=/storage/media" + if Digest(a) == before { + t.Fatal("the whole details are not in the digest") + } + b := a + b.WholeDetails = "+ shares: media=/storage/other" + if Digest(a) == Digest(b) { + t.Fatal("two whole details digest the same") + } + a.WholeDetails = "" + if Digest(a) != before { + t.Fatal("an ask without whole details digests otherwise than before") + } +} + +// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as +// a Whole and Details that happen to spell the same values, nor as any other split of the three fields. +func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) { + base := seat.Ask{ID: "a", Headline: "h", Explanation: "e"} + shapes := []seat.Ask{} + for _, f := range []struct{ whole, details, wholeDetails string }{ + {"", "", "+ x: 1"}, + {"whole-details", "+ x: 1", ""}, + {"", "+ x: 1", ""}, + {"+ x: 1", "", ""}, + {"", "", "whole-details"}, + {"", "+ x: 1", "+ x: 1"}, + {"+ x: 1", "+ x: 1", ""}, + {"", "", ""}, + } { + a := base + a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails + shapes = append(shapes, a) + } + seen := map[string]int{} + for i, a := range shapes { + if j, ok := seen[Digest(a)]; ok { + t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a) + } + seen[Digest(a)] = i + } +} + +// shift is an RFC 3339 time of the contract moved by d. +func shift(s string, d time.Duration) string { + t, err := Time(s) + if err != nil { + panic(err) + } + return t.Add(d).Format(time.RFC3339Nano) +} + +// inZone is an RFC 3339 time of the contract written in another zone: the same instant. +func inZone(s string, z *time.Location) string { + t, err := Time(s) + if err != nil { + panic(err) + } + return t.In(z).Format(time.RFC3339Nano) +} diff --git a/go/clients/operatorchannelclient/client.go b/go/clients/operatorchannelclient/client.go new file mode 100644 index 0000000..f67025a --- /dev/null +++ b/go/clients/operatorchannelclient/client.go @@ -0,0 +1,197 @@ +package operatorchannelclient + +// Client is a module's asking of a person (novox/hq ADR 0259 §10): it publishes an ask under the module's own name, +// cancels it, reads its state from the router's record, and hands the module each warrant once — only one for an ask +// it made, for an option it offered, at that option's level, before it expired. The module then acts with its own +// grants. +// +// It keeps what it asked, and which warrants it acted on, in a state of the module's own (declared in its manifest, +// by default `asked`): the ask is written before it is published, and a warrant is marked acted on by compare-and-set +// before the module's handler runs, so a redelivery, or a second instance, acts once. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Client asks as one module. +type Client struct { + // Module is the asking module's name: the one the bus grants its asks under. + Module string + // State is the module's own state the asks are kept in. + State Store + // Now is the clock; nil is time.Now. + Now func() time.Time + // Publish, Record and Subscribe reach the bus; nil is the stdio SDK's, through the module's runtime. + Publish func(subject string, body any, id string) error + Record func(key string) (*stdio.StateEntry, error) + Subscribe func(pattern string, handle func(stdio.Envelope) error) error +} + +// Store is where the asks are kept: stdio.State(name) is one. +type Store interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) +} + +// New is a client for the module the runtime serves, keeping its asks in the state named. +func New(state string) *Client { + return &Client{Module: os.Getenv("MESH_MODULE"), State: stdio.State(state)} +} + +func (c *Client) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +// Kept is one ask as the module keeps it. +type Kept struct { + Ask seat.Ask `json:"ask"` + Asked time.Time `json:"asked"` + Acted bool `json:"acted,omitempty"` + Ended string `json:"ended,omitempty"` + Answer *seat.Warrant `json:"answer,omitempty"` +} + +func key(id string) string { return "ask." + id } + +// Ask checks an ask, keeps it, and publishes it under the module's own name. An ask with an id already kept +// is refused: a new question is a new id. +func (c *Client) Ask(a seat.Ask) error { + if err := Check(a, c.now()); err != nil { + return err + } + if _, err := c.State.Create(key(a.ID), Kept{Ask: a, Asked: c.now()}); err != nil { + if errors.Is(err, stdio.ErrChanged) { + return fmt.Errorf("an ask %s was already made: a new question is a new id", a.ID) + } + return err + } + return c.publish(seat.AcceptSubjectAsk(c.Module), a, a.ID) +} + +// Cancel takes an ask back: the router ends it and edits every copy. +func (c *Client) Cancel(id string) error { + return c.publish(seat.AcceptSubjectCancel(c.Module), map[string]string{"id": id}, "cancel."+id) +} + +func (c *Client) publish(subject string, body any, id string) error { + if c.Publish != nil { + return c.Publish(subject, body, id) + } + return stdio.SeatPublish(subject, body, id) +} + +// Where is an ask's state as the router keeps it: its state and its warrant, if any — read under the +// module's own name and no other's. +func (c *Client) Where(id string) (state string, w *seat.Warrant, err error) { + read := c.Record + if read == nil { + read = func(k string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", k) } + } + e, err := read(id) + if err != nil || e == nil { + return "", nil, err + } + var rec struct { + State string `json:"state"` + Warrant *seat.Warrant `json:"warrant"` + } + if err := json.Unmarshal(e.Value, &rec); err != nil { + return "", nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err) + } + return rec.State, rec.Warrant, nil +} + +// Handled is what came of a warrant: acted on, refused (and why), or already done. +type Handled string + +const ( + Acted Handled = "acted" + Refused Handled = "refused" + Done Handled = "already acted on" + Recorded Handled = "ended without a choice" +) + +// Take hands act a warrant once, after holding it to the ask it answers. act performs the option with the +// module's own grants; its error is said and the warrant stays acted on (a person's decision is not +// performed twice; the module says the failure in its own record). +func (c *Client) Take(w seat.Warrant, act func(seat.Option, seat.Warrant) error) (Handled, error) { + if w.Asker != c.Module { + return Refused, fmt.Errorf("a warrant for %s reached %s", w.Asker, c.Module) + } + e, err := c.State.Get(key(w.Ask)) + if err != nil { + return "", err + } + if e == nil { + return Refused, fmt.Errorf("%s holds no ask %s", c.Module, w.Ask) + } + var k Kept + if err := json.Unmarshal(e.Value, &k); err != nil { + return "", err + } + if k.Acted || k.Ended != "" { + return Done, nil + } + if w.Outcome != OutcomeChosen { + k.Ended = w.Outcome + k.Answer = &w + if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil && !errors.Is(err, stdio.ErrChanged) { + return "", err + } + return Recorded, nil + } + o, err := For(w, c.Module, k.Ask) + if err != nil { + return Refused, err + } + // Marked before acting: of two deliveries, or two instances, only the one whose write stands acts. + k.Acted, k.Ended, k.Answer = true, OutcomeChosen, &w + if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil { + if errors.Is(err, stdio.ErrChanged) { + return Done, nil + } + return "", err + } + return Acted, act(o, w) +} + +// Listen hands every warrant for this module to Take, with act. It asks the runtime for the module's +// events matching the router's `decided` subject for this module. +func (c *Client) Listen(act func(seat.Option, seat.Warrant) error, said func(Handled, seat.Warrant, error)) error { + subscribe := c.Subscribe + if subscribe == nil { + subscribe = stdio.Subscribe + } + return subscribe(seat.Seat+"."+seat.EventDecided+"."+c.Module, func(e stdio.Envelope) error { + if !strings.HasPrefix(e.Key, seat.Seat+"."+seat.EventDecided+".") { + return nil + } + var w seat.Warrant + if err := json.Unmarshal(e.Body, &w); err != nil { + if said != nil { + said(Refused, w, fmt.Errorf("a warrant could not be read: %w", err)) + } + return nil + } + h, err := c.Take(w, act) + if h == "" && err != nil { + return err // the state could not be reached: offered again + } + if said != nil { + said(h, w, err) + } + return nil + }) +} diff --git a/go/clients/operatorchannelclient/client_test.go b/go/clients/operatorchannelclient/client_test.go new file mode 100644 index 0000000..0b16073 --- /dev/null +++ b/go/clients/operatorchannelclient/client_test.go @@ -0,0 +1,139 @@ +package operatorchannelclient + +import ( + "encoding/json" + "strings" + "testing" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +type mem struct { + vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 +} + +func (m *mem) Get(k string) (*stdio.StateEntry, error) { + v, ok := m.vals[k] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil +} +func (m *mem) Create(k string, v any) (uint64, error) { + if _, ok := m.vals[k]; ok { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *mem) Update(k string, v any, rev uint64) (uint64, error) { + if rev == 0 || m.revs[k] != rev { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *mem) put(k string, v any) (uint64, error) { + raw, _ := json.Marshal(v) + m.n++ + m.vals[k], m.revs[k] = raw, m.n + return m.n, nil +} + +func client() (*Client, map[string][]string) { + sent := map[string][]string{} + c := &Client{Module: "an-asker", State: &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}}, + Now: func() time.Time { return now }, + Publish: func(subject string, body any, id string) error { + sent[subject] = append(sent[subject], id) + return nil + }} + return c, sent +} + +// releaseHQ is the act the ask's one option binds. +var releaseHQ = map[string]string{"verb": "release", "delivery": "hq"} + +func askedHeld() seat.Ask { + binds, _ := ActDigest(releaseHQ) + return seat.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.", + Who: Operator, Expires: FormatTime(now.Add(time.Hour)), OnExpiry: "the delivery stays held", + Options: []seat.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: Approve, Binds: binds}}} +} + +func askedWarrant() seat.Warrant { + return seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", + Label: "Release", Level: Approve, At: FormatTime(now.Add(time.Minute)), AskDigest: Digest(askedHeld()), + By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} +} + +func TestAnAskIsKeptThenPublishedUnderTheModulesOwnName(t *testing.T) { + c, sent := client() + if err := c.Ask(askedHeld()); err != nil { + t.Fatal(err) + } + if got := sent[seat.AcceptSubjectAsk("an-asker")]; len(got) != 1 || got[0] != "d-1" { + t.Fatalf("%v", sent) + } + if err := c.Ask(askedHeld()); err == nil || !strings.Contains(err.Error(), "already made") { + t.Errorf("the same id was asked twice: %v", err) + } + bad := askedHeld() + bad.Who = "agent" + if err := c.Ask(bad); err == nil { + t.Error("an ask the contract refuses was published") + } +} + +func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) { + c, _ := client() + _ = c.Ask(askedHeld()) + acted := 0 + act := func(o seat.Option, _ seat.Warrant) error { + if err := Performs(o, releaseHQ); err != nil { + t.Errorf("the bound act: %v", err) + } + acted++ + return nil + } + if h, err := c.Take(askedWarrant(), act); h != Acted || err != nil || acted != 1 { + t.Fatalf("%s %v %d", h, err, acted) + } + if h, _ := c.Take(askedWarrant(), act); h != Done || acted != 1 { + t.Errorf("a redelivery was acted on: %s", h) + } + for why, change := range map[string]func(*seat.Warrant){ + "another asker": func(w *seat.Warrant) { w.Asker = "mesh-controller" }, + "an ask not made": func(w *seat.Warrant) { w.Ask = "d-9" }, + "another level": func(w *seat.Warrant) { w.Level = Acknowledge }, + "an option not offered": func(w *seat.Warrant) { w.Option = "delete" }, + "after its expiry": func(w *seat.Warrant) { w.At = FormatTime(now.Add(2 * time.Hour)) }, + "another ask's digest": func(w *seat.Warrant) { w.AskDigest = "sha256:00" }, + } { + c2, _ := client() + _ = c2.Ask(askedHeld()) + w := askedWarrant() + change(&w) + if h, _ := c2.Take(w, act); h == Acted { + t.Errorf("a warrant from %s was acted on", why) + } + } + if acted != 1 { + t.Errorf("acted %d times", acted) + } +} + +func TestAnAskEndedWithoutAChoiceIsRecordedAndNothingDone(t *testing.T) { + c, _ := client() + _ = c.Ask(askedHeld()) + w := askedWarrant() + w.Outcome, w.Option, w.By = OutcomeExpired, "", nil + if h, err := c.Take(w, func(seat.Option, seat.Warrant) error { t.Fatal("acted"); return nil }); h != Recorded || err != nil { + t.Fatalf("%s %v", h, err) + } + if h, _ := c.Take(askedWarrant(), func(seat.Option, seat.Warrant) error { t.Fatal("acted after it ended"); return nil }); h != Done { + t.Errorf("%s", h) + } +} diff --git a/go/clients/operatorchannelclient/condition.go b/go/clients/operatorchannelclient/condition.go new file mode 100644 index 0000000..af9485b --- /dev/null +++ b/go/clients/operatorchannelclient/condition.go @@ -0,0 +1,117 @@ +package operatorchannelclient + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + + seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" +) + +// ConditionSubject is where a module says a condition to the operator: the seat's by-caller condition accept +// (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("operator-channel: 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("operator-channel: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher) + } + return seat.AcceptSubjectCondition(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("operator-channel: 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" +) + +func checkCondition(c seat.Condition) error { + if c.Key == "" { + return errors.New("operator-channel: a condition has no key") + } + if c.Subject.Scope == "" || c.Subject.ID == "" { + return errors.New("operator-channel: a condition's subject needs its scope and id") + } + switch c.State { + case StateCleared: + return nil + case StateRaised: + default: + return fmt.Errorf("operator-channel: the condition's state %q is not raised or cleared", c.State) + } + switch c.Severity { + case SeverityUrgent, SeverityWarning: + default: + return fmt.Errorf("operator-channel: the condition's severity %q is not urgent or warning", c.Severity) + } + if c.Headline == "" { + return errors.New("operator-channel: a raised condition has no headline") + } + if n := len([]rune(c.Headline)); n > HeadlineLength { + return fmt.Errorf("operator-channel: 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("operator-channel: 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 seat.Condition) ([]byte, error) { + if err := checkCondition(c); 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) (seat.Condition, error) { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return seat.Condition{}, fmt.Errorf("operator-channel: the condition is not JSON: %w", err) + } + if probe.V == nil { + return seat.Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion) + } + if *probe.V != ConditionVersion { + return seat.Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion) + } + var c seat.Condition + if err := json.Unmarshal(data, &c); err != nil { + return seat.Condition{}, fmt.Errorf("operator-channel: the condition does not fit its shape: %w", err) + } + if err := checkCondition(c); err != nil { + return seat.Condition{}, err + } + return c, nil +} + +// SameCondition says whether two conditions are the same value. +func SameCondition(a, b seat.Condition) bool { return reflect.DeepEqual(a, b) } diff --git a/go/clients/operatorchannelclient/condition_test.go b/go/clients/operatorchannelclient/condition_test.go new file mode 100644 index 0000000..c9f32d1 --- /dev/null +++ b/go/clients/operatorchannelclient/condition_test.go @@ -0,0 +1,145 @@ +package operatorchannelclient + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel" +) + +func TestConditionSubject(t *testing.T) { // 1202/AC1 + s, err := ConditionSubject("an-asker-teller") + if err != nil || s != "mesh.seat.operator-channel.accept.condition.an-asker-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] != "an-asker-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() seat.Condition { + return seat.Condition{ + Key: "delivery.stuck.mesh-mail", Severity: "warning", + Subject: seat.ConditionSubject{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: []seat.ConditionActionsItem{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace", + Arguments: map[string]string{"why": ""}, Level: Approve}}, + } +} + +func clearedCondition() seat.Condition { + return seat.Condition{Key: "delivery.stuck.mesh-mail", Subject: seat.ConditionSubject{Scope: "module", ID: "mesh-mail"}, State: "cleared"} +} + +func TestConditionRoundTrip(t *testing.T) { // 1202/AC2 + for _, c := range []seat.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(*seat.Condition)) error { + c := raisedCondition() + f(&c) + _, err := EncodeCondition(c) + return err + } + for name, f := range map[string]func(*seat.Condition){ + "severity": func(c *seat.Condition) { c.Severity = "critical" }, + "state": func(c *seat.Condition) { c.State = "open" }, + "key": func(c *seat.Condition) { c.Key = "" }, + "headline": func(c *seat.Condition) { c.Headline = "" }, + "long headline": func(c *seat.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 *seat.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") + } +} diff --git a/go/clients/operatorchannelclient/go.mod b/go/clients/operatorchannelclient/go.mod new file mode 100644 index 0000000..d7217d2 --- /dev/null +++ b/go/clients/operatorchannelclient/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient + +go 1.26.0 diff --git a/module.json b/module.json index 5fecc68..fd20a00 100644 --- a/module.json +++ b/module.json @@ -9,6 +9,7 @@ { "name": "go-gopackageregistryclient", "kind": "package", "language": "go", "paths": ["go/clients/gopackageregistryclient"], "series": "0.1" }, { "name": "go-npmpackageregistryclient", "kind": "package", "language": "go", "paths": ["go/clients/npmpackageregistryclient"], "series": "0.1" }, { "name": "go-ociartifactregistryclient", "kind": "package", "language": "go", "paths": ["go/clients/ociartifactregistryclient"], "series": "0.1" }, + { "name": "go-operatorchannelclient", "kind": "package", "language": "go", "paths": ["go/clients/operatorchannelclient"], "series": "0.1" }, { "name": "go-containerimageregistry", "kind": "package", "language": "go", "paths": ["go/seats/containerimageregistry"], "series": "0.1", "generated-from": "." }, { "name": "go-gopackageregistry", "kind": "package", "language": "go", "paths": ["go/seats/gopackageregistry"], "series": "0.1", "generated-from": "." }, { "name": "go-meshbroker", "kind": "package", "language": "go", "paths": ["go/seats/meshbroker"], "series": "0.1", "generated-from": "." },