diff --git a/modules/mesh-grants/cmd/mesh-grants/bus.go b/modules/mesh-grants/cmd/mesh-grants/bus.go index d7c3e598..16d1610f 100644 --- a/modules/mesh-grants/cmd/mesh-grants/bus.go +++ b/modules/mesh-grants/cmd/mesh-grants/bus.go @@ -223,4 +223,6 @@ type emitting struct{ c *process.Conn } func (e emitting) Emit(event string, body any) error { return e.c.Emit(event, body) } -func (e emitting) EmitSeat(seat, event string, body any) error { return e.c.EmitSeat(seat, event, body) } +func (e emitting) EmitSeat(seat, event string, body any) error { + return e.c.EmitSeat(seat, event, body) +} diff --git a/modules/mesh-grants/cmd/mesh-grants/integration_test.go b/modules/mesh-grants/cmd/mesh-grants/integration_test.go index 3d2e578b..c63b7a6b 100644 --- a/modules/mesh-grants/cmd/mesh-grants/integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/integration_test.go @@ -85,6 +85,8 @@ func TestTheGrantsPartWhole(t *testing.T) { for _, c := range []jetstream.StreamConfig{ {Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}, {Name: "CONDITIONS", Subjects: []string{"mesh.seat.operator-channel.accept.condition.>"}}, + // The part's finding stream (design 47 G §529): where its four findings are said by the held finding writer. + {Name: grants.Module + "-findings", Subjects: []string{"mesh.stream." + grants.Module + ".event.>"}}, } { if _, err := js.CreateStream(ctx, c); err != nil { t.Fatal(err) @@ -280,6 +282,34 @@ func TestTheGrantsPartWhole(t *testing.T) { out[c.Key] = string(c.State) } } + // The four findings the part moved to its writer (design 47 G §388, A-51) are said on its finding stream, never to the + // operator channel: they show here as the conditions they were, as last said. + fs, err := js.Stream(ctx, grants.Module+"-findings") + if err != nil { + return out + } + finfo, _ := fs.Info(ctx) + for seq := finfo.State.FirstSeq; seq != 0 && seq <= finfo.State.LastSeq; seq++ { + raw, err := fs.GetMsg(ctx, seq) + if err != nil { + continue + } + var f struct{ Kind, ID string } + if json.Unmarshal(raw.Data, &f) != nil || f.Kind == "" { + continue + } + id, err := keys.UnescapeName(f.ID) + if err != nil { + continue + } + state := "raised" + if strings.Contains(raw.Subject, ".event.finding-cleared.") { + state = "cleared" + } else if !strings.Contains(raw.Subject, ".event.finding-written.") { + continue + } + out[grants.Module+"."+f.Kind+"."+id] = state + } return out } bindingsBoundAndWaits := func() { diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index b8eb059c..27f4f768 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -36,6 +36,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" @@ -125,9 +126,26 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // The owners' five states by their first views (ADR 0615, ADR 0622): asked through the part's own account, then // followed on the owners' views families, which the hearing below hands them. sayCond := sayVia(ctx, js, account.Module) + // The four findings the part says of what it waits for go through mesh-sdk's held finding writer (design 47 G §388, §529, + // A-51): it holds them, and states them at its start and when the keeper asks. It starts once its state is bound; what the + // part writes before is held and written at its start. + fw, err := views.NewHeldWriter(views.WriterConfig[mcseat.Finding, mcseat.FindingCleared, mcseat.FindingsStated, mcseat.FindingsAskedEvent]{ + Module: account.Module, State: conn.State(grants.StateFindings), Node: account.Node, Kinds: grants.FindingKinds, + Asked: mcseat.SubjectFindingsAsked, JS: js, Conn: nc, Logf: logger.Printf}) + if err != nil { + return err + } + findingsKV := process.AwaitBucket(ctx, js, nc, account.Module+"_"+grants.StateFindings, + "the part's findings are held and said when its state is bound", logger.Printf) + findingsKV.Ready(func(jetstream.KeyValue) { + if err := fw.Start(ctx); err != nil { + logger.Printf("the finding writer cannot start, its findings are kept in the state alone: %v", err) + } + }) + sayFinding := grants.SayFinding(fw, account.Node, time.Now) fv := grants.NewViews(func(tool string) func(context.Context, string) (views.Answer, error) { return grants.TimedAsk(tool, views.AskVia(grants.Unwrapped(conn.Call), tool), time.Now, logger.Printf) - }, sayCond, f.Wake, logger.Printf) + }, sayFinding, f.Wake, logger.Printf) fv.Rest, fv.Watching = bus, bus byState := map[string]*views.Follower{} for _, v := range grants.FirstViews { @@ -143,7 +161,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro f.HearKept = func() error { return hearRunning(ctx, account, js, f, logger.Printf) } // The first view of the bindings (design 25 bindings-first-view): the seat mesh-broker's list, asked at each start and by // hand, with the seat's events held until its answer is applied or its bound passes. - f.Part.Bindings = &grants.BindingsView{Say: sayCond, Changed: f.Wake, Logf: logger.Printf, + f.Part.Bindings = &grants.BindingsView{Say: sayFinding, Changed: f.Wake, Logf: logger.Printf, Bound: bindingsTiming.bound, Wait: bindingsTiming.wait, MaxWait: bindingsTiming.max, Ask: func(context.Context) (json.RawMessage, error) { return grants.Unwrapped(conn.Call)(grants.CallBrokerList, map[string]any{}) @@ -153,7 +171,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro gate := newSeatGate() gate.Follow(seatlist.ProcessBus(conn.Call, hub), f.Wake, logger.Printf) f.Part.SeatList = gate - f.Part.Say = sayCond + f.Part.Say, f.Part.SayFinding = sayCond, sayFinding fv.Start(ctx, f.Wake) f.Asked = func(wake func()) error { return conn.State(grants.StateUserList).Watch(grants.KeyAsked, func(c stdio.StateChange) error { diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index ce59281c..bfe7edae 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -64,7 +64,7 @@ type fakeBus struct { events []string // seatEvents are the seat events said, as seat.event. seatEvents []string - writes []string + writes []string // Each membership's subject: the last membership said there, every notice, and whether it is refused. stream map[string][]byte publishes []string @@ -3660,7 +3660,7 @@ func TestGrants(t *testing.T) { } waitFor(t, refusedOnce.Load) p := partOn(b) - p.Read, p.Views, p.Say = fv, fv, say + p.Read, p.Views, p.Say, p.SayFinding = fv, fv, say, say before := p.Compose() // The raise the condition keeper did not take was said again by the pass. if !raised(grants.FirstViewUnanswered+".mesh-delivery-indexer.verdicts", occ.StateRaised) { diff --git a/modules/mesh-grants/internal/grants/accounts.go b/modules/mesh-grants/internal/grants/accounts.go index 07367198..20fcfa4a 100644 --- a/modules/mesh-grants/internal/grants/accounts.go +++ b/modules/mesh-grants/internal/grants/accounts.go @@ -33,6 +33,10 @@ func Announced() []announce.State { out = append(out, announce.State{Name: s, Readers: slices.Clone(AccountsReaders)}) continue } + if s == StateFindings { + out = append(out, announce.State{Name: s}) // the keeper hears findings as events: nobody reads the state + continue + } out = append(out, announce.State{Name: s, Readable: true}) } return out diff --git a/modules/mesh-grants/internal/grants/findings.go b/modules/mesh-grants/internal/grants/findings.go new file mode 100644 index 00000000..2595280e --- /dev/null +++ b/modules/mesh-grants/internal/grants/findings.go @@ -0,0 +1,72 @@ +package grants + +import ( + "context" + "fmt" + "strings" + "time" + + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// **The grants part's findings** (design 47 G §388, §529, A-51). The four kinds the part says of what it waits for (`view-waits`, +// `first-view-unanswered`, `first-view-empty`, `binding-unlisted`) are written through mesh-sdk's held finding writer of a process, +// in the part's own state `findings` and on its finding stream `mesh-grants-findings`; every other condition of the part stays one +// said to the operator channel (Part.Say). + +// StateFindings is the part's state of findings (design 47 G §529, a foundation part's old `state` section). +const StateFindings = "findings" + +// FindingKinds are the finding kinds the part writes beside the four of a views reader (views.ViewKinds), which its writer always +// owns: first-view-empty and binding-unlisted. +var FindingKinds = []string{kindOf(FirstViewEmpty), kindOf(BindingUnlisted)} + +// FindingWriter is the part's held finding writer (views.HeldWriter): what the part writes and clears its findings through. +type FindingWriter interface { + Write(ctx context.Context, f mcseat.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// kindOf is the finding kind of one of the part's condition keys: the key `mesh-grants.` without its module. +func kindOf(key string) string { return strings.TrimPrefix(key, Module+".") } + +// SayFinding makes the part's say of the conditions that are findings: a condition whose key is `mesh-grants..` is the +// finding of that kind and id (the id escaped as a key's name is), written while the condition is raised and cleared when it is +// cleared. node is the machine the part runs on. A condition of any other key is refused, so none goes through by mistake. +func SayFinding(w FindingWriter, node string, now func() time.Time) func(oc.Condition) error { + if now == nil { + now = time.Now + } + return func(c oc.Condition) error { + rest, ok := strings.CutPrefix(c.Key, Module+".") + kind, id, hasID := strings.Cut(rest, ".") + if !ok || !hasID || !isFindingKind(kind) { + return fmt.Errorf("%q is not one of the part's findings", c.Key) + } + id = keys.EscapeName(id) + ctx := context.Background() + if c.State != occ.StateRaised { + return w.Clear(ctx, views.FindingScope, id, kind, c.Resolved) + } + sev := c.Severity + if sev == "" { + sev = mcseat.FindingSeverityWarning + } + return w.Write(ctx, mcseat.Finding{Scope: views.FindingScope, ID: id, Kind: kind, Severity: sev, Source: "mesh-grants", + Summary: c.Summary, Headline: c.Headline, Needs: c.Needs, Explanation: c.Explanation, Observer: Module, At: now().UTC(), + Subject: mcseat.Subject{Scope: views.FindingScope, ID: Module, Machine: node}}) + } +} + +func isFindingKind(kind string) bool { + for _, k := range append([]string{views.ViewWaitsKind, views.UnansweredKind}, FindingKinds...) { + if k == kind { + return true + } + } + return false +} diff --git a/modules/mesh-grants/internal/grants/findings_test.go b/modules/mesh-grants/internal/grants/findings_test.go new file mode 100644 index 00000000..de6bb5e3 --- /dev/null +++ b/modules/mesh-grants/internal/grants/findings_test.go @@ -0,0 +1,67 @@ +package grants + +import ( + "context" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/views" + + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +type findingsFake struct { + written []mcseat.Finding + cleared [][4]string +} + +func (f *findingsFake) Write(_ context.Context, w mcseat.Finding) error { + f.written = append(f.written, w) + return nil +} + +func (f *findingsFake) Clear(_ context.Context, scope, id, kind, why string) error { + f.cleared = append(f.cleared, [4]string{scope, id, kind, why}) + return nil +} + +// Design 47 G §388, §529, A-51: the part's first-view-unanswered, first-view-empty and binding-unlisted are findings of the part's +// module, written while raised and cleared when cleared, never conditions said to the operator channel; any other key is refused. +func TestTheFourKindsAreSaidAsFindings(t *testing.T) { + at := time.Date(2026, 11, 11, 22, 0, 0, 0, time.UTC) + f := &findingsFake{} + say := SayFinding(f, "ace", func() time.Time { return at }) + fv := FirstViews[0] + if err := say(unansweredCondition(fv, views.Status{}, true)); err != nil { + t.Fatal(err) + } + if err := say(emptyCondition(fv, true)); err != nil { + t.Fatal(err) + } + if err := say(unlistedCondition("mod-c", "kept", true)); err != nil { + t.Fatal(err) + } + want := []struct{ kind, id string }{ + {"first-view-unanswered", "mesh-delivery-assigner.assignments"}, {"first-view-empty", "mesh-delivery-assigner.assignments"}, {"binding-unlisted", "mod-c.kept"}} + if len(f.written) != 3 { + t.Fatalf("written %+v", f.written) + } + for i, w := range want { + got := f.written[i] + if got.Kind != w.kind || got.Scope != "module" || got.Observer != Module || got.Subject.Machine != "ace" || !got.At.Equal(at) || + got.Severity != mcseat.FindingSeverityWarning || got.Summary == "" || got.Headline == "" { + t.Errorf("finding %d = %+v, want kind %s", i, got, w.kind) + } + } + if err := say(unlistedCondition("mod-c", "kept", false)); err != nil { + t.Fatal(err) + } + if len(f.cleared) != 1 || f.cleared[0][0] != "module" || f.cleared[0][2] != "binding-unlisted" || f.cleared[0][3] == "" { + t.Errorf("cleared %+v", f.cleared) + } + if err := say(oc.Condition{Key: RefusedCondition, State: occ.StateRaised}); err == nil || len(f.written) != 3 { + t.Errorf("a condition that is no finding was taken: %v", err) + } +} diff --git a/modules/mesh-grants/internal/grants/firstviews.go b/modules/mesh-grants/internal/grants/firstviews.go index 611cad1d..8ada2af3 100644 --- a/modules/mesh-grants/internal/grants/firstviews.go +++ b/modules/mesh-grants/internal/grants/firstviews.go @@ -440,7 +440,7 @@ func (p *Part) sayFirstViews(res *Result) { if s.Answered.IsZero() && !p.fvSaid.known { continue // not answered yet: nothing to say of its emptiness } - if p.Say == nil || p.Say(emptyCondition(fv, empty)) == nil { + if p.SayFinding == nil || p.SayFinding(emptyCondition(fv, empty)) == nil { p.fvSaid.empty[fv.Read] = empty } } diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index f2c9bf77..5a4669d5 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -35,7 +35,7 @@ const ( ) // States is every state the part keeps. -var States = []string{StateAccounts, StateAssertions, StateGrants, StateMemberships, StateRunning, StateUserList, StateWithheld} +var States = []string{StateAccounts, StateAssertions, StateFindings, StateGrants, StateMemberships, StateRunning, StateUserList, StateWithheld} // The events the part says. const ( @@ -171,7 +171,9 @@ type Part struct { Bus BusAddress // Say says a condition to the operator: the process's; nil in the tools. Say func(oc.Condition) error - Now func() time.Time + // SayFinding says the part's conditions that are findings (SayFinding, design 47 G §388): first-view-empty; nil in the tools. + SayFinding func(oc.Condition) error + Now func() time.Time // SeatList is the seat list reader, which gates a module whose seats the list has not named (inputs.go, SeatGate); nil // gates none. SeatList SeatGate diff --git a/modules/mesh-grants/internal/grants/rootfree.go b/modules/mesh-grants/internal/grants/rootfree.go index 36aacd36..b901a577 100644 --- a/modules/mesh-grants/internal/grants/rootfree.go +++ b/modules/mesh-grants/internal/grants/rootfree.go @@ -43,7 +43,11 @@ const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent // Consumes is every event the part hears, as its manifest's consumes name them. // The seat mesh-broker's created, recreated and dropped are heard as every seat's event, `.` on EVENTS (the // lead's ruling, registry milestone 13:0x), never on the bus module's own stream. -var Consumes = append(append(append(slices.Clone(seatEvents), seatListEvents...), viewsEvents()...), ConsumesRunning) +var Consumes = append(append(append(slices.Clone(seatEvents), seatListEvents...), viewsEvents()...), ConsumesRunning, ConsumesFindingsAsked) + +// ConsumesFindingsAsked is the keeper's ask the part's finding writer hears on a consumer of its own (design 47 G §390, §529): in +// the manifest's consumes for the grant, never handed to the event hub. +const ConsumesFindingsAsked = meshconditions.Seat + "." + meshconditions.EventFindingsAsked // ConsumesRunning is the seat node-engine's event running, in the local form the part's durable on its kept stream // filters (running.go): it is heard on that stream alone, never on the shared events stream (SeatEventSubjects leaves it out). diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 6d9865eb..08434e00 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -13,6 +13,7 @@ "consumes": [ "mesh-conditions.condition-raised", "mesh-conditions.condition-cleared", + "mesh-conditions.findings-asked", "mesh-vault.account-issued", "mesh-broker.created", "mesh-broker.dropped", @@ -77,6 +78,10 @@ "name": "assertions", "history": 5 }, + { + "name": "findings", + "history": 5 + }, { "name": "grants", "history": 5 @@ -98,6 +103,18 @@ "history": 5 } ], + "streams": [ + { + "name": "mesh-grants-findings", + "events": [ + "finding-written.*", + "finding-cleared.*", + "findings-stated.*" + ], + "max-age-days": 7, + "max-mib": 16 + } + ], "own-secrets": { "broker": "${dir:state}/broker" },