From 6953b5bafdb2ef96aaf19c8d2d5b2657c52d02f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 6 Oct 2026 10:18:54 +0200 Subject: [PATCH] Say the heartbeat's interval, export the host's validator, grant the genesis controller Phase 1 (hq to-be 45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller's watchdog of a machine's heartbeat (S1) is bound to three of its intervals, and a bound the controller guessed would not move when the interval does: the heartbeat now carries interval_seconds. Its self-check (D1) must judge every composed declaration as the host does, and a second validator written from the host's rules would drift from them: the host's own parsing is exported, unchanged, as github.com/novox/mesh-host/validate. The installer's first user list grants what the controller now composes for itself: its condition buckets, the condition and doctor-heartbeat events, the bus's two consumer advisories and $SRV.INFO — or the first controller would be refused them until the broker's machine is pushed. --- examples/foundation-first-node-nats.lock | 2 +- internal/link/messages.go | 4 +++ internal/link/run.go | 2 +- validate/validate.go | 38 ++++++++++++++++++++++++ validate/validate_test.go | 31 +++++++++++++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 validate/validate.go create mode 100644 validate/validate_test.go diff --git a/examples/foundation-first-node-nats.lock b/examples/foundation-first-node-nats.lock index 5d87b7a..eaceca9 100644 --- a/examples/foundation-first-node-nats.lock +++ b/examples/foundation-first-node-nats.lock @@ -161,7 +161,7 @@ "type": "file", "path": "/var/lib/mesh-bus-conf/accounts.conf", "mode": "0600", - "content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n" + "content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"$SRV.INFO\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n" }, { "id": "broker", diff --git a/internal/link/messages.go b/internal/link/messages.go index b593674..a427eb7 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -28,6 +28,10 @@ const ( // look like a stale one. type Alive struct { Node string `json:"node"` + // IntervalSeconds is how often this node says it is there (novox/hq to-be 45 §3, S1): the + // controller's watchdog is bound to three of them, so the bound moves with the interval rather + // than with a number the controller guessed. A controller older than that ignores it. + IntervalSeconds int `json:"interval_seconds"` } // Signed is a declaration and the signature over it. diff --git a/internal/link/run.go b/internal/link/run.go index 8230ceb..4e42fc0 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -514,7 +514,7 @@ func publishReport(ctx context.Context, bus Bus, m Membership, report Report, // publishAlive says this node is here, and nothing else. func publishAlive(ctx context.Context, bus Bus, m Membership, say Announce, timeout time.Duration) { - body, err := json.Marshal(Alive{Node: m.Node}) + body, err := json.Marshal(Alive{Node: m.Node, IntervalSeconds: int(AliveEvery / time.Second)}) if err != nil { return } diff --git a/validate/validate.go b/validate/validate.go new file mode 100644 index 0000000..aa14089 --- /dev/null +++ b/validate/validate.go @@ -0,0 +1,38 @@ +// Package validate is the node-engine's own judgement of a declaration, for whoever must know before +// a declaration is sent whether a machine would take it (novox/hq to-be 45 §4, D1; §9, the merge gate). +// +// **One validator.** The controller composed declarations the host then refused whole — a manifest +// the catalogue check passed (novox/hq issue 236), a consumer's identity too long for the machine's +// names (issue 263) — because the only validator was the one the host runs as it applies. A second, +// written in the controller from the host's rules, would drift from them the first time either +// changed. So the host's own parsing is exported here, unchanged: what the controller's self-check and +// the merge gate run is what every machine runs. +// +// It judges a declaration as it arrives over the link: actions are refused, as the host refuses them +// from the link (novox/hq ADR 0005). Nothing here touches a machine. +package validate + +import ( + "errors" + + "github.com/novox/mesh-host/internal/declaration" +) + +// Declaration is every problem the node-engine would refuse a declaration for, each in its own words; +// nil when it would take it. The body is the declaration as the controller composes it — the bytes a +// signature is made over — not the signed envelope. +func Declaration(raw []byte) []string { + _, err := declaration.Parse(raw) + if err == nil { + return nil + } + var refused *declaration.RefusalError + if errors.As(err, &refused) { + return refused.Problems + } + return []string{err.Error()} +} + +// Version is the declaration vocabulary this validator speaks: a declaration of another version is +// refused whole by Declaration, as by the host. +const Version = declaration.Version diff --git a/validate/validate_test.go b/validate/validate_test.go new file mode 100644 index 0000000..cd3bfa5 --- /dev/null +++ b/validate/validate_test.go @@ -0,0 +1,31 @@ +package validate + +import ( + "strings" + "testing" +) + +// **The validator a controller imports is the host's**: a declaration the host takes passes, and one +// it refuses is refused with the host's own words, every problem at once. +func TestTheValidatorIsTheHosts(t *testing.T) { + good := `{"declaration": 1, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` + if problems := Declaration([]byte(good)); problems != nil { + t.Fatalf("a declaration the host takes was refused: %v", problems) + } + for _, bad := range []string{ + `{"declaration": 1, "resources": []}`, + `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/x"}]}`, + `{"declaration": 1, "resources": [{"id": "d", "type": "nothing-the-host-knows"}]}`, + `not json`, + } { + problems := Declaration([]byte(bad)) + if len(problems) == 0 { + t.Errorf("a declaration the host refuses was passed: %s", bad) + } + for _, p := range problems { + if strings.TrimSpace(p) == "" { + t.Errorf("a problem said nothing, for %s", bad) + } + } + } +}