mesh-vault: retirement in the controller's words, held to its types by golden files (review 255)
mesh/merge-gate head b71e696993b3 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b71e696993b3 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head b71e696993b3 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b71e696993b3 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
package main
|
||||
|
||||
// Retirement of a holder the mesh stops asking for (novox/hq ADR 0230), in the words the controller reads:
|
||||
// the `provisioner.retirement` event as mesh-controller's link.Retirement, the `provisioner_retirement`
|
||||
// answer as link.RetirementState, and the acts as the SDK's harness takes them (mesh-sdk
|
||||
// src/provisioner/index.ts) — the set repeated in `consumers`, a `why`, and the person's `by` and the
|
||||
// `via` the controller's verbs say. testdata/retirement-*.json are those types marshalled by the controller
|
||||
// at the commit its README names, and the tests hold this file's words to them.
|
||||
//
|
||||
// The vault is mark-only: it holds no access to switch off, so a retired holder keeps what it has until a
|
||||
// person deletes it, and the mark — when and why — is in the holder's own ledger file, so a restart forgets
|
||||
// nothing.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventRetirement is the one event a provider says about retirement (ADR 0230 §35).
|
||||
const EventRetirement = "provisioner.retirement"
|
||||
|
||||
// The rules of ADR 0230 §15 and §18.
|
||||
const (
|
||||
StablePasses = 5
|
||||
StableFor = 10 * time.Minute
|
||||
RetireAtOnce = 3
|
||||
RetireFraction = 0.5
|
||||
SayAgainEvery = time.Hour
|
||||
)
|
||||
|
||||
// RetiredConsumer is one holder in a retirement word or answer (link.RetiredConsumer).
|
||||
type RetiredConsumer struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Node string `json:"node,omitempty"`
|
||||
RetiredAt string `json:"retired_at,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
SizeBytes *int64 `json:"size_bytes,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Access string `json:"access,omitempty"`
|
||||
}
|
||||
|
||||
// RetirementWord is the event's body (link.Retirement).
|
||||
type RetirementWord struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderNode string `json:"provider-node"`
|
||||
Change string `json:"change"`
|
||||
Consumers []RetiredConsumer `json:"consumers"`
|
||||
Held int `json:"held"`
|
||||
Bound string `json:"bound,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
Via string `json:"via,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Since *time.Time `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// RetirementWaiting, RetirementRejected and RetirementState are the answer to provisioner_retirement
|
||||
// (link.RetirementWaiting, link.RetirementRejected, link.RetirementState).
|
||||
type RetirementWaiting struct {
|
||||
Consumers []RetiredConsumer `json:"consumers"`
|
||||
Since string `json:"since"`
|
||||
Held int `json:"held"`
|
||||
}
|
||||
|
||||
type RetirementRejected struct {
|
||||
Consumers []RetiredConsumer `json:"consumers"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
type RetirementState struct {
|
||||
Resource string `json:"resource"`
|
||||
Node string `json:"node"`
|
||||
Held []string `json:"held"`
|
||||
StablePasses int `json:"stable_passes"`
|
||||
StableForSeconds int `json:"stable_for_seconds,omitempty"`
|
||||
RetiresBy string `json:"retires_by,omitempty"`
|
||||
Bound string `json:"bound"`
|
||||
Waiting *RetirementWaiting `json:"waiting"`
|
||||
Rejected *RetirementRejected `json:"rejected"`
|
||||
Retired []RetiredConsumer `json:"retired"`
|
||||
}
|
||||
|
||||
// Act is a person's act on retirement: why, who (the person the controller proved) and through what.
|
||||
type Act struct {
|
||||
Why, By, Via string
|
||||
}
|
||||
|
||||
// Custody is the pass over the contributions file that keeps the ledger, and what it remembers between
|
||||
// passes about the holders the mesh no longer asks for.
|
||||
type Custody struct {
|
||||
Ledger *Ledger
|
||||
Receives string
|
||||
// Resource is the provision the vault provides; Node the machine it runs on.
|
||||
Resource string
|
||||
Node string
|
||||
Emit func(event string, body map[string]any) error
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
key string
|
||||
count int
|
||||
firstSeen time.Time
|
||||
asked map[string]bool
|
||||
read bool
|
||||
waiting *heldSet
|
||||
rejected *heldSet
|
||||
}
|
||||
|
||||
type heldSet struct {
|
||||
set []string
|
||||
since time.Time
|
||||
saidAt time.Time
|
||||
held int
|
||||
by, why string
|
||||
}
|
||||
|
||||
func (c *Custody) resource() string {
|
||||
if c.Resource == "" {
|
||||
return "secret"
|
||||
}
|
||||
return c.Resource
|
||||
}
|
||||
|
||||
// Pass records every delivered value, re-enables a retired holder asked for again, and counts the holders
|
||||
// no longer asked for toward their retirement.
|
||||
func (c *Custody) Pass() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
asked, err := Contributions(c.Receives)
|
||||
if err != nil {
|
||||
c.Log("THE CONTRIBUTIONS FILE IS NOT USABLE (%s): %v — nothing is recorded or retired on this pass, and "+
|
||||
"the count toward retiring anyone starts again", c.Receives, err)
|
||||
c.key, c.count = "", 0
|
||||
return
|
||||
}
|
||||
now := c.Now()
|
||||
keep := map[string]bool{}
|
||||
for _, a := range asked {
|
||||
keep[a.As] = true
|
||||
value, err := delivered(a)
|
||||
if err != nil {
|
||||
c.Log("%s: %v", a.As, err)
|
||||
continue
|
||||
}
|
||||
held, outcome, err := c.Ledger.Record(a.As, a.Node, value, now)
|
||||
if err != nil {
|
||||
c.Log("%s: %v", a.As, err)
|
||||
continue
|
||||
}
|
||||
if held.Retired != nil {
|
||||
if err := c.Ledger.Mark(a.As, nil); err != nil {
|
||||
c.Log("%s: asked for again, and its retirement mark could not be cleared: %v", a.As, err)
|
||||
} else {
|
||||
c.Log("%s: asked for again — re-enabled, its retirement mark cleared", a.As)
|
||||
c.tell(RetirementWord{Change: "reenabled", Consumers: []RetiredConsumer{{Consumer: a.As, Node: a.Node}}}, now)
|
||||
}
|
||||
}
|
||||
if outcome == Unchanged {
|
||||
continue
|
||||
}
|
||||
event := "provisioned"
|
||||
if outcome == Rotated {
|
||||
event = "rotated"
|
||||
}
|
||||
c.Log("%s: %s (%s…, rotations %d)", held.As, outcome, held.Fingerprint[:19], held.Rotations)
|
||||
c.say(event, map[string]any{"consumer": held.Consumer, "as": held.As, "fingerprint": held.Fingerprint, "rotations": held.Rotations})
|
||||
}
|
||||
c.asked, c.read = keep, true
|
||||
c.countUnasked(now)
|
||||
}
|
||||
|
||||
func (c *Custody) active() []Held {
|
||||
var out []Held
|
||||
for _, h := range c.Ledger.List() {
|
||||
if h.Retired == nil {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Custody) countUnasked(now time.Time) {
|
||||
active := c.active()
|
||||
var unasked []string
|
||||
for _, h := range active {
|
||||
if !c.asked[h.As] {
|
||||
unasked = append(unasked, h.As)
|
||||
}
|
||||
}
|
||||
key := strings.Join(unasked, ",")
|
||||
if c.waiting != nil && strings.Join(c.waiting.set, ",") != key {
|
||||
c.settle(c.waiting, "the set the mesh no longer asks for changed", now)
|
||||
c.waiting = nil
|
||||
}
|
||||
if c.rejected != nil && strings.Join(c.rejected.set, ",") != key {
|
||||
c.settle(c.rejected, "the set the mesh no longer asks for changed", now)
|
||||
c.rejected = nil
|
||||
}
|
||||
switch {
|
||||
case len(unasked) == 0:
|
||||
c.key, c.count = "", 0
|
||||
return
|
||||
case key != c.key:
|
||||
c.key, c.count, c.firstSeen = key, 1, now
|
||||
c.Log("no longer asked for: %s; retired once the same set holds for %d passes and %s (novox/hq ADR 0230)",
|
||||
key, StablePasses, StableFor)
|
||||
default:
|
||||
c.count++
|
||||
}
|
||||
if c.rejected != nil || c.count < StablePasses || now.Sub(c.firstSeen) < StableFor {
|
||||
return
|
||||
}
|
||||
if overTheBound(len(unasked), len(active)) {
|
||||
c.wait(unasked, len(active), now)
|
||||
return
|
||||
}
|
||||
c.retire(unasked, len(active), fmt.Sprintf("the mesh stopped asking for it: the same answer in %d passes over %s, from %s",
|
||||
c.count, now.Sub(c.firstSeen).Round(time.Second), c.firstSeen.UTC().Format(time.RFC3339)), now)
|
||||
}
|
||||
|
||||
func overTheBound(n, held int) bool {
|
||||
return n > RetireAtOnce || (held > 1 && float64(n) > RetireFraction*float64(held))
|
||||
}
|
||||
|
||||
func bound(held int) string {
|
||||
return fmt.Sprintf("more than %d, or more than %d%% of the %d held", RetireAtOnce, int(RetireFraction*100), held)
|
||||
}
|
||||
|
||||
func (c *Custody) wait(set []string, held int, now time.Time) {
|
||||
if c.waiting == nil {
|
||||
c.waiting = &heldSet{set: set, since: now, held: held, saidAt: now}
|
||||
c.Log("RETIREMENT WAITS FOR A PERSON: the mesh no longer asks for %d of the %d holder(s) (%s), %s. "+
|
||||
"Nothing is retired until `retire approve` or `retire reject` (novox/hq ADR 0230)",
|
||||
len(set), held, strings.Join(set, ", "), bound(held))
|
||||
} else if now.Sub(c.waiting.saidAt) < SayAgainEvery {
|
||||
return
|
||||
} else {
|
||||
c.waiting.saidAt = now
|
||||
}
|
||||
since := c.waiting.since.UTC()
|
||||
c.tell(RetirementWord{Change: "waiting", Consumers: c.named(set), Held: c.waiting.held, Since: &since}, now)
|
||||
}
|
||||
|
||||
func (c *Custody) settle(s *heldSet, why string, now time.Time) {
|
||||
c.Log("the set waiting on retirement (%s) is settled, nothing retired: %s", strings.Join(s.set, ", "), why)
|
||||
c.tell(RetirementWord{Change: "settled", Consumers: c.named(s.set), Why: why}, now)
|
||||
}
|
||||
|
||||
// retire marks each holder in set retired: the ledger keeps it, and its history, until a person deletes it.
|
||||
func (c *Custody) retire(set []string, held int, why string, now time.Time) []string {
|
||||
at := now.UTC().Format(isoMillis)
|
||||
var done []string
|
||||
for _, as := range set {
|
||||
if err := c.Ledger.Mark(as, &RetiredMark{At: at, Why: why}); err != nil {
|
||||
c.Log("%s: could not be marked retired, asked again once its set is stable again: %v", as, err)
|
||||
continue
|
||||
}
|
||||
done = append(done, as)
|
||||
}
|
||||
c.key, c.count = "", 0
|
||||
if len(done) == 0 {
|
||||
return done
|
||||
}
|
||||
c.Log("%s: RETIRED, MARK ONLY — the vault holds no access to switch off, so each keeps what it holds until a "+
|
||||
"person deletes it with `cleanup delete` (novox/hq ADR 0230): %s", strings.Join(done, ", "), why)
|
||||
c.tell(RetirementWord{Change: "retired", Consumers: c.retiredOnes(done), Held: held, Why: why}, now)
|
||||
return done
|
||||
}
|
||||
|
||||
// named is each holder as the controller's words name it.
|
||||
func (c *Custody) named(set []string) []RetiredConsumer {
|
||||
out := make([]RetiredConsumer, 0, len(set))
|
||||
for _, as := range set {
|
||||
h, _ := c.Ledger.Get(as)
|
||||
out = append(out, RetiredConsumer{Consumer: as, Node: h.Consumer})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Custody) retiredOnes(set []string) []RetiredConsumer {
|
||||
out := make([]RetiredConsumer, 0, len(set))
|
||||
for _, as := range set {
|
||||
h, _ := c.Ledger.Get(as)
|
||||
out = append(out, retiredOne(h))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func retiredOne(h Held) RetiredConsumer {
|
||||
unknown := int64(-1)
|
||||
r := RetiredConsumer{Consumer: h.As, Node: h.Consumer, SizeBytes: &unknown, Kind: "consumer", Access: "kept"}
|
||||
if h.Retired != nil {
|
||||
r.RetiredAt, r.Why = h.Retired.At, h.Retired.Why
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func sameSet(a, b []string) bool {
|
||||
x, y := append([]string{}, a...), append([]string{}, b...)
|
||||
sort.Strings(x)
|
||||
sort.Strings(y)
|
||||
return strings.Join(x, ",") == strings.Join(y, ",")
|
||||
}
|
||||
|
||||
// Approve retires exactly the set waiting (or rejected) for a person, repeated in consumers; any other set
|
||||
// is refused (ADR 0230 §20).
|
||||
func (c *Custody) Approve(consumers []string, act Act) ([]string, error) {
|
||||
if strings.TrimSpace(act.Why) == "" {
|
||||
return nil, errors.New("approve: why is required")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
s := c.waiting
|
||||
if s == nil {
|
||||
s = c.rejected
|
||||
}
|
||||
if s == nil || !sameSet(s.set, consumers) {
|
||||
return nil, fmt.Errorf("approve: nothing waits with exactly that set (%s); waiting: %s; rejected: %s. Look again",
|
||||
strings.Join(consumers, ", "), setOf(c.waiting), setOf(c.rejected))
|
||||
}
|
||||
now := c.Now()
|
||||
held := len(c.active())
|
||||
c.Log("retirement of %s approved by %s: %s", strings.Join(s.set, ", "), someone(act.By), act.Why)
|
||||
c.tell(RetirementWord{Change: "approved", Consumers: c.named(s.set), Held: held, Why: act.Why, By: act.By, Via: act.Via}, now)
|
||||
set := s.set
|
||||
c.waiting, c.rejected = nil, nil
|
||||
return c.retire(set, held, "approved by "+someone(act.By)+": "+act.Why, now), nil
|
||||
}
|
||||
|
||||
// Reject keeps the waiting set active, repeated in consumers, and asks no more about it while it stays the
|
||||
// same set.
|
||||
func (c *Custody) Reject(consumers []string, act Act) ([]string, error) {
|
||||
if strings.TrimSpace(act.Why) == "" {
|
||||
return nil, errors.New("reject: why is required")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.waiting == nil || !sameSet(c.waiting.set, consumers) {
|
||||
return nil, fmt.Errorf("reject: nothing waits with exactly that set (%s); waiting: %s. Look again",
|
||||
strings.Join(consumers, ", "), setOf(c.waiting))
|
||||
}
|
||||
now := c.Now()
|
||||
c.rejected = &heldSet{set: c.waiting.set, since: now, by: act.By, why: act.Why}
|
||||
c.waiting = nil
|
||||
c.Log("retirement of %s rejected by %s: %s; kept active while the mesh says the same",
|
||||
strings.Join(c.rejected.set, ", "), someone(act.By), act.Why)
|
||||
c.tell(RetirementWord{Change: "rejected", Consumers: c.named(c.rejected.set), Why: act.Why, By: act.By, Via: act.Via}, now)
|
||||
return c.rejected.set, nil
|
||||
}
|
||||
|
||||
// Delete forgets one retired holder on a person's word, its name repeated in confirm. Refused: before a
|
||||
// pass has read the contributions file, a holder that is not retired, one the contributions file asks for
|
||||
// now, and any act without a why or a matching confirm.
|
||||
func (c *Custody) Delete(consumer, confirm string, act Act) error {
|
||||
if strings.TrimSpace(act.Why) == "" {
|
||||
return errors.New("delete: why is required")
|
||||
}
|
||||
if confirm != consumer {
|
||||
return errors.New("delete: confirm must repeat the consumer's name")
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.read {
|
||||
return errors.New("delete: no pass has read the contributions file yet, so whether the mesh asks for it is not known; try again in a minute")
|
||||
}
|
||||
h, ok := c.Ledger.Get(consumer)
|
||||
if !ok {
|
||||
return fmt.Errorf("delete: %s is not held here", consumer)
|
||||
}
|
||||
if h.Retired == nil {
|
||||
return fmt.Errorf("delete: %s is active, not retired — only a retired holder is deleted", consumer)
|
||||
}
|
||||
asked, err := Contributions(c.Receives)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete: the contributions file cannot say whether the mesh asks for %s: %v", consumer, err)
|
||||
}
|
||||
for _, a := range asked {
|
||||
if a.As == consumer {
|
||||
return fmt.Errorf("delete: the mesh asks for %s again; the next pass re-enables it", consumer)
|
||||
}
|
||||
}
|
||||
if !c.Ledger.Delete(consumer) {
|
||||
return fmt.Errorf("delete: %s could not be deleted", consumer)
|
||||
}
|
||||
c.Log("%s: DELETED from the ledger, asked by %s: %s", consumer, someone(act.By), act.Why)
|
||||
unknown := int64(-1)
|
||||
c.tell(RetirementWord{Change: "deleted", Consumers: []RetiredConsumer{{Consumer: consumer, Node: h.Consumer, SizeBytes: &unknown}},
|
||||
Why: act.Why, By: act.By, Via: act.Via}, c.Now())
|
||||
return nil
|
||||
}
|
||||
|
||||
func someone(by string) string {
|
||||
if by == "" {
|
||||
return "someone"
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
func setOf(s *heldSet) string {
|
||||
if s == nil || len(s.set) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(s.set, ", ")
|
||||
}
|
||||
|
||||
// Retirement is the answer to provisioner_retirement.
|
||||
func (c *Custody) Retirement() RetirementState {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
st := RetirementState{Resource: c.resource(), Node: c.Node, Held: []string{}, StablePasses: StablePasses,
|
||||
StableForSeconds: int(StableFor.Seconds()), RetiresBy: "mark-only", Retired: []RetiredConsumer{}}
|
||||
for _, h := range c.Ledger.List() {
|
||||
if h.Retired == nil {
|
||||
st.Held = append(st.Held, h.As)
|
||||
} else {
|
||||
st.Retired = append(st.Retired, retiredOne(h))
|
||||
}
|
||||
}
|
||||
st.Bound = bound(len(st.Held))
|
||||
if c.waiting != nil {
|
||||
st.Waiting = &RetirementWaiting{Consumers: c.named(c.waiting.set),
|
||||
Since: c.waiting.since.UTC().Format(time.RFC3339), Held: c.waiting.held}
|
||||
}
|
||||
if c.rejected != nil {
|
||||
st.Rejected = &RetirementRejected{Consumers: c.named(c.rejected.set), By: c.rejected.by, Why: c.rejected.why,
|
||||
At: c.rejected.since.UTC().Format(time.RFC3339)}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// tell says one retirement word, with the provider, its machine, the bound and the time filled in.
|
||||
func (c *Custody) tell(w RetirementWord, now time.Time) {
|
||||
w.Provider, w.ProviderNode, w.At = c.resource(), c.Node, now.UTC()
|
||||
if w.Bound == "" {
|
||||
w.Bound = bound(len(c.active()))
|
||||
}
|
||||
if w.Consumers == nil {
|
||||
w.Consumers = []RetiredConsumer{}
|
||||
}
|
||||
c.say(EventRetirement, wordBody(w))
|
||||
}
|
||||
|
||||
// wordBody is a word as the map the SDK's Emit takes, through its JSON, so the bus carries exactly the tags.
|
||||
func wordBody(w RetirementWord) map[string]any {
|
||||
raw, _ := json.Marshal(w)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func (c *Custody) say(event string, body map[string]any) {
|
||||
if c.Emit == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Emit(event, body); err != nil {
|
||||
c.Log("%s could not be said: %v", event, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"resource": "secret",
|
||||
"node": "novox",
|
||||
"held": [
|
||||
"a"
|
||||
],
|
||||
"held_sizes": {
|
||||
"a": 1
|
||||
},
|
||||
"stable_passes": 5,
|
||||
"stable_for_seconds": 600,
|
||||
"retires_by": "mark-only",
|
||||
"bound": "b",
|
||||
"waiting": {
|
||||
"consumers": [
|
||||
{
|
||||
"consumer": "c",
|
||||
"node": "n",
|
||||
"retired_at": "t",
|
||||
"why": "w",
|
||||
"size_bytes": -1,
|
||||
"kind": "consumer",
|
||||
"access": "kept"
|
||||
}
|
||||
],
|
||||
"since": "t",
|
||||
"held": 1
|
||||
},
|
||||
"rejected": {
|
||||
"consumers": [
|
||||
{
|
||||
"consumer": "c",
|
||||
"node": "n",
|
||||
"retired_at": "t",
|
||||
"why": "w",
|
||||
"size_bytes": -1,
|
||||
"kind": "consumer",
|
||||
"access": "kept"
|
||||
}
|
||||
],
|
||||
"by": "b",
|
||||
"why": "w",
|
||||
"at": "t"
|
||||
},
|
||||
"retired": [
|
||||
{
|
||||
"consumer": "c",
|
||||
"node": "n",
|
||||
"retired_at": "t",
|
||||
"why": "w",
|
||||
"size_bytes": -1,
|
||||
"kind": "consumer",
|
||||
"access": "kept"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"provider": "secret",
|
||||
"provider-node": "novox",
|
||||
"change": "waiting",
|
||||
"consumers": [
|
||||
{
|
||||
"consumer": "c",
|
||||
"node": "n",
|
||||
"retired_at": "t",
|
||||
"why": "w",
|
||||
"size_bytes": -1,
|
||||
"kind": "consumer",
|
||||
"access": "kept"
|
||||
}
|
||||
],
|
||||
"held": 1,
|
||||
"bound": "b",
|
||||
"why": "w",
|
||||
"by": "person.x",
|
||||
"via": "mesh-controller",
|
||||
"at": "2026-10-14T09:00:00Z",
|
||||
"since": "2026-10-14T09:00:00Z"
|
||||
}
|
||||
Reference in New Issue
Block a user