From 0bbdae1d9dfb313ae582549cb6618bfbecfccf84 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 00:05:04 +0100 Subject: [PATCH] compose-candidate reads the indexer's candidate by its group key, not the check's key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checker asks by the composition key, . (ADR 0593 §7), but the indexer keys a candidate by its group alone (delivery.CandidatesKey). The node-engine read the ask's key as the bucket key, so every check's composition was refused on every node with "the indexer holds no candidate" (mesh-catalog #599, 2026-11-02). It now parses the ask, reads CandidatesKey(group), and answers unready when the group's candidate has moved to other heads. Both tests' fixtures had captured the wrong key; they now key by group, with a slash in the group name. Quick fix: this lookup. Thorough fix (a shared SDK reader that takes a composition key and returns the candidate at those heads) not taken: one reader, one repository. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- cmd/mesh-host/nodeagent_integration_test.go | 9 ++++++--- internal/selfcompose/candidate.go | 19 +++++++++++++++--- internal/selfcompose/candidate_test.go | 22 +++++++++++++++------ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index 68d8b5e..0c9f677 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -575,13 +575,16 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) { heads, _ := delivery.HeadsHash([]delivery.Head{head}) pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"}, Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}} - craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: "novox/web#3", HeadsHash: heads, Pulls: pulls, + // The indexer keys the candidate by its group (a branch name, slash allowed); the checker asks by the composition key. + const group = "fix/3-web" + craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: group, HeadsHash: heads, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)}) if err != nil { t.Fatal(err) } - key, _ := delivery.CompositionKey("novox/web#3", heads) - if _, err := cands.Put(key, craw); err != nil { + ckey, _ := delivery.CandidatesKey(group) + key, _ := delivery.CompositionKey(group, heads) + if _, err := cands.Put(ckey, craw); err != nil { t.Fatal(err) } before, _ := b.composed.Status() diff --git a/internal/selfcompose/candidate.go b/internal/selfcompose/candidate.go index de8eca4..6c6fe44 100644 --- a/internal/selfcompose/candidate.go +++ b/internal/selfcompose/candidate.go @@ -16,7 +16,8 @@ import ( ) // **A check's candidate, composed in shadow on a call** (novox/hq ADR 0593 §7): the node seat's verb compose-candidate. -// Given the key of the indexer's candidate (delivery.CandidatesBucket, `.`), the node composes itself +// Given the check's composition key (`.`), it reads the group's candidate from the indexer +// (delivery.CandidatesBucket, keyed by the group alone, delivery.CandidatesKey) at those heads, and composes itself // twice from what its shadow holds (placement, records, the indexer's modules, its config values), with its own sealed // values: once as it is, and once with the candidate's modules laid over the indexer's (each at the candidate's // fingerprint, its manifest by digest and the indexer's verdict on it, ADR 0578 §3; a module the candidate removes left @@ -47,7 +48,14 @@ func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandi a.Verdict = delivery.ComposeCandidateUnready return a } - if _, _, err := delivery.ParseCompositionKey(ask.Candidate); err != nil { + group, heads, err := delivery.ParseCompositionKey(ask.Candidate) + if err != nil { + return refuse(err.Error()) + } + // The ask names the check (the composition key, `.`); the indexer keys the group's candidate by + // the group alone (delivery.CandidatesKey) and holds its heads hash in the value. + ckey, err := delivery.CandidatesKey(group) + if err != nil { return refuse(err.Error()) } ctx, cancel := context.WithTimeout(ctx, delivery.ComposeAnswerBound) @@ -56,7 +64,7 @@ func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandi return unready(errNotReady.Error()) } base := c.scratch() - raw, err := base.get(ctx, delivery.CandidatesBucket, ask.Candidate) + raw, err := base.get(ctx, delivery.CandidatesBucket, ckey) if err != nil { return unready(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) } @@ -67,6 +75,11 @@ func (s *Shadow) ComposeCandidate(ctx context.Context, ask delivery.ComposeCandi if err != nil { return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) } + if cand.HeadsHash != heads { + // The group moved to other heads: the check asked is replaced, and this node composes not a candidate it was + // not asked about. No answer (ADR 0593 §9), never a verdict on the wrong heads. + return unready(fmt.Sprintf("the indexer's candidate of the group %s is at heads %s, not %s: the check asked is replaced", group, cand.HeadsHash, heads)) + } laid := c.scratch() touched, err := laid.layCandidate(ctx, cand) if err != nil { diff --git a/internal/selfcompose/candidate_test.go b/internal/selfcompose/candidate_test.go index aec1323..e935430 100644 --- a/internal/selfcompose/candidate_test.go +++ b/internal/selfcompose/candidate_test.go @@ -29,21 +29,24 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { key, _ := delivery.ManifestsKey(d) b.kv[delivery.ManifestsBucket+"/"+key] = man } - candidateOf := func(manifest string) string { + // The indexer keys a candidate by its group alone (delivery.CandidatesKey, the branch name escaped, a slash + // allowed), its heads hash inside the value; the checker asks by the composition key, `.`. + candidateOf := func(group, manifest string) string { head := delivery.Head{Repository: "novox/x", Number: 7, Head: "c0ffee" + manifest[len(manifest)-6:]} heads, _ := delivery.HeadsHash([]delivery.Head{head}) pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"lemurs"}, Fps: map[string]delivery.CandidateModule{"lemurs": {FP2: fpNew, Manifest: manifest}}}} - raw, err := delivery.EncodeCandidate(delivery.Candidate{Group: "novox/x#7", HeadsHash: heads, Pulls: pulls, + raw, err := delivery.EncodeCandidate(delivery.Candidate{Group: group, HeadsHash: heads, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)}) if err != nil { t.Fatal(err) } - key, _ := delivery.CompositionKey("novox/x#7", heads) - b.kv[delivery.CandidatesBucket+"/"+key] = raw + ckey, _ := delivery.CandidatesKey(group) + b.kv[delivery.CandidatesBucket+"/"+ckey] = raw + key, _ := delivery.CompositionKey(group, heads) return key } - accepted, refusedKey := candidateOf(digest2), "" + accepted, refusedKey := candidateOf("fix/940-no-retire-on-partial-read", digest2), "" { // The same change, its manifest refused by the indexer's judge. refusedMan := strings.Replace(lemurs2, `"version":"2"`, `"version":"3"`, 1) @@ -58,7 +61,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { t.Fatal(err) } b.kv[delivery.VerdictsBucket+"/"+vkey] = v - refusedKey = candidateOf(d3) + refusedKey = candidateOf("fix/941-refused", d3) } env := b.env() @@ -114,6 +117,13 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) { if m := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: missing}); !strings.Contains(m.Refused, "holds no candidate") { t.Errorf("a candidate the indexer does not hold answered %+v", m) } + // The group's candidate moved to other heads since the ask: the node composes not the one asked, and answers + // unready (no answer), naming the heads it holds. + moved, _ := delivery.CompositionKey("fix/940-no-retire-on-partial-read", "0123456789ab") + if m := s.ComposeCandidate(ctx, delivery.ComposeCandidateAsk{Candidate: moved}); m.Verdict != delivery.ComposeCandidateUnready || + !strings.Contains(m.Refused, "heads") { + t.Errorf("a candidate at other heads answered %+v", m) + } ended, end := context.WithCancel(ctx) end() if e := s.ComposeCandidate(ended, delivery.ComposeCandidateAsk{Candidate: accepted}); e.Verdict != delivery.ComposeCandidateUnready {