From 79ad8b539cf6f12c3d2b140997e0ab4435a5fb66 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:56:56 +0100 Subject: [PATCH] Name the expired enrolment's issued event by its id, and carry a node's login fall back in its health report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 6: enrolment-expired carries the enrolment-issued event's message id (IssuedID), since a bundle's publish answers no stream sequence and ADR 0587 §2 asks only that the event be named; it gains a codec refusing no event named. Blocker 7: NodeHealth gains Fallback (node, the login fallen back from, why, at), from which the watchman raises node-account-fallback (ADR 0583 §6); a fall back naming another node is refused. A token missing its signer now says the vault's signing key, not the retired word. Tested as rows of TestNodeRecord and the control records test; no new test function (1438 unit tests). (issue 774, review round 1) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/delivery/control_records_test.go | 8 ++++++ go/delivery/enrolment.go | 41 ++++++++++++++++++++++++++--- go/delivery/nodehealth.go | 20 ++++++++++++-- go/delivery/noderecord_test.go | 16 +++++++++++ go/delivery/token.go | 2 +- 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/go/delivery/control_records_test.go b/go/delivery/control_records_test.go index 419ee62..c98f15f 100644 --- a/go/delivery/control_records_test.go +++ b/go/delivery/control_records_test.go @@ -40,6 +40,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { {"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` + `"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` + `"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}}, + // A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback. + {"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` + + `"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` + + `"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}}, {"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}}, } { if err := json.Unmarshal([]byte(c.raw), c.into); err != nil { @@ -60,6 +64,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { if _, err := EncodeNodeHealth(NodeHealth{}); err == nil { t.Error("a report naming no node was encoded") } + if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b", + Why: "refused", At: time.Now()}}); err == nil { + t.Error("a fall back naming another node was encoded") + } // ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node. held := SeatHoldersOf(map[string]Placement{ "ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}}, diff --git a/go/delivery/enrolment.go b/go/delivery/enrolment.go index 0a07c7a..82166e0 100644 --- a/go/delivery/enrolment.go +++ b/go/delivery/enrolment.go @@ -176,11 +176,44 @@ func TokenLife(expires string) (time.Duration, error) { } // EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the -// key's content, by its sequence on the events' stream, since the bus deleted the key before the holder heard of it. +// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the +// event be named), since the bus deleted the key before the holder heard of it. type EnrolmentExpiry struct { - V int `json:"v"` - Node string `json:"node"` - Issued uint64 `json:"issued-event"` + V int `json:"v"` + Node string `json:"node"` + IssuedID string `json:"issued-event"` +} + +func (x EnrolmentExpiry) check() error { + if err := checkName("node", x.Node); err != nil { + return err + } + if strings.TrimSpace(x.IssuedID) == "" { + return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node) + } + return nil +} + +// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses. +func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) { + if err := x.check(); err != nil { + return nil, err + } + x.V = Version + return json.Marshal(x) +} + +// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and +// no issued event named. +func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) { + var x EnrolmentExpiry + if err := decode(data, &x); err != nil { + return EnrolmentExpiry{}, err + } + if err := x.check(); err != nil { + return EnrolmentExpiry{}, err + } + return x, nil } // **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine, diff --git a/go/delivery/nodehealth.go b/go/delivery/nodehealth.go index 62c541a..742a604 100644 --- a/go/delivery/nodehealth.go +++ b/go/delivery/nodehealth.go @@ -13,8 +13,8 @@ import ( // what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the // passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner), // network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and -// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a -// report from its own health statement there (NodeHealthOf). +// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host +// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf). // NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does. const ( @@ -40,6 +40,10 @@ type NodeHealth struct { Relogin []NodeAccount `json:"relogin,omitempty"` // Found are the directories used as found. Found []NodeDirectory `json:"found,omitempty"` + // Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its + // confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as + // its login in force. + Fallback *LoginFallback `json:"fallback,omitempty"` // Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq // ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root // judge an agent's confinement by, on the machine's own word. @@ -51,6 +55,15 @@ type NodeHealth struct { At time.Time `json:"at"` } +// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the +// rotation moved to), why, in words with no credential, and when. +type LoginFallback struct { + Node string `json:"node"` + From string `json:"from"` + Why string `json:"why"` + At time.Time `json:"at"` +} + // NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part. type NetworkHealth struct { State string `json:"state"` @@ -150,6 +163,9 @@ func EncodeNodeHealth(h NodeHealth) ([]byte, error) { if h.Node == "" { return nil, errors.New("a node's health report names its node") } + if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) { + return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node) + } return json.Marshal(h) } diff --git a/go/delivery/noderecord_test.go b/go/delivery/noderecord_test.go index a80b3c8..dc81b41 100644 --- a/go/delivery/noderecord_test.go +++ b/go/delivery/noderecord_test.go @@ -160,6 +160,22 @@ func TestNodeRecord(t *testing.T) { walk(reflect.TypeOf(Enrolment{}), "Enrolment") walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress") walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry") + // enrolment-expired names the issued event by its message id (§2), never a stream sequence. + if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil { + t.Errorf("an expiry: %v", err) + } else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" || + !strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) { + t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw) + } + for name, raw := range map[string]string{ + "no issued event": `{"v":1,"node":"lab"}`, + "a sequence for it": `{"v":1,"node":"lab","issued-event":7}`, + "a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`, + } { + if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil { + t.Errorf("an expiry with %s: taken", name) + } + } // And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential. walk(reflect.TypeOf(Placement{}), "Placement") } diff --git a/go/delivery/token.go b/go/delivery/token.go index 9a376da..9cfcde9 100644 --- a/go/delivery/token.go +++ b/go/delivery/token.go @@ -81,7 +81,7 @@ func (t Token) Missing() []string { missing = append(missing, "the broker certificate's fingerprint") } if len(t.Signer) != ed25519.PublicKeySize { - missing = append(missing, "the control plane's signing key") + missing = append(missing, "the vault's signing key") } if strings.TrimSpace(t.Secret) == "" { missing = append(missing, "the one-time secret")