Name the expired enrolment's issued event by its id, and carry a node's login fall back in its health report

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
This commit is contained in:
2026-11-02 01:41:36 +01:00
parent 90a5fc5797
commit 79ad8b539c
5 changed files with 80 additions and 7 deletions
+8
View File
@@ -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"}}},
+37 -4
View File
@@ -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,
+18 -2
View File
@@ -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)
}
+16
View File
@@ -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")
}
+1 -1
View File
@@ -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")