package resolution import ( "fmt" "sort" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) // appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207 // names them and no more. A process is supervised by the host itself, a file, a directory, an // archive, a user or an action is the host's own act, and a module's other kinds reach the machine // without a role in between — adding one here is a decision, not a refinement. var appliedThrough = map[string]string{ "service": mcat.ServiceManagerSeat, "package": mcat.PackageManagerSeat, "container": mcat.ContainerRuntimeSeat, } // enforceSeatDependencies is the one-line switch ADR 0207 §4 names. Off, an unmet dependency at // composition is *reported* — in the resolution, in `status`, once in the log — and the node still // resolves; on, it is refused like any unresolved requirement. Off until `status` reports none, // which is when the three holders are assigned to every node: switching it before then would stop // every machine lacking one from being sent anything at all. // // Switched on 2026-10-04, when `status` first reported no unmet dependency on any node: systemd, // pacman and docker were assigned to all four machines that afternoon (novox/hq to-be 42). // // A variable rather than a constant only so a test can hold both behaviours; nothing else sets it. var enforceSeatDependencies = true // foundationModules are the pieces genesis lays before any module exists (novox/hq ADR 0207 §5): // the host and the private network. Registered as modules so they can be assigned, but what they // declare is the installation's, not a module's, so it is never judged. The third piece, the // bootstrap container runtime, is not a module at all: its package and service are in the genesis // bundle the host applies itself, and never pass through a resolution here. // // The private network's module is overlay.Name, written out because the overlay package composes // on top of this one; its resources are computed, which exempts it by the rule below as well. var foundationModules = map[string]bool{ "mesh-host": true, "mesh-wireguard": true, } // isFoundation is whether a module's declarations are the foundation's rather than its own. A // module whose resources are computed is the mesh's by construction — the private network's peer // list and its tools are the controller's, written per node — so it counts whatever its name. func isFoundation(m mcat.Manifest) bool { return foundationModules[m.Module] || m.Computed != "" } // DependsOn is every seat a module needs held on its node: derived from the resource types it // declares itself (novox/hq ADR 0207 §2), from what it contributes to (ADR 0210), and from what a // node seat it claims needs beside it — read from whichever manifest in `catalogue` declares that // seat (novox/hq ADR 0220, ADR 0378 §8). Sorted. // // **The module's own `resources` only**, for the first source. What the controller composes around // a module — its filter, jails, certificates, kept files, bundles, the guard — is the mesh's, put // there because the module is assigned, and depending on it would make the module answer for the // mesh's choices. func DependsOn(catalogue map[string]mcat.Manifest, m mcat.Manifest) []string { if isFoundation(m) { return nil } seen := map[string]bool{} for _, r := range m.Resources { if seat, applied := appliedThrough[fmt.Sprint(r["type"])]; applied { seen[seat] = true } } for _, seat := range contributedTo(m) { seen[seat] = true } for _, seat := range neededBesideClaims(catalogue, m) { seen[seat] = true } out := make([]string, 0, len(seen)) for s := range seen { out = append(out, s) } sort.Strings(out) return out } // contributedTo is every seat a module contributes to (novox/hq ADR 0210 §3): a contribution is // configuration only the seat's holder applies, so it is a dependency on that seat exactly as a // resource is on the seat that applies it. The environment goes to node-environment's holder // (ADR 0203); shell code to the holder that places it for its target — the login shell's for a // shell, the display server's for the session's start and resources (ADR 0204, ADR 0208 §4). func contributedTo(m mcat.Manifest) []string { var out []string if e := m.Environment; e != nil && (len(e.Variables) > 0 || len(e.Path) > 0) { out = append(out, mcat.EnvironmentSeat) } for _, c := range m.Shell { out = append(out, placerOf(c.For)) } // Any other seat's contribution (novox/hq ADR 0212 §4), by the seat's canonical name; one the // mesh does not define is refused at registration and depends on nothing here. // A kind the seat only offers to place makes no dependency (novox/hq ADR 0255). for _, c := range m.Contributions { if s, r, ok := receivable(c.Seat, c.Kind); ok && r.Offered { continue } else if s.Name != "" { out = append(out, s.Name) } } // And the backup holder for what is irreplaceable (novox/hq ADR 0233): it backs it up or watches the // array it is on. What is valuable is backed up where a holder is — the standard plan — and makes // no machine refuse it for want of one. if m.NeedsBackupHolder() { out = append(out, mcat.BackupSeat) } return out } // neededBesideClaims is every seat a node seat the module claims needs held on the same node // (novox/hq ADR 0220, ADR 0378 §8): a module-declared seat's needs are read from whichever manifest // in the catalogue declares that seat, never written again in the claimant's own manifest. Derived // from the claim, as a resource's seat is derived from its type, so a module that claims the seat // cannot leave the dependency out. A compiled seat carries no needs (novox/hq ADR 0378 §8). func neededBesideClaims(catalogue map[string]mcat.Manifest, m mcat.Manifest) []string { var out []string for _, seat := range nodeSeatsClaimed(m) { for _, candidate := range catalogue { for _, s := range candidate.DefinesSeats { if s.Name == seat { out = append(out, s.Needs...) } } } } return out } // claimsSeat is whether a module claims a node seat, by its current name or one it used to have // (ADR 0122), so a rename leaves the dependency met. func claimsSeat(m mcat.Manifest, seat string) bool { for _, c := range m.Claims { if c.At() != mcat.ScopeNode { continue } name := c.Name if s, known := mcat.SeatNamed(name); known { name = s.Name } if name == seat { return true } } return false } // PossibleHolders is every module in the catalogue that claims a seat at node scope — what a // refusal names as the remedy. func PossibleHolders(catalogue map[string]mcat.Manifest, seat string) []string { var out []string for name, m := range catalogue { if claimsSeat(m, seat) { out = append(out, name) } } sort.Strings(out) return out } // UnheldDependencies is every dependency of the modules in `judged` that the node's whole set // leaves unmet (novox/hq ADR 0207 §3). // // **Judged over the whole set, never one module at a time.** The holders depend on each other: // the service manager's own package needs the package manager, and the package manager's timer // needs the service manager. Asked one by one, neither could ever be first; asked of the set, the // two assigned together meet each other. A module holding a seat it depends on meets its own // dependency. `judged` nil judges every module of the set. func UnheldDependencies(catalogue map[string]mcat.Manifest, node string, set []mcat.Manifest, judged map[string]bool) []mcat.Unheld { held := map[string]bool{} for _, m := range set { for _, seat := range nodeSeatsClaimed(m) { held[seat] = true } } var out []mcat.Unheld for _, m := range set { if judged != nil && !judged[m.Module] { continue } for _, seat := range DependsOn(catalogue, m) { if held[seat] { continue } out = append(out, mcat.Unheld{Node: node, Module: m.Module, Seat: seat, Holders: PossibleHolders(catalogue, seat)}) } } sort.Slice(out, func(i, j int) bool { if out[i].Module != out[j].Module { return out[i].Module < out[j].Module } return out[i].Seat < out[j].Seat }) return out } // nodeSeatsClaimed is every node seat a module claims, each by its current name (ADR 0122), so // a dependency on any of them — a resource's or a contribution's — is met by the claim. func nodeSeatsClaimed(m mcat.Manifest) []string { var out []string for _, c := range m.Claims { if c.At() != mcat.ScopeNode { continue } name := c.Name if s, known := mcat.SeatNamed(name); known { name = s.Name } out = append(out, name) } return out }