Files
mesh-delivery/modules/mesh-grants/internal/grants/firstviews.go
T
jschoubben cb1cf7fd73 DLV-R1: the grants part follows the verdicts on the indexer's manifest-accepted and manifest-refused at the revision each carries, not on its views family (ADR 0622 §2)
The next revision changes the verdicts' picture with no ask; one already held is passed over; an
event carrying no revision, past the next, or naming no manifest above the picture asks the first
view once more, said in the log. The manifest consumes the two events in place of views.verdicts.
Needs mesh-sdk 3ff694e (the events' Revision) and the indexer setting it (A2b).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 12:48:30 +01:00

421 lines
17 KiB
Go

package grants
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
)
// **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.
// The verdicts alone are followed on the indexer's manifest-accepted and manifest-refused, which carry the revision
// (ADR 0622 §2), not on its views family (HearVerdict).
// 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
}
// VerdictEvents are the indexer's two events the part follows the verdicts on after their first view (ADR 0622 §2), as its
// manifest's consumes name them.
var VerdictEvents = []string{IndexerModule + "." + delivery.ManifestAcceptedEvent, IndexerModule + "." + delivery.ManifestRefusedEvent}
// HearVerdict takes one of the indexer's manifest-accepted or manifest-refused (event, its name) into the verdicts' first
// view, as one change of the verdicts state at the revision it carries (ADR 0622 §2): the manifest's digest its key, the
// verdict it says its value. A revision past the one after the picture's is a gap, and so is an event carrying no
// revision (an indexer that does not say it) or one saying no manifest above the picture's revision (the empty accepted
// verdict of a module leaving the trunk): the first view is asked once more on its own goroutine, said in the log. A
// revision already in the picture is passed over. Data that does not read is answered as an error, and nothing is asked.
func (v *Views) HearVerdict(ctx context.Context, event string, data []byte, logf func(string, ...any)) error {
f := v.Followers[ReadVerdicts]
if f == nil {
return nil
}
var verdict delivery.ManifestVerdict
var digest string
var revision uint64
switch event {
case delivery.ManifestAcceptedEvent:
var a delivery.ManifestAccepted
if err := json.Unmarshal(data, &a); err != nil {
return fmt.Errorf("%s does not read: %w", event, err)
}
verdict = delivery.ManifestVerdict{V: delivery.Version, Module: a.Module, State: delivery.VerdictAccepted, Judge: a.Judge}
digest, revision = a.Digest, a.Revision
case delivery.ManifestRefusedEvent:
var r delivery.ManifestRefused
if err := json.Unmarshal(data, &r); err != nil {
return fmt.Errorf("%s does not read: %w", event, err)
}
verdict = delivery.ManifestVerdict{V: delivery.Version, Module: r.Module, State: delivery.VerdictRefused, Judge: r.Judge, Problems: r.Problems}
digest, revision = r.Digest, r.Revision
default:
return fmt.Errorf("%q is not a verdict event of the indexer", event)
}
held := f.Status()
gap := func(why string) error {
logf("views: a gap in %s (%s): its first view %s is asked once more", f.State, why, f.Tool)
go func() { _ = f.Gap(ctx, why) }()
return nil
}
if revision == 0 {
return gap(fmt.Sprintf("the indexer's %s of %s carries no revision", event, verdict.Module))
}
key, err := delivery.VerdictsKey(digest)
if err != nil {
if revision <= held.Revision {
return nil
}
return gap(fmt.Sprintf("the indexer's %s of %s at revision %d names no manifest", event, verdict.Module, revision))
}
value, err := json.Marshal(verdict)
if err != nil {
return err
}
if g, why := f.Apply(views.Event{V: 1, Owner: f.Owner, State: f.State, Key: key, Revision: revision, Created: held.Created,
Value: value, At: time.Now().UTC()}); g {
return gap(why)
}
return 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
}
}