DLV-F4r, DLV-R1: the grants part reads placement, wants, manifests, modules and verdicts by their owners' first views, then their views families; an answer empty at revision 0 drops no login (ADR 0615 §4 to §6, ADR 0621, ADR 0622)

Each first view is asked at the start until answered, past two minutes raising
first-view-unanswered naming the owner and the tool; an empty answer at revision 0 reads as
a failed read, so the user list stays in force, and first-view-empty names the operator's
step until an answer above 0. Each owner's views event applies one change with no ask; a gap
asks once more. The reads of the five buckets leave the manifest, and the tools ask the
first views too. Tools grants_first_views and grants_first_view_again (a hand act).

The verdicts are followed on the indexer's views family (views.verdicts), not on
manifest-accepted and manifest-refused as ADR 0622 §2 words it: those carry no revision.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 12:34:50 +01:00
parent 56891abe20
commit 2cb19c6415
11 changed files with 722 additions and 24 deletions
@@ -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 {
+23 -3
View File
@@ -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 {
+118 -6
View File
@@ -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, `<module>.<state>`.
type runtimeReader struct{}
// runtimeReader reads another part's state through the runtime, `<module>.<state>`; 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)
}
+3
View File
@@ -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
+118
View File
@@ -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.
@@ -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 (`<owner>.<state>`).
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
}
}
+17 -3
View File
@@ -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 `<module>.<state>`. 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.
@@ -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 `<stream>.<name>` 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
}
@@ -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, `<owner>.views.<state>`.
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 {
+4 -1
View File
@@ -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).
+16 -8
View File
@@ -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": [
{