From 346dd432288641be8fa2b1f5fe7bceed8ceefaec Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 18:19:24 +0100 Subject: [PATCH] =?UTF-8?q?delivery,=20catalogue:=20the=20node-engine's=20?= =?UTF-8?q?compose-candidate=20verb,=20compositions=20carrying=20their=20p?= =?UTF-8?q?lan=20or=20refusal,=20the=20kept=20import=20form=20and=20a=20bu?= =?UTF-8?q?ild's=20imports=20on=20its=20key,=20so=20the=20engine=20alone?= =?UTF-8?q?=20composes=20and=20the=20indexer=20switches=20form=20without?= =?UTF-8?q?=20a=20rebuild=20(ADR=200593=20=C2=A74,=20=C2=A76,=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/catalogue/engine_seat.go | 8 ++ go/delivery/builder.go | 6 + go/delivery/compose_candidate.go | 189 ++++++++++++++++++++++++++ go/delivery/compose_candidate_test.go | 120 ++++++++++++++++ go/delivery/composed.go | 19 ++- go/delivery/composed_value.go | 27 +++- go/delivery/source.go | 8 ++ 7 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 go/delivery/compose_candidate.go create mode 100644 go/delivery/compose_candidate_test.go diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index 829143f..518e8e7 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -69,6 +69,14 @@ func EngineVerbs() []Verb { "responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried " + "again. Changes only what a pass the engine makes on its own would change.", Input: schema(map[string]string{"module": "a module placed on this machine whose apply is also tried again (optional)"}, nil)}, + // A composition that does not exist yet (novox/hq ADR 0593 §7). Optional until every engine serves it: a node + // whose engine does not is a node that does not answer (§9). + {Name: "compose-candidate", Optional: true, Description: "Compose a check's candidate on this machine in shadow, " + + "with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it " + + "composes (with its digest and whether the candidate changes it), is refused by the candidate or is held " + + "without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: " + + "applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.", + Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, ."}, []string{"candidate"})}, } } diff --git a/go/delivery/builder.go b/go/delivery/builder.go index c940019..95468e3 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -188,9 +188,15 @@ type BuilderBuild struct { // its id (novox/hq ADR 0525 §1; issue 1887 A4b): a builder that takes the key over at its start waits for those asks' // steps and never puts them again. A reader that does not know the field reads the key as before. Asks map[string]string `json:"asks,omitempty"` + // Imports are, on a done key, the mesh packages the built programs import, as the build module answered them + // (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports). + Imports []string `json:"imports,omitempty"` } func (b BuilderBuild) check() error { + if err := CheckImports(b.Imports); err != nil { + return err + } switch b.State { case BuildQueued, BuildBuilding, BuildDone, BuildFailed: default: diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go new file mode 100644 index 0000000..ad382a8 --- /dev/null +++ b/go/delivery/compose_candidate.go @@ -0,0 +1,189 @@ +package delivery + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb +// compose-candidate, served by each node's engine alone, composes a check's candidate in shadow with the node's own +// sealed values and the indexer's manifest verdicts, applies and writes nothing, and answers within the asker's bound. +// The checker asks every placed node in parallel and makes the check's composition verdict from the answers; a node +// that does not answer within the bound is named "not composed on that node: no answer" and the check passes with that +// warning (§9). This is the contract only: the composing is the node-engine's own. + +const ( + // EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat). + EngineComposeCandidateVerb = "compose-candidate" + // ComposeAnswerBound is the default of the checker's setting compose-answer-bound: how long it waits for a node's + // answer (ADR 0593 §7, §9). + ComposeAnswerBound = 60 * time.Second + // ComposeNoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9). + ComposeNoAnswer = "not composed on that node: no answer" + + // ComposeCandidateComposed and ComposeCandidateRefused are an answer's verdict: no module and not the node's own composition is + // refused by the candidate, or at least one is (or the node could not compose with it at all, Refused). + ComposeCandidateComposed = "composed" + ComposeCandidateRefused = "refused" + + // The states of one module's composition with the candidate: composed (Digest set), refused by the candidate + // (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict. + ModuleComposedState = "composed" + ModuleRefusedState = "refused" + ModuleHeldState = "held" +) + +// ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey, +// `.`); the engine reads that candidate itself and lays its modules over its placement. +// Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the +// catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer +// runs over, for a group that changes the node-engine's composition code (ADR 0593 §7). +type ComposeCandidateAsk struct { + Candidate string `json:"candidate"` + Input bool `json:"input,omitempty"` +} + +// ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node +// sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate at +// all (Refused: Modules is then empty and Verdict refused), how long the engine took in milliseconds, and, when asked +// (ComposeCandidateAsk.Input), the composition input it composed from. +type ComposeCandidateAnswer struct { + V int `json:"v"` + Node string `json:"node"` + Candidate string `json:"candidate"` + Verdict string `json:"verdict"` + Modules []ModuleComposed `json:"modules"` + Own ModuleComposed `json:"own"` + Refused string `json:"refused,omitempty"` + Took int64 `json:"took"` + Input json.RawMessage `json:"input,omitempty"` +} + +// ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the candidate's +// FP2 for a module the candidate touches, else its newest done build as composed- holds it), its state, the +// composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one composed without +// it (the checker's "what the change reaches on this node"), and why, for a refused or held one. +type ModuleComposed struct { + Module string `json:"module"` + FP string `json:"fp,omitempty"` + State string `json:"state"` + Digest string `json:"digest,omitempty"` + Changed bool `json:"changed,omitempty"` + Words string `json:"words,omitempty"` +} + +func (a ComposeCandidateAsk) check() error { + if _, _, err := ParseCompositionKey(a.Candidate); err != nil { + return fmt.Errorf("delivery: a compose-candidate ask names no candidate: %w", err) + } + return nil +} + +func (m ModuleComposed) check(node string) error { + if m.Module == "" { + return fmt.Errorf("delivery: a module's composition on %s names no module", node) + } + switch m.State { + case ModuleComposedState: + if m.Digest == "" { + return fmt.Errorf("delivery: %s is composed on %s and names no digest", m.Module, node) + } + case ModuleRefusedState, ModuleHeldState: + if m.Words == "" { + return fmt.Errorf("delivery: %s is %s on %s and says no why", m.Module, m.State, node) + } + default: + return fmt.Errorf("delivery: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State, + ModuleComposedState, ModuleRefusedState, ModuleHeldState) + } + return nil +} + +func (a ComposeCandidateAnswer) check() error { + if err := checkName("node", a.Node); err != nil { + return err + } + if _, _, err := ParseCompositionKey(a.Candidate); err != nil { + return err + } + if a.Took < 0 { + return fmt.Errorf("delivery: the answer of %s took %d ms", a.Node, a.Took) + } + if a.Refused != "" { + if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused { + return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or no refusal", a.Node, a.Refused) + } + return nil + } + refused := false + for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) { + if err := m.check(a.Node); err != nil { + return err + } + if i > 1 && a.Modules[i-2].Module >= m.Module { + return fmt.Errorf("delivery: the modules of %s's answer are not sorted and each once (%s after %s)", a.Node, m.Module, a.Modules[i-2].Module) + } + refused = refused || m.State == ModuleRefusedState + } + switch { + case a.Own.Module != nodeWord: + return fmt.Errorf("delivery: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, nodeWord) + case refused && a.Verdict != ComposeCandidateRefused: + return fmt.Errorf("delivery: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict) + case !refused && a.Verdict != ComposeCandidateComposed: + return fmt.Errorf("delivery: %s's answer refuses no module and its verdict is %q", a.Node, a.Verdict) + } + return nil +} + +// strictDecode reads data into v, refusing an unknown key. +func strictDecode(data []byte, v any, what string) error { + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(v); err != nil { + return fmt.Errorf("delivery: %s does not fit its shape: %w", what, err) + } + return nil +} + +// EncodeComposeCandidateAsk is a as JSON; it refuses an ask naming no candidate key. +func EncodeComposeCandidateAsk(a ComposeCandidateAsk) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + return json.Marshal(a) +} + +// DecodeComposeCandidateAsk reads an ask strictly; it refuses an unknown key and what EncodeComposeCandidateAsk refuses. +func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) { + var a ComposeCandidateAsk + if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { + return ComposeCandidateAsk{}, err + } + return a, a.check() +} + +// EncodeComposeCandidateAnswer is a as JSON at Version; it refuses an unknown state, a composed module without a digest, +// a refused or held one without words, a refusal of the whole naming modules, and a verdict the modules do not give. +func EncodeComposeCandidateAnswer(a ComposeCandidateAnswer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeComposeCandidateAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown +// key and what EncodeComposeCandidateAnswer refuses. +func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) { + if err := checkVersion(data); err != nil { + return ComposeCandidateAnswer{}, err + } + var a ComposeCandidateAnswer + if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { + return ComposeCandidateAnswer{}, err + } + return a, a.check() +} diff --git a/go/delivery/compose_candidate_test.go b/go/delivery/compose_candidate_test.go new file mode 100644 index 0000000..4c9558c --- /dev/null +++ b/go/delivery/compose_candidate_test.go @@ -0,0 +1,120 @@ +package delivery + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The verb compose-candidate's ask and answer, and a composition that carries its plan or its refusal (ADR 0593 §6, §7): +// each round-trips, and a reader refuses what it could not take, naming it. +func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { + key, err := CompositionKey("mesh-sdk#7", "0123456789ab") + if err != nil { + t.Fatal(err) + } + raw, err := EncodeComposeCandidateAsk(ComposeCandidateAsk{Candidate: key, Input: true}) + if err != nil { + t.Fatal(err) + } + if a, err := DecodeComposeCandidateAsk(raw); err != nil || a.Candidate != key || !a.Input { + t.Fatalf("ask round trip: %v, %+v", err, a) + } + if _, err := DecodeComposeCandidateAsk([]byte(`{"candidate":"` + key + `","other":1}`)); err == nil { + t.Error("an ask with an unknown key was read") + } + if _, err := EncodeComposeCandidateAsk(ComposeCandidateAsk{Candidate: "nope"}); err == nil { + t.Error("an ask naming no candidate key was encoded") + } + + good := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Took: 812, + Own: ModuleComposed{Module: "node", State: ModuleComposedState, Digest: "sha256:00"}, + Modules: []ModuleComposed{ + {Module: "netcheck", FP: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, + {Module: "route-proxy", State: ModuleRefusedState, Words: "the setting domain has no value"}, + {Module: "zz", State: ModuleHeldState, Words: "held before"}, + }, Input: json.RawMessage(`{"machines":["ace"]}`)} + raw, err = EncodeComposeCandidateAnswer(good) + if err != nil { + t.Fatal(err) + } + back, err := DecodeComposeCandidateAnswer(raw) + if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || string(back.Input) != string(good.Input) { + t.Fatalf("answer round trip: %v, %+v", err, back) + } + for name, mutate := range map[string]func(a *ComposeCandidateAnswer){ + "verdict not the modules'": func(a *ComposeCandidateAnswer) { a.Verdict = ComposeCandidateComposed }, + "unknown state": func(a *ComposeCandidateAnswer) { a.Modules[0].State = "fine" }, + "composed without digest": func(a *ComposeCandidateAnswer) { a.Modules[0].Digest = "" }, + "refused without words": func(a *ComposeCandidateAnswer) { a.Modules[1].Words = "" }, + "unsorted": func(a *ComposeCandidateAnswer) { a.Modules[0], a.Modules[1] = a.Modules[1], a.Modules[0] }, + "own misnamed": func(a *ComposeCandidateAnswer) { a.Own.Module = "ace" }, + "whole refusal w/ modules": func(a *ComposeCandidateAnswer) { a.Refused = "the bound passed" }, + } { + a := good + a.Modules = append([]ModuleComposed(nil), good.Modules...) + mutate(&a) + if _, err := EncodeComposeCandidateAnswer(a); err == nil { + t.Errorf("%s: encoded", name) + } + } + whole := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Refused: "the bound passed"} + if _, err := EncodeComposeCandidateAnswer(whole); err != nil { + t.Errorf("a whole refusal was not encoded: %v", err) + } + if _, err := DecodeComposeCandidateAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil { + t.Error("an answer with an unknown key was read") + } + + fp := "src2:" + strings.Repeat("a", 64) + planned := Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}, + Plan: &catalogue.Plan{Files: []catalogue.PlanFile{}}} + raw, err = EncodeComposed(planned) + if err != nil { + t.Fatal(err) + } + c, err := DecodeComposed(raw) + if err != nil || c.Plan == nil { + t.Fatalf("a composition's plan did not round-trip: %v, %+v", err, c) + } + d1, _ := ComposedDigestOf(planned) + d0, _ := ComposedDigestOf(Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) + if d1 == "" || d1 == d0 { + t.Error("the digest does not move with the plan") + } + refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"} + if raw, err = EncodeComposed(refused); err != nil { + t.Fatal(err) + } + if c, err := DecodeComposed(raw); err != nil || c.Refused != refused.Refused { + t.Fatalf("a refused composition did not round-trip: %v", err) + } + if _, err := ComposedDigestOf(refused); err == nil { + t.Error("a refused composition has a digest") + } + refused.Data = true + if _, err := EncodeComposed(refused); err == nil { + t.Error("a refused composition holding data was encoded") + } + if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{}, Refused: "x"}); err == nil { + t.Error("the node's own composition carried a refusal") + } +} + +// A kept import form is set only with imports, and in the fingerprint's form (ADR 0593 §4). +func TestAKeptImportFormNeedsImports(t *testing.T) { + form := SourcePrefix2 + strings.Repeat("b", 64) + in := KeptInputs2{Tree: "t", Imports: []string{"git.novox.be/novox/mesh-sdk/go"}, ImportForm: form} + m := Module{FP2: form, Manifest: strings.Repeat("c", 64), Inputs2: &in, Repository: "novox/x", Commit: "c"} + if err := m.check(); err != nil && strings.Contains(err.Error(), "import form") { + t.Fatalf("a good import form refused: %v", err) + } + for _, bad := range []KeptInputs2{{Tree: "t", ImportForm: form}, {Tree: "t", Imports: in.Imports, ImportForm: "src2:short"}} { + m.Inputs2 = &bad + if err := m.check(); err == nil || !strings.Contains(err.Error(), "import form") { + t.Errorf("%+v: %v", bad, err) + } + } +} diff --git a/go/delivery/composed.go b/go/delivery/composed.go index 659a19b..5fdb5f5 100644 --- a/go/delivery/composed.go +++ b/go/delivery/composed.go @@ -8,8 +8,8 @@ import ( ) // The compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources composed for one node -// at one fingerprint: a node composes its own (ADR 0547 §2), and the ended controller wrote them into a bucket of each -// node, `composed-`, which nothing writes since it ended (ADR 0590), under `..`, and +// at one fingerprint: a node composes its own (ADR 0547 §2) and writes each it makes into its node's bucket, +// `composed-` (ADR 0593 §6), under `..`, and // the node's own resources (adoption, firewall openings, accounts, the resolver, identities and groups) under // `.node`. A composition's digest and its judgement of the move from the composition the node runs now is // ComposedDigest. @@ -23,6 +23,21 @@ const ( ComposedKeep = 5 ) +// Who writes and who reads a node's compositions (novox/hq ADR 0593 §6): the node's engine alone writes its node's +// bucket, unsigned, a record for readers that no engine applies; the grants part creates each bucket, create-only, and +// grants the readers read-only on every node's bucket. A reader declares ComposedRead in its manifest's `reads`, and the +// engine's manifest announces the state ComposedState so that name resolves. +const ( + // ComposedState is the local name the node-engine's manifest announces for the compositions of every node. + ComposedState = "composed" + // ComposedRead is the `reads` entry naming it: the node-engine's module and ComposedState. + ComposedRead = "mesh-host." + ComposedState +) + +// ComposedReaders are the modules that may read every node's compositions (ADR 0593 §6, §8): the provisioner makes its +// plans from them, the assigner compares them for mesh-wants.upgrade, and the view shows them. +var ComposedReaders = []string{"mesh-delivery-assigner", "mesh-delivery-view", "mesh-provisioner"} + // nodeWord is the last part of a node's own key, in the have bucket and in a node's composition bucket alike. const nodeWord = "node" diff --git a/go/delivery/composed_value.go b/go/delivery/composed_value.go index a1afb5a..b98f85e 100644 --- a/go/delivery/composed_value.go +++ b/go/delivery/composed_value.go @@ -8,6 +8,7 @@ import ( "fmt" "io" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" ) @@ -46,6 +47,15 @@ type Composed struct { Resources []json.RawMessage Filled []string Own NodeOwn + // Plan is the module's whole provisioning plan as the node-engine hands it to the provisioner (templates, files, + // own secrets by name, items, collections, provisions; never a value), composed node-wide (novox/hq ADR 0593 §6, + // §8): the provisioner makes every plan from it. Nil when the module hands the provisioner nothing; only on a + // module's key. It is part of the encoding, so the digest moves when the plan does. + Plan *catalogue.Plan + // Refused is why the module cannot be composed at this fingerprint on this node, the rest of the node composed + // (ADR 0593 §6): a refused composition holds no resource, filled file, data or plan, and has no digest + // (ComposedDigestOf refuses it); only on a module's key. The node keeps running what it ran of the module. + Refused string // Signature is the controller's or, made by hand, the operator's (ADR 0539 clause 3, SignComposed); it signs the // digest and is never part of it. Signature *Signature @@ -61,6 +71,8 @@ type composedWire struct { Resources []json.RawMessage `json:"resources"` Filled []string `json:"filled_files,omitempty"` Own *NodeOwn `json:"own,omitempty"` + Plan *catalogue.Plan `json:"plan,omitempty"` + Refused string `json:"refused,omitempty"` Signature *Signature `json:"signature,omitempty"` } @@ -99,6 +111,12 @@ func (c Composed) check() error { return fmt.Errorf("delivery: the composition of %s carries the node's own parts, which only .node carries", c.Module) } } + if c.Module == "" && (c.Plan != nil || c.Refused != "") { + return fmt.Errorf("delivery: the node's own composition carries no plan and no refusal: only a module's does") + } + if c.Refused != "" && (len(c.Resources) > 0 || len(c.Filled) > 0 || c.Data || c.Plan != nil) { + return fmt.Errorf("delivery: the composition of %s on %s is refused and holds resources, filled files, data or a plan", c.Module, c.Node) + } seen := map[string]bool{} files := map[string][]*declaration.File{} for _, raw := range c.Resources { @@ -166,7 +184,7 @@ func (c Composed) wire() (composedWire, error) { return composedWire{}, err } w := composedWire{V: Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled, - Signature: c.Signature, Resources: make([]json.RawMessage, len(c.Resources))} + Plan: c.Plan, Refused: c.Refused, Signature: c.Signature, Resources: make([]json.RawMessage, len(c.Resources))} for i, r := range c.Resources { canon, err := canonicalJSON(r) if err != nil { @@ -205,7 +223,7 @@ func DecodeComposed(data []byte) (Composed, error) { return Composed{}, fmt.Errorf("delivery: a composition does not fit its shape: %w", err) } c := Composed{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources, - Filled: w.Filled, Signature: w.Signature} + Filled: w.Filled, Plan: w.Plan, Refused: w.Refused, Signature: w.Signature} if w.Own != nil { c.Own = *w.Own } @@ -217,7 +235,12 @@ func DecodeComposed(data []byte) (Composed, error) { // ComposedDigestOf is a composition's digest, "sha256:", of its encoding without its signature. A want names it // (ADR 0539 clause 1), the controller writes it beside the composition, and the signature signs it. +// +// A refused composition has no digest: it is refused, naming the module (ADR 0593 §6). func ComposedDigestOf(c Composed) (string, error) { + if c.Refused != "" { + return "", fmt.Errorf("delivery: the composition of %s on %s is refused (%s) and has no digest", c.Module, c.Node, c.Refused) + } c.Signature = nil raw, err := EncodeComposed(c) if err != nil { diff --git a/go/delivery/source.go b/go/delivery/source.go index d424962..8063384 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -472,6 +472,11 @@ type KeptInputs2 struct { // 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"` + // ImportForm is the import-form fingerprint (src2: and 64 hex digits) of the inputs the build that reported Imports + // was built from (novox/hq ADR 0593 §4): set by the indexer when it keeps a build's Imports, while FP2 is still the + // form that build was asked at. A recompute whose import-form fingerprint equals it keeps FP2 as it is (the build + // stands); one that differs writes the import form. Absent with no Imports, and in a value written before. + ImportForm string `json:"import-form,omitempty"` } // checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object. @@ -557,6 +562,9 @@ func (m Module) check() error { if err := CheckImports(m.Inputs2.Imports); err != nil { return err } + if f := m.Inputs2.ImportForm; f != "" && (len(m.Inputs2.Imports) == 0 || !strings.HasPrefix(f, SourcePrefix2) || !isHex64(f[len(SourcePrefix2):])) { + return fmt.Errorf("delivery: the kept import form %q is set only with imports, and is %s and 64 hex digits", f, SourcePrefix2) + } if err := checkNamed(m.Inputs2.Tree, m.Manifest, m.Inputs2.Paths, "the module's kept inputs"); err != nil { return err }