delivery, catalogue: compose-candidate takes input as a switch and answers unready for a node that cannot compose yet, so the checker decides by the verdict, never by words (ADR 0593 §7, §9)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 18:34:56 +01:00
parent 029ba98527
commit 52bd3d087c
4 changed files with 47 additions and 4 deletions
@@ -0,0 +1,19 @@
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.
func TestTheEngineServesComposeCandidate(t *testing.T) {
for _, v := range EngineVerbs() {
if v.Name != "compose-candidate" {
continue
}
props, _ := v.Input["properties"].(map[string]any)
input, _ := props["input"].(map[string]any)
if !v.Optional || props["candidate"] == nil || input == nil || input["enum"] == nil {
t.Fatalf("compose-candidate reads as %+v", v)
}
return
}
t.Fatal("the seat node-engine serves no compose-candidate")
}
+3 -1
View File
@@ -76,7 +76,9 @@ func EngineVerbs() []Verb {
"composes (with its digest and whether the candidate changes it), is refused by the candidate or is held " +
"without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: " +
"applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.",
Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, <group>.<heads hash>"}, []string{"candidate"})},
Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, <group>.<heads hash>",
"input": "\"true\": also answer the composition input the candidate was composed from, for the sandbox path"},
[]string{"candidate"}, "input")},
}
}
+11 -3
View File
@@ -27,6 +27,10 @@ const (
// refused by the candidate, or at least one is (or the node could not compose with it at all, Refused).
ComposeCandidateComposed = "composed"
ComposeCandidateRefused = "refused"
// ComposeCandidateUnready 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
// (ComposeNoAnswer, ADR 0593 §9), deciding by this verdict and never by the words.
ComposeCandidateUnready = "unready"
// 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.
@@ -47,7 +51,7 @@ type ComposeCandidateAsk struct {
// 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), how long the engine took in milliseconds, and, when asked
// 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"`
@@ -111,9 +115,13 @@ func (a ComposeCandidateAnswer) check() error {
if a.Took < 0 {
return fmt.Errorf("delivery: the answer of %s took %d ms", a.Node, a.Took)
}
if a.Verdict == ComposeCandidateUnready && a.Refused == "" {
return fmt.Errorf("delivery: %s is not ready to compose and its answer says no why", a.Node)
}
if a.Refused != "" {
if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused {
return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or no refusal", a.Node, a.Refused)
if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused && a.Verdict != ComposeCandidateUnready {
return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or is neither %s nor %s",
a.Node, a.Refused, ComposeCandidateRefused, ComposeCandidateUnready)
}
return nil
}
+14
View File
@@ -64,6 +64,20 @@ func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) {
if _, err := EncodeComposeCandidateAnswer(whole); err != nil {
t.Errorf("a whole refusal was not encoded: %v", err)
}
unready := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateUnready, Refused: "the agent is starting"}
if raw, err := EncodeComposeCandidateAnswer(unready); err != nil {
t.Errorf("an unready answer was not encoded: %v", err)
} else if back, err := DecodeComposeCandidateAnswer(raw); err != nil || back.Verdict != ComposeCandidateUnready {
t.Errorf("an unready answer read back as %+v, %v", back, err)
}
unready.Refused = ""
if _, err := EncodeComposeCandidateAnswer(unready); err == nil {
t.Error("an unready answer saying no why was encoded")
}
unready.Refused, unready.Modules = "starting", good.Modules
if _, err := EncodeComposeCandidateAnswer(unready); err == nil {
t.Error("an unready answer naming modules was encoded")
}
if _, err := DecodeComposeCandidateAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil {
t.Error("an answer with an unknown key was read")
}