diff --git a/cmd/mesh-controller/core_downgrade.go b/cmd/mesh-controller/core_downgrade.go index e4b9885d..cae82843 100644 --- a/cmd/mesh-controller/core_downgrade.go +++ b/cmd/mesh-controller/core_downgrade.go @@ -11,8 +11,10 @@ import ( "github.com/novox/mesh-controller/internal/lease" ) -// **No send but a person's push and a gate's own put-back moves the mesh's own core to a build older than the -// one its machine was last sent** (novox/hq issue 201). On 2026-10-02 two merges a minute apart made two plans; +// **No send but a person's `push ` and a gate's own put-back moves the mesh's own core to a build older +// than the one its machine was last sent** (novox/hq issue 201). Every send made through sendToEach keeps it — a +// plan's, a release's, a healer's, a rotation's, and the bus step too, which is a person's act but composes under +// sendKeeps like the rest. On 2026-10-02 two merges a minute apart made two plans; // the controller's plan rolled the new controller, which widened its seat's row, and the node-engine's plan then // sent the control node the controller build before it. The older binary read the row it could not serve and the // mesh lost its voice for ten minutes — the thing that would have corrected it was the thing that was down. ADR @@ -21,7 +23,8 @@ import ( // // **Kept, not refused.** The send composes the controller or the node-engine at the build the machine was last // sent, as a recorded module is kept (ADR 0245), and carries everything else: no other module, secret, healer or -// walk waits on it, and nothing is retried. A plan whose own build is the older one finds its gate superseded by +// walk waits on it, and nothing is retried. The kept move is no move for a gate either: ungatedIn and gatedSend +// leave it out (withoutKept), so a gate judges the other moves of its send. A plan whose own build is the older one finds its gate superseded by // the build kept there (issue 352) and ends. The mesh's record stays older, and a warning condition says so until // a send carries the record — a person's `push ` accepts the older build — or the record moves forward: a // newer merge, or `rebuild mesh-controller`. diff --git a/cmd/mesh-controller/issue201_test.go b/cmd/mesh-controller/issue201_test.go index b601ecda..2e9c5c91 100644 --- a/cmd/mesh-controller/issue201_test.go +++ b/cmd/mesh-controller/issue201_test.go @@ -197,3 +197,63 @@ func TestARollbackWhoseSendWasRefusedIsCompletedByTheNextSend(t *testing.T) { t.Fatalf("a completed put-back raised a condition: %+v", open) } } + +// 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. +func TestAGateJudgesTheOtherMovesBesideAKeptCore(t *testing.T) { + open := core201(t) + ctx := t.Context() + withConditionsInMemory(t) + inv := open.inventory + old, now := time.Now().Add(-2*time.Hour), time.Now().Add(-time.Minute) + for _, b := range []struct { + commit string + at time.Time + }{{"a1aaaaaaaaaa", old}, {"a2bbbbbbbbbb", now}} { + manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"}) + if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-" + b.commit, Module: "app", Commit: b.commit, + Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: b.at, At: b.at, + Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-" + b.commit}}}); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{ + Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: b.commit, Head: b.commit, + Asked: b.at}); err != nil { + t.Fatal(err) + } + } + if _, err := inv.Assign(ctx, "anchor", "app"); err != nil { + t.Fatal(err) + } + if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor-2", + map[string]string{"mesh-controller": "c2bbbbbbbbbb", "app": "a1aaaaaaaaaa"}); err != nil { + t.Fatal(err) + } + // The send as it is made: the controller kept at c2, the app moved to the record. + wasSend := sendRollout + sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) { + return names, open.inventory.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor-3", + map[string]string{"mesh-controller": "c2bbbbbbbbbb", "app": "a2bbbbbbbbbb"}) + } + t.Cleanup(func() { sendRollout = wasSend }) + + moves, sent, _, err := gatedSend(ctx, open, "anchor", nil) + if err != nil || len(sent) != 1 { + t.Fatalf("the gated send is made: %v %v", sent, err) + } + if len(moves) != 1 || moves[0].Module != "app" || moves[0].To != "a2bbbbbbbbbb" { + t.Fatalf("the gate judges the app's move and not the kept controller: %+v", moves) + } + builds, _, err := inv.SentBuilds(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + g := &inventory.PlanGate{Carried: moves} + f := gateFacts{commits: judgedCommits(g, nil), sentBuilds: map[string]map[string]string{"anchor": builds}} + for _, m := range []string{"app", "mesh-controller"} { + if why, superseded := f.supersededOn(m, "anchor"); superseded { + t.Fatalf("the gate is superseded by its own send: %s", why) + } + } +} diff --git a/cmd/mesh-controller/release.go b/cmd/mesh-controller/release.go index ab21b05a..a97cf4a7 100644 --- a/cmd/mesh-controller/release.go +++ b/cmd/mesh-controller/release.go @@ -240,6 +240,32 @@ var machineMoves = func(ctx context.Context, open *stores, f moveFacts, node str return f.moves(node, modules, sent, known, all), nil } +// withoutKept is a machine's moves less those its send keeps at the build the machine was last sent, and so does +// not make: the grants step keeps every module (novox/hq issue 490), and every send keeps the controller and the +// node-engine over an older record (issue 201). Read as the send composes it (sendKeeps, set after the checks that +// call this), so a move it could not keep is still counted. +func withoutKept(ctx context.Context, open *stores, node string, moves []inventory.CarriedMove) ([]inventory.CarriedMove, error) { + if len(moves) == 0 { + return moves, nil + } + keeping := ctx + if _, on := keptExcept(ctx); !on { + keeping = keepingRecorded(ctx) + } + keeps, err := recordedKept(keeping, open, node) + if err != nil || len(keeps) == 0 { + return moves, err + } + var out []inventory.CarriedMove + for _, mv := range moves { + if was, kept := keeps[mv.Module]; kept && sameCommit(was, mv.From) { + continue + } + out = append(out, mv) + } + return out, nil +} + // ungatedIn refuses a send whose machines would move a build no gate has seen, outside its scope: the // machines named, and why; the machine holding the bus, added only for its user list, is left instead. func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder string) ([]string, error) { @@ -257,22 +283,11 @@ func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder st if err != nil { return nil, err } - // The grants step composes every module at the build its machine was last sent (novox/hq issue 490), - // and every send keeps the controller and the node-engine over an older record (issue 201): a move it - // keeps is not made. Read, not assumed, so a move it could not keep is still refused here. - keeping := ctx - if _, on := keptExcept(ctx); !on { - keeping = keepingRecorded(ctx) // as the send composes it (sendKeeps), which is set after this check - } - keeps, err := recordedKept(keeping, open, n) - if err != nil { + if moves, err = withoutKept(ctx, open, n, moves); err != nil { return nil, err } var waiting []string for _, mv := range moves { - if was, kept := keeps[mv.Module]; kept && sameCommit(was, mv.From) { - continue - } if !scope.judged[n] && !scope.modules[mv.Module] { waiting = append(waiting, fmt.Sprintf("%s %s → %s", mv.Module, short(mv.From), short(mv.To))) } @@ -314,6 +329,11 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory. if err != nil { return nil, nil, nil, err } + // A move the send keeps is not made, and is no move for the gate to judge (novox/hq issue 201): a core module + // kept over an older record judged as moving back to it would read the gate as superseded by its own send. + if moves, err = withoutKept(ctx, open, node, moves); err != nil { + return nil, nil, nil, err + } own := func(module string) bool { return slices.ContainsFunc(owns, func(o inventory.CarriedMove) bool { return o.Module == module }) } diff --git a/testdata/store-tests b/testdata/store-tests index 0d195b8c..c0d0515a 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -31,3 +31,6 @@ ./cmd/mesh-controller TestADirectoryOwnersKeyIsRefusedThroughAVerb ./cmd/mesh-controller TestTheSendMakesNoValueForAnOutsideSecretNobodyGave ./cmd/mesh-controller TestAQuestionComposesWithoutAnOutsideSecretNobodyGave +./cmd/mesh-controller TestReplay201 +./cmd/mesh-controller TestARollbackWhoseSendWasRefusedIsCompletedByTheNextSend +./cmd/mesh-controller TestAGateJudgesTheOtherMovesBesideAKeptCore