package resolution import ( "encoding/json" "fmt" "sort" "strings" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) // BundleID names the archive resource that delivers one of a module's bundles; prefixed with the // module like every resource of its own. func BundleID(bundle string) string { return "bundle-" + bundle } // BundlePath is where one module's bundle is unpacked on a machine. func BundlePath(module, bundle string) string { return mcat.BundleRoot + "/" + module + "/" + bundle } // runtimeHere says whether this node's set includes the runtime module, which is what decides // whether anything about tools changes on the machine (to-be 38 WP2): until the runtime is assigned, // a node is sent exactly what it was sent before, bundles included, because a bundle nothing loads // is bytes nobody reads. func resolutionRuntimeHere(r mcat.Resolution) bool { for _, m := range r.Modules { if m.Module == mcat.RuntimeModule { return true } } return false } // bundleArchives is one archive per tools bundle of a module — a bundle the runtime LOADS something // from — as the host fetches and unpacks any artifact (novox/hq ADR 0175 §3: a module brings its // tools as a bundle, delivered by the host like any artifact, never an image). A bundle it loads // nothing from is run rather than loaded: a daemon, a step, the runtime itself — delivered by the // process that runs it, and not again here. // // The source is the kept reference; the per-resource pass that follows routes it through the // artifact store as this network reaches it now, as it does every image and archive the mesh built. func bundleArchives(m mcat.Manifest) []map[string]any { var out []map[string]any for _, b := range m.Bundles { if len(b.Loads) == 0 { continue } out = append(out, map[string]any{ "id": BundleID(b.Name), "type": "archive", "source": b.Source, "digest": b.Digest, "path": BundlePath(m.Module, b.Name), }) } return out } // RuntimeProcessID names the one process the mesh composes for a machine's runtime; prefixed with // the runtime module like a resource of its own, because that module is what the host sees it as. func RuntimeProcessID() string { return "runtime" } // interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the // bundle's entrypoint after it. The one thing the composer takes from a language, and said here // rather than in a manifest because the runtime's process is the mesh's to compose (to-be 38 WP3). func interpreterFor(language string) (string, error) { switch language { case "typescript": return "node", nil } return "", fmt.Errorf( "%s is written in %q, and the mesh knows no interpreter to run a %q bundle with", mcat.RuntimeModule, language, language) } // runtimeProcess is the one process a machine runs the node's tool runtime as (novox/hq ADR 0175, // to-be 38 WP2.3): the runtime module's own bundle, run by its language's interpreter, told which // modules it serves and from which files, where its credential is, and who the machine's operator // is — and restarted when any bundle it loads or the credential it holds changes. // // Composed from the placed manifests, so the credential's path is where this node puts it. The // runtime runs as the operator's account when the machine has one, which is what lets a tool that // needs root escalate as the operator would (ADR 0175 §4); on a machine with no account it runs as // root, and the two operator words are not set. func resolutionRuntimeProcess(r mcat.Resolution, with mcat.Rendering) (map[string]any, error) { var runtime *mcat.Manifest for i := range r.Modules { if r.Modules[i].Module == mcat.RuntimeModule { runtime = &r.Modules[i] } } if runtime == nil || runtime.Stubbed { // Held (its stand-in, Stubbed): its process is never applied, what runs of it is carried, and its stand-in is // never refused by a rule about its bundle or its broker (issue 774: node-tools held for a build not done, then // "its build produced 0 bundle(s)", refused every module of ace, g14 and shanks). return nil, nil } if len(runtime.Bundles) != 1 { return nil, fmt.Errorf( "%s is assigned to %s and its build produced %d bundle(s); the runtime is one bundle "+ "the mesh runs, so the module declares exactly one (novox/hq to-be 38)", mcat.RuntimeModule, r.Node, len(runtime.Bundles)) } bundle := runtime.Bundles[0] // What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own // unpacked bundle; an interpreted one is its language's interpreter and its one entrypoint. var run []any if bundle.Binary != "" { run = []any{"./" + bundle.Binary} } else { if len(bundle.Entrypoints) != 1 { return nil, fmt.Errorf( "%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+ "declares exactly one (novox/hq to-be 38)", mcat.RuntimeModule, bundle.Name, len(bundle.Entrypoints)) } interpreter, err := interpreterFor(bundle.Language) if err != nil { return nil, err } run = []any{interpreter, bundle.Entrypoints[0]} } credential, declared := runtime.OwnSecrets["broker"] if !declared { return nil, fmt.Errorf( "%s declares no own secret named broker, and the node's credential is delivered there: "+ "a module that speaks on the bus declares \"own-secrets\": {\"broker\": }", mcat.RuntimeModule, ) } // What it serves, and from which files: every module on this machine that composes here, in // name order, each bundle it loads from in the order the manifest gave. A module left out of // the declaration — a filter on an adopted machine — is left out of this too, or the runtime // would be told to load files that were never delivered. var served []string var restartOn []string given := map[string]map[string]string{} for _, m := range r.Modules { if with.Adopted && m.Filtering != nil { continue } if m.RunsAs != "" { // Served by a runtime of its own, on its own account (ownRuntimes): never the machine's. continue } words, err := bundleWords(m, with) if err != nil { return nil, err } // The holder of node-files is told the same, and the paths modules own here (novox/hq ADR 0394 §4). if words, err = resolutionFilesWords(r, m, words, with); err != nil { return nil, err } if len(words) > 0 { given[m.Module] = words } for _, b := range m.Bundles { if len(b.Loads) == 0 { continue } for _, load := range b.Loads { // What the runtime starts: the launcher the build wrote beside the entrypoint, where // it wrote one (ADR 0193); the entrypoint itself for a build from before them. if launcher, has := b.Launchers[load]; has { load = launcher } served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load) } restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) } } sort.Strings(served) restartOn = append(restartOn, mcat.RuntimeModule+"."+mcat.NeedID("broker")) sort.Strings(restartOn) env := map[string]string{mcat.RuntimeToolModules: strings.Join(served, ","), mcat.RuntimeBrokerFile: credential.Path} if len(given) > 0 { // Marshalled from maps, whose keys encoding/json sorts: the same words, the same unit. body, err := json.Marshal(given) if err != nil { return nil, err } env[mcat.RuntimeToolEnv] = string(body) } process := map[string]any{ "id": mcat.RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": mcat.RuntimeModule, "source": bundle.Source, "digest": bundle.Digest, "run": run, "env": env, "restart-on": toAny(restartOn), } if r.Account != "" { env[mcat.RuntimeOperatorAccount] = r.Account env[mcat.RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome) process["user"] = r.Account } if agent, home := resolutionAgentAccount(r); agent != "" { env[mcat.RuntimeAgentAccount] = agent env[mcat.RuntimeAgentHome] = home } // Routed through the artifact store as this network reaches it now, like everything the mesh // built; refused with the same words when there is no store to route through. if err := artifactsInto(process, mcat.RuntimeModule, with); err != nil { return nil, err } return process, nil } // NoRuntimeToRunError is a module of its own account with no runtime program on its node to run it from. Held says the // runtime is there and held (its stand-in, Stubbed): the module cannot compose because the runtime does not, so a // composition that holds the runtime holds it too, never the node (issue 774). type NoRuntimeToRunError struct { Held bool said string } func (e *NoRuntimeToRunError) Error() string { return e.said } // ownRuntimes are the processes the modules of their own account are served by (novox/hq ADR 0259 §8): each // the machine's runtime program — the same build, run from the same source — serving that one module alone, // as the module's own account, on the module's own bus credential. Never the machine's runtime, which runs // as the operator's account and carries every module on the machine. func resolutionOwnRuntimes(r mcat.Resolution, with mcat.Rendering) ([]map[string]any, error) { var own []mcat.Manifest for _, m := range r.Modules { // A held module's stand-in (Stubbed) has no runtime of its own composed, and so none of this function's rules // about one: none of its composition is applied, and what runs of it is carried as it runs. Refusing it here // refused its whole node (issue 774, 2026-10-30 21:04: mesh-self-check, held for its broker not made, then // "runs as its own account and declares no own secret broker", failed 74 modules on ace and 79 on novox). if m.RunsAs != "" && !m.Stubbed && !(with.Adopted && m.Filtering != nil) { own = append(own, m) } } if len(own) == 0 { return nil, nil } var runtime *mcat.Manifest for i := range r.Modules { if r.Modules[i].Module == mcat.RuntimeModule { runtime = &r.Modules[i] } } if runtime == nil || len(runtime.Bundles) != 1 || runtime.Bundles[0].Binary == "" { return nil, &NoRuntimeToRunError{Held: runtime != nil && runtime.Stubbed, said: fmt.Sprintf("%s runs as its own "+ "account in a runtime of its own, and %s is not here to run it from: assign %s to %s first (novox/hq ADR 0259)", own[0].Module, mcat.RuntimeModule, mcat.RuntimeModule, r.Node)} } program := runtime.Bundles[0] var out []map[string]any for _, m := range own { // Never the node's operator account, nor the account agents run as there (the review of 2026-10-09): // either would hand what it holds back to the very accounts it is kept from. switch { case r.Account != "" && m.RunsAs == r.Account: return nil, fmt.Errorf("%s runs as %s, the operator's account on %s: a module of its own account never "+ "runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node) case r.AgentAccount != "" && m.RunsAs == r.AgentAccount: return nil, fmt.Errorf("%s runs as %s, the account agents run as on %s: a module of its own account "+ "never runs as it (novox/hq ADR 0259 §8)", m.Module, m.RunsAs, r.Node) } credential, declared := m.OwnSecrets["broker"] if !declared { return nil, fmt.Errorf("%s runs as its own account and declares no own secret broker", m.Module) } var served, restartOn []string for _, b := range m.Bundles { for _, load := range b.Loads { if launcher, has := b.Launchers[load]; has { load = launcher } served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load) } if len(b.Loads) > 0 { restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) } } if len(served) == 0 { return nil, fmt.Errorf("%s runs as its own account and its build produced no bundle to serve", m.Module) } sort.Strings(served) restartOn = append(restartOn, m.Module+"."+mcat.NeedID("broker")) sort.Strings(restartOn) env := map[string]string{mcat.RuntimeToolModules: strings.Join(served, ","), mcat.RuntimeBrokerFile: credential.Path} words, err := bundleWords(m, with) if err != nil { return nil, err } // The holder of node-reads is told where this node keeps its secrets (novox/hq ADR 0310 §5). if words, err = resolutionReadsWords(r, m, words, with); err != nil { return nil, err } // A build seat's holder gives way on its node, and its steps are told their part of the node's share // (novox/hq ADR 0553). yielding, yieldWords := resolutionYieldsOf(r, m, with) if len(yieldWords) > 0 { if words == nil { words = map[string]string{} } for k, v := range yieldWords { words[k] = v } } if len(words) > 0 { body, err := json.Marshal(map[string]map[string]string{m.Module: words}) if err != nil { return nil, err } env[mcat.RuntimeToolEnv] = string(body) } process := map[string]any{ "id": m.Module + "." + mcat.OwnRuntimeID(), "type": "process", "name": m.Module + "-runtime", "source": program.Source, "digest": program.Digest, "run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn), "user": m.RunsAs, } // How it is ready, as the module declared it (novox/hq ADR 0366 §1), in the node-engine's words. if _, has := mcat.OwnRuntimeHealth(m); has { process[mcat.HealthField] = m.RuntimeHealth healthInto(process, m, with) } if yielding != nil { process["yields"] = yielding } if err := artifactsInto(process, mcat.RuntimeModule, with); err != nil { return nil, err } out = append(out, process) } return out, nil } func toAny(in []string) []any { out := make([]any, 0, len(in)) for _, s := range in { out = append(out, s) } return out } // bundleWords is what one module's tools bundles are given on this machine (novox/hq ADR 0192): // each loaded bundle's env, its ${dir:…} resolved to where this machine places the module's // directories and its ${port:…} to the port this machine gave it — the same resolution a // container's environment gets. Two bundles of one module naming one word differently is refused: // the runtime hands a module's words to all its bundles. func bundleWords(m mcat.Manifest, with mcat.Rendering) (map[string]string, error) { var out map[string]string dirs := dirsFor(m, with) for _, b := range m.Bundles { if len(b.Loads) == 0 || len(b.Env) == 0 { continue } for _, word := range sortedKeys(b.Env) { value, err := dirFill(b.Env[word], dirs, m.Module) if err != nil { return nil, err } if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil { return nil, err } if out == nil { out = map[string]string{} } if was, had := out[word]; had && was != value { return nil, fmt.Errorf("%s's bundles give %s two values (%q, %q); a module's words are "+ "handed to all its bundles, so they agree (novox/hq ADR 0192)", m.Module, word, was, value) } out[word] = value } } return out, nil } // givenTo makes what a bundle's words name readable by the account the runtime runs as (novox/hq // ADR 0192): every file and directory of the module whose path a word names, or that holds one, // is owned by the account — a tool reads its configuration and its secret as the account, and a // root-owned 0600 file or a 0700 directory is one it cannot. Only where it says no owner already: // a module that named one knew why. Nothing on a machine with no account, where the runtime is root. // // It answers the files a word names exactly: what a tool reads, whose change the runtime must be // restarted for, as the container the tools came from was restarted when its configuration changed. func givenTo(out []map[string]any, owner map[string]string, words map[string]map[string]string, account string) []string { var named []string if len(words) == 0 { return nil } for _, resource := range out { module := owner[fmt.Sprint(resource["id"])] mine := words[module] if len(mine) == 0 { continue } kind := fmt.Sprint(resource["type"]) if kind != "file" && kind != "directory" { continue } path, _ := resource["path"].(string) if path == "" { continue } for _, value := range mine { if kind == "file" && value == path { named = append(named, fmt.Sprint(resource["id"])) } } if _, said := resource["owner"]; said || account == "" { continue } for _, value := range mine { if value == path || strings.HasPrefix(value, strings.TrimRight(path, "/")+"/") { resource["owner"] = account break } } } sort.Strings(named) return named }