From f06dd01bfed1caa788147950c0e30b3f51d8789f Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:19:53 +0100 Subject: [PATCH] =?UTF-8?q?GATE=20A-39,=20A-29,=20A-37,=20A-30,=20A-40,=20?= =?UTF-8?q?=C2=A7592:=20the=20gate's=20findings=20go=20through=20one=20hel?= =?UTF-8?q?d=20writer=20over=20its=20own=20findings=20state,=20approval-ca?= =?UTF-8?q?rried-open=20is=20written=20for=20each=20approval=20carried=20a?= =?UTF-8?q?nd=20cleared=20by=20the=20seat=20git's=20pull-merged=20and=20pu?= =?UTF-8?q?ll-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G A-39, A-29, A-37, A-30, A-17, A-40, §542, §592 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-gate/findings.go | 97 ++++++++++++++ .../cmd/mesh-delivery-gate/findings_test.go | 123 ++++++++++++++++++ .../cmd/mesh-delivery-gate/gate.go | 2 + .../cmd/mesh-delivery-gate/keeps.go | 2 +- .../cmd/mesh-delivery-gate/main.go | 23 +++- .../cmd/mesh-delivery-gate/manifest_test.go | 8 +- .../cmd/mesh-delivery-gate/switch.go | 3 + modules/mesh-delivery-gate/go.mod | 1 + modules/mesh-delivery-gate/module.json | 24 +++- 9 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/findings.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/findings_test.go diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/findings.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/findings.go new file mode 100644 index 000000000..2594b5244 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/findings.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// The gate's own findings (design 47 G A-39, A-29, A-37, A-30, §592): they go through one held writer over the gate's own state +// `findings`, whose three events the manifest declares. The first is `approval-carried-open`: for each `approval-carried` the +// gate says, a carried Approve nobody uses is seen, and cleared when the seat `git` says that pull request merged or closed. + +// Module is the gate's module name, the observer of its findings. +const Module = "mesh-delivery-gate" + +// The scope and the kind of the finding a carried approval nobody uses is (design 47 §592). +const ( + FindingScope = "gate" + ApprovalCarriedOpen = "approval-carried-open" +) + +// pullMergedEvent and pullClosedEvent are the seat git's events that end a pull request, as the gate's manifest consumes them. +var ( + pullMergedEvent = declared(gitseat.EventPullMerged) + pullClosedEvent = declared(gitseat.EventPullClosed) +) + +// FindingWriter is what the gate holds of the one held writer (views.NewStdioHeldWriter over its state `findings`). +type FindingWriter interface { + Write(ctx context.Context, f meshconditions.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error + Held() []meshconditions.Finding +} + +// NewFindingWriter makes the gate's held writer over the state findings, hearing findings-asked on the subject of the +// generated contract of the seat that defines it (A-17). It is started once the runtime serves (A-40). +func NewFindingWriter() (*views.HeldWriter[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent], error) { + return views.NewStdioHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{ + Asked: meshconditions.SubjectFindingsAsked, Module: Module, State: stdio.State("findings"), Kinds: []string{ApprovalCarriedOpen}}) +} + +// approvalID is the id of a pull request's finding: its repository and number, each escaped as a key's name is. +func approvalID(repository string, number int64) string { + return keys.EscapeName(repository) + "." + keys.EscapeName(strconv.FormatInt(number, 10)) +} + +// approvalOpen writes the finding for an `approval-carried` the gate says, naming the head the Approve named, unless it holds it. +func (h *Holder) approvalOpen(repository string, number int, head string) error { + if h.Findings == nil { + return nil + } + id := approvalID(repository, int64(number)) + for _, f := range h.Findings.Held() { + if f.Scope == FindingScope && f.ID == id && f.Kind == ApprovalCarriedOpen { + return nil + } + } + at := h.Now().UTC() + pull := fmt.Sprintf("%s#%d", repository, number) + return h.Findings.Write(context.Background(), meshconditions.Finding{Scope: FindingScope, ID: id, Kind: ApprovalCarriedOpen, + Subject: meshconditions.Subject{Scope: FindingScope, ID: id}, + Severity: meshconditions.FindingSeverityWarning, Resolver: "operator", + Summary: fmt.Sprintf("the Approve of %s at %s, given before G, is carried and no merge or close of that pull request followed", pull, shortOf(head, 12)), + Headline: "An Approve carried over waits for its pull request", + Explanation: "The operator's Approve of this pull request, given before the delivery's switch, counts as its approval step. Nobody has merged or closed the pull request since.", + Needs: "merge the pull request through the seat git's merge-pull, or close it; the finding clears when the seat git says either.", + Source: "approval-carried", Observer: Module, At: at, Since: at}) +} + +// pullEnded clears the finding of a pull request the seat git said merged or closed; a pull request with none changes nothing. +func (h *Holder) pullEnded(body []byte, why string) error { + if h.Findings == nil { + return nil + } + var p struct { + Repository string `json:"repository"` + Number int64 `json:"number"` + } + if err := json.Unmarshal(body, &p); err != nil || p.Repository == "" || p.Number <= 0 { + h.Refuse(fmt.Errorf("a %s of the seat git is not readable: %v", why, err)) + return nil + } + id := approvalID(p.Repository, p.Number) + for _, f := range h.Findings.Held() { + if f.Scope == FindingScope && f.ID == id && f.Kind == ApprovalCarriedOpen { + return h.Findings.Clear(context.Background(), FindingScope, id, ApprovalCarriedOpen, "the pull request was "+why) + } + } + return nil +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/findings_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/findings_test.go new file mode 100644 index 000000000..0e1fbdba2 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/findings_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// memState is the gate's state `findings` in memory. +type memState struct { + mu sync.Mutex + m map[string][]byte +} + +func (s *memState) Get(key string) (*stdio.StateEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + if v, ok := s.m[key]; ok { + return &stdio.StateEntry{Key: key, Value: v}, nil + } + return nil, nil +} +func (s *memState) Put(key string, value any) (uint64, error) { + raw, _ := json.Marshal(value) + s.mu.Lock() + defer s.mu.Unlock() + s.m[key] = raw + return 1, nil +} +func (s *memState) Delete(key string) error { s.mu.Lock(); delete(s.m, key); s.mu.Unlock(); return nil } +func (s *memState) Keys() (k []string, _ error) { + s.mu.Lock() + defer s.mu.Unlock() + for key := range s.m { + k = append(k, key) + } + return +} + +type oldFake map[string][]byte + +func (o oldFake) Keys(string) (k []string, _ error) { + for key := range o { + k = append(k, key) + } + return +} +func (o oldFake) Get(_, key string) ([]byte, uint64, error) { return o[key], 1, nil } + +// The gate's own finding (A-39, A-29, A-37, §592): written through the held writer for each approval carried, once, and cleared +// when the seat git says that pull request merged or closed; the writer's events are the manifest's. +func TestTheGatesApprovalCarriedOpenIsWrittenThroughItsHeldWriterAndClearedByTheSeatGit(t *testing.T) { + at := time.Date(2026, 11, 11, 21, 0, 0, 0, time.UTC) + head := strings.Repeat("1", 40) + state := &memState{m: map[string][]byte{}} + var mu sync.Mutex + var said []string + w, err := views.NewHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{ + Asked: meshconditions.SubjectFindingsAsked, Module: Module, State: state, Kinds: []string{ApprovalCarriedOpen}, Node: "ace", + Now: func() time.Time { return at }, + Publish: func(_ context.Context, subject string, _ []byte, _ string) error { + mu.Lock() + said = append(said, subject) + mu.Unlock() + return nil + }, + Listen: func(context.Context, string, func([]byte)) (func(), error) { return func() {}, nil }}) + if err != nil { + t.Fatal(err) + } + if err := w.Start(context.Background()); err != nil { + t.Fatal(err) + } + merge, merr := mdc.EncodeMerge(Merge{V: 1, Group: "feat/x", Ask: "merge.feat_x.0123456789ab", Approved: map[string]string{"novox/app@7": head, "novox/web@8": head, "novox/db@9": head}, + Pulls: []MergePull{{Head: mdc.Head{Repository: "novox/db", Number: 9, Head: head}, State: mdc.MergeMerged, Commit: "c0ffee"}}}.stored()) + h := &Holder{Logf: t.Logf, Now: func() time.Time { return at }, Old: oldFake{"feat_x": merge}, Findings: w, SaySeat: func(string, any, string) error { return nil }} + if merr != nil { + t.Fatal(merr) + } + if err := h.carryApprovals(); err != nil { + t.Fatal(err) + } + held := w.Held() + if len(held) != 2 { + t.Fatalf("held %+v", held) + } + for _, f := range held { + if f.Kind != ApprovalCarriedOpen || f.Scope != FindingScope || f.Observer != Module || !strings.Contains(f.Summary, head[:12]) { + t.Fatalf("finding %+v", f) + } + } + if id := approvalID("novox/app", 7); held[0].ID != id && held[1].ID != id { + t.Fatalf("no finding with id %s: %+v", id, held) + } + n := len(said) + if err := h.carryApprovals(); err != nil || len(said) != n { + t.Fatalf("said again: %v, %d events (was %d)", err, len(said), n) + } + // the seat git says one merged and the other closed; one it does not know changes nothing + if err := h.pullEnded([]byte(`{"repository":"novox/app","number":7}`), "merged"); err != nil { + t.Fatal(err) + } + if err := h.pullEnded([]byte(`{"repository":"novox/other","number":1}`), "merged"); err != nil || len(w.Held()) != 1 { + t.Fatalf("held %+v: %v", w.Held(), err) + } + if err := h.pullEnded([]byte(`{"repository":"novox/web","number":8}`), "closed"); err != nil || len(w.Held()) != 0 { + t.Fatalf("held %+v: %v", w.Held(), err) + } + got := strings.Join(said, " ") + for _, ev := range []string{"finding-written", "finding-cleared"} { + if !strings.Contains(got, ev) { + t.Fatalf("no %s said: %v", ev, said) + } + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gate.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gate.go index b0be2c524..2942e6dca 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gate.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/gate.go @@ -32,6 +32,8 @@ type Holder struct { Consumer *StepConsumer // SaySeat says an event of the seat mesh-delivery (recordevents.go); nil says none. SaySeat SeatSay + // Findings is the gate's one held writer of its own findings (findings.go); nil writes none. + Findings FindingWriter // Say says a condition to the operator, and Conditions keeps which the gate raised (conditions.go); nil says none. Say func(c oc.Condition) error Conditions ConditionStore diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go index ce50b04ef..2ee1d5c80 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go @@ -6,7 +6,7 @@ import "git.novox.be/novox/mesh-sdk/go/announce" // issue 1790): the module says it itself, from its code, before it serves, and a reader is granted what it said // (clause 3). A state is readable when another module's manifest reads it (keeps_test.go names the readers). keeps_test.go holds the list to the // manifest's `state`, and keeps_integration_test.go that the module announces it on a bus. -var Kept = []announce.State{{Name: "conditions"}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "source", Readable: true}} +var Kept = []announce.State{{Name: "conditions"}, {Name: "deliveries"}, {Name: "findings"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "source", Readable: true}} // announceKept says Kept through keeps, which is stdio.Keeps. func announceKept(keeps func(...announce.State) error) error { return keeps(Kept...) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index 8d2c4e5b6..86fb32541 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -6,6 +6,7 @@ package main import ( + "context" "errors" "fmt" "os" @@ -31,7 +32,7 @@ func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.n // The events this owner follows: the forge bridge's word on an act owed, and the step events the delivery records are built // from (consumer.go). -var followed = append([]string{owedEndedEvent}, stepPatterns()...) +var followed = append(append([]string{owedEndedEvent}, stepPatterns()...), pullMergedEvent, pullClosedEvent) func main() { settingsFile := os.Getenv("MESH_DELIVERY_GATE_SETTINGS") @@ -66,6 +67,20 @@ func main() { h.Records.Changed = h.recordChanged h.Old = kvOld{} h.Consumer = &StepConsumer{Records: h.Records, Families: stepFamilies, Now: h.Now, Logf: logf, Taken: h.TakenIn} + // The gate's one held writer of its own findings (design 47 G A-39, A-37, A-40): made at once, started once the runtime serves. + fw, err := NewFindingWriter() + if err != nil { + logf("%v", err) + os.Exit(1) + } + h.Findings = fw + go func() { + <-stdio.Served() + if err := fw.Start(context.Background()); err != nil { + logf("%v", err) + os.Exit(1) + } + }() l := &listening{now: "not yet: starting"} go run(h, l) if err := announceKept(stdio.Keeps); err != nil { @@ -130,6 +145,12 @@ func take(h *Holder, e stdio.Envelope) error { if e.Key == owedEndedEvent { return h.OwedEnded(e.Body) } + if e.Key == pullMergedEvent { + return h.pullEnded(e.Body, "merged") + } + if e.Key == pullClosedEvent { + return h.pullEnded(e.Body, "closed") + } return nil } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index 71837c6b1..551122b85 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest" @@ -136,7 +137,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { // From G's delivery the gate hears neither the bridge's merged and pushed messages nor the teller's answers nor the view's // reach (design 47 G §592, §593): it consumes the indexer's take-in.taken and the bridge's owed.ended, and the git events that clear // an approval carried. - if !reflect.DeepEqual(m.Consumes, followed) { + if want := append(slices.Clone(followed), meshconditions.Seat+"."+meshconditions.EventFindingsAsked); !reflect.DeepEqual(m.Consumes, want) { t.Fatalf("consumes %v, follows %v", m.Consumes, followed) } for _, c := range m.Consumes { @@ -146,6 +147,11 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { } } } + // A-30: the events its held writer says are declared in the manifest, and the two events that clear its finding are consumed. + if !strings.Contains(raw, `"finding-written.*"`) || !strings.Contains(raw, `"finding-cleared.*"`) || !strings.Contains(raw, `"findings-stated.*"`) || + pullMergedEvent == "" || pullClosedEvent == "" { + t.Fatalf("the finding events are not declared, or the pull events not consumed: %q %q", pullMergedEvent, pullClosedEvent) + } if !reflect.DeepEqual(m.Emits, []string{"transition"}) { // `group` and `merged` go with the merge states (§431) t.Fatalf("emits %v", m.Emits) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/switch.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/switch.go index dbc3098d1..ad1fc0a42 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/switch.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/switch.go @@ -263,6 +263,9 @@ func (h *Holder) carryApprovals() error { if err := h.SaySeat(subject, body, fmt.Sprintf("approval-carried.%s.%d", repository, number)); err != nil { return fmt.Errorf("the approval of %s#%d cannot be said carried: %w", repository, number, err) } + if err := h.approvalOpen(repository, number, head); err != nil { + return fmt.Errorf("the finding of the approval of %s#%d cannot be written: %w", repository, number, err) + } } } return nil diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index 2b9665c4d..e0e0f73d1 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index f4aff6a1c..f52ff2c01 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -18,7 +18,10 @@ ], "consumes": [ "mesh-delivery-forge-bridge.owed.ended", - "mesh-delivery-indexer.take-in.taken" + "mesh-delivery-indexer.take-in.taken", + "git.pull-merged", + "git.pull-closed", + "mesh-conditions.findings-asked" ], "emits": [ "transition" @@ -297,6 +300,25 @@ "name": "conditions", "storage": "key-value", "template": "state" + }, + { + "name": "findings", + "storage": "key-value", + "template": "state" + }, + { + "name": "finding-events", + "storage": "stream", + "template": "events", + "options": { + "events": [ + "finding-written.*", + "finding-cleared.*", + "findings-stated.*" + ], + "max-age-days": 7, + "max-mib": 16 + } } ], "packages": {