diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 2f79b1b..cfe1df5 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -278,6 +278,10 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st if need, wait := at.mustAsk(node, asked, hadLive, ran, running.FP, running.Composed == "", d); wait { if need != nil { out.Record = append(out.Record, *need) + } else if !hadLive && ran && runs(running) { + // The bus, which the assigner never moves (§15), runs here with no want: it is on neither list until the operator + // writes its want, and the pass says why (issue 1945). + out.Neither[node] = Holding{Rule: delivery.RuleComposed, Words: busMoveWords(m.Name, node, target, d)} } continue } @@ -443,6 +447,17 @@ func (m Module) mustAsk(node string, before delivery.Want, had, ran bool, runnin return &RecordNeed{Node: node, Digest: d.Digest, Judgement: d.Judgement, Words: d.Words}, true } +// busMoveWords say why the bus module's want at fp is not written on node: its composition differs from what the node runs in more +// than the bus's user list, so it is a move of the bus, which the operator writes (ADR 0539 §15). +func busMoveWords(module, node, fp string, d delivery.ComposedDigest) string { + why := string(d.Judgement) + if w := strings.TrimSpace(d.Words); w != "" { + why += ": " + w + } + return fmt.Sprintf("the composition of %s for %s at %s changes more than the bus's user list (judged %s), so it is a move of the bus, "+ + "which the assigner never makes: the operator writes its want (ADR 0539 §15)", module, node, short(fp), why) +} + // recorded says whether the module is of policy record: as the controller holds it beside the composition's digest, the // operator's own choice included (ADR 0539 §14); on a composition made by hand, which carries none, as the manifest derives it. func (m Module) recorded(d delivery.ComposedDigest) bool { @@ -456,11 +471,28 @@ func (m Module) recorded(d delivery.ComposedDigest) bool { // userListRevision is the want that names the bus module's composition at the fingerprint the node runs when that composition // differs from the one the node runs only in the bus's user list (ComposedDigest.UserListOnly), and whether there is one. A node // whose want names another fingerprint than the one it runs, or a module that does not provide the bus, has none. +// +// A node whose have is of the old form (from the old node-engine: a build and no fingerprint of the new form, no composition) runs +// no fingerprint a composition is kept at, so its revision names F, the newest fingerprint, when the controller judged F's +// composition to differ from the declaration the node last applied only in the bus's user list (ADR 0539 §7, ADR 0540 §9). Any +// other difference is a move of the bus and stays the operator's (§15). Without this the bus never got its first want after the +// switch, and its user list was never written again (issue 1945: novox's nats from 11:23 on 2026-10-25). func (m Module) userListRevision(node string, before delivery.Want, had bool, n Nodes) (delivery.Want, bool) { if !catalogue.ProvidesBus(m.Manifest) { return delivery.Want{}, false } running, ran := n.Haves[node][m.Name] + if ran && oldForm(running) && m.FP != "" && (!had || before.Removed || before.FP == m.FP) { + d, ok := m.composition(node, m.FP) + if !ok || !d.UserListOnly { + return delivery.Want{}, false + } + w := delivery.Want{FP: m.FP, Composed: d.Digest} + if had && !before.Removed && before.Composed == d.Digest { + w.Backup, w.Words = before.Backup, before.Words + } + return w, true + } if !ran || running.FP == "" || (had && (before.Removed || before.FP != running.FP)) { return delivery.Want{}, false } @@ -475,6 +507,13 @@ func (m Module) userListRevision(node string, before delivery.Want, had bool, n return w, true } +// oldForm says whether a have is of the old form: the old node-engine's, naming a build and no fingerprint of the new form, and no +// composition (issue 1945: novox's nats, build-1792674595610176744). +func oldForm(h delivery.Have) bool { + return h.Phase != delivery.PhaseRemoved && h.Composed == "" && (h.FP != "" || h.Build != "") && + !strings.HasPrefix(h.FP, delivery.SourcePrefix2) +} + // GrantNamesNode says whether a grant of module names its accounts on node: its own `.` or a process's // `..`. A grant that names no account (a module that does not speak on the bus) names every node. func GrantNamesNode(g delivery.ControllerGrant, node, module string) bool { diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 775d53c..d12ec36 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -498,8 +498,53 @@ func TestAUserListOnlyRevisionOfTheBusIsWrittenAtOnceBeforeTheRecordHold(t *test t.Errorf("%s: the user-list revision was asked: %+v", name, out.Record) } } + revision := m.Composed + // Issue 1945: a have of the old form (a build, no fingerprint of the new form, no composition) runs no fingerprint a composition is + // kept at. The revision then names F when the controller judged F's composition to differ from the declaration the node last + // applied only in the user list; any other difference is a move of the bus, refused by §15 and said on neither list. + for name, c := range map[string]struct { + userListOnly bool + wanted map[string]delivery.Want + want *delivery.Want // nil: no want written + neither bool + }{ + "old form, user list only, no want": {userListOnly: true, want: &delivery.Want{FP: fNew, Composed: userList}}, + "old form, user list only, a removed want": {userListOnly: true, wanted: map[string]delivery.Want{"ace": {Removed: true}}, want: &delivery.Want{FP: fNew, Composed: userList}}, + "old form, user list only, written before": {userListOnly: true, wanted: map[string]delivery.Want{"ace": {FP: fNew, Composed: userList}}}, + "old form, a real move of the bus": {neither: true}, + } { + m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}} + m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + if fp != fNew { + return delivery.ComposedDigest{}, false + } + if c.userListOnly { + return delivery.ComposedDigest{Digest: userList, Judgement: delivery.JudgedNeither, UserListOnly: true, Against: dig(fOld), + Words: "only the bus's user list changes, against the declaration ace last applied", Policy: delivery.PolicyRecord}, true + } + return delivery.ComposedDigest{Digest: dig(fNew), Judgement: delivery.JudgedNeither, Words: "the node runs no composition of mail", + Policy: delivery.PolicyRecord}, true + } + n.Haves["ace"]["mail"] = delivery.Have{Build: "build-1792674595610176744", Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + out := Wants(m, n, c.wanted, nil) + w, ok := out.Wants["ace"] + switch { + case c.want == nil && ok: + t.Errorf("%s: a want was written: %+v", name, w) + case c.want != nil && (!ok || w.FP != c.want.FP || w.Composed != c.want.Composed || w.Removed): + t.Errorf("%s: ace's want = %+v (written %v), want %+v", name, w, ok, *c.want) + } + if len(out.Record) != 0 { + t.Errorf("%s: asked: %+v", name, out.Record) + } + h, on := out.Neither["ace"] + if on != c.neither || (on && (!strings.Contains(h.Words, "§15") || !strings.Contains(h.Words, "more than the bus's user list"))) { + t.Errorf("%s: on neither list %v (%+v), want %v with the words of §15", name, on, h, c.neither) + } + } // the same flag on a module that is not the bus changes nothing - m.Manifest.Provides = nil + m.Manifest.Provides, m.Composed = nil, revision + n.Haves["ace"]["mail"] = delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} if out := Wants(m, n, map[string]delivery.Want{"ace": {FP: fOld, Composed: dig(fOld)}}, nil); out.Wants["ace"].Composed == userList { t.Errorf("a module that is not the bus was written at once: %+v", out.Wants["ace"]) }