H-8: a want's parts are checked by the digests the composition fetches

A want with parts is applied only when every fetched archive, process and container
digest is among the parts and every part is a fetched digest; otherwise the module
keeps running what it has and its have is held with detail parts-differ naming the
digests. A want with no parts is applied as before.

Builds: design 47 G §524, §379

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 15:47:04 +01:00
parent ce01ec1f68
commit 5d3b446988
3 changed files with 138 additions and 0 deletions
+18
View File
@@ -1601,6 +1601,24 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo
ranBefore = ranBefore || a.prior[module]
a.mu.Unlock()
// A want with parts is applied only when they and the digests its composition fetches agree (design 47 G §524): the
// module keeps running what it has, and the have says which digest differs.
if words := partsDifference(w.Want.Parts, comp.C.Resources); words != "" {
a.mu.Lock()
prev, runs := a.running[module]
a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev}
a.mu.Unlock()
if !runs {
if h, found, _ := a.readHave(ctx, module); found {
prev = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed}
}
}
a.env.Say(fmt.Sprintf("%s is held: %s", module, words))
a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseHeld),
Detail: words})
return
}
// A data module is backed up before a new composition of it moves (ADR 0429 §14), unless the want says to go on
// without one (ADR 0539 §16).
if comp.C.Data && ranBefore && a.env.Backup != nil && w.Want.Backup != meshwants.WantsByWantNodeModuleValueBackupSkip {
+44
View File
@@ -322,6 +322,50 @@ func TestAWantAndItsCompositionApplyTheModuleAndTheHaveNamesBoth(t *testing.T) {
if got := b.appliedList(); len(got) != 1 {
t.Fatalf("a want naming another digest applied the composition: %v", got)
}
// Design 47 G section 524: a want with parts is applied only when its parts and the digests the composition fetches
// agree; a want with no parts is applied as before.
d1, d2, d3 := "sha256:"+strings.Repeat("1", 64), "sha256:"+strings.Repeat("2", 64), "sha256:"+strings.Repeat("3", 64)
archive := func(id, digest string) json.RawMessage {
raw, _ := json.Marshal(map[string]any{"id": "web." + id, "type": "archive", "path": "/tmp/r6/web/" + id,
"source": "https://store/" + id, "digest": digest, "module": "web"})
return raw
}
for i, row := range []struct {
name string
parts map[string]string
applies bool
detail string
}{
{"agree", map[string]string{"web:a": d1, "web:b": d2}, true, ""},
{"no parts", nil, true, ""},
{"fetched outside", map[string]string{"web:a": d1}, false, PartsDiffer + " fetched and not in the want's parts: " + d2},
{"part not fetched", map[string]string{"web:a": d1, "web:b": d2, "web:c": d3}, false,
PartsDiffer + " in the want's parts and not fetched: " + d3},
} {
before := len(b.appliedList())
fpn := fpOf(string("5678"[i]))
ckn, crawn, dign := composition(t, "web", fpn, false, archive("a", d1), archive("b", d2))
b.comps.put(ckn, crawn)
wkn := meshwants.WantsByWantNodeModuleKey(node, "web")
wrawn, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fpn, Composed: dign, Parts: row.parts})
if err != nil {
t.Fatal(err)
}
b.wants.put(wkn, wrawn)
b.idle()
if got := len(b.appliedList()) - before; (got == 1) != row.applies {
t.Fatalf("parts %q: applied %d times, want applies=%v", row.name, got, row.applies)
}
if h := b.haves.last("web"); !row.applies && (h.Phase != string(nodeengineclient.PhaseHeld) || h.Detail != row.detail) {
t.Fatalf("parts %q: the have is %s %q, want held %q", row.name, h.Phase, h.Detail, row.detail)
}
}
// A container's digest is the one after its image's @.
container := json.RawMessage(`{"id":"web.c","type":"container","name":"web-c","image":"store/web@` + d3 + `","module":"web"}`)
if got := partsDifference(map[string]string{"web:c": d3}, []json.RawMessage{container}); got != "" {
t.Fatalf("a container whose digest is in the parts differs: %q", got)
}
}
// R6-A AC2: given a kept declaration and wants and compositions equal to it, when the agent starts, nothing is applied
+76
View File
@@ -0,0 +1,76 @@
package agent
import (
"encoding/json"
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// PartsDiffer is the start of the detail of a have held because a want's parts and the digests its composition fetches
// disagree (design 47 G section 524).
const PartsDiffer = "parts-differ:"
// partsDifference checks a want's `parts` against the digests of the archive, process and container resources of the
// composition, which are the digests the apply fetches and checks (design 47 G section 524). A want with no `parts` (an
// assigner of before G, or a node whose record does not state `want-parts`) is applied as today: it returns "". With
// `parts`, the want is applied only when the digest of every resource fetched is among the values of `parts` and every
// value of `parts` is the digest of a resource fetched; otherwise it returns the detail naming each digest.
func partsDifference(parts map[string]string, resources []json.RawMessage) string {
if len(parts) == 0 {
return ""
}
fetched := map[string]bool{}
for _, raw := range resources {
r, err := declaration.ParseResource(raw)
if err != nil {
continue
}
switch res := r.(type) {
case *declaration.Archive:
if res.Source != "" {
fetched[res.Digest] = true
}
case *declaration.Process:
if res.Source != "" {
fetched[res.Digest] = true
}
case *declaration.Container:
if strings.HasPrefix(res.Image, "sha256:") {
fetched[res.Image] = true
} else if _, digest, ok := strings.Cut(res.Image, "@"); ok {
fetched[digest] = true
}
}
}
named := map[string]bool{}
for _, d := range parts {
named[d] = true
}
var outside, missing []string
for d := range fetched {
if !named[d] {
outside = append(outside, d)
}
}
for d := range named {
if !fetched[d] {
missing = append(missing, d)
}
}
if len(outside) == 0 && len(missing) == 0 {
return ""
}
sort.Strings(outside)
sort.Strings(missing)
var said []string
if len(outside) > 0 {
said = append(said, "fetched and not in the want's parts: "+strings.Join(outside, ", "))
}
if len(missing) > 0 {
said = append(said, "in the want's parts and not fetched: "+strings.Join(missing, ", "))
}
return fmt.Sprintf("%s %s", PartsDiffer, strings.Join(said, "; "))
}