From 22abe5c955bd1ffdbedbcd2ab4ab7f500bf190af Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:50:00 +0100 Subject: [PATCH] =?UTF-8?q?ADR=200627=20=C2=A71:=20node-engine's=20client?= =?UTF-8?q?=20holds=20the=20checks=20of=20compose-candidate's=20ask=20and?= =?UTF-8?q?=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checks the node-engine, the checker and the assigner share over the verb (ADR 0593 §7, §9) leave mesh-sdk's delivery package, where they sat over aliases of the seat's types, for go/clients/nodeengineclient, a Go module and package artifact of its own over the seat's generated contract: the verdicts, the module states, the asker's bound, the no-answer words, Encode/DecodeAsk, Encode/DecodeAnswer and CheckAnswer. Its test holds the cases mesh-sdk's held, and that an open input's number keeps its digits; each fails without the code. seatgen -check finds nothing out of step. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- README.md | 4 +- go/clients/nodeengineclient/client.go | 183 +++++++++++++++++++++ go/clients/nodeengineclient/client_test.go | 97 +++++++++++ go/clients/nodeengineclient/go.mod | 3 + module.json | 1 + 5 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 go/clients/nodeengineclient/client.go create mode 100644 go/clients/nodeengineclient/client_test.go create mode 100644 go/clients/nodeengineclient/go.mod diff --git a/README.md b/README.md index 1efaec4..5536728 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ implements its contract, and no holder's code lives here. counts the npm package a lock resolves, so here too a module is rebuilt only for the seats it uses. - **A seat's client**, written by hand: `go/clients/client`, a Go module of its own, only where two or more callers share logic over the seat (ADR 0625 §4, ADR 0627 §1): operator-channel's (checking an ask, - its digest, a warrant's, and an asking module's keeping of its asks) and the four registry seats' (publishing a file - in parts). A client imports its own seat's contract and mesh-sdk's runtime, never another seat's client, and names + its digest, a warrant's, and an asking module's keeping of its asks), node-engine's (checking compose-candidate's ask + and answer) and the four registry seats' (publishing a file in parts). A client imports its own seat's contract and mesh-sdk's runtime, never another seat's client, and names no module's subject or tool. - **Not here**: the value layouts of seats not yet defined whole (they stay in mesh-sdk until their domain moves, ADR 0625 §9), and a role only one module could ever play (that module's own tools and events, ADR 0625 §8). diff --git a/go/clients/nodeengineclient/client.go b/go/clients/nodeengineclient/client.go new file mode 100644 index 0000000..0b1939f --- /dev/null +++ b/go/clients/nodeengineclient/client.go @@ -0,0 +1,183 @@ +// Package nodeengineclient is the node-engine seat's client (novox/hq ADR 0627 §1, ADR 0625 §4): the checks its callers +// share over the verb compose-candidate's ask and answer (ADR 0593 §7, §9), over the seat's generated contract. The +// node-engine encodes its answer through it, and the checker and the assigner read answers and the asker's bound +// through it. The composing is the node-engine's own; the call is the caller's own, so it holds no transport. +package nodeengineclient + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "time" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const ( + // AnswerBound is the default bound an asker of compose-candidate waits for a node's answer (the checker's setting + // compose-answer-bound, ADR 0593 §7, §9). + AnswerBound = 60 * time.Second + // NoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9). + NoAnswer = "not composed on that node: no answer" + + // Composed and Refused are an answer's verdict: no module and not the node's own composition is refused by the + // candidate, or at least one is (or the node could not compose with it at all, the answer's Refused). + Composed = "composed" + Refused = "refused" + // Unready is the verdict of a node that cannot compose yet (its shadow not ready, its agent starting): no module is + // named, Refused says why in words, and the checker takes it as a node that did not answer (NoAnswer), deciding by + // this verdict and never by the words. + Unready = "unready" + // NotJudged is the verdict for a node the checker's sandbox composed and could not judge (an older node-engine's + // input that does not say how it composes, ADR 0593 §9): no module is named, Refused says why in words, and the + // checker names the node with them and passes the check with that warning (ADR 0601 §4: a contract is a field, not + // a phrase). + NotJudged = "not-judged" + + // The states of one module's composition with the candidate: composed (Digest set), refused by the candidate + // (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict. + ModuleComposed = "composed" + ModuleRefused = "refused" + ModuleHeld = "held" + + // Own is the Module name of the node's own composition in an answer. + Own = "node" + + // Version is the version an answer is written at and the only one read. + Version = 1 +) + +// ErrUnknownVersion is what DecodeAnswer returns for an answer whose version is missing or not Version. +var ErrUnknownVersion = errors.New("node-engine: unknown compose-candidate answer version") + +var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`) + +func checkAsk(a seat.ComposeCandidateInput) error { + if _, _, err := delivery.ParseCompositionKey(a.Candidate); err != nil { + return fmt.Errorf("node-engine: a compose-candidate ask names no candidate: %w", err) + } + return nil +} + +func checkModule(m seat.Module, node string) error { + if m.Module == "" { + return fmt.Errorf("node-engine: a module's composition on %s names no module", node) + } + switch m.State { + case ModuleComposed: + if m.Digest == "" { + return fmt.Errorf("node-engine: %s is composed on %s and names no digest", m.Module, node) + } + case ModuleRefused, ModuleHeld: + if m.Words == "" { + return fmt.Errorf("node-engine: %s is %s on %s and says no why", m.Module, m.State, node) + } + default: + return fmt.Errorf("node-engine: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State, + ModuleComposed, ModuleRefused, ModuleHeld) + } + return nil +} + +// CheckAnswer refuses an answer naming no node or candidate, a negative time taken, an unknown module state, a composed +// module without a digest, a refused or held one without words, modules not sorted and each once, a refusal of the +// whole naming modules, an unready or not-judged one saying no why, and a verdict the modules do not give. +func CheckAnswer(a seat.ComposeCandidateAnswer) error { + if a.Node == "" || !nameOK.MatchString(a.Node) { + return fmt.Errorf("node-engine: the answer's node name %q is empty or holds a dot, a wildcard, whitespace or a character a key refuses", a.Node) + } + if _, _, err := delivery.ParseCompositionKey(a.Candidate); err != nil { + return err + } + if a.Took < 0 { + return fmt.Errorf("node-engine: the answer of %s took %d ms", a.Node, a.Took) + } + if (a.Verdict == Unready || a.Verdict == NotJudged) && a.Refused == "" { + return fmt.Errorf("node-engine: %s's answer is %s and says no why", a.Node, a.Verdict) + } + if a.Refused != "" { + if len(a.Modules) > 0 || a.Verdict != Refused && a.Verdict != Unready && a.Verdict != NotJudged { + return fmt.Errorf("node-engine: %s could not compose the candidate (%s), and its answer names modules or is neither %s, %s nor %s", + a.Node, a.Refused, Refused, Unready, NotJudged) + } + return nil + } + refused := false + for i, m := range append([]seat.Module{a.Own}, a.Modules...) { + if err := checkModule(m, a.Node); err != nil { + return err + } + if i > 1 && a.Modules[i-2].Module >= m.Module { + return fmt.Errorf("node-engine: the modules of %s's answer are not sorted and each once (%s after %s)", a.Node, m.Module, a.Modules[i-2].Module) + } + refused = refused || m.State == ModuleRefused + } + switch { + case a.Own.Module != Own: + return fmt.Errorf("node-engine: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, Own) + case refused && a.Verdict != Refused: + return fmt.Errorf("node-engine: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict) + case !refused && a.Verdict != Composed: + return fmt.Errorf("node-engine: %s's answer refuses no module and its verdict is %q", a.Node, a.Verdict) + } + return nil +} + +// strictDecode reads data into v, refusing an unknown key. +func strictDecode(data []byte, v any, what string) error { + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + d.UseNumber() // a number in an object the contract leaves open (the composition input) keeps its digits as written + if err := d.Decode(v); err != nil { + return fmt.Errorf("node-engine: %s does not fit its shape: %w", what, err) + } + return nil +} + +// EncodeAsk is a as JSON; it refuses an ask naming no candidate key. +func EncodeAsk(a seat.ComposeCandidateInput) ([]byte, error) { + if err := checkAsk(a); err != nil { + return nil, err + } + return json.Marshal(a) +} + +// DecodeAsk reads an ask strictly; it refuses an unknown key and what EncodeAsk refuses. +func DecodeAsk(data []byte) (seat.ComposeCandidateInput, error) { + var a seat.ComposeCandidateInput + if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { + return seat.ComposeCandidateInput{}, err + } + return a, checkAsk(a) +} + +// EncodeAnswer is a as JSON at Version; it refuses what CheckAnswer refuses. +func EncodeAnswer(a seat.ComposeCandidateAnswer) ([]byte, error) { + if err := CheckAnswer(a); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown key and what +// CheckAnswer refuses. +func DecodeAnswer(data []byte) (seat.ComposeCandidateAnswer, error) { + var probe struct { + V *int64 `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return seat.ComposeCandidateAnswer{}, fmt.Errorf("node-engine: a compose-candidate answer is not JSON: %w", err) + } + if probe.V == nil || *probe.V != Version { + return seat.ComposeCandidateAnswer{}, fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version) + } + var a seat.ComposeCandidateAnswer + if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { + return seat.ComposeCandidateAnswer{}, err + } + return a, CheckAnswer(a) +} diff --git a/go/clients/nodeengineclient/client_test.go b/go/clients/nodeengineclient/client_test.go new file mode 100644 index 0000000..542b207 --- /dev/null +++ b/go/clients/nodeengineclient/client_test.go @@ -0,0 +1,97 @@ +package nodeengineclient + +import ( + "errors" + "fmt" + "reflect" + "strings" + "testing" + + seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The verb compose-candidate's ask and answer (ADR 0593 §7, §9): each round-trips, and a reader refuses what it could +// not take, naming it. +func TestComposeCandidateRoundTripsAndRefuses(t *testing.T) { + key, err := delivery.CompositionKey("mesh-sdk#7", "0123456789ab") + if err != nil { + t.Fatal(err) + } + raw, err := EncodeAsk(seat.ComposeCandidateInput{Candidate: key, Input: true}) + if err != nil { + t.Fatal(err) + } + if a, err := DecodeAsk(raw); err != nil || a.Candidate != key || !a.Input { + t.Fatalf("ask round trip: %v, %+v", err, a) + } + if _, err := DecodeAsk([]byte(`{"candidate":"` + key + `","other":1}`)); err == nil { + t.Error("an ask with an unknown key was read") + } + if _, err := EncodeAsk(seat.ComposeCandidateInput{Candidate: "nope"}); err == nil { + t.Error("an ask naming no candidate key was encoded") + } + + good := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Refused, Took: 812, + Own: seat.Module{Module: Own, State: ModuleComposed, Digest: "sha256:00"}, + Modules: []seat.Module{ + {Module: "netcheck", Fp: "src2:x", State: ModuleComposed, Digest: "sha256:01", Changed: true}, + {Module: "route-proxy", State: ModuleRefused, Words: "the setting domain has no value"}, + {Module: "zz", State: ModuleHeld, Words: "held before"}, + }, Input: map[string]any{"machines": []any{"ace"}}} + raw, err = EncodeAnswer(good) + if err != nil { + t.Fatal(err) + } + back, err := DecodeAnswer(raw) + if err != nil || back.Verdict != Refused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) { + t.Fatalf("answer round trip: %v, %+v", err, back) + } + if _, err := DecodeAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil { + t.Error("an answer with an unknown key was read") + } + if _, err := DecodeAnswer([]byte(strings.Replace(string(raw), `"v":1`, `"v":2`, 1))); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("an answer at another version read as %v", err) + } + numbered := strings.Replace(string(raw), `"machines":["ace"]`, `"bound":12345678901234567890`, 1) + if back, err := DecodeAnswer([]byte(numbered)); err != nil || fmt.Sprint(back.Input["bound"]) != "12345678901234567890" { + t.Errorf("an open input's number did not keep its digits: %v, %+v", err, back.Input) + } + + whole := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Refused, Refused: "the bound passed"} + unready := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Unready, Refused: "the agent is starting"} + notJudged := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: NotJudged, Refused: "its node-engine sends no running state"} + for _, a := range []seat.ComposeCandidateAnswer{whole, unready, notJudged} { + if raw, err := EncodeAnswer(a); err != nil { + t.Errorf("a %s answer was not encoded: %v", a.Verdict, err) + } else if back, err := DecodeAnswer(raw); err != nil || back.Verdict != a.Verdict { + t.Errorf("a %s answer read back as %+v, %v", a.Verdict, back, err) + } + } + + for name, c := range map[string]struct { + from seat.ComposeCandidateAnswer + mutate func(a *seat.ComposeCandidateAnswer) + }{ + "verdict not the modules'": {good, func(a *seat.ComposeCandidateAnswer) { a.Verdict = Composed }}, + "unknown state": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0].State = "fine" }}, + "composed without digest": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0].Digest = "" }}, + "refused without words": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[1].Words = "" }}, + "unsorted": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0], a.Modules[1] = a.Modules[1], a.Modules[0] }}, + "own misnamed": {good, func(a *seat.ComposeCandidateAnswer) { a.Own.Module = "ace" }}, + "whole refusal with modules": {good, func(a *seat.ComposeCandidateAnswer) { a.Refused = "the bound passed" }}, + "node with a dot": {good, func(a *seat.ComposeCandidateAnswer) { a.Node = "a.b" }}, + "negative took": {good, func(a *seat.ComposeCandidateAnswer) { a.Took = -1 }}, + "unready saying no why": {unready, func(a *seat.ComposeCandidateAnswer) { a.Refused = "" }}, + "unready naming modules": {unready, func(a *seat.ComposeCandidateAnswer) { a.Modules = good.Modules }}, + "not judged saying no why": {notJudged, func(a *seat.ComposeCandidateAnswer) { a.Refused = "" }}, + "not judged naming modules": {notJudged, func(a *seat.ComposeCandidateAnswer) { a.Modules = good.Modules }}, + } { + a := c.from + a.Modules = append([]seat.Module(nil), c.from.Modules...) + c.mutate(&a) + if _, err := EncodeAnswer(a); err == nil { + t.Errorf("%s: encoded", name) + } + } +} diff --git a/go/clients/nodeengineclient/go.mod b/go/clients/nodeengineclient/go.mod new file mode 100644 index 0000000..2ccb4d7 --- /dev/null +++ b/go/clients/nodeengineclient/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient + +go 1.26.0 diff --git a/module.json b/module.json index 52bbde4..849e5c4 100644 --- a/module.json +++ b/module.json @@ -7,6 +7,7 @@ { "name": "go", "kind": "package", "language": "go", "paths": ["go"], "series": "0.1", "generated-from": "." }, { "name": "go-containerimageregistryclient", "kind": "package", "language": "go", "paths": ["go/clients/containerimageregistryclient"], "series": "0.1" }, { "name": "go-gopackageregistryclient", "kind": "package", "language": "go", "paths": ["go/clients/gopackageregistryclient"], "series": "0.1" }, + { "name": "go-nodeengineclient", "kind": "package", "language": "go", "paths": ["go/clients/nodeengineclient"], "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" },