From 6d620f24b78eed61eea406789c59a3ec8cf17728 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 10:31:59 +0200 Subject: [PATCH 1/7] Give the source revision, candidates, modules and forge messages one layout the parts read (issue 1200) --- go/delivery/delivery.go | 5 + go/delivery/source.go | 407 +++++++++++++++++++++++++++++++++++++ go/delivery/source_test.go | 288 ++++++++++++++++++++++++++ 3 files changed, 700 insertions(+) create mode 100644 go/delivery/source.go create mode 100644 go/delivery/source_test.go diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index fd109ec..50931ed 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -18,6 +18,11 @@ // question 9 item 7): a writer writes a new version only once every reader's have shows a version that reads it. A // writer writes only when the new value differs from the one before (the Same functions). // +// **Source, candidates, modules and the forge** (issue 1200; ADR 0437 §5, release 3): the gate's bucket `source` with +// its one key `main` (every repository's merged head and the group that moved it), the indexer's `candidates.` +// and `modules.` (a module's source fingerprint, or none and why), and the forge bridge's messages named +// `forge...` (source.go). +// // Standard library only. package delivery diff --git a/go/delivery/source.go b/go/delivery/source.go new file mode 100644 index 0000000..533fb32 --- /dev/null +++ b/go/delivery/source.go @@ -0,0 +1,407 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" +) + +// The modules of the parts release 3 adds (novox/hq ADR 0437): their buckets are their own state, `_`. +const ( + GateModule = "mesh-delivery-gate" + IndexerModule = "mesh-delivery-indexer" + ForgeBridgeModule = "mesh-delivery-forge-bridge" + TellerModule = "mesh-delivery-teller" +) + +// The gate's source bucket and the indexer's candidates and modules buckets (ADR 0437 §5). The source bucket has +// one key, SourceMainKey: every repository's merged head and the group that moved it, written by the gate alone by +// compare-and-set; its revision is the mesh's source revision. The indexer writes the other two. +const ( + SourceBucket = GateModule + "_source" + SourceMainKey = "main" + SourceHistory = 64 + CandidatesBucket = IndexerModule + "_candidates" + // CandidatesHistory is the revisions the candidates bucket keeps per key. + CandidatesHistory = 10 + ModulesBucket = IndexerModule + "_modules" + // ModulesHistory is the revisions the modules bucket keeps per key. + ModulesHistory = 20 +) + +// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names. +const ForgeStream = "forge" + +// The kinds of forge message, the last part of a forge event's name. +const ( + ForgePull = "pull" + ForgeMerged = "merged" + ForgeRefused = "refused" +) + +// The actions of a pull message. +const ( + PullOpened = "opened" + PullUpdated = "updated" + PullClosed = "closed" +) + +// SourcePrefix opens every source fingerprint. +const SourcePrefix = "src1:" + +// CandidatesKey is the key, in CandidatesBucket, of a group: ``, the branch name the group's pull requests +// share (a slash is allowed); a lone pull request is a group of one under its branch name. +func CandidatesKey(group string) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + return group, nil +} + +// ParseCandidatesKey is the inverse of CandidatesKey. +func ParseCandidatesKey(key string) (group string, err error) { + if group, err = CandidatesKey(key); err != nil { + return "", fmt.Errorf("delivery: %q is not a candidates key: %w", key, err) + } + return group, nil +} + +// ModulesKey is the key, in ModulesBucket, of a module: ``. +func ModulesKey(module string) (string, error) { + if err := checkName("module", module); err != nil { + return "", err + } + return module, nil +} + +// ParseModulesKey is the inverse of ModulesKey. +func ParseModulesKey(key string) (module string, err error) { + if module, err = ModulesKey(key); err != nil { + return "", fmt.Errorf("delivery: %q is not a modules key: %w", key, err) + } + return module, nil +} + +// checkRepository refuses a repository that is not `/`: exactly one slash, each part a valid name. +func checkRepository(repository string) error { + owner, name, ok := strings.Cut(repository, "/") + if !ok || strings.Contains(name, "/") { + return fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) + } + if err := checkName("repository owner", owner); err != nil { + return fmt.Errorf("delivery: the repository %q: %w", repository, err) + } + if err := checkName("repository name", name); err != nil { + return fmt.Errorf("delivery: the repository %q: %w", repository, err) + } + if strings.Contains(owner, "/") { + return fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) + } + return nil +} + +func checkForgeKind(kind string) error { + switch kind { + case ForgePull, ForgeMerged, ForgeRefused: + return nil + } + return fmt.Errorf("delivery: the forge kind %q is not pull, merged or refused", kind) +} + +// ForgeEvent is the name of a forge message of the given kind: `forge...`. +func ForgeEvent(repository, kind string) (string, error) { + if err := checkRepository(repository); err != nil { + return "", err + } + if err := checkForgeKind(kind); err != nil { + return "", err + } + owner, name, _ := strings.Cut(repository, "/") + return "forge." + owner + "." + name + "." + kind, nil +} + +// ParseForgeEvent is the inverse of ForgeEvent. +func ParseForgeEvent(event string) (repository, kind string, err error) { + parts := strings.Split(event, ".") + if len(parts) != 4 || parts[0] != "forge" { + return "", "", fmt.Errorf("delivery: %q is not a forge event, forge...", event) + } + repository = parts[1] + "/" + parts[2] + if _, err := ForgeEvent(repository, parts[3]); err != nil { + return "", "", fmt.Errorf("delivery: %q is not a forge event: %w", event, err) + } + return repository, parts[3], nil +} + +// Head is one pull request at one commit: the repository as `/` and the full sha. +type Head struct { + Repository string `json:"repository"` + Number int `json:"number"` + Head string `json:"head"` +} + +// HeadsHash is the first 12 hex digits of the sha256 over the lines `#=`, sorted and +// joined by a newline: the same for every order of heads, different when any head changes. An empty list is refused. +func HeadsHash(heads []Head) (string, error) { + if len(heads) == 0 { + return "", fmt.Errorf("delivery: a heads hash needs at least one pull request") + } + lines := make([]string, len(heads)) + for i, h := range heads { + lines[i] = fmt.Sprintf("%s#%d=%s", h.Repository, h.Number, h.Head) + } + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return hex.EncodeToString(sum[:])[:12], nil +} + +// Source is the value of SourceMainKey: every repository's merged head (repository to full sha, not only the +// group's) and the group that moved it. +type Source struct { + V int `json:"v"` + Group string `json:"group"` + Heads map[string]string `json:"heads"` +} + +func (s Source) check() error { + if len(s.Heads) == 0 { + return fmt.Errorf("delivery: a source names no repository") + } + for repository, head := range s.Heads { + if repository == "" { + return fmt.Errorf("delivery: a source names a repository with no name") + } + if head == "" { + return fmt.Errorf("delivery: the source's head of %s is empty", repository) + } + } + return nil +} + +// Candidate is the value of a candidates key: the group's pull requests and heads, the heads hash, the base (the +// source revision it was previewed onto) and the modules each pull request touches. It carries no fingerprint +// until release 4 (ADR 0437 §5). +type Candidate struct { + V int `json:"v"` + Group string `json:"group"` + Base uint64 `json:"base"` + HeadsHash string `json:"heads"` + Pulls []CandidatePull `json:"pulls"` +} + +// CandidatePull is one pull request of a candidate and the modules it touches. +type CandidatePull struct { + Head + Modules []string `json:"modules"` +} + +func (c Candidate) check() error { + if len(c.Pulls) == 0 { + return fmt.Errorf("delivery: a candidate names no pull request") + } + heads := make([]Head, len(c.Pulls)) + for i, p := range c.Pulls { + heads[i] = p.Head + } + want, _ := HeadsHash(heads) + if c.HeadsHash != want { + return fmt.Errorf("delivery: the candidate's heads hash %q is not the hash %q of its pull requests' heads", c.HeadsHash, want) + } + return nil +} + +// Module is the value of a modules key: the module's source fingerprint, or none and why (a build that resolves +// packages when built gets none, said here), and where it was computed: Repository and Commit are the trunk merge, +// Src the source revision then (0 before the gate writes source.main). Exactly one of FP and None is set. +type Module struct { + V int `json:"v"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` + Src uint64 `json:"src,omitempty"` + Repository string `json:"repository"` + Commit string `json:"commit"` +} + +func (m Module) check() error { + switch { + case m.FP != "" && m.None != "": + return fmt.Errorf("delivery: a module has both a fingerprint and a reason for none") + case m.FP == "" && m.None == "": + return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none") + case m.FP != "" && !strings.HasPrefix(m.FP, SourcePrefix): + return fmt.Errorf("delivery: the module's fingerprint %q does not start with %s", m.FP, SourcePrefix) + } + return nil +} + +// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull +// message says Action; a merged message carries the merge commit, the tree of every directory holding a +// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own +// words. +type Forge struct { + V int `json:"v"` + Kind string `json:"kind"` + Repository string `json:"repository"` + Branch string `json:"branch,omitempty"` + Number int `json:"number,omitempty"` + Head string `json:"head,omitempty"` + Action string `json:"action,omitempty"` + Merge string `json:"merge,omitempty"` + Trees map[string]string `json:"trees,omitempty"` + Root string `json:"root,omitempty"` + Act string `json:"act,omitempty"` + Words string `json:"words,omitempty"` +} + +func (f Forge) check() error { + if err := checkRepository(f.Repository); err != nil { + return err + } + if err := checkForgeKind(f.Kind); err != nil { + return err + } + switch f.Kind { + case ForgePull: + if f.Number <= 0 { + return fmt.Errorf("delivery: a pull message needs its pull request number") + } + if f.Head == "" { + return fmt.Errorf("delivery: a pull message needs its head") + } + switch f.Action { + case PullOpened, PullUpdated, PullClosed: + default: + return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action) + } + case ForgeMerged: + if f.Number <= 0 { + return fmt.Errorf("delivery: a merged message needs its pull request number") + } + if f.Head == "" { + return fmt.Errorf("delivery: a merged message needs its head") + } + if f.Merge == "" { + return fmt.Errorf("delivery: a merged message needs its merge commit") + } + if f.Root == "" { + return fmt.Errorf("delivery: a merged message needs the repository's root tree") + } + if len(f.Trees) == 0 { + return fmt.Errorf("delivery: a merged message needs the tree of at least one directory holding a module.json") + } + for dir, tree := range f.Trees { + if tree == "" { + return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir) + } + } + case ForgeRefused: + if f.Act == "" { + return fmt.Errorf("delivery: a refused message needs the act it answers") + } + if f.Words == "" { + return fmt.Errorf("delivery: a refused message needs the forge's words") + } + } + return nil +} + +// EncodeSource is s as JSON at Version; it refuses a source with no repository or an empty head. +func EncodeSource(s Source) ([]byte, error) { + if err := s.check(); err != nil { + return nil, err + } + s.V = Version + return json.Marshal(s) +} + +// DecodeSource reads a source; it refuses an unknown version (ErrUnknownVersion) and what EncodeSource refuses. +func DecodeSource(data []byte) (Source, error) { + var s Source + if err := decode(data, &s); err != nil { + return Source{}, err + } + if err := s.check(); err != nil { + return Source{}, err + } + return s, nil +} + +// EncodeCandidate is c as JSON at Version; it refuses a heads hash that is not the hash of its pull requests' heads. +func EncodeCandidate(c Candidate) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = Version + return json.Marshal(c) +} + +// DecodeCandidate reads a candidate; it refuses an unknown version (ErrUnknownVersion) and what EncodeCandidate refuses. +func DecodeCandidate(data []byte) (Candidate, error) { + var c Candidate + if err := decode(data, &c); err != nil { + return Candidate{}, err + } + if err := c.check(); err != nil { + return Candidate{}, err + } + return c, nil +} + +// EncodeModule is m as JSON at Version; it refuses both or neither of a fingerprint and a reason, and a +// fingerprint not of the form src1:. +func EncodeModule(m Module) ([]byte, error) { + if err := m.check(); err != nil { + return nil, err + } + m.V = Version + return json.Marshal(m) +} + +// DecodeModule reads a module; it refuses an unknown version (ErrUnknownVersion) and what EncodeModule refuses. +func DecodeModule(data []byte) (Module, error) { + var m Module + if err := decode(data, &m); err != nil { + return Module{}, err + } + if err := m.check(); err != nil { + return Module{}, err + } + return m, nil +} + +// EncodeForge is f as JSON at Version; it refuses a message missing what its kind needs. +func EncodeForge(f Forge) ([]byte, error) { + if err := f.check(); err != nil { + return nil, err + } + f.V = Version + return json.Marshal(f) +} + +// DecodeForge reads a forge message; it refuses an unknown version (ErrUnknownVersion) and what EncodeForge refuses. +func DecodeForge(data []byte) (Forge, error) { + var f Forge + if err := decode(data, &f); err != nil { + return Forge{}, err + } + if err := f.check(); err != nil { + return Forge{}, err + } + return f, nil +} + +// SameSource says whether two sources are the same value, so a writer writes only when they differ. +func SameSource(a, b Source) bool { return reflect.DeepEqual(a, b) } + +// SameCandidate says whether two candidates are the same value. +func SameCandidate(a, b Candidate) bool { return reflect.DeepEqual(a, b) } + +// SameModule says whether two modules are the same value. +func SameModule(a, b Module) bool { return a == b } + +// SameForge says whether two forge messages are the same value. +func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) } diff --git a/go/delivery/source_test.go b/go/delivery/source_test.go new file mode 100644 index 0000000..2abe8da --- /dev/null +++ b/go/delivery/source_test.go @@ -0,0 +1,288 @@ +package delivery + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestSourceKeys(t *testing.T) { // 1200/AC1 + if k, err := CandidatesKey("feat/delivery-source-keys"); err != nil || k != "feat/delivery-source-keys" { + t.Fatalf("candidates key = %q, %v", k, err) + } + if g, err := ParseCandidatesKey("feat/x"); err != nil || g != "feat/x" { + t.Fatalf("parse candidates = %q, %v", g, err) + } + if k, err := ModulesKey("mesh-mail"); err != nil || k != "mesh-mail" { + t.Fatalf("modules key = %q, %v", k, err) + } + if m, err := ParseModulesKey("mesh-mail"); err != nil || m != "mesh-mail" { + t.Fatalf("parse modules = %q, %v", m, err) + } + for _, bad := range []string{"", "a.b", "a*b", "a>b", "a b", "a\tb"} { + if _, err := CandidatesKey(bad); err == nil { + t.Errorf("candidates key %q accepted", bad) + } + if _, err := ModulesKey(bad); err == nil { + t.Errorf("modules key %q accepted", bad) + } + if _, err := ParseCandidatesKey(bad); err == nil { + t.Errorf("parse candidates %q accepted", bad) + } + if _, err := ParseModulesKey(bad); err == nil { + t.Errorf("parse modules %q accepted", bad) + } + } + ev, err := ForgeEvent("novox/mesh-sdk", ForgeMerged) + if err != nil || ev != "forge.novox.mesh-sdk.merged" { + t.Fatalf("forge event = %q, %v", ev, err) + } + repo, kind, err := ParseForgeEvent(ev) + if err != nil || repo != "novox/mesh-sdk" || kind != ForgeMerged { + t.Fatalf("parse forge event = %q %q %v", repo, kind, err) + } + for _, k := range []string{ForgePull, ForgeMerged, ForgeRefused} { + if _, err := ForgeEvent("a/b", k); err != nil { + t.Errorf("kind %s refused: %v", k, err) + } + } + for _, bad := range []struct{ repo, kind string }{ + {"", "pull"}, {"novox", "pull"}, {"a/b/c", "pull"}, {"/b", "pull"}, {"a/", "pull"}, + {"a.b/c", "pull"}, {"a/b c", "pull"}, {"a/b", "closed"}, {"a/b", ""}, + } { + if _, err := ForgeEvent(bad.repo, bad.kind); err == nil { + t.Errorf("forge event %q %q accepted", bad.repo, bad.kind) + } else if bad.kind == "closed" && !strings.Contains(err.Error(), "closed") { + t.Errorf("error does not name the kind: %v", err) + } + } + for _, bad := range []string{"", "forge.a.b", "forge.a.b.closed", "x.a.b.pull", "forge.a.b.pull.x", "forge..b.pull"} { + if _, _, err := ParseForgeEvent(bad); err == nil { + t.Errorf("parse forge event %q accepted", bad) + } + } +} + +func TestHeadsHash(t *testing.T) { // 1200/AC2 + a := Head{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"} + b := Head{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"} + h1, err := HeadsHash([]Head{a, b}) + if err != nil { + t.Fatal(err) + } + h2, _ := HeadsHash([]Head{b, a}) + if h1 != h2 { + t.Errorf("order changes the hash: %s %s", h1, h2) + } + if len(h1) != 12 || strings.Trim(h1, "0123456789abcdef") != "" { + t.Errorf("hash %q is not 12 hex digits", h1) + } + for _, changed := range []Head{ + {Repository: "novox/mesh-sdk", Number: 36, Head: "aaab"}, + {Repository: "novox/mesh-sdk", Number: 37, Head: "aaaa"}, + {Repository: "novox/other", Number: 36, Head: "aaaa"}, + } { + h3, _ := HeadsHash([]Head{changed, b}) + if h3 == h1 { + t.Errorf("changing %v keeps the hash", changed) + } + } + if _, err := HeadsHash(nil); err == nil { + t.Error("an empty list is accepted") + } +} + +func sampleSource() Source { + return Source{Group: "feat/x", Heads: map[string]string{"novox/mesh-sdk": "abc123", "novox/mesh-control": "def456"}} +} + +func sampleCandidate() Candidate { + pulls := []CandidatePull{ + {Head: Head{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}, Modules: []string{"mesh-mail"}}, + {Head: Head{Repository: "novox/mesh-control", Number: 7, Head: "bbbb"}, Modules: []string{"mesh-controller"}}, + } + hh, _ := HeadsHash([]Head{pulls[0].Head, pulls[1].Head}) + return Candidate{Group: "feat/x", Base: 12, HeadsHash: hh, Pulls: pulls} +} + +func sampleForges() []Forge { + return []Forge{ + {Kind: ForgePull, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 36, Head: "aaaa", Action: "opened"}, + {Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", + Trees: map[string]string{"modules/a": "t1"}, Root: "t0"}, + {Kind: ForgeRefused, Repository: "novox/mesh-sdk", Act: "novox/mesh-sdk.36.aaaa.view", Words: "403 forbidden"}, + } +} + +func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3 + check := func(name string, data []byte, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + var probe map[string]any + if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) { + t.Fatalf("%s: json %s does not carry v:1", name, data) + } + } + s := sampleSource() + data, err := EncodeSource(s) + check("source", data, err) + s.V = Version + if got, err := DecodeSource(data); err != nil || !SameSource(got, s) { + t.Errorf("source round trip: %+v %v", got, err) + } + c := sampleCandidate() + data, err = EncodeCandidate(c) + check("candidate", data, err) + c.V = Version + if got, err := DecodeCandidate(data); err != nil || !SameCandidate(got, c) { + t.Errorf("candidate round trip: %+v %v", got, err) + } + for _, m := range []Module{ + {FP: "src1:abc", Src: 5, Repository: "novox/mesh-sdk", Commit: "cccc"}, + {None: "a build that resolves packages when built", Repository: "novox/mesh-sdk", Commit: "cccc"}, + } { + data, err = EncodeModule(m) + check("module", data, err) + m.V = Version + if got, err := DecodeModule(data); err != nil || !SameModule(got, m) { + t.Errorf("module round trip: %+v %v", got, err) + } + } + for _, f := range sampleForges() { + data, err = EncodeForge(f) + check("forge "+f.Kind, data, err) + f.V = Version + if got, err := DecodeForge(data); err != nil || !SameForge(got, f) { + t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err) + } + } + for name, dec := range map[string]func([]byte) error{ + "source": func(b []byte) error { _, err := DecodeSource(b); return err }, + "candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err }, + "module": func(b []byte) error { _, err := DecodeModule(b); return err }, + "forge": func(b []byte) error { _, err := DecodeForge(b); return err }, + } { + for _, in := range []string{`{"group":"g"}`, `{"v":2,"group":"g"}`} { + if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err) + } + } + } +} + +func TestSourceValuesRefused(t *testing.T) { // 1200/AC4 + wantErr := func(name string, err error, words ...string) { + t.Helper() + if err == nil { + t.Errorf("%s accepted", name) + return + } + for _, w := range words { + if !strings.Contains(err.Error(), w) { + t.Errorf("%s: error %q does not name %q", name, err, w) + } + } + } + _, err := EncodeSource(Source{Group: "g"}) + wantErr("source without repositories", err, "repositor") + _, err = EncodeSource(Source{Group: "g", Heads: map[string]string{"a/b": ""}}) + wantErr("source with an empty head", err, "a/b") + _, err = EncodeSource(Source{Group: "g", Heads: map[string]string{"": "x"}}) + wantErr("source with an empty repository", err, "repositor") + + c := sampleCandidate() + c.HeadsHash = "000000000000" + _, err = EncodeCandidate(c) + wantErr("candidate with a stale hash", err, "heads hash") + c = sampleCandidate() + c.Pulls = nil + _, err = EncodeCandidate(c) + wantErr("candidate without pulls", err, "pull") + + _, err = EncodeModule(Module{FP: "src1:x", None: "why", Repository: "a/b", Commit: "c"}) + wantErr("module with both", err, "both") + _, err = EncodeModule(Module{Repository: "a/b", Commit: "c"}) + wantErr("module with neither", err, "fingerprint", "none") + _, err = EncodeModule(Module{FP: "abc", Repository: "a/b", Commit: "c"}) + wantErr("module with a bad fingerprint", err, "src1:") + + f := sampleForges()[1] + f.Merge = "" + _, err = EncodeForge(f) + wantErr("merged without merge commit", err, "merge") + f = sampleForges()[1] + f.Root = "" + _, err = EncodeForge(f) + wantErr("merged without root", err, "root") + f = sampleForges()[1] + f.Trees = nil + _, err = EncodeForge(f) + wantErr("merged without trees", err, "tree") + f = sampleForges()[1] + f.Trees = map[string]string{"modules/a": ""} + _, err = EncodeForge(f) + wantErr("merged with an empty tree", err, "modules/a") + f = sampleForges()[0] + f.Action = "reopened" + _, err = EncodeForge(f) + wantErr("pull with an unknown action", err, "reopened") + f = sampleForges()[0] + f.Head = "" + _, err = EncodeForge(f) + wantErr("pull without head", err, "head") + f = sampleForges()[2] + f.Words = "" + _, err = EncodeForge(f) + wantErr("refused without words", err, "words") + f = sampleForges()[2] + f.Act = "" + _, err = EncodeForge(f) + wantErr("refused without act", err, "act") + _, err = EncodeForge(Forge{Kind: "closed", Repository: "a/b"}) + wantErr("unknown kind", err, "closed") + _, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"}) + wantErr("bad repository", err, "nope") +} + +func TestSourceSame(t *testing.T) { // 1200/AC5 + if !SameSource(sampleSource(), sampleSource()) { + t.Error("equal sources differ") + } + s := sampleSource() + s.Heads["novox/mesh-sdk"] = "zzz" + if SameSource(s, sampleSource()) { + t.Error("different sources are the same") + } + if !SameCandidate(sampleCandidate(), sampleCandidate()) { + t.Error("equal candidates differ") + } + c := sampleCandidate() + c.Pulls[0].Modules = []string{"other"} + if SameCandidate(c, sampleCandidate()) { + t.Error("different candidates are the same") + } + m := Module{FP: "src1:a", Repository: "a/b", Commit: "c"} + if !SameModule(m, m) || SameModule(m, Module{FP: "src1:b", Repository: "a/b", Commit: "c"}) { + t.Error("SameModule wrong") + } + f := sampleForges()[1] + g := sampleForges()[1] + if !SameForge(f, g) { + t.Error("equal forge messages differ") + } + g.Trees["modules/a"] = "other" + if SameForge(f, g) { + t.Error("different forge messages are the same") + } +} + +func TestSourceBuckets(t *testing.T) { + if SourceBucket != "mesh-delivery-gate_source" || CandidatesBucket != "mesh-delivery-indexer_candidates" || + ModulesBucket != "mesh-delivery-indexer_modules" || SourceMainKey != "main" || + SourceHistory != 64 || CandidatesHistory != 10 || ModulesHistory != 20 { + t.Error("a bucket or history differs from the decided layout") + } +} -- 2.54.0 From 15af148520b6d213033a3ea641df383d60b0b148 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 10:33:17 +0200 Subject: [PATCH 2/7] Give the gate's merges, asks and owed acts and the teller's answers one layout the parts read (issue 1201) --- go/delivery/delivery.go | 6 +- go/delivery/gate.go | 390 +++++++++++++++++++++++++++++++++++++++ go/delivery/gate_test.go | 297 +++++++++++++++++++++++++++++ 3 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 go/delivery/gate.go create mode 100644 go/delivery/gate_test.go diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 50931ed..02c4ed9 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -23,7 +23,11 @@ // and `modules.` (a module's source fingerprint, or none and why), and the forge bridge's messages named // `forge...` (source.go). // -// Standard library only. +// **Merges, asks, owed and answers** (issue 1201; ADR 0437 §6, §8): the gate's `merges.`, its asks +// `merge..` (the router's ask whole), its owed keys `...` that the +// forge bridge performs, and the teller's answer under the ask's own key (gate.go). +// +// Standard library only, and this module's own asks package. package delivery import ( diff --git a/go/delivery/gate.go b/go/delivery/gate.go new file mode 100644 index 0000000..8ef7ed3 --- /dev/null +++ b/go/delivery/gate.go @@ -0,0 +1,390 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected +// head, its state and each merge commit), its asks (`merge..`, the router's ask whole) and owed +// (one key per act it owes the forge, which the forge bridge performs once per revision). The teller writes its +// answer under the ask's own key in its bucket answers. +const ( + MergesBucket = GateModule + "_merges" + AsksBucket = GateModule + "_asks" + OwedBucket = GateModule + "_owed" + AnswersBucket = TellerModule + "_answers" + MergesHistory = 10 + AsksHistory = 5 + OwedHistory = 5 + AnswersHistory = 5 +) + +// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key. +const AskKindMerge = "merge" + +// The acts the gate owes the forge, the last part of an owed key. +const ( + OwedStatus = "status" + OwedView = "view" + OwedNote = "note" + OwedComment = "comment" +) + +// The states of a pull request in a merge. +type MergeState string + +// The states a merge's pull request is in. +const ( + MergeMerging MergeState = "merging" + MergeMerged MergeState = "merged" + MergeRefused MergeState = "refused" +) + +// The states a status the gate owes the forge may carry. +var statusStates = []string{"pending", "success", "failure", "error", "warning"} + +var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`) + +// MergesKey is the key, in MergesBucket, of a group: ``. +func MergesKey(group string) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + return group, nil +} + +// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge..`. The teller's answer has the same key in AnswersBucket (AnswerKey). +func MergeAskKey(group string, heads []Head) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + hash, err := HeadsHash(heads) + if err != nil { + return "", err + } + return AskKindMerge + "." + group + "." + hash, nil +} + +// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash. +func ParseAskKey(key string) (kind, group, hash string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 3 || parts[0] != AskKindMerge { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key, merge..", key) + } + if err := checkName("group", parts[1]); err != nil { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err) + } + if !hashOK.MatchString(parts[2]) { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2]) + } + return parts[0], parts[1], parts[2], nil +} + +// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string. +func AnswerKey(askKey string) string { return askKey } + +// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID), +// so a dot becomes `-` and a slash `_`; a key too long for an id is cut and ends in a digest of the whole key. +// It cannot be read back: the teller keeps the key beside it. +func AskID(askKey string) string { + id := strings.NewReplacer(".", "-", "/", "_").Replace(askKey) + const most = 64 + if len(id) > most { + sum := sha256.Sum256([]byte(askKey)) + id = id[:most-13] + "-" + hex.EncodeToString(sum[:])[:12] + } + return id +} + +func checkOwedAct(act string) (kind, context string, err error) { + kind, context, hasContext := strings.Cut(act, "=") + switch kind { + case OwedStatus: + 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 { + return "", "", err + } + return kind, context, nil + case OwedView, OwedNote, OwedComment: + if hasContext { + return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act) + } + return kind, "", nil + } + return "", "", fmt.Errorf("delivery: the owed act %q is not status=, view, note or comment", act) +} + +// ParseOwedAct splits an owed act into its kind and, for a status, its context. +func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) } + +// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head: +// `...`, the act being `status=` (mesh/merge-gate), `view`, `note` or +// `comment`. +func OwedKey(repository string, number int, head, act string) (string, error) { + if err := checkRepository(repository); err != nil { + return "", err + } + if number <= 0 { + return "", fmt.Errorf("delivery: an owed key needs the pull request number, not %d", number) + } + if err := checkName("head", head); err != nil { + return "", err + } + if _, _, err := checkOwedAct(act); err != nil { + return "", err + } + return repository + "." + strconv.Itoa(number) + "." + head + "." + act, nil +} + +// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=` for a status. +func ParseOwedKey(key string) (repository string, number int, head, act string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 4 { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, ...", key) + } + number, convErr := strconv.Atoi(parts[1]) + if convErr != nil { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1]) + } + if _, err := OwedKey(parts[0], number, parts[2], parts[3]); err != nil { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err) + } + return parts[0], number, parts[2], parts[3], nil +} + +// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects +// and how far its merge has come. +type Merge struct { + V int `json:"v"` + Group string `json:"group"` + Ask string `json:"ask"` + Pulls []MergePull `json:"pulls"` +} + +// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the +// forge's words when refused. +type MergePull struct { + Head + State MergeState `json:"state"` + Commit string `json:"commit,omitempty"` + Words string `json:"words,omitempty"` +} + +func (m Merge) check() error { + if err := checkName("group", m.Group); err != nil { + return err + } + if m.Ask == "" { + return fmt.Errorf("delivery: a merge names no ask") + } + if len(m.Pulls) == 0 { + return fmt.Errorf("delivery: a merge names no pull request") + } + for _, p := range m.Pulls { + who := fmt.Sprintf("%s#%d", p.Repository, p.Number) + switch p.State { + case MergeMerging: + case MergeMerged: + if p.Commit == "" { + return fmt.Errorf("delivery: the merged pull request %s has no merge commit", who) + } + case MergeRefused: + if p.Words == "" { + return fmt.Errorf("delivery: the refused pull request %s has no words of the forge", who) + } + default: + return fmt.Errorf("delivery: the state %q of %s is not merging, merged or refused", p.State, who) + } + } + return nil +} + +// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it +// unchanged, and the group and heads it was composed for. +type Ask struct { + V int `json:"v"` + Group string `json:"group"` + Heads []Head `json:"heads"` + Ask asks.Ask `json:"ask"` +} + +func (a Ask) check() error { + key, err := MergeAskKey(a.Group, a.Heads) + if err != nil { + return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err) + } + if want := AskID(key); a.Ask.ID != want { + return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want) + } + if len(a.Ask.Options) == 0 { + return fmt.Errorf("delivery: the ask has no option") + } + return nil +} + +// Answer is the teller's answer to an ask: the option chosen, the warrant's id and its words. +type Answer struct { + V int `json:"v"` + Option string `json:"option"` + Warrant string `json:"warrant"` + Words string `json:"words"` +} + +func (a Answer) check() error { + for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} { + if f.value == "" { + return fmt.Errorf("delivery: an answer has no %s", f.name) + } + } + return nil +} + +// Owed is the value of an owed key: what the forge bridge performs. A status needs Context and State, a view and a +// comment need Text, a note needs Text and Ref (the notes ref, mesh-plan). +type Owed struct { + V int `json:"v"` + Act string `json:"act"` + Context string `json:"context,omitempty"` + State string `json:"state,omitempty"` + Text string `json:"text,omitempty"` + Ref string `json:"ref,omitempty"` +} + +func (o Owed) check() error { + switch o.Act { + case OwedStatus: + if o.Context == "" { + return fmt.Errorf("delivery: an owed status has no context") + } + for _, s := range statusStates { + if o.State == s { + return nil + } + } + return fmt.Errorf("delivery: the owed status state %q is not %s", o.State, strings.Join(statusStates, ", ")) + case OwedView, OwedComment: + if o.Text == "" { + return fmt.Errorf("delivery: an owed %s has no text", o.Act) + } + case OwedNote: + if o.Text == "" { + return fmt.Errorf("delivery: an owed note has no text") + } + if o.Ref == "" { + return fmt.Errorf("delivery: an owed note has no ref") + } + default: + return fmt.Errorf("delivery: the owed act %q is not status, view, note or comment", o.Act) + } + return nil +} + +// EncodeMerge is m as JSON at Version; it refuses a merged pull request without its merge commit, a refused one +// without the forge's words and a state outside the list. +func EncodeMerge(m Merge) ([]byte, error) { + if err := m.check(); err != nil { + return nil, err + } + m.V = Version + return json.Marshal(m) +} + +// DecodeMerge reads a merge; it refuses an unknown version (ErrUnknownVersion) and what EncodeMerge refuses. +func DecodeMerge(data []byte) (Merge, error) { + var m Merge + if err := decode(data, &m); err != nil { + return Merge{}, err + } + if err := m.check(); err != nil { + return Merge{}, err + } + return m, nil +} + +// EncodeAsk is a as JSON at Version; it refuses an ask whose id is not the one its group and heads give, or with +// no option. +func EncodeAsk(a Ask) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAsk reads an ask; it refuses an unknown version (ErrUnknownVersion) and what EncodeAsk refuses. +func DecodeAsk(data []byte) (Ask, error) { + var a Ask + if err := decode(data, &a); err != nil { + return Ask{}, err + } + if err := a.check(); err != nil { + return Ask{}, err + } + return a, nil +} + +// EncodeAnswer is a as JSON at Version; it refuses an answer missing its option, warrant or words. +func EncodeAnswer(a Answer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAnswer reads an answer; it refuses an unknown version (ErrUnknownVersion) and what EncodeAnswer refuses. +func DecodeAnswer(data []byte) (Answer, error) { + var a Answer + if err := decode(data, &a); err != nil { + return Answer{}, err + } + if err := a.check(); err != nil { + return Answer{}, err + } + return a, nil +} + +// EncodeOwed is o as JSON at Version; it refuses an act missing what it needs. +func EncodeOwed(o Owed) ([]byte, error) { + if err := o.check(); err != nil { + return nil, err + } + o.V = Version + return json.Marshal(o) +} + +// DecodeOwed reads an owed act; it refuses an unknown version (ErrUnknownVersion) and what EncodeOwed refuses. +func DecodeOwed(data []byte) (Owed, error) { + var o Owed + if err := decode(data, &o); err != nil { + return Owed{}, err + } + if err := o.check(); err != nil { + return Owed{}, err + } + return o, nil +} + +// SameMerge says whether two merges are the same value, so a writer writes only when they differ. +func SameMerge(a, b Merge) bool { return reflect.DeepEqual(a, b) } + +// SameAsk says whether two asks are the same value. +func SameAsk(a, b Ask) bool { return reflect.DeepEqual(a, b) } + +// SameAnswer says whether two answers are the same value. +func SameAnswer(a, b Answer) bool { return a == b } + +// SameOwed says whether two owed acts are the same value. +func SameOwed(a, b Owed) bool { return a == b } diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go new file mode 100644 index 0000000..a8b53ff --- /dev/null +++ b/go/delivery/gate_test.go @@ -0,0 +1,297 @@ +package delivery + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +func gateHeads() []Head { + return []Head{ + {Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}, + {Repository: "novox/mesh-control", Number: 7, Head: "bbbb"}, + } +} + +func TestMergeKeys(t *testing.T) { // 1201/AC1 + if k, err := MergesKey("feat/x"); err != nil || k != "feat/x" { + t.Fatalf("merges key = %q, %v", k, err) + } + if _, err := MergesKey("a.b"); err == nil { + t.Error("a group with a dot accepted") + } + hh, _ := HeadsHash(gateHeads()) + key, err := MergeAskKey("feat/x", gateHeads()) + if err != nil || key != "merge.feat/x."+hh { + t.Fatalf("ask key = %q, %v", key, err) + } + kind, group, hash, err := ParseAskKey(key) + if err != nil || kind != AskKindMerge || group != "feat/x" || hash != hh { + t.Fatalf("parse ask key = %q %q %q %v", kind, group, hash, err) + } + if AnswerKey(key) != key { + t.Error("the answer's key is not the ask's key") + } + if _, err := MergeAskKey("feat/x", nil); err == nil { + t.Error("an ask key without heads accepted") + } + if _, err := MergeAskKey("", gateHeads()); err == nil { + t.Error("an ask key without a group accepted") + } + for _, bad := range []string{"", "merge", "merge.g", "merge.g.h.x", "other.g.0123456789ab", "merge..0123456789ab", "merge.g.nothex"} { + if _, _, _, err := ParseAskKey(bad); err == nil { + t.Errorf("parse ask key %q accepted", bad) + } + } +} + +func TestAskID(t *testing.T) { // 1201/AC2 + k1, _ := MergeAskKey("feat/x", gateHeads()) + k2, _ := MergeAskKey("feat/y", gateHeads()) + k3, _ := MergeAskKey("feat/x", gateHeads()[:1]) + long, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20), gateHeads()) + long2, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20)+"2", gateHeads()) + seen := map[string]string{} + for _, k := range []string{k1, k2, k3, long, long2} { + id := AskID(k) + if strings.Contains(id, ".") { + t.Errorf("id %q holds a dot", id) + } + if !asks.UsableID(id) { + t.Errorf("id %q is not usable by the router", id) + } + if other, dup := seen[id]; dup { + t.Errorf("keys %q and %q give the id %q", k, other, id) + } + seen[id] = k + } + if AskID(k1) != AskID(k1) { + t.Error("the id is not stable") + } +} + +func TestOwedKey(t *testing.T) { // 1201/AC3 + for _, act := range []string{"status=mesh/merge-gate", "view", "note", "comment"} { + key, err := OwedKey("novox/mesh-sdk", 36, "aaaa", act) + if err != nil || key != "novox/mesh-sdk.36.aaaa."+act { + t.Fatalf("owed key for %s = %q, %v", act, key, err) + } + repo, number, head, gotAct, err := ParseOwedKey(key) + if err != nil || repo != "novox/mesh-sdk" || number != 36 || head != "aaaa" || gotAct != act { + t.Fatalf("parse %q = %q %d %q %q %v", key, repo, number, head, gotAct, err) + } + } + kind, context, err := ParseOwedAct("status=mesh/merge-gate") + if err != nil || kind != OwedStatus || context != "mesh/merge-gate" { + t.Errorf("owed act = %q %q %v", kind, context, err) + } + for _, bad := range []string{"", "merge", "status", "status=", "view=x", "status=a.b", "statuses=x"} { + if _, err := OwedKey("novox/mesh-sdk", 36, "aaaa", bad); err == nil { + t.Errorf("owed act %q accepted", bad) + } + } + if _, err := OwedKey("nope", 36, "aaaa", "view"); err == nil { + t.Error("a bad repository accepted") + } + if _, err := OwedKey("novox/mesh-sdk", 0, "aaaa", "view"); err == nil { + t.Error("pull request number 0 accepted") + } + if _, err := OwedKey("novox/mesh-sdk", 36, "", "view"); err == nil { + t.Error("an empty head accepted") + } + for _, bad := range []string{"", "novox/mesh-sdk.36.aaaa", "novox/mesh-sdk.x.aaaa.view", "novox/mesh-sdk.36.aaaa.merge", "a.b.36.aaaa.view"} { + if _, _, _, _, err := ParseOwedKey(bad); err == nil { + t.Errorf("parse owed key %q accepted", bad) + } + } +} + +func sampleMerge() Merge { + return Merge{Group: "feat/x", Ask: "merge.feat/x.0123456789ab", Pulls: []MergePull{ + {Head: gateHeads()[0], State: MergeMerged, Commit: "cccc"}, + {Head: gateHeads()[1], State: MergeRefused, Words: "the forge says no"}, + {Head: Head{Repository: "novox/other", Number: 1, Head: "dddd"}, State: MergeMerging}, + }} +} + +func sampleAsk() Ask { + key, _ := MergeAskKey("feat/x", gateHeads()) + return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{ + ID: AskID(key), Headline: "Merge into main: group feat/x", Explanation: "The group passed its checks.", + Who: asks.Operator, Expires: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), OnExpiry: "nothing is merged", + Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}, + }} +} + +func sampleOwed() []Owed { + return []Owed{ + {Act: OwedStatus, Context: "mesh/merge-gate", State: "success"}, + {Act: OwedView, Text: "the delivery reaches ace"}, + {Act: OwedNote, Text: "plan: mesh-mail to ace", Ref: "mesh-plan"}, + {Act: OwedComment, Text: "merged"}, + } +} + +func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4 + hasV := func(name string, data []byte, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + var probe map[string]any + if json.Unmarshal(data, &probe) != nil || probe["v"] != float64(1) { + t.Fatalf("%s: %s does not carry v:1", name, data) + } + } + m := sampleMerge() + data, err := EncodeMerge(m) + hasV("merge", data, err) + m.V = Version + if got, err := DecodeMerge(data); err != nil || !SameMerge(got, m) { + t.Errorf("merge round trip: %+v %v", got, err) + } + a := sampleAsk() + data, err = EncodeAsk(a) + hasV("ask", data, err) + a.V = Version + if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) { + t.Errorf("ask round trip: %+v %v", got, err) + } + an := Answer{Option: "approve", Warrant: "w-1", Words: "approved on telegram"} + data, err = EncodeAnswer(an) + hasV("answer", data, err) + an.V = Version + if got, err := DecodeAnswer(data); err != nil || !SameAnswer(got, an) { + t.Errorf("answer round trip: %+v %v", got, err) + } + for _, o := range sampleOwed() { + data, err = EncodeOwed(o) + hasV("owed "+o.Act, data, err) + o.V = Version + if got, err := DecodeOwed(data); err != nil || !SameOwed(got, o) { + t.Errorf("owed %s round trip: %+v %v", o.Act, got, err) + } + } + for name, dec := range map[string]func([]byte) error{ + "merge": func(b []byte) error { _, err := DecodeMerge(b); return err }, + "ask": func(b []byte) error { _, err := DecodeAsk(b); return err }, + "answer": func(b []byte) error { _, err := DecodeAnswer(b); return err }, + "owed": func(b []byte) error { _, err := DecodeOwed(b); return err }, + } { + for _, in := range []string{`{"group":"g"}`, `{"v":2}`} { + if err := dec([]byte(in)); !errors.Is(err, ErrUnknownVersion) { + t.Errorf("%s decode of %s = %v, want ErrUnknownVersion", name, in, err) + } + } + } +} + +func TestGateValuesRefused(t *testing.T) { // 1201/AC5 + wantErr := func(name string, err error, words ...string) { + t.Helper() + if err == nil { + t.Errorf("%s accepted", name) + return + } + for _, w := range words { + if !strings.Contains(err.Error(), w) { + t.Errorf("%s: error %q does not name %q", name, err, w) + } + } + } + m := sampleMerge() + m.Pulls[0].Commit = "" + _, err := EncodeMerge(m) + wantErr("merged without a merge commit", err, "commit") + m = sampleMerge() + m.Pulls[1].Words = "" + _, err = EncodeMerge(m) + wantErr("refused without words", err, "words") + m = sampleMerge() + m.Pulls[2].State = "done" + _, err = EncodeMerge(m) + wantErr("unknown state", err, "done") + m = sampleMerge() + m.Pulls = nil + _, err = EncodeMerge(m) + wantErr("merge without pulls", err, "pull") + m = sampleMerge() + m.Ask = "" + _, err = EncodeMerge(m) + wantErr("merge without its ask", err, "ask") + + a := sampleAsk() + a.Ask.ID = "something-else" + _, err = EncodeAsk(a) + wantErr("ask with another id", err, "something-else", AskID("merge.feat/x."+mustHash(gateHeads()))) + a = sampleAsk() + a.Ask.Options = nil + _, err = EncodeAsk(a) + wantErr("ask without options", err, "option") + a = sampleAsk() + a.Heads = nil + _, err = EncodeAsk(a) + wantErr("ask without heads", err, "pull request") + + for name, an := range map[string]Answer{ + "option": {Warrant: "w", Words: "x"}, + "warrant": {Option: "approve", Words: "x"}, + "words": {Option: "approve", Warrant: "w"}, + } { + _, err = EncodeAnswer(an) + wantErr("answer without "+name, err, name) + } + + for name, o := range map[string]Owed{ + "context": {Act: OwedStatus, State: "success"}, + "state": {Act: OwedStatus, Context: "mesh/merge-gate", State: "green"}, + "text": {Act: OwedView}, + "ref": {Act: OwedNote, Text: "line"}, + "comment": {Act: OwedComment}, + "act": {Act: "merge"}, + } { + _, err = EncodeOwed(o) + wantErr("owed missing "+name, err) + } + _, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"}) + wantErr("note without ref", err, "ref") + _, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"}) + wantErr("status without context", err, "context") +} + +func mustHash(h []Head) string { s, _ := HeadsHash(h); return s } + +func TestGateSame(t *testing.T) { // 1201/AC6 + if !SameMerge(sampleMerge(), sampleMerge()) || !SameAsk(sampleAsk(), sampleAsk()) { + t.Error("equal values differ") + } + m := sampleMerge() + m.Pulls[0].Commit = "other" + if SameMerge(m, sampleMerge()) { + t.Error("different merges are the same") + } + a := sampleAsk() + a.Ask.Headline = "other" + if SameAsk(a, sampleAsk()) { + t.Error("different asks are the same") + } + if !SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "a", Warrant: "w", Words: "x"}) || + SameAnswer(Answer{Option: "a", Warrant: "w", Words: "x"}, Answer{Option: "b", Warrant: "w", Words: "x"}) { + t.Error("SameAnswer wrong") + } + if !SameOwed(sampleOwed()[0], sampleOwed()[0]) || SameOwed(sampleOwed()[0], sampleOwed()[1]) { + t.Error("SameOwed wrong") + } +} + +func TestGateBuckets(t *testing.T) { + if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" || + OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" || + MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 { + t.Error("a bucket or history differs from the decided layout") + } +} -- 2.54.0 From 0ae600dc977e533abb16943ca3100d4fd16bcff3 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 10:33:53 +0200 Subject: [PATCH 3/7] Give a module one subject and one layout to say a condition to the operator (issue 1202) --- go/asks/condition.go | 149 ++++++++++++++++++++++++++++++++++++++ go/asks/condition_test.go | 143 ++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 go/asks/condition.go create mode 100644 go/asks/condition_test.go diff --git a/go/asks/condition.go b/go/asks/condition.go new file mode 100644 index 0000000..43aae39 --- /dev/null +++ b/go/asks/condition.go @@ -0,0 +1,149 @@ +package asks + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" +) + +// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel +// seat, `mesh.seat.operator-channel.accept.condition.` (novox/hq ADR 0437 §9). The router reads the +// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is +// refused when empty or when it holds a dot, a wildcard or whitespace. +func ConditionSubject(publisher string) (string, error) { + if publisher == "" { + return "", errors.New("asks: the condition's publisher is empty") + } + if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) { + return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher) + } + return "mesh.seat." + Seat + ".accept.condition." + publisher, nil +} + +// ConditionVersion is the version of the condition value this package writes and the only one it reads. +const ConditionVersion = 1 + +// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not +// ConditionVersion. Test it with errors.Is. +var ErrUnknownConditionVersion = errors.New("asks: unknown condition version") + +// The words a condition's explanation opens with (novox/hq ADR 0253 §1). +const ( + NothingToDo = "Nothing for you to do." + NeedsYou = "Needs you: " +) + +// The severities and states of a condition. +const ( + SeverityUrgent = "urgent" + SeverityWarning = "warning" + StateRaised = "raised" + StateCleared = "cleared" +) + +// Scope is what a condition is about, under the controller's names. +type Scope struct { + Scope string `json:"scope"` + ID string `json:"id"` + Machine string `json:"machine,omitempty"` +} + +// Action is one answer a notification offers, under the controller's names. +type Action struct { + Label string `json:"label"` + Verb string `json:"verb"` + Machine string `json:"machine,omitempty"` + Arguments map[string]string `json:"arguments,omitempty"` + Level Level `json:"level,omitempty"` +} + +// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key, +// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs +// only its key, subject and state. The router alone chooses the channels. +type Condition struct { + V int `json:"v"` + Key string `json:"key"` + Severity string `json:"severity,omitempty"` + Subject Scope `json:"subject"` + State string `json:"state"` + Summary string `json:"summary,omitempty"` + Headline string `json:"headline,omitempty"` + Needs string `json:"needs,omitempty"` + Explanation string `json:"explanation,omitempty"` + Resolved string `json:"resolved,omitempty"` + Actions []Action `json:"actions,omitempty"` +} + +func (c Condition) check() error { + if c.Key == "" { + return errors.New("asks: a condition has no key") + } + if c.Subject.Scope == "" || c.Subject.ID == "" { + return errors.New("asks: a condition's subject needs its scope and id") + } + switch c.State { + case StateCleared: + return nil + case StateRaised: + default: + return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State) + } + switch c.Severity { + case SeverityUrgent, SeverityWarning: + default: + return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity) + } + if c.Headline == "" { + return errors.New("asks: a raised condition has no headline") + } + if n := len([]rune(c.Headline)); n > HeadlineLength { + return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength) + } + verdict := NothingToDo + if c.Needs != "" { + verdict = NeedsYou + c.Needs + } + if !strings.HasPrefix(c.Explanation, verdict) { + return fmt.Errorf("asks: the condition's explanation must open with %q", verdict) + } + return nil +} + +// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse. +func EncodeCondition(c Condition) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = ConditionVersion + return json.Marshal(c) +} + +// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what +// EncodeCondition refuses. +func DecodeCondition(data []byte) (Condition, error) { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err) + } + if probe.V == nil { + return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion) + } + if *probe.V != ConditionVersion { + return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion) + } + var c Condition + if err := json.Unmarshal(data, &c); err != nil { + return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err) + } + if err := c.check(); err != nil { + return Condition{}, err + } + return c, nil +} + +// SameCondition says whether two conditions are the same value. +func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) } diff --git a/go/asks/condition_test.go b/go/asks/condition_test.go new file mode 100644 index 0000000..05ab6fa --- /dev/null +++ b/go/asks/condition_test.go @@ -0,0 +1,143 @@ +package asks + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestConditionSubject(t *testing.T) { // 1202/AC1 + s, err := ConditionSubject("mesh-delivery-teller") + if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" { + t.Fatalf("subject = %q, %v", s, err) + } + // The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5. + parts := strings.Split(s, ".") + if len(parts) != 6 || parts[4] != "condition" || parts[5] != "mesh-delivery-teller" { + t.Errorf("subject %q does not keep the router's shape: %v", s, parts) + } + for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} { + if _, err := ConditionSubject(bad); err == nil { + t.Errorf("publisher %q accepted", bad) + } + } +} + +func raisedCondition() Condition { + return Condition{ + Key: "delivery.stuck.mesh-mail", Severity: "warning", + Subject: Scope{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised", + Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck", + Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.", + Resolved: "The delivery of mesh-mail moved on.", + Actions: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace", + Arguments: map[string]string{"why": ""}, Level: Approve}}, + } +} + +func clearedCondition() Condition { + return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"} +} + +func TestConditionRoundTrip(t *testing.T) { // 1202/AC2 + for _, c := range []Condition{raisedCondition(), clearedCondition()} { + data, err := EncodeCondition(c) + if err != nil { + t.Fatalf("%s: %v", c.State, err) + } + var probe map[string]any + if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) { + t.Fatalf("%s: %s does not carry v:1", c.State, data) + } + c.V = ConditionVersion + got, err := DecodeCondition(data) + if err != nil || !SameCondition(got, c) { + t.Errorf("%s round trip: %+v %v", c.State, got, err) + } + } + data, _ := EncodeCondition(raisedCondition()) + var probe map[string]any + _ = json.Unmarshal(data, &probe) + for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} { + if _, ok := probe[name]; !ok { + t.Errorf("the JSON has no %q: %s", name, data) + } + } + subject := probe["subject"].(map[string]any) + for _, name := range []string{"scope", "id", "machine"} { + if _, ok := subject[name]; !ok { + t.Errorf("the subject has no %q: %s", name, data) + } + } + action := probe["actions"].([]any)[0].(map[string]any) + for _, name := range []string{"label", "verb", "machine", "arguments", "level"} { + if _, ok := action[name]; !ok { + t.Errorf("the action has no %q: %s", name, data) + } + } + for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} { + if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) { + t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err) + } + } +} + +func TestConditionVerdict(t *testing.T) { // 1202/AC3 + c := raisedCondition() + c.Needs, c.Explanation = "", "The plan waits." + _, err := EncodeCondition(c) + if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") { + t.Errorf("empty needs without its verdict: %v", err) + } + c.Explanation = "Nothing for you to do. The plan waits." + if _, err := EncodeCondition(c); err != nil { + t.Errorf("empty needs with its verdict refused: %v", err) + } + c = raisedCondition() + c.Explanation = "Nothing for you to do. The plan waits." + _, err = EncodeCondition(c) + if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") { + t.Errorf("needs set with the wrong verdict: %v", err) + } + c.Explanation = "Needs you: something else. The plan waits." + if _, err = EncodeCondition(c); err == nil { + t.Error("needs set with other needs in the verdict accepted") + } +} + +func TestConditionRefused(t *testing.T) { // 1202/AC4 + mutate := func(f func(*Condition)) error { + c := raisedCondition() + f(&c) + _, err := EncodeCondition(c) + return err + } + for name, f := range map[string]func(*Condition){ + "severity": func(c *Condition) { c.Severity = "critical" }, + "state": func(c *Condition) { c.State = "open" }, + "key": func(c *Condition) { c.Key = "" }, + "headline": func(c *Condition) { c.Headline = "" }, + "long headline": func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) }, + } { + if err := mutate(f); err == nil { + t.Errorf("a condition with a bad %s accepted", name) + } + } + if err := mutate(func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil { + t.Errorf("a headline of the greatest length refused: %v", err) + } + if _, err := EncodeCondition(clearedCondition()); err != nil { + t.Errorf("a cleared condition with only key, subject and state refused: %v", err) + } + c := clearedCondition() + c.Key = "" + if _, err := EncodeCondition(c); err == nil { + t.Error("a cleared condition without its key accepted") + } + c = clearedCondition() + c.State = "gone" + if _, err := EncodeCondition(c); err == nil { + t.Error("a condition with an unknown state accepted") + } +} -- 2.54.0 From c23833d3e520cc43959fc607c96863872fe87c99 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 10:34:19 +0200 Subject: [PATCH 4/7] Move the source fingerprint into one function the builder and the indexer both call (issue 1203) --- go/delivery/delivery.go | 3 + go/delivery/fingerprint.go | 61 +++++++++++++++++ go/delivery/fingerprint_test.go | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 go/delivery/fingerprint.go create mode 100644 go/delivery/fingerprint_test.go diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 02c4ed9..fe99528 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -27,6 +27,9 @@ // `merge..` (the router's ask whole), its owed keys `...` that the // forge bridge performs, and the teller's answer under the ask's own key (gate.go). // +// **The source fingerprint** (issue 1203; ADR 0437 §5): one function of named inputs that the controller's builder and +// the indexer both call, so they agree byte for byte (fingerprint.go). +// // Standard library only, and this module's own asks package. package delivery diff --git a/go/delivery/fingerprint.go b/go/delivery/fingerprint.go new file mode 100644 index 0000000..443039e --- /dev/null +++ b/go/delivery/fingerprint.go @@ -0,0 +1,61 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" +) + +// SourceInputs are what a module's source fingerprint is of (novox/hq ADR 0437 §5): the module, the git tree of +// its directory, its bases (references, of which only the digest counts), the root tree of each repository its +// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build +// when it does not (a build that resolves packages when built). +type SourceInputs struct { + Module string + Tree string + Bases []string + Contexts map[string]string + Toolchains map[string]string + Unpinned string +} + +// SourceFingerprint is the module's source fingerprint, `src1:` and the hex sha256 of its inputs' lines, or none +// and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is empty. It is the +// one function the controller's builder and the indexer both call, so they agree byte for byte. The lines are +// "module " and "tree ", then "base " (once each), "context " and "toolchain +// " sorted, all joined by a newline. +func SourceFingerprint(in SourceInputs) (fp, none string) { + if in.Unpinned != "" { + return "", in.Unpinned + } + if in.Tree == "" { + return "", "no tree" + } + lines := []string{"module " + in.Module, "tree " + in.Tree} + bases := map[string]bool{} + for _, b := range in.Bases { + bases[ReferenceDigest(b)] = true + } + for b := range bases { + lines = append(lines, "base "+b) + } + for a, t := range in.Contexts { + lines = append(lines, "context "+a+" "+t) + } + for a, t := range in.Toolchains { + lines = append(lines, "toolchain "+a+" "+t) + } + sort.Strings(lines[2:]) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return SourcePrefix + hex.EncodeToString(sum[:]), "" +} + +// ReferenceDigest is a reference's digest, `sha256:...`, so the registry address a base was copied into does not +// enter the fingerprint; the reference itself when it carries none. +func ReferenceDigest(reference string) string { + if _, digest, pinned := strings.Cut(reference, "@"); pinned && digest != "" { + return digest + } + return reference +} diff --git a/go/delivery/fingerprint_test.go b/go/delivery/fingerprint_test.go new file mode 100644 index 0000000..a7fd036 --- /dev/null +++ b/go/delivery/fingerprint_test.go @@ -0,0 +1,116 @@ +package delivery + +import ( + "strings" + "testing" +) + +func inputs() SourceInputs { + return SourceInputs{ + Module: "mesh-mail", Tree: "t-module", + Bases: []string{"registry.example/base@sha256:aaa", "registry.example/other@sha256:bbb"}, + Contexts: map[string]string{"mail": "t-root", "tool": "t-root2"}, + Toolchains: map[string]string{"mail": "go sha256:ccc 0123456789abcdef", "tool": "go sha256:ccc fedcba9876543210"}, + } +} + +func mustFP(t *testing.T, in SourceInputs) string { + t.Helper() + fp, none := SourceFingerprint(in) + if fp == "" || none != "" { + t.Fatalf("fingerprint = %q, none = %q", fp, none) + } + return fp +} + +func TestFingerprintForm(t *testing.T) { + fp := mustFP(t, inputs()) + hex := strings.TrimPrefix(fp, SourcePrefix) + if SourcePrefix != "src1:" || hex == fp || len(hex) != 64 || strings.Trim(hex, "0123456789abcdef") != "" { + t.Errorf("fingerprint %q is not src1: and 64 hex digits", fp) + } +} + +func TestFingerprintOrderAndDuplicates(t *testing.T) { // 1203/AC2 + want := mustFP(t, inputs()) + in := inputs() + in.Bases = []string{in.Bases[1], in.Bases[0]} + if got := mustFP(t, in); got != want { + t.Errorf("the order of bases changes it: %s", got) + } + in = inputs() + in.Bases = append(in.Bases, in.Bases[0]) + if got := mustFP(t, in); got != want { + t.Errorf("a base given twice changes it: %s", got) + } + in = inputs() + in.Bases = append(in.Bases, "other.registry.example/base@sha256:aaa") + if got := mustFP(t, in); got != want { + t.Errorf("two references to one digest change it: %s", got) + } + // Maps have no order, but build them in many insertion orders anyway. + for i := 0; i < 20; i++ { + in = inputs() + in.Contexts = map[string]string{} + in.Toolchains = map[string]string{} + for _, k := range []string{"tool", "mail"} { + in.Contexts[k] = inputs().Contexts[k] + in.Toolchains[k] = inputs().Toolchains[k] + } + if got := mustFP(t, in); got != want { + t.Fatalf("map order changes it: %s", got) + } + } +} + +func TestFingerprintChanges(t *testing.T) { // 1203/AC3 + want := mustFP(t, inputs()) + for name, f := range map[string]func(*SourceInputs){ + "module": func(in *SourceInputs) { in.Module = "other" }, + "tree": func(in *SourceInputs) { in.Tree = "t-other" }, + "base": func(in *SourceInputs) { in.Bases[0] = "registry.example/base@sha256:zzz" }, + "a base added": func(in *SourceInputs) { in.Bases = append(in.Bases, "x@sha256:new") }, + "context": func(in *SourceInputs) { in.Contexts["mail"] = "t-changed" }, + "context artifact": func(in *SourceInputs) { in.Contexts["extra"] = "t-root" }, + "toolchain": func(in *SourceInputs) { in.Toolchains["tool"] = "go sha256:ccc changed" }, + } { + in := inputs() + f(&in) + if got := mustFP(t, in); got == want { + t.Errorf("a changed %s keeps the fingerprint", name) + } + } +} + +func TestFingerprintNone(t *testing.T) { // 1203/AC4 + in := inputs() + in.Unpinned = "the build resolves packages when built" + fp, none := SourceFingerprint(in) + if fp != "" || none != in.Unpinned { + t.Errorf("unpinned: fp = %q, none = %q", fp, none) + } + in = inputs() + in.Tree = "" + fp, none = SourceFingerprint(in) + if fp != "" || none != "no tree" { + t.Errorf("no tree: fp = %q, none = %q", fp, none) + } + in = inputs() + in.Tree, in.Unpinned = "", "the build resolves packages when built" + if _, none = SourceFingerprint(in); none != in.Unpinned { + t.Errorf("the reason for unpinned stands over no tree: %q", none) + } +} + +func TestReferenceDigest(t *testing.T) { + for in, want := range map[string]string{ + "registry.example/base@sha256:aaa": "sha256:aaa", + "registry.example/base": "registry.example/base", + "registry.example/base@": "registry.example/base@", + "": "", + } { + if got := ReferenceDigest(in); got != want { + t.Errorf("ReferenceDigest(%q) = %q, want %q", in, got, want) + } + } +} -- 2.54.0 From 0bc5659076a75ab266e1feb6d61b709d9cb5e42b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 10:34:59 +0200 Subject: [PATCH 5/7] Make every ask id end in a digest of its key so two keys never share an id (issue 1201) --- go/delivery/gate.go | 18 ++++++++++-------- go/delivery/gate_test.go | 4 +++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/go/delivery/gate.go b/go/delivery/gate.go index 8ef7ed3..51fdb44 100644 --- a/go/delivery/gate.go +++ b/go/delivery/gate.go @@ -94,16 +94,18 @@ func ParseAskKey(key string) (kind, group, hash string, err error) { func AnswerKey(askKey string) string { return askKey } // AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID), -// so a dot becomes `-` and a slash `_`; a key too long for an id is cut and ends in a digest of the whole key. -// It cannot be read back: the teller keeps the key beside it. +// so in the readable part a dot becomes `-` and a slash `_`; that part is cut to fit, and every id ends in `-` and +// the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back: +// the teller keeps the key beside it. func AskID(askKey string) string { - id := strings.NewReplacer(".", "-", "/", "_").Replace(askKey) - const most = 64 - if len(id) > most { - sum := sha256.Sum256([]byte(askKey)) - id = id[:most-13] + "-" + hex.EncodeToString(sum[:])[:12] + const most = 64 // the router's bound on an id + const tail = 13 // "-" and 12 hex digits + readable := strings.NewReplacer(".", "-", "/", "_").Replace(askKey) + if len(readable) > most-tail { + readable = readable[:most-tail] } - return id + sum := sha256.Sum256([]byte(askKey)) + return readable + "-" + hex.EncodeToString(sum[:])[:12] } func checkOwedAct(act string) (kind, context string, err error) { diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go index a8b53ff..4f6dcb7 100644 --- a/go/delivery/gate_test.go +++ b/go/delivery/gate_test.go @@ -55,8 +55,10 @@ func TestAskID(t *testing.T) { // 1201/AC2 k3, _ := MergeAskKey("feat/x", gateHeads()[:1]) long, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20), gateHeads()) long2, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20)+"2", gateHeads()) + slash, _ := MergeAskKey("a/b", gateHeads()) + under, _ := MergeAskKey("a_b", gateHeads()) seen := map[string]string{} - for _, k := range []string{k1, k2, k3, long, long2} { + for _, k := range []string{k1, k2, k3, long, long2, slash, under} { id := AskID(k) if strings.Contains(id, ".") { t.Errorf("id %q holds a dot", id) -- 2.54.0 From 662fb0a57c1c2d60bba3bd62d9b8c30b161f6cba Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 10:37:03 +0200 Subject: [PATCH 6/7] Map every byte the router's id refuses in an ask id, so a group with = gives a usable ask (issue 1201) --- go/delivery/gate.go | 18 +++++++++++++++--- go/delivery/gate_test.go | 4 +++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/go/delivery/gate.go b/go/delivery/gate.go index 51fdb44..cd85b3f 100644 --- a/go/delivery/gate.go +++ b/go/delivery/gate.go @@ -94,18 +94,30 @@ func ParseAskKey(key string) (kind, group, hash string, err error) { func AnswerKey(askKey string) string { return askKey } // AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID), -// so in the readable part a dot becomes `-` and a slash `_`; that part is cut to fit, and every id ends in `-` and +// so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and // the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back: // the teller keeps the key beside it. func AskID(askKey string) string { const most = 64 // the router's bound on an id const tail = 13 // "-" and 12 hex digits - readable := strings.NewReplacer(".", "-", "/", "_").Replace(askKey) + readable := []byte(askKey) + for i, c := range readable { + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9': + case c == '.': + readable[i] = '-' + default: // a slash, an equals sign or any other byte the router's id refuses + readable[i] = '_' + } + } + if len(readable) > 0 && (readable[0] == '-' || readable[0] == '_') { + readable[0] = 'k' // an id starts with a letter or digit + } if len(readable) > most-tail { readable = readable[:most-tail] } sum := sha256.Sum256([]byte(askKey)) - return readable + "-" + hex.EncodeToString(sum[:])[:12] + return string(readable) + "-" + hex.EncodeToString(sum[:])[:12] } func checkOwedAct(act string) (kind, context string, err error) { diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go index 4f6dcb7..d8306a1 100644 --- a/go/delivery/gate_test.go +++ b/go/delivery/gate_test.go @@ -57,8 +57,10 @@ func TestAskID(t *testing.T) { // 1201/AC2 long2, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20)+"2", gateHeads()) slash, _ := MergeAskKey("a/b", gateHeads()) under, _ := MergeAskKey("a_b", gateHeads()) + eq, _ := MergeAskKey("feat=x", gateHeads()) + eqs, _ := MergeAskKey("feat_x", gateHeads()) seen := map[string]string{} - for _, k := range []string{k1, k2, k3, long, long2, slash, under} { + for _, k := range []string{k1, k2, k3, long, long2, slash, under, eq, eqs, "=odd.key"} { id := AskID(k) if strings.Contains(id, ".") { t.Errorf("id %q holds a dot", id) -- 2.54.0 From a39661aa36f3894f5054470c18014004dbc465f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 11:03:46 +0200 Subject: [PATCH 7/7] Prove the source fingerprint against three builds the live builder recorded (issue 1203) --- go/delivery/fingerprint.go | 4 ++- go/delivery/fingerprint_test.go | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/go/delivery/fingerprint.go b/go/delivery/fingerprint.go index 443039e..8966d67 100644 --- a/go/delivery/fingerprint.go +++ b/go/delivery/fingerprint.go @@ -10,7 +10,9 @@ import ( // SourceInputs are what a module's source fingerprint is of (novox/hq ADR 0437 §5): the module, the git tree of // its directory, its bases (references, of which only the digest counts), the root tree of each repository its // artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build -// when it does not (a build that resolves packages when built). +// when it does not (a build that resolves packages when built). A context is the value the builder records for it: +// a repository's root tree, or for an image that compiles a Go program the `narrow:` hash of the build source it was +// handed (mesh-controller internal/builder/builder.go:1146-1156). type SourceInputs struct { Module string Tree string diff --git a/go/delivery/fingerprint_test.go b/go/delivery/fingerprint_test.go index a7fd036..198862d 100644 --- a/go/delivery/fingerprint_test.go +++ b/go/delivery/fingerprint_test.go @@ -114,3 +114,65 @@ func TestReferenceDigest(t *testing.T) { } } } + +// 1203/AC1: three builds the live builder recorded a source fingerprint for. Each value is captured, never written +// by hand: the recorded fingerprint was read with mesh-controller.builds (after issues 1204 and 1208 were +// delivered); each tree is `git rev-parse :` in the repository's clone; the bases are from the +// build's `[bases]` log lines; the toolchain lines are mesh-controller's toolchainOf over its toolchains values. +func TestFingerprintLiveBuilds(t *testing.T) { + const ts = "typescript sha256:4b81682df5665c1f461d3f720a3a0fd7593f4aa71e4fc272dbfdc4bbd235b489 20125bd590041690" + for _, c := range []struct { + name string + in SourceInputs + recorded string + }{ + { + // mailu, build-1792391134620131370: mesh-catalog at b4527d9091e972f28cb365623d9280045e7741a3, dir modules/mailu. + name: "mailu", + in: SourceInputs{ + Module: "mailu", + Tree: "cfc6fa8ff5fc1e220132e51ade653b9d3f387b3e", + Bases: []string{"sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"}, + Toolchains: map[string]string{"code": ts}, + }, + recorded: "src1:539f2e64000d436c572ef53b40e524012455945f8c5a1c6bb173ee0707e88ef4", + }, + { + // radarr, build-1792391147149753355: mesh-media-catalog at 197abb061d9b77d67c7f11e9e567528ea7a85436, + // dir modules/radarr; no bases. + name: "radarr", + in: SourceInputs{ + Module: "radarr", + Tree: "42b9c918b5af75cba2bbc75845012e7f64e16df9", + Toolchains: map[string]string{ + "code": ts, + "tools-go": "go sha256:2054591ca034b68323d87ec887ba34f56819cb3364034360c0777da4b314dd54 0995d85edc9a6a23", + }, + }, + recorded: "src1:b3c150d39c395b99dd68974a48490e9a42467aa5d486d0c604d6ef1e8e9c910a", + }, + { + // build-agent, build-1792395447088936520: mesh-controller at 7a74cee83f88e6585a5511c3cc53bdf0f5303d20, + // dir modules/build-agent; the context is narrowTree's hash of the 185 paths GoBuildSource gave for + // cmd/mesh-builder at 7a74cee8. + name: "build-agent", + in: SourceInputs{ + Module: "build-agent", + Tree: "11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca", + Bases: []string{ + "sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc", + "sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c", + }, + Contexts: map[string]string{"server": "narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"}, + }, + recorded: "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7", + }, + } { + t.Run(c.name, func(t *testing.T) { + fp, none := SourceFingerprint(c.in) + if none != "" || fp != c.recorded { + t.Errorf("fingerprint = %q (none %q), the live builder recorded %q", fp, none, c.recorded) + } + }) + } +} -- 2.54.0