From d2ba93abcde81736db583d5d686669c8946162aa Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 22:22:07 +0100 Subject: [PATCH] D4 (sdk, checker): a compose request's manifest names its fingerprint beside its digest composer.Manifest gains Fingerprint (wire {"digest","fingerprint"}) and Fill's get takes the Manifest. The checker fills it from a pull's fingerprint, else from the trunk revision's entry of the module (repoModules keeps each entry's fingerprint). Builds: design 47 G A-310 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-checker/judge.go | 18 +++++++ .../internal/check/engines.go | 5 +- .../internal/check/engines_test.go | 9 ++++ .../internal/check/examiner_test.go | 8 ++++ .../internal/check/graph_edges.go | 2 +- .../internal/check/manifests_judge.go | 13 +++-- .../internal/check/manifests_judge_test.go | 23 +++++++++ .../mesh-delivery-sdk/composer/composer.go | 48 +++++++++++++------ .../composer/composer_test.go | 18 ++++--- 9 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 modules/mesh-delivery-checker/internal/check/manifests_judge_test.go diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go index e8daaf3b..bdccb213 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -266,6 +266,7 @@ type repoModules struct { path map[string]string // by module: its directory in its repository named map[string]map[string][]string // by module: each artifact's named paths, as the indexer keeps them repoOf map[string]string // by module: its repository + fp map[string]string // by module: its entry's fingerprint, whose graph entry holds the manifest commit map[string]string // by module: the commit its record was computed at (the commit the mesh runs) typed map[string]string // by module: its manifest digest parsed map[string]catalogue.Manifest // by manifest digest: the manifest as read, once (a digest's manifest never changes) @@ -311,6 +312,7 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted r.mu.Lock() if r.repoOf == nil { r.repoOf, r.typed, r.langs, r.commit = map[string]string{}, map[string]string{}, map[string]map[string]bool{}, map[string]string{} + r.fp = map[string]string{} r.tsPkgs, r.path, r.named = map[string][]string{}, map[string]string{}, map[string]map[string][]string{} } if deleted { @@ -319,6 +321,7 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted } delete(r.repoOf, key) delete(r.typed, key) + delete(r.fp, key) delete(r.commit, key) delete(r.path, key) delete(r.named, key) @@ -336,6 +339,7 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted r.mu.Lock() r.repoOf[key] = m.Repository r.typed[key] = m.Manifest + r.fp[key] = m.Fp2 r.commit[key] = m.Commit r.path[key] = m.Path r.named[key] = m.Paths @@ -575,6 +579,20 @@ func (r *repoModules) ManifestDigests() map[string]string { return out } +// ManifestFingerprints is the fingerprint of every module the indexer holds, by module, whose catalog graph entry holds its manifest +// (design 47 G A-310). +func (r *repoModules) ManifestFingerprints() map[string]string { + r.mu.Lock() + defer r.mu.Unlock() + out := map[string]string{} + for module, fp := range r.fp { + if fp != "" { + out[module] = fp + } + } + return out +} + // CommitOf is the commit the indexer's record of the module names: the commit its fingerprint first appeared at, so the source // of the build the mesh runs (a later merge that leaves the module alone writes no record, the operator 2026-10-25). func (r *repoModules) CommitOf(module string) string { diff --git a/modules/mesh-delivery-checker/internal/check/engines.go b/modules/mesh-delivery-checker/internal/check/engines.go index fa33dfee..02110b28 100644 --- a/modules/mesh-delivery-checker/internal/check/engines.go +++ b/modules/mesh-delivery-checker/internal/check/engines.go @@ -174,14 +174,15 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s } manifests := map[string]composer.Manifest{} if e.Manifests != nil { + fps := e.Manifests.ManifestFingerprints() for name, digest := range e.Manifests.ManifestDigests() { - manifests[name] = composer.Manifest{Digest: digest} + manifests[name] = composer.Manifest{Digest: digest, Fingerprint: fps[name]} } } for _, p := range c.Pulls { for name, m := range p.Fps { if m.Manifest != "" { - manifests[name] = composer.Manifest{Digest: m.Manifest} + manifests[name] = composer.Manifest{Digest: m.Manifest, Fingerprint: m.Fp2} } } } diff --git a/modules/mesh-delivery-checker/internal/check/engines_test.go b/modules/mesh-delivery-checker/internal/check/engines_test.go index 2771bde6..499d3ce5 100644 --- a/modules/mesh-delivery-checker/internal/check/engines_test.go +++ b/modules/mesh-delivery-checker/internal/check/engines_test.go @@ -110,6 +110,15 @@ type digests map[string]string func (d digests) ManifestDigests() map[string]string { return d } +// ManifestFingerprints: the trunk entry of each module is src2: in a test. +func (d digests) ManifestFingerprints() map[string]string { + out := map[string]string{} + for m := range d { + out[m] = "src2:" + m + } + return out +} + // ADR 0593 §7, §9 (D12, G1, G2): the checker asks every placed node's engine in parallel and makes the composition from // their answers: a refusal fails it naming the node and each module; a node that does not answer within the bound, // answers unready or cannot be asked is named "not composed on that node: no answer" and the check passes with that diff --git a/modules/mesh-delivery-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index 2eaee760..bd09b4a1 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -1270,6 +1270,14 @@ type fixedDigests map[string]string func (f fixedDigests) ManifestDigests() map[string]string { return f } +func (f fixedDigests) ManifestFingerprints() map[string]string { + out := map[string]string{} + for m := range f { + out[m] = "src2:" + m + } + return out +} + // ADR 0578 §2: a change's manifests are judged by the indexer's judge, built at mesh-delivery's trunk and run as the // composer is (a compose ask of its command); a refusal fails the check naming the manifest and each problem; a group // whose mesh-delivery member changes the judge is judged by that member's head. diff --git a/modules/mesh-delivery-checker/internal/check/graph_edges.go b/modules/mesh-delivery-checker/internal/check/graph_edges.go index 052d9c7d..e7c7afef 100644 --- a/modules/mesh-delivery-checker/internal/check/graph_edges.go +++ b/modules/mesh-delivery-checker/internal/check/graph_edges.go @@ -34,7 +34,7 @@ func (st *state) gatherGraph(ctx context.Context) { if st.j.Manifests == nil || st.j.Read == nil || st.narrowTry() { return } - req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.c.Pulls) + req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.j.Manifests.ManifestFingerprints(), st.c.Pulls) if len(touched) == 0 { return } diff --git a/modules/mesh-delivery-checker/internal/check/manifests_judge.go b/modules/mesh-delivery-checker/internal/check/manifests_judge.go index 81337882..848adc6a 100644 --- a/modules/mesh-delivery-checker/internal/check/manifests_judge.go +++ b/modules/mesh-delivery-checker/internal/check/manifests_judge.go @@ -63,6 +63,9 @@ func changesJudge(repository string, merged []Fetched) bool { // TrunkManifests says the manifest digest of every module the indexer holds (its modules state), by module. type TrunkManifests interface { ManifestDigests() map[string]string + // ManifestFingerprints is the fingerprint of the entry of every such module in the trunk revision, by module, whose + // catalog graph entry holds that manifest (design 47 G A-310). + ManifestFingerprints() map[string]string } // SeatList gives the seat definitions the manifests are judged against, whole, as raw definitions (a request's Seats): the @@ -81,12 +84,12 @@ type PredefinedList interface { // judgeRequest is the judge's request over the catalogue as the case leaves it: every trunk manifest by its digest, each // one a pull request touches replaced by the digest its candidate names; touched are the modules whose manifest the // change adds or changes (a digest the trunk does not hold for it), sorted (novox/hq ADR 0579 §2). -func judgeRequest(trunk map[string]string, pulls []Pull) (req composer.Request, touched []string) { +func judgeRequest(trunk, trunkFps map[string]string, pulls []Pull) (req composer.Request, touched []string) { manifests, before := map[string]composer.Manifest{}, map[string]composer.Manifest{} for module, digest := range trunk { if digest != "" { - manifests[module] = composer.Manifest{Digest: digest} - before[module] = composer.Manifest{Digest: digest} + manifests[module] = composer.Manifest{Digest: digest, Fingerprint: trunkFps[module]} + before[module] = composer.Manifest{Digest: digest, Fingerprint: trunkFps[module]} } } seen := map[string]bool{} @@ -95,7 +98,7 @@ func judgeRequest(trunk map[string]string, pulls []Pull) (req composer.Request, if cm.Manifest == "" { continue } - manifests[module] = composer.Manifest{Digest: cm.Manifest} + manifests[module] = composer.Manifest{Digest: cm.Manifest, Fingerprint: cm.Fp2} if trunk[module] != cm.Manifest { seen[module] = true } @@ -113,7 +116,7 @@ func (st *state) judgeManifests(ctx context.Context) { if st.j.Manifests == nil || st.narrowTry() { return } - req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.c.Pulls) + req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.j.Manifests.ManifestFingerprints(), st.c.Pulls) if len(touched) == 0 { return } diff --git a/modules/mesh-delivery-checker/internal/check/manifests_judge_test.go b/modules/mesh-delivery-checker/internal/check/manifests_judge_test.go new file mode 100644 index 00000000..edf3ae6a --- /dev/null +++ b/modules/mesh-delivery-checker/internal/check/manifests_judge_test.go @@ -0,0 +1,23 @@ +package check + +import ( + "reflect" + "testing" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/composer" +) + +// Design 47 G A-310: a manifest the judge's request names by its digest names the fingerprint whose catalog graph entry holds +// it: a pull's own fingerprint for a module the pull changes, the trunk revision's entry for the rest. +func TestTheJudgeRequestNamesEachManifestsFingerprint(t *testing.T) { + pull := Pull{Fps: map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"changed": {Manifest: "d2", Fp2: "src2:pull"}}} + req, _ := judgeRequest(map[string]string{"changed": "d1", "same": "d3"}, map[string]string{"changed": "src2:trunk", "same": "src2:same"}, []Pull{pull}) + got, trunk := req.Cases[0].Manifests, req.Cases[1].Manifests + if !reflect.DeepEqual(got["changed"], composer.Manifest{Digest: "d2", Fingerprint: "src2:pull"}) || !reflect.DeepEqual(got["same"], composer.Manifest{Digest: "d3", Fingerprint: "src2:same"}) { + t.Errorf("the change's manifests = %+v", got) + } + if !reflect.DeepEqual(trunk["changed"], composer.Manifest{Digest: "d1", Fingerprint: "src2:trunk"}) { + t.Errorf("the trunk's manifest = %+v, want the trunk entry's fingerprint", trunk["changed"]) + } +} diff --git a/modules/mesh-delivery-sdk/composer/composer.go b/modules/mesh-delivery-sdk/composer/composer.go index 5990fa29..88b66f95 100644 --- a/modules/mesh-delivery-sdk/composer/composer.go +++ b/modules/mesh-delivery-sdk/composer/composer.go @@ -88,31 +88,44 @@ type Case struct { } // Manifest is a module's manifest as its repository holds it (Raw, the bytes), or the digest the delivery indexer keeps -// it under (Digest), which Fill replaces by the bytes. On the wire it is the manifest itself, or {"digest": "..."}. +// it under (Digest) with the fingerprint whose catalog graph entry holds it (Fingerprint), which Fill replaces by the bytes. +// On the wire it is the manifest itself, or {"digest": "...", "fingerprint": "..."} (design 47 G A-310). type Manifest struct { - Raw json.RawMessage - Digest string + Raw json.RawMessage + Digest string + Fingerprint string } -// MarshalJSON writes the raw manifest, or {"digest": ...} when only the digest is known. +// MarshalJSON writes the raw manifest, or {"digest": ..., "fingerprint": ...} when only the digest is known (the fingerprint +// left out where none is named). func (m Manifest) MarshalJSON() ([]byte, error) { if len(m.Raw) > 0 { return m.Raw, nil } + if m.Fingerprint != "" { + return json.Marshal(map[string]string{"digest": m.Digest, "fingerprint": m.Fingerprint}) + } return json.Marshal(map[string]string{"digest": m.Digest}) } -// UnmarshalJSON reads {"digest": "..."} (an object with that one key) as a digest, and any other value as the raw -// manifest. +// UnmarshalJSON reads an object with the key "digest" and no key but "fingerprint" besides as a digest, and any other value as +// the raw manifest. func (m *Manifest) UnmarshalJSON(raw []byte) error { var probe map[string]json.RawMessage - if json.Unmarshal(raw, &probe) == nil && len(probe) == 1 { - if d, ok := probe["digest"]; ok { - var digest string + if json.Unmarshal(raw, &probe) == nil && len(probe) >= 1 && len(probe) <= 2 { + d, hasDigest := probe["digest"] + f, hasFp := probe["fingerprint"] + if hasDigest && len(probe) == 1+btoi(hasFp) { + var digest, fp string if err := json.Unmarshal(d, &digest); err != nil { return fmt.Errorf("a manifest's digest is not a string: %w", err) } - *m = Manifest{Digest: digest} + if hasFp { + if err := json.Unmarshal(f, &fp); err != nil { + return fmt.Errorf("a manifest's fingerprint is not a string: %w", err) + } + } + *m = Manifest{Digest: digest, Fingerprint: fp} return nil } } @@ -120,6 +133,13 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { return nil } +func btoi(b bool) int { + if b { + return 1 + } + return 0 +} + // Verdict is what one run answers: the mesh-sdk the command was built with, and each case's verdict in the order asked. type Verdict struct { V int `json:"v"` @@ -183,15 +203,15 @@ func Decode(data []byte) (Request, error) { return r, nil } -// Fill replaces every manifest named by its digest by the bytes get answers for that digest; a digest get cannot answer -// stops it, named. -func Fill(r *Request, get func(digest string) ([]byte, error)) error { +// Fill replaces every manifest named by its digest by the bytes get answers for it (its digest and fingerprint); a manifest get +// cannot answer stops it, named. +func Fill(r *Request, get func(m Manifest) ([]byte, error)) error { fill := func(m map[string]Manifest) error { for name, man := range m { if len(man.Raw) > 0 { continue } - raw, err := get(man.Digest) + raw, err := get(man) if err != nil { return fmt.Errorf("composer: the manifest of %s (digest %s): %w", name, man.Digest, err) } diff --git a/modules/mesh-delivery-sdk/composer/composer_test.go b/modules/mesh-delivery-sdk/composer/composer_test.go index 9ee1c405..63e31c79 100644 --- a/modules/mesh-delivery-sdk/composer/composer_test.go +++ b/modules/mesh-delivery-sdk/composer/composer_test.go @@ -3,6 +3,7 @@ package composer import ( "encoding/json" "errors" + "slices" "strings" "testing" ) @@ -13,23 +14,24 @@ const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files // place; a raw manifest goes as itself. func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { r := Request{V: Version, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{ - "demo": {Digest: "d1"}, "raw": {Raw: json.RawMessage(`{"module":"raw","version":"1"}`)}}, + "demo": {Digest: "d1", Fingerprint: "src2:f1"}, "raw": {Raw: json.RawMessage(`{"module":"raw","version":"1"}`)}}, Kept: map[string]map[string]Manifest{"laptop": {"demo": {Digest: "d1"}}}}}} wire, err := json.Marshal(r) if err != nil { t.Fatal(err) } - if !strings.Contains(string(wire), `"demo":{"digest":"d1"}`) || !strings.Contains(string(wire), `"raw":{"module":"raw","version":"1"}`) { + if !strings.Contains(string(wire), `"demo":{"digest":"d1","fingerprint":"src2:f1"}`) || !strings.Contains(string(wire), `"raw":{"module":"raw","version":"1"}`) { t.Fatalf("on the wire: %s", wire) } back, err := Decode(wire) if err != nil { t.Fatal(err) } - asked := 0 - if err := Fill(&back, func(d string) ([]byte, error) { + asked, fingerprints := 0, []string{} + if err := Fill(&back, func(m Manifest) ([]byte, error) { asked++ - if d != "d1" { + fingerprints = append(fingerprints, m.Fingerprint) + if m.Digest != "d1" { return nil, errors.New("no such digest") } return []byte(demo), nil @@ -39,6 +41,10 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo { t.Fatalf("filled %d times: %+v", asked, back.Cases[0]) } + // get is given the fingerprint the request names beside the digest, and none where it names none (design 47 G A-310). + if slices.Sort(fingerprints); !slices.Equal(fingerprints, []string{"", "src2:f1"}) { + t.Errorf("get was given the fingerprints %q, want one named and one not", fingerprints) + } // The known packages: none is no list (not on the wire), an empty list is a list, and a list reads back whole. for _, known := range [][]string{nil, {}, {"git.novox.be/novox/mesh-sdk", "@novox/mesh-sdk"}} { wire, err := json.Marshal(Request{V: Version, Packages: known}) @@ -52,7 +58,7 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { } } bad := Request{V: Version, Cases: []Case{{Name: "c", Manifests: map[string]Manifest{"x": {Digest: "nope"}}}}} - if err := Fill(&bad, func(string) ([]byte, error) { return nil, errors.New("not there") }); err == nil || !strings.Contains(err.Error(), "nope") { + if err := Fill(&bad, func(Manifest) ([]byte, error) { return nil, errors.New("not there") }); err == nil || !strings.Contains(err.Error(), "nope") { t.Fatalf("a digest the indexer does not hold: %v", err) } }