mesh-enrolment: confirm closes an enrolment on the node's own call

Only the node dialled as node.<node> confirms (the runtime's proof of the caller is the confirmation, ADR 0587 §6); the
holder then says node-enrolled with the key's content and deletes the address key (while it still names the node) and the
node's key. A stop between the event and the deletes is finished by calling confirm again; the event keeps one message.
release now answers what it could not delete. Tests added: one table-style test.

(issue 774, 774/S57)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 01:44:46 +01:00
parent 89370b6f5c
commit f904ff425e
4 changed files with 123 additions and 13 deletions
@@ -0,0 +1,42 @@
package main
import (
"fmt"
"strings"
)
// Confirm ends an enrolment that came through (ADR 0587 §6): the node, dialled as its own login node.<node> after it
// redeemed, calls it, and the runtime's proof of that caller is the confirmation (as ADR 0583 §3). Only then does the holder
// say node-enrolled, with the key's whole content, and delete the key and its address key. The node's name is the caller's
// own; an argument naming another node is refused. A confirm called again after a stop between the event and the deletes
// finishes the deletes and says nothing twice (the bus keeps one message of one id).
func (h *Holder) Confirm(caller, node string) (map[string]any, error) {
own, ok := strings.CutPrefix(caller, nodePrefix)
if !ok || CheckNode(own) != nil || strings.Contains(own, "--") {
return nil, fmt.Errorf("only the node itself, dialled as node.<node>, confirms its enrolment, and the runtime proved the caller to be %q", caller)
}
if node != "" && node != own {
return nil, fmt.Errorf("%s confirms its own enrolment only, not that of %s", caller, node)
}
row, found, err := h.Store.Get(NodeKey(own))
if err != nil {
return nil, fmt.Errorf("the enrolment of %s cannot be read: %w", own, err)
}
if !found {
return nil, fmt.Errorf("no enrolment of %s is open: nothing to confirm", own)
}
e, err := decodeEnrolment(row.Value)
if err != nil {
return nil, err
}
if e.Stage != StageRedeeming {
return nil, fmt.Errorf("the enrolment of %s is %s: it is confirmed after redeem", own, e.Stage)
}
if err := h.sayEvent(EventEnrolled, e, ""); err != nil {
return nil, fmt.Errorf("node-enrolled of %s cannot be said, so nothing is deleted (call confirm again): %w", own, err)
}
if err := h.release(e); err != nil {
return nil, fmt.Errorf("the enrolment of %s is confirmed and not closed (call confirm again): %w", own, err)
}
return map[string]any{"node": own, "address": e.Address, "says": "the node is enrolled; its enrolment is closed and the vault is asked to drop the token"}, nil
}
@@ -0,0 +1,44 @@
package main
import "testing"
// 774/S57/AC1: a confirm from any caller but node.<node> is refused; from node.<node> node-enrolled is said and both keys
// are deleted; a stop between the event and the deletes is finished by calling confirm again.
func TestConfirmIsTheNodesOwnAndEndsTheEnrolment(t *testing.T) {
for _, c := range []struct{ name, caller, node, want string }{
{"the enrolment user", "enrol.ace", "", "dialled as node.<node>"},
{"another node", "node.g14", "ace", "its own enrolment only"},
{"the second login", "node.ace--b", "", "dialled as node.<node>"},
{"no one", "unproven", "ace", "dialled as node.<node>"},
} {
r := newRig(t)
r.seed("ace", "10.9.0.2", StageRedeeming)
if _, err := r.h.Confirm(c.caller, c.node); err == nil {
t.Errorf("%s: confirmed", c.name)
} else {
mustContain(t, err.Error(), c.want)
}
if len(r.said) != 0 || len(r.keys()) != 2 {
t.Errorf("%s: said %v, keys %v", c.name, r.events(), r.keys())
}
}
r := newRig(t)
r.seed("ace", "10.9.0.2", StageLive)
if _, err := r.h.Confirm("node.ace", ""); err == nil {
t.Error("confirmed before redeem")
}
r.seed("ace", "10.9.0.2", StageRedeeming)
r.st.failDelete[NodeKey("ace")] = true
if _, err := r.h.Confirm("node.ace", ""); err == nil {
t.Fatal("a delete that failed was taken for done")
}
if r.has(AddressKey("10.9.0.2")) || !r.has(NodeKey("ace")) || len(r.said) != 1 {
t.Fatalf("keys %v, said %v", r.keys(), r.events())
}
if _, err := r.h.Confirm("node.ace", ""); err != nil || len(r.keys()) != 0 {
t.Fatalf("%v, keys %v", err, r.keys())
}
if r.events()[0] != EventEnrolled || r.said[0].ID != r.said[1].ID {
t.Errorf("events %v ids %s %s", r.events(), r.said[0].ID, r.said[1].ID)
}
}
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strconv"
@@ -119,21 +120,38 @@ func (h *Holder) Issue(caller, node, why string, lasts time.Duration) (IssueAnsw
return IssueAnswer{}, fmt.Errorf("the enrolment of %s cannot be written: %w", node, err)
}
if err := h.raise(e); err != nil {
h.release(e)
_ = h.release(e)
return IssueAnswer{}, err
}
return IssueAnswer{Node: node, Address: e.Address, Ask: e.Ask, Expires: e.Expires,
Says: "the operator is asked on their channel; only their yes makes the token. mesh-enrolment.tokens shows where it stands"}, nil
}
// release takes an enrolment's two keys away, the address key last (the node's key is what names the reservation's owner).
func (h *Holder) release(e Enrolment) {
if err := h.Store.Delete(NodeKey(e.Node)); err != nil {
h.logf("the key of %s could not be deleted and stays: %v", e.Node, err)
// release takes an enrolment's two keys away, the address key first and only while it still names this node (a key some
// other enrolment now holds is never deleted), then the node's key, whose presence is what lets a call made again finish a
// release that stopped. It answers what could not be deleted.
func (h *Holder) release(e Enrolment) error {
var errs []error
if row, found, err := h.Store.Get(AddressKey(e.Address)); err != nil {
errs = append(errs, fmt.Errorf("the address key %s cannot be read: %w", e.Address, err))
} else if found {
var r Reservation
if json.Unmarshal(row.Value, &r) == nil && r.Node == e.Node {
if err := h.Store.Delete(AddressKey(e.Address)); err != nil {
errs = append(errs, fmt.Errorf("the address key %s could not be deleted: %w", e.Address, err))
}
}
}
if err := h.Store.Delete(AddressKey(e.Address)); err != nil {
h.logf("the address key %s could not be deleted and stays: %v", e.Address, err)
if len(errs) == 0 {
if err := h.Store.Delete(NodeKey(e.Node)); err != nil {
errs = append(errs, fmt.Errorf("the key of %s could not be deleted: %w", e.Node, err))
}
}
if err := errors.Join(errs...); err != nil {
h.logf("%v", err)
return err
}
return nil
}
// AskOf is the ask of an enrolment, in the words the operator reads: that a machine of this name may join until the
@@ -235,8 +253,7 @@ func (h *Holder) refuse(e Enrolment, reason string) error {
if err := h.sayEvent(EventRefused, e, reason); err != nil {
return fmt.Errorf("enrolment-refused of %s cannot be said, so its keys stay: %w", e.Node, err)
}
h.release(e)
return nil
return h.release(e)
}
// Said is the asker's note of an ask that ended with no yes or no (expired, or refused by the router): the enrolment ends.
@@ -11,10 +11,11 @@ import (
// Tool names: the seat's verbs, as the manifest claims them (ADR 0587 §1, §8).
const (
IssueTool = Seat + ".issue"
AgainTool = Seat + ".again"
RedeemTool = Seat + ".redeem"
TokensTool = Seat + ".tokens"
IssueTool = Seat + ".issue"
AgainTool = Seat + ".again"
RedeemTool = Seat + ".redeem"
ConfirmTool = Seat + ".confirm"
TokensTool = Seat + ".tokens"
)
func obj(props map[string]any, required ...string) map[string]any {
@@ -87,6 +88,12 @@ func Tools(h *Holder) []stdio.Tool {
}
return h.Redeem(stdio.CallerOf(a), args)
}},
{Name: ConfirmTool,
Description: "The new machine's confirmation (novox/hq ADR 0587 §6), answered only for the node dialled as its own login " +
"node.<node> after it redeemed: the runtime's proof of that caller is the confirmation. Says node-enrolled and closes " +
"the enrolment; the vault is then asked to drop the token.",
Input: obj(map[string]any{"node": str("the machine's own name (optional)")}),
Run: func(a map[string]any) (any, error) { return h.Confirm(stdio.CallerOf(a), arg(a, "node")) }},
{Name: AgainTool,
Description: "Ask the operator again about an enrolment still at stage asked whose ask the router no longer holds open " +
"(novox/hq ADR 0587 §8): a new ask for the same node, address and expiry. Nothing more: a redeem stopped midway is finished " +