Assigner: who may add a node is read from the holders of the seat mesh-node-enroller, not from a module's name (B7f finding 5; ADR 0634 §1; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:50:29 +01:00
parent 891d292dfd
commit 1ca0af7a2d
6 changed files with 68 additions and 20 deletions
@@ -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.<node>, 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 {
@@ -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 `<node>.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 `<node>.<module>`, never a person's
// `person.<module>`), 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
}
@@ -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"} {
+2
View File
@@ -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
@@ -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 {
@@ -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 {