Serve node-engine.compose-candidate from the node's shadow (H4)

ADR 0593 §7: a check's composition is the checker's, made from each
placed node's answer, since only a node can compose with its own sealed
values. The node composes the indexer's candidate in shadow on a copy of
its shadow's inputs, the candidate's modules laid over the indexer's
with their manifest verdicts, and answers per module composed (changed
or not), refused or held, and its own composition's verdict, within the
answer bound; it applies, writes and hands nothing. With input it also
answers the composition input, for the sandbox path.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 18:33:58 +01:00
parent 4f51393b72
commit 3fa41dfe1a
7 changed files with 468 additions and 0 deletions
+26
View File
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
@@ -15,6 +16,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
@@ -359,3 +361,27 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s
return v, nil
}
}
// composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this
// node in shadow by the run's shadow, applying and writing nothing, within delivery.ComposeAnswerBound. The answer is
// the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet
// refuses in words, which the checker takes as no answer.
func composeCandidateFrom(shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) {
return func(candidate string, input bool) (any, error) {
ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input}
if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil {
return nil, err
}
s := shadow.Load()
if s == nil {
return nil, errors.New("this node's shadow does not run now (its node agent is starting), so it composes no candidate")
}
ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound)
defer cancel()
raw, err := delivery.EncodeComposeCandidateAnswer(s.ComposeCandidate(ctx, ask))
if err != nil {
return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err)
}
return json.RawMessage(raw), nil
}
}
+1
View File
@@ -1001,6 +1001,7 @@ func runLink(ctx context.Context, opts options) error {
queue.Acts.Composition = compositionFrom(current)
queue.Acts.Compose = composeFrom(current, &composerNow)
queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow)
queue.Acts.ComposeCandidate = composeCandidateFrom(&shadowNow)
setRunsHere(runsFromAgent(current))
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
sayFacts(ctx, opts, mine.Membership.Signer, queue, say, ag)
+6
View File
@@ -623,6 +623,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers))
})
go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again
shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with
// Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's
// composition bucket) is never applied. While the seat's placement holds no key for this node it composes
// nothing and keeps running what it runs; a placement it cannot watch stops the agent, said, and it begins again.
@@ -640,6 +641,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
cancel()
current.set(nil)
composerNow.Store(nil)
shadowNow.Store(nil)
if ctx.Err() != nil {
return
}
@@ -766,6 +768,10 @@ var machinesNow atomic.Pointer[selfcompose.Composer]
// compositions or no run is going: what the verb compose asks a pass of (issue 774).
var composerNow atomic.Pointer[selfcompose.Composer]
// shadowNow is the shadow of the run now, which answers placement candidates and the verb compose-candidate; nil
// between runs.
var shadowNow atomic.Pointer[selfcompose.Shadow]
// keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of
// its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9).
var keptRetired atomic.Bool