GR11 (A-51, §388): the grants part's first-view-unanswered, first-view-empty and binding-unlisted through the held finding writer
The part's state `findings`, its finding stream `mesh-grants-findings` and the consume of `mesh-conditions.findings-asked` are declared in module.json; the process makes mesh-sdk's held writer (Asked from the contract) and starts it once its state is bound. Its other conditions stay said to the operator channel. gofmt of bus.go and grants_test.go. Builds: design 47 G §388 (G-8), §529 (G-12), A-51 (G-20) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<kind>` 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.<kind>.<id>` 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, `<seat>.<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).
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user