Compare commits

...
Author SHA1 Message Date
mesh-admin f10aedac58 Merge pull request 'A merge check judges the head merged into its trunk, and names the trunk commit (hq ADR 0323, issue 341)' (#271) from feat/341-a-merge-check-judges-the-merge into main 2026-10-12 16:11:27 +00:00
jschoubben 875ebc093e Format PullUpdated, and keep the announced base head on a rechecked member
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/341-a-merge-check-judges-the-merge delivering: 2 of 4 delivered
mesh/delivery delivering: 0 machine step(s) passed
A head checked again through mesh-delivery reached the direct verdict with no
base head, so a head no build judges was answered with no trunk commit and the
merge tool would refuse it (novox/hq ADR 0323).
2026-10-12 17:52:12 +02:00
mesh-admin 1e28342ede Merge pull request 'Make no value for an own secret issued outside the mesh that nobody gave (issue 401)' (#262) from fix/401-an-outside-secret-not-given-is-not-made into main 2026-10-12 15:48:12 +00:00
jschoubben bc623bc1f9 Name the announced trunk commit on a verdict no build judges
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 fail: its merge-check.sh failed: not gofmt'd by the toolchain's gofmt: internal/link/events.go
mesh/delivery-group group feat/341-a-merge-check-judges-the-merge rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
A change touching no module, in a repository with no merge-check.sh, was
answered by the controller with no trunk commit, so the merge tool refused it,
asked again and refused it for ever (novox/mesh-tools-go; novox/hq ADR 0323).
2026-10-12 17:44:18 +02:00
jschoubben ce9dc8e157 Run no hook and no signature setting when a check makes its merge
mesh/delivery superseded: a newer head of the same pull request
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
A build machine's or a clone's git configuration must not run code or change
the merge a check judges (novox/hq ADR 0323).
2026-10-12 17:39:59 +02:00
mesh-admin c672b3df6d Merge pull request 'Stop composing ADR 0312's group grant, so deliveries to the media machine pass again (hq issue 567)' (#270) from fix/567-revert-the-group-grant into main 2026-10-12 15:36:12 +00:00
jschoubben 25deb499bf Say why a merge's changed files read as none on an error
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
2026-10-12 17:31:36 +02:00
jschoubben 83a9cf6e3a Judge a pull request's head merged into its trunk, and name the trunk commit
A check judged the head alone, so two pull requests that each passed could
break the trunk together (node-engine #66 on 2026-10-09), and a recheck after
the trunk moved judged the same head again. The build seat now merges the head
into the trunk's head in its own clone, never pushed, fails a head that does not
merge cleanly naming the files, and the verdict names the head, the trunk
commit and the commits read beside it, for the forge and mesh-delivery to weigh
against the trunk's next move (novox/hq ADR 0323, issue 341).
2026-10-12 17:27:01 +02:00
jschoubben 7b050abdcb Stop composing ADR 0312's group grant, so deliveries to the media machine pass again
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 delivered
Since #256 delivered, the node-engine judges every read-write access by the path's group alone and
states six media modules unhealthy on ace, though they write their folders as the owner; every
delivery there fails its gate (hq issue 567). ADR 0325 replaces ADR 0312; until it is built, the
controller sends no grant, as before #256.

This reverts commit 3cfc121670.
2026-10-12 17:25:46 +02:00
jochen c3b4f187e1 Name issue 401's two store tests, so a skip without the store fails the check (issue 459's rule)
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 failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
2026-10-12 17:15:18 +02:00
jochen edd33f21d2 Make no value for an own secret issued outside the mesh that nobody gave (issue 401)
The send minted a random value for a plain own secret marked issued-by outside and placed it, so a
module could not tell "not given" from "given and wrong" (ADR 0228). It now places only a value a
person gave: not given is no file, an empty value where a module's file names it, and nothing to
restart on, as ADR 0283 decided for a family's member. A value the mesh made for one earlier is not
read as given.
2026-10-12 17:15:12 +02:00
mesh-admin 3cfc121670 Merge pull request 'Compose each read-write access's grant for the node-engine (hq ADR 0312, issue 537)' (#256) from feat/537-a-read-write-access-grants-the-paths-group into main 2026-10-12 14:20:27 +00:00
mesh-admin db7e4869ae Merge pull request 'Raise a machine that refuses or only partly applies its declarations past one apply interval (issue 233)' (#263) from fix/233-a-failed-probe-fails-only-what-needs-the-tool-2 into main 2026-10-12 14:11:10 +00:00
jochen d4de4543cc Judge S21 by time since the first failing report while the machine is heard, not by how many reports said it (issue 233 review)
mesh/merge-gate pass: builds mesh-controller → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/233-a-failed-probe-fails-only-what-needs-the-tool-2 delivered: every member is delivered
2026-10-12 15:39:08 +02:00
jochen 3e10ad1535 Raise a machine that refuses or only partly applies its declarations past one apply interval, naming the package manager and its words, so the mesh says it rather than only status (issue 233)
mesh/delivery-group group fix/233-a-failed-probe-fails-only-what-needs-the-tool-2 ready: every member ready, and composed together they pass
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-12 14:49:33 +02:00
jschoubben 74bd90df7e Pin the node-engine at its merge of the access grant, and capture it beside (hq ADR 0312, issue 537)
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 failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
2026-10-12 13:47:28 +02:00
jschoubben 30599c1858 Pin the node-engine at its reviewed grant, and hold the mount match to the path itself (hq ADR 0312, issue 537 review)
A path beginning with the same letters is another path, and a read-only mount
of a read-write access is still granted: the grant follows what mounts it.
2026-10-12 13:47:17 +02:00
jschoubben 10d4712f82 Name who a read-write access grants its path's group to, so modules write shared data through the group the operator gave it (hq ADR 0312, issue 537)
Each read-write access is composed with a grant: the module's containers that
mount its path and the account its tools run as (the operator's, or the module's
own under ADR 0259 §8); that runtime restarts on the access, so it holds a group
the engine put its account in. A read access grants nothing. Sent only to an
engine whose statement says GrantContract (4), since an older one refuses the
field. Pins the node-engine at the branch that reads it.
2026-10-12 13:47:17 +02:00
24 changed files with 1319 additions and 33 deletions
+2
View File
@@ -6,3 +6,5 @@
/mesh-controller /mesh-controller
/postgres-provisioner /postgres-provisioner
/redis-provisioner /redis-provisioner
# And where `go build` in cmd/mesh-controller leaves it (novox/hq issue 233).
/cmd/mesh-controller/mesh-controller
+8 -2
View File
@@ -256,7 +256,13 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
v, err = builder.Check(building, builder.Command, checkSpecOf(request), workspace, registry, forgeFrom(), say) v, err = builder.Check(building, builder.Command, checkSpecOf(request), workspace, registry, forgeFrom(), say)
if err == nil { if err == nil {
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report, result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo)} Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside}
// What each of a group's heads was judged as (novox/hq ADR 0323 §6).
for _, m := range v.Members {
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
Commit: m.Head, Trunk: m.Trunk})
}
} }
} else if err == nil { } else if err == nil {
// The package-registry credential is a build input, so it is resolved before the clone: a // The package-registry credential is a build input, so it is resolved before the clone: a
@@ -354,7 +360,7 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
} }
for _, m := range c.Members { for _, m := range c.Members {
spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository, spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository,
Ref: m.Ref, Paths: m.Paths}) Ref: m.Ref, Paths: m.Paths, Base: m.Base})
} }
return spec return spec
} }
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"fmt"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
)
// kindApplyFailing is S21's kind: a machine whose declarations have been refused, or applied only in part,
// for longer than one apply interval (novox/hq issue 233).
const kindApplyFailing = "apply-failing"
// applyEvery is how often a node-engine applies what it was last told when nothing new arrives: its
// ReconcileEvery. A failure still there after one more of them is not one the next apply clears.
const applyEvery = 5 * time.Minute
// packageManagerKey is how a node-engine names a package manager that does not answer among what failed
// (mesh-host's probe.go): "the package manager (pacman)".
const packageManagerKey = "the package manager ("
// watchApplyFailing is S21: **a machine that does not apply what it was sent is said by the mesh, not only
// to someone who asks for `status`** (novox/hq issue 233, issue 187's family). A workstation refused every
// declaration for an hour and a half because its package manager did not answer, and the mesh's only word
// on it was a line in `status`. Raised once the machine's first report of this failure is older than one apply
// interval **while the machine is heard** — judged by time, not by how many reports said it: a node-engine
// says a re-apply only when how it went changed, so the same failure is one report however long it lasts.
// Names the refusal's or the failure's words; cleared by the clean apply the node-engine then says. Not while
// the machine is silent, asleep or not known: S1 says silent, and asleep is not lost.
func watchApplyFailing(f *signalFacts) []conditions.Observation {
heard := map[string]bool{}
for _, m := range f.machines {
if !m.asleep() && !m.lastHeard.IsZero() && f.now.Sub(m.lastHeard) <= heartbeatBound(m.every) {
heard[m.name] = true
}
}
var out []conditions.Observation
for _, d := range f.doing {
if !d.Wrong() || d.Since == nil || f.now.Sub(*d.Since) <= applyEvery || !heard[d.Node] {
continue
}
out = append(out, applyFailing(d, f.now))
}
return out
}
// applyFailing is one machine's S21 condition.
func applyFailing(d inventory.Doing, now time.Time) conditions.Observation {
m := d.Node
since := d.Since.UTC().Format("2006-01-02 15:04 MST")
o := conditions.Observation{Scope: conditions.ScopeMachine, ID: m, Kind: kindApplyFailing, Machine: m,
Severity: conditions.Warning, Said: doingWords(d),
Resolved: m + " applies what it is sent again"}
how := "refused its declaration whole"
if d.Outcome != inventory.OutcomeRefused {
how = "applied its declaration only in part"
}
o.Summary = fmt.Sprintf("%s has %s since %s (bound %s): %s", m, how, since, ago(applyEvery), failedNames(d))
o.Headline = m + " is not applying its update"
o.Explanation = fmt.Sprintf("%s has %s for %s, and trying again has not helped. The details say what fails.",
m, plainHow(d), humanDuration(now.Sub(*d.Since)))
if tool := unansweringTool(d); tool != "" {
o.Summary = fmt.Sprintf("%s: %s does not answer, so no package reaches it; everything else it was sent "+
"is applied, since %s (bound %s)", m, tool, since, ago(applyEvery))
o.Headline = fmt.Sprintf("%s cannot install packages", m)
o.Explanation = fmt.Sprintf("The package manager on %s (%s) has not answered for %s, so no package reaches "+
"it. Everything else is still applied, so an update that repairs it can land; if none does, it needs a "+
"look on the machine.", m, tool, humanDuration(now.Sub(*d.Since)))
}
return o
}
// plainHow is how a machine failed, as the operator reads it.
func plainHow(d inventory.Doing) string {
if d.Outcome == inventory.OutcomeRefused {
return "refused every update"
}
return "applied its updates only in part"
}
// unansweringTool is the package manager a machine said does not answer, or "".
func unansweringTool(d inventory.Doing) string {
for _, r := range d.Failed {
if rest, ok := strings.CutPrefix(r.ID, packageManagerKey); ok {
return strings.TrimSuffix(rest, ")")
}
}
return ""
}
// failedNames is what failed, by name: the refusal, or the failed resources' ids.
func failedNames(d inventory.Doing) string {
if d.Outcome == inventory.OutcomeRefused {
return "refused"
}
ids := make([]string, 0, len(d.Failed))
for _, r := range d.Failed {
ids = append(ids, r.ID)
}
sort.Strings(ids)
return "failed " + strings.Join(ids, ", ")
}
// doingWords is the machine's own words on what failed: the evidence, kept inside the mesh.
func doingWords(d inventory.Doing) string {
if d.Outcome == inventory.OutcomeRefused {
return "refused: " + oneLine(d.Refused)
}
var parts []string
for _, r := range d.Failed {
parts = append(parts, r.ID+": "+oneLine(r.Error))
}
sort.Strings(parts)
return strings.Join(parts, "; ")
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
)
// unansweringPackageManager is anchor's last report as the workstation's read on 2026-10-04, once its
// node-engine applies the rest (novox/hq issue 233): the package failed and the package manager did not answer,
// the same failure on times reports since for before now.
func unansweringPackageManager(now time.Time, forHowLong time.Duration, times int) inventory.Doing {
since := now.Add(-forHowLong)
return inventory.Doing{Node: "anchor", Outcome: inventory.OutcomeFailed, At: now.Add(-time.Second),
Since: &since, Times: times, Applied: 41, Failed: []inventory.FailedResource{
{ID: "apply", Error: `applying "pacman.base": nothing can be said about "base": this is the arch host and ` +
"pacman does not answer here. Either this machine is not Arch, or its package database is broken: " +
"pacman exited 1: error: config file /etc/pacman.conf could not be read: No such file or directory"},
{ID: "the package manager (pacman)", Error: "pacman does not answer here, so every package in this " +
"declaration fails and the rest of it is applied (novox/hq issue 233): pacman exited 1: error: config " +
"file /etc/pacman.conf could not be read: No such file or directory"}}}
}
// **A machine whose package manager does not answer is raised by the mesh itself, naming the tool and the
// probe's words** (novox/hq issue 233): the words in the evidence, the path kept out of the summary.
func TestAPackageManagerThatDoesNotAnswerIsRaisedNamingItsWords(t *testing.T) {
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
f := calm(now)
f.doing = []inventory.Doing{unansweringPackageManager(now, 90*time.Minute, 18)}
got := watchApplyFailing(f)
if len(got) != 1 {
t.Fatalf("raised %d conditions, want one: %+v", len(got), got)
}
o := got[0]
if o.Key() != "machine.anchor.apply-failing" || o.Machine != "anchor" {
t.Errorf("the condition is not the machine's: %s", o.Key())
}
if !strings.Contains(o.Summary, "pacman does not answer") || !strings.Contains(o.Summary, "since 2026-10-04 12:30") {
t.Errorf("the summary does not name the tool and since when: %s", o.Summary)
}
if strings.Contains(o.Summary, "/etc/") {
t.Errorf("the summary carries a path, which belongs in the evidence: %s", o.Summary)
}
if !strings.Contains(o.Said, "could not be read") {
t.Errorf("the evidence does not carry the probe's words: %s", o.Said)
}
if !strings.Contains(o.Headline, "cannot install packages") || o.Explanation == "" || o.Resolved == "" {
t.Errorf("the plain words do not say it: %+v", o)
}
}
// **A whole refusal is raised too**, with the node-engine's words: what the workstation's report said before the
// node-engine applied the rest.
func TestARefusalThatGoesOnIsRaised(t *testing.T) {
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
since := now.Add(-time.Hour)
f := calm(now)
f.doing = []inventory.Doing{{Node: "anchor", Outcome: inventory.OutcomeRefused, Since: &since, Times: 12,
Refused: "this is the arch host and pacman does not answer here", At: now.Add(-time.Minute)}}
got := watchApplyFailing(f)
if len(got) != 1 || !strings.Contains(got[0].Summary, "refused its declaration whole") ||
!strings.Contains(got[0].Said, "pacman does not answer") {
t.Fatalf("a refusal that went on for an hour was not raised with its words: %+v", got)
}
}
// **Judged by time while the machine is heard, not by how many reports said it** (review of novox/hq issue 233):
// a node-engine says the same failure once, so one report an apply interval old is a fault that goes on; one
// younger is not yet; and a silent or sleeping machine is S1's.
func TestOneReportOlderThanAnApplyIsRaisedAndAQuietMachineIsNot(t *testing.T) {
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
f := calm(now)
f.doing = []inventory.Doing{unansweringPackageManager(now, 6*time.Minute, 1)}
if got := watchApplyFailing(f); len(got) != 1 {
t.Errorf("one report of a failure six minutes old was not raised: %+v", got)
}
f.doing = []inventory.Doing{unansweringPackageManager(now, 4*time.Minute, 1)}
if got := watchApplyFailing(f); len(got) != 0 {
t.Errorf("a failure four minutes old was raised before one apply could clear it: %+v", got)
}
f.doing = []inventory.Doing{unansweringPackageManager(now, time.Hour, 1)}
f.machines[0].lastHeard = now.Add(-time.Hour)
if got := watchApplyFailing(f); len(got) != 0 {
t.Errorf("a silent machine was raised for its last report: %+v", got)
}
f = calm(now)
f.doing = []inventory.Doing{unansweringPackageManager(now, time.Hour, 1)}
f.doing[0].Node = "a-machine-the-mesh-does-not-know"
if got := watchApplyFailing(f); len(got) != 0 {
t.Errorf("a machine not heard of was raised: %+v", got)
}
}
// **A failed re-apply followed by a clean one clears S21** (review of novox/hq issue 233): the node-engine says
// the clean re-apply, the store keeps it as applied with no failure since, and the condition the failure raised
// is cleared through the keeper on the next tick.
func TestACleanReapplyAfterAFailedOneClearsTheCondition(t *testing.T) {
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
store := conditions.NewInMemory()
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store,
Teller: &conditions.Told{}, Now: func() time.Time { return now }})
defer k.Close(context.Background())
w := &watchdogs{keeper: k, started: now.Add(-time.Hour)}
failing := calm(now)
failing.doing = []inventory.Doing{unansweringPackageManager(now, 20*time.Minute, 1)}
w.see(t.Context(), failing)
open, err := k.Open(t.Context())
if err != nil || len(open) != 1 || open[0].Kind != kindApplyFailing {
t.Fatalf("the failed re-apply raised %+v (%v), want apply-failing", open, err)
}
clean := calm(now.Add(5 * time.Minute))
clean.doing = nil // the clean re-apply recorded: NotDoingWhatTheyWereTold no longer lists the machine
w.see(t.Context(), clean)
if open, _ := k.Open(t.Context()); len(open) != 0 {
t.Fatalf("the condition stayed open after the clean re-apply: %+v", open)
}
}
+50 -7
View File
@@ -161,9 +161,7 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
return u, err == nil return u, err == nil
}) })
fmt.Print(planText(plan)) fmt.Print(planText(plan))
direct := link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit, direct := directVerdict(p, plan)
ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched,
Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan}
switch { switch {
case !scope.gated() && !scope.Mesh: case !scope.gated() && !scope.Mesh:
fmt.Printf("%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n", fmt.Printf("%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n",
@@ -201,6 +199,24 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
return nil return nil
} }
// directVerdict is the verdict of a change no build judges — it touches no module, and either the repository is not
// the mesh's or the head holds no merge-check.sh. **It names the trunk commit it holds for** (novox/hq ADR 0323 §3):
// the base branch's head the forge announced with the head, so the merge tool can weigh it against the trunk's head
// and does not refuse it for ever (a verdict naming no trunk is never the trunk's head's). Nothing was built to
// judge: the trunk commit is the one the forge said the change was announced against.
func directVerdict(p link.PullUpdated, plan link.ChangePlan) link.Checked {
trunk := p.BaseSHA
if len(trunk) > 12 {
trunk = trunk[:12]
}
if trunk != "" {
plan.Trunk = trunk
}
return link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit,
ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched,
Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan, Trunk: trunk}
}
// checkRequestFor is the ask for one pull request's head: the repository as the mesh clones it, the head, // checkRequestFor is the ask for one pull request's head: the repository as the mesh clones it, the head,
// and what is read beside it. // and what is read beside it.
func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scope checkScope, func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scope checkScope,
@@ -363,6 +379,9 @@ func checkedOf(result link.BuildResult) link.Checked {
case result.Check != nil: case result.Check != nil:
c.Verdict, c.Summary, c.Report = result.Check.Verdict, result.Check.Summary, result.Check.Report c.Verdict, c.Summary, c.Report = result.Check.Verdict, result.Check.Summary, result.Check.Report
c.Gate, c.RepoCheck = result.Check.Gate, result.Check.RepoCheck c.Gate, c.RepoCheck = result.Check.Gate, result.Check.RepoCheck
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the
// repositories read beside it.
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
if c.Gate == nil { if c.Gate == nil {
// A build seat from before the layers: its verdict is the gate's. // A build seat from before the layers: its verdict is the gate's.
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary} c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
@@ -388,14 +407,20 @@ func checkedOf(result link.BuildResult) link.Checked {
} }
if result.Checked != nil { if result.Checked != nil {
c.Plan = result.Checked.Plan c.Plan = result.Checked.Plan
if c.Plan != nil && c.Trunk != "" {
// The kept delivery plan records the trunk commit with its other inputs (ADR 0323 §3).
plan := *c.Plan
plan.Trunk = c.Trunk
c.Plan = &plan
}
// A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first. // A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first.
if g := result.Checked; g.Group != "" { if g := result.Checked; g.Group != "" {
c.Group = g.Group c.Group = g.Group
c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number, c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number,
Commit: result.Ref}) Commit: result.Ref, Trunk: c.Trunk})
for _, m := range g.Members { for _, m := range g.Members {
c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number, c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number,
Commit: m.Ref}) Commit: m.Ref, Trunk: memberTrunk(result.Check, m)})
} }
} }
} }
@@ -410,6 +435,20 @@ func checkedOf(result link.BuildResult) link.Checked {
return c return c
} }
// memberTrunk is the trunk commit the build seat merged one of a group's heads into (novox/hq ADR 0323 §6), empty
// when it said none.
func memberTrunk(outcome *link.CheckOutcome, m link.GroupMember) string {
if outcome == nil {
return ""
}
for _, j := range outcome.Members {
if strings.EqualFold(j.Owner, m.Owner) && strings.EqualFold(j.Repo, m.Repo) && strings.HasPrefix(m.Ref, j.Commit) {
return j.Trunk
}
}
return ""
}
func prefixedAll(prefix string, items []string) []string { func prefixedAll(prefix string, items []string) []string {
out := make([]string, 0, len(items)) out := make([]string, 0, len(items))
for _, i := range items { for _, i := range items {
@@ -424,8 +463,12 @@ func sayChecked(ctx context.Context, c link.Checked) {
if c.RepoCheck != nil { if c.RepoCheck != nil {
repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary
} }
fmt.Printf("%s: %s/%s#%d at %.8s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number, judged := ""
c.Commit, orSomewhere(c.On), strings.ToUpper(c.Verdict), c.Summary, repo) if c.Trunk != "" {
judged = " merged into trunk " + c.Trunk
}
fmt.Printf("%s: %s/%s#%d at %.8s%s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
c.Commit, judged, orSomewhere(c.On), strings.ToUpper(c.Verdict), c.Summary, repo)
if checkEvents == nil { if checkEvents == nil {
return return
} }
+78
View File
@@ -210,3 +210,81 @@ func TestABuildOffTheTrunkIsNeverPublished(t *testing.T) {
t.Errorf("a build seat that said nothing was refused: %v", err) t.Errorf("a build seat that said nothing was refused: %v", err)
} }
} }
// **A verdict names the trunk commit it judged** (novox/hq ADR 0323 §3, issue 341): the controller's `checked`
// carries the head and the trunk commit the build seat merged it into, the commits of the repositories read beside
// it, how long it took, and the kept delivery plan records the trunk commit with its other inputs. A group's
// composed check names, per member, the trunk commit that head was merged into (§6).
func TestAVerdictNamesTheTrunkCommitItJudged(t *testing.T) {
plan := &link.ChangePlan{Repository: "novox/mesh-host", Base: "main", Head: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Summary: "builds mesh-host"}
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 66, Base: "main", Plan: plan}
c := checkedOf(link.BuildResult{ID: "b", Ref: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Checked: asked,
Check: &link.CheckOutcome{Verdict: "pass", Took: "4m12s", Head: "dfb1415aaaaa", Trunk: "d28ca95bbbbb",
Gate: &link.CheckLayer{Verdict: "pass"}, RepoCheck: &link.CheckLayer{Verdict: "pass"},
Beside: map[string]string{"mesh-catalog": "0123456789ab", "mesh-lab": "ba9876543210"}}})
if c.Trunk != "d28ca95bbbbb" || c.Commit != "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
t.Fatalf("the verdict names head %s and trunk %q", c.Commit, c.Trunk)
}
if c.Beside["mesh-catalog"] != "0123456789ab" || c.Beside["mesh-lab"] != "ba9876543210" {
t.Fatalf("the verdict names the repositories read beside it as %v", c.Beside)
}
if c.Took != "4m12s" {
t.Fatalf("the verdict says it took %q", c.Took)
}
if c.Plan == nil || c.Plan.Trunk != "d28ca95bbbbb" || c.Plan.Head != plan.Head {
t.Fatalf("the kept plan records %+v", c.Plan)
}
if plan.Trunk != "" {
t.Fatal("the plan the controller asked with was changed: the verdict's is a copy")
}
group := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 7, Base: "main", Group: "feat/x",
Members: []link.GroupMember{{Owner: "novox", Repo: "mesh-host", Number: 8, Ref: "cccccccccccc0000", Base: "main"}}}
c = checkedOf(link.BuildResult{ID: "g", Ref: "aaaaaaaaaaaa0000", Checked: group, Check: &link.CheckOutcome{
Verdict: "pass", Head: "aaaaaaaaaaaa", Trunk: "111111111111", Gate: &link.CheckLayer{Verdict: "pass"},
Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-host", Commit: "cccccccccccc", Trunk: "222222222222"}}}})
if len(c.Members) != 2 || c.Members[0].Trunk != "111111111111" || c.Members[1].Trunk != "222222222222" ||
c.Members[1].Commit != "cccccccccccc0000" {
t.Fatalf("the composed check names its members %+v", c.Members)
}
}
// **The merge made for a check is never registered** (novox/hq ADR 0323 §2): a build of it — a commit on no branch
// of the forge — is refused, naming the trunk. The rule of ADR 0238 §1 stands unchanged; this holds it for the
// merge commit a check makes.
func TestABuildOfACheckMergeIsRefusedNamingTheTrunk(t *testing.T) {
merge := link.BuildResult{ID: "b", Commit: "4b5c6d7e8f90aabbccddeeff0011223344556677", Trunk: "main"}
err := publishable(merge, "")
if !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "not on main") {
t.Fatalf("a build of a check's merge was answered %v", err)
}
}
// **A verdict no build judges names its trunk commit too** (novox/hq ADR 0323 §3): a change touching no module of a
// repository with no merge-check.sh (novox/mesh-tools-go) is answered by the controller itself. Naming no trunk, the
// merge tool refused it, asked again, got the same verdict, and refused for ever.
func TestADirectVerdictNamesTheTrunkTheForgeAnnounced(t *testing.T) {
p := link.PullUpdated{Owner: "novox", Repo: "mesh-tools-go", Number: 3, Base: "main", Commit: "1a2b3c4d5e6f7a8b9c0d",
BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"}
c := directVerdict(p, link.ChangePlan{Repository: "novox/mesh-tools-go", Base: "main", Head: p.Commit})
if c.Trunk != "7a8b9c0d1e2f" || c.Plan == nil || c.Plan.Trunk != "7a8b9c0d1e2f" {
t.Fatalf("the direct verdict names trunk %q, its plan %+v", c.Trunk, c.Plan)
}
if c.Verdict != "pass" || c.Commit != p.Commit {
t.Fatalf("the direct verdict is %+v", c)
}
if c := directVerdict(link.PullUpdated{Owner: "novox", Repo: "x", Commit: "abc"}, link.ChangePlan{}); c.Trunk != "" {
t.Fatalf("a forge that said no base head gave the trunk %q", c.Trunk)
}
}
// A head checked again through mesh-delivery (`delivery check --members`, a recheck) reaches the same direct verdict:
// it names the base head the forge last announced, kept on the member, or the merge tool would refuse it for ever.
func TestARecheckedHeadsDirectVerdictNamesItsTrunk(t *testing.T) {
m := orderMember{ID: "novox/mesh-tools-go@1a2b3c4d5e6f", Repository: "novox/mesh-tools-go", Base: "main",
Head: "1a2b3c4d5e6f7a8b9c0d", Number: 3, BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"}
if c := directVerdict(m.pull(), link.ChangePlan{}); c.Trunk != "7a8b9c0d1e2f" {
t.Fatalf("a rechecked head's direct verdict names trunk %q", c.Trunk)
}
}
+5 -2
View File
@@ -580,6 +580,9 @@ type orderMember struct {
CloneURL string `json:"clone_url,omitempty"` CloneURL string `json:"clone_url,omitempty"`
// After are the repositories its pull request says it goes after (`after: <repository>`). // After are the repositories its pull request says it goes after (`after: <repository>`).
After []string `json:"after,omitempty"` After []string `json:"after,omitempty"`
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
// a verdict no build judges names when the head is checked again through mesh-delivery.
BaseSHA string `json:"base_sha,omitempty"`
} }
// pull is the member as the forge announced it. // pull is the member as the forge announced it.
@@ -589,7 +592,7 @@ func (m orderMember) pull() link.PullUpdated {
if base == "" { if base == "" {
base = "main" base = "main"
} }
return link.PullUpdated{Owner: owner, Repo: repo, Number: m.Number, Base: base, Commit: m.Head, return link.PullUpdated{Owner: owner, Repo: repo, Number: m.Number, Base: base, Commit: m.Head, BaseSHA: m.BaseSHA,
CloneURL: m.CloneURL, Paths: m.Paths, PathsTruncated: m.PathsTruncated, Removed: m.Removed, CloneURL: m.CloneURL, Paths: m.Paths, PathsTruncated: m.PathsTruncated, Removed: m.Removed,
ModuleDirs: m.ModuleDirs, ModuleDirsSaid: m.ModuleDirsSaid} ModuleDirs: m.ModuleDirs, ModuleDirsSaid: m.ModuleDirsSaid}
} }
@@ -1185,7 +1188,7 @@ func askGroupCheck(ctx context.Context, open *stores, group string, members []or
return "", fmt.Errorf("%s cannot be cloned for the group's check: %w", m.ID, err) return "", fmt.Errorf("%s cannot be cloned for the group's check: %w", m.ID, err)
} }
request.Check.Members = append(request.Check.Members, link.GroupMember{Owner: mp.Owner, Repo: mp.Repo, request.Check.Members = append(request.Check.Members, link.GroupMember{Owner: mp.Owner, Repo: mp.Repo,
Number: mp.Number, Repository: url, Ref: mp.Commit, Paths: mp.Paths}) Number: mp.Number, Repository: url, Ref: mp.Commit, Paths: mp.Paths, Base: mp.Base})
} }
seat := buildSeatHeld(ctx) seat := buildSeatHeld(ctx)
ask, err := askOverOn(seat) ask, err := askOverOn(seat)
@@ -0,0 +1,120 @@
package main
import (
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228). Seen on
// 2026-10-10: the send made a random value for the mounts module's smb-credentials and placed it, and the module read
// it as a credential given and wrong. The send now makes none, places none, and places a value only once a person
// gave one — and a value the mesh made for it before is not placed either.
// anAssistant is a module with a secret the mesh makes and one only an outside party issues.
func anAssistant() catalogue.Manifest {
return catalogue.Manifest{Module: "assistant", Version: "1",
OwnSecrets: catalogue.OwnSecrets{
"password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart},
"vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
},
Resources: []map[string]any{
{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"},
}}
}
// sentTo composes what a push sends to a machine, on the send path.
func sentTo(t *testing.T, open *stores, node string) []map[string]any {
t.Helper()
plan, settings, err := planFor(t.Context(), open, node)
if err != nil {
t.Fatal(err)
}
declared, err := declarationFor(t.Context(), open, node, plan, settings)
if err != nil {
t.Fatalf("the send to %s did not compose: %v", node, err)
}
return declared.Resources
}
func placedAt(resources []map[string]any, path string) map[string]any {
for _, r := range resources {
if r["path"] == path && r["sealed"] != nil {
return r
}
}
return nil
}
func TestTheSendMakesNoValueForAnOutsideSecretNobodyGave(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, anAssistant())
if _, err := assign(ctx, open, "laptop", "assistant"); err != nil {
t.Fatal(err)
}
sent := sentTo(t, open, "laptop")
if placedAt(sent, "/var/lib/mesh/assistant/password") == nil {
t.Fatal("the secret the mesh makes was not placed")
}
if got := placedAt(sent, "/var/lib/mesh/assistant/vendor-key"); got != nil {
t.Fatalf("a secret nobody gave was placed: %v", got)
}
if _, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "assistant", "vendor-key"); err != nil || held {
t.Fatalf("the send made a value for a secret issued outside the mesh (held %v, %v)", held, err)
}
// A value the mesh made before this rule is not one a person gave: it is not placed.
if _, err := open.inventory.SecretForModule(ctx, "laptop", "assistant", "vendor-key"); err != nil {
t.Fatal(err)
}
if got := placedAt(sentTo(t, open, "laptop"), "/var/lib/mesh/assistant/vendor-key"); got != nil {
t.Fatalf("a value the mesh made for a secret issued outside was placed: %v", got)
}
// Given, it is placed.
if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "assistant", "vendor-key", "the vendor's key"); err != nil {
t.Fatal(err)
}
given, ok, err := open.inventory.GivenOwnSecret(ctx, "laptop", "assistant", "vendor-key")
if err != nil || !ok || !given.Current {
t.Fatalf("the value given is not read as given: %+v %v %v", given, ok, err)
}
placed := placedAt(sentTo(t, open, "laptop"), "/var/lib/mesh/assistant/vendor-key")
if placed == nil || placed["sealed"] != given.Sealed {
t.Fatalf("the value given is placed as %v", placed)
}
}
// The read paths answer as the send does: a question about the machine composes without the secret nobody gave, and
// asking writes nothing.
func TestAQuestionComposesWithoutAnOutsideSecretNobodyGave(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, anAssistant())
if _, err := assign(ctx, open, "laptop", "assistant"); err != nil {
t.Fatal(err)
}
pushedBy(t, open, "laptop") // the mesh's own secret is made by the send
plan, settings, err := planFor(ctx, open, "laptop")
if err != nil {
t.Fatal(err)
}
gens, err := generators(ctx, open)
if err != nil {
t.Fatal(err)
}
for _, choosing := range []Choosing{Reading, Foreseeing} {
got, err := declarationWith(ctx, open, "laptop", plan, settings, gens, choosing)
if err != nil {
t.Fatalf("%v: %v", choosing, err)
}
if placedAt(got.Resources, "/var/lib/mesh/assistant/vendor-key") != nil || len(got.foreseen) != 0 {
t.Fatalf("%v: a secret nobody gave is composed (foreseen %v)", choosing, got.foreseen)
}
}
if _, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "assistant", "vendor-key"); err != nil || held {
t.Fatalf("a question made a value (held %v, %v)", held, err)
}
}
+7
View File
@@ -81,6 +81,13 @@ var plainWordings = map[string]func(conditions.Observation) words{
"It usually catches up by itself.", m, m), "It usually catches up by itself.", m, m),
Resolved: m + " applied the change"} Resolved: m + " applied the change"}
}), }),
kindApplyFailing: worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine")
return words{Headline: m + " is not applying its update",
Explanation: fmt.Sprintf("%s has refused or only partly applied what the mesh sent it, again and again. "+
"The details say what fails.", conditions.Capital(m)),
Resolved: m + " applies what it is sent again"}
}),
"tools-silent": worded(func(o conditions.Observation) words { "tools-silent": worded(func(o conditions.Observation) words {
m := machineOr(o, "a machine") m := machineOr(o, "a machine")
return words{Headline: "Tools on " + m + " are not answering", return words{Headline: "Tools on " + m + " are not answering",
+33 -5
View File
@@ -590,6 +590,13 @@ func busCredentialIssued(ctx context.Context, inv *inventory.Inventory, node, mo
return nil return nil
} }
// givenUnderAnOldKey is the refusal for a value a person gave — a family's member, or an own secret issued outside
// the mesh — sealed to a key the machine no longer holds: the mesh cannot make another, so it is given again.
func givenUnderAnOldKey(module, node, name string) error {
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
"since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node)
}
// renderingFor is everything a node's declaration is composed with, and the node's record. // renderingFor is everything a node's declaration is composed with, and the node's record.
func renderingFor(ctx context.Context, open *stores, node string, func renderingFor(ctx context.Context, open *stores, node string,
plan catalogue.Resolution, settings catalogue.SettingsBy, plan catalogue.Resolution, settings catalogue.SettingsBy,
@@ -708,10 +715,7 @@ func renderingFor(ctx context.Context, open *stores, node string,
} }
if !g.Current { if !g.Current {
if choosing == Allocating { if choosing == Allocating {
return catalogue.Rendering{}, inventory.Node{}, fmt.Errorf( return catalogue.Rendering{}, inventory.Node{}, givenUnderAnOldKey(m.Module, node, g.Name)
"%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
"since generated a new sealing key. The mesh cannot make another; give it again",
m.Module, node, g.Name, node)
} }
continue continue
} }
@@ -721,7 +725,31 @@ func renderingFor(ctx context.Context, open *stores, node string,
needed[m.Module][g.Name] = g.Sealed needed[m.Module][g.Name] = g.Sealed
} }
} }
for name := range m.OwnSecrets.Plain() { for name, own := range m.OwnSecrets.Plain() {
// **An own secret issued outside the mesh is never made** (novox/hq issue 401, ADR 0228; ADR 0283 for
// a family): only a value a person gave is placed. One not given is nothing — no file, and an empty
// value where a file of the module names it — so the module can tell it was not given, where a value
// the mesh made looked given and wrong. A value the mesh made for it before is not one either.
if own.IssuedBy == catalogue.IssuedOutside {
g, given, err := inv.GivenOwnSecret(ctx, node, m.Module, name)
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
if !given {
continue
}
if !g.Current {
if choosing == Allocating {
return catalogue.Rendering{}, inventory.Node{}, givenUnderAnOldKey(m.Module, node, name)
}
continue
}
if needed[m.Module] == nil {
needed[m.Module] = map[string]string{}
}
needed[m.Module][name] = g.Sealed
continue
}
// Minted on the send path and only read on every other. Making one is an insert, and // Minted on the send path and only read on every other. Making one is an insert, and
// a question that writes is a question that can block against the machine it is about. // a question that writes is a question that can block against the machine it is about.
var sealed string var sealed string
+10
View File
@@ -254,6 +254,16 @@ var signalsTable = []signalRow{
newest: func(f *signalFacts) time.Time { newest: func(f *signalFacts) time.Time {
return newestOf(f.bus.waits, func(w busWaitFacts) time.Time { return w.since }) return newestOf(f.bus.waits, func(w busWaitFacts) time.Time { return w.since })
}}, }},
{Row: "S21", Signal: "a clean apply", Emitter: "node-engine", Trigger: "each report of a declaration refused or applied only in part (novox/hq issue 233)",
Bound: "one apply interval (5 min) past the first report of the same failure, judged by time while the machine " +
"is heard — the node-engine says a re-apply only when how it went changed; not while it is silent or " +
"asleep; naming the refusal, or the package manager that does not answer with its words; cleared by the " +
"clean apply it then says",
Kind: kindApplyFailing, Severity: conditions.Warning, Phase: 2,
needs: func(f *signalFacts) error { return f.doingErr }, watch: watchApplyFailing,
newest: func(f *signalFacts) time.Time {
return newestOf(f.doing, func(d inventory.Doing) time.Time { return d.At })
}},
} }
// watchFacts is S14: the snapshot a merge check is fed is older than its bound, or none was kept since // watchFacts is S14: the snapshot a merge check is fed is older than its bound, or none was kept since
+8
View File
@@ -186,6 +186,14 @@ var suppressions = map[string]suppression{
}, },
past: func(f *signalFacts) { f.refusedSends = []inventory.Send{refusedSend(f.now.Add(-time.Second))} }, past: func(f *signalFacts) { f.refusedSends = []inventory.Send{refusedSend(f.now.Add(-time.Second))} },
}, },
// A machine whose package manager does not answer (novox/hq issue 233): said once its first report of the
// failure is older than one apply interval while it is heard. Inside: exactly five minutes old.
"S21": {
inside: func(f *signalFacts) { f.doing = []inventory.Doing{unansweringPackageManager(f.now, 5*time.Minute, 1)} },
past: func(f *signalFacts) {
f.doing = []inventory.Doing{unansweringPackageManager(f.now, 5*time.Minute+time.Second, 1)}
},
},
// Twice by hand within a fortnight is a healer wanted; once, or the first of two a day too old, is not. // Twice by hand within a fortnight is a healer wanted; once, or the first of two a day too old, is not.
"S15": { "S15": {
inside: func(f *signalFacts) { inside: func(f *signalFacts) {
+5
View File
@@ -59,6 +59,10 @@ type signalFacts struct {
// from, each naming its sender (S20, novox/hq issue 234). // from, each naming its sender (S20, novox/hq issue 234).
refusedSends []inventory.Send refusedSends []inventory.Send
refusedSendsErr error refusedSendsErr error
// doing is every machine whose last report was not a clean apply, with since when it has said the same
// failure (S21, novox/hq issue 233).
doing []inventory.Doing
doingErr error
loop loopFacts loop loopFacts
loopErr error loopErr error
@@ -369,6 +373,7 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts {
} }
f.handActs, f.handActsErr = w.gatherHandActs(ctx, now) f.handActs, f.handActsErr = w.gatherHandActs(ctx, now)
f.refusedSends, f.refusedSendsErr = inv.RefusedSendsSince(ctx, now.Add(-generationRefusalsSaid)) f.refusedSends, f.refusedSendsErr = inv.RefusedSendsSince(ctx, now.Add(-generationRefusalsSaid))
f.doing, f.doingErr = inv.NotDoingWhatTheyWereTold(ctx)
f.facts.taken, _, f.facts.began, f.facts.err = exportedFacts.last() f.facts.taken, _, f.facts.began, f.facts.err = exportedFacts.last()
return f return f
} }
+77 -6
View File
@@ -49,8 +49,10 @@ import (
// //
// One check: // One check:
// //
// 1. clones the repository at the pull request's head, and beside it the repositories its check // 1. clones the repository at the pull request's head and **makes the merge of that head into the trunk's
// reads — the controller the mesh runs, the catalogue, the host, the lab — each at the ref asked; // head**, the check's trunk commit, in its own clone (novox/hq ADR 0323, issue 341; check_merge.go): what
// is judged is what merging makes, never pushed. Beside it the repositories its check reads — the
// controller the mesh runs, the catalogue, the host, the lab — each at the ref asked;
// 2. reads the facts snapshot the controller keeps, and with it **the versions the mesh runs**: the // 2. reads the facts snapshot the controller keeps, and with it **the versions the mesh runs**: the
// store a check's tests stand on is the store's own release, and the bus the bus's; // store a check's tests stand on is the store's own release, and the bus the bus's;
// 3. raises a throwaway PostgreSQL and a throwaway bus of those versions, labelled with the ask so a // 3. raises a throwaway PostgreSQL and a throwaway bus of those versions, labelled with the ask so a
@@ -79,7 +81,8 @@ type CheckSpec struct {
Modules []string Modules []string
New []string New []string
Manifests []string Manifests []string
// Base is the branch the pull request merges into: what the gate compares the change against. // Base is the branch the pull request merges into: what the gate compares the change against, and the trunk
// whose head the change is merged into before it is judged (novox/hq ADR 0323). Empty judges the head alone.
Base string Base string
// Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator" // Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator"
// the running one with the change's validator. // the running one with the change's validator.
@@ -104,6 +107,9 @@ type GroupHead struct {
Repository string Repository string
Ref string Ref string
Paths []string Paths []string
// Base is the branch it merges into: the head is merged into that trunk's head before it is composed
// (novox/hq ADR 0323 §6). Empty composes the head alone, as a controller from before it asks.
Base string
} }
// Gated is whether the change touches the mesh's graph, and so whether the gate runs. // Gated is whether the change touches the mesh's graph, and so whether the gate runs.
@@ -139,6 +145,16 @@ type CheckVerdict struct {
Took time.Duration Took time.Duration
Gate *Layer Gate *Layer
Repo *Layer Repo *Layer
// Head and Trunk are what was judged (novox/hq ADR 0323 §3): the pull request's head merged into the trunk
// commit, each as twelve characters. Trunk is empty for a check asked with no trunk, which judged the head
// alone. Merge is the merge commit made for the check, whole: only checked, never pushed or registered.
Head, Trunk, Merge string
// Beside are the commits of the repositories the check read beside its own, by the directory each was
// cloned under, as twelve characters: named, not judged against (ADR 0323 §3, issue 568).
Beside map[string]string
// Members are a delivery group's other heads as the composed check judged them, each with the trunk
// commit it was merged into (ADR 0323 §6).
Members []JudgedHead
} }
// Layer is one layer of a check, judged. // Layer is one layer of a check, judged.
@@ -281,6 +297,34 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
return CheckVerdict{}, err return CheckVerdict{}, err
} }
tree := filepath.Join(root, name) tree := filepath.Join(root, name)
// **What merging makes is judged, not the head** (novox/hq ADR 0323, issue 341): the head merged into the
// trunk's head as this clone found it, in this clone alone. A head that does not merge cleanly fails here.
judged := CheckVerdict{Head: twelve(spec.Ref)}
paths := spec.Paths
if spec.Base != "" {
m, err := mergeIntoTrunk(ctx, run, tree, spec.Base, spec.Ref)
if err != nil {
return CheckVerdict{}, err
}
judged.Head, judged.Trunk = twelve(m.Head), twelve(m.Trunk)
if len(m.Conflicts) > 0 {
said := conflictWords(m)
say("check", "%s", said)
return CheckVerdict{Verdict: "fail", Summary: said, Report: said, Took: time.Since(began),
Gate: &Layer{Verdict: "fail", Summary: said}, Repo: &Layer{Verdict: "fail", Summary: said},
Head: judged.Head, Trunk: judged.Trunk}, nil
}
judged.Merge = m.Merge
if changed := changedByMerge(ctx, run, tree, m); len(changed) > 0 {
paths = changed
}
say("check", "head %s merged into trunk %s (%s): the merge is judged, and never pushed", judged.Head,
judged.Trunk, spec.Base)
}
named := func(v CheckVerdict) CheckVerdict {
v.Head, v.Trunk, v.Merge, v.Beside, v.Members = judged.Head, judged.Trunk, judged.Merge, judged.Beside, judged.Members
return v
}
// **The base branch's check judges the change** (novox/hq issue 310), never the one the change brings. // **The base branch's check judges the change** (novox/hq issue 310), never the one the change brings.
judging, err := TheCheckThatJudges(ctx, tree, spec.Base) judging, err := TheCheckThatJudges(ctx, tree, spec.Base)
if err != nil { if err != nil {
@@ -297,7 +341,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
v := CheckVerdict{Verdict: "pass", Summary: noModule, Took: time.Since(began), v := CheckVerdict{Verdict: "pass", Summary: noModule, Took: time.Since(began),
Gate: &Layer{Verdict: "pass", Summary: noModule}, Repo: &Layer{Verdict: "warning", Summary: noScript}} Gate: &Layer{Verdict: "pass", Summary: noModule}, Repo: &Layer{Verdict: "warning", Summary: noScript}}
say("check", "%s; %s", noModule, noScript) say("check", "%s; %s", noModule, noScript)
return v, nil return named(v), nil
} }
for dir, b := range spec.Beside { for dir, b := range spec.Beside {
if dir == name || !safeName.MatchString(dir) { if dir == name || !safeName.MatchString(dir) {
@@ -306,6 +350,13 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
if err := clone(b.Repository, b.Ref, dir); err != nil { if err := clone(b.Repository, b.Ref, dir); err != nil {
return CheckVerdict{}, fmt.Errorf("beside it, %w", err) return CheckVerdict{}, fmt.Errorf("beside it, %w", err)
} }
// The commit each one read is named in the verdict (ADR 0323 §3): its move is issue 568's question.
if at := headAt(ctx, run, filepath.Join(root, dir)); at != "" {
if judged.Beside == nil {
judged.Beside = map[string]string{}
}
judged.Beside[dir] = at
}
say("check", "beside it %s at %s", dir, short(b.Ref)) say("check", "beside it %s at %s", dir, short(b.Ref))
} }
@@ -322,6 +373,26 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
return CheckVerdict{}, fmt.Errorf("a head of the group, %w", err) return CheckVerdict{}, fmt.Errorf("a head of the group, %w", err)
} }
say("check", "with it, of its group, %s/%s at %s", g.Owner, g.Repo, short(g.Ref)) say("check", "with it, of its group, %s/%s at %s", g.Owner, g.Repo, short(g.Ref))
// Each member is composed as its merge into its own trunk's head (ADR 0323 §6).
member := JudgedHead{Owner: g.Owner, Repo: g.Repo, Head: twelve(g.Ref)}
if g.Base != "" {
m, err := mergeIntoTrunk(ctx, run, filepath.Join(root, dir), g.Base, g.Ref)
if err != nil {
return CheckVerdict{}, fmt.Errorf("a head of the group, %s/%s: %w", g.Owner, g.Repo, err)
}
member.Head, member.Trunk = twelve(m.Head), twelve(m.Trunk)
if len(m.Conflicts) > 0 {
said := fmt.Sprintf("%s/%s: %s", g.Owner, g.Repo, conflictWords(m))
say("check", "%s", said)
judged.Members = append(judged.Members, member)
return named(CheckVerdict{Verdict: "fail", Summary: said, Report: said, Took: time.Since(began),
Gate: &Layer{Verdict: "fail", Summary: said}, Repo: &Layer{Verdict: "fail", Summary: said}}), nil
}
if changed := changedByMerge(ctx, run, filepath.Join(root, dir), m); len(changed) > 0 {
g.Paths = changed
}
}
judged.Members = append(judged.Members, member)
heads = append(heads, map[string]any{"repository": g.Owner + "/" + g.Repo, heads = append(heads, map[string]any{"repository": g.Owner + "/" + g.Repo,
"tree": filepath.Join(root, dir), "changed": g.Paths}) "tree": filepath.Join(root, dir), "changed": g.Paths})
} }
@@ -433,7 +504,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
verdictFile := filepath.Join(root, "verdict.json") verdictFile := filepath.Join(root, "verdict.json")
env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL, env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL,
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo, EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
EnvChanged + "=" + strings.Join(spec.Paths, ","), EnvBeside + "=" + root, EnvChanged + "=" + strings.Join(paths, ","), EnvBeside + "=" + root,
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","), EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
EnvGroup + "=" + groupFile, EnvGroup + "=" + groupFile,
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")} "GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")}
@@ -501,7 +572,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
v.Report, v.Took = redact.redact(withWhatFailed(out.String(), v.Repo)), time.Since(began) v.Report, v.Took = redact.redact(withWhatFailed(out.String(), v.Repo)), time.Since(began)
say("check", "gate %s — %s; repository %s — %s (%s)", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary, say("check", "gate %s — %s; repository %s — %s (%s)", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary,
strings.ToUpper(v.Repo.Verdict), v.Repo.Summary, v.Took.Round(time.Second)) strings.ToUpper(v.Repo.Verdict), v.Repo.Summary, v.Took.Round(time.Second))
return v, nil return named(v), nil
} }
// ownCheck runs the repository's own check, part by part, each in its toolchain's own container: the // ownCheck runs the repository's own check, part by part, each in its toolchain's own container: the
+115
View File
@@ -0,0 +1,115 @@
package builder
import (
"context"
"fmt"
"strings"
)
// **A merge check judges the merge, not the head** (novox/hq ADR 0323, issue 341).
//
// On 2026-10-09 a node-engine pull request was checked green at its head and broke the trunk when it merged:
// a merge an hour earlier had added a field under the same JSON key on other lines, git merged the two edits
// without a conflict, and the result did not compile. The check had judged the head alone. So a check now
// takes the trunk the pull request merges into, its head at the moment the check clones it as the check's
// **trunk commit**, and makes in its own clone the merge of the head into that commit, as the forge's
// default merge would. The gate and the repository's own check judge that merge.
//
// **The merge is only checked** (ADR 0323 §2, ADR 0238 §1): it is a commit off the trunk, made in the
// check's own clone, never pushed to the forge and never registered; the clone is removed when the check
// ends. Nothing here writes to a remote.
//
// The forge keeps no merge reference for a pull request, only its head, so the builder makes it itself.
// TrunkMerge is the merge a check made: the head and the trunk commit it was merged into, whole, and the merge
// commit; or the files in conflict when the head does not merge cleanly.
type TrunkMerge struct {
Head, Trunk, Merge string
Conflicts []string
}
// JudgedHead is one head a check judged, with the trunk commit it merged that head into, each as twelve
// characters (ADR 0323 §3, §6).
type JudgedHead struct {
Owner, Repo string
Head, Trunk string
}
// mergeIdentity is who a check's merge is made as: it is never pushed, so it names nobody's account. No hook of the
// clone's or the build machine's git configuration runs, and no setting there changes the merge.
var mergeIdentity = []string{"-c", "user.name=mesh merge check", "-c", "user.email=merge-check@mesh.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "-c", "merge.verifySignatures=false"}
// mergeIntoTrunk makes, in the clone at tree, the merge of head into the head of origin/<base> as it stands in
// that fresh clone: the trunk commit. A head that does not merge cleanly answers the files in conflict and leaves
// the tree at the trunk commit; an error is that the merge could not be tried at all.
func mergeIntoTrunk(ctx context.Context, run Runner, tree, base, head string) (TrunkMerge, error) {
var m TrunkMerge
revParse := func(ref string) (string, error) {
out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", ref+"^{commit}")
return strings.TrimSpace(out), err
}
var err error
if m.Head, err = revParse(head); err != nil || m.Head == "" {
return m, fmt.Errorf("the head %s cannot be read in the check's clone: %v", head, err)
}
if m.Trunk, err = revParse("refs/remotes/origin/" + base); err != nil || m.Trunk == "" {
return m, fmt.Errorf("the trunk %s cannot be read, and a check judges the head merged into it "+
"(novox/hq ADR 0323): %v", base, err)
}
if _, err := run(ctx, tree, "git", "checkout", "--quiet", "--detach", m.Trunk); err != nil {
return m, fmt.Errorf("the trunk commit %s cannot be checked out: %w", twelve(m.Trunk), err)
}
message := fmt.Sprintf("Check the merge of %s into %s at %s (made for a merge check, never pushed)",
twelve(m.Head), base, twelve(m.Trunk))
args := append(append([]string{}, mergeIdentity...), "merge", "--no-ff", "--no-edit", "--no-verify", "--quiet", "-m", message, m.Head)
if _, err := run(ctx, tree, "git", args...); err != nil {
out, _ := run(ctx, tree, "git", "diff", "--name-only", "--diff-filter=U")
m.Conflicts = strings.Fields(out)
_, _ = run(ctx, tree, "git", "merge", "--abort")
if len(m.Conflicts) == 0 {
return m, fmt.Errorf("the head %s could not be merged into trunk %s: %w", twelve(m.Head), twelve(m.Trunk), err)
}
return m, nil
}
if m.Merge, err = revParse("HEAD"); err != nil {
return m, fmt.Errorf("the merge made for the check cannot be read: %w", err)
}
return m, nil
}
// changedByMerge is every path the merge changes against the trunk commit: what the gate's delivery plan is
// computed from (ADR 0323 §1). Nil when it cannot be read; the caller keeps the pull request's own list.
func changedByMerge(ctx context.Context, run Runner, tree string, m TrunkMerge) []string {
if m.Merge == "" || m.Trunk == "" {
return nil
}
out, err := run(ctx, tree, "git", "diff", "--name-only", "--no-renames", m.Trunk, m.Merge)
if err != nil {
return nil // empty-on-error: the caller keeps the pull request's own list of changed files, the safe one
}
return strings.Fields(out)
}
// conflictWords is a head that does not merge cleanly, said as its check's failure.
func conflictWords(m TrunkMerge) string {
return fmt.Sprintf("head %s does not merge cleanly into trunk %s: in conflict %s", twelve(m.Head), twelve(m.Trunk),
strings.Join(m.Conflicts, ", "))
}
// headAt is the commit a clone is checked out at, as twelve characters; empty when it cannot be read.
func headAt(ctx context.Context, run Runner, tree string) string {
out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
if err != nil {
return ""
}
return twelve(strings.TrimSpace(out))
}
// twelve is a commit as a verdict names it: its first twelve characters.
func twelve(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}
+212
View File
@@ -0,0 +1,212 @@
package builder
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// **A merge check judges the merge, not the head** (novox/hq ADR 0323, issue 341): the head merged into the trunk
// commit in the check's own clone, never pushed; a head that does not merge cleanly fails, naming the files; the
// verdict names the head, the trunk commit and the commits of the repositories read beside it.
// aTrunkAndABranch is a repository whose main moved after the branch was cut: main holds trunkFiles over the
// common files, the branch headFiles. It answers the repository, the branch's head and main's head.
func aTrunkAndABranch(t *testing.T, common, trunkFiles, headFiles map[string]string) (string, string, string) {
t.Helper()
dir := t.TempDir()
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
write := func(files map[string]string) {
for name, body := range files {
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
git("add", "-A")
}
git("init", "--quiet", "-b", "main")
write(common)
git("commit", "--quiet", "-m", "common")
git("checkout", "--quiet", "-b", "change")
write(headFiles)
git("commit", "--quiet", "-m", "the change")
head := git("rev-parse", "HEAD")
git("checkout", "--quiet", "main")
write(trunkFiles)
git("commit", "--quiet", "-m", "the trunk moved")
return dir, head, git("rev-parse", "HEAD")
}
func TestACheckJudgesTheHeadMergedIntoTheTrunk(t *testing.T) {
// main's script passes only in a tree holding both the trunk's file and the head's: the merge.
repo, head, trunk := aTrunkAndABranch(t,
map[string]string{CheckScript: "test -f trunk-only && test -f head-only || { echo 'FAIL: not the merge'; exit 1; }\n"},
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
beside, besideHead := aCheckedRepository(t, map[string]string{"README": "the catalogue"})
registry := aFactsRegistry(t)
aRuntimeOnThisMachine(t)
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-341", Repository: repo, Ref: head, Owner: "novox",
Repo: "mesh-lab", Number: 1, Base: "main", Toolchain: "golang",
Beside: map[string]Beside{"mesh-catalog": {Repository: beside, Ref: besideHead}}}, t.TempDir(), registry,
GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Repo == nil || v.Repo.Verdict != "pass" {
t.Fatalf("the check did not judge the head merged into the trunk: %+v\n%s", v.Repo, v.Report)
}
if v.Head != head[:12] || v.Trunk != trunk[:12] {
t.Fatalf("the verdict names head %q and trunk %q, not %s and %s", v.Head, v.Trunk, head[:12], trunk[:12])
}
if v.Beside["mesh-catalog"] != besideHead[:12] {
t.Fatalf("the verdict names the catalogue read beside it as %q, not %s: %v", v.Beside["mesh-catalog"],
besideHead[:12], v.Beside)
}
if v.Merge == "" || v.Merge == head || v.Merge == trunk {
t.Fatalf("no merge of the head into the trunk was made for the check: %q", v.Merge)
}
}
func TestAHeadThatDoesNotMergeCleanlyFailsNamingTheFiles(t *testing.T) {
repo, head, trunk := aTrunkAndABranch(t,
map[string]string{"store.go": "package store\n\nconst Declared = 1\n", "other.go": "package store\n"},
map[string]string{"store.go": "package store\n\nconst Declared = 2\n"},
map[string]string{"store.go": "package store\n\nconst Declared = 3\n"})
// No artifact store and no container runtime: a head that does not merge is judged before either is needed.
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-341-conflict", Repository: repo, Ref: head,
Owner: "novox", Repo: "mesh-lab", Number: 2, Base: "main", Toolchain: "golang"}, t.TempDir(), "",
GitCredential{}, nil)
if err != nil {
t.Fatalf("a head that does not merge cleanly was not judged: %v", err)
}
want := "does not merge cleanly into trunk " + trunk[:12]
for name, l := range map[string]*Layer{"gate": v.Gate, "repository": v.Repo} {
if l == nil || l.Verdict != "fail" || !strings.Contains(l.Summary, want) || !strings.Contains(l.Summary, "store.go") {
t.Fatalf("its %s layer said %+v, not a failure saying %q and naming store.go", name, l, want)
}
}
if strings.Contains(v.Summary, "other.go") {
t.Fatalf("a file not in conflict is named: %s", v.Summary)
}
if v.Head != head[:12] || v.Trunk != trunk[:12] {
t.Fatalf("the failure names head %q and trunk %q", v.Head, v.Trunk)
}
}
// **The merge made for a check is only checked** (ADR 0323 §2): the forge never receives it.
func TestTheMergeMadeForACheckIsNeverPushed(t *testing.T) {
repo, head, trunk := aTrunkAndABranch(t, map[string]string{CheckScript: "true\n"},
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
registry := aFactsRegistry(t)
aRuntimeOnThisMachine(t)
refsBefore := gitOut(t, repo, "for-each-ref")
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-341-push", Repository: repo, Ref: head, Owner: "novox",
Repo: "mesh-lab", Number: 3, Base: "main", Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Merge == "" {
t.Fatal("the check made no merge of the head into the trunk")
}
if err := exec.Command("git", "-C", repo, "cat-file", "-e", v.Merge+"^{commit}").Run(); err == nil {
t.Fatalf("the merge %s made for the check reached the forge's repository", v.Merge)
}
if after := gitOut(t, repo, "for-each-ref"); after != refsBefore {
t.Fatalf("the check moved a ref of the forge's repository:\nbefore %s\nafter %s", refsBefore, after)
}
if got := gitOut(t, repo, "rev-parse", "main"); got != trunk {
t.Fatalf("the trunk moved to %s", got)
}
}
// A delivery group's composed check merges each member's head into its own trunk's head (ADR 0323 §6), and names
// both for each; a member that does not merge cleanly fails the composed check, naming it.
func TestAGroupsComposedCheckMergesEachMemberIntoItsTrunk(t *testing.T) {
repo, head, trunk := aTrunkAndABranch(t, map[string]string{CheckScript: "true\n"},
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
member, memberHead, memberTrunk := aTrunkAndABranch(t, map[string]string{"a": "1"},
map[string]string{"b": "trunk"}, map[string]string{"c": "head"})
registry := aFactsRegistry(t)
aRuntimeOnThisMachine(t)
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-341-group", Repository: repo, Ref: head, Owner: "novox",
Repo: "mesh-controller", Number: 4, Base: "main", Toolchain: "golang", Group: []GroupHead{{Owner: "novox",
Repo: "mesh-host", Repository: member, Ref: memberHead, Base: "main"}}}, t.TempDir(), registry,
GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Head != head[:12] || v.Trunk != trunk[:12] {
t.Fatalf("the composed check names its first head %q into %q", v.Head, v.Trunk)
}
if len(v.Members) != 1 || v.Members[0].Head != memberHead[:12] || v.Members[0].Trunk != memberTrunk[:12] {
t.Fatalf("the composed check names its members %+v, not mesh-host %s into %s", v.Members, memberHead[:12],
memberTrunk[:12])
}
conflicting, conflictHead, _ := aTrunkAndABranch(t, map[string]string{"a": "1"}, map[string]string{"a": "2"},
map[string]string{"a": "3"})
v, err = Check(t.Context(), Command, CheckSpec{ID: "check-341-group-conflict", Repository: repo, Ref: head,
Owner: "novox", Repo: "mesh-controller", Number: 4, Base: "main", Toolchain: "golang", Group: []GroupHead{{
Owner: "novox", Repo: "mesh-host", Repository: conflicting, Ref: conflictHead, Base: "main"}}}, t.TempDir(),
registry, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if v.Verdict != "fail" || !strings.Contains(v.Summary, "novox/mesh-host") ||
!strings.Contains(v.Summary, "does not merge cleanly") {
t.Fatalf("a member that does not merge cleanly was answered %s: %s", v.Verdict, v.Summary)
}
}
func gitOut(t *testing.T, dir string, args ...string) string {
t.Helper()
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
// No hook runs when a check makes its merge: not one in the clone, whatever the build machine's git configuration says.
func TestACheckMergeRunsNoHook(t *testing.T) {
repo, head, _ := aTrunkAndABranch(t, map[string]string{"a": "1"}, map[string]string{"b": "trunk"},
map[string]string{"c": "head"})
clone := filepath.Join(t.TempDir(), "clone")
if out, err := exec.Command("git", "clone", "--quiet", repo, clone).CombinedOutput(); err != nil {
t.Fatalf("%v\n%s", err, out)
}
ran := filepath.Join(t.TempDir(), "ran")
for _, hook := range []string{"pre-merge-commit", "commit-msg", "prepare-commit-msg", "post-merge"} {
if err := os.WriteFile(filepath.Join(clone, ".git", "hooks", hook),
[]byte("#!/bin/sh\necho "+hook+" >> "+ran+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
m, err := mergeIntoTrunk(t.Context(), Command, clone, "main", head)
if err != nil || m.Merge == "" {
t.Fatalf("the merge was not made: %+v, %v", m, err)
}
if said, err := os.ReadFile(ran); err == nil {
t.Fatalf("a hook ran when the check made its merge: %s", said)
}
}
+139
View File
@@ -0,0 +1,139 @@
package builder
import (
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestReplay341 is the replay of novox/hq issue 341, written with only what the build agent had before its fix,
// so mesh-lab's prover can lay it over the commit before. On 2026-10-09 node-engine #64/#65 merged at 00:15 and
// added Applied.Declared, under the JSON key `declared`, to the store record. #66, checked at 00:49 at its head,
// had added its own Applied.Declared under the same key on other lines. Its check passed; git merged the two
// edits without a conflict, and the trunk no longer compiled: "Declared redeclared". Before the fix the check
// judged the head alone and passed. After it (novox/hq ADR 0323), the check judges the head merged into the
// trunk commit that held the other field, and fails saying the trunk does not compile.
//
// It runs the check whole, with a container runtime that runs each container's command on this machine
// (aRuntimeOnThisMachine): the repository's own check is `go build ./...`.
func TestReplay341(t *testing.T) {
origin, head := theNinthOfOctober(t)
registry := aFactsRegistry(t)
aRuntimeOnThisMachine(t)
v, err := Check(t.Context(), Command, CheckSpec{ID: "replay-341", Repository: origin, Ref: head, Owner: "novox",
Repo: "mesh-host", Number: 66, Base: "main", Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
if err != nil {
t.Fatalf("the check could not run: %v", err)
}
if v.Repo == nil || v.Repo.Verdict != "fail" {
t.Fatalf("node-engine #66 at its head was answered %+v: the merge it makes into the trunk that holds #65's "+
"field does not compile, and the check let it merge", v.Repo)
}
if said := v.Repo.Summary + "\n" + v.Repo.Failed + "\n" + v.Report; !strings.Contains(said, "Declared redeclared") {
t.Fatalf("the check failed without saying the trunk does not compile:\n%s", said)
}
}
// theNinthOfOctober is the node-engine's repository as it stood at 00:49 on 2026-10-09, in miniature: a trunk
// holding #65's Applied.Declared (a string, key `declared`), and #66's head, branched before it, holding its own
// Applied.Declared (a *FoundUnit, the same key) on other lines. Each compiles alone; their merge does not.
func theNinthOfOctober(t *testing.T) (string, string) {
t.Helper()
origin := t.TempDir()
git := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = origin
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
write := func(name, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(filepath.Join(origin, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(origin, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
store := func(declared65, declared66 string) string {
return "package store\n\n// FoundUnit is a unit found on the machine.\ntype FoundUnit struct{ Name string }\n\n" +
"// Applied is what the node-engine applied.\ntype Applied struct {\n\tGeneration int `json:\"generation\"`\n" +
declared66 + "\tModules []string `json:\"modules\"`\n\tFiles []string `json:\"files\"`\n" +
"\tUnits []string `json:\"units\"`\n\tAt string `json:\"at\"`\n" + declared65 + "}\n"
}
git("init", "--quiet", "-b", "main")
write("go.mod", "module example.org/mesh-host\n\ngo 1.21\n")
write("internal/store/store.go", store("", ""))
write(CheckScript, "# mesh-check-toolchain: go\nset -e\nGOFLAGS= GOWORK=off GOTOOLCHAIN=local GOPROXY=off go build ./...\n")
git("add", "-A")
git("commit", "--quiet", "-m", "#63: the store record")
git("checkout", "--quiet", "-b", "adr-0269")
write("internal/store/store.go", store("", "\tDeclared *FoundUnit `json:\"declared\"`\n"))
git("commit", "--quiet", "-am", "#66: the unit a declaration names")
head := git("rev-parse", "HEAD")
git("checkout", "--quiet", "main")
write("internal/store/store.go", store("\tDeclared string `json:\"declared\"`\n", ""))
git("commit", "--quiet", "-am", "#65: the declaration applied")
return origin, head
}
// aRuntimeOnThisMachine puts a container runtime on PATH that runs a container's command on this machine, in the
// directory and with the environment the check gives it: a store and a bus that answer, a judge that is never
// built, and the repository's own check run as written. Only for a check whose code is the test's own.
func aRuntimeOnThisMachine(t *testing.T) {
t.Helper()
bus, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { bus.Close() })
go func() {
for {
c, err := bus.Accept()
if err != nil {
return
}
c.Close()
}
}()
dir := t.TempDir()
runtime := `#!/bin/sh
cmd="$1"; shift
case "$cmd" in
ps|rm|exec) exit 0 ;;
port) echo "` + bus.Addr().String() + `"; exit 0 ;;
run)
workdir=""; detached=""
while [ $# -gt 0 ]; do
case "$1" in
-d) detached=1; shift ;;
--rm) shift ;;
--workdir) workdir="$2"; shift 2 ;;
--env) export "$2"; shift 2 ;;
--network|--volume|--user|--label|--name|-p|-e) shift 2 ;;
*) break ;;
esac
done
if [ -n "$detached" ]; then echo replay-container; exit 0; fi
shift
if [ "$1" = "sh" ] && [ -n "$workdir" ]; then cd "$workdir" && exec "$@"; fi
echo "no such image on this machine" >&2
exit 1 ;;
esac
exit 1
`
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(runtime), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
+9 -1
View File
@@ -645,6 +645,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
sealed = ForeseenSealed sealed = ForeseenSealed
*foreseen = append(*foreseen, m.Module+"/"+name) *foreseen = append(*foreseen, m.Module+"/"+name)
} }
if sealed == "" && m.OwnSecrets[name].IssuedBy == IssuedOutside {
// **Not given, and the mesh never makes it** (novox/hq issue 401, ADR 0228): no file, as for a
// family's member (ADR 0283), so the module reads its absence as not given.
continue
}
if sealed == "" { if sealed == "" {
// Declared and not made. Refused rather than skipped: a module whose own // Declared and not made. Refused rather than skipped: a module whose own
// credential is silently absent starts, fails to authenticate, and the reason is // credential is silently absent starts, fails to authenticate, and the reason is
@@ -1002,7 +1007,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// content is where a setting lands, so a placeholder may only exist once the setting // content is where a setting lands, so a placeholder may only exist once the setting
// has been put in — filling secrets first would look at content that is not yet what // has been put in — filling secrets first would look at content that is not yet what
// the machine receives. // the machine receives.
if err := intoFile(copied, sealed, m.Module); err != nil { if err := intoFile(copied, sealed, outsideNotGiven(m, with.Needed[m.Module]), m.Module); err != nil {
return nil, err return nil, err
} }
// **Substituted here, not on the machine.** A bound value is not secret — the mesh // **Substituted here, not on the machine.** A bound value is not secret — the mesh
@@ -2431,6 +2436,9 @@ func secretsReadBy(resource map[string]any, m Manifest, given map[string]string)
} }
paths := map[string]string{} paths := map[string]string{}
for name, own := range m.OwnSecrets.Plain() { for name, own := range m.OwnSecrets.Plain() {
if own.IssuedBy == IssuedOutside && given[name] == "" {
continue // not given, so not placed: nothing to restart on (novox/hq issue 401)
}
paths[name] = own.Path paths[name] = own.Path
} }
for name, sealed := range given { for name, sealed := range given {
@@ -0,0 +1,106 @@
package catalogue
import (
"strings"
"testing"
)
// An own secret issued outside the mesh that nobody gave (novox/hq issue 401, ADR 0228): the mesh makes no value for it,
// so the composition places no file, a file of the module that names it says empty there, and nothing restarts on it.
// Given, it is placed and filled like any own secret.
func outsideManifest(t *testing.T) Manifest {
t.Helper()
m, err := ParseManifest([]byte(`{"module":"assistant","version":"1",
"own-secrets":{
"password":{"path":"/var/lib/assistant/password.secret","taken":"at-start"},
"vendor-key":{"path":"/var/lib/assistant/vendor-key.secret","taken":"at-start","issued-by":"outside"}},
"resources":[
{"id":"env","type":"file","path":"/var/lib/assistant/env","mode":"0600",
"content":"PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n"},
{"id":"server","type":"container","name":"assistant","image":"assistant:1",
"env-file":["/var/lib/assistant/env"],
"volumes":["/var/lib/assistant/vendor-key.secret:/run/vendor-key:ro"],
"secrets-in-environment":"reads its settings from the environment only"}]}`))
if err != nil {
t.Fatal(err)
}
return m
}
func resourceAt(out []map[string]any, path string) map[string]any {
for _, r := range out {
if r["path"] == path {
return r
}
}
return nil
}
func resourceNamed(out []map[string]any, name string) map[string]any {
for _, r := range out {
if r["name"] == name {
return r
}
}
return nil
}
func TestAnOutsideSecretNotGivenIsNoFileAndAFileNamingItSaysEmpty(t *testing.T) {
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": {"password": "sealed-password"}}})
if err != nil {
t.Fatalf("a module whose outside secret nobody gave did not compose: %v", err)
}
if got := resourceAt(out, "/var/lib/assistant/vendor-key.secret"); got != nil {
t.Fatalf("a secret nobody gave was placed: %v", got)
}
env := resourceAt(out, "/var/lib/assistant/env")
if env == nil {
t.Fatal("the module's own file is not composed")
}
if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=\n" {
t.Fatalf("the file names the secret nobody gave as %q", content)
}
secrets, _ := env["secrets"].(map[string]any)
if len(secrets) != 1 || secrets["password"] != "sealed-password" {
t.Fatalf("the file is filled from %v", secrets)
}
server := resourceNamed(out, "assistant")
if on := strings.Join(stringsIn(server["restart-on"]), " "); strings.Contains(on, "vendor-key") {
t.Fatalf("the container restarts on a secret nobody gave: %q", on)
}
}
func TestAnOutsideSecretGivenIsPlacedAndFilled(t *testing.T) {
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": {
"password": "sealed-password", "vendor-key": "sealed-vendor-key"}}})
if err != nil {
t.Fatal(err)
}
placed := resourceAt(out, "/var/lib/assistant/vendor-key.secret")
if placed == nil || placed["sealed"] != "sealed-vendor-key" {
t.Fatalf("the secret given is placed as %v", placed)
}
env := resourceAt(out, "/var/lib/assistant/env")
if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n" {
t.Fatalf("the file was changed: %q", content)
}
if secrets, _ := env["secrets"].(map[string]any); secrets["vendor-key"] != "sealed-vendor-key" {
t.Fatalf("the file is filled from %v", secrets)
}
server := resourceNamed(out, "assistant")
if on := strings.Join(stringsIn(server["restart-on"]), " "); !strings.Contains(on, "assistant."+NeedID("vendor-key")) {
t.Fatalf("the container does not restart on the secret given: %q", on)
}
}
// A secret the mesh makes is still refused when it was not made: only an outside one is left out.
func TestAnOwnSecretTheMeshMakesIsStillRefusedWhenNotMade(t *testing.T) {
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
_, err := r.Declaration(Rendering{})
if nm, ok := err.(*NotMadeError); !ok || nm.Name != "password" {
t.Fatalf("a secret the mesh makes, not made: %v", err)
}
}
+25 -2
View File
@@ -156,7 +156,11 @@ func secretFilesOf(resources []map[string]any) map[string]bool {
// A name the module never declared is refused here rather than on the machine. The host would // A name the module never declared is refused here rather than on the machine. The host would
// refuse it too — but it would do so having already been handed a declaration, which reads as the // refuse it too — but it would do so having already been handed a declaration, which reads as the
// mesh sending something broken, and the name it could not find is a manifest's typo. // mesh sending something broken, and the name it could not find is a manifest's typo.
func intoFile(resource map[string]any, sealed map[string]string, module string) error { //
// **An own secret issued outside the mesh that nobody gave is filled with nothing** (novox/hq issue 401): the mesh
// never makes one (ADR 0228), and a file is where the module reads it, so the place says empty — the module sees it
// was not given, where a value the mesh made looked given and wrong. notGiven names those secrets.
func intoFile(resource map[string]any, sealed map[string]string, notGiven map[string]bool, module string) error {
if fmt.Sprint(resource["type"]) != "file" { if fmt.Sprint(resource["type"]) != "file" {
return nil return nil
} }
@@ -171,6 +175,10 @@ func intoFile(resource map[string]any, sealed map[string]string, module string)
into := map[string]any{} into := map[string]any{}
for _, name := range used { for _, name := range used {
value := sealed[name] value := sealed[name]
if value == "" && notGiven[name] {
content = strings.ReplaceAll(content, "${secret:"+name+"}", "")
continue
}
if value == "" { if value == "" {
return fmt.Errorf( return fmt.Errorf(
"%s has a file that says ${secret:%s}, and %s has no secret of its own by that "+ "%s has a file that says ${secret:%s}, and %s has no secret of its own by that "+
@@ -179,10 +187,25 @@ func intoFile(resource map[string]any, sealed map[string]string, module string)
} }
into[name] = value into[name] = value
} }
resource["secrets"] = into resource["content"] = content
if len(into) > 0 {
resource["secrets"] = into
}
return nil return nil
} }
// outsideNotGiven is each own secret of a module issued outside the mesh that this composition holds no value for
// (novox/hq issue 401): what intoFile fills with nothing, and what nothing restarts on.
func outsideNotGiven(m Manifest, given map[string]string) map[string]bool {
out := map[string]bool{}
for name, own := range m.OwnSecrets.Plain() {
if own.IssuedBy == IssuedOutside && given[name] == "" {
out[name] = true
}
}
return out
}
// namesOr says what a module could have written, because the answer to "that name is wrong" is // namesOr says what a module could have written, because the answer to "that name is wrong" is
// almost always one of two or three right ones. // almost always one of two or three right ones.
func namesOr(sealed map[string]string) string { func namesOr(sealed map[string]string) string {
+31
View File
@@ -325,6 +325,8 @@ func (i *Inventory) ModuleSecretIfIssued(
return sealed, true, nil return sealed, true, nil
} }
// SecretForModule makes what is not held, so it is never asked for an own secret issued outside the mesh: the
// composition reads only a value a person gave for one (GivenOwnSecret; novox/hq issue 401, ADR 0228).
func (i *Inventory) SecretForModule(ctx context.Context, node, module, name string) (string, error) { func (i *Inventory) SecretForModule(ctx context.Context, node, module, name string) (string, error) {
key, err := i.SealingKeyOf(ctx, node) key, err := i.SealingKeyOf(ctx, node)
if err != nil { if err != nil {
@@ -655,6 +657,35 @@ func (i *Inventory) GivenMembers(ctx context.Context, node, module, family strin
return out, rows.Err() return out, rows.Err()
} }
// GivenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (novox/hq issue 401, ADR
// 0228): its value sealed to the machine and whether that is the key the machine holds now. Not given is false: a
// value the mesh made for it is not one a person gave, and is never read as one; a machine with no sealing key
// holds none either.
func (i *Inventory) GivenOwnSecret(ctx context.Context, node, module, name string) (GivenMember, bool, error) {
key, err := i.SealingKeyOf(ctx, node)
if err != nil || key == "" {
return GivenMember{}, false, err
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return GivenMember{}, false, err
}
g := GivenMember{Name: name}
var against string
err = i.store.Pool().QueryRow(ctx,
`select sealed, node_key from module_secret
where node = $1 and module = $2 and name = $3 and origin = 'accepted'`,
record.ID, module, name).Scan(&g.Sealed, &against)
if errors.Is(err, pgx.ErrNoRows) {
return GivenMember{}, false, nil
}
if err != nil {
return GivenMember{}, false, err
}
g.Current = against == key
return g, true, nil
}
// GivenOwnSecrets is every own secret of a module a person gave on a machine, by name, with when (novox/hq ADR // GivenOwnSecrets is every own secret of a module a person gave on a machine, by name, with when (novox/hq ADR
// 0283): what the controller checks a module's wait for a secret against. A value the mesh made is not one. // 0283): what the controller checks a module's wait for a secret against. A value the mesh made is not one.
func (i *Inventory) GivenOwnSecrets(ctx context.Context, node, module string) (map[string]time.Time, error) { func (i *Inventory) GivenOwnSecrets(ctx context.Context, node, module string) (map[string]time.Time, error) {
+11
View File
@@ -138,6 +138,9 @@ type GroupMember struct {
Repository string `json:"repository"` Repository string `json:"repository"`
Ref string `json:"ref"` Ref string `json:"ref"`
Paths []string `json:"paths,omitempty"` Paths []string `json:"paths,omitempty"`
// Base is the branch it merges into: the build seat merges the head into that trunk's head before the
// group is composed (novox/hq ADR 0323 §6). A build seat from before it composes the head alone.
Base string `json:"base,omitempty"`
} }
// Who judges a merge check's gate. // Who judges a merge check's gate.
@@ -164,6 +167,14 @@ type CheckOutcome struct {
// Gate and RepoCheck are its two layers; Verdict and Summary above are the gate's. // Gate and RepoCheck are its two layers; Verdict and Summary above are the gate's.
Gate *CheckLayer `json:"gate,omitempty"` Gate *CheckLayer `json:"gate,omitempty"`
RepoCheck *CheckLayer `json:"repo-check,omitempty"` RepoCheck *CheckLayer `json:"repo-check,omitempty"`
// Head and Trunk are what was judged (novox/hq ADR 0323 §3): the pull request's head merged into the trunk
// commit, each as twelve characters. Trunk is empty from a build seat from before it, which judged the head
// alone. Beside are the commits of the repositories read beside it, by directory, named and not judged
// against (issue 568); Members a delivery group's other heads, each with the trunk commit it merged into.
Head string `json:"head,omitempty"`
Trunk string `json:"trunk,omitempty"`
Beside map[string]string `json:"beside,omitempty"`
Members []CheckedMember `json:"members,omitempty"`
} }
// SourceOnSeat names a repository by the seat whose holder serves it and its path there. // SourceOnSeat names a repository by the seat whose holder serves it and its path there.
+25 -8
View File
@@ -221,13 +221,17 @@ type SourceMoved struct {
// PullUpdated is what the forge announces when an open pull request's head moves — opened, or pushed // PullUpdated is what the forge announces when an open pull request's head moves — opened, or pushed
// to (novox/hq to-be 45 §9): what the controller asks the build seat to check before it merges. // to (novox/hq to-be 45 §9): what the controller asks the build seat to check before it merges.
type PullUpdated struct { type PullUpdated struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Repo string `json:"repo"`
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Base string `json:"base"` Base string `json:"base"`
Head string `json:"head"` Head string `json:"head"`
Commit string `json:"head_sha"` Commit string `json:"head_sha"`
// BaseSHA is the base branch's head when the forge announced the head (novox/hq ADR 0323): the trunk commit a
// verdict the controller gives without the build seat — a change that runs no build — is judged against. Empty
// from a forge module from before it.
BaseSHA string `json:"base_sha,omitempty"`
CloneURL string `json:"clone_url"` CloneURL string `json:"clone_url"`
HTMLURL string `json:"html_url,omitempty"` HTMLURL string `json:"html_url,omitempty"`
// Paths are the files the pull request changes; PathsTruncated says there were more. // Paths are the files the pull request changes; PathsTruncated says there were more.
@@ -278,14 +282,24 @@ type Checked struct {
// status from it — the group's verdict is its owner's, mesh-delivery, to say on each member's head. // status from it — the group's verdict is its owner's, mesh-delivery, to say on each member's head.
Group string `json:"group,omitempty"` Group string `json:"group,omitempty"`
Members []CheckedMember `json:"members,omitempty"` Members []CheckedMember `json:"members,omitempty"`
// Trunk is the trunk commit the head was merged into and judged as, twelve characters (novox/hq ADR 0323
// §3): the verdict holds for that merge only, and goes out of date when the trunk moves. Empty from a build
// seat that judged the head alone. Beside are the commits of the other repositories the check read, by
// directory, named and not judged against (issue 568). Took is how long the check ran, for the merge tool
// to say how long the next one may take (ADR 0323 §5).
Trunk string `json:"trunk,omitempty"`
Beside map[string]string `json:"beside,omitempty"`
Took string `json:"took,omitempty"`
} }
// CheckedMember is one head a group's composed check judged. // CheckedMember is one head a group's composed check judged, and the trunk commit it was merged into (novox/hq
// ADR 0323 §6), twelve characters; empty from a build seat that composed the head alone.
type CheckedMember struct { type CheckedMember struct {
Owner string `json:"owner"` Owner string `json:"owner"`
Repo string `json:"repo"` Repo string `json:"repo"`
Number int `json:"number,omitempty"` Number int `json:"number,omitempty"`
Commit string `json:"commit"` Commit string `json:"commit"`
Trunk string `json:"trunk,omitempty"`
} }
// ChangePlan is what a change does to the mesh, computed from its diffset — a repository, the branch it // ChangePlan is what a change does to the mesh, computed from its diffset — a repository, the branch it
@@ -311,6 +325,9 @@ type ChangePlan struct {
Steps []string `json:"steps,omitempty"` Steps []string `json:"steps,omitempty"`
// Summary is the plan in one line, for a commit status. // Summary is the plan in one line, for a commit status.
Summary string `json:"summary"` Summary string `json:"summary"`
// Trunk is the trunk commit the head was merged into when it was judged (novox/hq ADR 0323 §3), twelve
// characters: set on the plan a verdict carries, which mesh-delivery keeps with its other inputs.
Trunk string `json:"trunk,omitempty"`
} }
// MachinePlan is one machine's part of a change plan. // MachinePlan is one machine's part of a change plan.
+2
View File
@@ -27,3 +27,5 @@
./cmd/mesh-controller TestAnUnassignThatLeavesItsNodeResolvedSaysNothingOfIt ./cmd/mesh-controller TestAnUnassignThatLeavesItsNodeResolvedSaysNothingOfIt
./cmd/mesh-controller TestAnAssignThatLeavesItsNodeUnresolvedSaysOnlyThePlansRefusal ./cmd/mesh-controller TestAnAssignThatLeavesItsNodeUnresolvedSaysOnlyThePlansRefusal
./cmd/mesh-controller TestADirectoryOwnersKeyIsRefusedThroughAVerb ./cmd/mesh-controller TestADirectoryOwnersKeyIsRefusedThroughAVerb
./cmd/mesh-controller TestTheSendMakesNoValueForAnOutsideSecretNobodyGave
./cmd/mesh-controller TestAQuestionComposesWithoutAnOutsideSecretNobodyGave