Merge pull request 'Without the controller: mesh-grants makes consumers, enrolment issues, the watchman keeps dead letters, and the controller cannot be placed again (issue 1126)' (#137) from fix/1126-gates-delivery into main

This commit was merged in pull request #137.
This commit is contained in:
2026-11-02 09:51:14 +00:00
28 changed files with 176 additions and 268 deletions
@@ -559,6 +559,7 @@ func (p *passer) addNode(ctx context.Context, prop delivery.PlacementProposal, r
return return
} }
laid, findings := delivery.LayChanges(current, prop.Changes) laid, findings := delivery.LayChanges(current, prop.Changes)
findings = append(findings, assign.ControllerRefusals(prop.Changes, laid)...)
node := prop.Changes[0].Node node := prop.Changes[0].Node
if prop.Changes[0].Op == delivery.OpRemoveNode { if prop.Changes[0].Op == delivery.OpRemoveNode {
p.removeNode(ctx, prop, current, findings) p.removeNode(ctx, prop, current, findings)
@@ -2,6 +2,8 @@ package assign
import ( import (
"fmt" "fmt"
"maps"
"slices"
"strings" "strings"
"time" "time"
@@ -53,6 +55,7 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
} }
laid, lay := delivery.LayChanges(in.Current, in.Changes) laid, lay := delivery.LayChanges(in.Current, in.Changes)
findings = append(findings, lay...) findings = append(findings, lay...)
findings = append(findings, ControllerRefusals(in.Changes, laid)...)
all := map[string]delivery.Placement{} all := map[string]delivery.Placement{}
for node, pl := range in.Current { for node, pl := range in.Current {
all[node] = pl all[node] = pl
@@ -105,6 +108,37 @@ func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Pla
return p, out, findings return p, out, findings
} }
// ControllerModule is the controller's module, which nothing places again (issue 1126).
const ControllerModule = "mesh-controller"
// ControllerEnded is why a placement of the controller is refused: the parts that acted only once its lease was handed
// (consumers, the user list, enrolment, dead letters) now act without reading the lease, so a controller placed again
// would write beside them, silently.
const ControllerEnded = "the controller ended by its unassignment on 2026-11-02; placing it again would make two writers (issue 1126)"
// ControllerRefusals names every change, and every node as laid, that places the controller: by a place, by a seed's
// whole placement, or in what the changes lay. None when nothing does.
func ControllerRefusals(changes []delivery.PlacementProposalChange, laid map[string]delivery.Placement) []string {
var out []string
nodes := map[string]bool{}
for i, c := range changes {
places := c.Module == ControllerModule && c.Op != delivery.OpTakeOff
if c.Whole != nil && slices.Contains(c.Whole.Assigned(), ControllerModule) {
places = true
}
if places {
out = append(out, fmt.Sprintf("change %d places %s on %s: %s", i+1, ControllerModule, c.Node, ControllerEnded))
nodes[c.Node] = true
}
}
for _, node := range slices.Sorted(maps.Keys(laid)) {
if !nodes[node] && slices.Contains(laid[node].Assigned(), ControllerModule) {
out = append(out, fmt.Sprintf("%s would run %s: %s", node, ControllerModule, ControllerEnded))
}
}
return out
}
// Write is one node a warrant writes (§5): the node, its node key `<node>.node` (the commit of the node's keys, // Write is one node a warrant writes (§5): the node, its node key `<node>.node` (the commit of the node's keys,
// mesh-sdk delivery.PlacementKeys.Writes), the revision of the node's placement it is written at (0: it has none, so // mesh-sdk delivery.PlacementKeys.Writes), the revision of the node's placement it is written at (0: it has none, so
// it is created) and its value (nil: it is deleted). // it is created) and its value (nil: it is deleted).
@@ -37,12 +37,17 @@ func TestThePlacementSeatDecides(t *testing.T) {
in ProposeIn in ProposeIn
want string want string
}{ }{
"a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"}, "a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"},
"a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"}, "a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"},
"nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"}, "nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"},
"no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"}, "no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"},
"no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"}, "no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"},
"a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"}, "a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "gone with adopt"},
// 1126: the controller ended by its unassignment; placed again it would be a second writer beside the parts that
// act without its lease.
"the controller placed again": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "mesh-controller"}), "ended by its unassignment"},
"the controller seeded whole": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "anchor", Whole: &delivery.Placement{V: delivery.Version, Node: "anchor",
Decided: anchor.Decided, Modules: []delivery.PlacedModule{{Module: "mesh-controller"}}}}), "two writers (issue 1126)"},
"a pin of nobody placed": {in(delivery.PlacementProposalChange{Op: delivery.OpPin, Node: "laptop", Provision: "db", Provider: &delivery.Provider{Node: "laptop", Module: "store"}}), "not placed there"}, "a pin of nobody placed": {in(delivery.PlacementProposalChange{Op: delivery.OpPin, Node: "laptop", Provision: "db", Provider: &delivery.Provider{Node: "laptop", Module: "store"}}), "not placed there"},
} { } {
if _, _, f := Propose(c.in); !slices.ContainsFunc(f, func(s string) bool { return strings.Contains(s, c.want) }) { if _, _, f := Propose(c.in); !slices.ContainsFunc(f, func(s string) bool { return strings.Contains(s, c.want) }) {
@@ -140,9 +140,9 @@ func Tools(states func(string) state, ask func(socket.Ask) (json.RawMessage, err
{Name: verb(VerbDeadLetters), Run: dead, {Name: verb(VerbDeadLetters), Run: dead,
Description: "What a consumer gave up on, kept in DEAD_LETTERS (novox/hq ADR 0564 §5, ADR 0264, ADR 0376): newest first, " + Description: "What a consumer gave up on, kept in DEAD_LETTERS (novox/hq ADR 0564 §5, ADR 0264, ADR 0376): newest first, " +
"for one consumer (its name or <stream>.<consumer>), at most limit; one whole by id (a restricted one's body " + "for one consumer (its name or <stream>.<consumer>), at most limit; one whole by id (a restricted one's body " +
"withheld). Who keeps them now: the controller until the lease names no controller, the watchman from then. " + "withheld). The watchman keeps them once the controller's consumer on DEAD_LETTER_NOTICES is gone, and says the " +
"Delivering again and dropping (act deliver or drop, with why) are refused here until the hand-act log's home " + "notices wait while it stands. Delivering again and dropping (act deliver or drop, with why) are refused here until " +
"(issue 1953): before the lease write use mesh-controller.dead-letters.", "the hand-act log's home (issue 1953).",
Input: map[string]any{"type": "object", "properties": map[string]any{ Input: map[string]any{"type": "object", "properties": map[string]any{
"consumer": map[string]any{"type": "string", "description": "only this consumer's, its name or <stream>.<consumer>"}, "consumer": map[string]any{"type": "string", "description": "only this consumer's, its name or <stream>.<consumer>"},
"limit": map[string]any{"type": "integer", "description": "at most this many, newest first"}, "limit": map[string]any{"type": "integer", "description": "at most this many, newest first"},
@@ -117,12 +117,8 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
kvs[c.Bucket] = kv kvs[c.Bucket] = kv
} }
// The controller's lease, its consumer on DEAD_LETTER_NOTICES, the dead letters' streams and a source stream with one // The controller's consumer on DEAD_LETTER_NOTICES, the dead letters' streams and a source stream with one message a
// message a consumer will give up on (1952/WM5). // consumer will give up on (1952/WM5); no lease bucket at all (issue 1126).
lease, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: buscompose.LeaseBucket})
if err != nil {
t.Fatal(err)
}
for _, c := range []jetstream.StreamConfig{ for _, c := range []jetstream.StreamConfig{
{Name: buscompose.DeadLetterNoticesStream, Subjects: []string{buscompose.MaxDeliveriesAdvisories}, Retention: jetstream.WorkQueuePolicy}, {Name: buscompose.DeadLetterNoticesStream, Subjects: []string{buscompose.MaxDeliveriesAdvisories}, Retention: jetstream.WorkQueuePolicy},
{Name: buscompose.DeadLettersStream, Subjects: []string{"mesh.events.dead.>"}, Duplicates: 24 * time.Hour, AllowMsgTTL: true}, {Name: buscompose.DeadLettersStream, Subjects: []string{"mesh.events.dead.>"}, Duplicates: 24 * time.Hour, AllowMsgTTL: true},
@@ -269,15 +265,8 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
}) })
until("node n1's silence", func() bool { return finding("machine.n1.silent") }) until("node n1's silence", func() bool { return finding("machine.n1.silent") })
// 1952/WM5: while the lease names a controller the watchman takes no notice; handed away while the controller's consumer // 1952/WM5, 1126/A2/AC2: with no lease written, while the controller's consumer stands the watchman says the notices
// stands, it says the notices wait; the consumer removed (the lease write's step), it makes its own and keeps a notice // wait; the consumer removed, it makes its own and keeps a notice with its headers.
// with its headers.
if _, err := js.Consumer(ctx, buscompose.DeadLetterNoticesStream, deadletters.Consumer); err == nil {
t.Fatal("the watchman made its consumer while the lease names a controller")
}
if _, err := lease.Put(ctx, buscompose.ControllerLeaseKey, []byte(`{"handed-to":"mesh-vault","handed-by":"operator","why":"the lease write"}`)); err != nil {
t.Fatal(err)
}
waits := watch.FindingKey(watch.ScopeBus, "DEAD_LETTER_NOTICES.controller", watch.KindNoticesWaiting) waits := watch.FindingKey(watch.ScopeBus, "DEAD_LETTER_NOTICES.controller", watch.KindNoticesWaiting)
until("the notices said waiting behind the controller's consumer", func() bool { return finding(waits) }) until("the notices said waiting behind the controller's consumer", func() bool { return finding(waits) })
if _, err := js.Consumer(ctx, buscompose.DeadLetterNoticesStream, deadletters.Consumer); err == nil { if _, err := js.Consumer(ctx, buscompose.DeadLetterNoticesStream, deadletters.Consumer); err == nil {
@@ -22,7 +22,6 @@ import (
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream" "github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions" "git.novox.be/novox/mesh-sdk/go/provisions"
@@ -171,7 +170,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// Its own presence, pulses and stuck rows are what it keeps: each stops the start, naming its grant. The assigner's wants // Its own presence, pulses and stuck rows are what it keeps: each stops the start, naming its grant. The assigner's wants
// and the node-engine's haves only make the pulses of a move: without one the watchman watches presence and the build // and the node-engine's haves only make the pulses of a move: without one the watchman watches presence and the build
// seats' steps, and says that moves are not watched (issue 1945). // seats' steps, and says that moves are not watched (issue 1945).
var kvs struct{ presence, pulse, stuck, findings, wants, haves, lease jetstream.KeyValue } var kvs struct{ presence, pulse, stuck, findings, wants, haves jetstream.KeyValue }
for _, b := range []struct { for _, b := range []struct {
name string name string
into *jetstream.KeyValue into *jetstream.KeyValue
@@ -186,7 +185,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
kvs.wants, kvs.haves = nil, nil // a want is a pulse only against the have that ends it kvs.wants, kvs.haves = nil, nil // a want is a pulse only against the have that ends it
} }
kvs.lease = process.OptionalBucket(ctx, js, buscompose.LeaseBucket, "the lease is read as naming a controller, so the dead letters stay the controller's", logger.Printf)
w := watch.New(&kvOut{ctx: ctx, presence: kvs.presence, pulse: kvs.pulse, stuk: kvs.stuck, findings: kvs.findings}, time.Now) w := watch.New(&kvOut{ctx: ctx, presence: kvs.presence, pulse: kvs.pulse, stuk: kvs.stuck, findings: kvs.findings}, time.Now)
w.Logf(logger.Printf) w.Logf(logger.Printf)
// A test shortens how long a move may take; the mesh never sets it. // A test shortens how long a move may take; the mesh never sets it.
@@ -379,23 +377,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
errs <- follow(ctx, provWatch, func(e jetstream.KeyValueEntry) { w.Provision(e.Key(), e.Value(), opOf(e)) }) errs <- follow(ctx, provWatch, func(e jetstream.KeyValueEntry) { w.Provision(e.Key(), e.Value(), opOf(e)) })
}() }()
} }
if kvs.lease != nil {
lw, err := kvs.lease.Watch(ctx, buscompose.ControllerLeaseKey)
if err != nil {
process.NotWatched(logger.Printf, buscompose.LeaseBucket, err, "the lease is read as naming a controller, so the dead letters stay the controller's")
} else {
go func() {
errs <- follow(ctx, lw, func(e jetstream.KeyValueEntry) {
var raw []byte
if opOf(e) == watch.Put {
raw = e.Value()
}
on, _ := buscompose.LeaseHandedAway(raw, len(raw) > 0, nil)
dead.lease(on)
})
}()
}
}
if l, err := socket.Listen(socket.PathOf(getenv)); err != nil { if l, err := socket.Listen(socket.PathOf(getenv)); err != nil {
logger.Printf("the tools' socket cannot be opened (%v): dead-letters and resync answer nothing until the watchman starts again", err) logger.Printf("the tools' socket cannot be opened (%v): dead-letters and resync answer nothing until the watchman starts again", err)
} else { } else {
@@ -105,7 +105,7 @@ func heard(w *watch.Watchman, dead *deadKeeper, m *nats.Msg, logf func(string, .
return return
} }
if dead.isTheControllersNotices(a.ID) { if dead.isTheControllersNotices(a.ID) {
dead.check() // the lease write's step removed it: the watchman may take the notices now dead.check() // the controller's consumer removed: the watchman may take the notices now
w.RemovalGone(watch.RemovalConsumer) w.RemovalGone(watch.RemovalConsumer)
return return
} }
@@ -164,17 +164,16 @@ func hear(conn *nats.Conn, w *watch.Watchman, dead *deadKeeper, logf func(string
return conn.Flush() return conn.Flush()
} }
// deadKeeper keeps what consumers gave up on from the lease write (ADR 0564 §5): it counts DEAD_LETTERS into findings, // deadKeeper keeps what consumers gave up on (ADR 0564 §5): it counts DEAD_LETTERS into findings, and takes
// and takes DEAD_LETTER_NOTICES on its own consumer only once the lease names no controller and `controller` is gone. // DEAD_LETTER_NOTICES on its own consumer once `controller` is gone (issue 1126: no lease is read).
type deadKeeper struct { type deadKeeper struct {
ctx context.Context ctx context.Context
js jetstream.JetStream js jetstream.JetStream
w *watch.Watchman w *watch.Watchman
logf func(string, ...any) logf func(string, ...any)
mu sync.Mutex mu sync.Mutex
handedAway bool taking jetstream.ConsumeContext
taking jetstream.ConsumeContext
} }
// ages pulses each consumer's restricted letters by when they age out, read from DEAD_LETTERS' headers (at the start and // ages pulses each consumer's restricted letters by when they age out, read from DEAD_LETTERS' headers (at the start and
@@ -225,38 +224,20 @@ func (d *deadKeeper) count() {
d.w.DeadLettersHeld(held, deadletters.ConsumerInWords) d.w.DeadLettersHeld(held, deadletters.ConsumerInWords)
} }
// lease is the lease's change heard: handed away or not. // isTheControllersNotices says an advisory is about the controller's consumer on DEAD_LETTER_NOTICES: its removal is the
func (d *deadKeeper) lease(handedAway bool) { // controller's end, not a loss.
d.mu.Lock()
d.handedAway = handedAway
d.mu.Unlock()
d.check()
}
func (d *deadKeeper) away() bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.handedAway
}
// isTheControllersNotices says an advisory is about the controller's consumer on DEAD_LETTER_NOTICES once the lease is
// handed away: its removal is the lease write's step, not a loss.
func (d *deadKeeper) isTheControllersNotices(id string) bool { func (d *deadKeeper) isTheControllersNotices(id string) bool {
return d.away() && id == buscompose.DeadLetterNoticesStream+"."+buscompose.ControllerName return id == buscompose.DeadLetterNoticesStream+"."+buscompose.ControllerName
} }
// check takes the notices, or says they wait, as Gate has it. // check takes the notices, or says they wait, as Gate has it.
func (d *deadKeeper) check() { func (d *deadKeeper) check() {
away := d.away() stands, err := deadletters.ControllerStands(d.ctx, d.js)
stands := true if err != nil {
if away { d.logf("whether the controller's consumer on %s stands cannot be read (%v): it is taken to stand", buscompose.DeadLetterNoticesStream, err)
var err error stands = true
if stands, err = deadletters.ControllerStands(d.ctx, d.js); err != nil {
d.logf("whether the controller's consumer on %s stands cannot be read (%v): it is taken to stand", buscompose.DeadLetterNoticesStream, err)
stands = true
}
} }
take, waiting := deadletters.Gate(away, stands) take, waiting := deadletters.Gate(stands)
d.w.NoticesWaiting(waiting) d.w.NoticesWaiting(waiting)
d.mu.Lock() d.mu.Lock()
defer d.mu.Unlock() defer d.mu.Unlock()
@@ -269,7 +250,7 @@ func (d *deadKeeper) check() {
return return
} }
d.taking = cc d.taking = cc
d.logf("the lease names no controller and its consumer on %s is gone: the watchman keeps the dead letters", buscompose.DeadLetterNoticesStream) d.logf("the controller's consumer on %s is gone: the watchman keeps the dead letters", buscompose.DeadLetterNoticesStream)
} }
// answer is the program's answer to the tools' ask on its socket. // answer is the program's answer to the tools' ask on its socket.
@@ -289,7 +270,7 @@ func answer(a socket.Ask, w *watch.Watchman, dead *deadKeeper, ask func([]byte)
if strings.TrimSpace(a.Why) == "" { if strings.TrimSpace(a.Why) == "" {
return nil, errors.New("a dead letter is delivered again or dropped only with why") return nil, errors.New("a dead letter is delivered again or dropped only with why")
} }
return nil, deadletters.Refusal(dead.away(), a.Act) return nil, deadletters.Refusal(a.Act)
case a.Act != "": case a.Act != "":
return nil, fmt.Errorf("%q is not deliver or drop", a.Act) return nil, fmt.Errorf("%q is not deliver or drop", a.Act)
case a.ID > 0: case a.ID > 0:
@@ -299,11 +280,7 @@ func answer(a socket.Ask, w *watch.Watchman, dead *deadKeeper, ask func([]byte)
if err != nil { if err != nil {
return nil, err return nil, err
} }
keeper := "the controller (it holds its lease)" return map[string]any{"dead-letters": list, "total": total, "kept-by": "the watchman"}, nil
if dead.away() {
keeper = "the watchman"
}
return map[string]any{"dead-letters": list, "total": total, "kept-by": keeper}, nil
} }
return nil, fmt.Errorf("refused: the watchman's socket answers %q and %q", socket.AskDeadLetters, socket.AskResync) return nil, fmt.Errorf("refused: the watchman's socket answers %q and %q", socket.AskDeadLetters, socket.AskResync)
} }
@@ -305,27 +305,23 @@ func Named(ctx context.Context, js jetstream.JetStream, id uint64) (DeadLetter,
return d, nil return d, nil
} }
// Refusal is why the watchman delivers nothing again and drops nothing: before the lease write the controller keeps them // Refusal is why the watchman delivers nothing again and drops nothing: the act waits for the hand-act log's home (issue
// and its verb acts; from it the act waits for the hand-act log's home (issue 1953), where a person's act is recorded. // 1953), where a person's act is recorded. act is deliver or drop.
// act is deliver or drop. func Refusal(act string) error {
func Refusal(handedAway bool, act string) error {
done := map[string]string{"deliver": "delivered again", "drop": "dropped"}[act] done := map[string]string{"deliver": "delivered again", "drop": "dropped"}[act]
if done == "" { if done == "" {
return fmt.Errorf("%q is not deliver or drop", act) return fmt.Errorf("%q is not deliver or drop", act)
} }
if !handedAway {
return fmt.Errorf("the controller still holds its lease and keeps the dead letters: %s it with "+
"mesh-controller.dead-letters (novox/hq ADR 0564 §5); nothing is %s here", act, done)
}
return fmt.Errorf("a dead letter is %s only as a person's act recorded in the hand-act log, whose home is not "+ return fmt.Errorf("a dead letter is %s only as a person's act recorded in the hand-act log, whose home is not "+
"delivered yet (issue 1953): nothing is %s", done, done) "delivered yet (issue 1953): nothing is %s", done, done)
} }
// Gate is whether the watchman takes the notices itself: only when the lease names no controller (handedAway) and the // Gate is whether the watchman takes the notices itself: once the controller's consumer is gone. waiting says `controller`
// controller's consumer is gone. waiting says the lease is handed away while `controller` still stands, which is the // still stands, which is the watchman's finding: each notice waits in the queue until that consumer is removed. The lease
// watchman's finding: each notice waits in the queue until the lease write's step removes it. // is not read: the controller ended by its unassignment, never by the lease write (issue 1126), and mesh-sdk's
func Gate(handedAway, controllerStands bool) (take, waiting bool) { // buscompose.LeaseHandedAway answers false for the absent key, which would keep the notices the controller's for good.
return handedAway && !controllerStands, handedAway && controllerStands func Gate(controllerStands bool) (take, waiting bool) {
return !controllerStands, controllerStands
} }
// ControllerStands asks the bus whether the controller's consumer on DEAD_LETTER_NOTICES stands. // ControllerStands asks the bus whether the controller's consumer on DEAD_LETTER_NOTICES stands.
@@ -8,36 +8,32 @@ import (
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
) )
// 1952/WM5, ADR 0564 §5: the watchman makes no consumer while `controller` exists or the lease names a controller, makes // 1952/WM5, ADR 0564 §5, 1126/A2/AC2: with no lease read at all (the controller ended by its unassignment), the watchman
// one once both are false, and says the wait meanwhile; it delivers and drops nothing, naming who does; and a kept message // makes no consumer while `controller` stands on DEAD_LETTER_NOTICES and says the wait, and makes its own once it is gone;
// reads as the controller's copies do, a restricted one's age and work queue kept. // it delivers and drops nothing, naming why; and a kept message reads as the controller's copies do, a restricted one's
func TestTheWatchmanKeepsTheDeadLettersOnlyFromTheLeaseWrite(t *testing.T) { // age and work queue kept.
func TestTheWatchmanKeepsTheDeadLetters(t *testing.T) {
for _, c := range []struct { for _, c := range []struct {
handedAway, controllerStands, take, waiting bool controllerStands, take, waiting bool
}{ }{
{false, true, false, false}, {true, false, true},
{false, false, false, false}, {false, true, false},
{true, true, false, true},
{true, false, true, false},
} { } {
take, waiting := Gate(c.handedAway, c.controllerStands) take, waiting := Gate(c.controllerStands)
if take != c.take || waiting != c.waiting { if take != c.take || waiting != c.waiting {
t.Errorf("Gate(%v, %v) = %v, %v", c.handedAway, c.controllerStands, take, waiting) t.Errorf("Gate(%v) = %v, %v", c.controllerStands, take, waiting)
} }
} }
for _, c := range []struct { for _, c := range []struct {
away bool
act string act string
says string says string
}{ }{
{false, "deliver", "mesh-controller.dead-letters"}, {"drop", "issue 1953"},
{false, "drop", "nothing is dropped"}, {"deliver", "delivered again"},
{true, "drop", "issue 1953"}, {"send", "not deliver or drop"},
{true, "deliver", "delivered again"},
{true, "send", "not deliver or drop"},
} { } {
if err := Refusal(c.away, c.act); err == nil || !strings.Contains(err.Error(), c.says) { if err := Refusal(c.act); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("Refusal(%v, %s) = %v, want it to say %q", c.away, c.act, err, c.says) t.Errorf("Refusal(%s) = %v, want it to say %q", c.act, err, c.says)
} }
} }
at := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) at := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
@@ -90,7 +90,7 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6
} }
reads := slices.Clone(m.Reads) reads := slices.Clone(m.Reads)
slices.Sort(reads) slices.Sort(reads)
if !reflect.DeepEqual(reads, []string{"mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-controller.lease", if !reflect.DeepEqual(reads, []string{"mesh-build-go.steps", "mesh-build-image.steps", "mesh-build-node.steps",
"mesh-delivery-assigner.wants", "mesh-delivery-indexer.modules", "mesh-host.health", "mesh-provisioner.provisions", "node-engine.have"}) { "mesh-delivery-assigner.wants", "mesh-delivery-indexer.modules", "mesh-host.health", "mesh-provisioner.provisions", "node-engine.have"}) {
t.Errorf("reads = %v", reads) t.Errorf("reads = %v", reads)
} }
@@ -19,7 +19,6 @@
"mesh-build-go.steps", "mesh-build-go.steps",
"mesh-build-image.steps", "mesh-build-image.steps",
"mesh-build-node.steps", "mesh-build-node.steps",
"mesh-controller.lease",
"mesh-delivery-assigner.wants", "mesh-delivery-assigner.wants",
"mesh-delivery-indexer.modules", "mesh-delivery-indexer.modules",
"mesh-host.health", "mesh-host.health",
@@ -12,7 +12,6 @@ import (
"time" "time"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -159,11 +158,11 @@ func checkTunnelKey(k string) error {
return nil return nil
} }
// issuing says whether issue is on: only while the controller's lease is handed to a holder that is no controller (ADR 0587 // issuing says whether issue is on, and why: always, since the controller ended by its unassignment, never by the lease
// §4), and why either way. // write (issue 1126), so the seat is the only issuer (ADR 0587 §4). The lease is no longer read: mesh-sdk's
// buscompose.LeaseHandedAway answers false for the absent key, which would keep issuing off for good.
func (h *Holder) issuing() (bool, string) { func (h *Holder) issuing() (bool, string) {
raw, found, err := h.Read.Get("mesh-controller.lease", buscompose.ControllerLeaseKey) return true, "the seat is the only issuer: the controller ended by its unassignment (issue 1126)"
return buscompose.LeaseHandedAway(raw, found, err)
} }
// openEnrolments reads every node key of the bucket. // openEnrolments reads every node key of the bucket.
@@ -220,11 +220,9 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
return kv return kv
} }
enrolments := mk(delivery.EnrolmentsBucket, true) enrolments := mk(delivery.EnrolmentsBucket, true)
logins, lease, placement := mk(delivery.NodeLoginsBucket, false), mk("mesh-controller_lease", false), mk("mesh-delivery-assigner_placement", false) // No controller lease bucket at all: the controller ended by its unassignment (issue 1126).
logins, placement := mk(delivery.NodeLoginsBucket, false), mk("mesh-delivery-assigner_placement", false)
have := mk("node-engine_have", false) have := mk("node-engine_have", false)
if _, err := lease.Put(ctx, "holder", []byte(`{"handed-to":"mesh-grants","handed-by":"operator"}`)); err != nil {
t.Fatal(err)
}
if _, err := placement.Put(ctx, "hub.node", []byte(hubPlacement)); err != nil { if _, err := placement.Put(ctx, "hub.node", []byte(hubPlacement)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -42,7 +42,7 @@ type IssueAnswer struct {
} }
// Issue opens an enrolment (ADR 0587 §4): it refuses at once, naming every finding, a name that is no node name or that a // Issue opens an enrolment (ADR 0587 §4): it refuses at once, naming every finding, a name that is no node name or that a
// node record, a placement key or an open enrolment holds, and every call before the lease write; chooses the lowest free // node record, a placement key or an open enrolment holds; chooses the lowest free
// address and reserves it by creating its key (the bus refuses a key that exists, so two issues never get one address); // address and reserves it by creating its key (the bus refuses a key that exists, so two issues never get one address);
// writes the key at stage asked; and asks the operator, on a channel that proves who answered. The token is made only on // writes the key at stage asked; and asks the operator, on a channel that proves who answered. The token is made only on
// the operator's yes (Warranted). // the operator's yes (Warranted).
@@ -68,10 +68,6 @@ func (h *Holder) Issue(caller, node, why, overlayKey string, lasts time.Duration
if lasts < 0 || lasts > delivery.MostTokenLife { if lasts < 0 || lasts > delivery.MostTokenLife {
findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, delivery.MostTokenLife)) findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, delivery.MostTokenLife))
} }
if on, reason := h.issuing(); !on {
findings = append(findings, "issuing is off until the controller's lease is written to a holder that is no controller: "+reason+
"; until then the controller's `token issue` is the only issuer (ADR 0587 §4)")
}
placements, perr := h.placements() placements, perr := h.placements()
if perr != nil { if perr != nil {
findings = append(findings, "placement cannot be read: "+perr.Error()) findings = append(findings, "placement cannot be read: "+perr.Error())
@@ -14,8 +14,8 @@ import (
var yes = asks.Option{ID: "yes"} var yes = asks.Option{ID: "yes"}
// 774/S55/AC1: issue refuses, naming every finding, what it must: the lease with the controller, a bad name, a name a node // 774/S55/AC1: issue refuses, naming every finding, what it must: a bad name, a name a node record, a placement key or an
// record, a placement key or an open enrolment holds, and an expiry past a day. // open enrolment holds, and an expiry past a day; never the controller's lease, which no rig writes (issue 1126).
func issueRefusesNamingEveryFinding(t *testing.T) { func issueRefusesNamingEveryFinding(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -24,8 +24,6 @@ func issueRefusesNamingEveryFinding(t *testing.T) {
lasts time.Duration lasts time.Duration
want []string want []string
}{ }{
{"the lease names the controller", func(r *rig) { r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}` }, "ace", 0,
[]string{"issuing is off until the controller's lease is written", "token issue"}},
{"a bad name and a long expiry", nil, "Ace.1", 48 * time.Hour, []string{"not a valid node name", "outside zero to 24h0m0s"}}, {"a bad name and a long expiry", nil, "Ace.1", 48 * time.Hour, []string{"not a valid node name", "outside zero to 24h0m0s"}},
{"a placement key and a node record", func(r *rig) { {"a placement key and a node record", func(r *rig) {
r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","modules":[]}` r.rd["mesh-delivery-assigner.placement"]["ace.node"] = `{"v":1,"node":"ace","modules":[]}`
@@ -201,7 +201,7 @@ type said struct {
Body json.RawMessage Body json.RawMessage
} }
// rig is a holder over fakes, with the lease handed to mesh-grants, one hub in placement and a clock the test moves. // rig is a holder over fakes, with no controller lease at all (issue 1126), one hub in placement and a clock the test moves.
type rig struct { type rig struct {
mu sync.Mutex mu sync.Mutex
t *testing.T t *testing.T
@@ -230,7 +230,6 @@ const hubPlacement = `{"v":1,"node":"hub","decided":{"hub":true,"endpoint":"192.
func newRig(t *testing.T) *rig { func newRig(t *testing.T) *rig {
r := &rig{t: t, st: newMem(), vault: &fakeVault{fail: map[string]bool{}}, asker: &fakeAsker{}, now: time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC), loaded: true} r := &rig{t: t, st: newMem(), vault: &fakeVault{fail: map[string]bool{}}, asker: &fakeAsker{}, now: time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC), loaded: true}
r.rd = memRead{ r.rd = memRead{
"mesh-controller.lease": {"holder": `{"handed-to":"mesh-grants","handed-by":"operator"}`},
"mesh-delivery-assigner.placement": {"hub.node": hubPlacement}, "mesh-delivery-assigner.placement": {"hub.node": hubPlacement},
"node-engine.have": {"hub.node": hubRecord}, "node-engine.have": {"hub.node": hubRecord},
} }
@@ -59,12 +59,11 @@ func tokensAnswersEachStageAndRelaysTheSealedTokenOfOneNode(t *testing.T) {
} }
} }
} }
// Before the lease write issuing is off, and says why. // 1126/A2/AC1: with no lease key issuing is on, and says why.
r.rd["mesh-controller.lease"]["holder"] = `{"instance":"novox.mesh-controller"}` on, _ := r.h.Tokens("", false)
off, _ := r.h.Tokens("", false) mustContain(t, on.Issuing.Why, "unassignment")
mustContain(t, off.Issuing.Why, "controller holds the lease") if !on.Issuing.On {
if off.Issuing.On { t.Errorf("issuing is off with no lease key: %s", on.Issuing.Why)
t.Error("issuing is on while the controller holds the lease")
} }
} }
@@ -83,6 +82,7 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) {
Serves []string `json:"serves"` Serves []string `json:"serves"`
} `json:"claims"` } `json:"claims"`
Emits []string `json:"emits"` Emits []string `json:"emits"`
Reads []string `json:"reads"`
State []json.RawMessage `json:"state"` State []json.RawMessage `json:"state"`
} }
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
@@ -92,6 +92,9 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) {
!slices.Equal(m.Claims[0].Serves, Verbs) || len(m.Emits) != 0 { !slices.Equal(m.Claims[0].Serves, Verbs) || len(m.Emits) != 0 {
t.Errorf("module %q runs as %q, claims %+v, emits %v", m.Module, m.Runs, m.Claims, m.Emits) t.Errorf("module %q runs as %q, claims %+v, emits %v", m.Module, m.Runs, m.Claims, m.Emits)
} }
if slices.Contains(m.Reads, "mesh-controller.lease") {
t.Error("the manifest reads the controller's lease, which the code no longer reads (issue 1126)")
}
var heard []string var heard []string
for _, e := range EndEvents { for _, e := range EndEvents {
heard = append(heard, Seat+"."+e) heard = append(heard, Seat+"."+e)
@@ -48,8 +48,7 @@ func Tools(h *Holder) []stdio.Tool {
{Name: IssueTool, {Name: IssueTool,
Description: "Open an enrolment: let a new machine join the mesh (novox/hq ADR 0587 §4). Takes the node's name, a why and an " + Description: "Open an enrolment: let a new machine join the mesh (novox/hq ADR 0587 §4). Takes the node's name, a why and an " +
"expiry (one hour unless given, at most 24 hours). Refused at once, naming every finding, for a name that is no node name " + "expiry (one hour unless given, at most 24 hours). Refused at once, naming every finding, for a name that is no node name " +
"or that a node record, a placement key or an open enrolment holds, and for every call before the controller's lease is " + "or that a node record, a placement key or an open enrolment holds. Otherwise it chooses the lowest free private-network address, reserves it, " +
"written to a holder that is no controller. Otherwise it chooses the lowest free private-network address, reserves it, " +
"writes the enrolment at stage asked and asks the operator on a channel that proves who answered; only their yes adds the " + "writes the enrolment at stage asked and asks the operator on a channel that proves who answered; only their yes adds the " +
"node to placement with the machine's tunnel key (overlay_key, as `nox-mesh-host key` prints it there), so the hub peers " + "node to placement with the machine's tunnel key (overlay_key, as `nox-mesh-host key` prints it there), so the hub peers " +
"it, and then makes the token, which carries the first tunnel (the vault makes it, this seat never holds it). The caller is the one the runtime proves.", "it, and then makes the token, which carries the first tunnel (the vault makes it, this seat never holds it). The caller is the one the runtime proves.",
@@ -107,8 +106,7 @@ func Tools(h *Holder) []stdio.Tool {
Run: func(a map[string]any) (any, error) { return h.Again(arg(a, "node")) }}, Run: func(a map[string]any) (any, error) { return h.Again(arg(a, "node")) }},
{Name: TokensTool, {Name: TokensTool,
Description: "What the seat holds (novox/hq ADR 0587 §8): every open enrolment, or one node's, with its stage (asked, live or " + Description: "What the seat holds (novox/hq ADR 0587 §8): every open enrolment, or one node's, with its stage (asked, live or " +
"redeeming), address, expiry, ask, revision and age; whether issuing is on and why (it is on only from the controller's " + "redeeming), address, expiry, ask, revision and age; whether issuing is on and why; the bucket's fill. With a node at stage live, the token sealed to the operator key, asked of the vault at " +
"lease write); the bucket's fill. With a node at stage live, the token sealed to the operator key, asked of the vault at " +
"this moment and relayed as the vault sealed it: the one route by which the operator gets a token, which this seat never " + "this moment and relayed as the vault sealed it: the one route by which the operator gets a token, which this seat never " +
"reads and keeps nowhere. With history=true the ended enrolments its events say. A machine stuck at redeeming shows here " + "reads and keeps nowhere. With history=true the ended enrolments its events say. A machine stuck at redeeming shows here " +
"with its age; revoke ends it. Read-only.", "with its age; revoke ends it. Read-only.",
-1
View File
@@ -34,7 +34,6 @@
"seat:mesh-vault.sign" "seat:mesh-vault.sign"
], ],
"reads": [ "reads": [
"mesh-controller.lease",
"mesh-delivery-assigner.placement", "mesh-delivery-assigner.placement",
"mesh-grants.user-list", "mesh-grants.user-list",
"mesh-vault.signers", "mesh-vault.signers",
+4 -5
View File
@@ -118,8 +118,8 @@ func toolsOf(p *grants.Part) []stdio.Tool {
Run: func(a map[string]any) (any, error) { Run: func(a map[string]any) (any, error) {
return p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why")) return p.TakeOver(stdio.CallerOf(a), grants.Answer(a, "why"))
}}, }},
{Name: grants.ToolHandBack, Description: "Ask the operator to switch the bus node back to the controller's list (ADR 0547 clause 8), " + {Name: grants.ToolHandBack, Description: "Gone: the controller ended by its unassignment (issue 1126), so nothing writes its user list and the grants " +
"by their yes on their channel; in shadow there is nothing to hand back, and it says so. Every act is recorded.", "part's list is the only one; it says so and asks nothing (ADR 0547 §11).",
Input: schema(map[string]string{"why": "why, kept with the act"}, "why"), Input: schema(map[string]string{"why": "why, kept with the act"}, "why"),
Run: func(a map[string]any) (any, error) { Run: func(a map[string]any) (any, error) {
return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why")) return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
@@ -129,15 +129,14 @@ func toolsOf(p *grants.Part) []stdio.Tool {
"wrote it to its state accounts, which the vault alone reads: user, kind, node, module, process, the bus's address and " + "wrote it to its state accounts, which the vault alone reads: user, kind, node, module, process, the bus's address and " +
"fingerprint, a module's claims with the verbs each seat promises, and when the key was written; one user's or all; " + "fingerprint, a module's claims with the verbs each seat promises, and when the key was written; one user's or all; " +
"what the last pass wrote, deleted or kept; and the durable consumers the accounts need as the last pass found them " + "what the last pass wrote, deleted or kept; and the durable consumers the accounts need as the last pass found them " +
"(missing, differing and why, created; whether the lease is handed away). Never a password. Read-only: " + "(missing, differing and why, created). Never a password. Read-only: " +
"grants_compose_now writes them again.", "grants_compose_now writes them again.",
Input: schema(map[string]string{"user": "one bus user, e.g. ace.mesh-build-go"}), Input: schema(map[string]string{"user": "one bus user, e.g. ace.mesh-build-go"}),
Run: func(a map[string]any) (any, error) { return p.Accounts(grants.Answer(a, "user")) }}, Run: func(a map[string]any) (any, error) { return p.Accounts(grants.Answer(a, "user")) }},
{Name: grants.ToolConsumerMatch, Description: "Bring one durable consumer an account needs (a module's on EVENTS, a process's on " + {Name: grants.ToolConsumerMatch, Description: "Bring one durable consumer an account needs (a module's on EVENTS, a process's on " +
"its work queue; novox/hq ADR 0561 §6) to the configuration the bus composer composes, by hand: the recovery when " + "its work queue; novox/hq ADR 0561 §6) to the configuration the bus composer composes, by hand: the recovery when " +
"the condition mesh-grants.consumer-differs names it. Asks the part's process, which alone may, and keeps where the " + "the condition mesh-grants.consumer-differs names it. Asks the part's process, which alone may, and keeps where the " +
"consumer delivers from. Refused while the controller holds its lease (the consumers are the controller's then) and " + "consumer delivers from. Refused for a consumer the records do not compose. grants_user_list's last pass says what became of it.",
"for a consumer the records do not compose. grants_user_list's last pass says what became of it.",
Input: schema(map[string]string{"stream": "the stream, e.g. EVENTS", "consumer": "the consumer, e.g. ace_mesh-build-go"}, "stream", "consumer"), Input: schema(map[string]string{"stream": "the stream, e.g. EVENTS", "consumer": "the consumer, e.g. ace_mesh-build-go"}, "stream", "consumer"),
Run: func(a map[string]any) (any, error) { Run: func(a map[string]any) (any, error) {
return p.MatchConsumer(grants.Answer(a, "stream"), grants.Answer(a, "consumer")) return p.MatchConsumer(grants.Answer(a, "stream"), grants.Answer(a, "consumer"))
+27 -57
View File
@@ -11,7 +11,6 @@ import (
"maps" "maps"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -540,7 +539,7 @@ func TestGrants(t *testing.T) {
gap string gap string
}{ }{
{"every record on the bus", func(*fakeBus) {}, {"every record on the bus", func(*fakeBus) {},
[]string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "enrolment tokens"}, []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "people"},
{"a manifest missing is a gap and its user is left out", func(b *fakeBus) { delete(b.states[grants.ReadModules], "mod-y") }, {"a manifest missing is a gap and its user is left out", func(b *fakeBus) { delete(b.states[grants.ReadModules], "mod-y") },
[]string{"a.mod-x", "a.nats", "controller", "node.a", "node.b"}, "manifest of mod-y"}, []string{"a.mod-x", "a.nats", "controller", "node.a", "node.b"}, "manifest of mod-y"},
{"a want's built manifest is composed, not the trunk's; the list's records carry both (issue 774)", func(b *fakeBus) { {"a want's built manifest is composed, not the trunk's; the list's records carry both (issue 774)", func(b *fakeBus) {
@@ -551,7 +550,7 @@ func TestGrants(t *testing.T) {
b.put(grants.ReadWants, "want.a.mod-x", delivery.Want{V: delivery.Version, FP: fp, Composed: "sha256:" + strings.Repeat("ef", 32)}) b.put(grants.ReadWants, "want.a.mod-x", delivery.Want{V: delivery.Version, FP: fp, Composed: "sha256:" + strings.Repeat("ef", 32)})
key, _ := delivery.BuilderBuildKey("mod-x", fp) key, _ := delivery.BuilderBuildKey("mod-x", fp)
b.put(grants.ReadBuilds, key, map[string]any{"v": delivery.Version, "state": "done", "manifest": digest}) b.put(grants.ReadBuilds, key, map[string]any{"v": delivery.Version, "state": "done", "manifest": digest})
}, []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "enrolment tokens"}, }, []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "people"},
{"placement refused is a gap; the machines come from the haves", func(b *fakeBus) { b.refused[grants.ReadPlacement] = true }, {"placement refused is a gap; the machines come from the haves", func(b *fakeBus) { b.refused[grants.ReadPlacement] = true },
[]string{"controller", "node.a", "node.b"}, grants.ReadPlacement}, []string{"controller", "node.a", "node.b"}, grants.ReadPlacement},
} { } {
@@ -1316,7 +1315,7 @@ func TestGrants(t *testing.T) {
} }
// Written in shadow, the bus node told to follow the controller's list; every user left out while the vault holds // Written in shadow, the bus node told to follow the controller's list; every user left out while the vault holds
// no hash; a tool's part (no object bucket) writes none. // no hash; a tool's part (no object bucket) writes none.
if g, _, had := p.Sealed(); !had || g.Follow != delivery.FollowController || g.Revision != 1 || g.Node != "a" || if g, _, had := p.Sealed(); !had || g.Follow != delivery.FollowController || !g.LeaseHanded || g.Revision != 1 || g.Node != "a" ||
g.Users != 0 || len(g.LeftOut) != 6 || len(objs.objects) != 1 || !strings.Contains(open(g), "accounts {") { g.Users != 0 || len(g.LeftOut) != 6 || len(objs.objects) != 1 || !strings.Contains(open(g), "accounts {") {
t.Errorf("the sealed list in shadow = %+v, %d objects", g, len(objs.objects)) t.Errorf("the sealed list in shadow = %+v, %d objects", g, len(objs.objects))
} }
@@ -1328,14 +1327,15 @@ func TestGrants(t *testing.T) {
} else if _, _, had := tp.Sealed(); had { } else if _, _, had := tp.Sealed(); had {
t.Error("a tool's part wrote the sealed list") t.Error("a tool's part wrote the sealed list")
} }
if len(first.Published) != 3 || len(b.publishes) != 3 { // Written to the state alone, never said on their subject, with no lease key (1126/A1).
t.Errorf("the first composition published %v, want the three assignments' memberships", first.Published) if len(first.Published) != 3 || first.MembershipsWritten != 3 || len(b.publishes) != 0 {
t.Errorf("the first composition published %v (%d on the subject), want the three assignments' memberships in the state alone", first.Published, len(b.publishes))
} }
// The second pass says each membership unchanged (published at the first); the third writes nothing at all. // The second pass says each membership unchanged (published at the first); the third writes nothing at all.
if again := p.Compose(); again.Written || again.Revision != 1 || len(again.Published) != 0 || again.AssertionsWritten != 0 { if again := p.Compose(); again.Written || again.Revision != 1 || len(again.Published) != 0 || again.AssertionsWritten != 0 {
t.Errorf("a composition with no input changed wrote or published: %+v", again) t.Errorf("a composition with no input changed wrote or published: %+v", again)
} }
if third := p.Compose(); third.Written || third.MembershipsWritten != 0 || len(third.Published) != 0 || len(b.publishes) != 3 { if third := p.Compose(); third.Written || third.MembershipsWritten != 0 || len(third.Published) != 0 || len(b.publishes) != 0 {
t.Errorf("a membership equal to the one last published was published again, or a state written: %+v", third) t.Errorf("a membership equal to the one last published was published again, or a state written: %+v", third)
} }
// mod-x placed on b too: b.mod-x is added and its first membership published; a.mod-x's loses the module's plain // mod-x placed on b too: b.mod-x is added and its first membership published; a.mod-x's loses the module's plain
@@ -1402,8 +1402,6 @@ func TestGrants(t *testing.T) {
var out any var out any
if act == "take-over" { if act == "take-over" {
out, err = p.TakeOver("shanks.node-tools", "a row") out, err = p.TakeOver("shanks.node-tools", "a row")
} else {
out, err = p.HandBack("shanks.node-tools", "a row")
} }
if err != nil { if err != nil {
t.Fatalf("%s: %v", act, err) t.Fatalf("%s: %v", act, err)
@@ -1452,7 +1450,6 @@ func TestGrants(t *testing.T) {
}, []string{"a.mod-x: 1 read"}}, }, []string{"a.mod-x: 1 read"}},
{"a yes with only added rights and every hash held records the switch, the ask naming them", "take-over", yes, settled(lacks), nil, nil, grants.TookOver, "recorded", {"a yes with only added rights and every hash held records the switch, the ask naming them", "take-over", yes, settled(lacks), nil, nil, grants.TookOver, "recorded",
nil, []string{"adds 1 right to 1 user and takes none away", "a.mod-x: 1 read"}}, nil, []string{"adds 1 right to 1 user and takes none away", "a.mod-x: 1 read"}},
{"a yes to hand back records the switch back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "recorded", nil, nil},
} { } {
t.Run(row.name, func(t *testing.T) { t.Run(row.name, func(t *testing.T) {
row.setup() row.setup()
@@ -1522,8 +1519,8 @@ func TestGrants(t *testing.T) {
// The key the bus node follows turns with the switch at once, and the state says switched only once the bus // The key the bus node follows turns with the switch at once, and the state says switched only once the bus
// node loaded the grants part's written list. // node loaded the grants part's written list.
g, _, _ := p.Sealed() g, _, _ := p.Sealed()
if want := map[string]string{grants.TookOver: delivery.FollowGrants, grants.Shadow: delivery.FollowController}[p.SwitchState()]; g.Follow != want { if want := map[string]string{grants.TookOver: delivery.FollowGrants, grants.Shadow: delivery.FollowController}[p.SwitchState()]; g.Follow != want || !g.LeaseHanded {
t.Errorf("the key says follow %s with the switch %s", g.Follow, p.SwitchState()) t.Errorf("the key says follow %s, lease handed %v, with the switch %s and no lease key (1126/A1/AC2)", g.Follow, g.LeaseHanded, p.SwitchState())
} }
effect := func() string { st, _ := p.TakeOverState(); return fmt.Sprint(st.(map[string]any)["effect"]) } effect := func() string { st, _ := p.TakeOverState(); return fmt.Sprint(st.(map[string]any)["effect"]) }
if row.outcome == "recorded" && row.act == "take-over" { if row.outcome == "recorded" && row.act == "take-over" {
@@ -1663,10 +1660,11 @@ func TestGrants(t *testing.T) {
} }
}) })
} }
t.Run("hand-back in shadow is refused at once, with nothing asked", func(t *testing.T) { // 1126/A1: the controller ended by its unassignment, so nothing writes its list and a hand-back is gone.
t.Run("hand-back is gone at once, with nothing asked", func(t *testing.T) {
n := len(fa.asks) n := len(fa.asks)
if _, err := p.HandBack("shanks.node-tools", "a row"); err == nil || !strings.Contains(err.Error(), "nothing to hand back") { if _, err := p.HandBack("shanks.node-tools", "a row"); err == nil || !strings.HasPrefix(err.Error(), "gone") {
t.Errorf("hand-back in shadow = %v", err) t.Errorf("hand-back = %v", err)
} }
p.Compose() p.Compose()
if len(fa.asks) != n { if len(fa.asks) != n {
@@ -1674,6 +1672,8 @@ func TestGrants(t *testing.T) {
} }
}) })
t.Run("an ask that can stay open is closed every way and can be asked again", func(t *testing.T) { t.Run("an ask that can stay open is closed every way and can be asked again", func(t *testing.T) {
// The rows above end taken over and a hand-back is gone (1126/A1): asked again from a part in shadow.
delete(b.own[grants.StateTakeover], grants.KeySwitch)
// A warrant the process missed is caught up from the router's record, whatever its outcome. // A warrant the process missed is caught up from the router's record, whatever its outcome.
id := asked("take-over") id := asked("take-over")
p.Compose() p.Compose()
@@ -1843,11 +1843,10 @@ func TestGrants(t *testing.T) {
if row.names == "" && row.last != nil && !strings.Contains(string(state), "10.0.0.1") { if row.names == "" && row.last != nil && !strings.Contains(string(state), "10.0.0.1") {
t.Errorf("the private network of the last membership is not carried: %s", state) t.Errorf("the private network of the last membership is not carried: %s", state)
} }
// Written to the state alone, never said on its subject (1126/A1).
got, err := membership.DecodeGrantsMembership(state) got, err := membership.DecodeGrantsMembership(state)
var sent membership.Membership if err != nil || got.Node != "a" || got.Module != "mod-x" || bytes.Contains(state, []byte(`"membership"`)) || len(b.stream) != 0 {
_ = json.Unmarshal(b.stream["mesh.assignment."+key], &sent) t.Errorf("the state is not the bare membership published, or its subject said (%v, %d said): %s", err, len(b.stream), state)
if err != nil || !reflect.DeepEqual(got, sent) || bytes.Contains(state, []byte(`"membership"`)) {
t.Errorf("the state is not the bare membership published (%v): %s", err, state)
} }
if !strings.Contains(pass, row.names) { if !strings.Contains(pass, row.names) {
t.Errorf("the pass does not name %q: %s", row.names, pass) t.Errorf("the pass does not name %q: %s", row.names, pass)
@@ -1866,23 +1865,6 @@ func TestGrants(t *testing.T) {
} }
}) })
} }
// The assignments stream removed (nats_stream_remove at the lease write) and its subject refused: every membership
// is still written to the state, where each runtime reads it, and the refusal is said (issue 774).
t.Run("the stream gone and its subject refused: memberships still written, the refusal said", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
b.refusePub = true
p := partOn(b)
refused := p.Compose()
if !slices.Equal(refused.Published, []string{"a.mod-x", "a.nats", "b.mod-y"}) || refused.MembershipsWritten != 3 ||
!strings.Contains(strings.Join(refused.Errors, "\n"), "Permissions Violation") {
t.Errorf("with the stream gone = %+v: every membership written, the refusal said", refused)
}
b.refusePub = false
if next := p.Compose(); len(next.Published) != 0 || next.MembershipsWritten != 0 {
t.Errorf("the next event published %v again", next.Published)
}
})
t.Run("a tool answers while a pass waits on the bus", func(t *testing.T) { t.Run("a tool answers while a pass waits on the bus", func(t *testing.T) {
b := fixture() b := fixture()
b.loaded = shadowOf(t, b) b.loaded = shadowOf(t, b)
@@ -2251,7 +2233,7 @@ func TestGrants(t *testing.T) {
} }
return nil return nil
} }
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) // With no lease key (1126/A1).
// The private network the mesh provides is placed with no fingerprint by construction: never a missing grant. // The private network the mesh provides is placed with no fingerprint by construction: never a missing grant.
b.assign("b", "mesh-wireguard") b.assign("b", "mesh-wireguard")
fp := "src2:d4afe7a1abab5ccdbef6d8629b2245006a2aec60290f430f7b4756e2930ed60c" fp := "src2:d4afe7a1abab5ccdbef6d8629b2245006a2aec60290f430f7b4756e2930ed60c"
@@ -2422,7 +2404,6 @@ func TestGrants(t *testing.T) {
b := fixture() b := fixture()
b.manifestOf("mesh-enrolment", enrolmentManifest) b.manifestOf("mesh-enrolment", enrolmentManifest)
b.assign("a", "mesh-enrolment") b.assign("a", "mesh-enrolment")
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
for node, stage := range map[string]string{"lab": delivery.EnrolmentLive, "pi": delivery.EnrolmentAsked} { for node, stage := range map[string]string{"lab": delivery.EnrolmentLive, "pi": delivery.EnrolmentAsked} {
raw, err := delivery.EncodeEnrolment(delivery.Enrolment{Node: node, Address: "10.0.0.7", Why: "joins", raw, err := delivery.EncodeEnrolment(delivery.Enrolment{Node: node, Address: "10.0.0.7", Why: "joins",
@@ -2545,27 +2526,21 @@ func TestGrants(t *testing.T) {
} }
}) })
// 774/AC6 (novox/hq ADR 0561 §6): the durable consumers an account needs, with the controller's configuration, are // 774/AC6 (novox/hq ADR 0561 §6), 1126/A1/AC1: the durable consumers an account needs, with the composed
// compared in shadow while the controller holds the lease, and created, only where the bus lacks them, from the write // configuration, are created where the bus lacks them with no lease key at all (the controller ended by its
// that hands the lease away; one the bus holds is never changed. // unassignment, never by the lease write); one the bus holds is never changed.
t.Run("account consumers: compared while the controller holds the lease, created once it is handed away", func(t *testing.T) { t.Run("account consumers: created where the bus lacks them, with no lease key", func(t *testing.T) {
const cManifest = `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["mod-y.done"]}` const cManifest = `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["mod-y.done"]}`
b := fixture() b := fixture()
b.manifestOf("mod-c", cManifest) b.manifestOf("mod-c", cManifest)
b.assign("b", "mod-c") b.assign("b", "mod-c")
b.loaded = shadowOf(t, b) b.loaded = shadowOf(t, b)
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/1/2026-10-27", "epoch": 57})
p := partOn(b) p := partOn(b)
p.Consumers = b p.Consumers = b
res := p.Compose()
if res.Consumers == nil || res.Consumers.On || !slices.Contains(res.Consumers.Missing, "EVENTS.b_mod-c") || len(b.consumerCreates) != 0 {
t.Fatalf("while the controller holds the lease: %+v, created %v", res.Consumers, b.consumerCreates)
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
b.consumers = map[string][]map[string]any{"EVENTS": {{"name": "b_other", "filters": []string{"x"}, "max_deliver": 5}}} b.consumers = map[string][]map[string]any{"EVENTS": {{"name": "b_other", "filters": []string{"x"}, "max_deliver": 5}}}
res = p.Compose() res := p.Compose()
if !res.Consumers.On || !slices.Equal(res.Consumers.Created, []string{"EVENTS.b_mod-c"}) { if res.Consumers == nil || !slices.Equal(res.Consumers.Created, []string{"EVENTS.b_mod-c"}) {
t.Fatalf("once handed away: %+v", res.Consumers) t.Fatalf("with no lease key: %+v", res.Consumers)
} }
var made map[string]any var made map[string]any
for _, c := range b.consumers["EVENTS"] { for _, c := range b.consumers["EVENTS"] {
@@ -2581,7 +2556,7 @@ func TestGrants(t *testing.T) {
t.Errorf("a consumer the bus holds was asked again: %+v, %v", res.Consumers, b.consumerCreates) t.Errorf("a consumer the bus holds was asked again: %+v, %v", res.Consumers, b.consumerCreates)
} }
// A held consumer that differs is never changed by a pass: it is raised as a condition naming it, and brought to // A held consumer that differs is never changed by a pass: it is raised as a condition naming it, and brought to
// the composed configuration only by hand (grants_consumer_match), never while the controller holds the lease. // the composed configuration only by hand (grants_consumer_match), with no lease key too.
var said []asks.Condition var said []asks.Condition
p.Say = func(c asks.Condition) error { p.Say = func(c asks.Condition) error {
if c.Key != grants.GrantMissingCondition && c.Key != grants.RefusedCondition { // their own rows if c.Key != grants.GrantMissingCondition && c.Key != grants.RefusedCondition { // their own rows
@@ -2596,11 +2571,6 @@ func TestGrants(t *testing.T) {
!strings.Contains(said[0].Explanation, "EVENTS.b_mod-c") { !strings.Contains(said[0].Explanation, "EVENTS.b_mod-c") {
t.Fatalf("a differing consumer: %+v, said %+v", res.Consumers, said) t.Fatalf("a differing consumer: %+v, said %+v", res.Consumers, said)
} }
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/2/2026-10-27", "epoch": 58})
if _, err := p.MatchConsumer("EVENTS", "b_mod-c"); err == nil || !strings.Contains(err.Error(), "controller holds the lease") {
t.Errorf("matched by hand under the controller's lease: %v", err)
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
if _, err := p.MatchConsumer("EVENTS", "b_nothing"); err == nil { if _, err := p.MatchConsumer("EVENTS", "b_nothing"); err == nil {
t.Error("a consumer the records do not compose was asked to match") t.Error("a consumer the records do not compose was asked to match")
} }
@@ -38,7 +38,8 @@ type ConsumerCreator interface {
// ConsumersState is what one pass found and did of the account consumers. // ConsumersState is what one pass found and did of the account consumers.
type ConsumersState struct { type ConsumersState struct {
// On says the lease is handed away, so the part creates what the bus lacks; Why says why it is on or off. // On says the part creates what the bus lacks: always, since the controller ended by its unassignment (issue 1126);
// Why says so.
On bool `json:"on"` On bool `json:"on"`
Why string `json:"why"` Why string `json:"why"`
// Wanted is how many the records compose; Missing those the bus lacks (`<stream>.<name>`), Differ those it holds // Wanted is how many the records compose; Missing those the bus lacks (`<stream>.<name>`), Differ those it holds
@@ -88,14 +89,13 @@ func heldConsumers(c Caller, stream string) (map[string]heldConsumer, error) {
return out, nil return out, nil
} }
// leaseHandedAway reads the lease as the part's reads give it. // ConsumersOn is why the part creates and matches the account consumers with no lease read: the controller ended by its
func leaseHandedAway(r Reader) (bool, string) { // unassignment, never by the lease write, so its lease names no holder for good and the part is their only maker
raw, found, err := r.Get(ReadLease, LeaseKey) // (issue 1126; mesh-sdk's buscompose.LeaseHandedAway answers false for an absent key, so it is no longer asked here).
return buscompose.LeaseHandedAway(raw, found, err) const ConsumersOn = "the grants part makes and matches the account consumers: the controller ended by its unassignment (issue 1126)"
}
// consumers compares the account consumers the records compose with those the bus holds, and creates the missing ones // consumers compares the account consumers the records compose with those the bus holds, and creates the missing ones
// once the lease is handed away and the part holds a creator. // where the part holds a creator.
func (p *Part) consumers(res *Result, recs buscompose.Records, match string) { func (p *Part) consumers(res *Result, recs buscompose.Records, match string) {
users, err := buscompose.Users(recs) users, err := buscompose.Users(recs)
if err != nil { if err != nil {
@@ -103,7 +103,7 @@ func (p *Part) consumers(res *Result, recs buscompose.Records, match string) {
return return
} }
st := &ConsumersState{} st := &ConsumersState{}
st.On, st.Why = leaseHandedAway(p.Read) st.On, st.Why = true, ConsumersOn
res.Consumers = st res.Consumers = st
wanted := buscompose.AccountConsumers(users) wanted := buscompose.AccountConsumers(users)
st.Wanted = len(wanted) st.Wanted = len(wanted)
@@ -154,9 +154,8 @@ func (p *Part) consumers(res *Result, recs buscompose.Records, match string) {
} }
// sayDiffer raises the warning condition mesh-grants.consumer-differs while an account consumer the part owns (from the // sayDiffer raises the warning condition mesh-grants.consumer-differs while an account consumer the part owns (from the
// lease write) differs from its composed configuration, naming each with the fields that differ, and clears it when they // controller's end) differs from its composed configuration, naming each with the fields that differ, and clears it when
// match. Said once per set of differences, again when it changes. Before the lease write the consumers are the // they match. Said once per set of differences, again when it changes.
// controller's, and a difference is only said in the pass.
func (p *Part) sayDiffer(st *ConsumersState) { func (p *Part) sayDiffer(st *ConsumersState) {
if p.Say == nil { if p.Say == nil {
return return
@@ -190,15 +189,12 @@ func (p *Part) sayDiffer(st *ConsumersState) {
const ConsumerDiffersCondition = "mesh-grants.consumer-differs" const ConsumerDiffersCondition = "mesh-grants.consumer-differs"
// MatchConsumer is the tool consumer-match (ADR 0561 §6, §9): it asks the part's process to bring one account consumer // MatchConsumer is the tool consumer-match (ADR 0561 §6, §9): it asks the part's process to bring one account consumer
// the records compose to its composed configuration, keeping where it delivers from. Refused while the controller holds // the records compose to its composed configuration, keeping where it delivers from. Refused for a
// the lease (the consumers are the controller's then), and for a consumer the records do not compose. // consumer the records do not compose.
func (p *Part) MatchConsumer(stream, name string) (any, error) { func (p *Part) MatchConsumer(stream, name string) (any, error) {
if stream == "" || name == "" { if stream == "" || name == "" {
return nil, errors.New("consumer-match names a stream and a consumer") return nil, errors.New("consumer-match names a stream and a consumer")
} }
if on, why := leaseHandedAway(p.Read); !on {
return nil, fmt.Errorf("not asked: %s; the controller makes and matches the consumers while it holds the lease", why)
}
in := ReadInputs(p.Read, &p.cache) in := ReadInputs(p.Read, &p.cache)
recs, _ := Records(in) recs, _ := Records(in)
users, err := buscompose.Users(recs) users, err := buscompose.Users(recs)
@@ -314,15 +314,15 @@ func orSaid(s, none string) string {
return s return s
} }
// sayMissing raises mesh-grants.grant-missing while a placed module has no grant, once the controller's lease is handed // sayMissing raises mesh-grants.grant-missing while a placed module has no grant (the part's grants are the ones the
// away (the part's grants are then the ones the assigner reads), and clears it when every one has one; in shadow the // assigner reads since the controller's end, issue 1126), and clears it when every one has one. Said once per set, again
// pass and the tool say them. Said once per set, again when it changes, and always at the first pass after a start. // when it changes, and always at the first pass after a start.
func (p *Part) sayMissing(missing []string) { func (p *Part) sayMissing(missing []string) {
if p.Say == nil { if p.Say == nil {
return return
} }
why := "" why := ""
if on, _ := leaseHandedAway(p.Read); on && len(missing) > 0 { if len(missing) > 0 {
why = strings.Join(missing, "; ") why = strings.Join(missing, "; ")
} }
p.holdMu.Lock() p.holdMu.Lock()
+2 -12
View File
@@ -81,8 +81,6 @@ type Gap struct {
// Standing gaps: what the controller composes from its own store, which no bus value carries in this milestone. // Standing gaps: what the controller composes from its own store, which no bus value carries in this milestone.
var standingGaps = []Gap{ var standingGaps = []Gap{
{Input: "enrolment tokens", Why: "while the controller holds the lease, its live enrolment tokens are in its store alone: " +
"an enrolment user is composed only from the seat mesh-enrolment's keys"},
{Input: "people", Why: "the people and the tools they may invoke are in the controller's store alone: no person user is composed"}, {Input: "people", Why: "the people and the tools they may invoke are in the controller's store alone: no person user is composed"},
{Input: "seat holdings", Why: "who holds each seat held once for the mesh is in the controller's store alone: a mesh seat claimed on two machines is composed as held on both"}, {Input: "seat holdings", Why: "who holds each seat held once for the mesh is in the controller's store alone: a mesh seat claimed on two machines is composed as held on both"},
{Input: "announced states", Why: "the controller does not announce its states-announced readable, so every read is composed from `reads`, as when announcements cannot be read"}, {Input: "announced states", Why: "the controller does not announce its states-announced readable, so every read is composed from `reads`, as when announcements cannot be read"},
@@ -131,9 +129,6 @@ type Inputs struct {
// NodeLogins is each node's key of the vault's node-logins (ReadNodeLogins, ADR 0583 §1); a node absent logs in as // NodeLogins is each node's key of the vault's node-logins (ReadNodeLogins, ADR 0583 §1); a node absent logs in as
// node.<n> alone. // node.<n> alone.
NodeLogins map[string]delivery.NodeLogin NodeLogins map[string]delivery.NodeLogin
// LeaseAway says the controller's lease is handed away, as ReadInputs read it: an enrolment user then enrols through
// the seat alone (ADR 0587 §6).
LeaseAway bool
// Refused is every manifest the indexer holds that the part cannot read or mesh-sdk's reader refuses, assigned or // Refused is every manifest the indexer holds that the part cannot read or mesh-sdk's reader refuses, assigned or
// not, each with why: its module counts as placed nowhere, so it is said loudly, never passed over (issue 1945). // not, each with why: its module counts as placed nowhere, so it is said loudly, never passed over (issue 1945).
Refused []Gap Refused []Gap
@@ -280,7 +275,6 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
in.Placed[node] = p in.Placed[node] = p
} }
} }
in.LeaseAway, _ = leaseHandedAway(r)
for node, p := range in.Placed { for node, p := range in.Placed {
nodes[node] = true nodes[node] = true
for _, m := range p.Assigned() { for _, m := range p.Assigned() {
@@ -448,12 +442,8 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
in.Gaps = append(in.Gaps, Gap{Input: "wanted manifests", Why: "no build record names the manifest at the wanted fingerprint, so the trunk's is used: " + strings.Join(fromTrunk, ", ")}) in.Gaps = append(in.Gaps, Gap{Input: "wanted manifests", Why: "no build record names the manifest at the wanted fingerprint, so the trunk's is used: " + strings.Join(fromTrunk, ", ")})
} }
in.Hashes = readHashes(r, &in.Gaps) in.Hashes = readHashes(r, &in.Gaps)
// The controller's tokens stand as a gap while it holds the lease; from the write the seat's keys are every token. // The seat mesh-enrolment's keys are every token since the controller's end (issue 1126).
var enrolmentsRead bool in.Enrolling, _ = readEnrolling(r, &in.Gaps)
in.Enrolling, enrolmentsRead = readEnrolling(r, &in.Gaps)
if enrolmentsRead && in.LeaseAway {
in.Gaps = slices.DeleteFunc(in.Gaps, func(g Gap) bool { return g.Input == "enrolment tokens" })
}
in.NodeLogins = readNodeLogins(r, &in.Gaps) in.NodeLogins = readNodeLogins(r, &in.Gaps)
for _, raw := range each(r, ReadResolved, &in.Gaps) { for _, raw := range each(r, ReadResolved, &in.Gaps) {
var v resolved var v resolved
+2 -9
View File
@@ -337,10 +337,8 @@ func (p *Part) Compose() Result {
members = append(members, m.Membership) members = append(members, m.Membership)
} }
seated := holdings.ByMemberships(members...) seated := holdings.ByMemberships(members...)
// From the lease write the memberships are written to the state alone and no longer said on their subject, which // The memberships are written to the state alone and never said on their subject, which nothing reads any more
// nothing reads any more (novox/hq ADR 0571 clause 10, issue 774). // (novox/hq ADR 0571 clause 10, issue 774; unconditional since the controller's end, issue 1126).
notice, _ := leaseHandedAway(p.Read)
notice = !notice
for _, key := range sortedKeys(ms) { for _, key := range sortedKeys(ms) {
// The last membership is the state's own (ADR 0571 clause 7), never the assignments stream's, which the bus // The last membership is the state's own (ADR 0571 clause 7), never the assignments stream's, which the bus
// module's tools remove at the lease write (research 072 row 33): a last read there would freeze every membership // module's tools remove at the lease write (research 072 row 33): a last read there would freeze every membership
@@ -360,11 +358,6 @@ func (p *Part) Compose() Result {
m.Published, now = "not published (tried again at the next event): its own "+StateMemberships+" cannot be written", false m.Published, now = "not published (tried again at the next event): its own "+StateMemberships+" cannot be written", false
} else { } else {
res.MembershipsWritten++ res.MembershipsWritten++
if notice {
if err := p.Publish.PublishMembership(m.Node, m.Module, body); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("the membership of %s is written, its notice on its subject not said: %v", key, err))
}
}
} }
} }
// One unchanged is written bare once where the state still holds the wrapper written before 2026-10-30. // One unchanged is written bare once where the state still holds the wrapper written before 2026-10-30.
@@ -39,7 +39,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) {
// The enrolment users, from the seat mesh-enrolment's keys, and from the lease write through its redeem alone (ADR 0587 // The enrolment users, from the seat mesh-enrolment's keys, and from the lease write through its redeem alone (ADR 0587
// §4, §6). // §4, §6).
out := buscompose.Records{Assigned: map[string][]buscompose.Declared{}, People: map[string][]string{}, out := buscompose.Records{Assigned: map[string][]buscompose.Declared{}, People: map[string][]string{},
Interchangeable: map[string]bool{}, Enrolling: slices.Clone(in.Enrolling), LeaseHandedAway: in.LeaseAway, Interchangeable: map[string]bool{}, Enrolling: slices.Clone(in.Enrolling), LeaseHandedAway: true,
// And each node's logins (ADR 0583 §1): both while it rotates. // And each node's logins (ADR 0583 §1): both while it rotates.
NodeLogins: in.NodeLogins} NodeLogins: in.NodeLogins}
values := func(provider, setting string) []any { values := func(provider, setting string) []any {
@@ -95,8 +95,9 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut, dropped
cur, _, had := p.Sealed() cur, _, had := p.Sealed()
next := cur next := cur
next.Follow = followOf(p.SwitchState()) next.Follow = followOf(p.SwitchState())
// The bus node follows this list alone once the lease is handed (ADR 0571 clause 8, ADR 0582 clause 3). // The bus node follows this list alone (ADR 0571 clause 8): always since the controller ended by its unassignment,
next.LeaseHanded = p.leaseHanded() // with no lease written (issue 1126).
next.LeaseHanded = true
key := "" key := ""
if in.Loaded != nil { if in.Loaded != nil {
key = in.Loaded.Key key = in.Loaded.Key
@@ -132,14 +132,11 @@ func (p *Part) SwitchState() string {
// the refusals of Decide. Asking again while an ask is open is refused. // the refusals of Decide. Asking again while an ask is open is refused.
func (p *Part) TakeOver(caller, why string) (any, error) { return p.request(ActTakeOver, caller, why) } func (p *Part) TakeOver(caller, why string) (any, error) { return p.request(ActTakeOver, caller, why) }
// HandBack is the tool: it asks the operator to switch the bus node back to the controller's list. Once the lease is // HandBack is the tool: it is answered as gone. The controller ended by its unassignment (issue 1126), so nothing
// handed to the grants part it is answered as gone: the grants part's list is the only one (ADR 0547 §11). // writes its list and the grants part's list is the only one (ADR 0547 §11).
func (p *Part) HandBack(caller, why string) (any, error) { func (p *Part) HandBack(caller, why string) (any, error) {
if p.leaseHanded() { return nil, errors.New("gone: the controller ended by its unassignment (issue 1126), so nothing writes its user list; " +
return nil, errors.New("gone: the controller's lease is handed to mesh-grants, so the grants part's list is the only " + "the grants part's list is the only one and the user list is not handed back (ADR 0547 §11)")
"one and the user list is not handed back (ADR 0547 §11); lease-hand-back gives the lease back first")
}
return p.request(ActHandBack, caller, why)
} }
// nonce is four hex digits that keep two asks of one second apart. // nonce is four hex digits that keep two asks of one second apart.
@@ -546,13 +543,7 @@ func (p *Part) recordWarrant(ask string) {
if p.Call == nil { if p.Call == nil {
return return
} }
rec := handacts.Recorder{Ask: p.Call.Call, LeaseAway: func() bool { rec := handacts.Recorder{Ask: p.Call.Call}
if p.Read == nil {
return false
}
away, _ := leaseHandedAway(p.Read)
return away
}}
taken, err := rec.RecordWarrant(Module, ask) taken, err := rec.RecordWarrant(Module, ask)
if p.Logf == nil { if p.Logf == nil {
return return