package catalogue import ( "fmt" "sort" "strings" ) // A module depends on the node seats that apply its resources (novox/hq ADR 0207). // // Some of what a module declares is applied through software on the machine that is itself a // module: a service through the service manager, a package through the package manager, a container // through the container runtime. A *capability* only says that software is installed; it does not // say that a module of the mesh holds the role and answers for it. So the dependency is derived from // the resources — never stated in a manifest, because a module that adds a service and forgets a // field would pass — and is met when some module assigned to the same node holds the seat. // The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation, // the seed and the messages all turn on these strings. const ( ServiceManagerSeat = "node-service-manager" PackageManagerSeat = "node-package-manager" ContainerRuntimeSeat = "node-container-runtime" ) // 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": ServiceManagerSeat, "package": PackageManagerSeat, "container": 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 // EnforcingSeatDependencies sets the switch and returns what puts it back. For tests in other // packages that hold the behaviour from before the switch; nothing else calls it. func EnforcingSeatDependencies(on bool) (restore func()) { was := enforceSeatDependencies enforceSeatDependencies = on return func() { enforceSeatDependencies = was } } // 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 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), sorted. // // **The module's own `resources` only.** 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(m 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 } } out := make([]string, 0, len(seen)) for s := range seen { out = append(out, s) } sort.Strings(out) 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 Manifest, seat string) bool { for _, c := range m.Claims { if c.At() != ScopeNode { continue } name := c.Name if s, known := 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]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 } // Unheld is one dependency of one module on a node that nothing on that node holds. type Unheld struct { Node string `json:"node"` Module string `json:"module"` Seat string `json:"seat"` // Holders are the modules in the catalogue that could hold the seat: assigning one meets it. Holders []string `json:"holders"` } // String is the line a refusal and a report both say, so the two never drift. func (u Unheld) String() string { remedy := "and no module in the catalogue claims it yet" if len(u.Holders) > 0 { remedy = "— assign one that holds it: " + strings.Join(u.Holders, ", ") } return fmt.Sprintf("%s on %s depends on %s, which nothing on %s holds (novox/hq ADR 0207) %s", u.Module, u.Node, u.Seat, u.Node, remedy) } // 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]Manifest, node string, set []Manifest, judged map[string]bool) []Unheld { held := map[string]bool{} for _, m := range set { for seat := range seatsApplying() { if claimsSeat(m, seat) { held[seat] = true } } } var out []Unheld for _, m := range set { if judged != nil && !judged[m.Module] { continue } for _, seat := range DependsOn(m) { if held[seat] { continue } out = append(out, 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 } func seatsApplying() map[string]bool { out := map[string]bool{} for _, s := range appliedThrough { out[s] = true } return out } // manifestsOf is the catalogue's definitions of the named modules; a name the catalogue does not // know contributes nothing, as it does to a resolution. func manifestsOf(catalogue map[string]Manifest, names []string) []Manifest { var out []Manifest seen := map[string]bool{} for _, n := range names { if m, known := catalogue[n]; known && !seen[n] { seen[n] = true out = append(out, m) } } return out } // AssignRefusal is why assigning `adding` to a node already running `assigned` is refused, or // nothing (novox/hq ADR 0207 §4): each new module's dependency the node's assignments, the new ones // included, leave unmet. // // **Only the new modules are judged.** A node already short of a holder is reported by `status`; // refusing an unrelated assignment for it would make the remedy — assigning the holder — refused too. // // **A dependency nothing in the catalogue can meet is said, not refused.** A refusal names the // module that would meet it; with none registered there is no remedy to name, and refusing would // stop every assignment of that kind until a module that does not exist yet is written. The answer // still says it, and `status` reports it — before the switch and after it alike: there is never a // remedy to name for it. The first return is those lines. func AssignRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) ([]string, error) { set := manifestsOf(catalogue, append(append([]string(nil), assigned...), adding...)) judged := map[string]bool{} for _, a := range adding { judged[a] = true } var refused, said []string for _, u := range UnheldDependencies(catalogue, node, set, judged) { if len(u.Holders) == 0 { said = append(said, u.String()) continue } refused = append(refused, u.String()) } if len(refused) > 0 { return said, &Refusal{Problems: append(refused, fmt.Sprintf("holders that depend on each other are assigned together: `assign %s …`", node))} } return said, nil } // UnassignRefusal is why taking `removing` off a node running `assigned` is refused, or nothing // (novox/hq ADR 0207): a seat the removed modules hold that nothing left on the node holds, while // a module left there depends on it. Names the dependents, because they are what must go first — // or the holder's replacement come. func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, removing []string) error { gone := map[string]bool{} for _, r := range removing { gone[r] = true } var left []string for _, a := range assigned { if !gone[a] { left = append(left, a) } } before := map[string]bool{} for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, assigned), nil) { before[u.Module+"\x00"+u.Seat] = true } dependents := map[string][]string{} for _, u := range UnheldDependencies(catalogue, node, manifestsOf(catalogue, left), nil) { if before[u.Module+"\x00"+u.Seat] { continue // unmet already; not this removal's doing } dependents[u.Seat] = append(dependents[u.Seat], u.Module) } if len(dependents) == 0 { return nil } seats := make([]string, 0, len(dependents)) for s := range dependents { seats = append(seats, s) } sort.Strings(seats) var problems []string for _, s := range seats { problems = append(problems, fmt.Sprintf( "%s holds %s on %s, and %s depend on it (novox/hq ADR 0207) — unassign them with it, "+ "or assign another holder first", strings.Join(removing, ", "), s, node, strings.Join(dependents[s], ", "))) } return &Refusal{Problems: problems} }