From 69270a30d5b4211ecb1f10b1e236992d4c33e2f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:19:11 +0100 Subject: [PATCH] Add the vault's rotation verbs and the state node-logins, so a node's account rotates over two logins on one contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go/catalogue/seats.go | 17 +++++ go/catalogue/vault_seat_test.go | 7 +- go/delivery/node_logins.go | 130 ++++++++++++++++++++++++++++++++ go/delivery/noderecord_test.go | 35 +++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 go/delivery/node_logins.go diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index 0180575..e0da9b7 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -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). diff --git a/go/catalogue/vault_seat_test.go b/go/catalogue/vault_seat_test.go index 7fd3265..28cd326 100644 --- a/go/catalogue/vault_seat_test.go +++ b/go/catalogue/vault_seat_test.go @@ -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) } diff --git a/go/delivery/node_logins.go b/go/delivery/node_logins.go new file mode 100644 index 0000000..d7a6517 --- /dev/null +++ b/go/delivery/node_logins.go @@ -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.` and +// `node.--b`, each with the node's permissions and its inbox `_INBOX.node.` 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.`, 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 +} diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index 0a39b1a..a80b3c8 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -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")