Take the tunnel key at issue and place a joining node on the operator's yes, so the hub peers it before redeem

The operator's decision of 2026-11-02 01:05: issue requires overlay_key, the machine's tunnel public key; the vault's
enrolment-token tunnel declares all five fields a token needs (hub_key, hub_endpoint, address, key, range). An
add-node is proposed at the yes with the tunnel key alone; the identity keys come whole at redeem or not at all. A new
remove-node, mesh-enrolment's alone on the same warrant and the proposal's one change, takes a joining node running
nothing off placement when its enrolment ends unconfirmed (RemovedNodes names the key the holder deletes; LayChanges
lays nothing for it and refuses a node that runs modules or holds seats). NodeLogin gains Acts, the hand acts that
warranted its rotations. Tested as rows of TestPlacement, TestNodeRecord and the two seat tests; no new test function
(1438 unit tests).

(issue 774, review round 1b)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 01:41:36 +01:00
parent 38a0bbca44
commit f913918a60
9 changed files with 126 additions and 20 deletions
+6 -2
View File
@@ -47,12 +47,16 @@ func enrolmentVerbs() []Verb {
"refused at once, naming every finding, for a name that is not a 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 handed away. Otherwise " +
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
"warrant, asks the vault for the token and says enrolment-issued. The token itself is read only through tokens.",
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
"The token itself is read only through tokens.",
Input: schema(map[string]string{
"node": "the new machine's name in the mesh",
"why": "why it joins, shown to the operator with the ask",
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
}, []string{"node", "why"})},
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
// enters placement with it and the hub peers it before redeem.
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
}, []string{"node", "why", "overlay_key"})},
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
+3 -1
View File
@@ -32,7 +32,9 @@ func placementVerbs() []Verb {
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
"proposal's one change, with the enrolment's warrant; ADR 0587 §7)",
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
"on the same warrant)",
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
return []Verb{
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
+4 -2
View File
@@ -256,9 +256,11 @@ var defaultSeats = append([]Seat{
"sealed": "\"true\": answer the token sealed to the operator key",
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
"public key, the hub's endpoint and the node's tunnel address",
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
"address": "the joining node's tunnel address"})},
"address": "the joining node's tunnel address",
"key": "the joining machine's own tunnel public key, as given at issue",
"range": "the private network's range, a prefix"})},
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
+1 -1
View File
@@ -104,7 +104,7 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
// and the node's address.
if name == "enrolment-token" {
tunnel, _ := props["tunnel"].(map[string]any)
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key"}) {
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) {
t.Errorf("enrolment-token's tunnel is %v", tunnel)
}
}
+5
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
@@ -31,6 +32,10 @@ func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment)
}
six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}
// The tunnel key is given at issue (the operator, 2026-11-02 01:05).
if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") {
t.Errorf("issue requires %v", issue.Input["required"])
}
holder := func(serves ...string) Manifest {
return Manifest{Module: "mesh-enrolment", Tools: serves,
Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}}
+7
View File
@@ -58,6 +58,8 @@ type NodeLogin struct {
Stage string `json:"stage,omitempty"`
Since time.Time `json:"since"`
ListRevision uint64 `json:"list-revision,omitempty"`
// Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each.
Acts []string `json:"acts,omitempty"`
}
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
@@ -89,6 +91,11 @@ func (l NodeLogin) check() error {
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
}
for _, act := range l.Acts {
if strings.TrimSpace(act) == "" {
return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node)
}
}
if !l.Rotates() {
if l.Stage != "" || l.ListRevision != 0 {
return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
+6
View File
@@ -139,6 +139,11 @@ func TestNodeRecord(t *testing.T) {
t.Errorf("node login round trip: %+v, %v", back, err)
}
}
// The hand acts that warranted its rotations, as the vault records them.
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
!strings.Contains(string(raw), `"acts":["act-1"]`) {
t.Errorf("a login with its acts: %s, %v", raw, err)
}
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
t.Errorf("an empty rotating: %+v, %v", l, err)
}
@@ -148,6 +153,7 @@ func TestNodeRecord(t *testing.T) {
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
} {
if _, err := EncodeNodeLogin(l); err == nil {
t.Errorf("node login with %s: taken", name)
+54 -11
View File
@@ -68,6 +68,10 @@ const (
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
// enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change.
OpAddNode = "add-node"
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
// (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
OpRemoveNode = "remove-node"
)
// The states of an open proposal.
@@ -111,13 +115,15 @@ type PlacementProposalChange struct {
// Whole is the node's whole placement (OpSeed).
Whole *Placement `json:"whole,omitempty"`
// Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among
// them, and the public halves of the keys it presented at redeem.
// them, and the public halves of its keys: at the operator's yes its tunnel key alone, given at issue (the operator's
// decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest.
Decided *NodeDecided `json:"decided,omitempty"`
Keys *JoiningKeys `json:"keys,omitempty"`
}
// JoiningKeys are the public halves of the keys a joining node presented at redeem (ADR 0587 §6, §7): its identity,
// private network, sealing and serving keys, as Redeem carries them. Public halves alone, never a private key.
// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given
// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the
// operator's yes carries the tunnel key alone. Public halves alone, never a private key.
type JoiningKeys struct {
Identity string `json:"identity"`
Overlay string `json:"overlay,omitempty"`
@@ -165,8 +171,12 @@ func (c PlacementProposalChange) check() error {
}
return c.Whole.check()
case OpAddNode:
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Identity == "" || c.Keys.Sealing == "" {
return fmt.Errorf("delivery: the add-node of %s names no address or no identity and sealing key", c.Node)
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
return fmt.Errorf("delivery: the add-node of %s names no address or no tunnel key", c.Node)
}
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
return fmt.Errorf("delivery: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
}
if c.Decided.Hub {
return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node)
@@ -175,8 +185,13 @@ func (c PlacementProposalChange) check() error {
return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
}
return nil
case OpRemoveNode:
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node)
}
return nil
}
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config or add-node", c.Op)
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
}
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
@@ -222,6 +237,16 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange)
continue
}
p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}}
case OpRemoveNode:
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
// anything is taken off module by module, by a proposal the operator approves).
cur, has := current[c.Node]
if !has {
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
} else if len(cur.Modules)+len(cur.Holds) > 0 {
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
}
continue
case OpPlace:
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
if at >= 0 {
@@ -472,12 +497,13 @@ func (p PlacementProposal) check() error {
return p.checkAddNode()
}
// checkAddNode refuses an add-node that is not the proposal's one change, carries no warrant, or whose warrant names
// another node or address than the change; and a warrant on any other proposal (ADR 0587 §7).
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
// proposal (ADR 0587 §7).
func (p PlacementProposal) checkAddNode() error {
adds := 0
for _, c := range p.Changes {
if c.Op == OpAddNode {
if c.Op == OpAddNode || c.Op == OpRemoveNode {
adds++
}
}
@@ -494,6 +520,12 @@ func (p PlacementProposal) checkAddNode() error {
if w == nil || w.Ask == "" || w.Warrant == "" {
return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node)
}
if c.Op == OpRemoveNode {
if w.Node != c.Node {
return fmt.Errorf("delivery: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
}
return nil
}
if w.Node != c.Node || w.Address != c.Decided.Address {
return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address,
w.Node, w.Address)
@@ -501,7 +533,18 @@ func (p PlacementProposal) checkAddNode() error {
return nil
}
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node from caller, the module the runtime proved
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
func (p PlacementProposal) RemovedNodes() []string {
var out []string
for _, c := range p.Changes {
if c.Op == OpRemoveNode {
out = append(out, c.Node)
}
}
return out
}
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-node from caller, the module the runtime proved
// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal
// refuses.
func AddNodeRefusal(p PlacementProposal, caller string) string {
@@ -509,7 +552,7 @@ func AddNodeRefusal(p PlacementProposal, caller string) string {
return err.Error()
}
if p.Warrant != nil && caller != EnrolmentModule {
return fmt.Sprintf("an add-node is mesh-enrolment's alone, and %s proposed it", caller)
return fmt.Sprintf("an add-node or a remove-node is mesh-enrolment's alone, and %s proposed it", caller)
}
return ""
}
+40 -3
View File
@@ -691,14 +691,15 @@ func TestPlacement(t *testing.T) {
}
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
// change, from mesh-enrolment alone; a warrant naming another node or address is refused.
// At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05).
joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"},
Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}}
Keys: &JoiningKeys{Overlay: "wg"}}
add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering,
Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
if raw, err := EncodePlacementProposal(add); err != nil {
t.Errorf("an add-node: %v", err)
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Digest() != KeysDigest("id", "", "seal", "") {
} else if back, err := DecodePlacementProposal(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, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") {
@@ -708,7 +709,7 @@ func TestPlacement(t *testing.T) {
t.Errorf("an add-node laid: %+v, %v", laid, f)
}
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"},
Keys: &JoiningKeys{Identity: "id", Sealing: "seal"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
Keys: &JoiningKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
t.Errorf("an add-node of a placed node: %v", f)
}
for name, mut := range map[string]func(*PlacementProposal){
@@ -719,6 +720,16 @@ func TestPlacement(t *testing.T) {
p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
},
"no warrant": func(p *PlacementProposal) { p.Warrant = nil },
"no tunnel key": func(p *PlacementProposal) {
c := joining
c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"}
p.Changes = []PlacementProposalChange{c}
},
"half its identity keys": func(p *PlacementProposal) {
c := joining
c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"}
p.Changes = []PlacementProposalChange{c}
},
"beside another change": func(p *PlacementProposal) {
p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
},
@@ -738,6 +749,32 @@ func TestPlacement(t *testing.T) {
t.Errorf("an add-node with %s: taken", name)
}
}
// With its identity keys whole, as bound at redeem, it is taken too.
whole := add
whole.Changes = []PlacementProposalChange{joining}
whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"}
if _, err := EncodePlacementProposal(whole); err != nil {
t.Errorf("an add-node with its identity keys: %v", err)
}
// An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing.
remove := add
remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}}
if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" ||
AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) {
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule))
}
joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}}
if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 {
t.Errorf("a joining node removed: %v, %v", laid, f)
}
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 ||
!strings.Contains(f[0], "runs modules") {
t.Errorf("a running node removed: %v", f)
}
remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
if _, err := EncodePlacementProposal(remove); err == nil {
t.Error("a remove-node on another node's warrant: taken")
}
for name, mut := range map[string]func(*PlacementProposal){
"no id": func(p *PlacementProposal) { p.ID = "" },
"no proposer": func(p *PlacementProposal) { p.By = "" },