From e5e8ff57833f1f8a4a81a46acbf06fdd2d58b4ca Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 23:49:36 +0100 Subject: [PATCH] Say each account's root verdict and the engine's contract in the node's health report, so the self-check's DA and D-root judge without the controller (issue 1952, ADR 0266) Every account resource marked root never goes into NodeHealth.Root; the statement's contract into Contract. Relogin, root and found are judged each on its own, so an account may be both. Pins mesh-sdk f3677eb. Test: the report's row in TestAHealthStatementIsSaidOnTheLinkOpenNow carries the agent account's verdict and the contract. --- go.mod | 2 +- go.sum | 4 ++-- internal/link/health_test.go | 9 ++++++--- internal/link/nodehealth.go | 18 ++++++++++++++---- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 5e975e7..e64967e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220254-6eae0765f4d3 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029224808-f3677eb6f36e github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index 5d9076b..139d8ea 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220254-6eae0765f4d3 h1:4qfwx2bv2vosFM6tEbBZUeaQ/pfRauXZuNI3whul30g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220254-6eae0765f4d3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029224808-f3677eb6f36e h1:h8bh7ZyAgPuFC9/lc6iFBD/hduzomLodZ0a1oJZ5X4U= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029224808-f3677eb6f36e/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/internal/link/health_test.go b/internal/link/health_test.go index 7748566..449f0cb 100644 --- a/internal/link/health_test.go +++ b/internal/link/health_test.go @@ -59,7 +59,7 @@ func TestAHealthStatementIsSaidOnTheLinkOpenNow(t *testing.T) { t.Run("the node's health report is written on a change only", func(t *testing.T) { since := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC) look := func(streak int, writer string) Health { - return Health{At: time.Now(), Network: &NetworkHealth{State: StateUnhealthy, Since: since, Parts: []NetworkPart{ + return Health{Contract: 7, At: time.Now(), Network: &NetworkHealth{State: StateUnhealthy, Since: since, Parts: []NetworkPart{ {Part: "resolv-conf", State: StateUnhealthy, Writer: writer, Owner: "dnsmasq", Since: since, Streak: streak}}}, Units: &UnitsHealth{State: "degraded", Failed: []FailedUnit{{Unit: "cups.service", Scope: "system", Since: since}}}, Resources: []ResourceHealth{ @@ -67,13 +67,16 @@ func TestAHealthStatementIsSaidOnTheLinkOpenNow(t *testing.T) { Reason: "relogin needed: the session began before scanner", Since: since}, {Module: "photos", Resource: "photos.library", Kind: KindDirectory, Target: "/srv/photos", State: StateUnhealthy, Reason: ReasonUsedAsFound + " owned by root", Since: since}, - {Module: "letta", Resource: "letta.server", Kind: "container", State: StateHealthy, Since: since}}} + {Module: "letta", Resource: "letta.server", Kind: "container", State: StateHealthy, Since: since}, + {Module: "claude-code", Resource: "claude-code.agent", Kind: KindAccount, Account: "agent", Root: "never", + State: StateHealthy, Since: since}}} } peers := []PeerSeen{{Key: "B=", Seen: true}, {Key: "A=", Addresses: []string{"10.10.0.2/32"}}} r := NodeHealthOf("anchor", look(1, "NetworkManager"), peers, Machines{}) if r.Network.Parts[0].Writer != "NetworkManager" || r.Network.Parts[0].Streak != 0 || r.Peers[0].Key != "A=" || len(r.Units.Failed) != 1 || len(r.Relogin) != 1 || r.Relogin[0].Account != "jochen" || - len(r.Found) != 1 || r.Found[0].Path != "/srv/photos" { + len(r.Found) != 1 || r.Found[0].Path != "/srv/photos" || + len(r.Root) != 1 || r.Root[0].Account != "agent" || r.Root[0].State != StateHealthy || r.Contract != 7 { t.Fatalf("the report is %+v", r) } // The machines an unhealthy part points at, as the controller holds it there; none for the machine's own. diff --git a/internal/link/nodehealth.go b/internal/link/nodehealth.go index 48b7234..b938dbb 100644 --- a/internal/link/nodehealth.go +++ b/internal/link/nodehealth.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" ) @@ -81,7 +82,7 @@ const TowardHub = "hub" // streaks, each unhealthy part naming the machines it points at (machines), its machine units, and each account and // directory of its resources that the six alerts read. func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) NodeHealth { - out := NodeHealth{V: NodeHealthVersion, Node: node, Units: h.Units} + out := NodeHealth{V: NodeHealthVersion, Node: node, Units: h.Units, Contract: h.Contract} if len(peers) > 0 { out.Peers = append([]PeerSeen(nil), peers...) sort.Slice(out.Peers, func(i, k int) bool { return out.Peers[i].Key < out.Peers[k].Key }) @@ -99,11 +100,17 @@ func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) No out.Network = &n } for _, r := range h.Resources { - switch { - case r.Kind == KindAccount && r.State == StateUnhealthy && strings.HasPrefix(r.Reason, ReasonReloginNeeded): + // Each alert reads its own: an account may need a new login and be declared never to become root at once. + if r.Kind == KindAccount && r.State == StateUnhealthy && strings.HasPrefix(r.Reason, ReasonReloginNeeded) { out.Relogin = append(out.Relogin, NodeAccount{Account: r.Account, Module: r.Module, Resource: r.Resource, Reason: r.Reason, Since: r.Since}) - case r.Kind == KindDirectory && strings.HasPrefix(r.Reason, ReasonUsedAsFound): + } + if r.Kind == KindAccount && r.Root == declaration.RootNever { + // The account declared never to become root without a person, judged (ADR 0266): what DA and D-root read. + out.Root = append(out.Root, delivery.RootVerdict{Account: r.Account, Module: r.Module, Resource: r.Resource, + State: r.State, Reason: r.Reason, Since: r.Since}) + } + if r.Kind == KindDirectory && strings.HasPrefix(r.Reason, ReasonUsedAsFound) { out.Found = append(out.Found, NodeDirectory{Module: r.Module, Resource: r.Resource, Path: r.Target, Reason: r.Reason, Since: r.Since}) } @@ -112,6 +119,9 @@ func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) No return out.Relogin[i].Account+out.Relogin[i].Module < out.Relogin[k].Account+out.Relogin[k].Module }) sort.Slice(out.Found, func(i, k int) bool { return out.Found[i].Path < out.Found[k].Path }) + sort.Slice(out.Root, func(i, k int) bool { + return out.Root[i].Module+out.Root[i].Resource < out.Root[k].Module+out.Root[k].Resource + }) return out }