From 1363a2fe27a9f686160ce3971f7d6bbe4b82e134 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 04:18:22 +0200 Subject: [PATCH] while-stopped names the container as the machine knows it (hq ADR 0189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module names its own resources locally; a declaration names them under the module. restart-on and reload-on are rewritten for exactly that reason and while-stopped was not, so the store's step said it held "store" still while the machine's container is "distribution.store". The host refuses a declaration naming a container it does not have — whole. So novox took nothing at all, on every push, from 04:15 until this. The machine was never damaged: refusing whole is what kept it serving. Both sides' tests passed throughout. The controller's read manifests, the host's read hand-written declarations with bare ids, and nothing composed one and judged the result. That test now exists. --- internal/catalogue/declaration.go | 9 ++++ internal/catalogue/while_stopped_test.go | 56 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index b2f651b..b21125f 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -907,6 +907,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") + } +}