compose-candidate reads the indexer's candidate by its group key, not the check's key
mesh/delivery merged: source.main moved to novox/mesh-host@27a956448c0a
mesh/check checking 0bbdae1d9dfb on 27a956448c0a

The checker asks by the composition key, <group>.<heads hash> (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
This commit is contained in:
2026-11-03 00:05:04 +01:00
parent 4c7056de42
commit 0bbdae1d9d
3 changed files with 38 additions and 12 deletions
+6 -3
View File
@@ -575,13 +575,16 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
heads, _ := delivery.HeadsHash([]delivery.Head{head}) heads, _ := delivery.HeadsHash([]delivery.Head{head})
pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"}, pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"},
Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}} 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)}) FPSet: delivery.FingerprintSet(pulls)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
key, _ := delivery.CompositionKey("novox/web#3", heads) ckey, _ := delivery.CandidatesKey(group)
if _, err := cands.Put(key, craw); err != nil { key, _ := delivery.CompositionKey(group, heads)
if _, err := cands.Put(ckey, craw); err != nil {
t.Fatal(err) t.Fatal(err)
} }
before, _ := b.composed.Status() before, _ := b.composed.Status()
+16 -3
View File
@@ -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. // **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, `<group>.<heads hash>`), the node composes itself // Given the check's composition key (`<group>.<heads hash>`), 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 // 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 // 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 // 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 a.Verdict = delivery.ComposeCandidateUnready
return a 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, `<group>.<heads hash>`); 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()) return refuse(err.Error())
} }
ctx, cancel := context.WithTimeout(ctx, delivery.ComposeAnswerBound) 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()) return unready(errNotReady.Error())
} }
base := c.scratch() base := c.scratch()
raw, err := base.get(ctx, delivery.CandidatesBucket, ask.Candidate) raw, err := base.get(ctx, delivery.CandidatesBucket, ckey)
if err != nil { if err != nil {
return unready(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) 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 { if err != nil {
return refuse(fmt.Sprintf("the indexer's candidate %s cannot be read: %v", ask.Candidate, err)) 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() laid := c.scratch()
touched, err := laid.layCandidate(ctx, cand) touched, err := laid.layCandidate(ctx, cand)
if err != nil { if err != nil {
+16 -6
View File
@@ -29,21 +29,24 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
key, _ := delivery.ManifestsKey(d) key, _ := delivery.ManifestsKey(d)
b.kv[delivery.ManifestsBucket+"/"+key] = man 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, `<group>.<heads hash>`.
candidateOf := func(group, manifest string) string {
head := delivery.Head{Repository: "novox/x", Number: 7, Head: "c0ffee" + manifest[len(manifest)-6:]} head := delivery.Head{Repository: "novox/x", Number: 7, Head: "c0ffee" + manifest[len(manifest)-6:]}
heads, _ := delivery.HeadsHash([]delivery.Head{head}) heads, _ := delivery.HeadsHash([]delivery.Head{head})
pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"lemurs"}, pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"lemurs"},
Fps: map[string]delivery.CandidateModule{"lemurs": {FP2: fpNew, Manifest: manifest}}}} 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)}) FPSet: delivery.FingerprintSet(pulls)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
key, _ := delivery.CompositionKey("novox/x#7", heads) ckey, _ := delivery.CandidatesKey(group)
b.kv[delivery.CandidatesBucket+"/"+key] = raw b.kv[delivery.CandidatesBucket+"/"+ckey] = raw
key, _ := delivery.CompositionKey(group, heads)
return key 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. // The same change, its manifest refused by the indexer's judge.
refusedMan := strings.Replace(lemurs2, `"version":"2"`, `"version":"3"`, 1) refusedMan := strings.Replace(lemurs2, `"version":"2"`, `"version":"3"`, 1)
@@ -58,7 +61,7 @@ func TestTheNodeComposesACandidateOnAsk(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
b.kv[delivery.VerdictsBucket+"/"+vkey] = v b.kv[delivery.VerdictsBucket+"/"+vkey] = v
refusedKey = candidateOf(d3) refusedKey = candidateOf("fix/941-refused", d3)
} }
env := b.env() 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") { 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) 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) ended, end := context.WithCancel(ctx)
end() end()
if e := s.ComposeCandidate(ended, delivery.ComposeCandidateAsk{Candidate: accepted}); e.Verdict != delivery.ComposeCandidateUnready { if e := s.ComposeCandidate(ended, delivery.ComposeCandidateAsk{Candidate: accepted}); e.Verdict != delivery.ComposeCandidateUnready {