diff --git a/cmd/mesh-controller/own_grants.go b/cmd/mesh-controller/own_grants.go new file mode 100644 index 00000000..8964a078 --- /dev/null +++ b/cmd/mesh-controller/own_grants.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "fmt" +) + +// A new controller's own grants reach the bus before it writes (novox/hq issue 1800). +// +// A controller's grants are composed by the controller binary itself (broker.PermissionsFor), so the controller that +// walks a delivery cannot put its successor's grants in the bus's user list: the list that carries them was sent only +// once the new controller passed its gate on its first machine (carryUserList). Until then the new controller wrote +// buckets its account was not granted, and the bus refused them: plans-building on 2026-10-21, declared-packages on +// 2026-10-22, and R4e's states-announced would be the third. +// +// So a controller that starts, holding the lease and before it starts anything that writes its buckets, sends the +// machine holding the bus its user list when the list it composes is not the one that machine was last sent: the grants +// step's send (release.go grantsStep), every build there kept at the one it runs, refused unsent when the declaration +// would change more than the user list (errNotGrantsOnly). No build of a running plan reaches that machine before the +// plan's own tier sends it (ADR 0503 §8). A send that cannot be made is said once, and the controller serves on. +// +// It does not ask heldMachines, as carryUserList does: every reason that gives is a build that would move with the +// list (a policy, a plan not yet sent there, no gate passed), and here none can. The running plan that brought this +// controller holds the bus's machine until its tier sends it; asking would leave the list behind exactly when 1800 +// meets it. The refusal unsent (errNotGrantsOnly) is the guard instead. + +// ownGrantsCarrier is what the start's send reads and does: whether the bus's list is behind and on which machine, and +// the grants-only send to it. Variables so a test needs no store and no bus. +type ownGrantsCarrier struct { + behind func(ctx context.Context) (holder string, behind bool, err error) + send func(ctx context.Context, holder string) error +} + +// carry sends the machine holding the bus the controller's own user list when it is behind, and says what it did. +func (c ownGrantsCarrier) carry(ctx context.Context) string { + holder, behind, err := c.behind(ctx) + switch { + case err != nil: + return fmt.Sprintf("whether the bus's user list carries this controller's grants cannot be read, so it is not sent "+ + "at start; a bucket it newly writes may be refused until the gate carries it: %v", err) + case holder == "" || !behind: + return "" + } + if err := c.send(ctx, holder); err != nil { + return fmt.Sprintf("this controller's grants could not be sent to %s, which holds the bus, at its start; a bucket "+ + "it newly writes may be refused until the gate carries them: %v", holder, err) + } + return fmt.Sprintf("sent %s, which holds the bus, this controller's user list alone, every build there kept, "+ + "before it writes", holder) +} + +// startWithOwnGrants carries the controller's own grants to the bus and only then starts what writes (then). +func startWithOwnGrants(ctx context.Context, c ownGrantsCarrier, then func()) { + if said := c.carry(ctx); said != "" { + fmt.Println(said) + } + then() +} + +// ownGrantsOf is the carrier of the serving controller: the grants step's reading and its grants-only send. +func ownGrantsOf(open *stores) ownGrantsCarrier { + return ownGrantsCarrier{ + behind: func(ctx context.Context) (string, bool, error) { return brokerBehindOf(ctx, open, nil) }, + send: func(ctx context.Context, holder string) error { + _, err := sendRollout(keepingEveryBuild(withScope(ctx, sendScope{}), holder), open, []string{holder}) + return err + }, + } +} diff --git a/cmd/mesh-controller/own_grants_test.go b/cmd/mesh-controller/own_grants_test.go new file mode 100644 index 00000000..d7a8ea4f --- /dev/null +++ b/cmd/mesh-controller/own_grants_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" +) + +// 1800 (1): a controller starting with the bus's list behind its own sends the bus's machine once, before anything +// that writes its buckets starts. +func TestAStartingControllerSendsItsGrantsBeforeItWrites(t *testing.T) { + var order []string + c := ownGrantsCarrier{ + behind: func(context.Context) (string, bool, error) { return "novox", true, nil }, + send: func(_ context.Context, holder string) error { order = append(order, "send "+holder); return nil }, + } + startWithOwnGrants(t.Context(), c, func() { order = append(order, "writers") }) + if strings.Join(order, ", ") != "send novox, writers" { + t.Fatalf("the start did %v", order) + } +} + +// 1800 (3), (4): no send when the list is current or there is no bus with a list; one that cannot be read or sent is +// said once, and the controller goes on. +func TestAStartingControllerSendsNothingWhenTheListIsCurrentAndGoesOnWhenRefused(t *testing.T) { + sends := 0 + send := func(context.Context, string) error { sends++; return nil } + for _, c := range []ownGrantsCarrier{ + {behind: func(context.Context) (string, bool, error) { return "novox", false, nil }, send: send}, + {behind: func(context.Context) (string, bool, error) { return "", true, nil }, send: send}, + } { + if said := c.carry(t.Context()); said != "" || sends != 0 { + t.Fatalf("a current list sent %d and said %q", sends, said) + } + } + refused := ownGrantsCarrier{behind: func(context.Context) (string, bool, error) { return "novox", true, nil }, + send: func(context.Context, string) error { return errNotGrantsOnly }} + went := false + startWithOwnGrants(t.Context(), refused, func() { went = true }) + if !went { + t.Fatal("a refused send stopped the controller") + } + if said := refused.carry(t.Context()); !strings.Contains(said, "could not be sent to novox") || + !strings.Contains(said, errNotGrantsOnly.Error()) { + t.Fatalf("a refused send is said as %q", said) + } + unread := ownGrantsCarrier{behind: func(context.Context) (string, bool, error) { return "", false, errors.New("no store") }, + send: send} + if said := unread.carry(t.Context()); !strings.Contains(said, "cannot be read") || sends != 0 { + t.Fatalf("an unread list: sent %d, said %q", sends, said) + } +} + +// 1800 (2): the start's send is the grants step's: composed with every build of the bus's machine kept at the one it +// runs, so the declaration differs from its last only by the user list (and is refused unsent otherwise, +// errNotGrantsOnly, release.go), whatever a running plan registered since. +func TestAStartingControllersSendKeepsEveryBuildOfTheBusMachine(t *testing.T) { + was := sendRollout + t.Cleanup(func() { sendRollout = was }) + var kept bool + var named []string + sendRollout = func(ctx context.Context, _ *stores, names []string) ([]string, error) { + kept, named = everyBuildKept(ctx, "novox"), names + return names, nil + } + if err := ownGrantsOf(nil).send(t.Context(), "novox"); err != nil { + t.Fatal(err) + } + if !kept || len(named) != 1 || named[0] != "novox" { + t.Fatalf("the start's send keeps every build: %v, names %v", kept, named) + } +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 61cfcb9f..41d88d4f 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -189,7 +189,9 @@ func serve(ctx context.Context) (err error) { // Open plans move on events (novox/hq ADR 0406 §5, issue 974): a tier is asked on the `built` and the send of the // tier before it, a walk ends on its machines' reports, a silent machine is a key that expired, and a plan left // by a replaced controller resumes from the first look, which this asks for. No timer looks every so often. - startRolloutEvents(ctx, open, server.JetStream()) + // **Its own grants on the bus before it writes** (novox/hq issue 1800): a bucket this controller newly writes is + // granted before the first write, not after its gate. + startWithOwnGrants(ctx, ownGrantsOf(open), func() { startRolloutEvents(ctx, open, server.JetStream()) }) // And the merge window (novox/hq ADR 0276, ADR 0407): a batch is cut into its walk when the window closes, // which no merge or outcome says. The window is two keys on the bus that expire and a watcher that hears it; // nothing polls (issue 973).