diff --git a/modules/mesh-grants/cmd/mesh-grants/main.go b/modules/mesh-grants/cmd/mesh-grants/main.go index 4e3654b3..4f258892 100644 --- a/modules/mesh-grants/cmd/mesh-grants/main.go +++ b/modules/mesh-grants/cmd/mesh-grants/main.go @@ -183,6 +183,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return grants.Unwrapped(conn.Call)(grants.CallBrokerList, map[string]any{}) }} f.Part.Bindings.Start(ctx, f.Part) + // The restore points' names (design 47 G §565): the seat mesh-recovery's kept-points, a kept call asked at each start and on the + // seat's ended, point-removed and expiry-ended, until it answers. + f.Part.KeptPoints = &grants.KeptPointsView{Changed: f.Wake, Logf: logger.Printf, + Ask: func(context.Context) (json.RawMessage, error) { + return grants.Unwrapped(conn.Call)(grants.CallKeptPoints, map[string]any{}) + }} + f.Part.KeptPoints.Start(ctx, f.Part) // The seat list: read over the connection and the hub, given to catalogue at every change, and a pass composes again then. gate := newSeatGate() gate.Follow(seatlist.ProcessBus(conn.Call, hub), f.Wake, logger.Printf) diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index 65e426d4..7c895399 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -8,6 +8,7 @@ require ( git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/meshbroker v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/go/seats/meshrecovery v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 20ac0160..98f1c720 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -3031,8 +3031,8 @@ func TestGrants(t *testing.T) { t.Errorf("%s is no seat event of Consumes beside the verdicts and views", subject) } } - if n := len(grants.SeatEventSubjects()); n != 6 { - t.Errorf("the part hears %d seats' events, want the keeper's two, the vault's one and the broker's three", n) + if n := len(grants.SeatEventSubjects()); n != 9 { + t.Errorf("the part hears %d seats' events, want the keeper's two, the vault's one, the broker's three and the recovery's three", n) } for _, c := range b.calls { if strings.Contains(c, "mesh-controller") { diff --git a/modules/mesh-grants/internal/grants/firstviews.go b/modules/mesh-grants/internal/grants/firstviews.go index 8ed61c88..f6f41508 100644 --- a/modules/mesh-grants/internal/grants/firstviews.go +++ b/modules/mesh-grants/internal/grants/firstviews.go @@ -399,6 +399,8 @@ type FirstViewsState struct { // Bindings is the first view of the bindings, asked of the seat mesh-broker's list, beside the others (design 25 // bindings-first-view §6); absent where the part's process holds none. Bindings *BindingsStatus `json:"bindings,omitempty"` + // KeptPoints is the kept call of the seat mesh-recovery's kept-points, `waiting` until it first answers after a start (design 47 G §565). + KeptPoints *KeptPointsStatus `json:"kept-points,omitempty"` // Streams are the whole reads of streams kept last per subject at the part's start, `waiting` until each ends (design 47 G §556). Streams []StreamRead `json:"streams,omitempty"` // Catalog is the first view of each branch of the catalog half the part follows (design 47 G §614), and CatalogEntries the entries @@ -420,7 +422,7 @@ type CatalogEntry struct { // 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 && p.Bindings == nil && len(p.StreamReads()) == 0 { + if p.Views == nil && p.Bindings == nil && p.KeptPoints == nil && len(p.StreamReads()) == 0 { return } state := FirstViewsState{V: 1, Views: []views.Status{}} @@ -436,6 +438,11 @@ func (p *Part) sayFirstViews(res *Result) { b.Asked, b.Answered = b.Asked.Truncate(time.Second), b.Answered.Truncate(time.Second) state.Bindings = &b } + if p.KeptPoints != nil { + k := p.KeptPoints.Status() + k.Asked, k.Answered = k.Asked.Truncate(time.Second), k.Answered.Truncate(time.Second) + state.KeptPoints = &k + } state.Streams = p.StreamReads() if p.Views != nil && p.Views.Catalog != nil { state.Catalog, state.CatalogWaiting = p.Views.Catalog.Statuses(), p.Views.Catalog.Waiting() diff --git a/modules/mesh-grants/internal/grants/follow.go b/modules/mesh-grants/internal/grants/follow.go index 07a78196..87e534c8 100644 --- a/modules/mesh-grants/internal/grants/follow.go +++ b/modules/mesh-grants/internal/grants/follow.go @@ -144,6 +144,11 @@ func (f *Follower) open() { f.Wake() return } + // The recovery module's points changed: the kept call asks again (keptpoints.go). + if f.Part.KeptPoints != nil && IsKeptPointsEvent(event, body) { + f.Part.KeptPoints.Heard(event, body) + return + } // An account the vault minted. if IsAccountIssued(event) { f.Wake() diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 1db94a80..4acd419c 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -73,7 +73,7 @@ const ( ) // Invokes is every tool the part calls. -var Invokes = append([]string{CallUsers, CallUserCan, CallStreams, CallRootFree, CallHandAct, CallSeatDefinitions, CallBrokerList}, append(catalogHalfVerbs(), firstViewTools()...)...) +var Invokes = append([]string{CallUsers, CallUserCan, CallStreams, CallRootFree, CallHandAct, CallSeatDefinitions, CallBrokerList, CallKeptPoints}, append(catalogHalfVerbs(), firstViewTools()...)...) // CallSeatDefinitions reads the seat list (mesh-sdk seatlist): the verb of the seat mesh-catalog. const CallSeatDefinitions = "seat:mesh-catalog.seat-definitions" @@ -216,7 +216,10 @@ type Inputs struct { // build of it): what the seats it defines, the streams others hear from it and its reads are composed from while its // trunk manifest is refused or waits, so no other principal loses a grant to it (ADR 0601 clause 3). Held map[string]catalogue.Manifest - Gaps []Gap + // KeptNames are the bus names the restore points and asides of the recovery module name, as last read from `kept-points` (design 47 G + // §565, A-230), kept in the part's own state: set by the pass, never read from another part. + KeptNames []string + Gaps []Gap } // HeldHash is one key of the vault's bus-accounts: the hash, and what it is of. The hash is never said by the part, and diff --git a/modules/mesh-grants/internal/grants/keptpoints.go b/modules/mesh-grants/internal/grants/keptpoints.go new file mode 100644 index 00000000..c6fa4b57 --- /dev/null +++ b/modules/mesh-grants/internal/grants/keptpoints.go @@ -0,0 +1,300 @@ +package grants + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-contracts/go/seats/meshrecovery" +) + +// **The recovery module's restore points' names, read through `kept-points`** (design 47 G §565, A-230). The bus names of the +// recovery module's account include those a restore point (or an aside) names. The part reads them by its `invokes` of +// `seat:mesh-recovery.kept-points`, a kept call (ADR 0668 §1: made again until it answers), at its start and again on every `ended` +// with outcome `done`, every `point-removed` and every `expiry-ended` of the seat, which it consumes. Until the call first answers +// after a start, the part grants the names it last granted from it, kept in its state memberships (KeptNamesKey): it takes no name +// away before that answer, and grants_first_views says the wait. + +// KeptPointsVerb is the seat's verb as the manifest's invokes names it. +const ( + CallKeptPoints = "seat:" + meshrecovery.Seat + "." + meshrecovery.VerbKeptPoints + // KeptNamesKey is the key of the part's own state memberships that keeps the names last read from `kept-points`; never a + // `.` key, since no machine is named `kept-names`. + KeptNamesKey = "kept-names." + meshrecovery.Seat +) + +// KeptNames is the value of KeptNamesKey: every bus name a restore point or an aside of the recovery module names. +type KeptNames struct { + V int `json:"v"` + Names []string `json:"names"` + At time.Time `json:"at,omitzero"` +} + +// keptPointsAnswer is the part of kept-points' answer the part reads: `points[].names` and `asides[].names` (A-230). The contract's +// Point gains `names` at the join, as its Aside has it; until then this is the wire's form. +type keptPointsAnswer struct { + Points []struct { + Names []string `json:"names"` + } `json:"points"` + Asides []struct { + Names []string `json:"names"` + } `json:"asides"` +} + +// KeptPointsStatus is what grants_first_views says of the kept call. +type KeptPointsStatus struct { + Seat string `json:"seat"` + Verb string `json:"verb"` + Asked time.Time `json:"asked,omitzero"` + Answered time.Time `json:"answered,omitzero"` + Asks int `json:"asks"` + // Names is how many names the last answer named (or, while waiting, the names kept from the last answer before the start). + Names int `json:"names"` + // Waiting says the call has not answered since the start: the part grants the names it kept. + Waiting bool `json:"waiting"` + Why string `json:"why,omitempty"` + Error string `json:"error,omitempty"` +} + +// KeptPointsView is the kept call of `kept-points`. +type KeptPointsView struct { + // Ask calls the seat's verb and answers its reply (the process's connection). + Ask func(ctx context.Context) (json.RawMessage, error) + Changed func() + Logf func(string, ...any) + Now func() time.Time + // Pause waits d between two asks that failed and answers false once ctx ended; nil waits on the context's bound. + Pause func(ctx context.Context, d time.Duration) bool + // Wait is the first wait between two asks that failed (ten seconds when zero), doubling to MaxWait (five minutes when zero). + Wait time.Duration + MaxWait time.Duration + + mu sync.Mutex + ctx context.Context + part *Part + st KeptPointsStatus + asking bool + again bool +} + +func (v *KeptPointsView) now() time.Time { + if v.Now != nil { + return v.Now().UTC() + } + return time.Now().UTC() +} + +func (v *KeptPointsView) logf(format string, args ...any) { + if v.Logf != nil { + v.Logf(format, args...) + } +} + +// Status is the kept call's status now. +func (v *KeptPointsView) Status() KeptPointsStatus { + v.mu.Lock() + defer v.mu.Unlock() + s := v.st + s.Seat, s.Verb = meshrecovery.Seat, meshrecovery.VerbKeptPoints + return s +} + +// Start asks at the part's start, on a goroutine of its own, and until it answers. +func (v *KeptPointsView) Start(ctx context.Context, p *Part) { + v.mu.Lock() + v.ctx, v.part = ctx, p + v.st.Waiting = true + v.mu.Unlock() + if kept, err := p.keptNames(); err == nil { + v.mu.Lock() + v.st.Names = len(kept) + v.mu.Unlock() + } + go v.askUntil(ctx, p, "start") +} + +// Heard asks again when an event heard is one that changes the points: `ended` with outcome `done`, `point-removed`, `expiry-ended` +// of the seat mesh-recovery, named `mesh-recovery.` or by its bus subject. +func (v *KeptPointsView) Heard(event string, body []byte) { + name, ok := recoveryEvent(event) + if !ok || !changesPoints(name, body) { + return + } + v.mu.Lock() + ctx, p := v.ctx, v.part + v.mu.Unlock() + if p == nil { + return // not started: the start's ask reads what the event changed + } + go v.askUntil(ctx, p, "the seat's "+name) +} + +func recoveryEvent(event string) (string, bool) { + for _, prefix := range []string{meshrecovery.Seat + ".", "mesh.seat." + meshrecovery.Seat + ".event."} { + if name, ok := strings.CutPrefix(event, prefix); ok { + return name, true + } + } + return "", false +} + +func changesPoints(name string, body []byte) bool { + switch name { + case meshrecovery.EventPointRemoved, meshrecovery.EventExpiryEnded: + return true + case meshrecovery.EventEnded: + var e meshrecovery.EndedEvent + return json.Unmarshal(body, &e) != nil || e.Outcome == meshrecovery.EndedEventOutcomeDone + } + return false +} + +// IsKeptPointsEvent says an event heard is one the kept call asks again on. +func IsKeptPointsEvent(event string, body []byte) bool { + name, ok := recoveryEvent(event) + return ok && changesPoints(name, body) +} + +func (v *KeptPointsView) pause(ctx context.Context, d time.Duration) bool { + if v.Pause != nil { + return v.Pause(ctx, d) + } + t, cancel := context.WithTimeout(ctx, d) + <-t.Done() + cancel() + return ctx.Err() == nil +} + +// askUntil asks until the seat answers or ctx ends, from ten seconds, doubling, at most every five minutes. An ask made while one +// runs is made once more when it ends, so an event heard meanwhile is never lost. +func (v *KeptPointsView) askUntil(ctx context.Context, p *Part, why string) { + v.mu.Lock() + if v.asking { + v.again = true + v.mu.Unlock() + return + } + v.asking = true + v.st.Why = why + v.mu.Unlock() + wait, most := v.Wait, v.MaxWait + if wait == 0 { + wait = 10 * time.Second + } + if most == 0 { + most = 5 * time.Minute + } + for { + v.mu.Lock() + v.st.Asked = v.now() + v.st.Asks++ + v.mu.Unlock() + raw, err := v.Ask(ctx) + var n int + if err == nil { + n, err = p.applyKeptPoints(raw, v.wake) + } + if err != nil { + v.mu.Lock() + v.st.Error = err.Error() + v.mu.Unlock() + v.logf("%s is not answered (%v): asked again in %s; the names last kept stay granted", CallKeptPoints, err, wait) + if !v.pause(ctx, wait) { + v.mu.Lock() + v.asking = false + v.mu.Unlock() + return + } + if wait *= 2; wait > most { + wait = most + } + continue + } + v.mu.Lock() + v.st.Answered, v.st.Waiting, v.st.Error, v.st.Names = v.now(), false, "", n + again := v.again + v.again = false + if !again { + v.asking = false + } + v.mu.Unlock() + v.wake() + if !again { + return + } + wait = v.Wait + if wait == 0 { + wait = 10 * time.Second + } + } +} + +func (v *KeptPointsView) wake() { + if v.Changed != nil { + v.Changed() + } +} + +// keptNames are the names last read from kept-points, from the part's own state: none before the first answer ever. +func (p *Part) keptNames() ([]string, error) { + raw, _, found, err := p.Store.Get(StateMemberships, KeptNamesKey) + if err != nil { + return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateMemberships, KeptNamesKey, err) + } + if !found { + return nil, nil + } + var k KeptNames + if err := json.Unmarshal(raw, &k); err != nil { + return nil, fmt.Errorf("its own %s %s does not read: %w", StateMemberships, KeptNamesKey, err) + } + return k.Names, nil +} + +// applyKeptPoints replaces the kept names with those of an answer (a name no point names any more is taken away), and calls wake +// when they changed. It answers how many names the answer named. +func (p *Part) applyKeptPoints(raw json.RawMessage, wake func()) (int, error) { + var a keptPointsAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return 0, fmt.Errorf("%s answered what it does not read: %w", CallKeptPoints, err) + } + var names []string + for _, pt := range a.Points { + names = append(names, pt.Names...) + } + for _, as := range a.Asides { + names = append(names, as.Names...) + } + slices.Sort(names) + names = slices.Compact(names) + was, err := p.keptNames() + if err != nil { + return 0, err + } + if slices.Equal(was, names) { + return len(names), nil + } + body, _ := json.Marshal(KeptNames{V: 1, Names: names, At: p.now()}) + if _, err := p.Store.Put(StateMemberships, KeptNamesKey, body); err != nil { + return 0, fmt.Errorf("its own %s %s cannot be written: %w", StateMemberships, KeptNamesKey, err) + } + if wake != nil { + wake() + } + return len(names), nil +} + +// withKeptNames sets the names the recovery module's account is granted for restore points on in (Inputs.KeptNames), read from +// the part's own state in one place for the pass and for its tools. +func (p *Part) withKeptNames(in *Inputs) error { + names, err := p.keptNames() + if err != nil { + return err + } + in.KeptNames = names + return nil +} diff --git a/modules/mesh-grants/internal/grants/keptpoints_test.go b/modules/mesh-grants/internal/grants/keptpoints_test.go new file mode 100644 index 00000000..e52ce1dc --- /dev/null +++ b/modules/mesh-grants/internal/grants/keptpoints_test.go @@ -0,0 +1,62 @@ +package grants + +import ( + "context" + "encoding/json" + "errors" + "slices" + "testing" + "time" +) + +// Design 47 G §565, A-230: the part reads points[].names and asides[].names of kept-points, keeps them in its state memberships, +// grants the names it kept until the call first answers after a start, takes a name away only by an answer, and asks again on +// the seat's ended (outcome done), point-removed and expiry-ended alone. +func TestKeptPointsNamesAreKeptUntilTheCallAnswers(t *testing.T) { + store := memStore{} + answer := func(s string) func(context.Context) (json.RawMessage, error) { + return func(context.Context) (json.RawMessage, error) { return json.RawMessage(s), nil } + } + p := &Part{Store: store, Now: func() time.Time { return time.Unix(100, 0) }} + v := &KeptPointsView{Ask: answer(`{"points":[{"names":["KV_a_x","KV_b_y"]}],"asides":[{"names":["OBJ_c_z"]}]}`)} + p.KeptPoints = v + v.askUntil(context.Background(), p, "start") + var in Inputs + if err := p.withKeptNames(&in); err != nil || !slices.Equal(in.KeptNames, []string{"KV_a_x", "KV_b_y", "OBJ_c_z"}) { + t.Fatalf("names after the first answer %v (%v)", in.KeptNames, err) + } + // A restart: the call fails, and the names last kept stay; the status says it waits. + p2 := &Part{Store: store} + failing := &KeptPointsView{Ask: func(context.Context) (json.RawMessage, error) { return nil, errors.New("no responders") }, + Pause: func(context.Context, time.Duration) bool { return false }} + p2.KeptPoints = failing + failing.Start(context.Background(), p2) + failing.askUntil(context.Background(), p2, "start") + in = Inputs{} + if err := p2.withKeptNames(&in); err != nil || len(in.KeptNames) != 3 { + t.Fatalf("names kept before the call answers %v (%v)", in.KeptNames, err) + } + if s := failing.Status(); !s.Waiting || s.Names != 3 || s.Error == "" { + t.Errorf("status %+v, want waiting with 3 names and the error", s) + } + // An answer takes a name away that no point names any more. + failing.Ask = answer(`{"points":[{"names":["KV_a_x"]}],"asides":[]}`) + failing.askUntil(context.Background(), p2, "the seat's point-removed") + in = Inputs{} + _ = p2.withKeptNames(&in) + if !slices.Equal(in.KeptNames, []string{"KV_a_x"}) || failing.Status().Waiting { + t.Errorf("names after the answer %v, status %+v", in.KeptNames, failing.Status()) + } + // Which events ask again. + for event, body := range map[string]string{"mesh-recovery.ended": `{"outcome":"done"}`, "mesh.seat.mesh-recovery.event.point-removed": `{}`, + "mesh-recovery.expiry-ended": `{}`} { + if !IsKeptPointsEvent(event, []byte(body)) { + t.Errorf("%s does not ask again", event) + } + } + for event, body := range map[string]string{"mesh-recovery.ended": `{"outcome":"failed"}`, "mesh-recovery.restored": `{"outcome":"done"}`, "mesh-broker.created": `{}`} { + if IsKeptPointsEvent(event, []byte(body)) { + t.Errorf("%s asks again", event) + } + } +} diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 5d95c2c3..c97c72b9 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -232,6 +232,8 @@ type Part struct { fvSaid firstViewsSaid // Bindings is the first view of the bindings, asked of the seat mesh-broker's list (bindingsview.go); nil in the tools. Bindings *BindingsView + // KeptPoints is the kept call of the seat mesh-recovery's kept-points (keptpoints.go); nil in the tools. + KeptPoints *KeptPointsView // bindMu orders the writes of the kept bindings: an event's and a first view's answer (bindings.go). bindMu sync.Mutex // runningRead is the whole read of the stream running at the start (running.go), guarded by mu. @@ -323,6 +325,9 @@ func (p *Part) Compose() Result { if err := p.withBound(&in); err != nil { res.Errors = append(res.Errors, err.Error()) } + if err := p.withKeptNames(&in); err != nil { + res.Errors = append(res.Errors, err.Error()) + } for _, g := range in.Refused { res.Refused = append(res.Refused, g.Input+": "+g.Why) } diff --git a/modules/mesh-grants/internal/grants/rootfree.go b/modules/mesh-grants/internal/grants/rootfree.go index fe38321d..e1e37fef 100644 --- a/modules/mesh-grants/internal/grants/rootfree.go +++ b/modules/mesh-grants/internal/grants/rootfree.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "git.novox.be/novox/mesh-contracts/go/seats/meshbroker" + "git.novox.be/novox/mesh-contracts/go/seats/meshrecovery" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "slices" "sort" @@ -59,7 +60,8 @@ var seatListEvents = []string{meshcatalog.Seat + "." + meshcatalog.EventSeatDefi // seatEvents are the events of seats the part follows its passes by, as `.`. var seatEvents = []string{EventKeeperRaised, EventKeeperCleared, EventAccountIssued, meshbroker.Seat + "." + BrokerCreated, - meshbroker.Seat + "." + BrokerDropped, meshbroker.Seat + "." + BrokerRecreated} + meshbroker.Seat + "." + BrokerDropped, meshbroker.Seat + "." + BrokerRecreated, + meshrecovery.Seat + "." + meshrecovery.EventEnded, meshrecovery.Seat + "." + meshrecovery.EventPointRemoved, meshrecovery.Seat + "." + meshrecovery.EventExpiryEnded} // SeatEventSubjects are the subjects of the seats' events the part follows its passes by besides the verdicts' and the // views': the keeper's condition events, the vault's account-issued and the seat mesh-broker's, each `.` of diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index cd57f538..3ce1114c 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -119,6 +119,9 @@ func (p *Part) MembershipOf(node, module string) (any, error) { // What the bus module said it created, as the pass reads it, so the composed side carries the grants and bindings the // pass writes (issue 1966). boundErr := p.withBound(&in) + if err := p.withKeptNames(&in); boundErr == nil { + boundErr = err + } recs, left := ListRecords(in) notFree := WithRootFree(&recs, p.Call) m, ok := Memberships(recs)[node+"."+module] diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 1edf4c56..9e6b89df 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -26,6 +26,9 @@ "mesh-broker.created", "mesh-broker.dropped", "mesh-broker.recreated", + "mesh-recovery.ended", + "mesh-recovery.expiry-ended", + "mesh-recovery.point-removed", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests", @@ -58,7 +61,8 @@ "seat:mesh-catalog.seat-definitions", "seat:mesh-delivery.source", "seat:mesh-hand-acts.hand-act", - "seat:mesh-broker.list" + "seat:mesh-broker.list", + "seat:mesh-recovery.kept-points" ], "emits": [ "asserted",