diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 6bc1ce2..473f8fe 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -921,6 +921,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string, if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil { copied["reload-on"] = renamed } + // And which of its module's containers a scheduled step holds still (novox/hq ADR 0189). + // **The loudest of the three when it is missed.** An unprefixed `restart-on` matches + // nothing and a service quietly never restarts; an unprefixed `while-stopped` names a + // container the declaration does not contain, and the host refuses the whole + // declaration — so the machine takes nothing at all, for every push, until this is + // right. That is what it did on the control node (2026-10-04). + if renamed := reflectsRenamed(m.Module, resource[WhileStopped]); renamed != nil { + copied[WhileStopped] = renamed + } // And what a process replaces (novox/hq issue 213): a resource of this module's that it // no longer declares, named as the host recorded it, or the host hands nothing over and // removes it first. diff --git a/internal/catalogue/while_stopped_test.go b/internal/catalogue/while_stopped_test.go index 39442a8..bec7a05 100644 --- a/internal/catalogue/while_stopped_test.go +++ b/internal/catalogue/while_stopped_test.go @@ -2,6 +2,7 @@ package catalogue import ( "encoding/json" + "fmt" "strings" "testing" ) @@ -87,3 +88,58 @@ func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testi } } } + +// A composed declaration names the step's held containers the way the machine knows them. +// +// **The gap that let a bug through to the control node.** The manifest says `while-stopped: +// ["store"]`, because a module names its own resources locally; the declaration a machine +// receives calls that container `distribution.store`, because every resource is composed under +// its module. `restart-on` and `reload-on` are rewritten for exactly this reason, and +// `while-stopped` was not — so the host found no container by that id and refused the whole +// declaration, every push, until it was fixed. +// +// It passed every test on both sides: the controller's tests read manifests, the host's read +// hand-written declarations with bare ids. Only composing one and judging the result catches it. +func TestAComposedWindowNamesTheContainerAsTheMachineKnowsIt(t *testing.T) { + store := Manifest{ + Module: "distribution", Version: "1", + Provides: FromAnywhere("artifact-store"), + Listens: []Listening{{Port: 5000, Protocol: "tcp", From: FromMesh}}, + Serves: map[string]map[string]any{"artifact-store": {"port": 5000}}, + Resources: []map[string]any{ + {"id": "store", "type": "container", "name": "mesh-registry", + "image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"5000"}}, + {"id": "collect", "type": "container", "name": "mesh-registry-collect", + "image": "registry@sha256:" + strings.Repeat("a", 64), + "schedule": "30 3 * * *", WhileStopped: []any{"store"}}, + }, + } + r, err := Resolve(shelf(store), []string{"distribution"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + collect := fileNamed(out, "distribution.collect") + if collect == nil { + for _, res := range out { + if res["id"] == "distribution.collect" { + collect = res + } + } + } + if collect == nil { + t.Fatalf("the step was not composed at all: %v", out) + } + held, _ := collect[WhileStopped].([]any) + if len(held) != 1 { + t.Fatalf("the composed step holds %v still; want one container", collect[WhileStopped]) + } + if got := fmt.Sprint(held[0]); got != "distribution.store" { + t.Fatalf("the composed step says it holds %q still, and the machine's container is "+ + "called %q — the host refuses a declaration naming a container it does not have, "+ + "whole, so the machine would take nothing at all", got, "distribution.store") + } +}