From 1027803e1de534d541b5f527dce17da8a682870f Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:21:31 +0100 Subject: [PATCH 1/4] =?UTF-8?q?A=20build=20names=20its=20needs=20and=20eve?= =?UTF-8?q?ry=20form=20of=20an=20artifact=20(ADR=200618=20=C2=A71,=20ADR?= =?UTF-8?q?=200624;=20issue=20774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuilderBuild.Needs (form:, done keys only) and BuiltArtifact.References (Reference among them, each checked as Reference is) on the delivery layouts mesh-sdk keeps until that area moves (ADR 0613 §3). The node-engine seat's verb and event of ADR 0618 go into mesh-contracts with the seat, not here (ADR 0613 §5). Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/delivery/builder.go | 25 +++++++++++++++++++++++++ go/delivery/builder_test.go | 32 ++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/go/delivery/builder.go b/go/delivery/builder.go index 42bee50..06abbb5 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "slices" "strings" contractseats "git.novox.be/novox/mesh-contracts/go/seats" @@ -89,6 +90,10 @@ type BuiltArtifact struct { Kind string `json:"kind,omitempty"` Digest string `json:"digest"` Reference string `json:"reference,omitempty"` + // References are every form this artifact is named in, Reference among them (novox/hq ADR 0618, ADR 0624): a build + // keeps each form that a counted node-engine states, away nodes included, and Reference stays the form every one + // of them reads. A reader that does not know the field reads Reference alone. + References []string `json:"references,omitempty"` } const artifactStore = "artifact-store://" @@ -211,6 +216,10 @@ type BuilderBuild struct { // Imports are, on a done key, the mesh packages the built programs import, as the build module answered them // (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports). Imports []string `json:"imports,omitempty"` + // Needs are, on a done key only, the reader capabilities (`form:`) the build's reference forms need of a + // node-engine, beside the ones its manifest needs (novox/hq ADR 0618 §1). A reader that does not know the field + // reads the build as needing none. + Needs []string `json:"needs,omitempty"` } func (b BuilderBuild) check() error { @@ -245,6 +254,22 @@ func (b BuilderBuild) check() error { if p := a.referenceProblem(); p != "" { return fmt.Errorf("delivery: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p) } + if len(a.References) > 0 && !slices.Contains(a.References, a.Reference) { + return fmt.Errorf("delivery: the references of the %s %q do not name its reference %q", a.Kind, a.Name, a.Reference) + } + for _, r := range a.References { + if p := (BuiltArtifact{Name: a.Name, Kind: a.Kind, Digest: a.Digest, Reference: r}).referenceProblem(); p != "" { + return fmt.Errorf("delivery: the reference %q of the %s %q %s", r, a.Kind, a.Name, p) + } + } + } + for _, n := range b.Needs { + if !strings.HasPrefix(n, "form:") || len(n) == len("form:") { + return fmt.Errorf("delivery: the need %q of the build is not form:", n) + } + } + if len(b.Needs) > 0 && b.State != BuildDone { + return fmt.Errorf("delivery: a %s build names needs; only a done key does", b.State) } for language, id := range b.Asks { if b.State != BuildBuilding { diff --git a/go/delivery/builder_test.go b/go/delivery/builder_test.go index 8e29474..82038ca 100644 --- a/go/delivery/builder_test.go +++ b/go/delivery/builder_test.go @@ -26,11 +26,15 @@ func doneBuild() BuilderBuild { func builderSamples() map[string]BuilderBuild { failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"} + both := doneBuild() + both.Needs = []string{"form:artifact-registry"} + both.Artifacts[1].References = []string{both.Artifacts[1].Reference, "artifact-store://mail/server@" + both.Artifacts[1].Digest} return map[string]BuilderBuild{ - "queued": {State: BuildQueued, Manifest: digestOf('4')}, - "building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')}, - "done": doneBuild(), - "failed": failed, + "done with needs and both forms": both, + "queued": {State: BuildQueued, Manifest: digestOf('4')}, + "building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')}, + "done": doneBuild(), + "failed": failed, } } @@ -90,10 +94,17 @@ func TestBuilderBuildChecked(t *testing.T) { edit func(*BuilderBuild) words []string }{ - "done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}}, - "a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}}, - "an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}}, - "an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}}, + "done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}}, + "a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}}, + "an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}}, + "an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}}, + "a need that is not a form": {func(b *BuilderBuild) { b.Needs = []string{"word:x"} }, []string{"word:x", "form:"}}, + "a reference not among the references": {func(b *BuilderBuild) { + b.Artifacts[1].References = []string{"artifact-store://mail/server@" + b.Artifacts[1].Digest} + }, []string{"server", "references"}}, + "a reference of another artifact": {func(b *BuilderBuild) { + b.Artifacts[1].References = []string{b.Artifacts[1].Reference, "registry.example/mail/other@" + b.Artifacts[1].Digest} + }, []string{"server", "other"}}, "an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}}, "a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}}, "a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}}, @@ -119,6 +130,11 @@ func TestBuilderBuildChecked(t *testing.T) { } // a queued or building value may not already carry an outcome q := builderSamples()["queued"] + q.Needs = []string{"form:x"} + if _, err := EncodeBuilderBuild(q); err == nil { + t.Error("a queued build with needs accepted") + } + q.Needs = nil q.Artifacts = doneBuild().Artifacts if _, err := EncodeBuilderBuild(q); err == nil { t.Error("a queued build with artifacts accepted") From effd0c00a6227590a05f9cb70d0b96a9e24ef3a0 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:31:17 +0100 Subject: [PATCH 2/4] The seat set's test takes node-engine as a seat defined whole, with ADR 0618's manifest-reads among its added verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-contracts now defines the node-engine seat whole (ADR 0613 §3): its verbs carry answers and it says manifest-reads-changed, so the set compares it as it compares every whole seat. Nothing else here is redundant: the seat's hand-written list went in SDK-8, and the compose-candidate layouts keep their checks, which a generated package does not carry, until the seat's client package exists (ADR 0625 §2, proposed). Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/catalogue/seats_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 8afda6e..dd13a59 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -54,10 +54,11 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { if err := json.Unmarshal(raw, &compiled); err != nil { t.Fatal(err) } - whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker"} + whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"} dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} - // Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it. - addedVerbs := map[string]bool{"node-engine.pull-waits": true} + // Verbs a seat gained in the milestone, each optional until its holders serve it: node-engine's manifest-reads (ADR + // 0618 §3) came with its move whole. + addedVerbs := map[string]bool{"node-engine.pull-waits": true, "node-engine.manifest-reads": true} norm := func(v any) any { b, _ := json.Marshal(v) var out any From 42ac1a9a0aaa6410c7ca061e4201404e86b0af0c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:38:10 +0100 Subject: [PATCH 3/4] mesh-sdk's node-engine names are the seat's generated contract, kept deprecated for their importers, with their checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat is defined whole in mesh-contracts (ADR 0613 §3), so mesh-sdk takes its names from there (ADR 0613 §5): EngineSeat and EngineEvents are nodeengine's, EngineComposeCandidateVerb is nodeengine.VerbComposeCandidate, and ComposeCandidateAsk, ComposeCandidateAnswer and ModuleComposed are aliases of nodeengine's ComposeCandidateInput, ComposeCandidateAnswer and Module. The encode and decode checks stay here over those types until the seat has a client package (ADR 0625 §4). For importers: a module's fingerprint is Fp, and the answer's Input is a JSON object (map[string]any), not raw bytes. compose-candidate's input is the boolean the engine and the checker read; the seat set's test takes that correction, and the whole seat's added verbs as da653ce reads them. EngineSeat leaves testdata/seat-names.txt. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/catalogue/engine_compose_candidate_test.go | 4 +- go/catalogue/engine_seat.go | 21 +++-- go/catalogue/seats_test.go | 14 ++- go/delivery/compose_candidate.go | 86 +++++++++---------- go/delivery/compose_candidate_test.go | 7 +- go/testdata/seat-names.txt | 1 - 6 files changed, 67 insertions(+), 66 deletions(-) diff --git a/go/catalogue/engine_compose_candidate_test.go b/go/catalogue/engine_compose_candidate_test.go index fb34000..b2c3797 100644 --- a/go/catalogue/engine_compose_candidate_test.go +++ b/go/catalogue/engine_compose_candidate_test.go @@ -2,7 +2,7 @@ package catalogue import "testing" -// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a switch. +// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a boolean, as the engine and the checker read it. func TestTheEngineServesComposeCandidate(t *testing.T) { for _, v := range EngineVerbs() { if v.Name != "compose-candidate" { @@ -10,7 +10,7 @@ func TestTheEngineServesComposeCandidate(t *testing.T) { } props, _ := v.Input["properties"].(map[string]any) input, _ := props["input"].(map[string]any) - if !v.Optional || props["candidate"] == nil || input == nil || input["enum"] == nil { + if !v.Optional || props["candidate"] == nil || input == nil || input["type"] != "boolean" { t.Fatalf("compose-candidate reads as %+v", v) } return diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index bcbbd2f..34d39eb 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -1,23 +1,26 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" +import "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" // EngineSeat is the node seat every node's engine holds. -const EngineSeat = contractseats.NodeEngine +// +// Deprecated: the seat is defined whole in mesh-contracts (nodeengine.Seat, its verbs, events, subjects and message +// types); kept for its importers until their next change takes it from there (testdata/seat-names.txt). +const EngineSeat = nodeengine.Seat // EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them: its definition's, read from the -// generated seat list (design 26). +// generated seat list (design 26), each with its description and its input's schema as discovery shows them. Their +// names are nodeengine.Verbs(). func EngineVerbs() []Verb { s, _ := seatIn(defaultSeats, EngineSeat) return append([]Verb(nil), s.Serves...) } -// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime -// proved it and the ask (ADR 0563 §1). -var EngineEvents = func() []string { - s, _ := seatIn(defaultSeats, EngineSeat) - return append([]string(nil), s.Emits...) -}() +// EngineEvents are what the seat says: of an act, each naming the act, the path or access, the caller as the runtime +// proved it and the ask (ADR 0563 §1), and that what its engine reads of a manifest changed (ADR 0618 §4). +// +// Deprecated: the seat's contract names them, nodeengine.Events(); kept for its importers. +var EngineEvents = nodeengine.Events() // seatIn is the seat of that name in a list. func seatIn(list []Seat, name string) (Seat, bool) { diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index dd13a59..4f6bdec 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -56,9 +56,11 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { } whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"} dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} - // Verbs a seat gained in the milestone, each optional until its holders serve it: node-engine's manifest-reads (ADR - // 0618 §3) came with its move whole. - addedVerbs := map[string]bool{"node-engine.pull-waits": true, "node-engine.manifest-reads": true} + // Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it. + addedVerbs := map[string]bool{"node-engine.pull-waits": true} + // A verb whose input the whole definition corrected: compose-candidate's input is the boolean the engine and the + // checker read, where the compiled list said a word (engine_compose_candidate_test.go reads it). + retyped := map[string]bool{"node-engine.compose-candidate": true} norm := func(v any) any { b, _ := json.Marshal(v) var out any @@ -101,7 +103,11 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { } continue } - kept = append(kept, byName[v.Name]) + kv := byName[v.Name] + if retyped[want.Name+"."+v.Name] { + kv.Input = v.Input + } + kept = append(kept, kv) } g.Serves, g.Emits, g.Accepts = kept, want.Emits, want.Accepts // A verb the whole seat added (new, optional while its holders catch up) is no verb of the compiled list. diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go index 84cdefd..26f051d 100644 --- a/go/delivery/compose_candidate.go +++ b/go/delivery/compose_candidate.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "time" + + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" ) // A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb @@ -16,7 +18,9 @@ import ( const ( // EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat). - EngineComposeCandidateVerb = "compose-candidate" + // + // Deprecated: the seat's contract names it, nodeengine.VerbComposeCandidate; kept for its importers. + EngineComposeCandidateVerb = nodeengine.VerbComposeCandidate // ComposeAnswerBound is the default of the checker's setting compose-answer-bound: how long it waits for a node's // answer (ADR 0593 §7, §9). ComposeAnswerBound = 60 * time.Second @@ -45,53 +49,41 @@ const ( ModuleHeldState = "held" ) -// ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey, -// `.`); the engine reads that candidate itself and lays its modules over its placement. -// Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the -// catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer -// runs over, for a group that changes the node-engine's composition code (ADR 0593 §7). -type ComposeCandidateAsk struct { - Candidate string `json:"candidate"` - Input bool `json:"input,omitempty"` -} +// The verb's ask and answer are the seat's contract, generated in mesh-contracts (seats/node-engine): mesh-sdk keeps +// these names for their importers and the checks below over them, until the seat has a client package (ADR 0625 §4). +type ( + // ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey, + // `.`); the engine reads that candidate itself and lays its modules over its placement. + // Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the + // catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer + // runs over, for a group that changes the node-engine's composition code (ADR 0593 §7). + // + // Deprecated: the seat's contract is nodeengine.ComposeCandidateInput; kept for its importers. + ComposeCandidateAsk = nodeengine.ComposeCandidateInput + // ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node + // sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate + // at all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the + // engine took in milliseconds, and, when asked (ComposeCandidateAsk.Input), the composition input it composed from. + // + // Deprecated: the seat's contract is nodeengine.ComposeCandidateAnswer; kept for its importers. + ComposeCandidateAnswer = nodeengine.ComposeCandidateAnswer + // ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the + // candidate's FP2 for a module the candidate touches, else its newest done build as composed- holds it), its + // state, the composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one + // composed without it (the checker's "what the change reaches on this node"), and why, for a refused or held one. + // + // Deprecated: the seat's contract is nodeengine.Module; kept for its importers. + ModuleComposed = nodeengine.Module +) -// ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node -// sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate at -// all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the engine took in milliseconds, and, when asked -// (ComposeCandidateAsk.Input), the composition input it composed from. -type ComposeCandidateAnswer struct { - V int `json:"v"` - Node string `json:"node"` - Candidate string `json:"candidate"` - Verdict string `json:"verdict"` - Modules []ModuleComposed `json:"modules"` - Own ModuleComposed `json:"own"` - Refused string `json:"refused,omitempty"` - Took int64 `json:"took"` - Input json.RawMessage `json:"input,omitempty"` -} - -// ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the candidate's -// FP2 for a module the candidate touches, else its newest done build as composed- holds it), its state, the -// composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one composed without -// it (the checker's "what the change reaches on this node"), and why, for a refused or held one. -type ModuleComposed struct { - Module string `json:"module"` - FP string `json:"fp,omitempty"` - State string `json:"state"` - Digest string `json:"digest,omitempty"` - Changed bool `json:"changed,omitempty"` - Words string `json:"words,omitempty"` -} - -func (a ComposeCandidateAsk) check() error { +func checkComposeAsk(a ComposeCandidateAsk) error { if _, _, err := ParseCompositionKey(a.Candidate); err != nil { return fmt.Errorf("delivery: a compose-candidate ask names no candidate: %w", err) } return nil } -func (m ModuleComposed) check(node string) error { +func checkComposedModule(m ModuleComposed, node string) error { if m.Module == "" { return fmt.Errorf("delivery: a module's composition on %s names no module", node) } @@ -111,7 +103,7 @@ func (m ModuleComposed) check(node string) error { return nil } -func (a ComposeCandidateAnswer) check() error { +func checkComposeAnswer(a ComposeCandidateAnswer) error { if err := checkName("node", a.Node); err != nil { return err } @@ -133,7 +125,7 @@ func (a ComposeCandidateAnswer) check() error { } refused := false for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) { - if err := m.check(a.Node); err != nil { + if err := checkComposedModule(m, a.Node); err != nil { return err } if i > 1 && a.Modules[i-2].Module >= m.Module { @@ -164,7 +156,7 @@ func strictDecode(data []byte, v any, what string) error { // EncodeComposeCandidateAsk is a as JSON; it refuses an ask naming no candidate key. func EncodeComposeCandidateAsk(a ComposeCandidateAsk) ([]byte, error) { - if err := a.check(); err != nil { + if err := checkComposeAsk(a); err != nil { return nil, err } return json.Marshal(a) @@ -176,13 +168,13 @@ func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) { if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { return ComposeCandidateAsk{}, err } - return a, a.check() + return a, checkComposeAsk(a) } // EncodeComposeCandidateAnswer is a as JSON at Version; it refuses an unknown state, a composed module without a digest, // a refused or held one without words, a refusal of the whole naming modules, and a verdict the modules do not give. func EncodeComposeCandidateAnswer(a ComposeCandidateAnswer) ([]byte, error) { - if err := a.check(); err != nil { + if err := checkComposeAnswer(a); err != nil { return nil, err } a.V = Version @@ -199,5 +191,5 @@ func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) { if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { return ComposeCandidateAnswer{}, err } - return a, a.check() + return a, checkComposeAnswer(a) } diff --git a/go/delivery/compose_candidate_test.go b/go/delivery/compose_candidate_test.go index 63e4e1d..66afd83 100644 --- a/go/delivery/compose_candidate_test.go +++ b/go/delivery/compose_candidate_test.go @@ -2,6 +2,7 @@ package delivery import ( "encoding/json" + "reflect" "strings" "testing" @@ -32,16 +33,16 @@ func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { good := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Took: 812, Own: ModuleComposed{Module: "node", State: ModuleComposedState, Digest: "sha256:00"}, Modules: []ModuleComposed{ - {Module: "netcheck", FP: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, + {Module: "netcheck", Fp: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, {Module: "route-proxy", State: ModuleRefusedState, Words: "the setting domain has no value"}, {Module: "zz", State: ModuleHeldState, Words: "held before"}, - }, Input: json.RawMessage(`{"machines":["ace"]}`)} + }, Input: map[string]any{"machines": []any{"ace"}}} raw, err = EncodeComposeCandidateAnswer(good) if err != nil { t.Fatal(err) } back, err := DecodeComposeCandidateAnswer(raw) - if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || string(back.Input) != string(good.Input) { + if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) { t.Fatalf("answer round trip: %v, %+v", err, back) } for name, mutate := range map[string]func(a *ComposeCandidateAnswer){ diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 3002238..ce3741b 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -7,7 +7,6 @@ catalogue/checker_seat.go: CheckerSeat catalogue/control_records_seats.go: HandActsSeat catalogue/control_records_seats.go: WantsSeat catalogue/delivery_seat.go: DeliverySeat -catalogue/engine_seat.go: EngineSeat catalogue/enrolment_seat.go: EnrolmentSeat catalogue/environment_into.go: EnvironmentSeat catalogue/environment_into.go: LoginShellSeat From f30a53b2425cc177e4bd2a7fc5f1f4741ac99385 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:43:11 +0100 Subject: [PATCH 4/4] A compose-candidate answer's open input keeps its numbers as written when decoded The answer's input is now the object the node-engine seat's contract names, so a decoder without UseNumber turns its numbers into float64 and a checker re-encoding it for the composer would hand on different digits. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- go/delivery/compose_candidate.go | 1 + 1 file changed, 1 insertion(+) diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go index 26f051d..2420d61 100644 --- a/go/delivery/compose_candidate.go +++ b/go/delivery/compose_candidate.go @@ -148,6 +148,7 @@ func checkComposeAnswer(a ComposeCandidateAnswer) error { 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("delivery: %s does not fit its shape: %w", what, err) }