From 2f2a8bb57e9065f1e4fcbd1f94178f8ef4255a8b Mon Sep 17 00:00:00 2001 From: fix-969 Date: Fri, 16 Oct 2026 23:12:16 +0200 Subject: [PATCH 1/3] 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 -- 2.54.0 From 262b39ae0900c38d3b0459e53fbbb296a654307e Mon Sep 17 00:00:00 2001 From: fix-969 Date: Fri, 16 Oct 2026 23:16:38 +0200 Subject: [PATCH 2/3] Test the gate's handing end to end: snapshot, store, and the composition either side (issue 969) --- .../merge_gate_handing_test.go | 57 +++++++++++++++++++ internal/catalogue/handed_bound_test.go | 34 +++++++++++ 2 files changed, 91 insertions(+) diff --git a/cmd/mesh-controller/merge_gate_handing_test.go b/cmd/mesh-controller/merge_gate_handing_test.go index 242aa76f..a5840284 100644 --- a/cmd/mesh-controller/merge_gate_handing_test.go +++ b/cmd/mesh-controller/merge_gate_handing_test.go @@ -1,10 +1,13 @@ package main import ( + "os" "testing" "time" + "github.com/novox/mesh-controller/internal/catalogue" snapshot "github.com/novox/mesh-controller/internal/facts" + "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -26,3 +29,57 @@ func TestIssue969TheGateGivesAMachineItsContractSoItTakesHandedFiles(t *testing. t.Fatalf("a snapshot that carries no contract states nothing: %+v, %v", none, ok) } } + +// Issue 969, through the store: a machine whose engine stated the handed contract is in the snapshot with it, the +// gate raises it as taking handed files, and one whose statement is below it is raised as not taking them. Run in +// the merge check with the other tests that need a store; the composition itself, handed here and refused there, is +// the catalogue's TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded. +func TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + said := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + for node, contract := range map[string]int{"anchor": link.HandedContract, "laptop": link.HandedContract - 1} { + if _, err := open.inventory.RecordHealth(ctx, inventory.NodeHealth{Node: node, Contract: contract, + SaidAt: said, HeardAt: said}); err != nil { + t.Fatal(err) + } + } + f, err := gatherFacts(ctx, open, "2.11.17") + if err != nil { + t.Fatal(err) + } + if len(f.Machines) != 2 { + t.Fatalf("the snapshot holds %d machines", len(f.Machines)) + } + want := map[string]bool{} + for _, m := range f.Machines { + if m.Contract == 0 { + t.Fatalf("%s: the snapshot carries no contract", m.Name) + } + want[m.Name] = m.Contract >= link.HandedContract + } + handing := 0 + for _, hands := range want { + if hands { + handing++ + } + } + if handing != 1 { + t.Fatalf("one machine takes handed files in the snapshot, got %v", want) + } + + raised, drop, err := throwawayStores(ctx, os.Getenv("MESH_TEST_POSTGRES")) + if err != nil { + t.Fatal(err) + } + defer drop() + if _, err := raiseFromFacts(ctx, raised, f, map[string]catalogue.Manifest{}, map[string]inventory.Source{}); err != nil { + t.Fatal(err) + } + for name, hands := range want { + got, err := engineTakesHanded(ctx, raised.inventory, name) + if err != nil || got != hands { + t.Errorf("%s: the gate raises it as taking handed files = %v (%v), the mesh says %v", name, got, err, hands) + } + } +} diff --git a/internal/catalogue/handed_bound_test.go b/internal/catalogue/handed_bound_test.go index 6b7349ba..526f4d29 100644 --- a/internal/catalogue/handed_bound_test.go +++ b/internal/catalogue/handed_bound_test.go @@ -123,3 +123,37 @@ func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *tes t.Fatalf("the plan names %v, want admin and internal-token", names) } } + +// **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a +// provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes +// handed files the client fills it and the module composes; where it does not the controller must fill it from what +// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with +// (ADR 0349 §17: the old way, byte for byte). +func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) { + text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n" + moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, + `"requires":["postgres-database"],"own-secrets":{"token":"${dir:state}/token"}`, 1) + unmoved := strings.Replace(moved, `"moved":true,`, ``, 1) + if unmoved == moved { + t.Fatal("the twin is still marked moved") + } + + c, got := composeWith(t, withDatabase(t, moved, text, true), handingTo("store:5000")) + if strings.Contains(got, "known_host") || len(c.Filled) == 0 { + t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got) + } + + for name, with := range map[string]Rendering{ + "an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, + "no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"}, + } { + _, err := withDatabase(t, moved, text, true).Compose(sealedForBot(with)) + if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) { + t.Fatalf("%s: the key is not refused: %v", name, err) + } + _, before := withDatabase(t, unmoved, text, false).Compose(sealedForBot(with)) + if before == nil || before.Error() != err.Error() { + t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before) + } + } +} -- 2.54.0 From 5890eb104da01d53ded7c5a96106a6b8a2de453f Mon Sep 17 00:00:00 2001 From: fix-969 Date: Fri, 16 Oct 2026 23:18:14 +0200 Subject: [PATCH 3/3] List the gate's store test so it cannot skip unseen (issue 969) --- testdata/store-tests | 1 + 1 file changed, 1 insertion(+) diff --git a/testdata/store-tests b/testdata/store-tests index 9980948a..2bdde702 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -8,6 +8,7 @@ ./cmd/mesh-controller TestAStopWithoutAMergeLeavesItOutOfABatch ./cmd/mesh-controller TestSavingPlansAndMovingMergesIsOneAct ./cmd/mesh-controller TestAPlanFollowedFromTheStoreAnswersWhenItMoves +./cmd/mesh-controller TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself ./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered ./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend ./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore -- 2.54.0