From 1ca0af7a2d0582657cd5dae247c6ebdf2b77066b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:50:29 +0100 Subject: [PATCH] =?UTF-8?q?Assigner:=20who=20may=20add=20a=20node=20is=20r?= =?UTF-8?q?ead=20from=20the=20holders=20of=20the=20seat=20mesh-node-enroll?= =?UTF-8?q?er,=20not=20from=20a=20module's=20name=20(B7f=20finding=205;=20?= =?UTF-8?q?ADR=200634=20=C2=A71;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../integration_test.go | 9 +++++ .../cmd/mesh-delivery-assigner/seat_tools.go | 17 +++++---- .../mesh-delivery-assigner/seat_tools_test.go | 5 ++- modules/mesh-delivery-assigner/go.mod | 2 + .../internal/placement/placement_test.go | 18 ++++++--- .../internal/placement/proposal.go | 37 ++++++++++++++++--- 6 files changed, 68 insertions(+), 20 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index a8c18e92..fd1ec37a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -1142,12 +1142,16 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { } b.putJSON(t, buckets.Modules, "web", []byte(`{"v":1}`)) b.putJSON(t, buckets.Modules, "shell", []byte(`{"v":1}`)) + b.putJSON(t, buckets.Modules, "mesh-enrolment", []byte(`{"v":1}`)) // place writes a node's placement as another writer would, in its node and module keys (research 072 row 37), and // answers its node key's revision; legacy writes it in the form before, placement., alone. place := func(node string, legacy bool, modules ...string) uint64 { p := mma.Placement{Node: node, Modules: []mma.PlacedModule{}} for _, m := range modules { p.Modules = append(p.Modules, mma.PlacedModule{Module: m}) + if m == "mesh-enrolment" { // the module that holds the seat mesh-node-enroller: who may add a node is read from it + p.Holds = append(p.Holds, mma.Holding{Claim: "mesh-node-enroller", Scope: "mesh", Module: m}) + } } values, _ := mmac.PlacementValues(p) key, _ := mmac.NodeKey(node) @@ -1309,6 +1313,11 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { for len(heard) > 0 { <-heard } + // The module holding the seat mesh-node-enroller is the one whose add-node is taken, whatever its name: it is placed + // now, on a node of its own. + hubRecord, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "hub"}}) + b.putJSON(t, buckets.Have, "hub.node", hubRecord) + place("hub", false, "mesh-enrolment") joined, err := verbs.doPropose(map[string]any{"why": "enrolment of ace", "warrant": `{"node":"ace","address":"10.9.0.2","ask":"enrol-ace-1","warrant":"enrol-ace-1@2026-10-28T20:00:00Z"}`, "changes": `[{"op":"add-node","node":"ace","decided":{"address":"10.9.0.2"},"keys":{"overlay":"dHVubmVs"}}]`}, "novox.mesh-enrolment") if err != nil { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go index beab6d65..613a5c2a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go @@ -225,10 +225,11 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { if err != nil { return nil, err } + enrollers := plc.EnrollersOf(current) now := s.now() p, laid, findings := assign.Propose(assign.ProposeIn{ID: plc.NewProposalID(now), By: by, Why: arg(args, "why"), At: now, Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Warrant: warrant}) - if refusal := plc.AddNodeRefusal(p, moduleOfCaller(by), delivery.EnrolmentModule); refusal != "" { + if refusal := plc.AddNodeRefusal(p, moduleOfCaller(by, enrollers), enrollers); refusal != "" { findings = append(findings, refusal) } if len(findings) > 0 { @@ -266,12 +267,14 @@ func warrantOf(v any) (*mma.Warrant, error) { return &out, nil } -// moduleOfCaller is the module a caller is, as the runtime proves it, for AddNodeRefusal: mesh-enrolment when the proven -// caller is that module's (its bus user `.mesh-enrolment`, never a person's `person.mesh-enrolment`), else the caller -// as proven, which the refusal names. -func moduleOfCaller(by string) string { - if (stdio.Callers{delivery.EnrolmentModule}).Allows(by) { - return delivery.EnrolmentModule +// moduleOfCaller is the module a caller is, as the runtime proves it, for AddNodeRefusal: the holder of the seat +// mesh-node-enroller when the proven caller is that module's (its bus user `.`, never a person's +// `person.`), else the caller as proven, which the refusal names. +func moduleOfCaller(by string, enrollers []string) string { + for _, module := range enrollers { + if (stdio.Callers{module}).Allows(by) { + return module + } } return by } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index 9d8ab40b..c6e34b3b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -46,12 +46,13 @@ func (s memStore) history(state, key string) ([]stdio.StateRevision, error) { // or refuses naming every finding and writes nothing; withdraw and again mark it for the holder; placement and // proposals read every state the seat keeps, and history names the stream for what the state no longer keeps. func TestTheSeatsVerbs(t *testing.T) { - anchor := mma.Placement{V: keys.Version, Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}} + anchor := mma.Placement{V: keys.Version, Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}, {Module: "mesh-enrolment"}}, + Holds: []mma.Holding{{Claim: "mesh-node-enroller", Scope: "mesh", Module: "mesh-enrolment"}}} st := memStore{} for _, state := range []string{mma.StatePlacement, mma.StateProposals, mma.StateCandidates, modulesState, haveState} { st[state] = &memKV{values: map[string][]byte{}} } - for _, m := range []string{"web", "shell"} { + for _, m := range []string{"web", "shell", "mesh-enrolment"} { st[modulesState].values[m] = []byte("{}") } for _, n := range []string{"anchor", "laptop"} { diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 2c4d5778..f24964c9 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -54,3 +54,5 @@ require git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0- replace git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery => ../../contracts/go/seats/meshdelivery require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 require git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-00010101000000-000000000000 + +require git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-delivery-assigner/internal/placement/placement_test.go b/modules/mesh-delivery-assigner/internal/placement/placement_test.go index ae1f396c..acffae50 100644 --- a/modules/mesh-delivery-assigner/internal/placement/placement_test.go +++ b/modules/mesh-delivery-assigner/internal/placement/placement_test.go @@ -33,7 +33,13 @@ func TestPlacement(t *testing.T) { Holds: []seat.Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}} current := map[string]seat.Placement{"g14": g14, "novox": novox} const id = "p-20261028t183000-1a2b3c" - const enroller = "mesh-enrolment" + // the enroller is whichever module holds the seat mesh-node-enroller: no module is named for it + const enroller = "enroll-lab" + enrollers := EnrollersOf(map[string]seat.Placement{"ace": {Node: "ace", + Holds: []seat.Holding{{Claim: "mesh-node-enroller", Scope: "mesh", Module: enroller}}}}) + if !slices.Equal(enrollers, []string{enroller}) { + t.Fatalf("the holders of the enroller seat = %v", enrollers) + } t.Run("what a change says", func(t *testing.T) { after := g14 @@ -200,8 +206,8 @@ func TestPlacement(t *testing.T) { } else if back, err := DecodeProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" { t.Errorf("an add-node round trip: %+v, %v", back, err) } - if AddNodeRefusal(add, enroller, enroller) != "" || !strings.Contains(AddNodeRefusal(add, "mesh-delivery-assigner", enroller), "mesh-enrolment's alone") { - t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, enroller, enroller), AddNodeRefusal(add, "mesh-delivery-assigner", enroller)) + if AddNodeRefusal(add, enroller, enrollers) != "" || !strings.Contains(AddNodeRefusal(add, "mesh-delivery-assigner", enrollers), "enroll-lab's alone") { + t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, enroller, enrollers), AddNodeRefusal(add, "mesh-delivery-assigner", enrollers)) } if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 { t.Errorf("an add-node laid: %+v, %v", laid, f) @@ -257,9 +263,9 @@ func TestPlacement(t *testing.T) { // An enrolment's end takes its never-confirmed node off again: the enroller's alone, of a node running nothing. remove := add remove.Changes = []seat.ProposalChange{{Op: client.OpRemoveNode, Node: "lab"}} - if _, err := EncodeProposal(remove); err != nil || AddNodeRefusal(remove, "mesh-delivery-assigner", enroller) == "" || - AddNodeRefusal(remove, enroller, enroller) != "" || !slices.Equal(RemovedNodes(remove), []string{"lab"}) { - t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, "mesh-delivery-assigner", enroller)) + if _, err := EncodeProposal(remove); err != nil || AddNodeRefusal(remove, "mesh-delivery-assigner", enrollers) == "" || + AddNodeRefusal(remove, enroller, enrollers) != "" || !slices.Equal(RemovedNodes(remove), []string{"lab"}) { + t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, "mesh-delivery-assigner", enrollers)) } joined := map[string]seat.Placement{"lab": {V: keys.Version, Node: "lab", Decided: seat.NodeDecided{Address: "10.0.0.7"}, Modules: []seat.PlacedModule{}}} if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 { diff --git a/modules/mesh-delivery-assigner/internal/placement/proposal.go b/modules/mesh-delivery-assigner/internal/placement/proposal.go index cdf6ea6a..ecc089c1 100644 --- a/modules/mesh-delivery-assigner/internal/placement/proposal.go +++ b/modules/mesh-delivery-assigner/internal/placement/proposal.go @@ -5,9 +5,11 @@ import ( "encoding/hex" "encoding/json" "fmt" + "slices" "strings" "time" + enroller "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller" client "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" seat "git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner" "git.novox.be/novox/mesh-sdk/go/keys" @@ -366,18 +368,43 @@ func checkAddNode(p seat.Proposal) error { } // AddNodeRefusal is why the holder of the seat refuses an add-node or a remove-node from caller, the module the runtime -// proved called (ADR 0587 §7), or "": only the enroller (the module holding the seat mesh-node-enroller, which the caller -// of this names) adds a node; what the proposal itself must hold, CheckProposal refuses. -func AddNodeRefusal(p seat.Proposal, caller, enroller string) string { +// proved called (ADR 0587 §7), or "": only a holder of the seat mesh-node-enroller (enrollers, EnrollersOf) adds a node; +// what the proposal itself must hold, CheckProposal refuses. +func AddNodeRefusal(p seat.Proposal, caller string, enrollers []string) string { if err := checkAddNode(p); err != nil { return err.Error() } - if p.Warrant != nil && caller != enroller { - return fmt.Sprintf("an add-node or a remove-node is %s's alone, and %s proposed it", enroller, caller) + if p.Warrant != nil && !slices.Contains(enrollers, caller) { + if len(enrollers) == 0 { + return fmt.Sprintf("an add-node or a remove-node is the holder's of the seat %s alone, nobody holds it, and %s proposed it", enroller.Seat, caller) + } + return fmt.Sprintf("an add-node or a remove-node is %s's alone, and %s proposed it", strings.Join(enrollers, ", "), caller) } return "" } +// EnrollersOf is the modules that hold the seat mesh-node-enroller on the placements given, sorted: the state seat-holders +// composed from them (SeatHoldersOf), read by the client's HoldersOf. +func EnrollersOf(placements map[string]seat.Placement) []string { + holders, _ := client.HoldersOf(rawHolders(SeatHoldersOf(placements)), enroller.Seat, "") + var out []string + for _, h := range holders { + if !slices.Contains(out, h.Module) { + out = append(out, h.Module) + } + } + slices.Sort(out) + return out +} + +func rawHolders(held map[string]seat.SeatHolders) map[string][]byte { + out := map[string][]byte{} + for k, v := range held { + out[k], _ = json.Marshal(v) + } + return out +} + // EncodeProposal is p as JSON at the keys' Version; it refuses what DecodeProposal refuses. func EncodeProposal(p seat.Proposal) ([]byte, error) { if err := CheckProposal(p); err != nil {