Drop the keeper's and config's take-over verbs with the controller's sources

mesh-sdk drops compare, take-over and hand-back from the keeper's and
config's seats (ADR 0590): there is no controller to compare with or hand
back to. mesh-conditions loses those verbs, its take-over key and asks;
mesh-config reads its own sources always and pull-declares is gone; and
mesh-hand-acts no longer names the controller's log (issue 1126).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 12:29:56 +01:00
parent f83e6e4713
commit a73b02ea30
24 changed files with 99 additions and 1097 deletions
@@ -1,185 +0,0 @@
package main
// **Built dark, compared, then switched by the operator** (novox/hq ADR 0564 clause 9; issue 1952 C5, C8): `compare`
// lists the open keys of the keeper and of the controller, each difference with its reason; the kinds that end with the
// controller, the kinds only the keeper raises and the keys held while their module is delivered are listed apart and
// not counted. `take-over` is refused (issue 1126): the keeper is the one source since the controller ended by its
// unassignment; a yes to an ask raised before still writes the take-over key naming the keeper. `hand-back` is gone (issue 774): nothing
// reads the take-over key any more, so it is answered as gone and changes nothing.
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/health"
)
// Difference is one key the two sides do not agree on, or one listed apart.
type Difference struct {
Key string `json:"key"`
Kind string `json:"kind"`
Reason string `json:"reason"`
}
// Comparison is compare's answer.
type Comparison struct {
Differences []Difference `json:"differences"`
// Apart are the keys not counted: of kinds that end with the controller, raised only by the keeper, or held.
Apart []Difference `json:"apart"`
Same int `json:"same"`
At time.Time `json:"at"`
}
// Empty says nothing differs.
func (c Comparison) Empty() bool { return len(c.Differences) == 0 }
// Compare is the keeper's open conditions against the controller's.
func (k *Keeper) Compare() (Comparison, error) {
mine, err := k.Open()
if err != nil {
return Comparison{}, err
}
k.mu.Lock()
k.init()
theirs := map[string]health.Condition{}
for key, c := range k.ctrl {
theirs[key] = c
}
heldNow := map[string]held{}
for key, h := range k.held {
heldNow[key] = h
}
k.mu.Unlock()
out := Comparison{Differences: []Difference{}, Apart: []Difference{}, At: k.Now().UTC()}
open := map[string]health.Condition{}
for _, c := range mine {
open[c.Key] = c
}
keys := map[string]bool{}
for key := range open {
keys[key] = true
}
for key := range theirs {
keys[key] = true
}
for key := range heldNow {
keys[key] = true
}
sorted := make([]string, 0, len(keys))
for key := range keys {
sorted = append(sorted, key)
}
sort.Strings(sorted)
for _, key := range sorted {
c, inMine := open[key]
t, inTheirs := theirs[key]
kind := c.Kind
if !inMine {
kind = t.Kind
}
home, homed := k.homeOf(kind)
if h, ok := heldNow[key]; ok {
out.Apart = append(out.Apart, Difference{Key: key, Kind: kind,
Reason: fmt.Sprintf("held while %s is delivered on %s (version %s)", h.Module, h.Node, short(h.Version))})
continue
}
switch {
case homed && (home.Carried || home.Replaced):
why := "ends with the controller: no longer carried (issue 1126)"
if home.Replaced {
why = "ends with the controller: its fault is observed otherwise"
}
if home.Why != "" {
why += " (" + home.Why + ")"
}
out.Apart = append(out.Apart, Difference{Key: key, Kind: kind, Reason: why})
case inMine && !inTheirs && (!homed || !home.Controller):
out.Apart = append(out.Apart, Difference{Key: key, Kind: kind, Reason: "keeper only: the controller never raises this kind"})
case inMine && !inTheirs:
out.Differences = append(out.Differences, Difference{Key: key, Kind: kind,
Reason: "open at the keeper, not at the controller"})
case !inMine && !homed:
out.Differences = append(out.Differences, Difference{Key: key, Kind: kind,
Reason: fmt.Sprintf("open at the controller; no home is decided for the kind %s (ADR 0564's gap)", kind)})
case !inMine:
out.Differences = append(out.Differences, Difference{Key: key, Kind: kind,
Reason: fmt.Sprintf("open at the controller; %s has not written its finding", home.Observer)})
case c.Severity != t.Severity:
out.Differences = append(out.Differences, Difference{Key: key, Kind: kind,
Reason: fmt.Sprintf("%s at the keeper, %s at the controller", c.Severity, t.Severity)})
default:
out.Same++
}
}
return out, nil
}
// Asks is the operator channel as the keeper asks it: an approve ask under its own name, and where one stands.
type Asks interface {
Ask(a asks.Ask) error
}
// TakeOver is refused: the keeper is the one source of conditions since the controller ended (issue 1126).
func (k *Keeper) TakeOver(a Asks, by, why string) (map[string]any, error) {
if strings.TrimSpace(why) == "" {
return nil, errors.New("a take-over says why")
}
return nil, errors.New("the keeper is the one source already: the controller ended by its unassignment (issue 1126)")
}
// Warranted is the act on the operator's answer: a yes, compare still empty, writes the take-over key naming the keeper.
func (k *Keeper) Warranted(o asks.Option, w asks.Warrant) error {
if o.ID != "yes" {
k.Logf("the take-over %s was answered %s: alarms stay with the controller", w.Ask, o.ID)
return nil
}
cmp, err := k.Compare()
if err != nil {
return err
}
if !cmp.Empty() {
return fmt.Errorf("the take-over %s was approved, and compare now shows %d difference(s): not switched", w.Ask,
len(cmp.Differences))
}
return k.writeTakeover(health.Takeover{Follow: health.FollowKeeper, At: k.Now().UTC(), By: w.Says(), Ask: w.Ask})
}
// HandBack is gone (issue 774): the readers of conditions hear the keeper alone and nothing reads the take-over key, so
// a hand-back written there would answer success and do nothing (ADR 0564 clause 9). It is answered as gone, naming the
// record, and changes nothing.
func (k *Keeper) HandBack(by, why string) (map[string]any, error) {
return nil, errors.New("hand-back is gone: the readers of conditions hear the condition keeper alone, and nothing " +
"reads the take-over key, so there is no controller to hand them back to (ADR 0564 clause 9)")
}
// writeTakeover writes the take-over key, and keeps it in history as the record of the switch.
func (k *Keeper) writeTakeover(t health.Takeover) error {
raw, err := health.EncodeTakeover(t)
if err != nil {
return err
}
_, rev, found, err := k.Store.Get(TakeoverState, health.TakeoverKey)
if err != nil {
return err
}
if found {
_, err = k.Store.Update(TakeoverState, health.TakeoverKey, json.RawMessage(raw), rev)
} else {
_, err = k.Store.Create(TakeoverState, health.TakeoverKey, json.RawMessage(raw))
}
if errors.Is(err, stdio.ErrChanged) {
return fmt.Errorf("the take-over key moved under this write: read it again and ask again")
}
if err != nil {
return err
}
k.Takeover(t)
k.Logf("the readers of conditions follow the %s from now (%s)", t.Follow, t.By)
return nil
}
@@ -309,7 +309,7 @@ func short(fp string) string {
return fp
}
// Held is every key held now, for the read tool and compare.
// Held is every key held now, for the read tool.
func (k *Keeper) Held() []held {
k.mu.Lock()
defer k.mu.Unlock()
@@ -73,17 +73,6 @@ func TestADeliveryHoldsItsOwnFindingsUntilItEnds(t *testing.T) {
if got := r.changes(); got != "raised "+other+", raised "+machine {
t.Errorf("%s: during the delivery said %q: another module's and the machine's at once, nothing of its own", c.name, got)
}
// C8: compare lists the held keys as held, never as a difference.
cmp, _ := r.k.Compare()
held := 0
for _, d := range cmp.Apart {
if strings.HasPrefix(d.Reason, "held while mesh-controller is delivered on novox") {
held++
}
}
if held != 1 {
t.Errorf("%s: compare lists %d held, %+v", c.name, held, cmp)
}
// Its own finding goes unless it stands.
if !c.standing {
_ = r.k.Finding(watchman, own, nil, true)
@@ -1,12 +1,11 @@
package main
// **Where each condition kind lives after the take-over** (novox/hq ADR 0564 clauses 4, 6, 7 and 9, the Context's probe
// table; issue 1952 C3, C5): data, so a kind's home is a row and never code. `compare` reads it to tell a difference from
// a kind that ends with the controller or that the controller never raises, and the keeper reads it to clear, once, what
// it carried of the kinds that ended with the controller (EndCarried, issue 1126).
// **Where each condition kind the controller raised lives now** (novox/hq ADR 0564 clauses 4, 6, 7 and 9, the Context's
// probe table; issue 1952 C3, C5): data, so a kind's home is a row and never code. The keeper reads it to clear, once,
// what it carried of the kinds that ended with the controller (EndCarried, issue 1126); `compare`, which read it too,
// went with the controller (ADR 0590).
//
// The rows are ADR 0564's and the lead's table of 2026-10-29, by the controller's source of each kind. A kind with no
// row is named by `compare` as "no home decided".
// The rows are ADR 0564's and the lead's table of 2026-10-29, by the controller's source of each kind.
// Home is where one condition kind lives.
type Home struct {
@@ -28,12 +28,9 @@ const (
Seat = "mesh-conditions"
ConditionsState = health.ConditionsState
TakeoverState = health.TakeoverState
HistoryState = "history"
HeldState = "held"
BoundsState = "bounds"
// AskedState keeps the keeper's asks of the operator (go/asker).
AskedState = "asked"
)
// HistoryKeptFor is how long a transition is kept (clause 3).
@@ -94,7 +91,7 @@ type Keeper struct {
Say func(e health.Event) error
Now func() time.Time
Logf func(format string, a ...any)
// Homes says, by kind, where each controller kind lives after the take-over (homes.go); Homes when nil.
// Homes says, by kind, where each kind the controller raised lives now (homes.go); Homes when nil.
Homes map[string]Home
// Bound is a delivery's hold bound; delivery.DefaultDeliveryBound when zero (hold.go).
Bound time.Duration
@@ -103,10 +100,6 @@ type Keeper struct {
findings map[string]map[string]health.Finding
blind map[string]string
attempts map[string]health.Attempt
// ctrl is the controller's conditions as Controller hands them: compare's other side alone, never a condition's
// input (issue 1126). Nothing feeds it since issue 774; it goes with compare.
ctrl map[string]health.Condition
takeover health.Takeover
wants map[string]delivery.Want
haves map[string]delivery.Have
// holders are, by `<node>.<seat>`, the module the placement says holds the seat there.
@@ -125,11 +118,10 @@ type Keeper struct {
func (k *Keeper) init() {
if k.findings == nil {
k.findings, k.blind, k.attempts, k.ctrl = map[string]map[string]health.Finding{}, map[string]string{},
map[string]health.Attempt{}, map[string]health.Condition{}
k.findings, k.blind, k.attempts = map[string]map[string]health.Finding{}, map[string]string{},
map[string]health.Attempt{}
k.wants, k.haves, k.stuck, k.held, k.cleared = map[string]delivery.Want{}, map[string]delivery.Have{},
map[string]string{}, map[string]held{}, map[string]clearedMemo{}
k.takeover = health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}
k.passed, k.outOfHold, k.holders = map[string]string{}, map[string]string{}, map[string]string{}
k.placement = delivery.NewPlacementKeys()
}
@@ -209,26 +201,6 @@ func (k *Keeper) Attempt(key string, value []byte, deleted bool) error {
return k.Settle(condition)
}
// Controller takes one change of the controller's `conditions` bucket, for compare alone: no condition is carried from
// it (issue 1126).
func (k *Keeper) Controller(key string, value []byte, deleted bool) error {
k.mu.Lock()
k.init()
if deleted {
delete(k.ctrl, key)
} else {
var c health.Condition
if err := json.Unmarshal(value, &c); err != nil {
k.mu.Unlock()
k.Logf("the controller's condition %s is not readable, so compare does not list it: %v", key, err)
return nil
}
k.ctrl[key] = c
}
k.mu.Unlock()
return nil
}
// endedWhy is why a condition of a kind that ended with the controller is cleared.
const endedWhy = "its kind ended with the controller, which was unassigned (issue 1126): no finding raises it again"
@@ -273,14 +245,6 @@ func (k *Keeper) EndCarried() (int, error) {
return n, nil
}
// Takeover takes the keeper's own take-over key as read.
func (k *Keeper) Takeover(t health.Takeover) {
k.mu.Lock()
defer k.mu.Unlock()
k.init()
k.takeover = t
}
// Settle derives the condition key should be and writes the difference (clause 2), retried while another write moves
// it under this one.
func (k *Keeper) Settle(key string) error {
@@ -10,7 +10,6 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -242,28 +241,6 @@ func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *test
t.Fatal(err)
}
r.said = nil
until := r.now.Add(time.Hour)
for _, c := range []health.Condition{
{Key: "core.controller.deaf", Kind: "controller-deaf", Source: "S4", Subject: health.Subject{Scope: health.ScopeCore, ID: "controller"},
Severity: health.Urgent, Summary: "deaf", Resolver: "self"},
{Key: key, Kind: "silent", Severity: health.Urgent, Resolver: health.ResolverOperator,
Tried: []health.Tried{{At: r.now, What: "x", Outcome: "escalated", By: "healer H1"}},
Silenced: &health.Silence{Until: until}},
} {
raw, _ := json.Marshal(c)
if err := r.k.Controller(c.Key, raw, false); err != nil {
t.Fatal(err)
}
if _, err := r.k.Resync(); err != nil {
t.Fatal(err)
}
}
if _, open := r.open("core.controller.deaf"); open || r.changes() != "" {
t.Error("the controller's condition was carried")
}
if c, _ := r.open(key); c.Severity != health.Warning || c.Resolver != health.ResolverSelf || len(c.Tried) != 0 || c.Silenced != nil {
t.Errorf("the controller's escalation reached the keeper's condition: %+v", c)
}
// Carried while the controller ran: two of kinds that ended with it, still open in conditions.
for _, c := range []health.Condition{
{Key: "core.controller.deaf", Kind: "controller-deaf", Severity: health.Urgent, Resolver: "self", Count: 1},
@@ -285,51 +262,10 @@ func TestTheKeeperCarriesNoControllerConditionAndClearsWhatItCarriedOnce(t *test
}
}
// ADR 0564 clause 9 (issue 1952 C5, C8; the lead 2026-10-29): compare counts only what both sides can raise. A key held
// while its module is delivered, a kind that ends with the controller and a kind the controller never raises are listed
// apart; a kind with no home decided is a difference naming the gap; take-over is refused (issue 1126), and
// hand-back is gone (issue 774, review g1): answered as gone, naming its record, and writing nothing.
func TestCompareCountsOnlyWhatBothSidesRaise(t *testing.T) {
r := newRig()
put := func(key, kind string) []byte {
raw, _ := json.Marshal(health.Condition{Key: key, Kind: kind, Severity: health.Warning, Source: "x"})
return raw
}
key, raw := finding(t, watchman, health.ScopeMachine, "g14", "silent", "S1", health.Warning, r.now)
_ = r.k.Finding(watchman, key, raw, false)
_ = r.k.Controller(key, put(key, "silent"), false)
only, onlyRaw := finding(t, delivery.AssignerModule, health.ScopeSeat, "mesh-placement.ace", "states-unbound", "say", health.Warning, r.now)
_ = r.k.Finding(delivery.AssignerModule, only, onlyRaw, false)
_ = r.k.Controller("core.controller.deaf", put("core.controller.deaf", "controller-deaf"), false)
_ = r.k.Controller("delivery.x.mystery", put("delivery.x.mystery", "mystery"), false)
_ = r.k.Controller("bus.x.consumer-wrong", put("bus.x.consumer-wrong", "consumer-wrong"), false)
_ = r.k.Controller("seat.vault.ace.holder-silent", put("seat.vault.ace.holder-silent", "holder-silent"), false)
_ = r.k.Controller("bus.nats.upgrade-failed", put("bus.nats.upgrade-failed", "bus-upgrade-failed"), false)
cmp, err := r.k.Compare()
if err != nil {
t.Fatal(err)
}
reasons := map[string]string{}
for _, d := range append(append([]Difference(nil), cmp.Differences...), cmp.Apart...) {
reasons[d.Key] = d.Reason
}
for key, says := range map[string]string{
only: "keeper only",
"core.controller.deaf": "carried",
"delivery.x.mystery": "no home is decided for the kind mystery",
"bus.x.consumer-wrong": "mesh-self-check has not written its finding",
// A kind of a probe the operator dropped (2026-10-30) is carried, never missing.
"seat.vault.ace.holder-silent": "no longer carried (issue 1126) (dropped 2026-10-30 by the operator",
// The controller's own bus upgrade ends with its lease (the operator, 2026-10-31; issue 1952).
"bus.nats.upgrade-failed": "ends with the controller",
} {
if !strings.Contains(reasons[key], says) {
t.Errorf("%s: %q, want it to say %q", key, reasons[key], says)
}
}
if cmp.Same != 1 || len(cmp.Differences) != 2 {
t.Errorf("compare: %+v", cmp)
}
// ADR 0564 (issue 1952 C5; the lead 2026-10-29): every kind the controller raised has its home in the table, and the
// kinds that ended with it are marked carried, which EndCarried clears once (issue 1126). compare, take-over and
// hand-back went with the controller (ADR 0590): there is no other side to compare and nothing to hand back to.
func TestEveryControllerKindHasItsHome(t *testing.T) {
// The lead's table of 2026-10-29: each kind has its home, lease-split and image-missing do not end, and the 22
// that end are carried; with the 11 kinds of the probes the operator dropped on 2026-10-30, 33.
carried := 0
@@ -355,14 +291,4 @@ func TestCompareCountsOnlyWhatBothSidesRaise(t *testing.T) {
if len(Homes) != 84 || carried != 34 { // 19 of ADR 0564, the 63 of the table, plan-failed and stalled
t.Errorf("the table holds %d kinds, %d carried", len(Homes), carried)
}
if _, err := r.k.TakeOver(nil, "jochen", "now"); err == nil || !strings.Contains(err.Error(), "one source already") {
t.Errorf("take-over: %v", err)
}
if _, err := r.k.HandBack("jochen", "back"); err == nil || !strings.Contains(err.Error(), "hand-back is gone") ||
!strings.Contains(err.Error(), "ADR 0564 clause 9") {
t.Errorf("hand-back: %v, want it answered as gone naming its record", err)
}
if _, _, found, _ := r.k.Store.Get(TakeoverState, health.TakeoverKey); found {
t.Error("hand-back or take-over wrote the take-over key")
}
}
@@ -13,8 +13,6 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -90,15 +88,6 @@ func Keep(k *Keeper) {
}
}
}
if e, err := stdio.State(TakeoverState).Get(health.TakeoverKey); err == nil {
var raw []byte
if e != nil {
raw = e.Value
}
if t, err := health.DecodeTakeover(raw, e != nil); err == nil {
k.Takeover(t)
}
}
said := func(err error) error {
if err != nil {
logf("%v", err)
@@ -134,20 +123,11 @@ func Keep(k *Keeper) {
func main() {
k := NewKeeper()
client := asker.New(AskedState)
client.Module = Module
go func() {
<-stdio.Served()
Keep(k)
if err := client.Listen(k.Warranted, func(h asker.Handled, w asks.Warrant, err error) {
if err != nil {
logf("the answer to %s (%s): %v", w.Ask, h, err)
}
}); err != nil {
logf("the operator's answers cannot be heard: %v", err)
}
}()
if err := stdio.Serve("", Tools(k, client)); err != nil {
if err := stdio.Serve("", Tools(k)); err != nil {
logf("%v", err)
os.Exit(1)
}
@@ -49,11 +49,10 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
ttl[HistoryState], ttl[BoundsState], keyTTL[BoundsState])
}
// Clause 12: each state with the tool that reads it, each act with the tool that starts it.
readBy := map[string]string{ConditionsState: Seat + ".conditions", HistoryState: HistoryTool, TakeoverState: TakeoverTool,
HeldState: HeldTool, BoundsState: HeldTool, AskedState: Seat + ".take-over", "a resync": Seat + ".resync",
"what it reads": InputsTool}
readBy := map[string]string{ConditionsState: Seat + ".conditions", HistoryState: HistoryTool,
HeldState: HeldTool, BoundsState: HeldTool, "a resync": Seat + ".resync", "what it reads": InputsTool}
tools := map[string]bool{}
for _, tool := range Tools(&Keeper{}, nil) {
for _, tool := range Tools(&Keeper{}) {
tools[tool.Name] = true
}
for name := range ttl {
@@ -17,14 +17,13 @@ import (
)
// Verbs are the seat's verbs, as the manifest claims them.
var Verbs = []string{"conditions", "silence", "compare", "take-over", "hand-back", "resync"}
var Verbs = []string{"conditions", "silence", "resync"}
// The keeper's own read tools.
const (
HistoryTool = "conditions_history"
HeldTool = "conditions_held"
TakeoverTool = "conditions_takeover"
InputsTool = "conditions_inputs"
HistoryTool = "conditions_history"
HeldTool = "conditions_held"
InputsTool = "conditions_inputs"
)
// briefEvidence is how many observations a condition carries in a list: its newest (the controller's, issue 314).
@@ -50,7 +49,7 @@ func str(description string) map[string]any {
}
// Tools are what the runtime serves.
func Tools(k *Keeper, a Asks) []stdio.Tool {
func Tools(k *Keeper) []stdio.Tool {
return []stdio.Tool{
{Name: Seat + ".conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then " +
"oldest, raised from the observers' findings and cleared when the finding goes, never by hand. Given a key, that " +
@@ -70,19 +69,6 @@ func Tools(k *Keeper, a Asks) []stdio.Tool {
}
return k.Silence(strArg(args, "key"), d, stdio.CallerOf(args), strArg(args, "why"))
}},
{Name: Seat + ".compare", Description: "The keeper's open conditions against the controller's, each difference with " +
"its reason; what ends with the controller, what only the keeper raises and what is held listed apart.",
Run: func(map[string]any) (any, error) { return k.Compare() }},
{Name: Seat + ".take-over", Description: "Refused: the keeper is the one source of conditions since the " +
"controller ended by its unassignment (issue 1126); kept as the seat's verb until the seat drops it.",
Input: obj(map[string]any{"why": str("why now")}, "why"),
Run: func(args map[string]any) (any, error) {
return k.TakeOver(a, stdio.CallerOf(args), strArg(args, "why"))
}},
{Name: Seat + ".hand-back", Description: "Gone: the readers of conditions hear the keeper alone, so there is nothing " +
"to hand back; answered as gone and changes nothing (ADR 0564 clause 9).",
Input: obj(map[string]any{"why": str("why")}, "why"),
Run: func(args map[string]any) (any, error) { return k.HandBack(stdio.CallerOf(args), strArg(args, "why")) }},
{Name: Seat + ".resync", Description: "Read every finding again and raise and clear what differs.",
Run: func(map[string]any) (any, error) {
n, err := k.Resync()
@@ -99,16 +85,8 @@ func Tools(k *Keeper, a Asks) []stdio.Tool {
bounds, err := k.Store.Keys(BoundsState)
return map[string]any{"held": k.Held(), "bounds": bounds}, err
}},
{Name: TakeoverTool, Description: "The take-over key: which source the router, the grants part and mesh-watcher hear.",
Run: func(map[string]any) (any, error) {
raw, _, found, err := k.Store.Get(TakeoverState, health.TakeoverKey)
if err != nil {
return nil, err
}
return health.DecodeTakeover(raw, found)
}},
{Name: InputsTool, Description: "What the keeper reads, as it stands: each observer's findings counted, an observer it " +
"cannot read and why, the healers' attempts, the controller's conditions and whether its lease is handed away.",
"cannot read and why, the healers' attempts, the wants and the haves.",
Run: func(map[string]any) (any, error) { return k.Inputs(), nil }},
}
}
@@ -212,7 +190,6 @@ func (k *Keeper) Inputs() map[string]any {
findings[o] = len(fs)
}
return map[string]any{"findings": findings, "blind": k.blind, "attempts": len(k.attempts),
"controller-conditions": len(k.ctrl), "follow": k.takeover.Follow,
"wants": len(k.wants), "haves": len(k.haves)}
}
@@ -266,7 +266,7 @@ func TestTheKeeperWholeRaisesAndClearsOnTheBus(t *testing.T) {
for _, o := range Observers {
buckets = append(buckets, o+"_findings")
}
for _, s := range []string{ConditionsState, HistoryState, TakeoverState, HeldState, BoundsState, AskedState} {
for _, s := range []string{ConditionsState, HistoryState, HeldState, BoundsState} {
buckets = append(buckets, Module+"_"+s)
}
for _, b := range buckets {
+1 -6
View File
@@ -9,9 +9,6 @@
"serves": [
"conditions",
"silence",
"compare",
"take-over",
"hand-back",
"resync"
]
}
@@ -38,13 +35,11 @@
"name": "history",
"ttl-seconds": 7776000
},
"takeover",
"held",
{
"name": "bounds",
"key-ttl": true
},
"asked"
}
],
"own-secrets": {
"broker": "${dir:state}/broker"
+4 -9
View File
@@ -66,14 +66,9 @@ Each directory or archive this module declares says how it treats a file it did
- `mesh-config_declared`: each placed module's declarations at the trunk, with the manifest and commit they come
from, written only when they differ.
- `mesh-config_takeover`: the key `switch` names the source that holds: none (declarations from the modules' own
manifests, the list from the indexer's `predefined`, pull requests' config from the controller's `pull-declares`),
or `indexer` after the operator's take-over (pull requests' config from the indexer's `config-candidates`; the
controller's `pull-declares` refused). A switch still naming `controller` (the old hand-back) is read as `indexer` and
said gone. Each take-over ask is kept beside it.
- `config_compare` lists every difference between each open pull request's candidate at the indexer and what the
controller told by `pull-declares`; the controller's mirrors are no longer read (ADR 0565 §4, issue 774).
`config_take_over` asks the operator's Approve, refused while any difference stands and switched on the Approve only
if none stands then. `config_hand_back` is gone: it answers so and changes nothing. `config_declared` and `config_resync` read and write again what it keeps.
- Its own sources hold always (novox/hq ADR 0590): declarations from the modules' own manifests, the list from the
indexer's `predefined`, pull requests' config from the indexer's `config-candidates`. The controller's
`pull-declares`, the take-over, compare and hand-back went with the controller. `config_declared` and
`config_resync` read and write again what it keeps.
- A trusted value is set only through propose and the operator's Approve: no controller relay is trusted (issue 1126).
- Its sets, clears and warrants are recorded in the hand-act log of the seat `mesh-hand-acts` alone.
+1 -1
View File
@@ -124,7 +124,7 @@ func resolving(c *Config) {
// A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is
// every change of what a module declares where (issue 1953); the builds themselves are read, not watched.
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, IndexerPredefinedRead,
CandidatesRead, TakeoverState} {
CandidatesRead} {
// Asked again until the runtime takes it, each give-up said (mesh-sdk's Follow).
go stdio.State(read).Follow("", poke)
}
@@ -31,10 +31,10 @@ func TestEveryStateAndActHasItsTool(t *testing.T) {
via := map[string]string{
ValuesState: "config list", ResolvedState: "config list", PredefinedState: "config list",
ProposalsState: "config proposals", PullsState: "config list", KeptState: "config list", HistoryLog: "config history",
DeclaredState: DeclaredTool, TakeoverState: DeclaredTool,
PlacementRead: CompareTool, WantsRead: CompareTool, BuildsRead: CompareTool, ModulesRead: DeclaredTool,
ManifestsRead: DeclaredTool, IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool,
"a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool, "a take-over decided": TakeOverTool,
DeclaredState: DeclaredTool,
PlacementRead: "config list", WantsRead: "config list", BuildsRead: "config list", ModulesRead: DeclaredTool,
ManifestsRead: DeclaredTool, IndexerPredefinedRead: "config list", CandidatesRead: ResyncTool,
"a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool,
}
served := map[string]bool{}
for _, tool := range Tools(&Config{}) {
@@ -758,7 +758,6 @@ func (c *Config) Decide() ([]string, error) {
for _, r := range ready {
said = append(said, c.decide(r.p, r.w))
}
said = append(said, c.decideTakeovers()...)
c.recordWarrants(now)
return said, nil
}
+7 -51
View File
@@ -136,59 +136,15 @@ type PullDeclaresAnswer struct {
Unsaid []string `json:"unsaid,omitempty"`
}
// PullDeclares keeps what a pull request's head declares, from the controller alone, and judges again the values
// held for it: a value whose key is declared the same (by digest) and still passes is kept at the new head; any
// other is dropped, recorded and raised, and must be proposed again (ADR 0502 clause 6).
func (c *Config) PullDeclares(call Call) (*PullDeclaresAnswer, error) {
if call.Caller != ControllerCaller {
return nil, fmt.Errorf("pull-declares is the controller's: it reads a pull request's manifests and tells "+
"mesh-config what the head declares; %s is not the controller", call.Caller)
}
if sw, err := c.source(); err != nil {
return nil, err
} else if sw.Source == SourceIndexer {
return nil, errors.New("pull-declares is refused: since the take-over mesh-config reads a pull request's config " +
"declarations from the indexer's config-candidates (novox/hq ADR 0565 §4)")
}
if _, _, _, err := parsePull(call.Pull); err != nil {
return nil, err
}
head := strings.TrimSpace(call.Head)
if head == "" {
return nil, errors.New("name the head the declarations are read at")
}
raw, err := json.Marshal(call.Declares)
if err != nil {
return nil, fmt.Errorf("the declarations cannot be read: %v", err)
}
declares := map[string]map[string]Declaration{}
if call.Declares != nil {
if err := json.Unmarshal(raw, &declares); err != nil {
return nil, fmt.Errorf("declares is the modules' declarations, a key to a declaration for each module: %v", err)
}
}
for m, keys := range declares {
if !nodeOrModule.MatchString(m) {
return nil, fmt.Errorf("%q is not a module's name", m)
}
for k, d := range keys {
if !keyName.MatchString(k) {
return nil, fmt.Errorf("%q is not a config key", k)
}
if !d.HasDefault() {
d.Default = nil
}
keys[k] = d
}
}
pull := strings.TrimSpace(call.Pull)
c.mu.Lock()
defer c.mu.Unlock()
return c.declarePull(pull, head, declares)
// PullDeclares is gone: a pull request's config declarations are read from the indexer's config-candidates
// (takeCandidates), and the controller that told them is not placed (novox/hq ADR 0565 §4, ADR 0590).
func (c *Config) PullDeclares(Call) (*PullDeclaresAnswer, error) {
return nil, errors.New("pull-declares is gone: mesh-config reads a pull request's config declarations from the " +
"indexer's config-candidates (novox/hq ADR 0565 §4, ADR 0590)")
}
// declarePull keeps what a pull request's head declares and judges again the values held for it; from the
// controller's pull-declares, or after the take-over from the indexer's config candidates. The caller holds the lock.
// declarePull keeps what a pull request's head declares and judges again the values held for it, from the indexer's
// config candidates. The caller holds the lock.
func (c *Config) declarePull(pull, head string, declares map[string]map[string]Declaration) (*PullDeclaresAnswer, error) {
if err := c.Store.Put(PullsState, pullKey(pull), PullDecl{Pull: pull, Head: head, Declares: declares,
At: c.now()}); err != nil {
@@ -51,13 +51,21 @@ func (f *standForge) Pull(owner, repo string, n int) (bool, bool, string, error)
return st[0], st[1], f.head, nil
}
// tell keeps what a pull request's head declares as the indexer's candidate is taken (takeCandidates).
func tell(t *testing.T, c *Config, pull, head string, declares map[string]any) *PullDeclaresAnswer {
t.Helper()
a, err := c.Do(Call{Action: "pull-declares", Pull: pull, Head: head, Declares: declares, Caller: ControllerCaller})
if err != nil {
t.Fatalf("pull-declares: %v", err)
raw, _ := json.Marshal(declares)
d := map[string]map[string]Declaration{}
if err := json.Unmarshal(raw, &d); err != nil {
t.Fatal(err)
}
return a.(*PullDeclaresAnswer)
c.mu.Lock()
defer c.mu.Unlock()
a, err := c.declarePull(pull, head, d)
if err != nil {
t.Fatalf("declare: %v", err)
}
return a
}
// holdIt proposes media-gid=2000 for n8n on ace held for the pull request and has the operator approve it.
@@ -75,21 +83,15 @@ func holdIt(t *testing.T, c *Config, m *memStore, r *standIn) string {
func keptCount(m *memStore) int { k, _ := m.Keys(KeptState); return len(k) }
// 1826/K1: mesh-config takes what a pull request declares from the controller alone, and keeps it per pull request.
func TestAPullRequestsDeclarationsAreTakenFromTheControllerAlone(t *testing.T) {
// 1826/K1: mesh-config keeps what a pull request declares per pull request; pull-declares is gone (ADR 0590).
func TestAPullRequestsDeclarationsAreKeptPerPullRequest(t *testing.T) {
c, m, _, _ := aHoldingMesh(t)
for _, caller := range []string{"g14.node-tools", Unproven, ""} {
for _, caller := range []string{"g14.node-tools", Unproven, "", ControllerCaller} {
if _, err := c.Do(Call{Action: "pull-declares", Pull: thePull, Head: "abc1234", Declares: n8nPullDeclares(),
Caller: caller}); err == nil || !strings.Contains(err.Error(), "controller") {
Caller: caller}); err == nil || !strings.Contains(err.Error(), "config-candidates") {
t.Errorf("pull-declares from %q: %v", caller, err)
}
}
for _, bad := range []Call{{Pull: "mesh-catalog#434", Head: "a"}, {Pull: thePull}, {Pull: "a/b#0", Head: "a"}} {
bad.Action, bad.Caller = "pull-declares", ControllerCaller
if _, err := c.Do(bad); err == nil {
t.Errorf("pull-declares %+v was taken", bad)
}
}
a := tell(t, c, thePull, "abc1234def", n8nPullDeclares())
if len(a.Modules) != 1 || a.Modules[0] != "n8n" {
t.Errorf("answered %+v", a)
@@ -365,10 +365,6 @@ func (c *Config) Reconcile() (Resolution, error) {
c.mu.Lock()
defer c.mu.Unlock()
var r Resolution
if sw, serr := c.source(); serr == nil && sw.Gone != "" {
r.SourceGone = sw.Gone
c.logf("[mesh-config] %s", sw.Gone)
}
published, err := c.publish()
if err != nil {
c.logf("[mesh-config] the list of predefined keys is not published: %v", err)
@@ -1,7 +1,7 @@
package main
// The tools of the switch (novox/hq ADR 0565 §4, §15): compare, take-over and hand-back, served as the seat's verbs
// and as the module's own tools beside them, the read of `declared` and resync.
// The module's own tools beside the seat's verb (novox/hq ADR 0565 §15): the read of `declared` and resync. compare,
// take-over and hand-back went with the controller (ADR 0590).
import (
"strings"
@@ -11,56 +11,17 @@ import (
// The switch's tools.
const (
CompareTool = "config_compare"
TakeOverTool = "config_take_over"
HandBackTool = "config_hand_back"
DeclaredTool = "config_declared"
ResyncTool = "config_resync"
)
// SeatVerbs are the seat's verbs, as the manifest claims them.
var SeatVerbs = []string{Verb, CompareVerb, TakeOverVerb, HandBackVerb}
var SeatVerbs = []string{Verb}
func switchTools(c *Config) []stdio.Tool {
own := ownSwitchTools(c)
byName := map[string]stdio.Tool{}
for _, t := range own {
byName[t.Name] = t
}
for verb, tool := range map[string]string{CompareVerb: CompareTool, TakeOverVerb: TakeOverTool, HandBackVerb: HandBackTool} {
t := byName[tool]
t.Name = Seat + "." + verb
own = append(own, t)
}
return own
}
func switchTools(c *Config) []stdio.Tool { return ownSwitchTools(c) }
func ownSwitchTools(c *Config) []stdio.Tool {
why := map[string]any{"type": "object", "properties": map[string]any{
"why": map[string]any{"type": "string", "description": "why, in a sentence"}}, "required": []string{"why"}}
whyOf := func(a map[string]any) string { s, _ := a["why"].(string); return strings.TrimSpace(s) }
return []stdio.Tool{
{Name: CompareTool,
Description: "Every difference between the indexer's records and the controller's pull-declares (novox/hq ADR 0565 §4): " +
"each open pull request's config (the indexer's candidate against the controller's pull-declares), each " +
"with its reason. The controller's mirrors are no longer read, so nothing else is compared. The " +
"take-over is refused while any stands.",
Run: func(map[string]any) (any, error) { return c.Compare() }},
{Name: TakeOverTool,
Description: "Ask the operator's Approve, under mesh-config's own name, to switch config to its own sources: " +
"the indexer's list of predefined keys and pull request config, refusing the controller's pull-declares. " +
"Refused while compare shows a difference; on Approve switched if nothing differs then.",
Input: why,
Run: func(a map[string]any) (any, error) {
return c.TakeOver(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
}},
{Name: HandBackTool,
Description: "Gone: the controller's records end with its lease and config no longer reads them (novox/hq ADR 0565 §4, " +
"issue 774); answers that, and changes nothing.",
Input: why,
Run: func(a map[string]any) (any, error) {
return c.HandBack(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
}},
{Name: DeclaredTool,
Description: "Each placed module's declarations as config keeps them (its state declared): the manifest and " +
"commit they come from and how many keys, or one module whole; and which source holds now.",
@@ -72,7 +33,7 @@ func ownSwitchTools(c *Config) []stdio.Tool {
}},
{Name: ResyncTool,
Description: "Read every source again and write what differs: the published list of predefined keys, the " +
"resolved values, declared, and after the take-over the indexer's config candidates.",
"resolved values, declared, and the indexer's config candidates.",
Run: func(map[string]any) (any, error) { return c.Resync() }},
}
}
+15 -428
View File
@@ -1,95 +1,44 @@
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 each open pull request's candidate at the indexer and what the controller told by pull-declares;
// `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.
// Config's records out of the controller (novox/hq ADR 0565 §3, §15, and the progressive insight of 2026-10-29):
// mesh-config keeps each placed module's declarations in its own state `declared`, reads the list of predefined keys
// from the indexer's `predefined` and a pull request's config from the indexer's `config-candidates`.
//
// **The controller's records are no longer read** (issue 774, ADR 0571 §13): its mirrors `declared-config` and
// `predefined` are gone from the reads, so declarations come from each placed module's own manifests and the list of
// predefined keys from the indexer's `predefined`, whatever the switch says. `hand-back` is answered as gone (ADR 0565
// §4: it ends with the lease), and a switch that still names the controller is read as the indexer, said so.
//
// **Which source holds** is the key `switch` alone, read by mesh-config alone:
// - none written: a pull request's declarations from the controller's pull-declares;
// - `indexer` (taken over): a pull request's from the indexer's `config-candidates`; pull-declares refused;
// - `controller` (the old hand-back): gone, read as `indexer`.
// **Its own sources hold always** (ADR 0590, issue 1126): the controller ended by its unassignment, so its pull-declares,
// the take-over that switched away from them, compare and hand-back are gone, and the switch is the indexer's whatever
// a key once said.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The states and reads of the switch.
// The states and reads.
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"
// IndexerPredefinedRead and CandidatesRead are the indexer's states (ADR 0565 §2).
IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState
CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState
)
// The sources the switch names.
const (
SourceInterim = "interim"
SourceIndexer = "indexer"
// SourceController is a switch the old hand-back wrote; the controller's records are gone, so it is read as the indexer.
SourceController = "controller"
)
// SourceIndexer is the one source of a pull request's config and of the predefined list: the indexer's.
const SourceIndexer = "indexer"
// 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.
// Switch is which source holds: the indexer's, always, since the controller ended (ADR 0590).
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"`
// Gone says the stored switch named the controller, whose records config no longer reads (ADR 0565 §4); the switch
// returned is then the indexer's. Never stored.
Gone string `json:"-"`
Source string `json:"source"`
Why string `json:"why"`
}
// goneSource is what is said of a switch that still names the controller.
const goneSource = "the switch names the controller, which is gone: config no longer reads its records (novox/hq ADR 0565 §4, " +
"issue 774) and reads the indexer's"
// source is the source that holds now.
// source is the source that holds: the indexer's.
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)
}
if s.Source == SourceController {
s.Source, s.Gone = SourceIndexer, goneSource
}
return s, nil
return Switch{Source: SourceIndexer, Why: "config reads its own sources since the controller ended by its unassignment (novox/hq ADR 0590)"}, nil
}
// ---- declared --------------------------------------------------------------------------------------------
@@ -254,368 +203,6 @@ func declaresDigest(d map[string]map[string]Declaration) string {
return string(raw)
}
// ---- compare ----------------------------------------------------------------------------------------------
// Difference is one thing the indexer's records and the controller's pull-declares 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"`
Why string `json:"why"`
}
// The kinds of difference.
const (
DiffPull = "pull"
)
// Comparison is what compare answers.
type Comparison struct {
Source string `json:"source"`
Differences []Difference `json:"differences"`
Count int `json:"count"`
Said string `json:"said"`
// Gone is said when the stored switch still names the controller.
Gone string `json:"gone,omitempty"`
}
// 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. The controller's mirrors are no longer read (issue 774, ADR 0565 §4),
// so the declarations, the list and the resolved values have no second side: what remains to compare is each open pull
// request's candidate at the indexer against what the controller told by pull-declares.
func (c *Config) compare() (*Comparison, error) {
sw, err := c.source()
if err != nil {
return nil, err
}
var own Sources
if err := c.readPulls(&own); err != nil {
return nil, fmt.Errorf("mesh-config's own pull request records cannot be read: %w", err)
}
var diffs []Difference
// 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()})
}
settled := map[string]string{} // a pull request the forge says is no longer open → how it ended
for _, k := range unionKeys(cands, own.Pulls) {
a, aok := cands[k]
b, bok := own.Pulls[k]
isSettled, forgeNote := c.pullSettled(firstNonEmpty(a.Pull, b.Pull), settled)
if isSettled {
continue // a merged or closed pull request is no candidate to agree on (the operator, 2026-11-01)
}
before := len(diffs)
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"})
}
if forgeNote != "" && len(diffs) > before {
diffs[len(diffs)-1].Why += "; " + forgeNote // the row stays because the forge was not heard: say so in the row
}
}
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), Gone: sw.Gone}
if len(diffs) == 0 {
cmp.Said = "nothing differs: the take-over may be asked (the controller's mirrors are no longer read, so only the pull requests are compared)"
} else {
cmp.Said = fmt.Sprintf("%d difference(s): the take-over is refused while any stands", len(diffs))
}
return cmp, nil
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// pullSettled is whether the forge says a pull request merged or closed. Without a forge, or when it does not say, the
// pull request counts as open, so its row stays (failure is loud).
func (c *Config) pullSettled(pull string, memo map[string]string) (bool, string) {
if c.Forge == nil || pull == "" {
return false, ""
}
if v, ok := memo[pull]; ok {
return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v)
}
owner, repo, n, err := parsePull(pull)
if err != nil {
return false, ""
}
open, merged, _, err := c.Forge.Pull(owner, repo, n)
switch {
case err != nil:
c.logf("[mesh-config] compare: the forge does not say whether %s is open (%v); its row stays", pull, err)
memo[pull] = forgeSilent + err.Error()
case open:
memo[pull] = ""
case merged:
memo[pull] = "merged"
default:
memo[pull] = "closed"
}
v := memo[pull]
return v != "" && !strings.HasPrefix(v, forgeSilent), forgeNoteOf(v)
}
// forgeSilent marks a memo entry for a pull request the forge could not be asked about; the rest of the entry is why.
const forgeSilent = "forge-silent: "
// forgeNoteOf is the words a row carries when the forge was not heard on its pull request, or "".
func forgeNoteOf(v string) string {
if why, ok := strings.CutPrefix(v, forgeSilent); ok {
return "the forge does not say whether the pull request is open (" + why + ")"
}
return ""
}
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 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; a pull request's config keeps coming from the controller's pull-declares", About: "config.takeover",
Details: "Read what was compared with mesh-config's compare verb, through the mesh MCP server. hand-back is gone."}
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 is answered as gone: the controller's records end with its lease, and config no longer reads them (novox/hq
// ADR 0565 §4, issue 774). Nothing is written.
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")
}
return nil, errors.New("hand-back is gone: the controller's records end with its lease and config no longer reads " +
"them, so there is nothing to hand back to (novox/hq ADR 0565 §4); nothing was changed")
}
// Declared is the read of `declared`: every module's key, or one whole.
func (c *Config) Declared(module string) (any, error) {
if module != "" {
@@ -641,7 +228,7 @@ func (c *Config) Declared(module string) (any, error) {
}
// 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).
// the indexer's config candidates (ADR 0565 §15).
func (c *Config) Resync() (any, error) {
r, err := c.Reconcile()
if err != nil {
@@ -2,11 +2,8 @@ package main
import (
"encoding/json"
"errors"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -18,7 +15,7 @@ const (
oldDeclared = `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`
)
// anAgreeingMesh is aProposingMesh with the indexer's predefined list in place and no open pull request: compare is empty.
// anAgreeingMesh is aProposingMesh with the indexer's predefined list in place and no open pull request.
func anAgreeingMesh(t *testing.T) (*Config, *memStore, *standIn) {
t.Helper()
c, m, r := aProposingMesh(t)
@@ -47,80 +44,11 @@ func candidate(t *testing.T, m *memStore, pull, head, declares string) {
m.mirror(t, CandidatesRead, key, `{"v":1,"pull":"`+pull+`","head":"`+head+`","declares":`+declares+`}`)
}
func kinds(cmp *Comparison) []string {
var out []string
for _, d := range cmp.Differences {
k := d.What + ":" + d.Module + d.Pull + d.Key
if d.Node != "" {
k += "@" + d.Node
}
if !slices.Contains(out, k) {
out = append(out, k)
}
}
return out
}
// compare lists every difference with its reason — a pull request's config at the indexer against what the controller
// told — and the take-over is refused while one stands (ADR 0565 §4 and its insight of 2026-10-29; issue 1953 L5-C4).
// The controller's mirrors are no longer read, so no declaration, list or value is compared (issue 774, S9).
func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
cases := []struct {
name string
change func(t *testing.T, m *memStore)
want []string
}{
{"all agree", func(*testing.T, *memStore) {}, nil},
{"a pull request only the indexer holds", func(t *testing.T, m *memStore) {
candidate(t, m, "novox/mesh-catalog#9", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#9"}},
{"a candidate of a merged pull request is no difference", func(t *testing.T, m *memStore) {
candidate(t, m, "novox/mesh-catalog#11", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
candidate(t, m, "novox/mesh-catalog#10", "h2", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#10"}},
{"a forge that cannot be asked keeps the row and says why in it", func(t *testing.T, m *memStore) {
candidate(t, m, "novox/mesh-catalog#12", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#12"}},
}
for _, tc := range cases {
c, m, r := anAgreeingMesh(t)
c.Forge = &standForge{state: map[string][2]bool{"novox/mesh-catalog#11": {false, true}}}
if strings.Contains(tc.name, "forge that cannot be asked") {
c.Forge = &standForge{err: errors.New("forge down")}
}
tc.change(t, m)
cmp, err := c.Compare()
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got := kinds(cmp); !slices.Equal(got, tc.want) {
t.Errorf("%s: compare lists %v, want %v (%+v)", tc.name, got, tc.want, cmp.Differences)
}
for _, d := range cmp.Differences {
if d.Why == "" {
t.Errorf("%s: a difference without its reason: %+v", tc.name, d)
}
if strings.Contains(tc.name, "forge that cannot be asked") && !strings.Contains(d.Why, "forge down") {
t.Errorf("%s: the row does not say the forge was not heard: %q", tc.name, d.Why)
}
}
a, err := c.TakeOver(Call{Why: "compare is empty", Caller: "person.jochen"})
if len(tc.want) > 0 {
if err == nil || !strings.Contains(err.Error(), "refused") || len(r.subjects) != 0 {
t.Errorf("%s: the take-over was asked while compare differs: %v", tc.name, err)
}
continue
}
if err != nil || a.Ask == "" || len(r.subjects) != 1 {
t.Errorf("%s: the take-over was not asked: %v", tc.name, err)
}
}
}
// The operator's Approve switches config to the indexer: pull-declares is refused, the list and the pull requests'
// config come from the indexer; hand-back is gone, and a switch naming the controller is read as the indexer; declared is written once per manifest (ADR 0565 §3, §4, §13).
func TestTheTakeOverAndTheHandBack(t *testing.T) {
c, m, r := anAgreeingMesh(t)
// ADR 0590 (issue 1126): config reads its own sources always, with no take-over: declared is written once per
// manifest, pull-declares is refused naming the indexer's candidates, and the list and a pull request's config come from
// the indexer. compare, take-over and hand-back went with the controller.
func TestConfigReadsItsOwnSourcesAlways(t *testing.T) {
c, m, _ := anAgreeingMesh(t)
steps := []struct {
name string
do func() (string, error)
@@ -132,22 +60,7 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
again, _ := c.Reconcile()
return strings.Join(first.Declared, ",") + "|" + strings.Join(again.Declared, ","), err
}, "dunst|"},
{"pull-declares taken before the take-over", func() (string, error) {
a, err := c.PullDeclares(Call{Caller: ControllerCaller, Pull: "novox/mesh-catalog#7", Head: "h0",
Declares: map[string]any{}})
return answered(a), err
}, "keeps what"},
{"the take-over is asked", func() (string, error) {
_ = m.Delete(PullsState, "novox.mesh-catalog.7") // compare holds the told pull request against no candidate
a, err := c.TakeOver(Call{Why: "the release is delivered", Caller: "person.jochen"})
return answered(a), err
}, "is asked"},
{"the operator approves: switched to the indexer", func() (string, error) {
r.choose(t, r.lastAsk(t), OptionApprove, nil)
out, err := c.Decide()
return strings.Join(out, "; "), err
}, "reads its own sources from now"},
{"pull-declares refused after it, naming the indexer", func() (string, error) {
{"pull-declares refused, naming the indexer", func() (string, error) {
_, err := c.PullDeclares(Call{Caller: ControllerCaller, Pull: "novox/mesh-catalog#8", Head: "h1",
Declares: map[string]any{}})
return "", err
@@ -164,15 +77,6 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
res, err := c.Reconcile()
return strings.Join(res.Candidates, "; "), err
}, "from the indexer"},
{"hand-back is gone, naming the record (774/S9/AC1)", func() (string, error) {
_, err := c.HandBack(Call{Why: "a difference was found after the switch", Caller: "person.jochen"})
return "", err
}, "ADR 0565 §4"},
{"a switch naming the controller is read as gone, and the indexer holds (774/S9/AC2)", func() (string, error) {
m.mirror(t, TakeoverState, SwitchKey, `{"source":"controller","by":"person.jochen","why":"old hand-back"}`)
res, err := c.Reconcile()
return res.SourceGone + "|" + string(m.resolved(t, "g14", "dunst", "width").Value), err
}, "is gone: config no longer reads its records"},
}
for _, s := range steps {
got, err := s.do()
@@ -184,44 +88,7 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
t.Errorf("%s: said %q, want %q", s.name, text, s.want)
}
}
if len(m.handActs) != 0 {
t.Errorf("a hand-back that is gone recorded an act: %v", m.handActs)
}
if e, _ := m.Get(DeclaredState, "dunst"); e == nil || !strings.Contains(string(e.Value), `"manifest":"`) || !strings.Contains(string(e.Value), `"commit":"c1"`) {
t.Errorf("declared holds no dunst with its manifest: %v", e)
}
// An Approve that finds compare differing at the answer switches nothing.
c2, m2, r2 := anAgreeingMesh(t)
if _, err := c2.TakeOver(Call{Why: "now", Caller: "person.jochen"}); err != nil {
t.Fatal(err)
}
candidate(t, m2, "novox/mesh-catalog#9", "h9", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
r2.choose(t, r2.lastAsk(t), OptionApprove, nil)
out, _ := c2.Decide()
if sw, _ := c2.source(); sw.Source != SourceInterim || !strings.Contains(strings.Join(out, ""), "difference") {
t.Errorf("an Approve while compare differs switched: %s, %v", sw.Source, out)
if sw, _ := c.source(); sw.Source != SourceIndexer {
t.Errorf("the source is %q", sw.Source)
}
}
// lastAsk is the newest ask the router took.
func (r *standIn) lastAsk(t *testing.T) string {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
var newest string
var at time.Time
for id, a := range r.asks {
if newest == "" || a.Expires.After(at) {
newest, at = id, a.Expires
}
}
if newest == "" {
t.Fatal("the router took no ask")
}
return newest
}
func answered(a any) string {
raw, _ := json.Marshal(a)
return string(raw)
}
+2 -9
View File
@@ -7,10 +7,7 @@
"name": "mesh-config",
"scope": "mesh",
"serves": [
"config",
"compare",
"take-over",
"hand-back"
"config"
]
}
],
@@ -40,9 +37,6 @@
"mesh-delivery-indexer.config-candidates"
],
"tools": [
"config_compare",
"config_take_over",
"config_hand_back",
"config_declared",
"config_resync"
],
@@ -53,8 +47,7 @@
"proposals",
"pulls",
"kept",
"declared",
"takeover"
"declared"
],
"logs": [
"history"
@@ -30,11 +30,10 @@ import (
// The seat, the log, the states and the event, as mesh-sdk's handacts names them.
const (
Module = handacts.Module
Seat = handacts.Seat
ActsLog = handacts.ActsLog
WarrantsState = handacts.WarrantsState
ControllerActs = handacts.ControllerActs
Module = handacts.Module
Seat = handacts.Seat
ActsLog = handacts.ActsLog
WarrantsState = handacts.WarrantsState
// RouterRecords is the router's record of every ask, `<asker>.<ask>` (ADR 0259): the warrant is read there.
RouterRecords = "messenger.asks"
RecordedEvent = handacts.EventRecorded
@@ -248,8 +248,8 @@ func TestHandActsReadsItsOwnLogAlone(t *testing.T) {
put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", time.Hour)
put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", 2*time.Hour)
put(ActsLog, VerbRecord, "config", "ace.mesh-config", 30*24*time.Hour)
put(ControllerActs, VerbRecord, "never-read", "ace.old", time.Hour)
store.keysErr[ControllerActs] = errors.New("the controller's bucket must not be read")
put(controllerActs, VerbRecord, "never-read", "ace.old", time.Hour)
store.keysErr[controllerActs] = errors.New("the controller's bucket must not be read")
cases := []struct {
name string
days int
@@ -291,12 +291,12 @@ func TestHandActsReadsItsOwnLogAlone(t *testing.T) {
// read and write nothing of the controller's bucket nor of the log (774/S10; research 072 row 26).
func TestTheCopyOfTheControllersHandActsIsOver(t *testing.T) {
l, store, _, now := newLog()
store.keysErr[ControllerActs] = errors.New("the controller's bucket must not be read")
store.keysErr[controllerActs] = errors.New("the controller's bucket must not be read")
st, err := l.Copy("test", true)
if err != nil || st.Done || !strings.Contains(st.Said, "no longer read") {
t.Errorf("without a marker: %+v, %v", st, err)
}
_ = store.Put(CopiedState, CopyKey, CopyMarker{At: *now, By: "start", Source: ControllerActs, Found: 606, Copied: 606})
_ = store.Put(CopiedState, CopyKey, CopyMarker{At: *now, By: "start", Source: controllerActs, Found: 606, Copied: 606})
st, err = l.Copy("test", false)
if err != nil || !st.Done || st.Marker.Copied != 606 || !strings.Contains(st.Said, "no longer read") || len(store.logKeys(ActsLog)) != 0 {
t.Errorf("with a marker: %+v, %v", st, err)
@@ -305,3 +305,6 @@ func TestTheCopyOfTheControllersHandActsIsOver(t *testing.T) {
t.Errorf("copied says %+v, %v", s, err)
}
}
// controllerActs is the controller's hand-act log as it was named: a test reads it never.
const controllerActs = "mesh-controller.hand-acts"