DLV-C2, DLV-C6 (verdict): the checker says each pull request's verdict on its stream asks, with the out-of-step files its steps named

Design 26 has the indexer learn out-of-step generated files from the check's
verdict event, so it runs no generator; the test step's answer carries them.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 13:35:51 +01:00
parent 8c3916bd7b
commit 2a3ce39590
10 changed files with 158 additions and 6 deletions
@@ -910,6 +910,36 @@ func TestAMemberNamingAMeshSDKCommitOffTheTrunkIsJudgedAndNotRefused(t *testing.
if got[check.SDKModulePath] != "v0.1.15-feat-x.1" {
t.Errorf("mesh-delivery was built against %v, want mesh-sdk's Go module at the group's tagged version v0.1.15-feat-x.1", got)
}
// DLV-C2, DLV-C6 (design 26 *Out of step*, ADR 0620): each check's end says one verdict per pull request on the stream
// asks, at the definition's subject and in its body (decoded strictly): the lone pull request's, then the group's two.
verdicts, err := b.js.OrderedConsumer(b.ctx, "ASKS", jetstream.OrderedConsumerConfig{FilterSubjects: []string{meshdeliverychecker.HolderSubjectVerdict(delivery.CheckerModule)}})
if err != nil {
t.Fatal(err)
}
said := map[int64]meshdeliverychecker.VerdictHolderEvent{}
for len(said) < 3 {
msg, err := verdicts.Next(jetstream.FetchMaxWait(20 * time.Second))
if err != nil {
t.Fatalf("verdicts said %+v, then none: %v", said, err)
}
var e meshdeliverychecker.VerdictHolderEvent
dec := json.NewDecoder(bytes.NewReader(msg.Data()))
dec.DisallowUnknownFields()
if err := dec.Decode(&e); err != nil {
t.Fatalf("verdict %s is not the definition's body: %v", msg.Data(), err)
}
if _, twice := said[e.Pull]; twice {
t.Errorf("a second verdict of pull %d: %+v", e.Pull, e)
}
said[e.Pull] = e
}
for pull, want := range map[int64]meshdeliverychecker.VerdictHolderEvent{7: {Repository: repo, Pull: 7, Head: head1, Verdict: delivery.VerdictPass},
8: {Repository: repo, Pull: 8, Head: head1, Verdict: delivery.VerdictPass}, 12: {Repository: sdkRepo, Pull: 12, Head: head2, Verdict: delivery.VerdictPass}} {
if got := said[pull]; got.Repository != want.Repository || got.Head != want.Head || got.Verdict != want.Verdict || len(got.OutOfStep) != 0 {
t.Errorf("verdict of pull %d = %+v, want %+v", pull, got, want)
}
}
}
// triesStore is the tries bucket as the tools write it.
@@ -218,6 +218,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// registry seats' published heard on the checker's durable consumer of the shared events stream. Where that consumer is
// not there yet a group's check asks and waits in vain until its bound, said in its verdict.
tags := tagAsks{js: js, node: cred.Node, heard: &check.PublishedHeard{}, logf: logger.Printf, now: time.Now}
ch.Says = tags // a pull request's verdict event, on the same stream asks (design 26 *Out of step*)
switch cons, err := cred.EventConsumer(ctx, js, process.EventsStream); {
case err != nil:
logger.Printf("the consumer %s_%s on %s does not exist yet, so no package registry's published is heard and a group's check fails at its publish bound until the checker starts again: %v",
@@ -2,8 +2,10 @@ package main
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
@@ -23,7 +25,8 @@ var publishedSeats = map[string]string{
npmpackageregistry.SubjectPublished: npmpackageregistry.Seat,
}
// tagAsks is check.TagAsks on the bus: tag-wanted published on the checker's own stream asks, and the published heard.
// tagAsks is check.TagAsks and check.VerdictSayer on the bus: tag-wanted and verdict published on the checker's own stream
// asks, and the published heard.
type tagAsks struct {
js jetstream.JetStream
node string
@@ -52,6 +55,25 @@ func (t tagAsks) Want(ctx context.Context, w check.TagWanted) error {
return err
}
// Say publishes one verdict on the checker's own stream asks with the checker's event headers; the message id makes a
// second publish of the same pull request's head, verdict and out-of-step files within the duplicate window one event.
func (t tagAsks) Say(ctx context.Context, v check.VerdictEvent) error {
body, err := json.Marshal(v)
if err != nil {
return err
}
msg := nats.NewMsg(eventSubject(check.EventVerdict))
id := fmt.Sprintf("verdict.%s.%d.%s.%s.%x", v.Repository, v.Pull, v.Head, v.Verdict, sha256.Sum256([]byte(strings.Join(v.OutOfStep, "\n"))))
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", delivery.CheckerModule)
msg.Header.Set("x-node", t.node)
msg.Header.Set("x-time", t.now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = body
_, err = t.js.PublishMsg(ctx, msg, jetstream.WithMsgID(id))
return err
}
func (t tagAsks) Await(ctx context.Context, seat, head string, names []string) (check.TaggedPublished, bool) {
return t.heard.Await(ctx, seat, head, names)
}
@@ -48,6 +48,9 @@ type Outcome struct {
// Beside are the siblings the check read at their trunk's head, by repository (ADR 0548 §6): the verdict names them, and
// a move of one is carried.
Beside map[string]string
// OutOfStep are, by pull request (pullKey), the generated files out of step with their seat definitions that its steps
// named (design 26): the verdict event carries them.
OutOfStep map[string][]string
}
// BuildNote is the state of a candidate's module build, by module: built, failed or waiting (ADR 0484 clause 7).
@@ -90,8 +93,10 @@ type Checker struct {
Link string
// Announce says a status was written (EventOwedWritten); nil says nothing.
Announce Announcer
Now func() time.Time
Logf func(format string, args ...any)
// Says publishes the verdict event of each pull request whose check ended (EventVerdict); nil says none.
Says VerdictSayer
Now func() time.Time
Logf func(format string, args ...any)
// Settle is how long a group's heads must stay the same after the candidate last changed them before its check starts
// (issue 1890): the members of a group are announced one after another, and a check of each partial set is thrown
// away at the next. None when 0; DefaultSettle in the module.
@@ -888,6 +893,7 @@ func (ch *Checker) try(ctx, run context.Context, key string, cs *Case) {
return
}
ch.writeOwed(ctx, cs, v, out)
ch.sayVerdict(ctx, cs, v, out)
ch.logf("%s: %s", cs.Key, v.State)
}
@@ -430,9 +430,12 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T
raw, _, _, _ := r.owed.Get(ctx, key)
o, _ := delivery.DecodeCheckerOwed(raw)
seen = append(seen, o.State)
return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}}}, nil
return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}},
OutOfStep: map[string][]string{repoA + "#7": {"go/seats/x/contract.go"}}}, nil
}}
r = newRig(t, j)
says := &verdictsSaid{}
r.ch.Says = says
r.ch.Candidate(ctx, "feat/x", candidateOf(t, "feat/x", pullOf(repoA, 7, head1)))
r.ch.Wait()
key, _ := delivery.CheckerOwedKey(repoA, 7, head1)
@@ -444,6 +447,25 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T
if len(seen) != 1 || seen[0] != "pending" || o.State != "failure" || o.Context != delivery.StatusContextCheck || !strings.HasPrefix(o.Target, "https://view.invalid/checks/") {
t.Errorf("while running %v, at the end %+v", seen, o)
}
// DLV-C2 (design 26 *Out of step*): the check's end says one verdict event for the pull request, naming its out-of-step
// files; the running status says none.
if len(says.said) != 1 || says.said[0].Repository != repoA || says.said[0].Pull != 7 || says.said[0].Head != head1 ||
says.said[0].Verdict != delivery.VerdictFail || strings.Join(says.said[0].OutOfStep, ",") != "go/seats/x/contract.go" {
t.Errorf("verdict events %+v", says.said)
}
}
// verdictsSaid is a VerdictSayer that keeps what it was told.
type verdictsSaid struct {
mu sync.Mutex
said []VerdictEvent
}
func (s *verdictsSaid) Say(_ context.Context, v VerdictEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
s.said = append(s.said, v)
return nil
}
// No check of heads no candidate holds is left running (issue 1890): at start the sweep ends a key left running; while the
@@ -55,6 +55,19 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) {
t.Errorf("%s: the verdict is not written: %v", name, err)
}
}
// Design 26 *Out of step* (DLV-C2): the out-of-step files the compare step names, in each result, are the outcome's for
// the pull request, each once and sorted; a level naming none leaves the outcome with none.
step := func(files ...string) json.RawMessage {
raw, _ := json.Marshal(Tested{Level: delivery.LevelUnit, Tests: 1, Passed: false, Why: "out of step", OutOfStep: files})
return raw
}
_, st := judgeLevel(t, step("go/seats/b/contract.go", "go/seats/a/contract.go"), step("go/seats/a/contract.go"))
if got := st.outcome(delivery.Verdict{State: delivery.VerdictFail}).OutOfStep["novox/x#0"]; strings.Join(got, ",") != "go/seats/a/contract.go,go/seats/b/contract.go" {
t.Errorf("out of step %v", got)
}
if _, st := judgeLevel(t, failingResult(t, 2, a)); st.outcome(delivery.Verdict{State: delivery.VerdictFail}).OutOfStep != nil {
t.Errorf("a level naming no out-of-step file has %v", st.outOfStep)
}
}
// Issue 1921: past delivery.MaxFailures the list names the first fifty and how many more, the status line says how many
@@ -257,6 +257,8 @@ type state struct {
unpreviewed []string
// tags are the group's tagged versions its members are built against (ADR 0607 §3), once the check heard them.
tags groupVersions
// outOfStep are, by pull request (pullKey), the generated files a level's compare step found out of step (design 26).
outOfStep map[string][]string
}
// Judge runs the case (it is the Judge of a Checker). A failure of the change, of a merge or of a step is a verdict; an error is only a case that cannot
@@ -353,7 +355,7 @@ func (st *state) outcome(v delivery.Verdict) Outcome {
slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed))
}
}
out := Outcome{Verdict: v, Slowest: slow}
out := Outcome{Verdict: v, Slowest: slow, OutOfStep: st.outOfStep}
if len(st.beside) > 0 {
out.Beside = st.beside
}
@@ -368,6 +370,20 @@ func (st *state) outcome(v delivery.Verdict) Outcome {
return out
}
// stepOutOfStep keeps the out-of-step files a step of the pull request named, each once, sorted.
func (st *state) stepOutOfStep(p Pull, files []string) {
if len(files) == 0 {
return
}
if st.outOfStep == nil {
st.outOfStep = map[string][]string{}
}
k := pullKey(Pull{Repository: p.Repository, Number: p.Number})
all := append(st.outOfStep[k], files...)
slices.Sort(all)
st.outOfStep[k] = slices.Compact(all)
}
// fail is a verdict of failure at a machine.
func fail(machine, words string) *delivery.Verdict {
v := failure(machine, words)
@@ -712,6 +728,7 @@ func (st *state) level(ctx context.Context, t *target, la delivery.LevelAnswer)
if !res.Passed {
failedResults = append(failedResults, res)
}
st.stepOutOfStep(t.pull, res.OutOfStep)
}
if len(failedResults) > 0 {
return levelFailure(t, level, failedResults...), elapsed
@@ -3,8 +3,8 @@ package check
import (
"context"
"encoding/json"
"reflect"
"maps"
"reflect"
"slices"
"strings"
"sync"
@@ -110,6 +110,10 @@ type Tested struct {
// By is the build module that answered and the SDK it was built with (buildstep.By); absent from a build module from
// before the failure contract.
By string `json:"by,omitempty"`
// OutOfStep are the generated files the step's regenerate-and-compare found out of step with their seat definitions,
// each a repository-relative path (design 26; the build modules' compare step, mesh-catalog CAT-BG5 and CAT-BN2):
// the checker names them in its verdict event. Absent from a build module that does not compare.
OutOfStep []string `json:"out-of-step,omitempty"`
}
// Digested is the answer of the Go build module's digest step: one digest per package that has tests at the level, by import
@@ -0,0 +1,37 @@
package check
import (
"context"
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// EventVerdict is the checker's event that says a pull request's check ended, with the generated files its steps found out
// of step with their seat definitions (design 26 *Out of step*; ADR 0620): on the checker's own family asks (its stream
// asks), so the indexer learns them from the event and runs no generator, and a waiter on the stream checks does not wake.
const EventVerdict = meshdeliverychecker.HolderEventVerdict
// VerdictEvent is the body of verdict, as the seat definition of mesh-delivery-checker says it.
type VerdictEvent = meshdeliverychecker.VerdictHolderEvent
// VerdictSayer publishes a verdict event.
type VerdictSayer interface {
Say(ctx context.Context, v VerdictEvent) error
}
// sayVerdict says, for each pull request of a check that ended in pass or fail, one verdict event naming its head and its
// out-of-step files. A try owes no pull request anything and says none (ADR 0501 §5). A publish that fails is said in the
// log: the verdict and the status are written already, and the next check of the heads says it again.
func (ch *Checker) sayVerdict(ctx context.Context, cs *Case, v delivery.Verdict, out Outcome) {
if ch.Says == nil || cs.Try != nil || (v.State != delivery.VerdictPass && v.State != delivery.VerdictFail) {
return
}
for _, p := range cs.Pulls {
e := VerdictEvent{Repository: p.Repository, Pull: int64(p.Number), Head: p.Head, Verdict: v.State,
OutOfStep: out.OutOfStep[pullKey(Pull{Repository: p.Repository, Number: p.Number})]}
if err := ch.Says.Say(ctx, e); err != nil {
ch.logf("%s: the verdict of %s#%d was written, and its event could not be said: %v", cs.Key, p.Repository, p.Number, err)
}
}
}