diff --git a/go/delivery/source.go b/go/delivery/source.go index 3c09503..e9d28a0 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -415,6 +415,19 @@ type Module struct { // so the checker tells whether a change touches an artifact's build source without reading a manifest (the lead's // decision L8 of R4e). Absent for an artifact that names none, and in a value written before. ArtifactPaths map[string][]string `json:"paths,omitempty"` + // Verdict is the indexer's verdict on Manifest, VerdictAccepted or VerdictRefused, and Problems each problem of a + // refusal, as the verdicts state holds them (novox/hq ADR 0578 §1): a reader of modules takes it without a read of + // that state. Empty in a value written before the indexer judged, which a reader takes as accepted (TakeManifestVerdict). + Verdict string `json:"verdict,omitempty"` + Problems []string `json:"problems,omitempty"` +} + +// VerdictOf is the verdict a modules value carries, or nil when it carries none (taken as accepted, TakeManifestVerdict). +func (m Module) VerdictOf(module string) *ManifestVerdict { + if m.Verdict == "" { + return nil + } + return &ManifestVerdict{Module: module, State: m.Verdict, Problems: m.Problems} } // RootPath is Module.Path of a module whose manifest is its repository's root. @@ -503,6 +516,14 @@ func (m Module) check() error { return err } } + switch { + case m.Verdict != "" && m.Verdict != VerdictAccepted && m.Verdict != VerdictRefused: + return fmt.Errorf("delivery: the module's verdict %q is neither %s nor %s", m.Verdict, VerdictAccepted, VerdictRefused) + case m.Verdict != "" && m.Manifest == "": + return fmt.Errorf("delivery: a module carries a verdict and no manifest digest") + case (m.Verdict == VerdictRefused) != (len(m.Problems) > 0): + return fmt.Errorf("delivery: a module's problems are named only with a refusal, and a refusal names them") + } return checkNewForm(m.FP2, m.Manifest) } diff --git a/go/delivery/verdicts.go b/go/delivery/verdicts.go new file mode 100644 index 0000000..86de134 --- /dev/null +++ b/go/delivery/verdicts.go @@ -0,0 +1,152 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "time" +) + +// The verdicts on manifests (novox/hq ADR 0578 §1, §3): the indexer judges every manifest it takes in once, with its own +// judge, and writes the verdict in its own state; every other part decodes a manifest leniently (catalogue.Decode) and +// takes the verdict from here. This is the contract only: what a verdict holds, its key and its event. The rules that +// make a verdict are the judge's, a package of mesh-delivery the indexer and the checker use, and no reader runs them. + +const ( + // VerdictsBucket is the indexer's state of verdicts, one key per manifest digest (VerdictsKey). + VerdictsBucket = IndexerModule + "_verdicts" + // VerdictsHistory is the revisions the verdicts state keeps per key: a verdict is written again only when the judge + // that made it changed and the verdict with it. + VerdictsHistory = 5 + + // VerdictAccepted is the state of a manifest the judge found no problem in. + VerdictAccepted = "accepted" + // VerdictRefused is the state of a manifest the judge found a problem in; Problems says each. + VerdictRefused = "refused" + + // ManifestRefusedEvent is the indexer's event saying a manifest was refused (ManifestRefused), so the watchman + // raises it as a condition beside the indexer's finding. + ManifestRefusedEvent = "manifest-refused" +) + +// ManifestVerdict is the value of a verdicts key: the module whose manifest it is, accepted or refused, every problem the judge +// found (none when accepted), the judge that made it (the indexer's build fingerprint) and when. +type ManifestVerdict struct { + V int `json:"v"` + Module string `json:"module"` + State string `json:"state"` + Problems []string `json:"problems,omitempty"` + Judge string `json:"judge"` + At time.Time `json:"at"` +} + +// Refused is whether v refuses its manifest. +func (v ManifestVerdict) Refused() bool { return v.State == VerdictRefused } + +// Same is whether v and o say the same of a manifest: the module, the state and the problems, whoever judged and when. +// The indexer writes a verdict only when it is not the same as the one it holds. +func (v ManifestVerdict) Same(o ManifestVerdict) bool { + if v.Module != o.Module || v.State != o.State || len(v.Problems) != len(o.Problems) { + return false + } + for i := range v.Problems { + if v.Problems[i] != o.Problems[i] { + return false + } + } + return true +} + +func (v ManifestVerdict) check() error { + if err := checkName("module", v.Module); err != nil { + return err + } + switch v.State { + case VerdictAccepted: + if len(v.Problems) > 0 { + return fmt.Errorf("delivery: the verdict on %s accepts its manifest and names problems", v.Module) + } + case VerdictRefused: + if len(v.Problems) == 0 { + return fmt.Errorf("delivery: the verdict on %s refuses its manifest and names no problem", v.Module) + } + default: + return fmt.Errorf("delivery: the verdict on %s is %q, neither %s nor %s", v.Module, v.State, VerdictAccepted, VerdictRefused) + } + if v.Judge == "" { + return fmt.Errorf("delivery: the verdict on %s names no judge", v.Module) + } + return nil +} + +// VerdictsKey is the key, in VerdictsBucket, of the verdict on a manifest: its digest (ManifestDigest), as in the +// manifests bucket. +func VerdictsKey(digest string) (string, error) { + if !isHex64(digest) { + return "", fmt.Errorf("delivery: %q is not a manifest digest, 64 lowercase hex digits", digest) + } + return digest, nil +} + +// EncodeManifestVerdict is v as JSON at Version; it refuses a verdict without a module or a judge, a state other than accepted +// or refused, a refusal naming no problem and an acceptance naming one. +func EncodeManifestVerdict(v ManifestVerdict) ([]byte, error) { + if err := v.check(); err != nil { + return nil, err + } + v.V = Version + return json.Marshal(v) +} + +// DecodeManifestVerdict reads a verdict; it refuses an unknown version (ErrUnknownVersion) and what EncodeManifestVerdict refuses. +func DecodeManifestVerdict(data []byte) (ManifestVerdict, error) { + var v ManifestVerdict + if err := decode(data, &v); err != nil { + return ManifestVerdict{}, err + } + if err := v.check(); err != nil { + return ManifestVerdict{}, err + } + return v, nil +} + +// TakeManifestVerdict is how a reader takes the verdict on a manifest (ADR 0578 §3): refused, with its problems, only when the +// indexer wrote a refusal. A missing verdict (nil) is taken as accepted, because every manifest the indexer holds before +// it has judged it was taken in under the judges that ran until then; that lasts until the indexer's first pass after its +// delivery has written every verdict. A reader holds a refused module and names it with Why, and reads the rest. +func TakeManifestVerdict(v *ManifestVerdict) (refused bool, problems []string) { + if v == nil || !v.Refused() { + return false, nil + } + return true, v.Problems +} + +// Why is a refusal in a reader's words: the module, that the indexer's judge refused its manifest, and each problem. +func (v ManifestVerdict) Why() string { + if !v.Refused() { + return "" + } + judge := "the indexer's judge" + if v.Judge != "" { + judge += " (" + v.Judge + ")" + } + why := fmt.Sprintf("%s refused the manifest of %s:", judge, v.Module) + for _, p := range v.Problems { + why += " " + p + ";" + } + return why[:len(why)-1] +} + +// ManifestRefused is the body of the indexer's event ManifestRefusedEvent: the module, the digest of its manifest, the +// judge and each problem, as its verdict holds them. +type ManifestRefused struct { + V int `json:"v"` + Module string `json:"module"` + Digest string `json:"digest"` + Judge string `json:"judge"` + Problems []string `json:"problems"` +} + +// RefusedOf is the event body saying v, the verdict on the manifest of digest. +func RefusedOf(digest string, v ManifestVerdict) ManifestRefused { + return ManifestRefused{V: Version, Module: v.Module, Digest: digest, Judge: v.Judge, Problems: v.Problems} +} diff --git a/go/delivery/verdicts_test.go b/go/delivery/verdicts_test.go new file mode 100644 index 0000000..0de3517 --- /dev/null +++ b/go/delivery/verdicts_test.go @@ -0,0 +1,65 @@ +package delivery + +import ( + "strings" + "testing" + "time" +) + +// A verdict on a manifest round-trips, refuses what it cannot be, and a reader takes a missing one as accepted (novox/hq +// ADR 0578 §1, §3); a modules value carries it. +func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) { + at := time.Date(2026, 11, 1, 2, 0, 0, 0, time.UTC) + refused := ManifestVerdict{Module: "nats", State: VerdictRefused, Problems: []string{"a", "b"}, Judge: "src2:x", At: at} + data, err := EncodeManifestVerdict(refused) + if err != nil { + t.Fatal(err) + } + back, err := DecodeManifestVerdict(data) + if err != nil || !back.Same(refused) || back.Judge != "src2:x" || !back.At.Equal(at) { + t.Fatalf("round trip: %+v %v", back, err) + } + if !strings.Contains(back.Why(), "refused the manifest of nats: a; b") { + t.Fatalf("why: %q", back.Why()) + } + for name, bad := range map[string]ManifestVerdict{ + "no module": {State: VerdictAccepted, Judge: "j"}, + "no judge": {Module: "m", State: VerdictAccepted}, + "another state": {Module: "m", State: "maybe", Judge: "j"}, + "refused, no problem": {Module: "m", State: VerdictRefused, Judge: "j"}, + "accepted, a problem": {Module: "m", State: VerdictAccepted, Problems: []string{"x"}, Judge: "j"}, + } { + if _, err := EncodeManifestVerdict(bad); err == nil { + t.Errorf("%s: encoded", name) + } + } + if refusedTaken, _ := TakeManifestVerdict(nil); refusedTaken { + t.Fatal("a missing verdict was taken as a refusal") + } + if r, p := TakeManifestVerdict(&refused); !r || len(p) != 2 { + t.Fatalf("a refusal was not taken: %v %v", r, p) + } + if k, err := VerdictsKey(strings.Repeat("a", 64)); err != nil || k != strings.Repeat("a", 64) { + t.Fatalf("key: %q %v", k, err) + } + if _, err := VerdictsKey("nats"); err == nil { + t.Fatal("a key that is no digest") + } + m := Module{FP2: "src2:" + strings.Repeat("b", 64), Manifest: strings.Repeat("c", 64), Repository: "novox/x", + Commit: "c", Verdict: VerdictRefused, Problems: []string{"p"}} + data, err = EncodeModule(m) + if err != nil { + t.Fatal(err) + } + got, err := DecodeModule(data) + if err != nil { + t.Fatal(err) + } + if r, p := TakeManifestVerdict(got.VerdictOf("x")); !r || p[0] != "p" { + t.Fatalf("the module's verdict: %+v", got) + } + m.Problems = nil + if _, err := EncodeModule(m); err == nil { + t.Fatal("a module refused naming no problem was encoded") + } +}