package main import ( "strings" "testing" "time" "github.com/novox/mesh-controller/internal/lease" ) // novox/hq issue 201: on 2026-10-02 a plan sent the control node a controller older than the one running there, // whose seat row the newer one had already widened; the older binary refused the row and the mesh went quiet for // ten minutes. No send but a person's push and a gate's own put-back moves the controller or the node-engine back // behind the build its machine was last sent: the send keeps it there, and carries the rest. // askedIn answers a build from a fixed table: module@commit → when it was asked; failed names those that failed // their gate. func askedIn(table map[string]time.Time, failed ...string) func(module, commit string) (askedBuild, bool, error) { return func(module, commit string) (askedBuild, bool, error) { at, known := table[module+"@"+commit] b := askedBuild{asked: at} for _, f := range failed { if f == module+"@"+commit { b.failedGate = true } } return b, known, nil } } func TestACoreModuleIsKeptOverAnOlderRecord(t *testing.T) { old, now := t0, t0.Add(time.Minute) table := map[string]time.Time{ "mesh-controller@c1": old, "mesh-controller@c2": now, "mesh-host@h1": old, "mesh-host@h2": now, "app@a1": old, "app@a2": now, } sent := map[string]string{"mesh-controller": "c2", "mesh-host": "h2", "app": "a2"} record := map[string]string{"mesh-controller": "c1", "mesh-host": "h1", "app": "a1"} found, err := coreDowngrades("anchor", sent, record, sendScope{judged: map[string]bool{"anchor": true}}, askedIn(table)) if err != nil { t.Fatal(err) } if len(found) != 2 || found[0].module != "mesh-controller" || found[0].component != lease.ComponentController || found[0].sent != "c2" || found[1].module != "mesh-host" || found[1].component != lease.ComponentEngine { t.Fatalf("the controller and the node-engine over an older record are kept, and nothing else: %+v", found) } if said := found[0].String(); !strings.Contains(said, "anchor was last sent mesh-controller c2") || !strings.Contains(said, "`push anchor`") || !strings.Contains(said, "`rebuild mesh-controller`") { t.Fatalf("it says the machine, the build kept and the two ways out: %s", said) } // The record forward, the same build, or a build the records do not hold: nothing kept. for _, c := range [][2]string{{"c1", "c2"}, {"c2", "c2"}, {"c2", "c9"}, {"c9", "c1"}} { found, err := coreDowngrades("anchor", map[string]string{"mesh-controller": c[0]}, map[string]string{"mesh-controller": c[1]}, sendScope{}, askedIn(table)) if err != nil || len(found) != 0 { t.Fatalf("sent %s, record %s: no downgrade proved, nothing kept: %+v %v", c[0], c[1], found, err) } } // A sent build that failed its gate is never ahead: the record put back before it is sent. if found, _ := coreDowngrades("anchor", sent, record, sendScope{}, askedIn(table, "mesh-controller@c2")); len(found) != 1 || found[0].module != "mesh-host" { t.Fatalf("a controller that failed its gate was kept over the build put back: %+v", found) } // A gate's own put-back carries its own modules back, and nothing else. putBack := sendScope{modules: map[string]bool{"mesh-controller": true}, puttingBack: true} if found, _ := coreDowngrades("anchor", sent, record, putBack, askedIn(table)); len(found) != 1 || found[0].module != "mesh-host" { t.Fatalf("a put-back of the controller carries it back, and not the node-engine: %+v", found) } if found, _ := coreDowngrades("anchor", sent, record, sendScope{modules: map[string]bool{"mesh-controller": true}}, askedIn(table)); len(found) != 2 { t.Fatalf("a send for the controller that is no put-back carries it back: %+v", found) } } // core201 is a mesh whose control node, anchor, was sent the newer controller c2 while the mesh's record of the // controller is the build before it, c1, which passed its gate when it rolled out. // TestReplay201 replays the race on a store: the control node runs the newer controller and the mesh's record is // the build before it. A plan's send there — the node-engine's plan of 2026-10-02 — goes, keeps the newer // controller, and says so as a condition; the gate's own put-back carries it back; a send that carries the record, // as a person's push does, clears the condition. // The review of #273: a gate failed the newer controller, put the record back, and the put-back's own send was // refused. The next automatic send — a healer's, which judges nothing — completes the put-back: a build that // failed its gate is never kept. // Review 91 of #273: a gated send to a machine whose controller is kept over an older record leaves the kept // controller out of what its gate judges. Judged as moving back to the record, the gate would read the send's own // kept controller as another build sent there after it (issue 352) and be superseded, judging nothing.