From b944d07158a3727d2b5f1ffccd9866d1596fce98 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 19:21:22 +0200 Subject: [PATCH] Add the checker's owed bucket, the mesh/check status context and the previews layout, and correct the checker header: the checker decides the status, the bridge sets it (issue 1673) --- go/delivery/checker.go | 8 +- go/delivery/checker_test.go | 188 ++++++++++++++++++++++++++++++++++++ go/delivery/gate.go | 26 ++++- go/delivery/preview.go | 168 ++++++++++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 go/delivery/preview.go diff --git a/go/delivery/checker.go b/go/delivery/checker.go index acc287b..81c0dc9 100644 --- a/go/delivery/checker.go +++ b/go/delivery/checker.go @@ -9,9 +9,11 @@ import ( ) // The checker's keys (novox/hq ADR 0447 clauses 1 to 3, ADR 0444, ADR 0446 clause 5, ADR 0453 clause 4): the verdicts of -// its checks and the passing verdicts it keeps per package and level. The checker writes them from R4c, dark; the -// forge bridge writes the status `mesh/check` from a verdict (unrequired until R4f), the gate reads it from R4f, and -// the view and the watchman read them (a running check carries a pulse). +// its checks and the passing verdicts it keeps per package and level. The checker writes them from R4c, dark, and +// writes the status `mesh/check` it decides as an owed act in its own bucket (CheckerOwedBucket, the gate's owed shape); +// the forge bridge, the only part that calls the forge, performs that act (unrequired until R4f), once per revision of +// the key (ADR 0484 clause 2). The gate reads the status from R4f, and the view and the watchman read the verdicts (a +// running check carries a pulse). // // **The verdict's key is the group and the heads, not the fingerprints** (the lead's limit for R4a). Research 063 // section 01 drew `verdicts..check.`; ADR 0447 clause 1 judges a pull request's head merged onto its diff --git a/go/delivery/checker_test.go b/go/delivery/checker_test.go index f8542c4..eb79299 100644 --- a/go/delivery/checker_test.go +++ b/go/delivery/checker_test.go @@ -328,3 +328,191 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) { t.Errorf("the old reader of a new value: %+v, %v", ov, err) } } + +func sampleCheckerOwed() Owed { + return Owed{Act: OwedStatus, Context: StatusContextCheck, State: "success", Target: "https://mesh.example/delivery/check/36", + Text: "pass 4b5491557853 on aaaa: unit 12 run 301 kept, integration 3 run 40 kept; composition: not yet run"} +} + +// 1673/AC1: the checker's owed bucket is the checker module plus _owed, and its key is the gate's owed key with the act +// status=mesh/check. +func TestCheckerOwedKeyAndBucket(t *testing.T) { + if CheckerOwedBucket != "mesh-delivery-checker_owed" || CheckerOwedHistory != 5 || PreviewsBucket != "mesh-delivery-checker_previews" || PreviewsHistory != 5 { + t.Errorf("buckets %s %d %s %d", CheckerOwedBucket, CheckerOwedHistory, PreviewsBucket, PreviewsHistory) + } + key, err := CheckerOwedKey("novox/mesh-sdk", 36, "aaaa") + if err != nil || key != "novox/mesh-sdk.36.aaaa.status=mesh/check" { + t.Fatalf("key = %q, %v", key, err) + } + repo, number, head, err := ParseCheckerOwedKey(key) + if err != nil || repo != "novox/mesh-sdk" || number != 36 || head != "aaaa" { + t.Errorf("parse = %q %d %q %v", repo, number, head, err) + } + other, _ := OwedKey("novox/mesh-sdk", 36, "aaaa", "status=mesh/merge-gate") + for _, bad := range []string{other, "novox/mesh-sdk.36.aaaa.view", "novox/mesh-sdk.36.aaaa.note", "novox/mesh-sdk.36.aaaa.comment.1", ""} { + if _, _, _, err := ParseCheckerOwedKey(bad); err == nil { + t.Errorf("checker owed key %q accepted", bad) + } + } + if _, err := CheckerOwedKey("nope", 36, "aaaa"); err == nil { + t.Error("a bad repository accepted") + } +} + +// 1673/AC2: an owed value with the context mesh/check is accepted and an unknown context is refused, in the act and in the value. +func TestOwedAcceptsMeshCheckAndRefusesAnUnknownContext(t *testing.T) { + for _, ctx := range []string{StatusContextCheck, StatusContextMergeGate} { + if kind, got, err := ParseOwedAct("status=" + ctx); err != nil || kind != OwedStatus || got != ctx { + t.Errorf("ParseOwedAct(status=%s) = %q %q %v", ctx, kind, got, err) + } + } + for _, bad := range []string{"status=mesh/other", "status=check", "status=mesh/Check"} { + if _, err := OwedKey("novox/mesh-sdk", 36, "aaaa", bad); err == nil || !strings.Contains(err.Error(), bad[len("status="):]) { + t.Errorf("owed act %q: %v, want a refusal naming the context", bad, err) + } + } + data, err := EncodeCheckerOwed(sampleCheckerOwed()) + if err != nil { + t.Fatal(err) + } + if got, err := DecodeCheckerOwed(data); err != nil || !SameOwed(got, sampleCheckerOwed()) && got.Context != StatusContextCheck { + t.Errorf("round trip = %+v, %v", got, err) + } + unknown := sampleCheckerOwed() + unknown.Context = "mesh/other" + if _, err := EncodeOwed(unknown); err == nil || !strings.Contains(err.Error(), "mesh/other") { + t.Errorf("an owed status of the context mesh/other: %v", err) + } + // The checker's bucket holds only its own status. + gate := sampleCheckerOwed() + gate.Context = StatusContextMergeGate + for name, o := range map[string]Owed{"the gate's context": gate, "a view": {Act: OwedView, Text: "x"}, "a note": {Act: OwedNote, Text: "x", Ref: "mesh-plan"}} { + if _, err := EncodeCheckerOwed(o); err == nil { + t.Errorf("the checker's bucket takes %s", name) + } + } + for _, state := range []string{"pending", "success", "failure"} { + o := sampleCheckerOwed() + o.State = state + if _, err := EncodeCheckerOwed(o); err != nil { + t.Errorf("state %s: %v", state, err) + } + } +} + +func samplePreview() Preview { + return Preview{Repository: "novox/mesh-delivery", Head: "aaaa", Trunk: "bbbb", Merge: "cccc", + Modules: map[string]PreviewModule{ + "modules/mesh-delivery-checker": {Tree: "t1", Manifest: strings.Repeat("1", 64), Locks: map[string]string{"go.sum": strings.Repeat("2", 64)}}, + "modules/mesh-delivery-view": {Tree: "t2", Manifest: strings.Repeat("3", 64)}, + }} +} + +// 1673/AC3: the previews key is .. and parses back. +func TestPreviewKey(t *testing.T) { + key, err := PreviewKey("novox/mesh-delivery", "aaaa", "bbbb") + if err != nil || key != "novox/mesh-delivery.aaaa.bbbb" { + t.Fatalf("key = %q, %v", key, err) + } + repo, head, trunk, err := ParsePreviewKey(key) + if err != nil || repo != "novox/mesh-delivery" || head != "aaaa" || trunk != "bbbb" { + t.Errorf("parse = %q %q %q %v", repo, head, trunk, err) + } + for _, bad := range [][3]string{{"nope", "aaaa", "bbbb"}, {"novox/x", "", "bbbb"}, {"novox/x", "aaaa", ""}, {"novox/x", "a.b", "bbbb"}, {"novox/x", "aaaa", "b b"}} { + if _, err := PreviewKey(bad[0], bad[1], bad[2]); err == nil { + t.Errorf("PreviewKey%v accepted", bad) + } + } + for _, bad := range []string{"", "novox/x.aaaa", "novox/x.aaaa.bbbb.cccc", "nope.aaaa.bbbb", "novox/x..bbbb", "novox/x.aaaa."} { + if _, _, _, err := ParsePreviewKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParsePreviewKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1673/AC4: a preview round trips, and an invalid one is refused naming the field. +func TestPreviewRoundTripAndRefusals(t *testing.T) { + data, err := EncodePreview(samplePreview()) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(data), `{"v":1,`) { + t.Errorf("at %s", data) + } + got, err := DecodePreview(data) + want := samplePreview() + want.V = Version + if err != nil || !SamePreview(got, want) { + t.Errorf("round trip = %+v, %v", got, err) + } + if _, err := EncodePreview(Preview{Repository: "novox/x", Head: "aaaa", Trunk: "bbbb", Merge: "cccc"}); err != nil { + t.Errorf("a preview of a head that changes no module refused: %v", err) + } + cases := map[string]struct { + edit func(*Preview) + words []string + }{ + "no repository": {func(p *Preview) { p.Repository = "" }, []string{"repository"}}, + "a bad repository": {func(p *Preview) { p.Repository = "nope" }, []string{"nope"}}, + "no head": {func(p *Preview) { p.Head = "" }, []string{"head"}}, + "no trunk": {func(p *Preview) { p.Trunk = "" }, []string{"trunk"}}, + "no merge": {func(p *Preview) { p.Merge = "" }, []string{"merge"}}, + "a module with no tree": {func(p *Preview) { + p.Modules["modules/mesh-delivery-view"] = PreviewModule{Manifest: strings.Repeat("3", 64)} + }, []string{"modules/mesh-delivery-view", "tree"}}, + "a bad manifest digest": {func(p *Preview) { + p.Modules["modules/mesh-delivery-view"] = PreviewModule{Tree: "t2", Manifest: "xyz"} + }, []string{"modules/mesh-delivery-view", "xyz"}}, + "an empty directory": {func(p *Preview) { p.Modules[""] = PreviewModule{Tree: "t", Manifest: strings.Repeat("3", 64)} }, []string{"directory"}}, + "a lock with no hash": {func(p *Preview) { + p.Modules["modules/mesh-delivery-view"] = PreviewModule{Tree: "t2", Manifest: strings.Repeat("3", 64), Locks: map[string]string{"go.sum": ""}} + }, []string{"modules/mesh-delivery-view", "go.sum"}}, + } + for name, c := range cases { + p := samplePreview() + c.edit(&p) + _, err := EncodePreview(p) + if err == nil { + t.Errorf("%s accepted", name) + continue + } + for _, w := range c.words { + if !strings.Contains(err.Error(), w) { + t.Errorf("%s: error %q does not name %q", name, err, w) + } + } + } + if _, err := DecodePreview([]byte(`{"v":2,"repository":"novox/x"}`)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("a preview of version 2: %v", err) + } + if _, err := DecodePreview([]byte(`{"v":1,"repository":"novox/x","head":"a","trunk":"b"}`)); err == nil { + t.Error("decoded a preview with no merge") + } +} + +// 1673/AC5: a reader of today's checker shapes ignores a field it does not know, and the checker's owed value is the +// gate's shape, so the gate's reader reads it (as indexer2_test.go does for the indexer's values). +func TestCheckerOwedIsReadableByTheGatesReaderAndAnOldOwedReaderIgnoresNewFields(t *testing.T) { + data, err := EncodeCheckerOwed(sampleCheckerOwed()) + if err != nil { + t.Fatal(err) + } + var old struct { + V int `json:"v"` + Act string `json:"act"` + Context string `json:"context"` + State string `json:"state"` + } + if err := json.Unmarshal(data, &old); err != nil || old.Act != OwedStatus || old.Context != "mesh/check" { + t.Errorf("the old reader: %+v %v", old, err) + } + withNew := strings.Replace(string(data), `{"v":1,`, `{"v":1,"a-field-of-tomorrow":7,`, 1) + if _, err := DecodeOwed([]byte(withNew)); err != nil { + t.Errorf("an owed value with a field of tomorrow: %v", err) + } + p, _ := EncodePreview(samplePreview()) + withNew = strings.Replace(string(p), `{"v":1,`, `{"v":1,"a-field-of-tomorrow":7,`, 1) + if _, err := DecodePreview([]byte(withNew)); err != nil { + t.Errorf("a preview with a field of tomorrow: %v", err) + } +} diff --git a/go/delivery/gate.go b/go/delivery/gate.go index 61ec74d..281ab13 100644 --- a/go/delivery/gate.go +++ b/go/delivery/gate.go @@ -28,6 +28,17 @@ const ( AnswersHistory = 5 ) +// The contexts of the statuses the mesh owes the forge: the gate's, the delivery's view of a head and of a group +// (design 47), and the checker's, which is not required until R4f (ADR 0484 clause 2, ADR 0453 clause 4). +const ( + StatusContextMergeGate = "mesh/merge-gate" + StatusContextDelivery = "mesh/delivery" + StatusContextDeliveryGroup = "mesh/delivery-group" + StatusContextCheck = "mesh/check" +) + +var statusContexts = []string{StatusContextMergeGate, StatusContextDelivery, StatusContextDeliveryGroup, StatusContextCheck} + // AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key. const AskKindMerge = "merge" @@ -131,6 +142,16 @@ func AskingID(askKey string, asking int) string { return AskID(askKey + "." + strconv.Itoa(asking)) } +// checkStatusContext refuses a status context the mesh does not owe the forge. +func checkStatusContext(context string) error { + for _, c := range statusContexts { + if context == c { + return nil + } + } + return fmt.Errorf("delivery: the status context %q is not one of %s", context, strings.Join(statusContexts, ", ")) +} + func checkOwedAct(act string) (kind, context string, err error) { kind, context, hasContext := strings.Cut(act, "=") switch kind { @@ -138,7 +159,7 @@ func checkOwedAct(act string) (kind, context string, err error) { if !hasContext { return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=", act) } - if err := checkName("status context", context); err != nil { + if err := checkStatusContext(context); err != nil { return "", "", err } return kind, context, nil @@ -345,6 +366,9 @@ func (o Owed) check() error { if o.Context == "" { return fmt.Errorf("delivery: an owed status has no context") } + if err := checkStatusContext(o.Context); err != nil { + return err + } if o.Target == "" { return fmt.Errorf("delivery: an owed status has no target") } diff --git a/go/delivery/preview.go b/go/delivery/preview.go new file mode 100644 index 0000000..67ce56c --- /dev/null +++ b/go/delivery/preview.go @@ -0,0 +1,168 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// The checker's other buckets (ADR 0484 clauses 2 and 3): owed, the acts it owes the forge, which the forge bridge +// performs once per revision of a key, and previews, what the fetch step answered for a head merged onto a trunk commit. +const ( + // CheckerOwedBucket is the checker's bucket of owed acts, in the gate's owed shape: a status with the context + // StatusContextCheck for each head it judged. One part writes each key: the checker. + CheckerOwedBucket = CheckerModule + "_owed" + // CheckerOwedHistory is the revisions the checker's owed bucket keeps per key. + CheckerOwedHistory = 5 + // PreviewsBucket is the checker's bucket of merge previews; the indexer reads it for the candidates' fingerprints. + PreviewsBucket = CheckerModule + "_previews" + // PreviewsHistory is the revisions the previews bucket keeps per key. + PreviewsHistory = 5 +) + +// CheckerOwedKey is the key, in CheckerOwedBucket, of the status the checker owes on a pull request at a head: the +// gate's OwedKey with the act `status=mesh/check`, the only act the checker owes. +func CheckerOwedKey(repository string, number int, head string) (string, error) { + return OwedKey(repository, number, head, OwedStatus+"="+StatusContextCheck) +} + +// ParseCheckerOwedKey is the inverse of CheckerOwedKey; it refuses an owed key of any other act. +func ParseCheckerOwedKey(key string) (repository string, number int, head string, err error) { + repository, number, head, act, err := ParseOwedKey(key) + if err != nil { + return "", 0, "", err + } + if act != OwedStatus+"="+StatusContextCheck { + return "", 0, "", fmt.Errorf("delivery: %q is not a checker owed key: the checker owes only status=%s, not %q", key, StatusContextCheck, act) + } + return repository, number, head, nil +} + +// EncodeCheckerOwed is o as JSON at Version; it refuses what EncodeOwed refuses and any act but a status with the +// context StatusContextCheck. +func EncodeCheckerOwed(o Owed) ([]byte, error) { + if err := checkCheckerOwed(o); err != nil { + return nil, err + } + return EncodeOwed(o) +} + +func checkCheckerOwed(o Owed) error { + if o.Act != OwedStatus || o.Context != StatusContextCheck { + return fmt.Errorf("delivery: the checker owes only a status with the context %s, not the act %q with the context %q", StatusContextCheck, o.Act, o.Context) + } + return nil +} + +// DecodeCheckerOwed reads an owed act of the checker's bucket. +func DecodeCheckerOwed(data []byte) (Owed, error) { + o, err := DecodeOwed(data) + if err != nil { + return Owed{}, err + } + if err := checkCheckerOwed(o); err != nil { + return Owed{}, err + } + return o, nil +} + +// PreviewKey is the key, in PreviewsBucket, of a head merged onto a trunk commit: `..`. +func PreviewKey(repository, head, trunk string) (string, error) { + repo, err := escapeRepository(repository) + if err != nil { + return "", err + } + if err := checkName("head", head); err != nil { + return "", err + } + if err := checkName("trunk commit", trunk); err != nil { + return "", err + } + return repo + "." + head + "." + trunk, nil +} + +// ParsePreviewKey is the inverse of PreviewKey. +func ParsePreviewKey(key string) (repository, head, trunk string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 3 { + return "", "", "", fmt.Errorf("delivery: %q is not a preview key, ..", key) + } + repository, err = unescapeRepository(parts[0]) + if err != nil { + return "", "", "", fmt.Errorf("delivery: %q is not a preview key: %w", key, err) + } + if _, err := PreviewKey(repository, parts[1], parts[2]); err != nil { + return "", "", "", fmt.Errorf("delivery: %q is not a preview key: %w", key, err) + } + return repository, parts[1], parts[2], nil +} + +// PreviewModule is what the preview holds of one module directory: the tree sha of the directory, the digest of its +// manifest (the key in the manifests bucket) and the hex sha256 of its lock files by path relative to it. +type PreviewModule struct { + Tree string `json:"tree"` + Manifest string `json:"manifest"` + Locks map[string]string `json:"locks,omitempty"` +} + +// Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge +// commit and, for each module directory of the merge, a PreviewModule. +type Preview struct { + V int `json:"v"` + Repository string `json:"repository"` + Head string `json:"head"` + Trunk string `json:"trunk"` + Merge string `json:"merge"` + Modules map[string]PreviewModule `json:"modules,omitempty"` +} + +func (p Preview) check() error { + if _, err := PreviewKey(p.Repository, p.Head, p.Trunk); err != nil { + return err + } + if p.Merge == "" { + return fmt.Errorf("delivery: a preview names no merge commit") + } + for dir, m := range p.Modules { + if dir == "" { + return fmt.Errorf("delivery: a preview names a module with an empty directory") + } + if m.Tree == "" { + return fmt.Errorf("delivery: the preview's module %q has no tree", dir) + } + if _, err := ManifestsKey(m.Manifest); err != nil { + return fmt.Errorf("delivery: the preview's module %q: %w", dir, err) + } + for path, hash := range m.Locks { + if path == "" || hash == "" { + return fmt.Errorf("delivery: the lock %q of the preview's module %q has an empty path or hash", path, dir) + } + } + } + return nil +} + +// EncodePreview is p as JSON at Version. +func EncodePreview(p Preview) ([]byte, error) { + if err := p.check(); err != nil { + return nil, err + } + p.V = Version + return json.Marshal(p) +} + +// DecodePreview reads a preview. +func DecodePreview(data []byte) (Preview, error) { + var p Preview + if err := decode(data, &p); err != nil { + return Preview{}, err + } + if err := p.check(); err != nil { + return Preview{}, err + } + return p, nil +} + +// SamePreview says whether two previews are the same value. +func SamePreview(a, b Preview) bool { return reflect.DeepEqual(a, b) }