Compare commits

...
Author SHA1 Message Date
jschoubben 1cfe6be9c4 The move ends with the old broker going, not staying
`rollout check` said the old broker stays running as an ordinary provider of
amqp, and this was not its retirement. That was ADR 0127, which ADR 0131 has
superseded: AMQP is not a provision, so once every machine reports on the new bus
nothing of the mesh speaks to the old broker and its module is unassigned. The
plan says so, as its last step.

The flag that made the "it stays" line conditional is gone with the line — there
is no case in which the broker is kept. The test that pinned the opposite now
pins this, and says which record changed under it. The stale citation of a
record numbered 0119 is corrected while here.
2026-09-27 23:04:59 +02:00
jschoubben 4e4481b6f2 Merge pull request 'The store owns the seat set, so only the control plane may judge a claim' (#89) from fix/the-store-owns-the-seat-set into main 2026-09-27 20:00:05 +00:00
jschoubben 63ca073938 The store owns the seat set, so only the control plane may judge a claim
A claim on a seat was checked against `SeatNamed` inside `ParseManifest`, and the
build machine parses manifests too. It has no store, so there it answered from the
set compiled into the binary — a copy of data the control plane owns (ADR 0122).

When the two disagreed, that copy won where it mattered. The store's row said the
bus seat answers for `amqp`; the binary's said `mesh-bus`; and a holder that
provides `amqp` was refused at build time for not providing `mesh-bus`. The seat
went unheld, the controller lost the address it composes through that seat, and the
control plane crash-looped on a bus that was healthy the whole time.

So the two checks that read the set — a seat's scope, and what its holder must
provide — move to CatalogueProblems, which runs only in the control plane and only
after UseSeats has replaced the set with the store's. The parser keeps what it can
judge from the manifest alone, the reserved-namespace rule included.

A test pins it: the same manifest, two different values in the store, and the answer
follows the store both times. It fails if the check moves back.
2026-09-27 21:59:40 +02:00
jschoubben b244a768a3 Merge pull request 'The Go base has to be 1.26: the NATS client requires it' (#88) from fix/go-126-base into main 2026-09-27 19:00:32 +00:00
jschoubben f6a93fe74c Merge pull request 'The bus on NATS: both transports behind seams, and the rollout switch' (#87) from feat/nats-genesis into main 2026-09-27 17:36:41 +00:00
7 changed files with 99 additions and 43 deletions
+5 -6
View File
@@ -25,11 +25,11 @@ import (
// against a mesh that is serving. It answers from records: what is missing, and what would happen. // against a mesh that is serving. It answers from records: what is missing, and what would happen.
// `rollout` itself refuses unless the check is clean. // `rollout` itself refuses unless the check is clean.
// //
// **The old broker is not switched off by this.** It stays an ordinary provider of `amqp` for whatever // **The old broker goes with the move, and goes last** (novox/hq ADR 0131): AMQP is not a provision,
// else uses it — on this installation, a whole automation layer that has nothing to do with the mesh // so once every machine reports on the new bus its module is unassigned. Only the mesh's own traffic
// ([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)). Only the mesh's own // is what moves, which is why this is survivable at all: what breaks if it goes wrong is the mesh's
// traffic moves, which is why this is survivable at all: what breaks if it goes wrong is the mesh's // ability to change things, not the services its modules are serving — measured on 2026-09-27, when
// ability to change things, not the services its modules are serving. // a seat emptied mid-change and the control plane looped for two hours while every service stayed up.
const rolloutUsage = "rollout check | rollout --confirm" const rolloutUsage = "rollout check | rollout --confirm"
@@ -105,7 +105,6 @@ func readinessOf(ctx context.Context, inv *inventory.Inventory) (broker.Readines
ModuleCredentialled: map[string]bool{}, ModuleCredentialled: map[string]bool{},
// The old broker keeps its other clients on this installation, and saying so is how the plan // The old broker keeps its other clients on this installation, and saying so is how the plan
// stops reading as a retirement. // stops reading as a retirement.
OldBusHasOtherClients: true,
} }
address, _, err := broker.OnNATS() address, _, err := broker.OnNATS()
+5 -8
View File
@@ -35,10 +35,6 @@ type Readiness struct {
Modules []string Modules []string
// ModuleCredentialled is which of those has one. // ModuleCredentialled is which of those has one.
ModuleCredentialled map[string]bool ModuleCredentialled map[string]bool
// StillOnTheOldBus is whether anything of the mesh's own still needs the bus it is leaving —
// which is not a reason to stop, because that broker stays as an ordinary provider of `amqp`
// (ADR 0119). Recorded so nobody reads the move as a retirement.
OldBusHasOtherClients bool
} }
// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them. // NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them.
@@ -120,10 +116,11 @@ func WhatMoves(r Readiness) []string {
out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers", out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers",
len(r.Modules))) len(r.Modules)))
} }
if r.OldBusHasOtherClients { // **The old broker goes, and it goes last** (novox/hq ADR 0131). AMQP is not a provision, so once
out = append(out, "leave the old broker running: it stays an ordinary provider of `amqp` for "+ // every machine reports on the new bus nothing of the mesh is left speaking to it, and its module
"whatever else uses it (ADR 0119), and this move is not its retirement") // is unassigned. Said as a step so nobody reads the move as leaving a second bus behind.
} out = append(out, "then unassign the old broker's module: AMQP is not a provision (ADR 0131), and "+
"once every machine reports on the new bus nothing of the mesh speaks to it")
return out return out
} }
+7 -6
View File
@@ -79,9 +79,8 @@ func TestEachThingMissingNamesItsOwnRemedy(t *testing.T) {
// What the move would do is written out rather than summarised, because this is the one step with // What the move would do is written out rather than summarised, because this is the one step with
// nothing to inspect afterwards — so reading it is the last chance to disagree. // nothing to inspect afterwards — so reading it is the last chance to disagree.
func TestWhatMovesNamesEveryMachineAndSaysTheOldBrokerStays(t *testing.T) { func TestWhatMovesNamesEveryMachineAndEndsWithTheOldBrokerGoing(t *testing.T) {
r := aMeshReadyToMove() r := aMeshReadyToMove()
r.OldBusHasOtherClients = true
steps := strings.Join(WhatMoves(r), "\n") steps := strings.Join(WhatMoves(r), "\n")
for _, want := range []string{"anchor", "laptop", "user list", "module runtime"} { for _, want := range []string{"anchor", "laptop", "user list", "module runtime"} {
@@ -89,9 +88,11 @@ func TestWhatMovesNamesEveryMachineAndSaysTheOldBrokerStays(t *testing.T) {
t.Errorf("the plan does not mention %q:\n%s", want, steps) t.Errorf("the plan does not mention %q:\n%s", want, steps)
} }
} }
// Said explicitly, so nobody reads the move as switching the old broker off — it stays serving // Said explicitly, and last: AMQP is not a provision (novox/hq ADR 0131), so the move ends with
// whatever else uses it, and that is a decision already taken. // the old broker's module unassigned, not left behind as a second bus. An earlier version of this
if !strings.Contains(steps, "not its retirement") { // test pinned the opposite, under a record 0131 superseded.
t.Errorf("the plan does not say the old broker stays:\n%s", steps) lines := WhatMoves(r)
if last := lines[len(lines)-1]; !strings.Contains(last, "unassign the old broker") {
t.Errorf("the plan does not end with the old broker going:\n%s", steps)
} }
} }
+11 -11
View File
@@ -184,17 +184,17 @@ func claimProblems(m Manifest) []string {
} }
for _, c := range m.Claims { for _, c := range m.Claims {
if seat, known := SeatNamed(c.Name); known { if _, known := SeatNamed(c.Name); known {
if c.At() != seat.Scope { // **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122).
problems = append(problems, fmt.Sprintf( // This function runs wherever a manifest is parsed, and one of those places is the
"%s claims %s at scope %q, and %s is a %s seat", // build machine, which has no store: there, `SeatNamed` answers from the set the
m.Module, c.Name, c.At(), c.Name, seat.Scope)) // binary shipped with, so a build would be refused for disagreeing with a compiled
} // copy of data the control plane owns. Exactly that happened — a holder of the bus
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) { // seat was refused for not providing what a stale compiled row said the seat
problems = append(problems, fmt.Sprintf( // delivered, while the store's own row said otherwise.
"%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope", //
m.Module, c.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)) // Both checks moved to CatalogueProblems, which only ever runs in the control plane,
} // after UseSeats has replaced the set with the store's.
continue continue
} }
if isSystemSeatName(c.Name) { if isSystemSeatName(c.Name) {
+16 -1
View File
@@ -190,7 +190,22 @@ func CatalogueProblems(shelf Shelf) []string {
} }
s, isModuleSeat := declared[c.Name] s, isModuleSeat := declared[c.Name]
if !isModuleSeat { if !isModuleSeat {
continue // a mesh seat: already judged by claimProblems // **A mesh seat is judged here and nowhere else** (novox/hq ADR 0122): the set is
// the store's, and this is the only place that runs with the store's set loaded.
// The parser cannot do it — it also runs on the build machine, against whatever
// set that binary was compiled with.
seat, _ := SeatNamed(c.Name)
if c.At() != seat.Scope {
problems = append(problems, fmt.Sprintf(
"%s claims %s at scope %q, and %s is a %s seat",
module, c.Name, c.At(), c.Name, seat.Scope))
}
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) {
problems = append(problems, fmt.Sprintf(
"%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
module, c.Name, seat.Delivers, module, seat.Delivers, seat.Scope))
}
continue
} }
if c.At() != s.At() { if c.At() != s.At() {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
+39 -1
View File
@@ -91,7 +91,10 @@ func TestAClaimMustMatchTheDeclaredScope(t *testing.T) {
// The mesh's own seats still work, and are not shadowed by the derived half. // The mesh's own seats still work, and are not shadowed by the derived half.
func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) { func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) {
m := Manifest{Module: "nats", Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} // It delivers the bus, so its holder provides the bus — the rule this check now enforces.
m := Manifest{Module: "nats",
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
if got := problemsFor(t, Shelf{"nats": m}); got != "" { if got := problemsFor(t, Shelf{"nats": m}); got != "" {
t.Fatalf("a mesh seat was refused by the derived check: %s", got) t.Fatalf("a mesh seat was refused by the derived check: %s", got)
} }
@@ -106,3 +109,38 @@ func TestTheProblemsAreStable(t *testing.T) {
t.Fatalf("unstable:\n%s\n%s", first, second) t.Fatalf("unstable:\n%s\n%s", first, second)
} }
} }
// **A build machine has no store, so it may not judge a seat.** The set is data the control plane
// owns (novox/hq ADR 0122), and `ParseManifest` runs on the build machine too, against whatever set
// that binary was compiled with. When the two disagreed, a valid holder of the bus seat was refused
// mid-rollout — the compiled row said the seat delivered one provision, the store's row said
// another, and the build failed on the copy rather than the truth. The parser judges the manifest;
// the seat set judges the claim, where it is loaded.
func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) {
was := Seats()
t.Cleanup(func() { UseSeats(was) })
// A store whose bus seat delivers something this module does provide.
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "amqp", Decision: "test"}})
raw := []byte(`{"module":"lavinmq","version":"1",` +
`"provides":[{"name":"amqp","scope":"mesh"}],` +
`"claims":[{"name":"mesh-broker","scope":"mesh"}]}`)
m, err := ParseManifest(raw)
if err != nil {
t.Fatalf("the parser refused a claim only the seat set can judge: %v", err)
}
if got := CatalogueProblems(Shelf{m.Module: m}); len(got) != 0 {
t.Fatalf("a holder that provides what the store says the seat delivers was refused: %v", got)
}
// And with the store saying the seat delivers something else, registration is what refuses it.
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
if _, err := ParseManifest(raw); err != nil {
t.Fatalf("the parser judged it the second time: %v", err)
}
got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ")
if !strings.Contains(got, `does not provide "mesh-bus"`) {
t.Fatalf("registration did not refuse a holder that cannot answer for the seat: %q", got)
}
}
+16 -10
View File
@@ -138,26 +138,32 @@ func TestAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) {
} }
} }
// **At registration, not in the parser** (novox/hq ADR 0122): a seat's scope is a property of the
// set, the set is the store's, and the parser also runs on a build machine that has no store.
func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) { func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) {
_, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`)) m, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`))
if err == nil { if err != nil {
t.Fatal("a mesh seat was held per node") t.Fatalf("the parser judged a scope it reads from data it may not have: %v", err)
} }
if !strings.Contains(err.Error(), "mesh seat") { got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ")
t.Fatalf("the refusal does not say which scope the seat is: %v", err) if !strings.Contains(got, "mesh seat") {
t.Fatalf("the refusal does not say which scope the seat is: %q", got)
} }
} }
func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) { func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) {
// Holding it makes the module the mesh's answer for the provision. A module that cannot answer // Holding it makes the module the mesh's answer for the provision. A module that cannot answer
// would be the answer anyway, and every consumer would be sent to it. // would be the answer anyway, and every consumer would be sent to it.
// And refused at registration, where the seat set is the store's: what a seat delivers is
// data, so a compiled copy of it may not be what refuses a build (novox/hq ADR 0122).
raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`) raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`)
_, err := ParseManifest(raw) m, err := ParseManifest(raw)
if err == nil { if err != nil {
t.Fatal("a module holding the git seat need not provide git") t.Fatalf("the parser judged what a seat delivers: %v", err)
} }
if !strings.Contains(err.Error(), `does not provide "git"`) { got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ")
t.Fatalf("the refusal does not say what is missing: %v", err) if !strings.Contains(got, `does not provide "git"`) {
t.Fatalf("the refusal does not say what is missing: %q", got)
} }
} }