package catalogue import ( "fmt" "path" "sort" "strings" ) // Turning a manifest that names artifacts into one that names digests. // // **Two documents, deliberately.** The manifest in a repository says *this resource uses the // archive called `config`*; the manifest the mesh holds says *this resource is sha256:…*. A digest // is not knowable until something is built, so a repository carrying one would be a repository // whose file is wrong the moment anybody edits anything — and the mesh would be pinning a value // nobody could have checked. // // So the built manifest is **derived**, and the record of which commit it was derived from is what // makes "is this current?" answerable without building (novox/hq ADR 0009). // Built is one artifact after it exists: where it is and what it hashes to. type Built struct { // Name is what the manifest called it. Name string // Kind is "image" or "archive". Kind string // Reference is what a machine uses to get it — an image reference for an image, a URL for an // archive. Both already carry the digest for an image; an archive carries it separately. Reference string // Digest is "sha256:", for an archive. An image reference already ends in one. Digest string // Launchers are, for a bundle in an interpreted language, the executable the build wrote beside // each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it. Launchers map[string]string // Texts are, for the archive a module packs its templates into (TemplatesArtifact), each file's text as the // build read it back from the packed archive, by its path inside it (novox/hq ADR 0349 §2). Never carried on // the wire or kept: Resolve reads them into the files' content, which is what the mesh holds. Texts map[string]string `json:"-"` } // Resolve fills a manifest's resources in from what was built. // // Every resource naming an artifact is rewritten to name the thing itself, and the `artifact` key // is removed — because it is a build-time word and the host has never heard of it. A resource // naming an artifact nothing produced is refused: it would otherwise reach a machine with an // empty image or an unpinned archive, which is the shape of failure that looks like success. func (m Manifest) Resolve(built []Built) (Manifest, error) { if m.Build == nil && len(built) == 0 { return m, nil } by := map[string]Built{} for _, b := range built { by[b.Name] = b } // Declared and not produced is a build that did not do what the manifest asked, and saying so // here beats a machine reporting it later. var missing []string if m.Build != nil { for _, a := range m.Build.Artifacts { if _, ok := by[a.Name]; !ok { missing = append(missing, a.Name) } } } if len(missing) > 0 { sort.Strings(missing) return Manifest{}, fmt.Errorf( "%s says it builds %s and the build did not produce %s — the build did not do what "+ "the manifest asked, which is a different fault from a resource asking for the "+ "wrong thing", m.Module, strings.Join(missing, " and "), oneOrOther(len(missing))) } // **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into // the file's content, so what the mesh holds and composes is what the same text inline would be. var handed *Handed if m.Templated() { name := TemplatesArtifact(m) texts := by[name].Texts if name == "" || texts == nil { return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+ "archive it packs %s/ into (%q): the builder that built it does not read templates", m.Module, m.TemplatesDir(), name) } dir := m.TemplatesDir() filled, err := fillTemplates(m.Module, dir, m.Resources, func(template string) (string, bool) { t, ok := texts[strings.TrimPrefix(template, dir+"/")] return t, ok }, fmt.Sprintf("the archive %q", name)) if err != nil { return Manifest{}, err } items, err := fillItemTemplates(m, m.Items, func(template string) (string, bool) { t, ok := texts[strings.TrimPrefix(template, dir+"/")] return t, ok }, fmt.Sprintf("the archive %q", name)) if err != nil { return Manifest{}, err } // And, for a moved module, where its templates are and which one each file and item is filled from // (handed.go): read while its resources and items still name their templates. handed = handedBy(m, by[name]) m.Resources = filled m.Items = items } out := m out.Handed = handed out.Build = nil // Read with the build, like Build: the manifest the mesh holds carries each file's text (issue 731). out.Templates = "" out.Resources = nil // What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is // named by no resource of the module's own — the node's runtime loads it — so this is the only // place the mesh would otherwise not have it. In artifact order, so two resolutions of one // build compare equal. out.Bundles = nil if m.Build != nil { run := runByAResource(m) for _, a := range m.Build.Artifacts { if a.Kind != ArtifactBundle { continue } made := by[a.Name] loads := BundleLoads(m, a, run) // **Kept, never routed** (ADR 0155): the builder publishes to the store at the address // it reached it by, and a manifest carrying that address names an installation — // registration refused node-tools for exactly this on 2026-10-02. The build record // already keeps the store-relative form; the resolved manifest keeps the same, and // composition routes it through the store a machine reaches (Routed). out.Bundles = append(out.Bundles, Bundle{ Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest, Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...), Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers), Binary: BinaryOf(a), Bus: a.Bus, }) } sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name }) } for _, r := range m.Resources { named, _ := r["artifact"].(string) if named == "" { out.Resources = append(out.Resources, r) continue } artifact, ok := by[named] if !ok { return Manifest{}, fmt.Errorf( "%s: %v uses the artifact %q, and this module builds no such thing", m.Module, r["id"], named) } filled := map[string]any{} for k, v := range r { filled[k] = v } delete(filled, "artifact") switch artifact.Kind { case ArtifactPackage: // A package is not a resource on any machine; it is consumed by other builds. A // resource that names one is a manifest error, named here rather than shipped. return Manifest{}, fmt.Errorf( "%s: %v uses %q, which is a package — a build input, not a resource a machine runs", m.Module, r["id"], named) case ArtifactImage, ArtifactUpstream: filled["image"] = artifact.Reference // An image is not unpacked anywhere, so it has no directory to be named for its // version and `${version}` has nothing to mean. Refused rather than left as literal // text in a path, which is how it would reach a machine and be created as a directory // called `${version}`. for key, value := range filled { if text, isText := value.(string); isText && strings.Contains(text, versionRef) { return Manifest{}, fmt.Errorf( "%s: %v says %s in %q, and %q is an image — an image is not unpacked, so "+ "it has no versioned place. %s is for an archive or a bundle", m.Module, r["id"], versionRef, key, named, versionRef) } } case ArtifactArchive, ArtifactBundle: // The same on the wire: both are bytes fetched by digest and unpacked. They differ in // how they were made — one packed as it stood, the other compiled first — and a // machine has no reason to care which. // Kept, not routed, for the reason the bundles above are (ADR 0155). filled["source"] = Recorded(artifact.Reference) filled["digest"] = artifact.Digest // **And `${version}`, so a resource can name a place that is this build's alone** // (novox/hq ADR 0141, 04-ISSUES/142). A component is unpacked into a directory named // for its version so it can read its own version from its path — and until this, // nothing could compose that path: an archive named a fixed one in the manifest and // nothing interpolated the build into it, so nothing could ask for // `…/versions//` and every machine took a hand-placed fallback. // // The version is the artifact's own digest, short. Not the commit: two builds of one // commit are meant to be the same bytes (the toolchains are `-trimpath` for this), and // a content-addressed version means an unchanged build resolves to the path it already // had — so re-composing a declaration moves nothing, where a commit would move the // path of an identical binary and recreate everything that reads it. for key, value := range filled { filled[key] = withVersion(value, versionOf(artifact.Digest)) } default: return Manifest{}, fmt.Errorf("%s: %q is a %q, and an artifact is %q, %q, %q or %q", m.Module, named, artifact.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle) } out.Resources = append(out.Resources, filled) } return out, nil } // withVersion is a resource's value with `${version}` filled: in a string, and in each string of a map — // a process's env, where the controller is told which build it is (novox/hq issue 352). Anything else is // left as it is. func withVersion(value any, version string) any { switch v := value.(type) { case string: if strings.Contains(v, versionRef) { return strings.ReplaceAll(v, versionRef, version) } case map[string]any: out := make(map[string]any, len(v)) for k, x := range v { out[k] = withVersion(x, version) } return out case map[string]string: out := make(map[string]string, len(v)) for k, x := range v { out[k] = strings.ReplaceAll(x, versionRef, version) } return out } return value } // NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the // paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact // names paths; otherwise none, and the module keeps its directory's tree. // // **Read by the core alone** (ADR 0495 clause 1, the lead's decision L8 of R4e): the indexer and its merge preview // (buildstep.ModuleTrees) call it. A build module's fetch step is handed the paths by the builder, from the indexer's // kept inputs (delivery.KeptInputs2.Paths), and reads no manifest for them. func (m Manifest) NamedPaths() (paths []string, every bool) { if m.Build == nil || len(m.Build.Artifacts) == 0 { return nil, false } seen := map[string]bool{} for _, a := range m.Build.Artifacts { if len(a.Paths) == 0 { return nil, false } for _, p := range a.Paths { seen[path.Clean(p)] = true } } for p := range seen { paths = append(paths, p) } sort.Strings(paths) return paths, true } // oneOrOther keeps the message readable for one artifact and for several, because a message that // says "neither" about one thing reads as a bug in the message. func oneOrOther(n int) string { if n == 1 { return "it" } return "them" } // compilesToABinary is whether this language's bundle is a binary for one operating system rather // than code that runs wherever its interpreter does. // // **Asked of the language, not of the artifact.** A module says what it is written in; what that // implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact // would let two artifacts in one language disagree about whether they are portable. func compilesToABinary(language string) bool { switch strings.ToLower(strings.TrimSpace(language)) { case "go": return true default: return false } } // versionRef is how a resource names the version of the artifact it uses: ${version}. // // No artifact name in it, because the resource already says which artifact it is for — a second // name would be a second thing to keep in step with the first. const versionRef = "${version}" // versionOf is an artifact's version as a path names it: its digest, short. // // **Content-addressed on purpose.** The alternative is the commit a build came from, and two builds // of one commit are meant to produce the same bytes — every toolchain here is `-trimpath` for that // reason. A commit-named path would move for an identical binary, and everything reading that path // would be recreated for a change that is not one. A digest-named path moves exactly when the bytes // do. // // Twelve hex characters: enough that two of this mesh's builds will not collide, short enough to // read in a path and in a journal line. The `sha256:` prefix goes, because a directory name carrying // a colon is a directory name people quote wrong. func versionOf(digest string) string { hex := strings.TrimPrefix(strings.TrimSpace(digest), "sha256:") if len(hex) > 12 { return hex[:12] } return hex } func copyWords(in map[string]string) map[string]string { if len(in) == 0 { return nil } out := make(map[string]string, len(in)) for k, v := range in { out[k] = v } return out } // BinaryOf is what a bundle compiled to a binary is called once built: what the artifact names, else // the package it is built from, else the artifact's own name (novox/hq 04-ISSUES/142). Empty for a // language that does not compile to one. The builder writes the binary under this name, and the // composer runs it by it, so both ask here. func BinaryOf(a Artifact) string { if !compilesToABinary(a.Language) { return "" } if name := strings.TrimSpace(a.Binary); name != "" { return name } if from := strings.Trim(a.From, "./"); from != "" { return path.Base(from) } return a.Name } // runByAResource is the artifacts one of a module's own resources names — a process that runs it, // a step, an archive that unpacks it — by name. func runByAResource(m Manifest) map[string]bool { named := map[string]bool{} for _, r := range m.Resources { if a, ok := r["artifact"].(string); ok && a != "" { named[a] = true } } return named } // BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs, // runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a // bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a // module composed as what runs of it reads again when the runtime already dropped it (issue 1126). func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string { // What the runtime loads: what the artifact said, else every entrypoint of a module // that declares tools, else nothing (the field's own rule; see Artifact.Loads). // // **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213). // A process the host runs is the module's service, not its tools: the controller declares // the verbs it answers as `tools` and serves them itself, and its bundle would otherwise // have been launched a second time by the node's runtime, as an MCP child it is not. loads := append([]string(nil), a.Loads...) if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] { loads = append([]string(nil), a.Entrypoints...) // A bundle compiled to a binary has no entrypoints: the binary is what it is, and what // the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served // as Go — the runtime execs it — exactly as a TypeScript one is through its launcher. if bin := BinaryOf(a); bin != "" { loads = []string{bin} } } return loads } // RunByAResource is the bundles of m one of its own resources runs (its service, not its tools). func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }