Raise the mesh as it is in the gate, call a baseline that does not compose an error, and let a check run by hand as the seat runs it
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

The gate composed 0 of 4 machines with the change and without, and passed every change: the store it
raised held each module's bus credential but no account for it (issue 203's refusal), no outward links
(so no filter could be composed), and refused settings the mesh holds. Now the account is minted with
its credential, the facts carry each machine's outward links (a stand-in for an older snapshot), the
mesh's layers are kept as held, and a withheld path keeps a path's shape. A machine the mesh composes
that the gate cannot raise makes the verdict an error, never a pass; the verdict alone is on stdout.

A merge-check.sh that passed on an agent's machine failed on the build seat: a newer gofmt, siblings at
a feature branch, another user. `mesh-controller check-here` runs builder.Check with the ask the
controller would make, from facts that now name the toolchains and the refs cloned beside; a failed
script is said by what failed. (novox/hq issues 282, 283)
This commit is contained in:
jochen
2026-10-07 01:33:18 +02:00
parent 72d7802415
commit a011743c69
12 changed files with 640 additions and 31 deletions
+136 -12
View File
@@ -22,6 +22,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
snapshot "github.com/novox/mesh-controller/internal/facts"
"github.com/novox/mesh-controller/internal/inventory"
@@ -103,9 +104,12 @@ type mergeVerdict struct {
Verdict string `json:"verdict"` // pass, warning or fail
Summary string `json:"summary"`
// Judge is the controller build that judged it, and Facts when the snapshot it judged against was taken.
Judge string `json:"judge"`
Facts time.Time `json:"facts"`
Failures []string `json:"failures,omitempty"`
Judge string `json:"judge"`
Facts time.Time `json:"facts"`
Failures []string `json:"failures,omitempty"`
// Errors are what kept the gate from judging: a machine the mesh composes that the gate could not
// raise as it is. Any one makes the verdict an error — never a pass (novox/hq issue 282).
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Notes []string `json:"notes,omitempty"`
Machines []mergeMachine `json:"machines"`
@@ -182,11 +186,18 @@ func mergeGateCommand(ctx context.Context, args []string) error {
return err
}
out := io.Writer(os.Stdout)
stdout := os.Stdout
if *asJSON {
out = os.Stderr
// **The verdict is the only thing on standard output** when it is asked for as JSON: composition
// says what it finds as it goes (a bus user without a credential, a module left out) and prints it,
// and a line of that before the verdict makes the verdict unreadable — the build seat would read no
// verdict at all. What is said goes beside the report.
os.Stdout = os.Stderr
}
v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: splitList(*changed),
group: others, admin: *admin, say: out})
os.Stdout = stdout
if err != nil {
return err
}
@@ -201,12 +212,19 @@ func mergeGateCommand(ctx context.Context, args []string) error {
} else {
fmt.Print(v.Report())
}
if v.Verdict == "fail" {
switch v.Verdict {
case "fail":
return errMergeGateFailed
case "error":
return errMergeGateCouldNotJudge
}
return nil
}
// errMergeGateCouldNotJudge is the gate's own error: the mesh as it is could not be raised, so nothing
// the change does to it can be seen. Never a pass (novox/hq issue 282).
var errMergeGateCouldNotJudge = errors.New("the merge gate could not judge the change")
// errMergeGateFailed is the gate's own failure: the verdict says why, so the error says nothing more.
var errMergeGateFailed = errors.New("the change fails the merge gate")
@@ -333,10 +351,12 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
v.Failures = append(v.Failures, fmt.Sprintf("%s: nothing could be sent to it with this change — %s",
gm.Described, firstOr(newOnly(gm.Change.Problems, gm.Base.Problems), gm.Change.Problems)))
case !gm.Base.Composes && m.Declaration.Composes:
// The mesh composes it and the gate could not raise it as it is: a fact the snapshot does
// not carry. Said, and judged by what the change adds.
v.Notes = append(v.Notes, fmt.Sprintf("%s composes on the mesh and not as the snapshot raised it: %s — "+
"judged by what the change adds", gm.Described, firstOr(gm.Base.Problems, nil)))
// **The mesh composes it and the gate could not raise it as it is** (novox/hq issue 282): a fact
// the snapshot does not carry, or one the gate does not raise. Then the change is judged against a
// machine that is not the mesh's — broken against broken, which passes whatever the change does —
// so the gate cannot judge, and says so: an error, never a pass.
v.Errors = append(v.Errors, fmt.Sprintf("%s composes on the mesh and not as the gate raised it from the "+
"snapshot: %s", gm.Described, firstOr(gm.Base.Problems, nil)))
if added := newOnly(gm.Change.Problems, gm.Base.Problems); len(added) > 0 {
v.Failures = append(v.Failures, fmt.Sprintf("%s: the change adds — %s", gm.Described, added[0]))
}
@@ -387,10 +407,16 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
sort.Strings(v.Failures)
v.Failures = slices.Compact(v.Failures)
sort.Strings(v.Errors)
switch {
case len(v.Failures) > 0:
v.Verdict = "fail"
v.Summary = fmt.Sprintf("%d problem(s) the change brings; the first: %s", len(v.Failures), v.Failures[0])
case len(v.Errors) > 0:
v.Verdict = "error"
v.Summary = fmt.Sprintf("the mesh as it is could not be raised, so the change cannot be judged against it "+
"(%d of %d machines compose on the mesh and not in the gate); the first: %s", len(v.Errors),
len(v.Machines), v.Errors[0])
case len(v.Warnings) > 0:
v.Verdict = "warning"
v.Summary = v.Warnings[0]
@@ -423,6 +449,7 @@ func (v mergeVerdict) Report() string {
}
}
list("fails", v.Failures)
list("could not judge", v.Errors)
list("warns", v.Warnings)
if len(v.Modules) > 0 {
var changed []string
@@ -961,6 +988,18 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
return notes, err
}
}
// The links it faces outside by, which a filter is written around (ADR 0140). A snapshot from a
// controller that did not carry them stands in one for a machine the mesh composes: composing, it
// had reported them.
outward := m.OutwardLinks
if len(outward) == 0 && m.Declaration.Composes {
outward = []string{"outside0"}
}
if len(outward) > 0 {
if err := inv.RecordOutwardLinks(ctx, node.ID, outward); err != nil {
return notes, err
}
}
if m.Account != "" {
if err := inv.SetAccount(ctx, m.Name, m.Account, m.AccountHome); err != nil {
return notes, err
@@ -994,7 +1033,7 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
}
}
for _, s := range f.Settings {
if err := inv.SetSettings(ctx, "", s.Module, s.Values); err != nil {
if err := inv.KeepSettings(ctx, "", s.Module, standInPaths(s.Values)); err != nil {
notes = append(notes, fmt.Sprintf("the mesh's settings of %s are not kept: %s", s.Module, oneLine(err.Error())))
}
}
@@ -1006,13 +1045,31 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
}
}
for _, s := range m.Settings {
if err := inv.SetSettings(ctx, m.Name, s.Module, s.Values); err != nil {
if err := inv.KeepSettings(ctx, m.Name, s.Module, standInPaths(s.Values)); err != nil {
notes = append(notes, fmt.Sprintf("%s's settings of %s are not kept: %s", m.Described(), s.Module,
oneLine(err.Error())))
}
}
for _, a := range m.Accepted {
standIn := fmt.Sprintf("gate-stand-in-%x", sha256.Sum256([]byte(m.Name+a.Module+a.Name+a.Provider+a.Local)))[:40]
if a.Provider == "" && a.Name == "broker" {
// **The bus credential is an account, not a given value** (novox/hq issue 203): composition asks
// whether one was issued for the module, so the raised store mints the account the mesh issued
// as well as holding the sealed value — otherwise every machine running a module on the bus
// fails to compose, with the change and without, and the gate compares broken to broken.
// A credential the mesh still holds for a module that no longer reads one composes nothing,
// so it is not raised.
if !readsBusCredential(shelf, a.Module) {
continue
}
if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Kind: busKindOf(a.Module), Node: m.Name,
Module: a.Module, Username: broker.Principal{Kind: broker.KindModule, Node: m.Name,
Module: a.Module}.Username()}); err != nil {
notes = append(notes, fmt.Sprintf("%s's bus account for %s is not kept: %s", m.Described(),
a.Module, oneLine(err.Error())))
continue
}
}
var err error
if a.Provider == "" {
err = inv.AcceptSecretForModule(ctx, m.Name, a.Module, a.Name, standIn)
@@ -1028,6 +1085,64 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
return notes, nil
}
// standInPaths is a settings layer with every value the snapshot withheld where a path stands — an
// access's or a place's — given a path of its own: a path that reads as a key is withheld by the scrub,
// and a bare "withheld" is no absolute path, which composition refuses (a machine the mesh composes
// would not compose as the gate raised it). Each stand-in is distinct, so two withheld places stay two.
func standInPaths(values map[string]any) map[string]any {
out := make(map[string]any, len(values))
for k, v := range values {
out[k] = v
}
if accesses, ok := values["accesses"].(map[string]any); ok {
kept := make(map[string]any, len(accesses))
for name, at := range accesses {
if withheldPath(at) {
at = "/" + snapshot.Withheld + "/access/" + name
}
kept[name] = at
}
out["accesses"] = kept
}
if places, ok := values["places"].(map[string]any); ok {
kept := make(map[string]any, len(places))
for name, p := range places {
if place, ok := p.(map[string]any); ok && withheldPath(place["path"]) {
copied := make(map[string]any, len(place))
for k, v := range place {
copied[k] = v
}
copied["path"] = "/" + snapshot.Withheld + "/place/" + name
p = copied
}
kept[name] = p
}
out["places"] = kept
}
return out
}
// withheldPath is a value the scrub withheld where a path stood: bare, or keeping a path's shape.
func withheldPath(v any) bool {
return v == snapshot.Withheld || v == "/"+snapshot.Withheld
}
// readsBusCredential is whether a module of the shelf, or one that came with the controller, declares the
// own secret its bus account is delivered as.
func readsBusCredential(shelf map[string]catalogue.Manifest, module string) bool {
if m, held := shelf[module]; held {
_, reads := m.OwnSecrets["broker"]
return reads
}
for _, m := range provided {
if m.Module == module {
_, reads := m.OwnSecrets["broker"]
return reads
}
}
return false
}
// standInKey is a key a machine could have reported; its private half is never kept.
func standInKey() (string, error) {
k, err := ecdh.X25519().GenerateKey(rand.Reader)
@@ -1057,12 +1172,21 @@ func reachOfChange(f snapshot.Facts, repository string, paths []string, tree str
}
}
}
entries, read, edges, err := graphOfFacts(f)
if err != nil {
return mergeReach{}, err
}
return reachOfMerge(m, entries, read, edges), nil
}
// graphOfFacts is the module graph the snapshot carries, as the planner reads it from the store.
func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.ReadRepository, []inventory.Edge, error) {
var entries []inventory.Entry
read := map[string][]inventory.ReadRepository{}
for _, mod := range f.Modules {
var manifest catalogue.Manifest
if err := json.Unmarshal(mod.Manifest, &manifest); err != nil {
return mergeReach{}, err
return nil, nil, nil, err
}
entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided,
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
@@ -1074,7 +1198,7 @@ func reachOfChange(f snapshot.Facts, repository string, paths []string, tree str
for _, e := range f.Edges {
edges = append(edges, inventory.Edge{From: e.From, To: e.To, Kind: e.Kind})
}
return reachOfMerge(m, entries, read, edges), nil
return entries, read, edges, nil
}
// widthOf is how wide the rebuild of a reach is.