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
This commit is contained in:
2026-11-12 22:22:07 +01:00
parent 4ba52d9ab7
commit d2ba93abcd
9 changed files with 116 additions and 28 deletions
@@ -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 {
@@ -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}
}
}
}
@@ -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:<module> 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
@@ -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.
@@ -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
}
@@ -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
}
@@ -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"])
}
}
+34 -14
View File
@@ -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)
}
@@ -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)
}
}