mesh-delivery-view: node_trusts answers in one call whether every node's record names the vault's signing key, the gate before the controller's lease is handed away (issue 774, ADR 0550 §2, design 47 L3 s6b)

This commit is contained in:
2026-10-28 21:54:47 +01:00
parent 9e3d1d5e68
commit 1dbb7c5740
5 changed files with 370 additions and 4 deletions
@@ -166,6 +166,15 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool {
node, module := nodeModuleOf(args)
return r.Haves(node, module)
}},
{Name: view.TrustsReadTool,
Description: "What every node trusts a mesh signature from, in one call (novox/hq ADR 0550 §2): each node's record's trusts " +
"(from node-engine_have <node>.node), whether it names the vault's signing key as mesh-vault publishes it in its " +
"state signers and whether it still names the controller's, the nodes that do not name the vault's yet (not_yet), " +
"and every: true once all do, the gate before the controller's lease is handed away. Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) {
return r.Trusts()
}},
}
}
@@ -223,8 +232,9 @@ func read(state stdio.KeptState) view.StateRead {
func main() {
r := view.Reader{State: read(stdio.State("mesh-delivery-indexer.modules")), Builds: read(stdio.State("mesh-delivery-builder.builds")), CandidatesState: read(stdio.State("mesh-delivery-indexer.candidates")),
WantsState: read(stdio.State("mesh-delivery-assigner.wants")), DifferencesState: read(stdio.State("mesh-delivery-assigner.differences")), HavesState: read(stdio.State("node-engine.have")),
ReachState: read(stdio.State("reach")),
HeardState: read(stdio.State("mesh-delivery-indexer.heard"))}
ReachState: read(stdio.State("reach")),
HeardState: read(stdio.State("mesh-delivery-indexer.heard")),
SignersState: read(stdio.State("mesh-vault.signers"))}
u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")),
Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())}
for _, lang := range delivery.AskLanguages {
@@ -1,13 +1,17 @@
package main
import (
"crypto/ed25519"
"encoding/json"
"fmt"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-view/internal/view"
)
@@ -438,6 +442,41 @@ func TestAssignerDifferencesAndNodeHavesToolsAnswerAsStored(t *testing.T) {
t.Errorf("haves of ace (the node's own key too) = %v", got)
}
// L3 s6b (ADR 0550 §2): node_trusts answers for every node in one call whether its record names the vault's key the
// vault publishes, and every is true only once all do.
vaultPub, _, _ := ed25519.GenerateKey(nil)
vaultKey, _ := json.Marshal(delivery.NewSignerKey(vaultPub, time.Now()))
vaultFP := delivery.SignerFingerprint(vaultPub)
record := func(node string, trusts ...string) string {
raw, _ := delivery.EncodeHaveNode(delivery.HaveNode{V: delivery.Version, Record: &delivery.NodeRecord{Node: node, Trusts: trusts}})
return string(raw)
}
for _, row := range []struct {
name string
haves fake
every bool
notYet []string
}{
{"one node trusts the controller's alone", fake{"ace.node": record("ace", "controller key sha256:aa", "vault key "+vaultFP),
"g14.node": record("g14", "controller key sha256:aa"), "ace.zsh": `{"fp":"x"}`}, false, []string{"g14"}},
{"every node trusts the vault's key", fake{"ace.node": record("ace", "vault key "+vaultFP),
"g14.node": record("g14", "controller key sha256:aa", "vault key "+vaultFP)}, true, []string{}},
} {
tr := view.Reader{HavesState: row.haves.reader().State, SignersState: fake{"key": string(vaultKey)}.reader().State}
out, err := run(t, tr, view.TrustsReadTool, map[string]any{})
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
a := out.(view.TrustsAnswer)
if a.Every != row.every || !reflect.DeepEqual(a.NotYet, row.notYet) || a.VaultKey != vaultFP || len(a.Nodes) != 2 {
t.Errorf("%s: %+v", row.name, a)
}
}
if out, err := run(t, view.Reader{HavesState: fake{"ace.node": record("ace", "vault key "+vaultFP)}.reader().State,
SignersState: fake{}.reader().State}, view.TrustsReadTool, map[string]any{}); err != nil || out.(view.TrustsAnswer).Every {
t.Errorf("with no key published: %+v, %v", out, err)
}
// More haves than one call reads (4 nodes, 300 each, 1 ms a Get): a node and a module read that one key and list
// nothing; the whole answer reads MaxStateReads values in parallel and says it was cut.
slow := newSlow(300, time.Millisecond, func(n string, i int) string { return fmt.Sprintf("%s.m%03d", n, i) })
@@ -44,6 +44,8 @@ type Reader struct {
ReachState StateRead // the view's own reach (ADR 0556)
// HeardState is the FORGE messages the indexer handled (issue 1126 note 76); unset reads none.
HeardState StateRead
// SignersState is the vault's public signing halves (ADR 0550 §2); unset reads none.
SignersState StateRead
}
// ReachReadTool reads the view's reach: per repository, how far each module reached and the reached head (ADR 0556).
@@ -649,3 +651,316 @@ func (r Reader) Differences(node, module string) (KeptAnswer, error) {
func (r Reader) Haves(node, module string) (KeptAnswer, error) {
return r.kept(r.HavesState, "the haves", node, module)
}
// TrustsReadTool reads what every node trusts a mesh signature from, in one call (novox/hq ADR 0550 §2; design 47's L3
// s6b): the gate before the lease write is that every node's record names the vault's key.
const TrustsReadTool = "node_trusts"
// NodeTrusts is what one node's record says it trusts: its lines (delivery.Trusts) and whether they name the vault's key
// the vault publishes and the controller's key.
type NodeTrusts struct {
Trusts []string `json:"trusts"`
TrustsVault bool `json:"trusts_vault"`
TrustsController bool `json:"trusts_controller"`
}
// TrustsAnswer is node_trusts' answer: the vault's key as it publishes it, each node's trusts, the nodes whose record does
// not name the vault's key yet, and whether every node does.
type TrustsAnswer struct {
VaultKey string `json:"vault_key,omitempty"`
Nodes map[string]NodeTrusts `json:"nodes"`
NotYet []string `json:"not_yet"`
Every bool `json:"every"`
Unreadable []string `json:"unreadable,omitempty"`
Note string `json:"note,omitempty"`
}
// Trusts reads every node's record (`<node>.node` of the haves) and the vault's `signers` key, and answers which nodes
// trust the vault's key. Read-only.
func (r Reader) Trusts() (TrustsAnswer, error) {
if r.HavesState.Get == nil || r.HavesState.Keys == nil {
return TrustsAnswer{}, fmt.Errorf("the haves cannot be read here")
}
out := TrustsAnswer{Nodes: map[string]NodeTrusts{}, NotYet: []string{}}
if r.SignersState.Get != nil {
raw, err := r.SignersState.Get(delivery.SignersKeyKey)
if err != nil {
return TrustsAnswer{}, err
}
if len(raw) > 0 {
k, err := delivery.DecodeSignerKey(raw)
if err != nil {
return TrustsAnswer{}, err
}
out.VaultKey = k.Fingerprint
}
}
keys, err := r.HavesState.Keys()
if err != nil {
return TrustsAnswer{}, err
}
var records []string
for _, k := range keys {
if _, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
records = append(records, k)
}
}
values, _, err := r.HavesState.read(records)
if err != nil {
return TrustsAnswer{}, err
}
for key, raw := range values {
node := strings.TrimSuffix(key, "."+delivery.HaveNodeWord)
h, err := delivery.DecodeHaveNode(raw)
if err != nil {
out.Unreadable = append(out.Unreadable, key)
continue
}
var t NodeTrusts
if h.Record != nil {
t.Trusts = h.Record.Trusts
}
for _, line := range t.Trusts {
t.TrustsController = t.TrustsController || strings.HasPrefix(line, delivery.SignerController+" key ")
}
t.TrustsVault = out.VaultKey != "" && delivery.TrustsKey(t.Trusts, delivery.SignerVault, out.VaultKey)
out.Nodes[node] = t
if !t.TrustsVault {
out.NotYet = append(out.NotYet, node)
}
}
sort.Strings(out.NotYet)
sort.Strings(out.Unreadable)
out.Every = out.VaultKey != "" && len(out.Nodes) > 0 && len(out.NotYet) == 0 && len(out.Unreadable) == 0
if out.VaultKey == "" {
out.Note = "the vault publishes no signing key yet (" + delivery.SignersRead + "), so no node can trust it"
}
return out, nil
}
// Placement is the indexer's placement as stored (ADR 0549 §2): one node's key with node, every node placing a module
// with module (each node's key whole, so its pins, ports and bindings are beside the module), and every node's key with
// neither. A key that does not read as a placement is listed as unreadable, never dropped.
//
// Beside it, candidates (issue 1126, ADR 0549 §4): each open catalogue pull request's placement of a node that differs from
// the trunk's (the indexer's placement-candidates `<group>.<node>`), narrowed the same way, each with the node's answer from
// its `<node>.node` have and whether that answer is for the candidate as it stands (its head and key revision).
func (r Reader) Placement(node, module string) (map[string]any, error) {
node, module = strings.TrimSpace(node), strings.TrimSpace(module)
if r.PlacementState.Keys == nil || r.PlacementState.Get == nil {
return nil, fmt.Errorf("the placement state cannot be read here")
}
candidates, err := r.PlacementCandidates(node, module)
if err != nil {
return nil, err
}
if node != "" {
key, err := delivery.PlacementKey(node)
if err != nil {
return nil, err
}
raw, err := r.PlacementState.Get(key)
if err != nil {
return nil, err
}
if len(raw) == 0 && len(candidates) == 0 {
return nil, fmt.Errorf("no placement of %s: the catalogue holds no placement file for it, or the indexer has read none yet", node)
}
out := map[string]json.RawMessage{}
if len(raw) > 0 {
out[node] = raw
}
return map[string]any{"placement": out, "candidates": candidates}, nil
}
keys, err := r.PlacementState.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
values, err := r.PlacementState.values(keys)
if err != nil {
return nil, err
}
out := map[string]json.RawMessage{}
var unreadable []string
for i, key := range keys {
n, err := delivery.ParsePlacementKey(key)
if err != nil {
unreadable = append(unreadable, key)
continue
}
p, err := delivery.DecodePlacement(values[i])
if err != nil {
unreadable = append(unreadable, key)
continue
}
if module != "" && !slices.Contains(p.Assigned(), module) {
continue
}
out[n] = values[i]
}
return map[string]any{"placement": out, "unreadable": unreadable, "candidates": candidates}, nil
}
// The states of a node's answer to a placement candidate, as the view and the assigner's check read it.
const (
// AnswerNone: the node's have holds no answer for the candidate's group (a node asleep, or not composing yet).
AnswerNone = "none"
// AnswerStale: the node answered an earlier head or revision of the candidate.
AnswerStale = "stale"
)
// CandidateRow is one placement candidate and the node's answer to it.
type CandidateRow struct {
Group string `json:"group"`
Node string `json:"node"`
Pull delivery.Head `json:"pull"`
Revision uint64 `json:"revision"`
Placement json.RawMessage `json:"placement,omitempty"` // the candidate value as stored
Removes bool `json:"removes,omitempty"` // the head removes the node's file
Answered string `json:"answered"` // same, differs, refused, stale or none
Answer *delivery.CandidateAnswer `json:"answer,omitempty"`
}
// PlacementCandidates is every placement candidate, narrowed by node and by a module its placement names, with each
// node's answer; beside each group's keys, a row for every other node placed at the group's head or on the trunk, whose
// answer counts by the head alone (its file is unchanged, so it has no key, ADR 0559 §5, §7). A key that does not read is
// a row naming only its key's group and node, answered none, never dropped.
func (r Reader) PlacementCandidates(node, module string) ([]CandidateRow, error) {
st := r.PlacementCandidatesState
if st.Keys == nil || st.GetEntry == nil {
return []CandidateRow{}, nil
}
keys, err := st.Keys()
if err != nil {
return nil, fmt.Errorf("reading the placement candidates: %w", err)
}
sort.Strings(keys)
// The trunk's placed nodes, each with its modules.
trunk := map[string][]string{}
if r.PlacementState.Keys != nil && r.PlacementState.Get != nil {
if tkeys, err := r.PlacementState.Keys(); err == nil {
for _, k := range tkeys {
n, err := delivery.ParsePlacementKey(k)
if err != nil {
continue
}
if raw, err := r.PlacementState.Get(k); err == nil && len(raw) > 0 {
if p, err := delivery.DecodePlacement(raw); err == nil {
trunk[n] = p.Assigned()
}
}
}
}
}
type group struct {
pull delivery.Head
keyed map[string]bool
placed map[string]bool
}
groups := map[string]*group{}
answers := map[string]map[string]delivery.CandidateAnswer{} // node, then group
answerOf := func(n, g string) (delivery.CandidateAnswer, bool) {
if _, read := answers[n]; !read {
answers[n] = r.candidateAnswers(n)
}
a, ok := answers[n][g]
return a, ok
}
rows := []CandidateRow{}
for _, key := range keys {
gname, n, err := delivery.ParsePlacementCandidateKey(key)
if err != nil {
continue
}
raw, rev, err := st.GetEntry(key)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", key, err)
}
if len(raw) == 0 {
continue
}
g := groups[gname]
if g == nil {
g = &group{keyed: map[string]bool{}, placed: map[string]bool{}}
groups[gname] = g
}
g.keyed[n] = true
row := CandidateRow{Group: gname, Node: n, Revision: rev, Placement: raw, Answered: AnswerNone}
c, err := delivery.DecodePlacementCandidate(raw)
if err == nil {
g.pull = c.Pull
if c.Placement != nil {
g.placed[n] = true
}
row.Pull, row.Removes = c.Pull, c.Placement == nil
if module != "" && (c.Placement == nil || !slices.Contains(c.Placement.Assigned(), module)) {
continue
}
} else if module != "" {
continue
}
if node != "" && n != node {
continue
}
if a, ok := answerOf(n, gname); ok && err == nil {
row.Answer = &a
row.Answered = AnswerOf(c.Pull.Head, rev, true, a)
}
rows = append(rows, row)
}
for _, gname := range slices.Sorted(maps.Keys(groups)) {
g := groups[gname]
if g.pull.Head == "" {
continue
}
others := map[string]bool{}
for n := range trunk {
others[n] = true
}
for n := range g.placed {
others[n] = true
}
for _, n := range slices.Sorted(maps.Keys(others)) {
if g.keyed[n] || (node != "" && n != node) || (module != "" && !slices.Contains(trunk[n], module)) {
continue
}
row := CandidateRow{Group: gname, Node: n, Pull: g.pull, Answered: AnswerNone}
if a, ok := answerOf(n, gname); ok {
row.Answer = &a
row.Answered = AnswerOf(g.pull.Head, 0, false, a)
}
rows = append(rows, row)
}
}
return rows, nil
}
// AnswerOf is the state of a node's answer a to its group's candidates at head: its verdict when it answers that head
// and, for a node with a key of its own (keyed), that key's revision rev; AnswerStale otherwise.
func AnswerOf(head string, rev uint64, keyed bool, a delivery.CandidateAnswer) string {
if a.Head != head || (keyed && a.Revision != rev) {
return AnswerStale
}
return a.Verdict
}
// candidateAnswers is a node's answers to its candidates, by group, from its `<node>.node` have; none when it has none.
func (r Reader) candidateAnswers(node string) map[string]delivery.CandidateAnswer {
if r.HavesState.Get == nil {
return nil
}
key, err := delivery.HaveNodeKey(node)
if err != nil {
return nil
}
raw, err := r.HavesState.Get(key)
if err != nil || len(raw) == 0 {
return nil
}
h, err := delivery.DecodeHaveNode(raw)
if err != nil {
return nil
}
return h.Candidates
}
+2 -2
View File
@@ -87,7 +87,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
if m.Module != "mesh-delivery-view" {
t.Errorf("module = %q", m.Module)
}
want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-config.resolved", "mesh-controller.builds", "mesh-controller.composed", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9
want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-config.resolved", "mesh-controller.builds", "mesh-controller.composed", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-vault.signers", "node-engine.have"} // build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9
got := slices.Clone(m.Reads)
slices.Sort(got)
if !reflect.DeepEqual(got, want) {
@@ -119,7 +119,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
}
// The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer
// itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads.
if want := []string{"indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) {
if want := []string{"indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
for _, a := range m.Build.Artifacts {
+2
View File
@@ -14,6 +14,7 @@
"assigner_want",
"assigner_differences",
"node_haves",
"node_trusts",
"build_usage",
"view_reach",
"indexer_module_history",
@@ -53,6 +54,7 @@
"mesh-delivery-teller.answers",
"mesh-delivery-watchman.presence",
"mesh-delivery-watchman.stuck",
"mesh-vault.signers",
"node-engine.have"
],
"own-secrets": {