From 2f2a8bb57e9065f1e4fcbd1f94178f8ef4255a8b Mon Sep 17 00:00:00 2001 From: fix-969 Date: Fri, 16 Oct 2026 23:12:16 +0200 Subject: [PATCH] Compose each machine in the merge gate with its engine's contract, so moved modules are handed there as on the mesh (issue 969) --- cmd/mesh-controller/facts.go | 6 ++++ cmd/mesh-controller/merge_gate.go | 16 +++++++++++ .../merge_gate_handing_test.go | 28 +++++++++++++++++++ cmd/mesh-controller/plan.go | 8 +++++- internal/facts/facts.go | 4 +++ 5 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 cmd/mesh-controller/merge_gate_handing_test.go diff --git a/cmd/mesh-controller/facts.go b/cmd/mesh-controller/facts.go index af04f202..f1f38f76 100644 --- a/cmd/mesh-controller/facts.go +++ b/cmd/mesh-controller/facts.go @@ -287,6 +287,12 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot return snapshot.Facts{}, err } m.Architecture, m.Kernel = reported.Architecture, reported.Kernel + // Whether its node-engine takes handed files is its contract (issue 969). + if stated, had, err := inv.HealthOf(ctx, n.Name); err != nil { + return snapshot.Facts{}, err + } else if had { + m.Contract = stated.Contract + } outward, err := inv.OutwardLinksOf(ctx, n.Name) if err != nil { return snapshot.Facts{}, err diff --git a/cmd/mesh-controller/merge_gate.go b/cmd/mesh-controller/merge_gate.go index f4dcdd59..a199c4f4 100644 --- a/cmd/mesh-controller/merge_gate.go +++ b/cmd/mesh-controller/merge_gate.go @@ -1017,6 +1017,13 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m "kernel": m.Kernel, "capabilities": caps}); err != nil { return notes, err } + // What its node-engine takes, as the mesh composes it today (issue 969): without the statement the gate + // composes a moved module the old way on a machine the mesh hands it to. + if stated, ok := standInHealth(m, f.Taken); ok { + if _, err := inv.RecordHealth(ctx, stated); err != nil { + return notes, err + } + } for _, record := range []func(context.Context, string, string) error{inv.RecordSealingKey, inv.RecordOverlayKey} { key, err := standInKey() if err != nil { @@ -1355,3 +1362,12 @@ func firstOr(items, otherwise []string) string { } return "it says nothing more" } + +// standInHealth is the statement the gate gives a machine of the snapshot so that it composes as the mesh does: +// only its contract, which says whether it takes handed files (issue 969). False for a snapshot that carries none. +func standInHealth(m snapshot.Machine, taken time.Time) (inventory.NodeHealth, bool) { + if m.Contract == 0 { + return inventory.NodeHealth{}, false + } + return inventory.NodeHealth{Node: m.Name, Contract: m.Contract, SaidAt: taken, HeardAt: taken}, true +} diff --git a/cmd/mesh-controller/merge_gate_handing_test.go b/cmd/mesh-controller/merge_gate_handing_test.go new file mode 100644 index 00000000..242aa76f --- /dev/null +++ b/cmd/mesh-controller/merge_gate_handing_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + "time" + + snapshot "github.com/novox/mesh-controller/internal/facts" + "github.com/novox/mesh-controller/internal/link" +) + +// Issue 969: the gate composes a machine as the mesh does, so a moved module is handed where the machine takes +// handed files and composed the old way where it does not. +func TestIssue969TheGateGivesAMachineItsContractSoItTakesHandedFiles(t *testing.T) { + taken := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + + stated, ok := standInHealth(snapshot.Machine{Name: "f35", Contract: link.HandedContract}, taken) + if !ok || !takesHanded(stated, ok) { + t.Fatalf("a machine whose engine takes handed files must be given that in the gate: %+v, %v", stated, ok) + } + old, ok := standInHealth(snapshot.Machine{Name: "e12", Contract: link.HandedContract - 1}, taken) + if !ok || takesHanded(old, ok) { + t.Fatalf("a machine whose engine is older must not take handed files: %+v, %v", old, ok) + } + none, ok := standInHealth(snapshot.Machine{Name: "d01"}, taken) + if ok || takesHanded(none, ok) { + t.Fatalf("a snapshot that carries no contract states nothing: %+v, %v", none, ok) + } +} diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index f08199c4..82b58544 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -1126,7 +1126,13 @@ func engineTakesHanded(ctx context.Context, inv *inventory.Inventory, node strin if err != nil { return false, err } - return had && stated.Contract >= link.HandedContract, nil + return takesHanded(stated, had), nil +} + +// takesHanded is whether a machine's newest statement (had: it ever stated one) is of an engine that writes a +// filled file on the provisioner client's word alone. +func takesHanded(stated inventory.NodeHealth, had bool) bool { + return had && stated.Contract >= link.HandedContract } // provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 §4): the user diff --git a/internal/facts/facts.go b/internal/facts/facts.go index be55f445..6526db66 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -112,6 +112,10 @@ type Machine struct { // NodeEngine and NodeTools are the builds it runs. NodeEngine string `json:"node-engine,omitempty"` NodeTools string `json:"node-tools,omitempty"` + // Contract is the contract of the node-engine's newest health statement (0 when it never stated one): what + // says whether the machine takes handed files (novox/hq issue 969). A check that composed it without would + // judge a moved module the old way where the mesh hands it. + Contract int `json:"contract,omitempty"` // Capabilities are what it reported it can do; the detail kept only where it is a version. Capabilities []Capability `json:"capabilities,omitempty"` // Site, Hub, Public: where it is on the private network — its site (a pseudonym), whether it is the