From 0abe480dbabc1c5dd258434f73687fc3798e98f4 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:24:06 +0100 Subject: [PATCH 1/3] =?UTF-8?q?delivery:=20the=20gate's=20merge=20turns,?= =?UTF-8?q?=20so=20the=20checker=20holds=20new=20checks=20off=20a=20reposi?= =?UTF-8?q?tory=20an=20approved=20merge=20waits=20on=20(ADR=200548=20?= =?UTF-8?q?=C2=A73,=20=C2=A74;=20issue=20568=20S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/delivery/gate_test.go | 65 ++++++++++++++- go/delivery/turns.go | 170 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 go/delivery/turns.go diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go index fe36564..227c81e 100644 --- a/go/delivery/gate_test.go +++ b/go/delivery/gate_test.go @@ -123,6 +123,15 @@ func sampleMerge() Merge { }} } +func sampleTurn() Turn { + at := time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC) + return Turn{Repository: "novox/mesh-sdk", Waiting: []TurnWait{ + {Group: "feat/x", Heads: mustHash(gateHeads()), Ask: "merge.feat/x." + mustHash(gateHeads()), Approved: at, Since: at, + WaitsFor: "feat/y.0123456789ab", WaitsSince: at.Add(-time.Minute), Step: "the check ask of novox/mesh-sdk#40"}, + {Group: "fix/1.2", Heads: "0123456789ac", Approved: at.Add(time.Minute), Since: at.Add(time.Minute)}, + }} +} + func sampleAsk() Ask { key, _ := MergeAskKey("feat/x", gateHeads()) return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{ @@ -181,7 +190,24 @@ func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4 t.Errorf("owed %s round trip: %+v %v", o.Act, got, err) } } + tu := sampleTurn() + data, err = EncodeTurn(tu) + hasV("turn", data, err) + tu.V = Version + if got, err := DecodeTurn(data); err != nil || !SameTurn(got, tu) { + t.Errorf("turn round trip: %+v %v", got, err) + } + for _, field := range []string{`"waits-for":"feat/y.0123456789ab"`, `"waits-since":`, `"approved":`, `"step":`} { + if !strings.Contains(string(data), field) { + t.Errorf("the turn lacks %s: %s", field, data) + } + } + // ADR 0548 §3: a turn holds off every check but the one of a waiting delivery's own heads. + if !TurnHolds(tu, "fedcba987654") || TurnHolds(Turn{Waiting: tu.Waiting[:1]}, tu.Waiting[0].Heads) || !TurnHolds(tu, tu.Waiting[0].Heads) { + t.Error("TurnHolds: a turn holds other heads' checks and never its only delivery's own") + } for name, dec := range map[string]func([]byte) error{ + "turn": func(b []byte) error { _, err := DecodeTurn(b); return err }, "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 }, @@ -265,6 +291,27 @@ func TestGateValuesRefused(t *testing.T) { // 1201/AC5 } _, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"}) wantErr("note without ref", err, "ref") + + for name, c := range map[string]struct { + edit func(*Turn) + words []string + }{ + "no delivery": {func(t *Turn) { t.Waiting = nil }, []string{"no delivery", "deleted"}}, + "a repository in capitals": {func(t *Turn) { t.Repository = "novox/Mesh-SDK" }, []string{"lowercase"}}, + "no repository": {func(t *Turn) { t.Repository = "" }, []string{"repository"}}, + "no group": {func(t *Turn) { t.Waiting[0].Group = "" }, []string{"group"}}, + "a bad heads hash": {func(t *Turn) { t.Waiting[0].Heads = "xyz" }, []string{"xyz"}}, + "the same heads twice": {func(t *Turn) { t.Waiting[1].Heads = t.Waiting[0].Heads }, []string{"twice"}}, + "no Approve time": {func(t *Turn) { t.Waiting[1].Approved = time.Time{} }, []string{"Approve"}}, + "out of the Approve's order": {func(t *Turn) { t.Waiting[1].Approved = t.Waiting[0].Approved.Add(-time.Hour) }, []string{"order"}}, + "waits for no verdict key": {func(t *Turn) { t.Waiting[0].WaitsFor = "feat/y" }, []string{"verdict key"}}, + "a step and no check": {func(t *Turn) { t.Waiting[1].Step = "x" }, []string{"names no check"}}, + } { + tu := sampleTurn() + c.edit(&tu) + _, err = EncodeTurn(tu) + wantErr("turn with "+name, err, c.words...) + } _, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"}) wantErr("status without context", err, "context") } @@ -297,7 +344,8 @@ func TestGateSame(t *testing.T) { // 1201/AC6 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 { + MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 || + TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 { t.Error("a bucket or history differs from the decided layout") } } @@ -370,6 +418,21 @@ func TestKeysEscapeAndRoundTrip(t *testing.T) { // 1323/AC1 if r, n, h, a, err := ParseOwedKey(ok); err != nil || r != repo || n != 3 || h != "aaaa" || a != "view" { t.Errorf("owed round trip %q = %q %d %q %q %v", repo, r, n, h, a, err) } + // ADR 0548 §3: a repository's turn key is ., lowercase, each part escaped. + tk, err := TurnsKey(repo) + if err != nil || strings.Count(tk, ".") != 1 { + t.Errorf("turns key for repository %q = %q, %v", repo, tk, err) + } else if r, err := ParseTurnsKey(tk); err != nil || r != strings.ToLower(repo) { + t.Errorf("turns round trip %q = %q, %v", repo, r, err) + } + } + if k, err := TurnsKey("novox/Mesh-SDK"); err != nil || k != "novox.mesh-sdk" { + t.Errorf("turns key = %q, %v; want novox.mesh-sdk", k, err) + } + for _, bad := range []string{"", "novox", "novox.", ".x", "a.b.c", "Novox.mesh-sdk", "novox.a=zz"} { + if _, err := ParseTurnsKey(bad); err == nil { + t.Errorf("parse turns key %q accepted", bad) + } } if EscapeName("a.b") != "a=2eb" || EscapeName("a=b") != "a=3db" || EscapeName("a+b") != "a=2bb" { t.Errorf("escapes: %q %q %q", EscapeName("a.b"), EscapeName("a=b"), EscapeName("a+b")) diff --git a/go/delivery/turns.go b/go/delivery/turns.go new file mode 100644 index 0000000..b0e86c4 --- /dev/null +++ b/go/delivery/turns.go @@ -0,0 +1,170 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "time" +) + +// **The merge turns** (novox/hq ADR 0548 clauses 3 and 4). An approved merge holds a turn on each repository it goes into: +// before it reads the checker's verdicts for the merge, the gate writes one key per repository in its bucket turns, naming +// the deliveries that wait in the order of their Approve, the verdict key each waits for and since when. The gate is the +// key's one writer; the checker is granted to read the bucket, reads it absent as empty, and claims no check of a repository +// a turn names, except the check of the waiting delivery's own heads (TurnHolds). The key goes when the last delivery in it +// merged or left. +const ( + // TurnsBucket is the gate's bucket of merge turns. + TurnsBucket = GateModule + "_turns" + // TurnsHistory is the revisions the turns bucket keeps per key. + TurnsHistory = 5 +) + +// TurnsKey is the key, in TurnsBucket, of a repository's turn: `.`, lowercase, each part escaped (EscapeName), +// so the key names the repository the checker's verdicts name as a trunk whatever its case. +func TurnsKey(repository string) (string, error) { + repository = strings.ToLower(repository) + if _, err := escapeRepository(repository); err != nil { + return "", err + } + owner, name, _ := strings.Cut(repository, "/") + return EscapeName(owner) + "." + EscapeName(name), nil +} + +// ParseTurnsKey is the inverse of TurnsKey: the repository, lowercase. +func ParseTurnsKey(key string) (string, error) { + owner, name, ok := strings.Cut(key, ".") + if !ok || owner == "" || name == "" || strings.Contains(name, ".") { + return "", fmt.Errorf("delivery: %q is not a turns key, .", key) + } + o, err := UnescapeName(owner) + if err != nil { + return "", fmt.Errorf("delivery: %q is not a turns key: %w", key, err) + } + n, err := UnescapeName(name) + if err != nil { + return "", fmt.Errorf("delivery: %q is not a turns key: %w", key, err) + } + repository := o + "/" + n + if again, err := TurnsKey(repository); err != nil || again != key { + return "", fmt.Errorf("delivery: %q is not a turns key: it does not name a lowercase /", key) + } + return repository, nil +} + +// Turn is the value of a turns key: the repository and the approved deliveries that wait to merge into it, in the order of +// their Approve. A turn with no delivery is no value: its key is deleted. +type Turn struct { + V int `json:"v"` + Repository string `json:"repository"` + Waiting []TurnWait `json:"waiting"` +} + +// TurnWait is one approved delivery in a turn: its group (a lone pull request's branch), the hash of the heads the Approve +// covers (the checker's verdict key's hash, HeadsHash over its members), the Approve's ask key, when it was approved and since +// when it waits; and, while a check of another delivery runs on the repository, that check's verdict key, when the gate saw +// it start, and the step the checker says it is at. +type TurnWait struct { + Group string `json:"group"` + Heads string `json:"heads"` + Ask string `json:"ask,omitempty"` + Approved time.Time `json:"approved"` + Since time.Time `json:"since"` + // WaitsFor is the verdict key (`.`) of the running check the merge waits for; empty when none runs. + WaitsFor string `json:"waits-for,omitempty"` + WaitsSince time.Time `json:"waits-since,omitzero"` + Step string `json:"step,omitempty"` +} + +func (t Turn) check() error { + repository := strings.ToLower(t.Repository) + if repository != t.Repository { + return fmt.Errorf("delivery: the turn's repository %q is not lowercase", t.Repository) + } + if _, err := escapeRepository(repository); err != nil { + return err + } + if len(t.Waiting) == 0 { + return fmt.Errorf("delivery: the turn of %s names no delivery: its key is deleted instead", t.Repository) + } + seen := map[string]bool{} + for i, w := range t.Waiting { + if w.Group == "" { + return fmt.Errorf("delivery: a delivery in the turn of %s names no group", t.Repository) + } + if !isHex12(w.Heads) { + return fmt.Errorf("delivery: the heads hash %q in the turn of %s is not 12 lowercase hex digits", w.Heads, t.Repository) + } + if seen[w.Heads] { + return fmt.Errorf("delivery: the turn of %s names the heads %s twice", t.Repository, w.Heads) + } + seen[w.Heads] = true + if w.Approved.IsZero() || w.Since.IsZero() { + return fmt.Errorf("delivery: the delivery %s in the turn of %s says no time of its Approve or of its wait", w.Group, t.Repository) + } + if i > 0 && w.Approved.Before(t.Waiting[i-1].Approved) { + return fmt.Errorf("delivery: the turn of %s is not in the order of the Approve: %s was approved before %s", t.Repository, w.Group, t.Waiting[i-1].Group) + } + if w.WaitsFor != "" { + if _, _, err := ParseCheckerVerdictKey(w.WaitsFor); err != nil { + return fmt.Errorf("delivery: the delivery %s in the turn of %s waits for what is not a verdict key: %w", w.Group, t.Repository, err) + } + } else if !w.WaitsSince.IsZero() || w.Step != "" { + return fmt.Errorf("delivery: the delivery %s in the turn of %s says when and where a check runs, and names no check", w.Group, t.Repository) + } + } + return nil +} + +// TurnHolds says whether a turn holds off the check of the heads: it names a delivery of other heads. The check of a waiting +// delivery's own heads (the carry its merge waits for) is never held by its own turn. +func TurnHolds(t Turn, heads string) bool { + for _, w := range t.Waiting { + if w.Heads != heads { + return true + } + } + return false +} + +// EncodeTurn is t as JSON at Version; it refuses a turn with no delivery, a repository that is not lowercase /, +// a delivery without its group, heads hash or times, the same heads twice, an order other than the Approve's, and a check +// waited for that is not a verdict key. +func EncodeTurn(t Turn) ([]byte, error) { + if err := t.check(); err != nil { + return nil, err + } + t.V = Version + return json.Marshal(t) +} + +// DecodeTurn reads a turn; it refuses an unknown version (ErrUnknownVersion) and what EncodeTurn refuses. +func DecodeTurn(data []byte) (Turn, error) { + var t Turn + if err := decode(data, &t); err != nil { + return Turn{}, err + } + if err := t.check(); err != nil { + return Turn{}, err + } + return t, nil +} + +// SameTurn says whether two turns are the same value, so the gate writes only when they differ. +func SameTurn(a, b Turn) bool { + if len(a.Waiting) != len(b.Waiting) || a.Repository != b.Repository || a.V != b.V { + return false + } + for i := range a.Waiting { + x, y := a.Waiting[i], b.Waiting[i] + if !x.Approved.Equal(y.Approved) || !x.Since.Equal(y.Since) || !x.WaitsSince.Equal(y.WaitsSince) { + return false + } + x.Approved, y.Approved, x.Since, y.Since, x.WaitsSince, y.WaitsSince = time.Time{}, time.Time{}, time.Time{}, time.Time{}, time.Time{}, time.Time{} + if !reflect.DeepEqual(x, y) { + return false + } + } + return true +} From f5c233a9288c097840fd51df4567dc91f3b98fb2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 11:25:07 +0100 Subject: [PATCH 2/3] =?UTF-8?q?delivery:=20a=20verdict=20names=20the=20sib?= =?UTF-8?q?lings=20it=20read=20and=20the=20commits=20it=20was=20carried=20?= =?UTF-8?q?from,=20and=20a=20composition=20its=20fingerprint=20set,=20so?= =?UTF-8?q?=20a=20trunk=20or=20sibling=20move=20is=20carried,=20not=20judg?= =?UTF-8?q?ed=20again=20(ADR=200548=20=C2=A75,=20=C2=A76;=20issue=20568=20?= =?UTF-8?q?S2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/delivery/checker.go | 39 +++++++++++++++++++++++++++++++++++++ go/delivery/checker_test.go | 28 +++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/go/delivery/checker.go b/go/delivery/checker.go index e23a51e..6b07ede 100644 --- a/go/delivery/checker.go +++ b/go/delivery/checker.go @@ -115,6 +115,10 @@ type Composition struct { Words string `json:"words,omitempty"` SDK string `json:"sdk,omitempty"` Machines []string `json:"machines,omitempty"` + // FPSet is the candidate's fingerprint set (Candidate.FPSet) the composition judged: a carry keeps a composition that + // passed while the candidate's set is the same, and composes again when a fingerprint changed (novox/hq ADR 0548 §5). + // Absent in a value written before it, which a carry composes again. + FPSet string `json:"fpset,omitempty"` } // Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning, @@ -166,6 +170,14 @@ type Verdict struct { Composition *Composition `json:"composition,omitempty"` // OverBound names the levels whose running went over their bound; it does not change the State (ADR 0453 clause 9). OverBound []string `json:"over-bound,omitempty"` + // Beside is the commit of each sibling repository the check read beside the repositories it merged (ADR 0484 §4), by + // repository: a move of one puts the verdict out of date as a trunk move does, and it is carried (novox/hq ADR 0548 §6). + // A repository is in Trunk or here, never both. Absent in a value written before it. + Beside map[string]string `json:"beside,omitempty"` + // CarriedFrom names, of a carried verdict, the trunk and sibling commits it was judged on before the moves it was + // carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages + // whose digest the moves changed. Absent from a verdict judged whole, and from one still running. + CarriedFrom map[string]string `json:"carried-from,omitempty"` } func (c *Composition) check() error { @@ -203,6 +215,33 @@ func (v Verdict) check() error { return fmt.Errorf("delivery: the verdict's trunk head of %s is empty", repository) } } + for repository, commit := range v.Beside { + if commit == "" { + return fmt.Errorf("delivery: the verdict's sibling commit of %s is empty", repository) + } + if _, ok := v.Trunk[repository]; ok { + return fmt.Errorf("delivery: the verdict names %s as a trunk it merged and as a sibling it read", repository) + } + } + if len(v.CarriedFrom) > 0 { + if v.State == VerdictRunning { + return fmt.Errorf("delivery: a running verdict is carried from nothing: carried-from is written with its end") + } + moved := false + for repository, commit := range v.CarriedFrom { + now, inTrunk := v.Trunk[repository] + if !inTrunk { + now = v.Beside[repository] + } + if commit == "" || now == "" { + return fmt.Errorf("delivery: the verdict's carried-from names %s, which it judged on no commit of", repository) + } + moved = moved || commit != now + } + if !moved { + return fmt.Errorf("delivery: the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried") + } + } for level, r := range v.Levels { if err := checkLevel(level); err != nil { return err diff --git a/go/delivery/checker_test.go b/go/delivery/checker_test.go index 815577a..b20af2e 100644 --- a/go/delivery/checker_test.go +++ b/go/delivery/checker_test.go @@ -29,7 +29,12 @@ func verdictSamples() map[string]Verdict { failTest.Levels = map[string]LevelResult{LevelUnit: {Run: 1}} failMachine := passVerdict() failMachine.State, failMachine.Failed = VerdictFail, FailedFirst{Machine: "ace", Words: "path /etc/x is owned by two modules"} - return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine} + carried := passVerdict() + carried.Beside = map[string]string{"novox/mesh-catalog": "cccc"} + carried.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999", "novox/mesh-control": "bbbb", "novox/mesh-catalog": "cccc"} + carried.Composition = &Composition{State: CompositionPass, SDK: carried.SDK, Machines: []string{"ace"}, FPSet: "fps1:00"} + return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine, + "carried": carried} } func keptSample() Kept { @@ -248,7 +253,14 @@ func TestVerdictCarriesSkippedCompositionAndOverBound(t *testing.T) { if err != nil { t.Fatal(err) } - for _, field := range []string{`"skipped":4`, `"composition":{"state":"pass"`, `"over-bound":["integration"]`} { + v.Beside, v.CarriedFrom = map[string]string{"novox/mesh-catalog": "cccc"}, map[string]string{"novox/mesh-catalog": "cbcb"} + v.Composition.FPSet = "fps1:ab" + data, err = EncodeVerdict(v) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{`"skipped":4`, `"composition":{"state":"pass"`, `"over-bound":["integration"]`, `"beside":{"novox/mesh-catalog":"cccc"}`, + `"carried-from":{"novox/mesh-catalog":"cbcb"}`, `"fpset":"fps1:ab"`} { if !strings.Contains(string(data), field) { t.Errorf("the value lacks %s: %s", field, data) } @@ -295,6 +307,13 @@ func TestVerdictNewFieldsChecked(t *testing.T) { "a not-run composition naming machines": {func(v *Verdict) { v.Composition = &Composition{State: CompositionNotRun, Machines: []string{"ace"}} }, []string{"composition", "not run"}}, "an unknown over-bound level": {func(v *Verdict) { v.OverBound = []string{"e2e"} }, []string{"e2e"}}, "an over-bound level named twice": {func(v *Verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, []string{"over-bound", "unit"}}, + // ADR 0548 §5, §6: the siblings read and the commits a verdict was carried from. + "a sibling with no commit": {func(v *Verdict) { v.Beside = map[string]string{"novox/mesh-catalog": ""} }, []string{"sibling", "mesh-catalog"}}, + "a trunk named as a sibling": {func(v *Verdict) { v.Beside = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"trunk", "sibling", "mesh-sdk"}}, + "carried from an unjudged repo": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/other": "1111"} }, []string{"carried-from", "novox/other"}}, + "carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}}, + "carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}}, + "a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}}, } for name, c := range cases { v := passVerdict() @@ -319,7 +338,7 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) { if err != nil { t.Fatalf("the new reader of an old value: %v", err) } - if v.Composition != nil || v.OverBound != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 { + if v.Composition != nil || v.OverBound != nil || v.Beside != nil || v.CarriedFrom != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 { t.Errorf("an old value grew fields: %+v", v) } again, err := EncodeVerdict(v) @@ -330,6 +349,9 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) { n.Levels[LevelUnit] = LevelResult{Run: 12, Kept: 301, Skipped: 4} n.Composition = &Composition{State: CompositionNotRun} n.OverBound = []string{LevelUnit} + n.Beside = map[string]string{"novox/mesh-catalog": "cccc"} + n.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999"} + n.Composition.FPSet = "fps1:00" data, err := EncodeVerdict(n) if err != nil { t.Fatal(err) From 9c61918ac36f80f5ec694040be10738fdfb59487 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 12:12:53 +0100 Subject: [PATCH 3/3] =?UTF-8?q?delivery:=20a=20turn=20never=20holds=20the?= =?UTF-8?q?=20check=20of=20heads=20that=20wait=20in=20it,=20so=20two=20app?= =?UTF-8?q?roved=20merges=20in=20one=20turn=20never=20hold=20each=20other'?= =?UTF-8?q?s=20carry=20(ADR=200548=20=C2=A73;=20issue=20568,=20review=20ro?= =?UTF-8?q?und=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/delivery/gate_test.go | 6 +++--- go/delivery/turns.go | 14 +++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go index 227c81e..71ea691 100644 --- a/go/delivery/gate_test.go +++ b/go/delivery/gate_test.go @@ -202,9 +202,9 @@ func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4 t.Errorf("the turn lacks %s: %s", field, data) } } - // ADR 0548 §3: a turn holds off every check but the one of a waiting delivery's own heads. - if !TurnHolds(tu, "fedcba987654") || TurnHolds(Turn{Waiting: tu.Waiting[:1]}, tu.Waiting[0].Heads) || !TurnHolds(tu, tu.Waiting[0].Heads) { - t.Error("TurnHolds: a turn holds other heads' checks and never its only delivery's own") + // ADR 0548 §3: a turn holds off every check but those of the heads that wait in it, whoever else waits there. + if !TurnHolds(tu, "fedcba987654") || TurnHolds(tu, tu.Waiting[0].Heads) || TurnHolds(tu, tu.Waiting[1].Heads) || TurnHolds(Turn{}, "fedcba987654") { + t.Error("TurnHolds: a turn holds other heads' checks and never the check of heads waiting in it") } for name, dec := range map[string]func([]byte) error{ "turn": func(b []byte) error { _, err := DecodeTurn(b); return err }, diff --git a/go/delivery/turns.go b/go/delivery/turns.go index b0e86c4..e9f0000 100644 --- a/go/delivery/turns.go +++ b/go/delivery/turns.go @@ -117,15 +117,19 @@ func (t Turn) check() error { return nil } -// TurnHolds says whether a turn holds off the check of the heads: it names a delivery of other heads. The check of a waiting -// delivery's own heads (the carry its merge waits for) is never held by its own turn. +// TurnHolds says whether a turn holds off the check of the heads: it names deliveries, and none of them of these heads. A +// turn never holds the check of heads that wait in it (the carry a waiting merge needs): two approved merges in one turn whose +// trunk moved would otherwise each hold the other's carry, and neither would ever merge (review of ADR 0548's build, round 1). func TurnHolds(t Turn, heads string) bool { + if len(t.Waiting) == 0 { + return false + } for _, w := range t.Waiting { - if w.Heads != heads { - return true + if w.Heads == heads { + return false } } - return false + return true } // EncodeTurn is t as JSON at Version; it refuses a turn with no delivery, a repository that is not lowercase /,