Files
mesh-host/internal/agent/composition.go
T
jschoubben be0226b624 Stop verifying composition signatures: the node applies only its own
ADR 0593 §6: a composition read from composed-<node> is a record never
applied, and the engine applies only what it composes itself, so no
composition carries a signature and nothing is verified. The agent's
trusted signers, its signed-composition test and the unused signing
helper of the integration bed go, so mesh-sdk can drop SignComposed,
VerifyComposed and Composed.Signature.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 19:01:12 +01:00

222 lines
7.8 KiB
Go

package agent
import (
"context"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/judge"
)
// The node-engine seat's read verb composition (issue 774): one module's composition beside what runs of it, so why a
// record module waits for a yes, or what its next apply would change, is read from the engine and not guessed from its
// have's words. It changes nothing.
// CompositionView is what the verb composition answers.
type CompositionView struct {
Node string `json:"node"`
Module string `json:"module"`
// Want is the module's want as the agent holds it; nil when it holds none.
Want *WantView `json:"want,omitempty"`
// Have is what the module's have key says now; nil when it says nothing.
Have *HaveView `json:"have,omitempty"`
// Composition is the revision read: the latest of the want's build, or the one whose digest was asked.
Composition RevisionView `json:"composition"`
// Revisions are the revisions of its key the bucket keeps, oldest first, each with its digest.
Revisions []RevisionView `json:"revisions,omitempty"`
// Runs is what runs of the module, and where that is known from; nil when nothing of it runs.
Runs *RunsView `json:"runs,omitempty"`
// Judged is the move from what runs to the composition read, as the agent judges it before an apply.
Judged JudgedView `json:"judged"`
// Diff is every resource that differs from what runs, when asked.
Diff []judge.ResourceChange `json:"diff,omitempty"`
}
// WantView is a want as the verb shows it.
type WantView struct {
FP string `json:"fp"`
Composed string `json:"composed,omitempty"`
Yes string `json:"yes,omitempty"`
Removed bool `json:"removed,omitempty"`
}
// HaveView is a have as the verb shows it.
type HaveView struct {
FP string `json:"fp,omitempty"`
Composed string `json:"composed,omitempty"`
Phase string `json:"phase"`
Detail string `json:"detail,omitempty"`
Judgement string `json:"judgement,omitempty"`
Waits string `json:"waits,omitempty"`
}
// RevisionView is one revision of a composition key.
type RevisionView struct {
Key string `json:"key"`
Revision uint64 `json:"revision,omitempty"`
Digest string `json:"digest,omitempty"`
Resources int `json:"resources,omitempty"`
Latest bool `json:"latest,omitempty"`
// Refused is why the revision cannot be read or trusted.
Refused string `json:"refused,omitempty"`
}
// RunsView is what runs of a module.
type RunsView struct {
FP string `json:"fp,omitempty"`
Composed string `json:"composed,omitempty"`
Phase string `json:"phase,omitempty"`
// From is applied (what this engine applied), have (what its have named at the start) or kept (the declaration the
// node kept before the switch).
From string `json:"from"`
Resources int `json:"resources"`
}
// JudgedView is a move's judgement.
type JudgedView struct {
Judgement string `json:"judgement"`
Words string `json:"words"`
// WaitsForYes says the agent would wait for a person's yes before applying it: a module of policy record, a move that
// recreates a container, moves data or cannot be judged, and no yes in its want for this digest.
WaitsForYes bool `json:"waits_for_yes"`
}
// Composition reads one module's composition beside what runs of it: the want's build (else the have's), its latest
// revision or the one whose digest is given, and the move's judgement; with diff, the resources that differ.
func (a *Agent) Composition(ctx context.Context, module, digest string, diff bool) (CompositionView, error) {
if module == "" {
return CompositionView{}, errors.New("name the module, or node for the node's own composition")
}
view := CompositionView{Node: a.env.Node, Module: module}
var have *Have
if a.env.Haves != nil {
if h, ok := a.env.Haves.Read(ctx, module); ok {
have = &h
phase := h.Phase
if phase == "" {
phase = string(delivery.PhaseRunning)
}
view.Have = &HaveView{FP: h.FP, Composed: h.Composed, Phase: phase, Detail: h.Detail, Judgement: h.Judgement, Waits: h.Waits}
}
}
a.mu.Lock()
w := a.wants[module]
fp := ""
if w.Present {
fp = w.Want.FP
view.Want = &WantView{FP: w.Want.FP, Composed: w.Want.Composed, Yes: w.Want.Yes, Removed: w.Want.Removed}
} else if have != nil {
fp = have.FP
}
latest, haveLatest := a.compositionFor(module, fp)
r, ran := a.running[module]
from, known := a.runningComposition(module)
source := "applied"
if _, ok := a.applied[module]; !ok {
if _, ok := a.had[module]; ok {
source = "have"
} else {
source = "kept"
}
}
a.mu.Unlock()
if module != NodeModule && fp == "" {
return view, fmt.Errorf("%s has no want and no have on %s, so no build of it is composed here", module, a.env.Node)
}
key, err := a.compositionKey(module, fp)
if err != nil {
return view, err
}
revs, err := a.revisionsOf(ctx, key)
if err != nil {
return view, fmt.Errorf("the revisions of %s cannot be read: %w", key, err)
}
var read *compNow
for i := range revs {
c := revs[i]
rv := RevisionView{Key: key, Revision: c.Rev, Digest: c.Dig, Resources: len(c.C.Resources),
Latest: haveLatest && c.Rev == latest.Rev}
if c.Err != nil {
rv.Refused = c.Err.Error()
}
view.Revisions = append(view.Revisions, rv)
if digest != "" && c.Err == nil && c.Dig == digest {
read = &revs[i]
}
}
switch {
case digest != "" && read == nil && haveLatest && latest.Err == nil && latest.Dig == digest:
read = &latest
case digest != "" && read == nil:
return view, fmt.Errorf("no revision of %s the bucket keeps has the digest %s; revisions lists those it keeps", key, digest)
case read == nil && !haveLatest:
return view, fmt.Errorf("the bucket holds no composition of %s at %s", module, fp)
case read == nil:
read = &latest
}
view.Composition = RevisionView{Key: key, Revision: read.Rev, Digest: read.Dig, Resources: len(read.C.Resources),
Latest: haveLatest && read.Rev == latest.Rev}
if read.Err != nil {
view.Composition.Refused = read.Err.Error()
return view, nil
}
var running *delivery.Composed
if known {
running = &from
rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP}
if ran {
rv.FP, rv.Composed, rv.Phase = r.FP, r.Composed, string(r.Phase)
}
view.Runs = rv
}
j := judge.JudgeMove(running, read.C)
view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words}
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
switch j.Judgement {
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot:
view.Judged.WaitsForYes = a.env.Policy(ctx, module) == catalogue.PolicyRecord
}
}
if diff {
view.Diff = judge.ChangedResources(running, read.C)
}
return view, nil
}
// compositionKey is a module's composition key at a build, or the node's own.
func (a *Agent) compositionKey(module, fp string) (string, error) {
if module == NodeModule {
return delivery.ComposedNodeKey(a.env.Node)
}
return delivery.ComposedKey(a.env.Node, module, fp)
}
// revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR
// 0593 §6), or why it is refused. Nil Revisions
// reads none.
func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) {
if a.env.Revisions == nil {
return nil, nil
}
revs, err := a.env.Revisions(ctx, key)
if err != nil {
return nil, err
}
var out []compNow
for _, u := range revs {
if u.Deleted || u.Key != key {
continue
}
c, err := delivery.DecodeComposed(u.Value)
if err != nil {
out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err})
continue
}
dig, err := delivery.ComposedDigestOf(c) // unsigned: a record, never verified (ADR 0593 §6)
out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err})
}
return out, nil
}