Files
mesh-catalog/modules/mesh-config/cmd/mesh-config/takeover.go
T
jschoubben d34645361d Build config's switch from the controller's records: declared, compare, take-over and hand-back, so the operator approves the last move only once nothing differs (issue 1953)
compare also resolves every placed module's values on every node both ways, as ADR 0565's insight of 2026-10-29 asks after #558. After the take-over the predefined list and pull requests' config come from the indexer and pull-declares is refused; a hand-back returns to the mirrors until the lease write. From the lease write a trusted value is set only on an Approve. Acts are recorded through mesh-hand-acts. Pins mesh-sdk 2e5cc4f.
2026-10-29 22:27:23 +01:00

772 lines
28 KiB
Go

package main
// Config's records out of the controller (novox/hq ADR 0565 §3, §4, §13, §15, and the progressive insight of
// 2026-10-29): mesh-config keeps each placed module's declarations in its own state `declared`; `compare` lists every
// difference between its own sources and the controller's mirrors — each module's declarations, the predefined list,
// each open pull request's config declarations, and every placed module's resolved value on every node both ways;
// `take-over` asks the operator's Approve under mesh-config's own name and, refused while anything differs, writes the
// key `takeover.switch` naming the indexer; `hand-back` returns to the mirrors until the lease write.
//
// **Which source holds** is the key `switch` alone, read by mesh-config alone:
// - none written (the interim of mesh-catalog #558): declarations from each placed module's own manifests, the list
// of predefined keys from the controller's mirror, a pull request's declarations from the controller's
// pull-declares;
// - `indexer` (taken over): declarations as before, the list from the indexer's `predefined`, a pull request's from
// the indexer's `config-candidates`; pull-declares refused;
// - `controller` (handed back): declarations from the mirror `declared-config`, the list from the mirror,
// pull-declares taken.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The states and reads of the switch.
const (
// DeclaredState is each placed module's declarations at the trunk, one key per module (ADR 0565 §3).
DeclaredState = "declared"
// TakeoverState holds the key SwitchKey and each take-over ask, `ask.<id>`.
TakeoverState = "takeover"
SwitchKey = "switch"
// MirrorDeclaredRead is the controller's mirror of every registered module's declarations, one key per module:
// what `compare` holds the own source to, and the source after a hand-back.
MirrorDeclaredRead = "mesh-controller.declared-config"
// IndexerPredefinedRead and CandidatesRead are the indexer's states (ADR 0565 §2).
IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState
CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState
// LeaseRead is the controller's lease: after its write hand-back is gone and the terminal sets nothing (§13).
LeaseRead = buscompose.ControllerLease
)
// The sources the switch names.
const (
SourceInterim = "interim"
SourceIndexer = "indexer"
SourceController = "controller"
)
// TakeOverVerb and HandBackVerb are the seat's verbs.
const (
TakeOverVerb = "take-over"
HandBackVerb = "hand-back"
CompareVerb = "compare"
)
// Switch is the key `takeover.switch`: the source that holds, who chose it, why and on which ask.
type Switch struct {
Source string `json:"source"`
At time.Time `json:"at"`
By string `json:"by"`
Why string `json:"why"`
Ask string `json:"ask,omitempty"`
}
// source is the source that holds now.
func (c *Config) source() (Switch, error) {
e, err := c.Store.Get(TakeoverState, SwitchKey)
if err != nil {
return Switch{}, fmt.Errorf("the take-over's switch cannot be read: %w", err)
}
if e == nil {
return Switch{Source: SourceInterim}, nil
}
var s Switch
if err := json.Unmarshal(e.Value, &s); err != nil || (s.Source != SourceIndexer && s.Source != SourceController) {
return Switch{}, fmt.Errorf("the take-over's switch is not one: %s", e.Value)
}
return s, nil
}
// leaseAway says the controller's lease is handed away (ADR 0561 §3's reading).
func (c *Config) leaseAway() bool {
e, err := c.Store.Get(LeaseRead, buscompose.ControllerLeaseKey)
var raw []byte
if e != nil {
raw = e.Value
}
away, _ := buscompose.LeaseHandedAway(raw, e != nil, err)
return away
}
// ---- declared --------------------------------------------------------------------------------------------
// DeclaredKept is one key of `declared`: a module's declarations as the manifest the indexer's `modules.<module>`
// names holds them, with that manifest's digest and commit (ADR 0565 §3).
type DeclaredKept struct {
Module string `json:"module"`
Manifest string `json:"manifest"`
Commit string `json:"commit,omitempty"`
Config map[string]Declaration `json:"settings"`
}
// trunkKept is a module's declarations at the trunk with the manifest they come from.
func (c *Config) trunkKept(module string) (DeclaredKept, error) {
key, err := delivery.ModulesKey(module)
if err != nil {
return DeclaredKept{}, err
}
e, err := c.Store.Get(ModulesRead, key)
if err != nil || e == nil {
return DeclaredKept{}, fmt.Errorf("the indexer's modules.%s cannot be read (%v)", module, err)
}
var m struct {
Manifest string `json:"manifest"`
Commit string `json:"commit"`
}
if err := json.Unmarshal(e.Value, &m); err != nil || m.Manifest == "" {
return DeclaredKept{}, fmt.Errorf("the indexer's modules.%s names no manifest", module)
}
d, err := c.manifestDeclarations(module, m.Manifest)
if err != nil {
return DeclaredKept{}, err
}
return DeclaredKept{Module: module, Manifest: m.Manifest, Commit: m.Commit, Config: d.Config}, nil
}
// writeDeclared makes `declared` hold each placed module's trunk declarations, writing a key only when it differs
// (ADR 0289 §1) and removing one of a module placed nowhere. It answers the modules written.
func (c *Config) writeDeclared(s Sources) ([]string, error) {
held := map[string][]byte{}
if err := c.each(DeclaredState, func(key string, raw []byte) error {
held[key] = raw
return nil
}); err != nil {
return nil, err
}
var written []string
var problems []error
placed := map[string]bool{}
for _, module := range modulesOf(s.Assignments) {
placed[module] = true
k, err := c.trunkKept(module)
if err != nil {
continue // unresolved, said by the resolution; its key stays as it was
}
raw, _ := json.Marshal(k)
if bytes.Equal(raw, held[module]) {
continue
}
if err := c.Store.Put(DeclaredState, module, k); err != nil {
problems = append(problems, fmt.Errorf("%s: %w", module, err))
continue
}
written = append(written, module)
}
for module := range held {
if !placed[module] {
if err := c.Store.Delete(DeclaredState, module); err != nil {
problems = append(problems, fmt.Errorf("%s: %w", module, err))
}
}
}
return written, errors.Join(problems...)
}
// mirrorDeclarations makes the sources' declarations the controller's mirror's, for every placed module: the source
// after a hand-back, and the other side of compare.
func (c *Config) mirrorDeclarations(s *Sources) {
s.Declared, s.OnNode, s.Unresolved = map[string]ModuleDeclarations{}, map[string]ModuleDeclarations{}, nil
for _, module := range modulesOf(s.Assignments) {
e, err := c.Store.Get(MirrorDeclaredRead, module)
var d ModuleDeclarations
if err == nil && e != nil {
d, err = ParseModuleDeclarations(e.Value)
} else if err == nil {
err = errors.New("the mirror holds no key for it")
}
if err != nil {
for _, a := range s.Assignments {
if a.Module == module {
s.Unresolved = append(s.Unresolved, Unresolved{Node: a.Node, Module: module,
Why: "the controller's mirror declared-config: " + err.Error()})
}
}
continue
}
s.Declared[module] = d
}
}
// ---- the predefined list and the pull requests from the indexer ------------------------------------------
// indexerList is the indexer's predefined list as the mirror's list holds one: the keys, and the commit.
func (c *Config) indexerList() (PredefinedList, bool, error) {
e, err := c.Store.Get(IndexerPredefinedRead, delivery.PredefinedListKey)
if err != nil || e == nil {
return PredefinedList{}, false, err
}
var kept delivery.PredefinedKept
if err := json.Unmarshal(e.Value, &kept); err != nil {
return PredefinedList{}, false, fmt.Errorf("the indexer's predefined list is not one: %w", err)
}
list := PredefinedList{Keys: map[string]PredefinedKey{}, Commit: kept.Commit}
if err := json.Unmarshal(kept.List, &list.Keys); err != nil {
return PredefinedList{}, false, fmt.Errorf("the indexer's predefined list is not predefined.json: %w", err)
}
return list, true, nil
}
// mirrorList is the controller's mirror's list.
func (c *Config) mirrorList() (PredefinedList, bool, error) {
e, err := c.Store.Get(PredefinedRead, PredefinedMirrorKey)
if err != nil || e == nil {
return PredefinedList{}, false, err
}
var list PredefinedList
if err := json.Unmarshal(e.Value, &list); err != nil || list.Keys == nil {
return PredefinedList{}, false, fmt.Errorf("the controller's list is not one: %s", e.Value)
}
return list, true, nil
}
// candidates is every open pull request's config declarations as the indexer keeps them, by pull key.
func (c *Config) candidates() (map[string]PullDecl, error) {
out := map[string]PullDecl{}
err := c.each(CandidatesRead, func(key string, raw []byte) error {
var cand delivery.ConfigCandidate
if err := json.Unmarshal(raw, &cand); err != nil || cand.Pull == "" {
return fmt.Errorf("the indexer's config candidate %s is not one", key)
}
p := PullDecl{Pull: cand.Pull, Head: cand.Head, Declares: map[string]map[string]Declaration{}, At: cand.At}
for module, keys := range cand.Declares {
raw, _ := json.Marshal(keys)
var d map[string]Declaration
if err := json.Unmarshal(raw, &d); err != nil {
return fmt.Errorf("the indexer's config candidate %s declares %s unreadably", key, module)
}
for k, decl := range d {
if !decl.HasDefault() {
decl.Default = nil
}
d[k] = decl
}
p.Declares[module] = d
}
out[pullKey(cand.Pull)] = p
return nil
})
return out, err
}
// takeCandidates is the indexer's config candidates taken in place of pull-declares, after the take-over: each one
// whose head or declarations differ from what `pulls` holds is declared again, its held values judged again (ADR 0502
// §6). The caller holds the lock.
func (c *Config) takeCandidates() []string {
cands, err := c.candidates()
if err != nil {
return []string{"the indexer's config candidates cannot be read: " + err.Error()}
}
var s Sources
if err := c.readPulls(&s); err != nil {
return []string{err.Error()}
}
var said []string
for _, k := range sortedKeys(cands) {
p := cands[k]
if have, ok := s.Pulls[k]; ok && have.Head == p.Head && declaresDigest(have.Declares) == declaresDigest(p.Declares) {
continue
}
a, err := c.declarePull(p.Pull, p.Head, p.Declares)
if err != nil {
said = append(said, fmt.Sprintf("%s from the indexer: %v", p.Pull, err))
continue
}
said = append(said, a.Said+" (from the indexer)")
}
return said
}
// declaresDigest is what a pull request's declarations hold a value to, in one encoding.
func declaresDigest(d map[string]map[string]Declaration) string {
flat := map[string]string{}
for m, keys := range d {
for k, decl := range keys {
flat[m+"."+k] = declDigest(decl)
}
}
raw, _ := json.Marshal(flat)
return string(raw)
}
// ---- compare ----------------------------------------------------------------------------------------------
// Difference is one thing the own sources and the controller's mirrors say differently, and why.
type Difference struct {
What string `json:"what"`
Module string `json:"module,omitempty"`
Node string `json:"node,omitempty"`
Key string `json:"key,omitempty"`
Pull string `json:"pull,omitempty"`
Own json.RawMessage `json:"own,omitempty"`
Mirror json.RawMessage `json:"mirror,omitempty"`
Why string `json:"why"`
}
// The kinds of difference.
const (
DiffDeclared = "declared"
DiffPredefined = "predefined"
DiffPull = "pull"
DiffValue = "value"
)
// Comparison is what compare answers.
type Comparison struct {
Source string `json:"source"`
Differences []Difference `json:"differences"`
Count int `json:"count"`
Said string `json:"said"`
}
// Compare lists every difference (ADR 0565 §4 and its insight). The caller holds no lock.
func (c *Config) Compare() (*Comparison, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.compare()
}
// compare is Compare; the caller holds the lock.
func (c *Config) compare() (*Comparison, error) {
sw, err := c.source()
if err != nil {
return nil, err
}
own, err := c.sourcesFrom(SourceInterim)
if err != nil {
return nil, fmt.Errorf("mesh-config's own sources cannot be read: %w", err)
}
mirror := own
mirror.Declared, mirror.OnNode, mirror.Unresolved = nil, nil, nil
c.mirrorDeclarations(&mirror)
var diffs []Difference
// Each placed module's declarations: `declared` against the mirror's key for it.
for _, module := range modulesOf(own.Assignments) {
var ownRaw []byte
if e, err := c.Store.Get(DeclaredState, module); err == nil && e != nil {
var k DeclaredKept
if json.Unmarshal(e.Value, &k) == nil {
ownRaw, _ = json.Marshal(normal(k.Config))
}
}
var mirrorRaw []byte
if d, ok := mirror.Declared[module]; ok {
mirrorRaw, _ = json.Marshal(normal(d.Config))
}
switch {
case ownRaw == nil && mirrorRaw == nil:
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Why: "neither side holds its declarations"})
case ownRaw == nil:
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Mirror: mirrorRaw,
Why: "the mirror holds its declarations and declared does not"})
case mirrorRaw == nil:
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Own: ownRaw,
Why: "declared holds its declarations and the mirror does not"})
case !bytes.Equal(ownRaw, mirrorRaw):
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Own: ownRaw, Mirror: mirrorRaw,
Why: "the trunk's manifest declares it otherwise than the mirror"})
}
}
// The predefined list: the indexer's against the mirror's.
il, iok, ierr := c.indexerList()
ml, mok, merr := c.mirrorList()
switch {
case ierr != nil || merr != nil:
diffs = append(diffs, Difference{What: DiffPredefined, Why: "a list cannot be read: " + errors.Join(ierr, merr).Error()})
case !iok || !mok:
if iok != mok {
diffs = append(diffs, Difference{What: DiffPredefined, Why: fmt.Sprintf("the indexer holds a list: %v; the "+
"mirror holds one: %v", iok, mok)})
}
default:
for _, k := range unionKeys(il.Keys, ml.Keys) {
a, aok := il.Keys[k]
b, bok := ml.Keys[k]
ar, _ := json.Marshal(a)
br, _ := json.Marshal(b)
switch {
case !aok:
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Mirror: br, Why: "only the mirror's list holds it"})
case !bok:
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Own: ar, Why: "only the indexer's list holds it"})
case !bytes.Equal(ar, br):
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Own: ar, Mirror: br,
Why: "the two lists declare it otherwise"})
}
}
}
// Each open pull request: the indexer's candidate against what the controller told by pull-declares.
cands, cerr := c.candidates()
if cerr != nil {
diffs = append(diffs, Difference{What: DiffPull, Why: "the indexer's config candidates cannot be read: " + cerr.Error()})
}
for _, k := range unionKeys(cands, own.Pulls) {
a, aok := cands[k]
b, bok := own.Pulls[k]
switch {
case !aok:
diffs = append(diffs, Difference{What: DiffPull, Pull: b.Pull, Why: "the controller told its declarations and the indexer holds no candidate"})
case !bok:
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the indexer holds a candidate and the controller told nothing"})
case a.Head != b.Head:
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: fmt.Sprintf("the indexer's candidate is at %s, "+
"the controller told %s", short(a.Head), short(b.Head))})
case declaresDigest(a.Declares) != declaresDigest(b.Declares):
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the two declare its config otherwise"})
}
}
// Every placed module's value on every node, resolved both ways.
ownValues, mirrorValues := Resolve(own), Resolve(mirror)
for _, k := range unionKeys(ownValues, mirrorValues) {
a, aok := ownValues[k]
b, bok := mirrorValues[k]
ar, _ := json.Marshal(a)
br, _ := json.Marshal(b)
node, module, key := a.Node, a.Module, a.Key
if !aok {
node, module, key = b.Node, b.Module, b.Key
}
d := Difference{What: DiffValue, Node: node, Module: module, Key: key}
switch {
case !aok:
d.Mirror, d.Why = br, "only the mirror's declarations resolve it on this node"
case !bok:
d.Own, d.Why = ar, "only mesh-config's own declarations (with this node's build) resolve it"
case !bytes.Equal(ar, br):
d.Own, d.Mirror, d.Why = ar, br, "it resolves to another value or source"
default:
continue
}
diffs = append(diffs, d)
}
for _, u := range mirror.Unresolved {
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from the mirror: " + u.Why})
}
for _, u := range own.Unresolved {
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from its own source: " + u.Why})
}
sort.SliceStable(diffs, func(i, j int) bool {
a, b := diffs[i], diffs[j]
return a.What+a.Module+a.Node+a.Key+a.Pull < b.What+b.Module+b.Node+b.Key+b.Pull
})
if diffs == nil {
diffs = []Difference{}
}
cmp := &Comparison{Source: sw.Source, Differences: diffs, Count: len(diffs)}
if len(diffs) == 0 {
cmp.Said = "nothing differs: the take-over may be asked"
} else {
cmp.Said = fmt.Sprintf("%d difference(s): the take-over is refused while any stands", len(diffs))
}
return cmp, nil
}
// normal is declarations in one encoding: a null default as none.
func normal(d map[string]Declaration) map[string]Declaration {
out := map[string]Declaration{}
for k, decl := range d {
if !decl.HasDefault() {
decl.Default = nil
} else {
decl.Default = compact(decl.Default)
}
out[k] = decl
}
return out
}
func unionKeys[A, B any](a map[string]A, b map[string]B) []string {
seen := map[string]bool{}
for k := range a {
seen[k] = true
}
for k := range b {
seen[k] = true
}
return sortedKeys(seen)
}
// ---- take-over and hand-back ---------------------------------------------------------------------------------
// TakeoverAsk is one take-over asked of the operator, `takeover.ask.<id>`: what the ask is composed from again, and
// where it stands.
type TakeoverAsk struct {
ID string `json:"id"`
From string `json:"from"`
Why string `json:"why"`
At time.Time `json:"at"`
State string `json:"state"`
Outcome string `json:"outcome,omitempty"`
Decided time.Time `json:"decided,omitzero"`
Answered string `json:"answered,omitempty"`
Recorded bool `json:"recorded,omitempty"`
}
func askKey(id string) string { return "ask." + id }
// act is what an answer to the take-over binds.
func (t TakeoverAsk) act(option string) asks.Act {
return asks.Act{"verb": Seat + "." + TakeOverVerb, "option": option, "ask": t.ID, "from": t.From,
"at": t.At.UTC().Format(time.RFC3339Nano)}
}
// ask is the take-over's ask, composed from it alone.
func (t TakeoverAsk) ask() asks.Ask {
why, _ := sayable("Why: " + t.Why)
a := asks.Ask{ID: t.ID, Headline: "Switch config to its own sources",
Explanation: strings.Join([]string{
"mesh-config would read the list of predefined keys and each pull request's config from the delivery's " +
"indexer, no longer from the controller. Nothing any node resolves changes: compare showed no difference.",
point(why)}, "\n"),
Who: asks.Operator, Expires: t.At.Add(ProposalLasts),
OnExpiry: "nothing changes; config keeps reading the controller's records", About: "config.takeover",
Details: "Read what was compared with mesh-config's compare verb, through the mesh MCP server. hand-back returns " +
"to the controller's records until the controller's lease is handed away."}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "config switches to its own sources at once, if nothing differs then"},
{OptionDecline, "Decline", "nothing changes"},
} {
binds, _ := asks.ActDigest(t.act(o.id))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
}
return a
}
// TakeoverAnswer is what take-over and hand-back answer.
type TakeoverAnswer struct {
Said string `json:"said"`
Ask string `json:"ask,omitempty"`
Source string `json:"source"`
}
// TakeOver asks the operator's Approve to switch to the own sources, refused while compare shows a difference.
func (c *Config) TakeOver(call Call) (*TakeoverAnswer, error) {
if strings.TrimSpace(call.Why) == "" {
return nil, errors.New("say why the take-over is asked now: it is shown to the operator")
}
if call.Caller == "" || call.Caller == Unproven {
return nil, errors.New("the call names no proven caller")
}
if c.Asks == nil {
return nil, errors.New("the operator cannot be asked from here, so nothing is switched")
}
c.mu.Lock()
defer c.mu.Unlock()
sw, err := c.source()
if err != nil {
return nil, err
}
if sw.Source == SourceIndexer {
return &TakeoverAnswer{Source: sw.Source, Said: "config reads its own sources already, since " +
sw.At.UTC().Format(time.RFC3339) + " (" + sw.By + ")"}, nil
}
cmp, err := c.compare()
if err != nil {
return nil, err
}
if cmp.Count > 0 {
first := cmp.Differences[0]
return nil, fmt.Errorf("the take-over is refused: compare shows %d difference(s), the first %s %s%s%s%s: %s. "+
"Nothing was asked", cmp.Count, first.What, first.Module, first.Node, first.Key, first.Pull, first.Why)
}
now := c.now()
var open []string
_ = c.each(TakeoverState, func(key string, raw []byte) error {
var t TakeoverAsk
if strings.HasPrefix(key, "ask.") && json.Unmarshal(raw, &t) == nil && t.State == ProposalWaiting &&
now.Before(t.At.Add(ProposalLasts)) {
open = append(open, t.ID)
}
return nil
})
if len(open) > 0 {
return nil, fmt.Errorf("the take-over is asked already, as %s, and waits for the operator's answer", open[0])
}
t := TakeoverAsk{ID: "takeover-" + newProposalID(), From: call.Caller, Why: strings.TrimSpace(call.Why), At: now.UTC(),
State: ProposalWaiting}
ask := t.ask()
if err := ask.Check(now); err != nil {
return nil, err
}
if err := c.Store.Put(TakeoverState, askKey(t.ID), t); err != nil {
return nil, fmt.Errorf("the take-over could not be kept, so the operator was not asked: %w", err)
}
if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
t.State, t.Outcome = ProposalUnasked, "the ask could not be published: "+err.Error()
_ = c.Store.Put(TakeoverState, askKey(t.ID), t)
return nil, fmt.Errorf("the operator could not be asked: %w", err)
}
return &TakeoverAnswer{Ask: t.ID, Source: sw.Source, Said: fmt.Sprintf("the operator is asked %q under "+
"mesh-config's own name; nothing changes until they approve, and on Approve config switches if nothing differs "+
"then", ask.Headline)}, nil
}
// decideTakeovers acts on each take-over ask the router has closed. The caller holds the lock.
func (c *Config) decideTakeovers() []string {
var said []string
var waiting []TakeoverAsk
_ = c.each(TakeoverState, func(key string, raw []byte) error {
var t TakeoverAsk
if strings.HasPrefix(key, "ask.") && json.Unmarshal(raw, &t) == nil && t.State == ProposalWaiting {
waiting = append(waiting, t)
}
return nil
})
now := c.now()
for _, t := range waiting {
keep := func(state, outcome string) string {
t.State, t.Outcome, t.Decided = state, outcome, now
_ = c.Store.Put(TakeoverState, askKey(t.ID), t)
return "take-over " + t.ID + ": " + outcome
}
e, err := c.Asks.Record(t.ID)
if err != nil || e == nil {
if err == nil && now.After(t.At.Add(ProposalLasts+time.Hour)) {
said = append(said, keep(ProposalEnded, "the router keeps no record of its ask; nothing was switched"))
}
continue
}
var rec routerRecord
if json.Unmarshal(e.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
continue
}
w := *rec.Warrant
if w.Asker != Module || w.Ask != t.ID {
said = append(said, keep(ProposalRefused, "the router's record is for another ask; nothing was switched"))
continue
}
if w.Outcome != asks.OutcomeChosen {
said = append(said, keep(ProposalEnded, "the ask "+string(w.Outcome)+"; nothing was switched"))
continue
}
option, err := w.For(Module, t.ask())
if err == nil {
err = option.Performs(t.act(option.ID))
}
if err != nil {
said = append(said, keep(ProposalRefused, "the answer is refused: "+err.Error()))
continue
}
t.Answered = w.Says()
if option.ID != OptionApprove {
said = append(said, keep(ProposalDeclined, "declined by the operator; nothing was switched"))
continue
}
cmp, err := c.compare()
switch {
case err != nil:
said = append(said, keep(ProposalRefused, "approved, and compare could not be read, so nothing was switched: "+err.Error()))
continue
case cmp.Count > 0:
said = append(said, keep(ProposalRefused, fmt.Sprintf("approved, and refused: compare shows %d difference(s) "+
"now; nothing was switched, ask again once it is empty", cmp.Count)))
continue
}
sw := Switch{Source: SourceIndexer, At: now.UTC(), By: approvedTakeover(w), Why: t.Why, Ask: t.ID}
if err := c.Store.Put(TakeoverState, SwitchKey, sw); err != nil {
said = append(said, fmt.Sprintf("take-over %s: approved, and the switch could not be written (%v); read again "+
"on the next look", t.ID, err))
continue
}
if err := c.Asks.Warranted(t.ID); err == nil {
t.Recorded = true
}
said = append(said, keep(ProposalApproved, "approved by the operator: config reads its own sources from now"))
}
return said
}
func approvedTakeover(w asks.Warrant) string {
if w.By == nil {
return "the operator"
}
return fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
}
// HandBack returns to the controller's records until the controller's lease is handed away.
func (c *Config) HandBack(call Call) (*TakeoverAnswer, error) {
if strings.TrimSpace(call.Why) == "" {
return nil, errors.New("say why config hands back: it is kept with the switch")
}
if call.Caller == "" || call.Caller == Unproven {
return nil, errors.New("the call names no proven caller")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.leaseAway() {
return nil, errors.New("hand-back is gone: the controller handed its lease away, and its records end with it " +
"(novox/hq ADR 0565 §4)")
}
sw, err := c.source()
if err != nil {
return nil, err
}
if sw.Source == SourceController {
return &TakeoverAnswer{Source: sw.Source, Said: "config reads the controller's records already"}, nil
}
now := c.now()
back := Switch{Source: SourceController, At: now.UTC(), By: call.Caller, Why: strings.TrimSpace(call.Why)}
if err := c.Store.Put(TakeoverState, SwitchKey, back); err != nil {
return nil, fmt.Errorf("the switch could not be written: %w", err)
}
answer := &TakeoverAnswer{Source: back.Source, Said: "config reads the controller's records again: its mirrors " +
"declared-config and predefined, and pull-declares"}
if err := c.Store.HandAct("handed config back to the controller's records", back.Why, "config-hand-back"); err != nil {
answer.Said += "; the hand-act log does not hold it: " + err.Error()
}
return answer, nil
}
// Declared is the read of `declared`: every module's key, or one whole.
func (c *Config) Declared(module string) (any, error) {
if module != "" {
e, err := c.Store.Get(DeclaredState, module)
if err != nil {
return nil, err
}
if e == nil {
return nil, fmt.Errorf("declared holds no %s: it is placed nowhere, or its manifest cannot be read", module)
}
return json.RawMessage(e.Value), nil
}
rows := map[string]any{}
err := c.each(DeclaredState, func(key string, raw []byte) error {
var k DeclaredKept
if json.Unmarshal(raw, &k) == nil {
rows[key] = map[string]any{"manifest": k.Manifest, "commit": k.Commit, "keys": len(k.Config)}
}
return nil
})
sw, _ := c.source()
return map[string]any{"declared": rows, "count": len(rows), "source": sw}, err
}
// Resync reads every source again and writes what differs: the published list, the resolved values, `declared`, and
// after the take-over the indexer's config candidates (ADR 0565 §15).
func (c *Config) Resync() (any, error) {
r, err := c.Reconcile()
if err != nil {
return r, err
}
c.mu.Lock()
defer c.mu.Unlock()
out := map[string]any{"resolution": r}
if sw, err := c.source(); err == nil && sw.Source == SourceIndexer {
out["candidates"] = c.takeCandidates()
}
return out, nil
}