delivery: the indexer's packages state and the imports a build reports, so only the indexer computes a fingerprint over what a module imports (ADR 0593 §2)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 18:15:35 +01:00
parent 21dd8881ed
commit 3d87ddcc9a
5 changed files with 234 additions and 2 deletions
+9 -2
View File
@@ -171,6 +171,10 @@ type BuildLogEntry struct {
Manifest string `json:"manifest,omitempty"`
Against []string `json:"against,omitempty"`
Made []BuiltArtifact `json:"made,omitempty"`
// Imports are, at a build's end, the mesh packages (import paths) the built programs import, their tests left out,
// sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer
// keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's.
Imports []string `json:"imports,omitempty"`
}
// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held
@@ -216,8 +220,11 @@ func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) {
return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time")
case e.Event == BuildEndedEvent && e.Outcome == "":
return nil, fmt.Errorf("delivery: a build's end names its outcome")
case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0):
return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base or artifact")
case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0):
return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base, artifact or import")
}
if err := CheckImports(e.Imports); err != nil {
return nil, err
}
return json.Marshal(e)
}
+126
View File
@@ -0,0 +1,126 @@
package delivery
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
)
// The published mesh packages (novox/hq ADR 0593 §2): the indexer alone reads each mesh Go module's package digests
// from the forge's listing at its merge and writes, in its own state, the latest published version of the module and
// the content digest of each package in it. The builder, the checker and the build modules read this state; no other
// part computes a package's version or digest. This is the contract only: the key, the value and its checks. How a
// digest is computed is the indexer's own.
const (
// PackagesState is the name of the indexer's state of published packages, as its manifest declares it, and
// PackagesBucket its bucket: one key per mesh Go module (PackagesKey).
PackagesState = "packages"
PackagesBucket = IndexerModule + "_" + PackagesState
// PackagesHistory is the revisions the packages state keeps per key: the module's newest publications.
PackagesHistory = 5
)
// PackagesKey is the key, in PackagesBucket, of a mesh Go module (its module path, such as
// git.novox.be/novox/mesh-sdk/go): the path as a key part (EscapeName). It refuses a path that is no mesh Go module's
// (PackageRepository).
func PackagesKey(module string) (string, error) {
if _, _, ok := PackageRepository(module); !ok {
return "", fmt.Errorf("delivery: %q is no mesh Go module's path (it starts with %s)", module, ForgeModulePrefix)
}
return EscapeName(module), nil
}
// PublishedPackages is the value of a packages key: a mesh Go module as its latest trunk merge published it. Module is
// its module path, Repository (`<owner>/<name>`) and Dir its repository and its directory there (RootPath for the
// root), Commit the merge commit (the version a build pins, StepAsk.Packages), Requires the digest of its go.mod and
// go.sum together, and Packages the content digest of each package of the module (its files, tests and sub-packages
// excluded), by import path, sorted and each once. Packages is a list, never a map keyed by import path: a state value
// refuses a field name ending in a credential word, and a package such as .../internal/token is one (issue 1930).
type PublishedPackages struct {
V int `json:"v"`
Module string `json:"module"`
Repository string `json:"repository"`
Dir string `json:"dir"`
Commit string `json:"commit"`
Requires string `json:"requires"`
Packages []PackageDigest `json:"packages"`
At time.Time `json:"at"`
}
// DigestOf is the content digest of the package at import path pkg, and whether p holds it.
func (p PublishedPackages) DigestOf(pkg string) (string, bool) {
i, found := slices.BinarySearchFunc(p.Packages, pkg, func(d PackageDigest, s string) int { return strings.Compare(d.Package, s) })
if !found {
return "", false
}
return p.Packages[i].Digest, true
}
func (p PublishedPackages) check() error {
repository, _, ok := PackageRepository(p.Module)
switch {
case !ok:
return fmt.Errorf("delivery: %q is no mesh Go module's path (it starts with %s)", p.Module, ForgeModulePrefix)
case !SameRepository(p.Repository, repository):
return fmt.Errorf("delivery: the module %s is published from %s, and the value names the repository %q", p.Module, repository, p.Repository)
case p.Dir == "":
return fmt.Errorf("delivery: the published module %s names no directory (%q for the repository's root)", p.Module, RootPath)
case !askCommitOK.MatchString(p.Commit):
return fmt.Errorf("delivery: the published module %s names the commit %q, which is no full commit", p.Module, p.Commit)
case p.Requires == "":
return fmt.Errorf("delivery: the published module %s names no digest of its requirements", p.Module)
case p.At.IsZero():
return fmt.Errorf("delivery: the published module %s names no time", p.Module)
}
for i, d := range p.Packages {
if d.Package != p.Module && !strings.HasPrefix(d.Package, p.Module+"/") {
return fmt.Errorf("delivery: the package %q is not in the module %s", d.Package, p.Module)
}
if d.Digest == "" {
return fmt.Errorf("delivery: the package %s names no digest", d.Package)
}
if i > 0 && p.Packages[i-1].Package >= d.Package {
return fmt.Errorf("delivery: the packages of %s are not sorted and each once (%s after %s)", p.Module, d.Package, p.Packages[i-1].Package)
}
}
return nil
}
// EncodePublishedPackages is p as JSON at Version; it refuses what a reader could not take (check).
func EncodePublishedPackages(p PublishedPackages) ([]byte, error) {
if err := p.check(); err != nil {
return nil, err
}
p.V = Version
return json.Marshal(p)
}
// DecodePublishedPackages reads a packages value; it refuses an unknown version (ErrUnknownVersion) and what
// EncodePublishedPackages refuses.
func DecodePublishedPackages(data []byte) (PublishedPackages, error) {
var p PublishedPackages
if err := decode(data, &p); err != nil {
return PublishedPackages{}, err
}
if err := p.check(); err != nil {
return PublishedPackages{}, err
}
return p, nil
}
// CheckImports refuses an import set (KeptInputs2.Imports, BuildAnswer.Imports, BuildLogEntry.Imports) that holds a
// path that is no mesh package's, or is not sorted and each once.
func CheckImports(imports []string) error {
for i, pkg := range imports {
if _, _, ok := PackageRepository(pkg); !ok || strings.ContainsAny(pkg, " \t\n@") {
return fmt.Errorf("delivery: the import %q is no mesh package's path (it starts with %s)", pkg, ForgeModulePrefix)
}
if i > 0 && imports[i-1] >= pkg {
return fmt.Errorf("delivery: the imports are not sorted and each once (%s after %s)", pkg, imports[i-1])
}
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package delivery
import (
"encoding/json"
"reflect"
"strings"
"testing"
"time"
)
// The packages state, the build end's imports and the kept imports (ADR 0593 §2) round-trip, and a reader refuses what it
// could not take, naming it.
func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) {
commit := strings.Repeat("a", 40)
sdk := "git.novox.be/novox/mesh-sdk/go"
good := PublishedPackages{Module: sdk, Repository: "novox/mesh-sdk", Dir: "go", Commit: commit, Requires: "sha256:01",
Packages: []PackageDigest{{Package: sdk, Digest: "d0"}, {Package: sdk + "/announce", Digest: "d1"}, {Package: sdk + "/internal/token", Digest: "d2"}},
At: time.Date(2026, 11, 2, 18, 0, 0, 0, time.UTC)}
raw, err := EncodePublishedPackages(good)
if err != nil {
t.Fatal(err)
}
back, err := DecodePublishedPackages(raw)
good.V = Version
if err != nil || !reflect.DeepEqual(back, good) {
t.Fatalf("round trip: %v, %+v", err, back)
}
if d, ok := back.DigestOf(sdk + "/announce"); !ok || d != "d1" {
t.Fatalf("DigestOf announce = %q, %v", d, ok)
}
if _, ok := back.DigestOf(sdk + "/buildstep"); ok {
t.Fatal("DigestOf finds a package the value does not hold")
}
if key, err := PackagesKey(sdk); err != nil || key != "git=2enovox=2ebe/novox/mesh-sdk/go" {
t.Fatalf("PackagesKey = %q, %v", key, err)
}
if _, err := PackagesKey("github.com/x/y"); err == nil {
t.Fatal("PackagesKey takes a path that is no mesh Go module's")
}
for name, mutate := range map[string]func(p *PublishedPackages){
"no mesh module": func(p *PublishedPackages) { p.Module = "github.com/x/y" },
"other repository": func(p *PublishedPackages) { p.Repository = "novox/mesh-host" },
"short commit": func(p *PublishedPackages) { p.Commit = "abc" },
"no requires": func(p *PublishedPackages) { p.Requires = "" },
"package elsewhere": func(p *PublishedPackages) {
p.Packages = []PackageDigest{{Package: "git.novox.be/novox/mesh-host/x", Digest: "d"}}
},
"unsorted": func(p *PublishedPackages) { p.Packages = []PackageDigest{p.Packages[1], p.Packages[0]} },
"no digest": func(p *PublishedPackages) { p.Packages = []PackageDigest{{Package: sdk}} },
} {
p := good
p.Packages = slicesClone(good.Packages)
mutate(&p)
if _, err := EncodePublishedPackages(p); err == nil {
t.Errorf("%s: encoded", name)
}
}
imports := []string{sdk, sdk + "/announce"}
end := BuildLogEntry{Event: BuildEndedEvent, Module: "netcheck", FP: "src2:x", Repository: "novox/mesh-catalog", Commit: commit,
Node: "ace", At: good.At, Outcome: "done", Imports: imports}
raw, err = EncodeBuildLogEntry(end)
if err != nil {
t.Fatal(err)
}
if e, err := DecodeBuildLogEntry(raw); err != nil || !reflect.DeepEqual(e.Imports, imports) {
t.Fatalf("build end round trip: %v, %v", err, e.Imports)
}
start := end
start.Event, start.Outcome = BuildStartedEvent, ""
if _, err := EncodeBuildLogEntry(start); err == nil {
t.Error("a build's start with imports encoded")
}
end.Imports = []string{sdk + "/announce", sdk}
if _, err := EncodeBuildLogEntry(end); err == nil {
t.Error("unsorted imports encoded")
}
var answer BuildAnswer
if err := json.Unmarshal([]byte(`{"artifacts":[],"imports":["`+sdk+`"]}`), &answer); err != nil || len(answer.Imports) != 1 {
t.Fatalf("build answer imports: %v, %v", err, answer.Imports)
}
if err := CheckImports([]string{"fmt"}); err == nil {
t.Error("CheckImports takes a standard package")
}
}
func slicesClone(d []PackageDigest) []PackageDigest { return append([]PackageDigest(nil), d...) }
+8
View File
@@ -467,6 +467,11 @@ type KeptInputs2 struct {
// fingerprint takes the latest published version of each (Module.Packages). Absent for none, and in a value written
// before.
Packages []string `json:"packages,omitempty"`
// Imports are the mesh packages (import paths) the module's programs import, their tests left out, as the module's
// last trunk build reported them at its end (BuildLogEntry.Imports; novox/hq ADR 0593 §2), sorted and each once:
// the fingerprint takes each one's content digest (PublishedPackages) instead of Packages' versions. Absent for a
// module with no reported import set, which keeps the version line, and in a value written before.
Imports []string `json:"imports,omitempty"`
}
// checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object.
@@ -549,6 +554,9 @@ func (m Module) check() error {
}
}
if m.Inputs2 != nil {
if err := CheckImports(m.Inputs2.Imports); err != nil {
return err
}
if err := checkNamed(m.Inputs2.Tree, m.Manifest, m.Inputs2.Paths, "the module's kept inputs"); err != nil {
return err
}
+4
View File
@@ -555,6 +555,10 @@ func DecodeStepAsk(data []byte) (StepAsk, error) {
type BuildAnswer struct {
Artifacts []AskBuilt `json:"artifacts"`
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
// Imports are the mesh packages (import paths) the built programs import, their tests left out, sorted and each
// once (novox/hq ADR 0593 §2): a Go build module reports them, the builder carries them onto the build's end
// (BuildLogEntry.Imports). Absent from a build module that reports none.
Imports []string `json:"imports,omitempty"`
}
// AskBuilt is one artifact a build ask built.