From b71e696993b3275cb4a675103b8cecfb055ef4d8 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 01:37:27 +0000 Subject: [PATCH] mesh-vault: retirement in the controller's words, held to its types by golden files (review 255) Co-Authored-By: Claude Opus 5.5 --- .../mesh-vault/cmd/mesh-vault/retirement.go | 466 ++++++++++++++++++ .../mesh-vault/testdata/retirement-state.json | 56 +++ .../mesh-vault/testdata/retirement-word.json | 23 + 3 files changed, 545 insertions(+) create mode 100644 modules/mesh-vault/cmd/mesh-vault/retirement.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/testdata/retirement-state.json create mode 100644 modules/mesh-vault/cmd/mesh-vault/testdata/retirement-word.json diff --git a/modules/mesh-vault/cmd/mesh-vault/retirement.go b/modules/mesh-vault/cmd/mesh-vault/retirement.go new file mode 100644 index 00000000..c614a9d1 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/retirement.go @@ -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) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/testdata/retirement-state.json b/modules/mesh-vault/cmd/mesh-vault/testdata/retirement-state.json new file mode 100644 index 00000000..600bf79f --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/testdata/retirement-state.json @@ -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" + } + ] +} diff --git a/modules/mesh-vault/cmd/mesh-vault/testdata/retirement-word.json b/modules/mesh-vault/cmd/mesh-vault/testdata/retirement-word.json new file mode 100644 index 00000000..57792616 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/testdata/retirement-word.json @@ -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" +}