Add the vault's rotation verbs and the state node-logins, so a node's account rotates over two logins on one contract
ADR 0583 §1 (insight 2026-11-01) keeps which login a node uses in the vault's state node-logins, one key per node: the login in use, the one rotating and the stage (begun, ready, confirmed), with the list revision that held both. §2, §3 and §5 give the vault's seat rotate-node (with the recorded hand act's id), node-account-confirm (the caller is the proof) and abandon, optional until the vault serves them; the events rotation-begun, node-account-ready, rotation-done and the engine's finding node-account-fallback are named beside them. The codec refuses a stage without a rotation and a rotation to anything but the node's other login; an empty rotating reads as none. Tested as rows of TestNodeRecord and TestTheVaultsSeatPromisesItsIntakeVerbs, no new test function. (issue 774, 774/S38) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -262,6 +262,23 @@ var defaultSeats = append([]Seat{
|
||||
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
|
||||
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
|
||||
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
|
||||
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
|
||||
// node last; the state node-logins says where each node stands.
|
||||
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
|
||||
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
|
||||
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
|
||||
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
|
||||
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
|
||||
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
|
||||
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
|
||||
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
|
||||
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
|
||||
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
|
||||
"without it.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
|
||||
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
|
||||
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
|
||||
}},
|
||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||
|
||||
@@ -69,6 +69,10 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
"enrolment-token": {"node", "expires", "sealed", "drop"},
|
||||
"node-account": {"node", "sealing_key", "keys_digest", "retire"},
|
||||
"sign": {"kind", "node", "serving_key", "reply"},
|
||||
// 774/S38: the rotation verbs (ADR 0583 §2, §3, §5).
|
||||
"rotate-node": {"node", "why", "act"},
|
||||
"node-account-confirm": {},
|
||||
"abandon": {"node"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
@@ -109,7 +113,8 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account"}}}}
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
|
||||
"rotate-node", "node-account-confirm", "abandon"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.<n>` and
|
||||
// `node.<n>--b`, each with the node's permissions and its inbox `_INBOX.node.<n>` unchanged (§1). Which one a node uses
|
||||
// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login
|
||||
// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the
|
||||
// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does:
|
||||
// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement.
|
||||
const (
|
||||
NodeLoginsState = "node-logins"
|
||||
NodeLoginsBucket = VaultModule + "_" + NodeLoginsState
|
||||
NodeLoginsRead = VaultModule + "." + NodeLoginsState
|
||||
|
||||
// The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready
|
||||
// when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called
|
||||
// node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared.
|
||||
RotationBegun = "begun"
|
||||
RotationReady = "ready"
|
||||
RotationConfirmed = "confirmed"
|
||||
|
||||
// The vault's rotation verbs on its seat (§2, §3, §5).
|
||||
RotateNodeVerb = "rotate-node"
|
||||
NodeAccountConfirmVerb = "node-account-confirm"
|
||||
AbandonVerb = "abandon"
|
||||
|
||||
// The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin.
|
||||
RotationBegunEvent = "rotation-begun"
|
||||
NodeAccountReadyEvent = "node-account-ready"
|
||||
RotationDoneEvent = "rotation-done"
|
||||
|
||||
// NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated
|
||||
// authentication failures with the new one, before rotation-done; the watchman raises it (§6).
|
||||
NodeAccountFallbackFinding = "node-account-fallback"
|
||||
|
||||
nodeLoginPrefix = "node."
|
||||
// SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114).
|
||||
SecondLoginSuffix = "--b"
|
||||
)
|
||||
|
||||
// RotationEventSubject is where the vault says event of a rotation.
|
||||
func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event }
|
||||
|
||||
// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and
|
||||
// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held
|
||||
// both logins, from ready on.
|
||||
type NodeLogin struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Rotating string `json:"rotating,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
ListRevision uint64 `json:"list-revision,omitempty"`
|
||||
}
|
||||
|
||||
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
|
||||
func FirstLogin(node string) string { return nodeLoginPrefix + node }
|
||||
func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix }
|
||||
|
||||
// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second.
|
||||
func OtherLogin(node, login string) string {
|
||||
if login == SecondLogin(node) {
|
||||
return FirstLogin(node)
|
||||
}
|
||||
return SecondLogin(node)
|
||||
}
|
||||
|
||||
// LoginNode is the node a node login names, either of its two, and whether login is one.
|
||||
func LoginNode(login string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(login, nodeLoginPrefix)
|
||||
rest = strings.TrimSuffix(rest, SecondLoginSuffix)
|
||||
return rest, ok && checkName("node", rest) == nil
|
||||
}
|
||||
|
||||
// Rotates says the node is rotating: a login rotating is named.
|
||||
func (l NodeLogin) Rotates() bool { return l.Rotating != "" }
|
||||
|
||||
func (l NodeLogin) check() error {
|
||||
if err := checkName("node", l.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if l.Rotating != OtherLogin(l.Node, l.Login) {
|
||||
return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating)
|
||||
}
|
||||
switch l.Stage {
|
||||
case RotationBegun, RotationReady, RotationConfirmed:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses.
|
||||
func EncodeNodeLogin(l NodeLogin) ([]byte, error) {
|
||||
if err := l.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.V = Version
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not
|
||||
// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An
|
||||
// empty rotating reads as none.
|
||||
func DecodeNodeLogin(data []byte) (NodeLogin, error) {
|
||||
var l NodeLogin
|
||||
if err := decode(data, &l); err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
if err := l.check(); err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
@@ -122,6 +122,41 @@ func TestNodeRecord(t *testing.T) {
|
||||
t.Errorf("a life of %q: taken", life)
|
||||
}
|
||||
}
|
||||
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
|
||||
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
|
||||
for _, l := range []NodeLogin{
|
||||
{Node: "g14", Login: "node.g14", Since: issued},
|
||||
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
|
||||
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
|
||||
} {
|
||||
raw, err := EncodeNodeLogin(l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeNodeLogin(raw)
|
||||
l.V = Version
|
||||
if err != nil || !reflect.DeepEqual(back, l) {
|
||||
t.Errorf("node login round trip: %+v, %v", back, 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)
|
||||
}
|
||||
for name, l := range map[string]NodeLogin{
|
||||
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
|
||||
"another node's login": {Node: "g14", Login: "node.ace"},
|
||||
"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"},
|
||||
} {
|
||||
if _, err := EncodeNodeLogin(l); err == nil {
|
||||
t.Errorf("node login with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
|
||||
t.Errorf("the second login's node: %q %v", node, ok)
|
||||
}
|
||||
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
|
||||
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
|
||||
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
|
||||
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
|
||||
|
||||
Reference in New Issue
Block a user