From c6ae0fc405fb5074d1b3f9ace048fb91c562c0d7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:40:34 +0100 Subject: [PATCH] The node-engine answers compose-candidate in its seat's contract: the input as a JSON object, a module's fingerprint Fp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-sdk's ComposeCandidateAnswer and ModuleComposed are now the generated nodeengine types (ADR 0613 §3, §5), so the input sent for the checker's sandbox is decoded once into the object the contract carries, its numbers kept as written (json.Number), and its size still counted from the bytes it was marshalled to. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn --- cmd/mesh-host/nodeagent_integration_test.go | 2 +- internal/selfcompose/candidate.go | 16 ++++++++++++---- internal/selfcompose/candidate_test.go | 5 +++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index cecee45..f6f15d3 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -603,7 +603,7 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) { t.Fatalf("the answer %s: %v", raw, err) } if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" || - a.Modules[0].FP != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() { + a.Modules[0].Fp != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() { t.Errorf("the node's answer to a candidate changing web: %+v", a) } after, _ := b.composed.Status() diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index 1176808..f38037b 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -1,6 +1,7 @@ package selfcompose import ( + "bytes" "context" "encoding/json" "errors" @@ -181,6 +182,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery if err != nil { return unready(err.Error()) } + sentSize := 0 laidComps, laidHeld, laidErr := laid.composeShadow(ctx, running, touched, func(scope frombus.Scope, fps map[string]string) { if !ask.Input { return @@ -196,13 +198,19 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery in.ComposedHow.HeldBefore = maps.Clone(baseHeld) // As the sandbox reads it (composer.SentInput, the one shape): this node's own modules as taken in from their // builds, every other module's manifest left to the cases. + // The seat's contract carries it as a JSON object; its numbers are kept as written (json.Number). if body, err := json.Marshal(composer.SentInput(in, laid.env.Node)); err == nil { - a.Input = body + d := json.NewDecoder(bytes.NewReader(body)) + d.UseNumber() + var obj map[string]any + if d.Decode(&obj) == nil { + a.Input, sentSize = obj, len(body) + } } }) - if len(a.Input) > maxSentInput { + if sentSize > maxSentInput { // Past what the bus carries in one answer, the checker would hear nothing: refused, saying so. - size := len(a.Input) + size := sentSize a.Input = nil return refuse(fmt.Sprintf("the composition input this node composed from is %d bytes, more than the %d an answer on the bus carries", size, maxSentInput)) } @@ -251,7 +259,7 @@ func (s *Shadow) composeCandidate(ctx context.Context, c *Composer, ask delivery m.State, m.Words = delivery.ModuleRefusedState, err.Error() return m, true } - m.State, m.FP, m.Digest = delivery.ModuleComposedState, comp.FP, dig + m.State, m.Fp, m.Digest = delivery.ModuleComposedState, comp.FP, dig if before, ok := was[module]; !ok { m.Changed = true } else if d, err := delivery.ComposedDigestOf(before); err != nil || d != dig { diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index ea78e81..e3cc8ae 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -103,7 +103,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { t.Fatalf("the answer does not fit the seat's shape: %v (%+v)", err, a) } if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "lemurs" || - a.Modules[0].State != delivery.ModuleComposedState || a.Modules[0].FP != fpNew || !a.Modules[0].Changed || + a.Modules[0].State != delivery.ModuleComposedState || a.Modules[0].Fp != fpNew || !a.Modules[0].Changed || a.Own.Module != delivery.HaveNodeWord || a.Took > delivery.ComposeAnswerBound.Milliseconds() { t.Errorf("an accepted candidate answered %+v", a) } @@ -111,7 +111,8 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { // through the same path. sent := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: accepted, Input: true}) var in compose.Input - if err := json.Unmarshal(sent.Input, &in); err != nil || in.ComposedHow == nil || in.ComposedHow.Node != "n1" || + raw, _ := json.Marshal(sent.Input) + if err := json.Unmarshal(raw, &in); err != nil || in.ComposedHow == nil || in.ComposedHow.Node != "n1" || in.ComposedHow.Builds["lemurs"] != fpNew { t.Errorf("the input sent for the sandbox does not say how the node composes: %+v, %v", in.ComposedHow, err) }