From 72da51a10a17f5a72bfed4e721091e2b0e63ca25 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:36:48 +0100 Subject: [PATCH] =?UTF-8?q?The=20node-engine=20seat's=20manifest-reads=20a?= =?UTF-8?q?nswer=20and=20event=20come=20from=20mesh-contracts'=20nodeengin?= =?UTF-8?q?e=20contract,=20judged=20by=20their=20reader=20(ADR=200618=20?= =?UTF-8?q?=C2=A73,=20=C2=A74,=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract carries no checks, so sdk/enginereads judges the layout's version, the node, each capability, the reason and the times. The local replaces of mesh-sdk are gone: the lead pins mesh-sdk and mesh-contracts at the join. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- .../integration_test.go | 9 +- .../cmd/mesh-delivery-assigner/pass_test.go | 40 ++++++- modules/mesh-delivery-assigner/go.mod | 1 - .../cmd/mesh-delivery-builder/refused_test.go | 3 +- modules/mesh-delivery-builder/go.mod | 1 - modules/mesh-delivery-indexer/go.mod | 1 - .../mesh-delivery-indexer/judge/rules_test.go | 2 +- .../cmd/mesh-delivery-view-tools/main_test.go | 3 +- modules/mesh-delivery-view/go.mod | 1 - modules/mesh-grants/go.mod | 1 - sdk/enginereads/enginereads.go | 105 ++++++++++++++++-- sdk/go.mod | 1 - 12 files changed, 143 insertions(+), 25 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index 166ab4d1..1d746b2a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -31,6 +31,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/provisions" "mesh-delivery-assigner/internal/judge" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/sdk/enginereads" "mesh-delivery-assigner/internal/assign" @@ -513,7 +514,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { defer nc.Close() if _, err := nc.Subscribe("mesh.seat.node-engine.tool.manifest-reads.ace", func(m *nats.Msg) { statedMu.Lock() - answer, _ := delivery.EncodeManifestReadsAnswer(delivery.ManifestReadsAnswer{Node: "ace", ManifestReads: stated, StatedAt: time.Now()}) + answer, _ := json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: "ace", ManifestReads: stated, StatedAt: time.Now().Format(time.RFC3339Nano)}) statedMu.Unlock() reply, _ := json.Marshal(map[string]any{"result": json.RawMessage(answer), "node": "ace"}) _ = m.Respond(reply) @@ -521,12 +522,12 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { t.Fatal(err) } say := func(why string) { - body, _ := delivery.EncodeManifestReadsChanged(delivery.ManifestReadsChanged{Node: "ace", ManifestReads: stated, At: time.Now(), Why: why}) + body, _ := json.Marshal(nodeengine.ManifestReadsChangedEvent{V: 1, Node: "ace", ManifestReads: stated, At: time.Now().Format(time.RFC3339Nano), Why: why}) if _, err := b.js.Publish(b.ctx, enginereads.SubjectPrefix+"ace", body); err != nil { t.Fatal(err) } } - say(delivery.ReadsStarted) + say(enginereads.ReadsStarted) formsOld, formsNew := fpOf('5'), fpOf('6') formsDigest := b.manifest(t, "forms", `{"module":"forms","version":"1"}`) formsAt := func(fp string) { @@ -578,7 +579,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { statedMu.Lock() stated = append(slices.Clone(stated), "form:new") statedMu.Unlock() - say(delivery.ReadsStarted) + say(enginereads.ReadsStarted) b.wantNow(t, "ace", "forms", "the want moved once ace's engine said it reads the form", func(w delivery.Want) bool { return w.FP == formsNew }) // assigner_engine_reads asks ace's engine again through the state: the assigner answers there with what it states now. again, err := enginereads.AskAgainOf(bedState{b, engineReadsBucket}, "ace", "n1.ada", "a person asks", time.Now(), 15*time.Second) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index d87a9bfa..560572a4 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -18,6 +18,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/sdk/enginereads" "git.novox.be/novox/mesh-delivery/sdk/graph" @@ -177,7 +178,7 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { if node == "g14" || key != enginereads.Invoke { return nil, &busretry.Answered{Words: "the seat node-engine serves no verb manifest-reads"} } - return delivery.EncodeManifestReadsAnswer(delivery.ManifestReadsAnswer{Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now()}) + return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)}) } p := &passer{w: w, reads: &enginereads.Keeper{Call: call, Fallback: w.recordReads, Sync: true}} on, _ := delivery.EncodePresence(delivery.Presence{Online: false}) @@ -197,6 +198,43 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { if asked["ace"] != 2 { t.Errorf("ace, back online, was asked %d times, want 2", asked["ace"]) } + // The contract carries no checks, so its reader judges what the engine says (mesh-contracts seats/nodeengine): an + // answer or an event that does not hold is no statement, and an event's node is its subject's last token. + now := time.Now().Format(time.RFC3339Nano) + for _, row := range []struct { + name string + raw string + stays bool + }{ + {"a whole answer", `{"v":1,"node":"ace","manifest-reads":["form:new"],"stated-at":"` + now + `"}`, true}, + {"an engine stating nothing", `{"v":1,"node":"ace","stated-at":"` + now + `"}`, true}, + {"another layout", `{"v":2,"node":"ace","stated-at":"` + now + `"}`, false}, + {"no node", `{"v":1,"node":"","stated-at":"` + now + `"}`, false}, + {"a node that is no token", `{"v":1,"node":"a.ce","stated-at":"` + now + `"}`, false}, + {"another node's answer", `{"v":1,"node":"g14","stated-at":"` + now + `"}`, false}, + {"an empty capability", `{"v":1,"node":"ace","manifest-reads":[""],"stated-at":"` + now + `"}`, false}, + {"a time that is no time", `{"v":1,"node":"ace","stated-at":"yesterday"}`, false}, + } { + s := enginereads.Ask(func(string, string, any) (json.RawMessage, error) { return json.RawMessage(row.raw), nil }, "ace", time.Now()) + if (s.From == enginereads.FromVerb) != row.stays || !s.Answered { + t.Errorf("answer %s: %+v", row.name, s) + } + } + for _, row := range []struct { + name, subject, raw string + ours, fails bool + }{ + {"the event", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"returned"}`, true, false}, + {"another reason", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"bored"}`, true, true}, + {"another node in the body", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"g14","at":"` + now + `","why":"started"}`, true, true}, + {"a time that is no time", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"x","why":"started"}`, true, true}, + {"another event", "mesh.seat.node-engine.event.done.ace", `{}`, false, false}, + } { + _, ours, err := enginereads.NodeOf(row.subject, []byte(row.raw)) + if ours != row.ours || (err != nil) != row.fails { + t.Errorf("event %s: ours %v, %v", row.name, ours, err) + } + } }) } diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 2ce41a81..f1ce8f99 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -25,4 +25,3 @@ require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk -replace git.novox.be/novox/mesh-sdk/go => ../../../0618-sdk/go diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go index f03c7cfd..11ed3eaf 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go @@ -19,6 +19,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/sdk/enginereads" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -87,7 +88,7 @@ func theCountedNodesEngineReads(t *testing.T) { if away[node] { return nil, nats.ErrNoResponders } - return delivery.EncodeManifestReadsAnswer(delivery.ManifestReadsAnswer{Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now()}) + return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)}) }, gate, t.Logf) r.keeper.Sync = true kept := map[string][]byte{} diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index 51de4783..9622cac1 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -26,7 +26,6 @@ require ( replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ./buildrun -replace git.novox.be/novox/mesh-sdk/go => ../../../0618-sdk/go require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 9eb69def..1de264a7 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -28,4 +28,3 @@ require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk -replace git.novox.be/novox/mesh-sdk/go => ../../../0618-sdk/go diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 1dddd272..57409c6d 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -4886,7 +4886,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil { t.Errorf("acts takes no id: %v", engine.Serves[5].Input) } - if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose,compose-candidate,manifest-reads" || + if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose,compose-candidate,pull-waits,manifest-reads" || strings.Join(engine.Emits, ",") != "asked,done,refused,manifest-reads-changed" { t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits) } diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index 01a8f924..33f9e8b7 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -14,6 +14,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/busretry" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/sdk/enginereads" "mesh-delivery-view/internal/view" @@ -594,7 +595,7 @@ func manifestReadsAsksEveryNode(t *testing.T) { <-hang // never answers within the bound return nil, nil } - return delivery.EncodeManifestReadsAnswer(delivery.ManifestReadsAnswer{Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now()}) + return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)}) } record := func(node string, reads ...string) string { raw, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node, ManifestReads: reads}}) diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index 82d12a43..46742fd5 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -25,4 +25,3 @@ require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk -replace git.novox.be/novox/mesh-sdk/go => ../../../0618-sdk/go diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index 2fa1dc18..3cf0be53 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -21,4 +21,3 @@ require ( golang.org/x/time v0.15.0 // indirect ) -replace git.novox.be/novox/mesh-sdk/go => ../../../0618-sdk/go diff --git a/sdk/enginereads/enginereads.go b/sdk/enginereads/enginereads.go index 98aac884..a50ae917 100644 --- a/sdk/enginereads/enginereads.go +++ b/sdk/enginereads/enginereads.go @@ -18,19 +18,99 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/busretry" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" ) -// The verb and the event as a manifest names them, and the subject the event is said on with the engine's machine last. +// The verb and the event as the seat's contract names them (mesh-contracts seats/nodeengine), as a manifest names them, +// and the subject the event is said on with the engine's machine last. const ( - Verb = "manifest-reads" - Event = "manifest-reads-changed" - Invoke = "seat:" + catalogue.EngineSeat + "." + Verb - Consumes = catalogue.EngineSeat + "." + Event - SubjectPrefix = "mesh.seat." + catalogue.EngineSeat + ".event." + Event + "." + Verb = nodeengine.VerbManifestReads + Event = nodeengine.EventManifestReadsChanged + Invoke = "seat:" + nodeengine.Seat + "." + Verb + Consumes = nodeengine.Seat + "." + Event ) +// SubjectPrefix is the event's subject up to the machine, its last token. +var SubjectPrefix = strings.TrimSuffix(nodeengine.SubjectManifestReadsChanged("*"), "*") + +// The reasons an engine says its reads, in the event's why (ADR 0618 ยง4). +const ( + // ReadsStarted: the engine started, or what it states changed. + ReadsStarted = "started" + // ReadsReturned: the engine's node returned after being away. + ReadsReturned = "returned" +) + +// ContractVersion is the layout version of the verb's answer and the event's body this reader reads. +const ContractVersion = 1 + +// checkNode refuses a node that is no subject token. +func checkNode(node string) error { + if node == "" || strings.ContainsAny(node, ".*> \t\n") { + return fmt.Errorf("%q is no node", node) + } + return nil +} + +// checkReads refuses an empty reader capability. +func checkReads(node string, reads []string) error { + for _, c := range reads { + if strings.TrimSpace(c) == "" { + return fmt.Errorf("the reads of %s name an empty reader capability", node) + } + } + return nil +} + +// DecodeAnswer reads the verb's answer as the contract lays it out, and judges it: the layout's version, the node a +// name, no empty capability, and its stated-at an RFC 3339 time (the contract carries no checks; its reader does). +func DecodeAnswer(raw []byte) (nodeengine.ManifestReadsAnswer, time.Time, error) { + var a nodeengine.ManifestReadsAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return a, time.Time{}, err + } + if a.V != ContractVersion { + return a, time.Time{}, fmt.Errorf("the answer's layout is version %d, not %d", a.V, ContractVersion) + } + if err := checkNode(a.Node); err != nil { + return a, time.Time{}, err + } + if err := checkReads(a.Node, a.ManifestReads); err != nil { + return a, time.Time{}, err + } + at, err := time.Parse(time.RFC3339Nano, a.StatedAt) + if err != nil { + return a, time.Time{}, fmt.Errorf("stated-at %q is no RFC 3339 time", a.StatedAt) + } + return a, at, nil +} + +// DecodeChanged reads the event's body as the contract lays it out, and judges it as DecodeAnswer does, its why one of +// ReadsStarted and ReadsReturned. +func DecodeChanged(raw []byte) (nodeengine.ManifestReadsChangedEvent, error) { + var e nodeengine.ManifestReadsChangedEvent + if err := json.Unmarshal(raw, &e); err != nil { + return e, err + } + if e.V != ContractVersion { + return e, fmt.Errorf("the event's layout is version %d, not %d", e.V, ContractVersion) + } + if err := checkNode(e.Node); err != nil { + return e, err + } + if e.Why != ReadsStarted && e.Why != ReadsReturned { + return e, fmt.Errorf("the reads of %s changed for %q, not %s or %s", e.Node, e.Why, ReadsStarted, ReadsReturned) + } + if err := checkReads(e.Node, e.ManifestReads); err != nil { + return e, err + } + if _, err := time.Parse(time.RFC3339Nano, e.At); err != nil { + return e, fmt.Errorf("at %q is no RFC 3339 time", e.At) + } + return e, nil +} + // Where a node's statement came from. const ( // FromVerb: the node's engine answered the verb. @@ -101,12 +181,15 @@ func Ask(call Caller, node string, now time.Time) Statement { var answered *busretry.Answered switch { case err == nil: - a, derr := delivery.DecodeManifestReadsAnswer(raw) + a, at, derr := DecodeAnswer(raw) + if derr == nil && a.Node != node { + derr = fmt.Errorf("it names the node %s", a.Node) + } if derr != nil { s.Answered, s.Words = true, "the engine answered what is no statement: "+derr.Error() return s } - s.ManifestReads, s.StatedAt, s.From, s.Answered = slices.Clone(a.ManifestReads), a.StatedAt.UTC(), FromVerb, true + s.ManifestReads, s.StatedAt, s.From, s.Answered = slices.Clone(a.ManifestReads), at.UTC(), FromVerb, true case errors.As(err, &answered): s.Answered, s.Words = true, "the engine does not serve "+Verb+": "+answered.Words default: @@ -122,7 +205,7 @@ func NodeOf(subject string, data []byte) (string, bool, error) { if !ok || node == "" || strings.Contains(node, ".") { return "", false, nil } - e, err := delivery.DecodeManifestReadsChanged(data) + e, err := DecodeChanged(data) if err != nil { return node, true, err } diff --git a/sdk/go.mod b/sdk/go.mod index 9be6ffa6..aa399329 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -13,4 +13,3 @@ require ( golang.org/x/sys v0.48.0 // indirect ) -replace git.novox.be/novox/mesh-sdk/go => ../../0618-sdk/go