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
138 lines
5.7 KiB
Go
138 lines
5.7 KiB
Go
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"`
|
|
// 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.
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
}
|