diff --git a/modules/mesh-grants/cmd/mesh-grants/integration_test.go b/modules/mesh-grants/cmd/mesh-grants/integration_test.go index 0189b327..2ed1c835 100644 --- a/modules/mesh-grants/cmd/mesh-grants/integration_test.go +++ b/modules/mesh-grants/cmd/mesh-grants/integration_test.go @@ -18,6 +18,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-delivery/sdk/views" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/membership" "git.novox.be/novox/mesh-sdk/go/process" @@ -56,7 +57,7 @@ func TestTheGrantsPartWhole(t *testing.T) { defer cancel() kvs := map[string]jetstream.KeyValue{} - for _, state := range grants.Reads { + for _, state := range append(append([]string(nil), grants.Reads...), grants.ViewReads...) { kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucketOf(state), History: 5}) if err != nil { t.Fatal(err) @@ -148,6 +149,36 @@ func TestTheGrantsPartWhole(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = sub.Unsubscribe() }) + // DLV-F4r, DLV-F5r (ADR 0615 §4, ADR 0621 §3): the owners answer their first-view tools at once from what they hold, + // with its revision, as the indexer's and the assigner's runtimes of their own do; the part never reads their buckets. + var firstViewAsks atomic.Int32 + for _, owner := range []string{grants.IndexerModule, grants.AssignerModule} { + owner := owner + osub, err := nc.Subscribe("mesh.mod."+owner+".>", func(m *nats.Msg) { + for _, fv := range grants.FirstViews { + if fv.Owner != owner || !strings.Contains(m.Subject, "."+fv.State) { + continue + } + firstViewAsks.Add(1) + values, rev := map[string]json.RawMessage{}, uint64(0) + if keys, err := kvs[fv.Read].ListKeys(ctx); err == nil { + for k := range keys.Keys() { + if e, err := kvs[fv.Read].Get(ctx, k); err == nil { + values[k] = e.Value() + rev = max(rev, e.Revision()) + } + } + } + answer(m, views.Answer{V: views.Version, Owner: owner, State: fv.State, Revision: rev, + Created: time.Date(2026, 11, 4, 0, 0, 0, 0, time.UTC), Values: values, At: time.Now()}) + return + } + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = osub.Unsubscribe() }) + } // A membership is never said on its subject any more (issue 1126). var said atomic.Int32 if _, err := nc.Subscribe("mesh.assignment.>", func(*nats.Msg) { said.Add(1) }); err != nil { @@ -197,6 +228,9 @@ func TestTheGrantsPartWhole(t *testing.T) { if n := said.Load(); n != 0 { t.Errorf("%d memberships were said on their subject", n) } + if firstViewAsks.Load() < int32(len(grants.FirstViews)) { + t.Errorf("the part composed with %d first-view asks, want one of each of the %d", firstViewAsks.Load(), len(grants.FirstViews)) + } // DLV-G1, 1966/AC34 (novox/hq ADR 0595 §1, §7): the bus module's created, said on its own stream, reaches the part on // its durable consumer there, and the binding it carries is in the module's membership; dropped takes it out. until("the part's durable consumer on the bus module's stream", func() bool { diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index ee122fcd..7a192dbd 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -36,6 +36,8 @@ import ( "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-delivery/sdk/views" + "mesh-grants/internal/grants" ) @@ -104,12 +106,25 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } bus := newBusReader(ctx, js) - f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn}, + // 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) + fv := grants.NewViews(func(tool string) func(context.Context, string) (views.Answer, error) { + return views.AskVia(grants.Unwrapped(conn.Call), tool) + }, sayCond, f.Wake, logger.Printf) + fv.Rest, fv.Watching = bus, bus + byState := map[string]*views.Follower{} + for _, v := range grants.FirstViews { + byState[v.State] = fv.Followers[v.Read] + } + hearViews := views.Hear(ctx, byState, logger.Printf) + f.Part = &grants.Part{Read: fv, Call: conn, Store: ownStore{conn}, Emit: emitting{conn}, Views: fv, FirstViewContext: ctx, Create: creating{ctx: ctx, js: js}, Consumers: creating{ctx: ctx, js: js}, Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf, Bus: grants.BusAddress{URL: account.URL, Fingerprint: account.Fingerprint}} - f.Watch = bus - f.Part.Say = sayVia(ctx, js, account.Module) + f.Watch = fv + f.Part.Say = sayCond + fv.Start(ctx, f.Wake) f.Asked = func(wake func()) error { return conn.State(grants.StateUserList).Watch(grants.KeyAsked, func(c stdio.StateChange) error { if !c.Current { @@ -136,6 +151,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return fmt.Errorf("the consumer %s on %s: %w", name, stream, err) } if _, err := cons.Consume(func(m jetstream.Msg) { + // An owner's views event goes to the follower of its state, which acknowledges it (firstviews.go). + if strings.Contains(m.Subject(), ".event."+views.EventPrefix) { + hearViews(m) + return + } handle(m.Subject(), m.Data()) _ = m.Ack() }, jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { logger.Printf("hearing the bus: %v", err) })); err != nil { diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 5eb4e40d..636d16e9 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -1,11 +1,19 @@ package main import ( + "context" "encoding/json" + "fmt" + "maps" + "sort" "strings" + "sync" "time" + "git.novox.be/novox/mesh-delivery/sdk/views" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" "mesh-grants/internal/grants" ) @@ -13,12 +21,34 @@ import ( // The part's tools, served through the tool runner from its bundle (ADR 0436: a process never serves). They read and // write through the runtime as the module: its own states, the reads its manifest names, the bus module's read tools. -// runtimeReader reads another part's state through the runtime, `.`. -type runtimeReader struct{} +// runtimeReader reads another part's state through the runtime, `.`; the owners' five states the part +// reads by their first views (grants.ViewReads) it reads by asking each owner's first-view tool, once per tool call +// (fresh), never their buckets (ADR 0615 §1). +type runtimeReader struct{ views *toolViews } -func (runtimeReader) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } +func (r runtimeReader) Keys(state string) ([]string, error) { + if values, mine, err := r.views.of(state); mine { + if err != nil { + return nil, err + } + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out, nil + } + return stdio.State(state).Keys() +} -func (runtimeReader) Get(state, key string) ([]byte, bool, error) { +func (r runtimeReader) Get(state, key string) ([]byte, bool, error) { + if values, mine, err := r.views.of(state); mine { + if err != nil { + return nil, false, err + } + v, ok := values[key] + return v, ok, nil + } e, err := stdio.State(state).Get(key) if err != nil || e == nil { return nil, false, err @@ -26,6 +56,63 @@ func (runtimeReader) Get(state, key string) ([]byte, bool, error) { return e.Value, true, nil } +// toolViews are the five first views as one tool call reads them: each asked whole at its first read in the call, kept +// for the rest of it, and forgotten by fresh at the next call. +type toolViews struct { + mu sync.Mutex + held map[string]map[string]json.RawMessage +} + +func (v *toolViews) fresh() { + v.mu.Lock() + v.held = nil + v.mu.Unlock() +} + +// of is the whole picture of a first view's state, whether state is one, and why it could not be had. +func (v *toolViews) of(state string) (map[string]json.RawMessage, bool, error) { + var fv *grants.FirstView + for i := range grants.FirstViews { + if grants.FirstViews[i].Read == state { + fv = &grants.FirstViews[i] + } + } + if fv == nil { + return nil, false, nil + } + v.mu.Lock() + defer v.mu.Unlock() + if held, ok := v.held[state]; ok { + return held, true, nil + } + ask := views.AskVia(grants.Unwrapped(stdio.Ask), fv.Tool) + values := map[string]json.RawMessage{} + first := true + var rev uint64 + for after := ""; ; { + a, err := ask(context.Background(), after) + if err != nil { + return nil, true, fmt.Errorf("%s does not answer: %w", fv.Tool, err) + } + if first { + rev, first = a.Revision, false + } + maps.Copy(values, a.Values) + if a.Next == "" || a.Next == after { + break + } + after = a.Next + } + if rev == 0 { + return nil, true, fmt.Errorf("%s by %s: %w", state, fv.Tool, grants.ErrFirstViewNotYet) + } + if v.held == nil { + v.held = map[string]map[string]json.RawMessage{} + } + v.held[state] = values + return values, true, nil +} + // runtimeStore is the part's own state through the runtime. type runtimeStore struct{} @@ -142,11 +229,36 @@ func toolsOf(p *grants.Part) []stdio.Tool { } return p.AcceptHashes(stdio.CallerOf(a), users, grants.Answer(a, "why")) }}, + {Name: grants.ToolFirstViews, Description: "The grants part's first views of the owners' states (novox/hq ADR 0615 §4 to §6, " + + "ADR 0622): the assigner's placement and wants, the indexer's manifests, modules and verdicts, each with its owner, tool " + + "and the revision its picture holds, when it asked and when it was answered, how many asks and hand acts, and whether " + + "its condition is raised, as the part's process last kept them. Read-only.", + Run: func(map[string]any) (any, error) { return p.FirstViewsOf() }}, + {Name: grants.ToolFirstViewAgain, Description: "Ask one of the grants part's first views once more now (placement, wants, " + + "manifests, modules or verdicts; none for all), by a person's act with why, recorded as a hand act: the step after an " + + "owner's state was written again from its sources (an empty answer at revision 0, condition " + + "mesh-grants.first-view-empty). Asks the part's process, which alone follows them; grants_first_views shows the answer.", + Input: schema(map[string]string{"state": "placement, wants, manifests, modules or verdicts; none for all", "why": "why, kept in the hand act"}, "why"), + Run: func(a map[string]any) (any, error) { + return p.AskFirstViewAgain(stdio.CallerOf(a), grants.Answer(a, "state"), grants.Answer(a, "why"), recordHandAct) + }}, } } +// recordHandAct records a person's act through the seat mesh-hand-acts. +func recordHandAct(what, why string) error { + _, err := handacts.Recorder{Ask: stdio.Ask}.Record(handacts.Request{What: what, Why: why, Cause: "first-view-again"}) + return err +} + // serveTools serves the tools to the runtime until it stops. func serveTools() error { - p := &grants.Part{Read: runtimeReader{}, Call: runtimeBus{}, Store: runtimeStore{}, Emit: runtimeBus{}} - return stdio.Serve("", toolsOf(p)) + tv := &toolViews{} + p := &grants.Part{Read: runtimeReader{views: tv}, Call: runtimeBus{}, Store: runtimeStore{}, Emit: runtimeBus{}} + tools := toolsOf(p) + for i := range tools { + run := tools[i].Run + tools[i].Run = func(a map[string]any) (any, error) { tv.fresh(); return run(a) } + } + return stdio.Serve("", tools) } diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index ee23823d..bb7210ea 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.0 // indirect @@ -20,3 +21,5 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.15.0 // indirect ) + +replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 9a64e6fa..f5e59d03 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -15,11 +15,13 @@ import ( "slices" "strings" "sync" + "sync/atomic" "testing" "time" "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-delivery/sdk/views" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -2302,6 +2304,122 @@ func TestGrants(t *testing.T) { } }) + // DLV-F4r, DLV-R1 (novox/hq ADR 0615 §4 to §6, ADR 0622; design 25 § 12.2): the five owners' states are read by their + // first views; an answer empty at revision 0 drops no login and raises first-view-empty with the operator's step until an + // answer above 0; an owner down past the bound raises first-view-unanswered, cleared by its answer; an event applies a + // change with no ask; grants_first_view_again refuses a state that is none of the five and records the hand act. + t.Run("first views: the owners' states by their first views, an empty answer at revision 0 dropping no login", func(t *testing.T) { + b := fixture() + b.loaded = shadowOf(t, b) + created := time.Date(2026, 11, 4, 9, 0, 0, 0, time.UTC) + var down, empty atomic.Bool + empty.Store(true) + owner := func(tool string) func(context.Context, string) (views.Answer, error) { + return func(context.Context, string) (views.Answer, error) { + if down.Load() && strings.HasSuffix(tool, ".verdicts") { + return views.Answer{}, errors.New("no responders") + } + for _, fv := range grants.FirstViews { + if fv.Tool != tool { + continue + } + if empty.Load() && fv.State == "placement" { + return views.Answer{Revision: 0, Created: created, Values: map[string]json.RawMessage{}}, nil + } + values := map[string]json.RawMessage{} + b.mu.Lock() + for k, v := range b.states[fv.Read] { + values[k] = v + } + b.mu.Unlock() + return views.Answer{Revision: 7, Created: created, Values: values}, nil + } + return views.Answer{}, errors.New("no such tool " + tool) + } + } + var said []asks.Condition + var sayMu sync.Mutex + say := func(c asks.Condition) error { sayMu.Lock(); said = append(said, c); sayMu.Unlock(); return nil } + fv := grants.NewViews(owner, say, func() {}, nil) + fv.Rest = b + var minutes atomic.Int64 + for _, f := range fv.Followers { + f.Wait = time.Nanosecond + f.Now = func() time.Time { return created.Add(time.Duration(minutes.Add(1)) * time.Minute) } + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + down.Store(true) + fv.Start(ctx, func() {}) + for _, f := range fv.Followers { + if f.Tool != grants.IndexerModule+".verdicts" { + <-f.Ready() + } + } + raised := func(key string, state string) bool { + sayMu.Lock() + defer sayMu.Unlock() + last := "" + for _, c := range said { + if c.Key == key { + last = c.State + } + } + return last == state + } + waitFor(t, func() bool { + return raised(grants.FirstViewUnanswered+".mesh-delivery-indexer.verdicts", asks.StateRaised) + }) + p := partOn(b) + p.Read, p.Views, p.Say = fv, fv, say + before := p.Compose() + if !slices.ContainsFunc(before.UserList.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) { + t.Fatalf("placement answered empty at revision 0 was not a failed read: %+v", before.UserList.Gaps) + } + if !raised(grants.FirstViewEmpty+".mesh-delivery-assigner.placement", asks.StateRaised) { + t.Fatalf("no first-view-empty: %+v", said) + } + empty.Store(false) + down.Store(false) + if _, err := fv.Again(ctx, "placement", "the assigner composed its wants again"); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return fv.Followers[grants.ReadPlacement].Status().Revision == 7 }) + waitFor(t, func() bool { + return raised(grants.FirstViewUnanswered+".mesh-delivery-indexer.verdicts", asks.StateCleared) + }) + after := p.Compose() + if slices.ContainsFunc(after.UserList.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement }) || + !raised(grants.FirstViewEmpty+".mesh-delivery-assigner.placement", asks.StateCleared) { + t.Fatalf("after an answer above 0: gaps %+v, said %+v", after.UserList.Gaps, said) + } + if after.UserList.Users != before.UserList.Users && before.UserList.Users > after.UserList.Users { + t.Errorf("the empty first view dropped users: %d then %d", before.UserList.Users, after.UserList.Users) + } + // An event of the owner's views family changes the picture with no ask. + asksBefore := fv.Followers[grants.ReadPlacement].Status().Asks + raw, _ := json.Marshal(map[string]any{"v": 1, "node": "c"}) + if gap, why := fv.Followers[grants.ReadPlacement].Apply(views.Event{V: 1, Owner: grants.AssignerModule, State: "placement", + Key: "placement.c", Revision: 8, Created: created, Value: raw}); gap { + t.Fatalf("a next revision was a gap: %s", why) + } + if _, found, _ := fv.Get(grants.ReadPlacement, "placement.c"); !found || fv.Followers[grants.ReadPlacement].Status().Asks != asksBefore { + t.Error("the event did not reach the picture, or asked") + } + // The tools: a state that is none of the five is refused; one is asked with its hand act. + var acts []string + record := func(what, why string) error { acts = append(acts, what+": "+why); return nil } + if _, err := p.AskFirstViewAgain("jochen@shanks", "builds", "x", record); err == nil || len(acts) != 0 { + t.Errorf("a state that is no first view was asked: %v %v", err, acts) + } + if _, err := p.AskFirstViewAgain("jochen@shanks", "verdicts", "after the take-in", record); err != nil || len(acts) != 1 { + t.Errorf("verdicts again: %v %v", err, acts) + } + if got, err := p.FirstViewsOf(); err != nil || !strings.Contains(fmt.Sprint(got), "mesh-delivery-indexer.verdicts") { + t.Errorf("grants_first_views = %v, %v", got, err) + } + }) + // 774/AC4 (novox/hq ADR 0561 §2): every account the vault mints that the records compose is written to the state // accounts once, with the bus's address and a module's claims, read by the vault alone; a key is deleted when placement // no longer names its user, and never in a pass whose reads failed. diff --git a/modules/mesh-grants/internal/grants/firstviews.go b/modules/mesh-grants/internal/grants/firstviews.go new file mode 100644 index 00000000..8a42e848 --- /dev/null +++ b/modules/mesh-grants/internal/grants/firstviews.go @@ -0,0 +1,354 @@ +package grants + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/views" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// **The grants part's first views** (novox/hq ADR 0615 §1, §4 to §6, ADR 0621, ADR 0622; design 25 § 12.2; DLV-F4r, +// DLV-R1). The part reads the assigner's placement and wants and the indexer's manifests, modules and verdicts no longer +// from their buckets, but by each owner's first-view tool, asked once at its start, then from the owner's views family +// alone (each event one changed key with its revision), asking once more only on a gap in revisions, never on a timer. +// Started while an owner is down it keeps asking and never reads the owner's state; past the bound (two minutes) it +// raises FirstViewUnanswered naming the owner and the tool, cleared by the answer. +// +// **An answer empty at revision 0 drops no login**: until an owner answers above revision 0 the state reads as a failed +// read, so the part keeps the user list it has in force, and FirstViewEmpty stands, naming the owner and the operator's +// step (the owner writes its state again from its sources, then grants_first_view_again); an answer above 0 clears it. + +// FirstView is one owner's state the part reads by its first view: the owner, the tool its `invokes` names, the state, +// and the read it stands for in the part's inputs (`.`). +type FirstView struct { + Owner string `json:"owner"` + Tool string `json:"tool"` + State string `json:"state"` + Read string `json:"read"` +} + +// The owners. +const ( + AssignerModule = "mesh-delivery-assigner" + IndexerModule = "mesh-delivery-indexer" +) + +// FirstViews are the five (ADR 0615 §3 as ADR 0622 §1 amends it: the grants part's placement, wants, manifests, +// modules and verdicts). +var FirstViews = []FirstView{ + {Owner: AssignerModule, Tool: AssignerModule + ".placement", State: "placement", Read: ReadPlacement}, + {Owner: AssignerModule, Tool: AssignerModule + ".wants", State: "wants", Read: ReadWants}, + {Owner: IndexerModule, Tool: IndexerModule + ".manifests", State: "manifests", Read: ReadManifests}, + {Owner: IndexerModule, Tool: IndexerModule + ".modules", State: "modules", Read: ReadModules}, + {Owner: IndexerModule, Tool: IndexerModule + ".verdicts", State: "verdicts", Read: ReadVerdicts}, +} + +// The conditions of the first views, each naming the owner and its state. +const ( + FirstViewUnanswered = "mesh-grants.first-view-unanswered" + FirstViewEmpty = "mesh-grants.first-view-empty" +) + +// KeyFirstViews is the key of the part's own state user-list where its process keeps what each first view holds, which +// grants_first_views reads. +const KeyFirstViews = "first-views" + +// ErrFirstViewNotYet is a read of a first view not answered above revision 0: the part composes as from a failed read. +var ErrFirstViewNotYet = errors.New("not answered above revision 0 yet") + +// Views is the part's reads of the five owners' states through their first views: a Reader and a Watcher for those +// states, every other read going to Rest. +type Views struct { + // Followers are the five, by the read they stand for. + Followers map[string]*views.Follower + // Rest reads every other state, and Watching watches it (nil: watched by nobody, as in a tool). + Rest Reader + Watching Watcher +} + +// NewViews makes the five followers: ask calls an owner's tool (through the part's own account), say says a condition, +// changed wakes a pass. Started by Start. +func NewViews(ask func(tool string) func(context.Context, string) (views.Answer, error), say func(asks.Condition) error, + changed func(), logf func(string, ...any)) *Views { + v := &Views{Followers: map[string]*views.Follower{}} + for _, fv := range FirstViews { + fv := fv + f := &views.Follower{Owner: fv.Owner, Tool: fv.Tool, State: fv.State, Ask: ask(fv.Tool), Logf: logf, + Changed: func(string, json.RawMessage, bool, time.Time) { changed() }} + f.Raise = func(s views.Status) { + if say != nil { + _ = say(unansweredCondition(fv, s, true)) + } + changed() + } + f.Clear = func(s views.Status) { + if say != nil { + _ = say(unansweredCondition(fv, s, false)) + } + changed() + } + v.Followers[fv.Read] = f + } + return v +} + +// Start asks each first view on a goroutine of its own, until it is answered or ctx ends; each answer wakes a pass. +func (v *Views) Start(ctx context.Context, changed func()) { + for _, f := range v.Followers { + f := f + go func() { + if f.Start(ctx) == nil { + changed() + } + }() + } +} + +// Following says the follower of the owner's views event state, or nil. +func (v *Views) Following(owner, state string) *views.Follower { + for _, fv := range FirstViews { + if fv.Owner == owner && fv.State == state { + return v.Followers[fv.Read] + } + } + return nil +} + +// ready is the follower of read, answered above revision 0; nil and why when it is not one of the five, or not yet. +func (v *Views) ready(read string) (*views.Follower, bool, error) { + f, ok := v.Followers[read] + if !ok { + return nil, false, nil + } + select { + case <-f.Ready(): + default: + return f, true, fmt.Errorf("%s by %s: %w", read, f.Tool, ErrFirstViewNotYet) + } + if s := f.Status(); s.Revision == 0 { + return f, true, fmt.Errorf("%s by %s answered empty at revision 0: %w", read, f.Tool, ErrFirstViewNotYet) + } + return f, true, nil +} + +// Get reads one key: from its first view for the five, else from Rest. +func (v *Views) Get(state, key string) ([]byte, bool, error) { + f, mine, err := v.ready(state) + if !mine { + return v.Rest.Get(state, key) + } + if err != nil { + return nil, false, err + } + raw, ok := f.Get(key) + return raw, ok, nil +} + +// Keys lists a state's keys: from its first view for the five, else from Rest. +func (v *Views) Keys(state string) ([]string, error) { + f, mine, err := v.ready(state) + if !mine { + return v.Rest.Keys(state) + } + if err != nil { + return nil, err + } + values := f.Values() + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +// Watch watches Rest's states; the five are followed by their first views and events, never watched. +func (v *Views) Watch(state string, changed func(key string, value []byte), ended func(error)) error { + if _, mine := v.Followers[state]; mine || v.Watching == nil { + return nil + } + return v.Watching.Watch(state, changed, ended) +} + +// Statuses are what each first view holds now, in FirstViews' order. +func (v *Views) Statuses() []views.Status { + out := make([]views.Status, 0, len(FirstViews)) + for _, fv := range FirstViews { + out = append(out, v.Followers[fv.Read].Status()) + } + return out +} + +// Again asks the first view of one state (its state name, `placement`, or its tool) once more, or every one when state is +// empty, each on a goroutine of its own; it answers which it asked, or refuses a state that is none of the five. +func (v *Views) Again(ctx context.Context, state, why string) ([]string, error) { + var asked []string + for _, fv := range FirstViews { + if state != "" && state != fv.State && state != fv.Tool && state != fv.Read { + continue + } + f := v.Followers[fv.Read] + go func() { _ = f.Again(ctx, why) }() + asked = append(asked, fv.Tool) + } + if len(asked) == 0 { + return nil, fmt.Errorf("%q is no first view of the grants part: one of placement, wants, manifests, modules, verdicts, or none for all", state) + } + return asked, nil +} + +// unansweredCondition is FirstViewUnanswered for one first view, raised or cleared. +func unansweredCondition(fv FirstView, s views.Status, raised bool) asks.Condition { + c := asks.Condition{Key: FirstViewUnanswered + "." + fv.Owner + "." + fv.State, Subject: asks.Scope{Scope: "module", ID: Module}} + if !raised { + c.State = asks.StateCleared + c.Resolved = fv.Tool + " answered at revision " + fmt.Sprint(s.Revision) + return c + } + c.State, c.Severity = asks.StateRaised, asks.SeverityWarning + c.Summary = fmt.Sprintf("the grants part has no first view of %s's %s: %s has not answered since %s (%s)", fv.Owner, fv.State, + fv.Tool, s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error)) + c.Headline = "The grants part waits for " + fv.Owner + c.Explanation = "The grants part reads " + fv.Owner + "'s " + fv.State + " only through " + fv.Tool + ", and keeps asking it; " + + "it never reads the state itself, and keeps what it composed meanwhile (novox/hq ADR 0615 §6). The answer clears this." + c.Needs = "a look at " + fv.Owner + "'s runtime of its own: is it running and connected?" + return c +} + +// emptyCondition is FirstViewEmpty for one first view: raised while it answered empty at revision 0, with the +// operator's step, cleared by an answer above 0. +func emptyCondition(fv FirstView, raised bool) asks.Condition { + c := asks.Condition{Key: FirstViewEmpty + "." + fv.Owner + "." + fv.State, Subject: asks.Scope{Scope: "module", ID: Module}} + if !raised { + c.State = asks.StateCleared + c.Resolved = fv.Tool + " answered above revision 0" + return c + } + step := "the indexer takes each module in again (indexer_take_in)" + if fv.Owner == AssignerModule { + step = "the assigner composes the wants again at its next composition" + } + c.State, c.Severity = asks.StateRaised, asks.SeverityWarning + c.Summary = fmt.Sprintf("%s answered %s's %s empty at revision 0, so the grants part keeps the user list in force", fv.Tool, fv.Owner, fv.State) + c.Headline = fv.Owner + "'s " + fv.State + " is empty" + c.Explanation = asks.NeedsYou + "a step. The owner holds nothing of " + fv.State + " (its state rebuilt by a recreate that could " + + "not keep the data, or a fresh mesh), so the grants part drops no login and keeps what it holds; a login revoked meanwhile " + + "stays until the owner's first change (novox/hq design 25 § 12.2)." + c.Needs = "let the owner write its state again from its sources (" + step + "), then grants_first_view_again" + return c +} + +// firstViewsSaid is what the part last said and kept of its first views (sayFirstViews). +type firstViewsSaid struct { + mu sync.Mutex + empty map[string]bool + known bool +} + +// FirstViewsState is the value of KeyFirstViews: each first view's status, as the part's process last kept it. +type FirstViewsState struct { + V int `json:"v"` + Views []views.Status `json:"views"` +} + +// sayFirstViews keeps the first views' statuses in the part's own state (for grants_first_views) when they change, and +// raises or clears FirstViewEmpty for each answered at revision 0. Each pass calls it; nil Views is none. +func (p *Part) sayFirstViews(res *Result) { + if p.Views == nil { + return + } + statuses := p.Views.Statuses() + for i := range statuses { + statuses[i].Asked, statuses[i].Answered = statuses[i].Asked.Truncate(time.Second), statuses[i].Answered.Truncate(time.Second) + } + data, _ := json.Marshal(FirstViewsState{V: 1, Views: statuses}) + p.changed(res, StateUserList, KeyFirstViews, data) + p.fvSaid.mu.Lock() + defer p.fvSaid.mu.Unlock() + if p.fvSaid.empty == nil { + p.fvSaid.empty = map[string]bool{} + } + for i, fv := range FirstViews { + s := statuses[i] + empty := !s.Answered.IsZero() && s.Revision == 0 + if p.fvSaid.known && p.fvSaid.empty[fv.Read] == empty { + continue + } + 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 { + p.fvSaid.empty[fv.Read] = empty + } + } + p.fvSaid.known = true +} + +// FirstViewsOf answers the tool grants_first_views: what the part's process last kept of each first view (owner, tool, +// state, revision, when asked and answered, asks, hand acts, whether its condition is raised). +func (p *Part) FirstViewsOf() (any, error) { + raw, rev, found, err := p.Store.Get(StateUserList, KeyFirstViews) + if err != nil { + return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateUserList, KeyFirstViews, err) + } + if !found { + return map[string]any{"views": []any{}, "said": "the part's process has kept no first view yet: it has not passed since it started"}, nil + } + var st FirstViewsState + if err := json.Unmarshal(raw, &st); err != nil { + return nil, fmt.Errorf("its own %s %s does not read: %w", StateUserList, KeyFirstViews, err) + } + return map[string]any{"views": st.Views, "revision": rev, "first-views": FirstViews}, nil +} + +// AskFirstViewAgain answers the tool grants_first_view_again: it asks the part's process to ask one first view (by its +// state, or every one when state is empty) once more now, and records the hand act through record (the seat +// mesh-hand-acts); a state that is none of the five is refused before anything is asked. +func (p *Part) AskFirstViewAgain(caller, state, why string, record func(what, why string) error) (any, error) { + which := AllFirstViews + if state != "" { + which = "" + for _, fv := range FirstViews { + if state == fv.State || state == fv.Tool || state == fv.Read { + which = fv.State + } + } + if which == "" { + return nil, fmt.Errorf("%q is no first view of the grants part: one of placement, wants, manifests, modules, verdicts, or none for all", state) + } + } + if strings.TrimSpace(why) == "" { + return nil, errors.New("grants_first_view_again needs why: it is recorded as a hand act") + } + if record != nil { + if err := record("grants_first_view_again "+which, why); err != nil { + return nil, fmt.Errorf("the hand act could not be recorded, so nothing was asked: %w", err) + } + } + ask, _ := json.Marshal(Ask{By: caller, At: p.now(), FirstView: which}) + rev, err := p.Store.Put(StateUserList, KeyAsked, ask) + if err != nil { + return nil, fmt.Errorf("the ask could not be written: %w", err) + } + return map[string]any{"asked": rev, "first-view": which, + "says": "the part's process asks it once more at this ask; grants_first_views shows its answer"}, nil +} + +// Unwrapped is a call whose answer is read as the tool's own text where the runtime wraps it as content (unwrap), as +// a first view's answer is decoded (views.AskVia). +func Unwrapped(call func(key string, body any) (json.RawMessage, error)) func(key string, body any) (json.RawMessage, error) { + return func(key string, body any) (json.RawMessage, error) { + raw, err := call(key, body) + if err != nil { + return nil, err + } + return unwrap(raw), nil + } +} diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 52fad980..0b330afa 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -47,8 +47,11 @@ const ( // Reads is every state the part reads, in the manifest's order. Not the controller's assignments: the seat's placement // alone places modules (novox/hq ADR 0549 §5, ADR 0571 §10; issue 774). -var Reads = []string{ReadModules, ReadManifests, ReadVerdicts, ReadResolved, ReadHave, ReadWants, ReadBuilds, - ReadAccounts, ReadPlacement, ReadEnrolments, ReadNodeLogins} +var Reads = []string{ReadResolved, ReadHave, ReadBuilds, ReadAccounts, ReadEnrolments, ReadNodeLogins} + +// ViewReads are the owners' states the part reads by their first views and views families, never their buckets +// (firstviews.go, ADR 0615): each read in Reads' place. +var ViewReads = []string{ReadModules, ReadManifests, ReadVerdicts, ReadWants, ReadPlacement} // The bus module's tools the part calls, as its manifest's invokes name them: read-only, each. const ( @@ -58,7 +61,18 @@ const ( ) // Invokes is every tool the part calls. -var Invokes = []string{CallUsers, CallUserCan, CallStreams, CallRootFree} +var Invokes = append([]string{CallUsers, CallUserCan, CallStreams, CallRootFree, CallHandAct}, firstViewTools()...) + +// CallHandAct records a person's act (grants_first_view_again). +const CallHandAct = "seat:mesh-hand-acts.hand-act" + +func firstViewTools() []string { + var out []string + for _, fv := range FirstViews { + out = append(out, fv.Tool) + } + return out +} // Reader reads another part's state, named `.`. A refusal (a read not granted yet, a bucket not there) is // an error the part names as a gap and composes past: it never stops for one. diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 011ddb63..c23c9498 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -2,6 +2,7 @@ package grants import ( "bytes" + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -197,6 +198,11 @@ type Part struct { hashHold []string // brokers are the modules whose manifest claims mesh-broker at the last pass (bindings.go), guarded by mu. brokers map[string]bool + // Views are the part's first views of the owners' states (firstviews.go), and FirstViewContext the process's life + // they ask in: the process's; nil in the tools. + Views *Views + FirstViewContext context.Context + fvSaid firstViewsSaid } // keepBrokers keeps the modules whose manifest claims the seat mesh-broker: the only emitters whose created, recreated @@ -233,8 +239,13 @@ type Ask struct { // Consumer is, from grants_consumer_match, one account consumer `.` to bring to the composed // configuration by hand (ADR 0561 §6). Consumer string `json:"consumer,omitempty"` + // FirstView is, from grants_first_view_again, one first view to ask once more by hand (its state), or AllFirstViews. + FirstView string `json:"first-view,omitempty"` } +// AllFirstViews asks every first view once more. +const AllFirstViews = "all" + // asked is a new ask, read once per ask; the zero Ask when none. func (p *Part) asked() Ask { raw, rev, found, err := p.Store.Get(StateUserList, KeyAsked) @@ -449,6 +460,18 @@ func (p *Part) Compose() Result { } } p.consumers(&res, recs, ask.Consumer) + p.sayFirstViews(&res) + if ask.FirstView != "" && p.Views != nil && p.FirstViewContext != nil { + state := ask.FirstView + if state == AllFirstViews { + state = "" + } + if asked, err := p.Views.Again(p.FirstViewContext, state, "by hand: "+ask.By); err != nil { + res.Errors = append(res.Errors, err.Error()) + } else if p.Logf != nil { + p.Logf("asked the first views %v once more by hand, for %s", asked, ask.By) + } + } return res } diff --git a/modules/mesh-grants/internal/grants/rootfree.go b/modules/mesh-grants/internal/grants/rootfree.go index 1eb3ae0f..532af1fa 100644 --- a/modules/mesh-grants/internal/grants/rootfree.go +++ b/modules/mesh-grants/internal/grants/rootfree.go @@ -39,8 +39,17 @@ func FromKeeper(event string) bool { const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent // Consumes is every event the part hears, as its manifest's consumes name them. -var Consumes = []string{EventKeeperRaised, EventKeeperCleared, EventAccountIssued, BusModule + "." + BrokerCreated, - BusModule + "." + BrokerDropped, BusModule + "." + BrokerRecreated} +var Consumes = append([]string{EventKeeperRaised, EventKeeperCleared, EventAccountIssued, BusModule + "." + BrokerCreated, + BusModule + "." + BrokerDropped, BusModule + "." + BrokerRecreated}, viewsEvents()...) + +// viewsEvents are the owners' views events the part follows its first views by, `.views.`. +func viewsEvents() []string { + var out []string + for _, fv := range FirstViews { + out = append(out, fv.Owner+".views."+fv.State) + } + return out +} // IsAccountIssued says an event heard is the vault's account-issued, by its name or its bus subject. func IsAccountIssued(event string) bool { diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 6503d91f..a94e32d8 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -23,11 +23,14 @@ const ( ToolAccounts = "grants_accounts" ToolConsumerMatch = "grants_consumer_match" ToolAcceptHashes = "grants_accept_hashes" + // ToolFirstViews and ToolFirstViewAgain read and ask again the part's first views (firstviews.go). + ToolFirstViews = "grants_first_views" + ToolFirstViewAgain = "grants_first_view_again" ) // Tools is every tool, in the manifest's order. var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch, - ToolAcceptHashes} + ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain} // WritesGrants is what grants says of the part's own grants: written once the list the bus loaded carries a build's // accounts, and the one key the assigner reads (ADR 0571 clause 9, which changes ADR 0547 clause 5). diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index b361aed6..5d0f7dae 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -4,12 +4,7 @@ "slug": "grants", "reads": [ "mesh-config.resolved", - "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", - "mesh-delivery-indexer.manifests", - "mesh-delivery-indexer.modules", - "mesh-delivery-indexer.verdicts", - "mesh-delivery-assigner.placement", "node-engine.have", "mesh-vault.bus-accounts", "mesh-enrolment.enrolments", @@ -21,7 +16,12 @@ "mesh-vault.account-issued", "nats.created", "nats.dropped", - "nats.recreated" + "nats.recreated", + "mesh-delivery-assigner.views.placement", + "mesh-delivery-assigner.views.wants", + "mesh-delivery-indexer.views.manifests", + "mesh-delivery-indexer.views.modules", + "mesh-delivery-indexer.views.verdicts" ], "uses": [ "operator-channel" @@ -30,7 +30,13 @@ "nats.nats_streams", "nats.nats_user_can", "nats.nats_users", - "seat:node-engine.root-free" + "seat:node-engine.root-free", + "mesh-delivery-assigner.placement", + "mesh-delivery-assigner.wants", + "mesh-delivery-indexer.manifests", + "mesh-delivery-indexer.modules", + "mesh-delivery-indexer.verdicts", + "seat:mesh-hand-acts.hand-act" ], "emits": [ "asserted", @@ -47,7 +53,9 @@ "grants_assert", "grants_accounts", "grants_consumer_match", - "grants_accept_hashes" + "grants_accept_hashes", + "grants_first_views", + "grants_first_view_again" ], "state": [ {