From ec2bd3b99079a23840e5894a9c998548d5baeffb Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:10:09 +0200 Subject: [PATCH 1/5] Lay out the assigner's wants, holds and differences in mesh-sdk, so the assigner, the watchman and the view read one layout (issue 1297, subtask B1a; ADR 0528 clauses 1 and 4) --- go/delivery/assigner.go | 287 +++++++++++++++++++++++++++++++++++ go/delivery/assigner_test.go | 157 +++++++++++++++++++ 2 files changed, 444 insertions(+) create mode 100644 go/delivery/assigner.go create mode 100644 go/delivery/assigner_test.go diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go new file mode 100644 index 0000000..4d6f5c3 --- /dev/null +++ b/go/delivery/assigner.go @@ -0,0 +1,287 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// The assigner's keys (novox/hq ADR 0528 clauses 1 and 4, ADR 0429 §6, issue 1297). The assigner writes, by the six +// rules of ADR 0429 §6, what each node should run: `want..` in its bucket wants, and beside it +// `held..` for a fingerprint it holds after a failed health check. A module that leaves a node's +// modules gets its want written removed, never deleted (ADR 0408 §4). For each node and module it compares the want with +// what the controller sends (the build the controller's assignment names) and writes every difference under +// `.` in its bucket differences, with the rule that explains it or unexplained. In release 5 wants are +// only compared; the node agent applying them is release 6 (ADR 0429 §7). Only the assigner's credential writes these +// buckets; the watchman, the view and the controller read them. +// +// **The bucket is the first word, the key the rest** (as the builder's `builds..` is the key +// `.` in its builds bucket): the differences bucket's keys are `.`. The wants bucket holds +// two kinds of key, so each keeps its word: `want..` and `held..`. +const ( + // AssignerModule is the module of the assigner part. + AssignerModule = "mesh-delivery-assigner" + // AssignerWantsBucket is the assigner's bucket of wants and holds, `_`. + AssignerWantsBucket = AssignerModule + "_wants" + // AssignerWantsHistory is the revisions the wants bucket keeps per key, so a want's history shows each move and + // the operator's manual write (ADR 0429 §6, the one escape). + AssignerWantsHistory = 20 + // AssignerDifferencesBucket is the assigner's bucket of differences between a want and what the controller sends. + AssignerDifferencesBucket = AssignerModule + "_differences" + // AssignerDifferencesHistory is the revisions the differences bucket keeps per key: acceptance test 17 of design 47 + // reads a day of them. + AssignerDifferencesHistory = 64 +) + +// The words that open the wants bucket's two kinds of key. +const ( + wantWord = "want" + heldWord = "held" +) + +// WantKey is the key, in AssignerWantsBucket, of a node's want for a module: `want..`. A module named +// `node` is refused, since `want..node` is the node's own key (ADR 0429 §4), as HaveKey refuses it. +func WantKey(node, module string) (string, error) { + have, err := HaveKey(node, module) + if err != nil { + return "", err + } + return wantWord + "." + have, nil +} + +// ParseWantKey is the inverse of WantKey; it refuses a held key, a have key and any key of another bucket, naming it. +func ParseWantKey(key string) (node, module string, err error) { + rest, ok := strings.CutPrefix(key, wantWord+".") + if !ok { + return "", "", fmt.Errorf("delivery: %q is not a want key, want..", key) + } + node, module, isNode, err := ParseHaveKey(rest) + if err != nil || isNode { + return "", "", fmt.Errorf("delivery: %q is not a want key, want..", key) + } + return node, module, nil +} + +// HeldKey is the key, in AssignerWantsBucket, of a module's fingerprint the assigner holds: `held..`, +// the hex being the fingerprint's key part (FingerprintKeyPart). +func HeldKey(module, fingerprint string) (string, error) { + key, err := BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return heldWord + "." + key, nil +} + +// ParseHeldKey is the inverse of HeldKey: the module and the fingerprint (`src2:` and the hex); it refuses a want key +// and any key of another bucket, naming it. +func ParseHeldKey(key string) (module, fingerprint string, err error) { + rest, ok := strings.CutPrefix(key, heldWord+".") + if !ok { + return "", "", fmt.Errorf("delivery: %q is not a held key, held..", key) + } + if module, fingerprint, err = ParseBuilderBuildKey(rest); err != nil { + return "", "", fmt.Errorf("delivery: %q is not a held key: %w", key, err) + } + return module, fingerprint, nil +} + +// DifferenceKey is the key, in AssignerDifferencesBucket, of the difference for a module on a node: `.`. +func DifferenceKey(node, module string) (string, error) { + return HaveKey(node, module) +} + +// ParseDifferenceKey is the inverse of DifferenceKey; it refuses a want or held key and a node's own key, naming it. +func ParseDifferenceKey(key string) (node, module string, err error) { + node, module, isNode, err := ParseHaveKey(key) + if err != nil || isNode { + return "", "", fmt.Errorf("delivery: %q is not a difference key, .", key) + } + return node, module, nil +} + +// The six rules of ADR 0429 §6, by number, as a want or a difference names the one that holds a node back. +const ( + RuleBuildDone = 1 // the build of F is done + RuleNotHeld = 2 // F is not held + RuleCanary = 3 // the node is the module's canary, or the canary runs F healthy + RuleStep = 4 // at most step other nodes are ahead (step 0: no limit, ADR 0528 §3) + RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary + RuleGrant = 6 // the module's credential for F exists (grants..) +) + +func checkRule(rule int, of string) error { + if rule < RuleBuildDone || rule > RuleGrant { + return fmt.Errorf("delivery: %s names the rule %d, which is not one of the six (1 to 6)", of, rule) + } + return nil +} + +func checkFingerprint(fp, of string) error { + if _, err := FingerprintKeyPart(fp); err != nil { + return fmt.Errorf("delivery: %s: %w", of, err) + } + return nil +} + +// Want is a node's want for a module: the fingerprint FP the node should run, or Removed for a module that left the +// node's modules (then FP is empty). When the module's newest fingerprint is not yet allowed on this node, Newest names +// it and Rule the rule that holds the node back; both are empty when FP is the newest. Words say why in a sentence. +// The entry's own time says since when. +type Want struct { + V int `json:"v"` + FP string `json:"fp,omitempty"` + Removed bool `json:"removed,omitempty"` + Newest string `json:"newest,omitempty"` + Rule int `json:"rule,omitempty"` + Words string `json:"words,omitempty"` +} + +func (w Want) check() error { + if w.Removed { + if w.FP != "" || w.Newest != "" || w.Rule != 0 { + return fmt.Errorf("delivery: a removed want names no fingerprint and no rule") + } + return nil + } + if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil { + return err + } + if w.Newest == "" && w.Rule == 0 { + return nil + } + if err := checkFingerprint(w.Newest, "a want's newest fingerprint"); err != nil { + return err + } + if w.Newest == w.FP { + return fmt.Errorf("delivery: a want at its newest fingerprint names no rule holding it back") + } + return checkRule(w.Rule, "a want held back") +} + +// Held is a fingerprint the assigner holds: the node whose health check failed on it, and the check's words. +type Held struct { + V int `json:"v"` + Node string `json:"node"` + Words string `json:"words,omitempty"` +} + +func (h Held) check() error { + return checkName("node", h.Node) +} + +// Difference is where a want and what the controller sends differ for a module on a node (ADR 0528 clause 4): the +// want's fingerprint (Want, empty when the node has no want for the module, WantRemoved when the want is removed), the +// fingerprint of the build the controller's assignment names (Sent, empty when it sends none or the build names no +// fingerprint; SentBuild names that build), and the rule that explains the difference, or Unexplained. +type Difference struct { + V int `json:"v"` + Want string `json:"want,omitempty"` + WantRemoved bool `json:"want-removed,omitempty"` + Sent string `json:"sent,omitempty"` + SentBuild string `json:"sent-build,omitempty"` + Rule int `json:"rule,omitempty"` + Unexplained bool `json:"unexplained,omitempty"` + Words string `json:"words,omitempty"` +} + +func (d Difference) check() error { + if d.WantRemoved && d.Want != "" { + return fmt.Errorf("delivery: a difference with a removed want names no wanted fingerprint") + } + if d.Want != "" { + if err := checkFingerprint(d.Want, "a difference's want"); err != nil { + return err + } + } + if d.Sent != "" { + if err := checkFingerprint(d.Sent, "a difference's sent fingerprint"); err != nil { + return err + } + } + if !d.WantRemoved && d.Want == d.Sent && (d.Want != "" || d.SentBuild == "") { + return fmt.Errorf("delivery: a difference whose want and send are both %q is no difference", d.Want) + } + if d.Unexplained { + if d.Rule != 0 { + return fmt.Errorf("delivery: an unexplained difference names no rule") + } + return nil + } + return checkRule(d.Rule, "a difference") +} + +// EncodeWant is w as JSON at Version; it refuses a fingerprint not of the new form, a removed want naming one, and a +// want held back without its rule. +func EncodeWant(w Want) ([]byte, error) { + if err := w.check(); err != nil { + return nil, err + } + w.V = Version + return json.Marshal(w) +} + +// DecodeWant reads a want; it refuses an unknown version (ErrUnknownVersion) and what EncodeWant refuses. +func DecodeWant(data []byte) (Want, error) { + var w Want + if err := decode(data, &w); err != nil { + return Want{}, err + } + if err := w.check(); err != nil { + return Want{}, err + } + return w, nil +} + +// SameWant says whether two wants are the same value, so the assigner writes only when they differ. +func SameWant(a, b Want) bool { return a == b } + +// EncodeHeld is h as JSON at Version; it refuses a node name a key refuses. +func EncodeHeld(h Held) ([]byte, error) { + if err := h.check(); err != nil { + return nil, err + } + h.V = Version + return json.Marshal(h) +} + +// DecodeHeld reads a hold; it refuses an unknown version (ErrUnknownVersion) and what EncodeHeld refuses. +func DecodeHeld(data []byte) (Held, error) { + var h Held + if err := decode(data, &h); err != nil { + return Held{}, err + } + if err := h.check(); err != nil { + return Held{}, err + } + return h, nil +} + +// SameHeld says whether two holds are the same value. +func SameHeld(a, b Held) bool { return a == b } + +// EncodeDifference is d as JSON at Version; it refuses a difference with neither a rule nor unexplained, one with both, +// a rule outside the six, and a want equal to the send. +func EncodeDifference(d Difference) ([]byte, error) { + if err := d.check(); err != nil { + return nil, err + } + d.V = Version + return json.Marshal(d) +} + +// DecodeDifference reads a difference; it refuses an unknown version (ErrUnknownVersion) and what EncodeDifference +// refuses. +func DecodeDifference(data []byte) (Difference, error) { + var d Difference + if err := decode(data, &d); err != nil { + return Difference{}, err + } + if err := d.check(); err != nil { + return Difference{}, err + } + return d, nil +} + +// SameDifference says whether two differences are the same value. +func SameDifference(a, b Difference) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/assigner_test.go b/go/delivery/assigner_test.go new file mode 100644 index 0000000..84379df --- /dev/null +++ b/go/delivery/assigner_test.go @@ -0,0 +1,157 @@ +package delivery + +import ( + "errors" + "strings" + "testing" +) + +// 1297/B1a: a want key round-trips through its parser; a held key, a have key and a node's own key are refused, +// naming the key. +func TestWantKey(t *testing.T) { + key, err := WantKey("ace", "mesh-mail") + if err != nil || key != "want.ace.mesh-mail" { + t.Fatalf("WantKey = %q, %v", key, err) + } + node, module, err := ParseWantKey(key) + if err != nil || node != "ace" || module != "mesh-mail" { + t.Errorf("ParseWantKey = %q, %q, %v", node, module, err) + } + for _, n := range [][2]string{{"ace", "node"}, {"a.b", "m"}, {"ace", ""}, {"", "m"}, {"ace", "m*"}} { + if _, err := WantKey(n[0], n[1]); err == nil { + t.Errorf("WantKey(%q, %q) accepted", n[0], n[1]) + } + } + held, _ := HeldKey("mesh-mail", fp2of('a')) + for _, bad := range []string{"ace.mesh-mail", "want.ace.node", "want.ace", "want.ace.m.x", held, "wants.ace.m", ""} { + if _, _, err := ParseWantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseWantKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1297/B1a: a held key round-trips; a want key, a builder build key and a fingerprint not of the new form are refused. +func TestHeldKey(t *testing.T) { + fp := fp2of('b') + key, err := HeldKey("mesh-mail", fp) + if err != nil || key != "held.mesh-mail."+strings.Repeat("b", 64) { + t.Fatalf("HeldKey = %q, %v", key, err) + } + module, back, err := ParseHeldKey(key) + if err != nil || module != "mesh-mail" || back != fp { + t.Errorf("ParseHeldKey = %q, %q, %v", module, back, err) + } + src1, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"}) + if _, err := HeldKey("mesh-mail", src1); err == nil { + t.Errorf("a src1 fingerprint was held") + } + build, _ := BuilderBuildKey("mesh-mail", fp) + for _, bad := range []string{build, "want.ace.mesh-mail", "held.mesh-mail.abc", "held." + strings.Repeat("b", 64), ""} { + if _, _, err := ParseHeldKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseHeldKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1297/B1a: a difference key round-trips; a want or held key and a node's own key are refused. +func TestDifferenceKey(t *testing.T) { + key, err := DifferenceKey("g14", "mesh-mail") + if err != nil || key != "g14.mesh-mail" { + t.Fatalf("DifferenceKey = %q, %v", key, err) + } + node, module, err := ParseDifferenceKey(key) + if err != nil || node != "g14" || module != "mesh-mail" { + t.Errorf("ParseDifferenceKey = %q, %q, %v", node, module, err) + } + held, _ := HeldKey("mesh-mail", fp2of('a')) + for _, bad := range []string{"want.g14.mesh-mail", held, "g14.node", "g14", ""} { + if _, _, err := ParseDifferenceKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseDifferenceKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1297/B1a: wants, holds and differences round-trip through their encoders unchanged, at Version. +func TestAssignerValuesRoundTrip(t *testing.T) { + wants := []Want{ + {FP: fp2of('a')}, + {FP: fp2of('a'), Newest: fp2of('b'), Rule: RuleCanary, Words: "the canary ace does not run it healthy yet"}, + {Removed: true, Words: "the module left g14"}, + } + for _, w := range wants { + data, err := EncodeWant(w) + if err != nil { + t.Fatalf("EncodeWant(%+v): %v", w, err) + } + got, err := DecodeWant(data) + w.V = Version + if err != nil || !SameWant(got, w) { + t.Errorf("want %+v came back %+v, %v", w, got, err) + } + } + h := Held{Node: "ace", Words: "its health check failed: connection refused"} + data, err := EncodeHeld(h) + if err != nil { + t.Fatal(err) + } + gotH, err := DecodeHeld(data) + h.V = Version + if err != nil || !SameHeld(gotH, h) { + t.Errorf("held came back %+v, %v", gotH, err) + } + diffs := []Difference{ + {Want: fp2of('a'), Sent: fp2of('b'), Rule: RuleCanary}, + {Sent: fp2of('b'), Rule: RuleBuildDone}, + {WantRemoved: true, Sent: fp2of('b'), Unexplained: true}, + {SentBuild: "build-17", Rule: RuleGrant}, + } + for _, d := range diffs { + data, err := EncodeDifference(d) + if err != nil { + t.Fatalf("EncodeDifference(%+v): %v", d, err) + } + got, err := DecodeDifference(data) + d.V = Version + if err != nil || !SameDifference(got, d) { + t.Errorf("difference %+v came back %+v, %v", d, got, err) + } + } +} + +// 1297/B1a: what a want, a hold and a difference may not say is refused on encode and on decode. +func TestAssignerValuesRefused(t *testing.T) { + src1, _ := SourceFingerprint(SourceInputs{Module: "m", Tree: "t"}) + for _, w := range []Want{ + {}, + {FP: src1}, + {Removed: true, FP: fp2of('a')}, + {FP: fp2of('a'), Newest: fp2of('b')}, + {FP: fp2of('a'), Newest: fp2of('a'), Rule: 3}, + {FP: fp2of('a'), Newest: fp2of('b'), Rule: 7}, + } { + if _, err := EncodeWant(w); err == nil { + t.Errorf("EncodeWant(%+v) accepted", w) + } + } + if _, err := EncodeHeld(Held{Node: "a.b"}); err == nil { + t.Errorf("a held naming a dotted node was accepted") + } + for _, d := range []Difference{ + {Want: fp2of('a'), Sent: fp2of('b')}, + {Want: fp2of('a'), Sent: fp2of('b'), Rule: 3, Unexplained: true}, + {Want: fp2of('a'), Sent: fp2of('a'), Rule: 3}, + {}, + {Want: fp2of('a'), Sent: fp2of('b'), Rule: 9}, + {WantRemoved: true, Want: fp2of('a'), Rule: 1}, + } { + if _, err := EncodeDifference(d); err == nil { + t.Errorf("EncodeDifference(%+v) accepted", d) + } + } + if _, err := DecodeWant([]byte(`{"v":2,"fp":"` + fp2of('a') + `"}`)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("a want of version 2 was read: %v", err) + } + if _, err := DecodeDifference([]byte(`{"v":1,"want":"` + fp2of('a') + `","sent":"` + fp2of('b') + `"}`)); err == nil { + t.Errorf("a difference naming no rule was read") + } +} From 687d715e99af5b1102279ab96bf29359bcb4a8e7 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:11:34 +0200 Subject: [PATCH 2/5] Lay out the controller's grants and the assigner's composition verdict under the checker's key, so the assigner's rule 6 and the checker's composition read one layout (issue 1297 subtask B1b, issue 1394; ADR 0528 clauses 2 and 5) --- go/delivery/assigner.go | 121 +++++++++++++++++++++++++++++++++++ go/delivery/assigner_test.go | 108 +++++++++++++++++++++++++++++++ go/delivery/grants.go | 75 ++++++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 go/delivery/grants.go diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go index 4d6f5c3..46560f2 100644 --- a/go/delivery/assigner.go +++ b/go/delivery/assigner.go @@ -285,3 +285,124 @@ func DecodeDifference(data []byte) (Difference, error) { // SameDifference says whether two differences are the same value. func SameDifference(a, b Difference) bool { return reflect.DeepEqual(a, b) } + +// The assigner's composition check (novox/hq ADR 0446 clauses 1 to 5, ADR 0528 clause 5, ADR 0482 clauses 3 and 9): for +// each group the checker judges, the assigner composes the machines the change touches and writes its verdict under +// the checker's key of that check, `.` (CheckerVerdictKey's form), in its bucket +// compositions. The checker watches that key and reads it into its verdict's composition (Composition); a key not yet +// written is a composition still running. +const ( + // AssignerCompositionsBucket is the assigner's bucket of composition verdicts. + AssignerCompositionsBucket = AssignerModule + "_compositions" + // AssignerCompositionsHistory is the revisions the bucket keeps per key. + AssignerCompositionsHistory = 5 + // CompositionWaits is the state of a composition that waits for what it composes from (ADR 0446 clause 3: the + // indexer holds no candidates for the group yet), its Words saying what; it is no verdict. + CompositionWaits = "waits" + // CompositionRebuildWarning is the most modules a merge may rebuild before the composition warns (ADR 0446 clause 1). + CompositionRebuildWarning = 12 +) + +// CompositionKey is the key, in AssignerCompositionsBucket, of the composition of a group's check at its heads: the +// checker's verdict key of the same check (CheckerVerdictKey). +func CompositionKey(group, headsHash string) (string, error) { + return CheckerVerdictKey(group, headsHash) +} + +// ParseCompositionKey is the inverse of CompositionKey; it refuses a want, held or difference key, naming it. +func ParseCompositionKey(key string) (group, headsHash string, err error) { + if group, headsHash, err = ParseCheckerVerdictKey(key); err != nil { + return "", "", fmt.Errorf("delivery: %q is not a composition key, .: %w", key, err) + } + return group, headsHash, nil +} + +// CompositionVerdict is the value of a composition key: the group and its heads hash, the State (pass, fail or waits), +// the Words of a refusal or of what it waits for, the mesh-sdk commit it composed with (the assigner's own, or the +// group's mesh-sdk member's head, ADR 0482 clause 3), the machines composed, the first machine that failed, the +// warnings that fail nothing (more than twelve modules rebuilt; what was already broken before the change, ADR 0446 +// clause 1), and the time it took in integer milliseconds. +type CompositionVerdict struct { + V int `json:"v"` + Group string `json:"group"` + Heads string `json:"heads"` + State string `json:"state"` + Words string `json:"words,omitempty"` + SDK string `json:"sdk,omitempty"` + Machines []string `json:"machines,omitempty"` + Machine string `json:"machine,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Took int64 `json:"took,omitempty"` +} + +func (c CompositionVerdict) check() error { + if c.Group == "" { + return fmt.Errorf("delivery: a composition names no group") + } + if !isHex12(c.Heads) { + return fmt.Errorf("delivery: the composition's heads hash %q is not 12 lowercase hex digits", c.Heads) + } + if c.Took < 0 { + return fmt.Errorf("delivery: the composition's time is negative") + } + switch c.State { + case CompositionPass: + if c.SDK == "" { + return fmt.Errorf("delivery: a composition that passed names no mesh-sdk commit") + } + if c.Machine != "" || c.Words != "" { + return fmt.Errorf("delivery: a composition that passed names no failure") + } + case CompositionFail: + if c.Words == "" { + return fmt.Errorf("delivery: a composition that failed gives no words") + } + if c.SDK == "" { + return fmt.Errorf("delivery: a composition that failed names no mesh-sdk commit") + } + case CompositionWaits: + if c.Words == "" { + return fmt.Errorf("delivery: a composition that waits does not say for what") + } + if c.Machine != "" || len(c.Machines) > 0 { + return fmt.Errorf("delivery: a composition that waits composed no machine") + } + default: + return fmt.Errorf("delivery: the composition state %q is not %s, %s or %s", c.State, CompositionPass, CompositionFail, CompositionWaits) + } + return nil +} + +// Composition is what the checker writes into its verdict from c: nil while c waits (the check is still running). +func (c CompositionVerdict) Composition() *Composition { + if c.State == CompositionWaits { + return nil + } + return &Composition{State: c.State, Words: c.Words, SDK: c.SDK, Machines: c.Machines} +} + +// EncodeCompositionVerdict is c as JSON at Version; it refuses an unknown state, a pass or fail naming no mesh-sdk +// commit, a fail without words, a pass naming a failure, and a wait that composed a machine. +func EncodeCompositionVerdict(c CompositionVerdict) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = Version + return json.Marshal(c) +} + +// DecodeCompositionVerdict reads a composition; it refuses an unknown version (ErrUnknownVersion) and what +// EncodeCompositionVerdict refuses. +func DecodeCompositionVerdict(data []byte) (CompositionVerdict, error) { + var c CompositionVerdict + if err := decode(data, &c); err != nil { + return CompositionVerdict{}, err + } + if err := c.check(); err != nil { + return CompositionVerdict{}, err + } + return c, nil +} + +// SameCompositionVerdict says whether two compositions are the same value. +func SameCompositionVerdict(a, b CompositionVerdict) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/assigner_test.go b/go/delivery/assigner_test.go index 84379df..3a2d5cc 100644 --- a/go/delivery/assigner_test.go +++ b/go/delivery/assigner_test.go @@ -155,3 +155,111 @@ func TestAssignerValuesRefused(t *testing.T) { t.Errorf("a difference naming no rule was read") } } + +// 1297/B1b: a composition key is the checker's verdict key of the same check, and round-trips; a want, held or +// difference key is refused, naming it. +func TestCompositionKey(t *testing.T) { + key, err := CompositionKey("feat/r4g", "0123456789ab") + verdict, _ := CheckerVerdictKey("feat/r4g", "0123456789ab") + if err != nil || key != verdict { + t.Fatalf("CompositionKey = %q, %v; the checker's key is %q", key, err, verdict) + } + group, heads, err := ParseCompositionKey(key) + if err != nil || group != "feat/r4g" || heads != "0123456789ab" { + t.Errorf("ParseCompositionKey = %q, %q, %v", group, heads, err) + } + held, _ := HeldKey("mesh-mail", fp2of('a')) + for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", "g.0123456789AB", ""} { + if _, _, err := ParseCompositionKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseCompositionKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1297/B1b: a composition in each state round-trips; the checker's composition of a pass or fail carries its state, +// words, mesh-sdk commit and machines, and a wait gives none. +func TestCompositionVerdictRoundTrip(t *testing.T) { + for _, c := range []CompositionVerdict{ + {Group: "g", Heads: "0123456789ab", State: CompositionPass, SDK: "7aacbc1", Machines: []string{"ace"}, Took: 120}, + {Group: "g", Heads: "0123456789ab", State: CompositionFail, SDK: "7aacbc1", Machines: []string{"ace", "g14"}, Machine: "g14", + Words: "g14 runs mesh-mail, which the change removes from its source", Warnings: []string{"the merge rebuilds 13 modules"}}, + {Group: "g", Heads: "0123456789ab", State: CompositionWaits, Words: "the indexer holds no candidates for the group yet"}, + } { + data, err := EncodeCompositionVerdict(c) + if err != nil { + t.Fatalf("EncodeCompositionVerdict(%+v): %v", c, err) + } + got, err := DecodeCompositionVerdict(data) + c.V = Version + if err != nil || !SameCompositionVerdict(got, c) { + t.Errorf("composition %+v came back %+v, %v", c, got, err) + } + comp := got.Composition() + if c.State == CompositionWaits { + if comp != nil { + t.Errorf("a wait gave the checker a composition %+v", comp) + } + continue + } + if comp == nil || comp.State != c.State || comp.SDK != c.SDK || comp.Words != c.Words || len(comp.Machines) != len(c.Machines) { + t.Errorf("the checker's composition of %+v is %+v", c, comp) + } + if err := comp.check(); err != nil { + t.Errorf("the checker's composition of %+v is refused by the verdict: %v", c, err) + } + } +} + +// 1297/B1b: what a composition may not say is refused. +func TestCompositionVerdictRefused(t *testing.T) { + for _, c := range []CompositionVerdict{ + {Heads: "0123456789ab", State: CompositionPass, SDK: "x"}, + {Group: "g", Heads: "0123", State: CompositionPass, SDK: "x"}, + {Group: "g", Heads: "0123456789ab", State: CompositionPass}, + {Group: "g", Heads: "0123456789ab", State: CompositionPass, SDK: "x", Machine: "ace"}, + {Group: "g", Heads: "0123456789ab", State: CompositionFail, SDK: "x"}, + {Group: "g", Heads: "0123456789ab", State: CompositionFail, Words: "w"}, + {Group: "g", Heads: "0123456789ab", State: CompositionWaits}, + {Group: "g", Heads: "0123456789ab", State: CompositionWaits, Words: "w", Machines: []string{"ace"}}, + {Group: "g", Heads: "0123456789ab", State: CompositionNotRun}, + {Group: "g", Heads: "0123456789ab", State: CompositionPass, SDK: "x", Took: -1}, + } { + if _, err := EncodeCompositionVerdict(c); err == nil { + t.Errorf("EncodeCompositionVerdict(%+v) accepted", c) + } + } +} + +// 1394/B1b: a grant key round-trips; a want or held key is refused, naming it; a grant round-trips and one naming an +// empty account is refused. +func TestControllerGrant(t *testing.T) { + fp := fp2of('c') + key, err := ControllerGrantKey("mesh-delivery-assigner", fp) + if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) { + t.Fatalf("ControllerGrantKey = %q, %v", key, err) + } + module, back, err := ParseControllerGrantKey(key) + if err != nil || module != "mesh-delivery-assigner" || back != fp { + t.Errorf("ParseControllerGrantKey = %q, %q, %v", module, back, err) + } + held, _ := HeldKey("mesh-mail", fp) + for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", ""} { + if _, _, err := ParseControllerGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseControllerGrantKey(%q) = %v, want a refusal naming it", bad, err) + } + } + for _, g := range []ControllerGrant{{}, {Accounts: []string{"UABC", "UDEF"}}} { + data, err := EncodeControllerGrant(g) + if err != nil { + t.Fatal(err) + } + got, err := DecodeControllerGrant(data) + g.V = Version + if err != nil || !SameControllerGrant(got, g) { + t.Errorf("grant %+v came back %+v, %v", g, got, err) + } + } + if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil { + t.Errorf("a grant naming an empty account was accepted") + } +} diff --git a/go/delivery/grants.go b/go/delivery/grants.go new file mode 100644 index 0000000..39e602c --- /dev/null +++ b/go/delivery/grants.go @@ -0,0 +1,75 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// The controller's grants (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6): when the controller has issued a module's +// bus credential for a fingerprint, it writes `.` in its bucket grants. The assigner reads +// it: a node's want moves to a fingerprint only once its grant exists. Only the controller writes the bucket. +// +// The names say Controller, since the catalogue's GrantKey (catalogue/streams.go) is a stream's grant pattern, unrelated. +const ( + // ControllerGrantsBucket is the controller's bucket of issued credentials by fingerprint. + ControllerGrantsBucket = "mesh-controller_grants" + // ControllerGrantsHistory is the revisions the bucket keeps per key. + ControllerGrantsHistory = 5 +) + +// ControllerGrantKey is the key, in ControllerGrantsBucket, of a module's credential issued for a fingerprint: +// `.`, the hex being the fingerprint's key part (FingerprintKeyPart). +func ControllerGrantKey(module, fingerprint string) (string, error) { + return BuilderBuildKey(module, fingerprint) +} + +// ParseControllerGrantKey is the inverse of ControllerGrantKey; it refuses a want or held key, naming it. +func ParseControllerGrantKey(key string) (module, fingerprint string, err error) { + if module, fingerprint, err = ParseBuilderBuildKey(key); err != nil { + return "", "", fmt.Errorf("delivery: %q is not a grant key, .: %w", key, err) + } + return module, fingerprint, nil +} + +// ControllerGrant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued +// for that fingerprint, none for a module whose grants the tool runner's account carries, so a reissue for another +// account is a new revision. The entry's own time says when it was issued. +type ControllerGrant struct { + V int `json:"v"` + Accounts []string `json:"accounts,omitempty"` +} + +func (g ControllerGrant) check() error { + for _, a := range g.Accounts { + if strings.TrimSpace(a) == "" || strings.ContainsAny(a, " \t\n") { + return fmt.Errorf("delivery: a grant names an empty or spaced account %q", a) + } + } + return nil +} + +// EncodeControllerGrant is g as JSON at Version; it refuses an empty or spaced account. +func EncodeControllerGrant(g ControllerGrant) ([]byte, error) { + if err := g.check(); err != nil { + return nil, err + } + g.V = Version + return json.Marshal(g) +} + +// DecodeControllerGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account. +func DecodeControllerGrant(data []byte) (ControllerGrant, error) { + var g ControllerGrant + if err := decode(data, &g); err != nil { + return ControllerGrant{}, err + } + if err := g.check(); err != nil { + return ControllerGrant{}, err + } + return g, nil +} + +// SameControllerGrant says whether two grants are the same value. +func SameControllerGrant(a, b ControllerGrant) bool { return reflect.DeepEqual(a, b) } From d9384bcb239a84502d1495c24c7227b67f579e49 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:12:40 +0200 Subject: [PATCH 3/5] Lay out the asks the view may answer and the view's answer key, refusing the merge's ask, so the view and the teller answer only what needs no proof (issue 1297 subtask B1c; ADR 0528 clause 6, ADR 0437 clause 10) --- go/delivery/view_answers.go | 184 +++++++++++++++++++++++++++++++ go/delivery/view_answers_test.go | 91 +++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 go/delivery/view_answers.go create mode 100644 go/delivery/view_answers_test.go diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go new file mode 100644 index 0000000..e7524a9 --- /dev/null +++ b/go/delivery/view_answers.go @@ -0,0 +1,184 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "strings" +) + +// The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The +// gate's merge ask (`merge..`, MergeAskKey) needs proof of who answered (ADR 0421 §1), so it is +// answered on the phone alone until issue 953 closes. The assigner's asks need no such proof, and the view may +// answer them: the hold of a fingerprint whose health check failed (`hold..`: release it or +// keep it held) and the restore of a data module set back (`restore...`: restore and +// lose newer writes, or leave it stopped, ADR 0429 §6). The assigner writes them in its bucket asks; the view writes +// its answer `.view` in its bucket answers, and the teller, which alone fills an ask's answer, takes it from +// there as it takes a warrant from the phone. +const ( + // ViewModule is the module of the view part. + ViewModule = "mesh-delivery-view" + // ViewAnswersBucket is the view's bucket of answers, the only state the view writes. + ViewAnswersBucket = ViewModule + "_answers" + // ViewAnswersHistory is the revisions the bucket keeps per key. + ViewAnswersHistory = 5 + // AssignerAsksBucket is the assigner's bucket of asks that need no proof of who answered. + AssignerAsksBucket = AssignerModule + "_asks" + // AssignerAsksHistory is the revisions the bucket keeps per key. + AssignerAsksHistory = 5 + + // AskKindHold is the kind of the assigner's ask about a held fingerprint. + AskKindHold = "hold" + // AskKindRestore is the kind of the assigner's ask about a data module set back. + AskKindRestore = "restore" + + viewWord = "view" +) + +// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's +// Module and fingerprint FP, a restore's Node, Module and FP. +type AskKey struct { + Kind string + Group string + Hash string + Node string + Module string + FP string +} + +// AskNeedsProof says whether an ask of this kind must prove who answered it, so the view may not answer it: the +// merge's Approve does (ADR 0421 §1, issue 953), the assigner's hold and restore do not. An unknown kind is refused. +func AskNeedsProof(kind string) (bool, error) { + switch kind { + case AskKindMerge: + return true, nil + case AskKindHold, AskKindRestore: + return false, nil + } + return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s or %s", kind, AskKindMerge, AskKindHold, AskKindRestore) +} + +// HoldAskKey is the key, in AssignerAsksBucket, of the ask about a module's held fingerprint: `hold..`. +func HoldAskKey(module, fingerprint string) (string, error) { + key, err := BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindHold + "." + key, nil +} + +// RestoreAskKey is the key, in AssignerAsksBucket, of the ask about a data module set back on a node from a +// fingerprint: `restore...`. +func RestoreAskKey(node, module, fingerprint string) (string, error) { + if err := checkName("node", node); err != nil { + return "", err + } + key, err := BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindRestore + "." + node + "." + key, nil +} + +// ParseAnyAskKey reads an ask key of any kind (MergeAskKey, HoldAskKey, RestoreAskKey); it refuses an unknown kind +// and a key whose parts do not fit its kind, naming the key. +func ParseAnyAskKey(key string) (AskKey, error) { + kind, rest, _ := strings.Cut(key, ".") + refused := func(err error) (AskKey, error) { + return AskKey{}, fmt.Errorf("delivery: %q is not an ask key: %w", key, err) + } + switch kind { + case AskKindMerge: + _, group, hash, err := ParseAskKey(key) + if err != nil { + return AskKey{}, err + } + return AskKey{Kind: kind, Group: group, Hash: hash}, nil + case AskKindHold: + module, fp, err := ParseBuilderBuildKey(rest) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Module: module, FP: fp}, nil + case AskKindRestore: + node, build, ok := strings.Cut(rest, ".") + if !ok { + return refused(fmt.Errorf("a restore ask is restore...")) + } + if err := checkName("node", node); err != nil { + return refused(err) + } + module, fp, err := ParseBuilderBuildKey(build) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil + } + _, err := AskNeedsProof(kind) + return refused(err) +} + +// ViewAnswerKey is the key, in ViewAnswersBucket, of the view's answer to the ask at askKey: `.view`. An ask +// that needs proof of who answered is refused with its reason: it is answered on the phone (issue 953). +func ViewAnswerKey(askKey string) (string, error) { + ask, err := ParseAnyAskKey(askKey) + if err != nil { + return "", err + } + if proof, _ := AskNeedsProof(ask.Kind); proof { + return "", fmt.Errorf("delivery: the ask %s needs proof of who answered: answer it on the phone (issue 953)", askKey) + } + return askKey + "." + viewWord, nil +} + +// ParseViewAnswerKey is the inverse of ViewAnswerKey: the ask's key. It refuses a key without `.view`, an ask key of +// a kind that needs proof, and a key of another bucket, naming it. +func ParseViewAnswerKey(key string) (askKey string, err error) { + askKey, ok := strings.CutSuffix(key, "."+viewWord) + if !ok { + return "", fmt.Errorf("delivery: %q is not a view answer key, .view", key) + } + if _, err := ViewAnswerKey(askKey); err != nil { + return "", fmt.Errorf("delivery: %q is not a view answer key: %w", key, err) + } + return askKey, nil +} + +// ViewAnswer is the view's answer to an ask: the option the operator chose and their words. The entry's own time +// says when. +type ViewAnswer struct { + V int `json:"v"` + Option string `json:"option"` + Words string `json:"words,omitempty"` +} + +func (a ViewAnswer) check() error { + if strings.TrimSpace(a.Option) == "" { + return fmt.Errorf("delivery: a view answer names no option") + } + return nil +} + +// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no option. +func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeViewAnswer reads a view answer; it refuses an unknown version (ErrUnknownVersion) and an answer naming no +// option. +func DecodeViewAnswer(data []byte) (ViewAnswer, error) { + var a ViewAnswer + if err := decode(data, &a); err != nil { + return ViewAnswer{}, err + } + if err := a.check(); err != nil { + return ViewAnswer{}, err + } + return a, nil +} + +// SameViewAnswer says whether two view answers are the same value. +func SameViewAnswer(a, b ViewAnswer) bool { return a == b } diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go new file mode 100644 index 0000000..d3bca5d --- /dev/null +++ b/go/delivery/view_answers_test.go @@ -0,0 +1,91 @@ +package delivery + +import ( + "strings" + "testing" +) + +// 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an +// unknown kind is refused. +func TestAskNeedsProof(t *testing.T) { + for kind, want := range map[string]bool{AskKindMerge: true, AskKindHold: false, AskKindRestore: false} { + if got, err := AskNeedsProof(kind); err != nil || got != want { + t.Errorf("AskNeedsProof(%q) = %v, %v; want %v", kind, got, err, want) + } + } + if _, err := AskNeedsProof("approve"); err == nil { + t.Errorf("an unknown kind was read") + } +} + +// 1297/B1c: each kind of ask key round-trips through ParseAnyAskKey; a key that fits no kind is refused, naming it. +func TestParseAnyAskKey(t *testing.T) { + fp := fp2of('d') + merge, _ := MergeAskKey("feat/r4g", []Head{{Repository: "novox/mesh-sdk", Number: 1, Head: strings.Repeat("1", 40)}}) + hold, err := HoldAskKey("mesh-mail", fp) + if err != nil || hold != "hold.mesh-mail."+strings.Repeat("d", 64) { + t.Fatalf("HoldAskKey = %q, %v", hold, err) + } + restore, err := RestoreAskKey("ace", "mesh-mail", fp) + if err != nil || restore != "restore.ace.mesh-mail."+strings.Repeat("d", 64) { + t.Fatalf("RestoreAskKey = %q, %v", restore, err) + } + for key, want := range map[string]AskKey{ + hold: {Kind: AskKindHold, Module: "mesh-mail", FP: fp}, + restore: {Kind: AskKindRestore, Node: "ace", Module: "mesh-mail", FP: fp}, + } { + if got, err := ParseAnyAskKey(key); err != nil || got != want { + t.Errorf("ParseAnyAskKey(%q) = %+v, %v", key, got, err) + } + } + if got, err := ParseAnyAskKey(merge); err != nil || got.Kind != AskKindMerge || got.Group != "feat/r4g" || len(got.Hash) != 12 { + t.Errorf("ParseAnyAskKey(%q) = %+v, %v", merge, got, err) + } + for _, bad := range []string{"want.ace.mesh-mail", "hold.mesh-mail", "restore.mesh-mail." + strings.Repeat("d", 64), "approve.x.y", "restore.a.b.c.d", ""} { + if _, err := ParseAnyAskKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseAnyAskKey(%q) = %v, want a refusal naming it", bad, err) + } + } + if _, err := RestoreAskKey("a.b", "m", fp); err == nil { + t.Errorf("a restore ask on a dotted node was keyed") + } +} + +// 1297/B1c: the view's answer key is the ask's key and `.view`, and round-trips; the merge's ask is refused with its +// reason (issue 953); another bucket's key is refused, naming it. +func TestViewAnswerKey(t *testing.T) { + hold, _ := HoldAskKey("mesh-mail", fp2of('e')) + key, err := ViewAnswerKey(hold) + if err != nil || key != hold+".view" { + t.Fatalf("ViewAnswerKey = %q, %v", key, err) + } + if back, err := ParseViewAnswerKey(key); err != nil || back != hold { + t.Errorf("ParseViewAnswerKey = %q, %v", back, err) + } + merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) + if _, err := ViewAnswerKey(merge); err == nil || !strings.Contains(err.Error(), "phone") || !strings.Contains(err.Error(), "953") { + t.Errorf("the merge's ask was answerable in the view, or its reason is missing: %v", err) + } + for _, bad := range []string{hold, merge + ".view", "want.ace.mesh-mail.view", "ace.mesh-mail", ""} { + if _, err := ParseViewAnswerKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseViewAnswerKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1297/B1c: a view answer round-trips; one naming no option is refused. +func TestViewAnswerRoundTrip(t *testing.T) { + a := ViewAnswer{Option: "release", Words: "fixed by #12"} + data, err := EncodeViewAnswer(a) + if err != nil { + t.Fatal(err) + } + got, err := DecodeViewAnswer(data) + a.V = Version + if err != nil || !SameViewAnswer(got, a) { + t.Errorf("view answer came back %+v, %v", got, err) + } + if _, err := EncodeViewAnswer(ViewAnswer{Option: " "}); err == nil { + t.Errorf("a view answer naming no option was accepted") + } +} From f625d98713e478e37962e59e4421213770735d34 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:20:58 +0200 Subject: [PATCH 4/5] Lay out the assigner's ask value and name the asking a view answer is for, so a stale answer never fills a later asking of the same key (issue 1297 subtask B1c, for the view lane's B9 and B10) --- go/delivery/view_answers.go | 85 ++++++++++++++++++++++++++++++-- go/delivery/view_answers_test.go | 76 ++++++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 7 deletions(-) diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go index e7524a9..d8f15a3 100644 --- a/go/delivery/view_answers.go +++ b/go/delivery/view_answers.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" ) // The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The @@ -143,22 +145,97 @@ func ParseViewAnswerKey(key string) (askKey string, err error) { return askKey, nil } -// ViewAnswer is the view's answer to an ask: the option the operator chose and their words. The entry's own time -// says when. +// AssignerAsk is the value of a key in AssignerAsksBucket: the router's ask whole, as the assigner composes it, so the +// teller publishes it unchanged. Asking counts the times the assigner wrote the ask at this key, as the gate's Ask +// does: 0 or 1 for the first, n for the n-th, and the ask's id is AskingID(key, Asking), so each asking has its own id +// and an answer to an earlier asking is never taken for a later one. +type AssignerAsk struct { + V int `json:"v"` + Ask asks.Ask `json:"ask"` + Asking int `json:"asking,omitempty"` +} + +func (a AssignerAsk) check(key string) error { + ask, err := ParseAnyAskKey(key) + if err != nil { + return err + } + if proof, _ := AskNeedsProof(ask.Kind); proof { + return fmt.Errorf("delivery: the assigner asks no %s ask: it needs proof of who answered", ask.Kind) + } + if a.Asking < 0 { + return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking) + } + if want := AskingID(key, a.Asking); a.Ask.ID != want { + return fmt.Errorf("delivery: the ask's id %q is not %q, the id its key and asking %d give", a.Ask.ID, want, a.Asking) + } + if len(a.Ask.Options) == 0 { + return fmt.Errorf("delivery: the ask has no option") + } + return nil +} + +// EncodeAssignerAsk is a, the ask at key, as JSON at Version; it refuses a key of a kind that needs proof or of no +// kind, an id other than the one the key and the asking give, and an ask with no option. +func EncodeAssignerAsk(key string, a AssignerAsk) ([]byte, error) { + if err := a.check(key); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAssignerAsk reads the ask at key; it refuses an unknown version (ErrUnknownVersion) and what EncodeAssignerAsk +// refuses. +func DecodeAssignerAsk(key string, data []byte) (AssignerAsk, error) { + var a AssignerAsk + if err := decode(data, &a); err != nil { + return AssignerAsk{}, err + } + if err := a.check(key); err != nil { + return AssignerAsk{}, err + } + return a, nil +} + +// SameAssignerAsk says whether two asks are the same value. +func SameAssignerAsk(a, b AssignerAsk) bool { + return a.V == b.V && a.Asking == b.Asking && a.Ask.Digest() == b.Ask.Digest() +} + +// ViewAnswer is the view's answer to an ask: the id of the asking it answers (the ask's id, AskingID of its key and +// asking), the option the operator chose and their words. The entry's own time says when. type ViewAnswer struct { V int `json:"v"` + Asking string `json:"asking"` Option string `json:"option"` Words string `json:"words,omitempty"` } func (a ViewAnswer) check() error { + if strings.TrimSpace(a.Asking) == "" { + return fmt.Errorf("delivery: a view answer names no asking") + } if strings.TrimSpace(a.Option) == "" { return fmt.Errorf("delivery: a view answer names no option") } return nil } -// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no option. +// ViewAnswerFits says whether the view's answer a answers the ask as it stands now: it refuses an answer to another +// asking of the ask (a stale answer to an earlier writing of the same key) and an option the ask does not offer, +// naming both. +func ViewAnswerFits(a ViewAnswer, ask AssignerAsk) error { + if a.Asking != ask.Ask.ID { + return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, ask.Ask.ID) + } + if _, ok := ask.Ask.Option(a.Option); !ok { + return fmt.Errorf("delivery: the view's answer chose %q, which the asking %s does not offer", a.Option, ask.Ask.ID) + } + return nil +} + +// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no asking or no option. func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { if err := a.check(); err != nil { return nil, err @@ -168,7 +245,7 @@ func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { } // DecodeViewAnswer reads a view answer; it refuses an unknown version (ErrUnknownVersion) and an answer naming no -// option. +// asking or no option. func DecodeViewAnswer(data []byte) (ViewAnswer, error) { var a ViewAnswer if err := decode(data, &a); err != nil { diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go index d3bca5d..c069e01 100644 --- a/go/delivery/view_answers_test.go +++ b/go/delivery/view_answers_test.go @@ -3,6 +3,9 @@ package delivery import ( "strings" "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" ) // 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an @@ -73,9 +76,9 @@ func TestViewAnswerKey(t *testing.T) { } } -// 1297/B1c: a view answer round-trips; one naming no option is refused. +// 1297/B1c: a view answer round-trips; one naming no asking or no option is refused. func TestViewAnswerRoundTrip(t *testing.T) { - a := ViewAnswer{Option: "release", Words: "fixed by #12"} + a := ViewAnswer{Asking: "hold-mesh-mail-aaaa-0123456789ab", Option: "release", Words: "fixed by #12"} data, err := EncodeViewAnswer(a) if err != nil { t.Fatal(err) @@ -85,7 +88,74 @@ func TestViewAnswerRoundTrip(t *testing.T) { if err != nil || !SameViewAnswer(got, a) { t.Errorf("view answer came back %+v, %v", got, err) } - if _, err := EncodeViewAnswer(ViewAnswer{Option: " "}); err == nil { + if _, err := EncodeViewAnswer(ViewAnswer{Asking: "x", Option: " "}); err == nil { t.Errorf("a view answer naming no option was accepted") } + if _, err := EncodeViewAnswer(ViewAnswer{Option: "release"}); err == nil { + t.Errorf("a view answer naming no asking was accepted") + } +} + +func holdAsk(t *testing.T, asking int) (string, AssignerAsk) { + t.Helper() + key, err := HoldAskKey("mesh-mail", fp2of('f')) + if err != nil { + t.Fatal(err) + } + return key, AssignerAsk{Asking: asking, Ask: asks.Ask{ID: AskingID(key, asking), Headline: "mesh-mail is held", Explanation: "its health failed", + Options: []asks.Option{{ID: "release", Label: "Release"}, {ID: "keep", Label: "Keep held"}}, Who: "operator", + Expires: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC), OnExpiry: "it stays held"}} +} + +// 1297/B1c: the assigner's ask round-trips at its key; an id another key or asking gives, an ask of a kind that needs +// proof and one with no option are refused. +func TestAssignerAskRoundTrip(t *testing.T) { + key, a := holdAsk(t, 2) + data, err := EncodeAssignerAsk(key, a) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(data), `{"v":1,"ask":`) { + t.Errorf("the ask is written as %s", data) + } + got, err := DecodeAssignerAsk(key, data) + a.V = Version + if err != nil || !SameAssignerAsk(got, a) { + t.Errorf("the ask came back %+v, %v", got, err) + } + other, _ := HoldAskKey("mesh-web", fp2of('f')) + if _, err := DecodeAssignerAsk(other, data); err == nil { + t.Errorf("an ask was read at another key") + } + stale := a + stale.Asking = 3 + if _, err := EncodeAssignerAsk(key, stale); err == nil { + t.Errorf("an ask whose id is another asking's was accepted") + } + merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) + if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: asks.Ask{ID: AskingID(merge, 0), Options: a.Ask.Options}}); err == nil { + t.Errorf("the assigner was let write a merge's ask") + } + none := a + none.Ask.Options = nil + if _, err := EncodeAssignerAsk(key, none); err == nil { + t.Errorf("an ask with no option was accepted") + } +} + +// 1297/B1c: a view answer fits only the asking it names: an answer to an earlier asking of the same key is refused, +// naming both, and so is an option the ask does not offer. +func TestViewAnswerFits(t *testing.T) { + key, first := holdAsk(t, 0) + _, second := holdAsk(t, 2) + answer := ViewAnswer{Asking: AskingID(key, 0), Option: "release"} + if err := ViewAnswerFits(answer, first); err != nil { + t.Errorf("the answer to the first asking does not fit it: %v", err) + } + if err := ViewAnswerFits(answer, second); err == nil || !strings.Contains(err.Error(), first.Ask.ID) || !strings.Contains(err.Error(), second.Ask.ID) { + t.Errorf("a stale answer fits the later asking, or the refusal names neither: %v", err) + } + if err := ViewAnswerFits(ViewAnswer{Asking: first.Ask.ID, Option: "restore"}, first); err == nil { + t.Errorf("an option the ask does not offer fits") + } } From 3e5d4c30ffa72e63599ec41d37f72fffddfd23ec Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:41:55 +0200 Subject: [PATCH 5/5] Let a composition not run say why, the assigner not running among it, and name the indexer's words for a removed module, so a check never waits for a composition nobody makes (issue 1297, B3d) --- go/delivery/checker.go | 10 ++++++++-- go/delivery/checker_test.go | 10 ++++++++++ go/delivery/source.go | 4 ++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/go/delivery/checker.go b/go/delivery/checker.go index 81c0dc9..679985b 100644 --- a/go/delivery/checker.go +++ b/go/delivery/checker.go @@ -96,10 +96,16 @@ type LevelResult struct { // The states of a Composition (ADR 0484 clause 6): the composition check is the assigner's, so until it delivers a // verdict says CompositionNotRun, and a line never says the composition passed on that. +// +// A composition not run may say why in its Words: AssignerNotRunning when no instance of the assigner is online, so a +// check never waits for a composition nobody is making (issue 1297). const ( CompositionNotRun = "not-run" CompositionPass = "pass" CompositionFail = "fail" + + // AssignerNotRunning is the Words of a composition not run because no instance of the assigner is online. + AssignerNotRunning = "the assigner is not running" ) // Composition is what the check of the machines' composition made of a verdict: its State (not-run, pass or fail), the @@ -156,8 +162,8 @@ func (c *Composition) check() error { } switch c.State { case CompositionNotRun: - if c.Words != "" || c.SDK != "" || len(c.Machines) > 0 { - return fmt.Errorf("delivery: the verdict's composition is not run and says nothing of words, the mesh-sdk commit or machines") + if c.SDK != "" || len(c.Machines) > 0 { + return fmt.Errorf("delivery: the verdict's composition is not run and names no mesh-sdk commit or machines") } case CompositionPass: if c.SDK == "" { diff --git a/go/delivery/checker_test.go b/go/delivery/checker_test.go index 296da52..22d4611 100644 --- a/go/delivery/checker_test.go +++ b/go/delivery/checker_test.go @@ -262,6 +262,16 @@ func TestVerdictCarriesSkippedCompositionAndOverBound(t *testing.T) { if _, err := EncodeVerdict(notRun); err != nil { t.Errorf("a composition not yet run refused: %v", err) } + // 1297: a composition not run says why when the assigner is not running, and round-trips with its words. + notRunning := passVerdict() + notRunning.Composition = &Composition{State: CompositionNotRun, Words: AssignerNotRunning} + data, err = EncodeVerdict(notRunning) + if err != nil { + t.Fatalf("a composition not run because the assigner is not running refused: %v", err) + } + if back, err := DecodeVerdict(data); err != nil || back.Composition.Words != AssignerNotRunning { + t.Errorf("its words came back as %+v, %v", back.Composition, err) + } failed := passVerdict() failed.State, failed.Failed = VerdictFail, FailedFirst{Machine: "ace", Words: "x"} failed.Composition = &Composition{State: CompositionFail, Words: "path /etc/x is owned by two modules", SDK: failed.SDK, Machines: []string{"ace"}} diff --git a/go/delivery/source.go b/go/delivery/source.go index 145b4e2..535f771 100644 --- a/go/delivery/source.go +++ b/go/delivery/source.go @@ -271,6 +271,10 @@ type CandidatePull struct { Fps map[string]CandidateModule `json:"fps,omitempty"` } +// CandidateNoneNoModule is the None2 of a candidate's module whose directory the merge preview names no manifest for: +// the change removes the module. The indexer writes it and the assigner reads it as a removal (issue 1297). +const CandidateNoneNoModule = "the merge preview names no manifest for the module's directory" + // CandidateModule is one touched module's new-form fingerprint FP2 (`src2:` and 64 hex digits), or None2, the reason // it has none; exactly one of them. Manifest is the digest of the module's manifest as read at the preview. type CandidateModule struct {