mesh-config stops reading the controller's predefined list and declared-config mirror (ADR 0565 §4)

Declarations come from the modules' own manifests and the list of predefined keys from the indexer's, whatever the
switch says; compare keeps only the pull request rows; hand-back is answered as gone; a switch that still names the
controller is read as the indexer and said gone. The read-only mirrors, mirrorDeclarations/mirrorList and the value
and list comparisons are deleted with their test cases (two table tests lose their mirror rows, one block of the
newest-list test goes; two table rows added).

(issue 774, 774/S9)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 00:15:43 +01:00
parent c1a32b4385
commit 2f52f91b5c
11 changed files with 106 additions and 334 deletions
+11 -12
View File
@@ -12,8 +12,8 @@ account alone writes its buckets.
- `mesh-config_resolved`: each assigned module's value for each key it declares on its node, keyed
`<node>.<module>.<key>`, with its source: `default`, `mesh`, `node`, `module`, or `unset` for a required value
nothing sets.
- `mesh-config_predefined`: the list of predefined keys, published from the list the controller kept (its mirror
`mesh-controller.predefined`), the newest trunk build winning. A newer list dropping a key that still holds a
- `mesh-config_predefined`: the list of predefined keys, published from the indexer's list (the controller's mirror is no longer read, issue 774), the newest trunk
build winning. A newer list dropping a key that still holds a
value at the mesh or a node is withheld, and every listing says why, until that value is cleared.
- `mesh-config_proposals`: each value proposed for the operator's Approve, whole, with the value stored at its level
when it was proposed (what the operator is shown the change against), who proposed it, why, and what came of it.
@@ -66,16 +66,15 @@ 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 (the interim since mesh-catalog #558:
declarations from the modules' own manifests, the list of predefined keys and pull requests' config from the
controller), `indexer` after the operator's take-over (the list and pull requests' config from the indexer's
`predefined` and `config-candidates`; the controller's `pull-declares` refused), `controller` after a hand-back
(declarations, list and pull requests' config from the controller's records). Each take-over ask is kept beside it.
- `config_compare` lists every difference between the own sources and the controller's records: each module's
declarations, the list, each open pull request's config, and every placed module's value on every node resolved
both ways. `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` returns to the controller's records until its lease is handed
away. `config_declared` and `config_resync` read and write again what it keeps.
- `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.
- From the controller's lease write a trusted value is set only through propose and the operator's Approve.
- Its sets, clears and warrants are recorded in the hand-act log of the seat `mesh-hand-acts`, falling back to the
controller's while that seat has no holder and the controller holds its lease.
@@ -244,10 +244,9 @@ const dunstDeclared = `{"module":"dunst","settings":{
"position":{"kind":"","default":"top-right","why":"","type":"string","description":"where notifications show","enum":["top-right","top-left"]},
"origin":{"kind":"","default":null,"why":"","type":"string","description":"the operator's own origin","required":true,"pattern":"[a-z]+"}}}`
// theList is the controller's predefined mirror, as it writes it.
const theList = `{"keys":{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}},
"repository":"mesh-catalog","commit":"aaaaaaaaaaaa","generation":5,"build":"b5"}`
// theList is the indexer's list of predefined keys, as predefined.json holds it.
const theList = `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`
func aMesh(t *testing.T) (*Config, *memStore) {
t.Helper()
@@ -256,7 +255,7 @@ func aMesh(t *testing.T) (*Config, *memStore) {
m.declare(t, "claude-code", `{"module":"claude-code","settings":{
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
indexerList(t, m, "aaaaaaaaaaaa", theList)
for _, node := range []string{"g14", "novox"} {
m.assign(t, node, "dunst")
}
@@ -507,7 +506,7 @@ func TestAChangeTheHandActLogRefusesIsSaid(t *testing.T) {
expect(t, m, "g14", "12", SourceMesh)
}
// **The newest list of predefined keys is published, and an older one heard later is not** (ADR 0348 §7).
// **The newest list of predefined keys is published** (ADR 0348 §7; the indexer never keeps an older merge's).
func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
c, m := aMesh(t)
published := func() map[string]Published {
@@ -521,24 +520,17 @@ func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
}
return out
}
if p := published(); len(p) != 2 || p["font-size"].Generation != 5 || !p["agent-managed-settings"].Trusted {
if p := published(); len(p) != 2 || p["font-size"].Commit != "aaaaaaaaaaaa" || !p["agent-managed-settings"].Trusted {
t.Fatalf("published %+v", p)
}
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"font-size":{"type":"number","description":"x"},
"theme":{"type":"string","description":"the theme"}},"commit":"bbbb","generation":7}`)
indexerList(t, m, "bbbb", `{"font-size":{"type":"number","description":"x"},
"theme":{"type":"string","description":"the theme"}}`)
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
if p := published(); len(p) != 2 || p["theme"].Generation != 7 || p["agent-managed-settings"].Type != "" {
if p := published(); len(p) != 2 || p["theme"].Commit != "bbbb" || p["agent-managed-settings"].Type != "" {
t.Fatalf("a newer list published %+v", p)
}
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) // generation 5, heard later
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
if p := published(); p["theme"].Generation != 7 {
t.Fatalf("an older list replaced the newer one: %+v", p)
}
}
// **A list dropping a key that still holds a value is withheld** (ADR 0348 §7): the list published stands, and the
@@ -546,8 +538,7 @@ func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T) {
c, m := aMesh(t)
set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"})
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"agent-managed-settings":{"type":"object",
"description":"managed settings","trusted":true}},"commit":"cccccccccc","generation":9}`)
indexerList(t, m, "cccccccccc", `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true}}`)
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
@@ -129,7 +129,7 @@ type PredefinedKey struct {
Trusted bool `json:"trusted,omitempty"`
}
// PredefinedList is the list the controller kept, as its predefined mirror holds it under PredefinedMirrorKey.
// PredefinedList is the list of predefined keys with the commit it came from, as the indexer keeps it.
type PredefinedList struct {
Keys map[string]PredefinedKey `json:"keys"`
Repository string `json:"repository"`
@@ -138,9 +138,6 @@ type PredefinedList struct {
Build string `json:"build"`
}
// PredefinedMirrorKey is the controller's predefined mirror's one key.
const PredefinedMirrorKey = "list"
// Published is one key of mesh-config_predefined: the key's declaration, and the build of the list it came from.
type Published struct {
PredefinedKey
+3 -3
View File
@@ -6,7 +6,7 @@
// asked under this module's own name, design 57 K4).
// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own
// manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the
// list of predefined keys still comes from the controller's mirror until the indexer keeps it (ADR 0565 §2). stdout is the MCP channel; what this module says, it says on stderr.
// list of predefined keys comes from the indexer's (ADR 0565 §2); the controller's records are no longer read (issue 774). stdout is the MCP channel; what this module says, it says on stderr.
package main
import (
@@ -132,8 +132,8 @@ 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, PredefinedRead, IndexerPredefinedRead,
CandidatesRead, MirrorDeclaredRead, TakeoverState} {
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, IndexerPredefinedRead,
CandidatesRead, TakeoverState} {
// Asked again until the runtime takes it, each give-up said (mesh-sdk's Follow).
go stdio.State(read).Follow("", poke)
}
@@ -33,8 +33,7 @@ func TestEveryStateAndActHasItsTool(t *testing.T) {
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, PredefinedRead: CompareTool, MirrorDeclaredRead: CompareTool,
IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool, LeaseRead: HandBackTool,
ManifestsRead: DeclaredTool, IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool, LeaseRead: HandBackTool,
"a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool, "a take-over decided": TakeOverTool,
}
served := map[string]bool{}
@@ -34,9 +34,6 @@ const (
// repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16).
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
// PredefinedRead is the controller's list of predefined keys, which ADR 0565 §2 moves to the indexer's
// `predefined`: read here until that state exists.
PredefinedRead = "mesh-controller.predefined"
// HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
HistoryLog = "history"
+11 -25
View File
@@ -14,17 +14,8 @@ import (
// sources reads everything a resolution reads, from the bus as it is now, from the source the switch names
// (takeover.go): which modules are placed where from the seat mesh-placement, and each placed module's declarations
// from its own manifests as built (issue 1953, ADR 0565 §3), or after a hand-back from the controller's mirror.
// from its own manifests as built (issue 1953, ADR 0565 §3); the controller's records are no longer read.
func (c *Config) sources() (Sources, error) {
sw, err := c.source()
if err != nil {
return Sources{}, err
}
return c.sourcesFrom(sw.Source)
}
// sourcesFrom reads the sources with declarations from one source: the own (interim and indexer) or the mirror.
func (c *Config) sourcesFrom(source string) (Sources, error) {
s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{},
Predefined: map[string]Published{}, Values: map[string]Value{}}
var problems []string
@@ -34,9 +25,6 @@ func (c *Config) sourcesFrom(source string) (Sources, error) {
if err := c.readDeclarations(&s); err != nil {
return s, fmt.Errorf("the modules' declarations cannot be read: %w", err)
}
if source == SourceController {
c.mirrorDeclarations(&s)
}
for _, u := range s.Unresolved {
problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why))
}
@@ -366,6 +354,8 @@ type Resolution struct {
// did after the take-over.
Declared []string `json:"declared,omitempty"`
Candidates []string `json:"candidates,omitempty"`
// SourceGone is said while the stored switch still names the controller, whose records are no longer read.
SourceGone string `json:"source_gone,omitempty"`
}
// Reconcile publishes the newest list of predefined keys and brings mesh-config_resolved to what the sources
@@ -375,6 +365,10 @@ 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)
@@ -401,24 +395,16 @@ func (c *Config) Reconcile() (Resolution, error) {
return r, err
}
// publish makes mesh-config_predefined hold the list its source keeps — the indexer's after the take-over, the
// controller's mirror otherwise — when it is newer than the one published (ADR 0348 §7: the newest trunk build wins;
// publish makes mesh-config_predefined hold the indexer's list (the controller's mirror is no longer read, issue 774)
// when it is newer than the one published (ADR 0348 §7: the newest trunk build wins;
// the indexer never keeps an older merge's, so its list is newer when its commit differs). **A list dropping a key that still holds a value is withheld**: the
// list published stays, and why is said in every listing and in the log, because a value for a key no longer on
// the list could neither be resolved nor cleared by name (§7: removing a key still holding a value is refused).
// It answers what it published, empty when nothing changed.
func (c *Config) publish() (string, error) {
sw, err := c.source()
list, found, err := c.indexerList()
if err != nil {
return "", err
}
read, whose := c.mirrorList, "the controller's list"
if sw.Source == SourceIndexer {
read, whose = c.indexerList, "the indexer's list"
}
list, found, err := read()
if err != nil {
return "", fmt.Errorf("%s cannot be read: %w", whose, err)
return "", fmt.Errorf("the indexer's list cannot be read: %w", err)
}
if !found {
return "", nil // no list kept yet
@@ -41,11 +41,10 @@ func ownSwitchTools(c *Config) []stdio.Tool {
whyOf := func(a map[string]any) string { s, _ := a["why"].(string); return strings.TrimSpace(s) }
return []stdio.Tool{
{Name: CompareTool,
Description: "Every difference between config's own sources and the controller's records (novox/hq ADR 0565 §4): " +
"each placed module's declarations (declared against the mirror declared-config), the list of predefined " +
"keys (the indexer's against the mirror's), each open pull request's config (the indexer's candidate " +
"against the controller's pull-declares), and every placed module's value on every node resolved both " +
"ways; each with its reason. The take-over is refused while any stands.",
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: " +
@@ -56,8 +55,8 @@ func ownSwitchTools(c *Config) []stdio.Tool {
return c.TakeOver(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
}},
{Name: HandBackTool,
Description: "Return config to the controller's records (its mirrors declared-config and predefined, and " +
"pull-declares) until the controller hands its lease away; gone after it. Recorded as a hand act.",
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)})
+42 -216
View File
@@ -2,19 +2,19 @@ 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;
// 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; `hand-back` returns to the mirrors until the lease write.
// key `takeover.switch` naming the indexer.
//
// **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 (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.
// - 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`.
import (
"bytes"
@@ -38,9 +38,6 @@ const (
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
@@ -50,8 +47,9 @@ const (
// The sources the switch names.
const (
SourceInterim = "interim"
SourceIndexer = "indexer"
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"
)
@@ -69,8 +67,15 @@ type Switch struct {
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:"-"`
}
// 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.
func (c *Config) source() (Switch, error) {
e, err := c.Store.Get(TakeoverState, SwitchKey)
@@ -84,6 +89,9 @@ func (c *Config) source() (Switch, error) {
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
}
@@ -172,31 +180,6 @@ func (c *Config) writeDeclared(s Sources) ([]string, error) {
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.
@@ -216,19 +199,6 @@ func (c *Config) indexerList() (PredefinedList, bool, error) {
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{}
@@ -300,7 +270,7 @@ func declaresDigest(d map[string]map[string]Declaration) string {
// ---- compare ----------------------------------------------------------------------------------------------
// Difference is one thing the own sources and the controller's mirrors say differently, and why.
// 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"`
@@ -308,16 +278,12 @@ type Difference struct {
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"
DiffPull = "pull"
)
// Comparison is what compare answers.
@@ -326,6 +292,8 @@ type Comparison struct {
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.
@@ -335,93 +303,20 @@ func (c *Config) Compare() (*Comparison, error) {
return c.compare()
}
// compare is Compare; the caller holds the lock.
// 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
}
own, err := c.sourcesFrom(SourceInterim)
if err != nil {
return nil, fmt.Errorf("mesh-config's own sources cannot be read: %w", 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)
}
mirror := own
mirror.Declared, mirror.OnNode, mirror.Unresolved = nil, nil, nil
c.mirrorDeclarations(&mirror)
var diffs []Difference
// A placed module no catalog manifest names (mesh-wireguard, which the mesh ships computed) whose mirror key holds
// no config key declares nothing on both sides: nothing resolves from it, so nothing differs (issue 1953, L5d).
declaresNothing := map[string]bool{}
for module, d := range mirror.Declared {
if len(d.Config) == 0 {
// Only an indexer that was read and holds no modules.<module>: a failed read says nothing, and the row stays.
if key, kerr := delivery.ModulesKey(module); kerr == nil {
if e, err := c.Store.Get(ModulesRead, key); err == nil && e == nil {
declaresNothing[module] = true
}
}
}
}
// 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 && declaresNothing[module]:
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 {
@@ -452,39 +347,6 @@ func (c *Config) compare() (*Comparison, error) {
}
}
// 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 {
if declaresNothing[u.Module] {
continue
}
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
@@ -492,9 +354,9 @@ func (c *Config) compare() (*Comparison, error) {
if diffs == nil {
diffs = []Difference{}
}
cmp := &Comparison{Source: sw.Source, Differences: diffs, Count: len(diffs)}
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"
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))
}
@@ -548,20 +410,6 @@ func forgeNoteOf(v string) string {
return ""
}
// 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 {
@@ -602,13 +450,12 @@ 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 " +
"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; 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."}
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"},
@@ -770,7 +617,8 @@ func approvedTakeover(w asks.Warrant) string {
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.
// 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")
@@ -778,30 +626,8 @@ func (c *Config) HandBack(call Call) (*TakeoverAnswer, error) {
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
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.
@@ -11,21 +11,17 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The trunk's manifests aMesh declares, as the controller's mirror declared-config holds the same declarations.
// The trunk's manifests aMesh declares.
const (
claudeDeclared = `{"module":"claude-code","settings":{
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`
oldDeclared = `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`
)
// anAgreeingMesh is aProposingMesh whose controller mirrors say what mesh-config's own sources say: the mirror's
// declarations of every placed module, and the indexer's predefined list equal to the mirror's.
// anAgreeingMesh is aProposingMesh with the indexer's predefined list in place and no open pull request: compare is empty.
func anAgreeingMesh(t *testing.T) (*Config, *memStore, *standIn) {
t.Helper()
c, m, r := aProposingMesh(t)
m.mirror(t, MirrorDeclaredRead, "dunst", dunstDeclared)
m.mirror(t, MirrorDeclaredRead, "claude-code", claudeDeclared)
m.mirror(t, MirrorDeclaredRead, "old", oldDeclared)
indexerList(t, m, "aaaaaaaaaaaa", `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`)
if _, err := c.Reconcile(); err != nil {
@@ -65,9 +61,9 @@ func kinds(cmp *Comparison) []string {
return out
}
// compare lists every difference with its reason — a module's declarations, the predefined list, a pull request's
// config, and each value a node resolves both ways — and the take-over is refused while one stands (ADR 0565 §4 and
// its insight of 2026-10-29; issue 1953 L5-C4).
// 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
@@ -75,17 +71,6 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
want []string
}{
{"all agree", func(*testing.T, *memStore) {}, nil},
{"the mirror declares a default otherwise", func(t *testing.T, m *memStore) {
m.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
}, []string{"declared:dunst", "value:dunstwidth@g14", "value:dunstwidth@novox"}},
{"the mirror lacks a placed module", func(t *testing.T, m *memStore) { _ = m.Delete(MirrorDeclaredRead, "old") },
[]string{"declared:old", "value:old@g14", "value:oldworkers@g14"}},
{"a node's build adds a key the mirror lacks", func(t *testing.T, m *memStore) {
m.want(t, "g14", "dunst", "src2:"+strings.Repeat("ab", 32), strings.Replace(dunstDeclared, `"width"`, `"height":{"type":"number","default":5},"width"`, 1))
}, []string{"value:dunstheight@g14", "value:dunstheight@novox"}}, // a key only a build declares resolves on every node (#558)
{"the lists differ", func(t *testing.T, m *memStore) {
indexerList(t, m, "bbbbbbbbbbbb", `{"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`)
}, []string{"predefined:agent-managed-settings"}},
{"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"}},
@@ -96,10 +81,6 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
{"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"}},
{"a module no manifest names and the mirror declares nothing of", func(t *testing.T, m *memStore) {
m.assign(t, "g14", "mesh-wireguard")
m.mirror(t, MirrorDeclaredRead, "mesh-wireguard", `{"module":"mesh-wireguard","settings":{}}`)
}, nil},
}
for _, tc := range cases {
c, m, r := anAgreeingMesh(t)
@@ -137,7 +118,7 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
}
// 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 returns to the mirrors until the lease write and is gone after it; from the
// config come from the indexer; hand-back is gone, and a switch naming the controller is read as the indexer; from the
// lease write a trusted value is set only on an Approve; declared is written once per manifest (ADR 0565 §3, §4, §13).
func TestTheTakeOverAndTheHandBack(t *testing.T) {
c, m, r := anAgreeingMesh(t)
@@ -184,20 +165,19 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
res, err := c.Reconcile()
return strings.Join(res.Candidates, "; "), err
}, "from the indexer"},
{"hand-back returns to the mirrors", func() (string, error) {
a, err := c.HandBack(Call{Why: "a difference was found after the switch", Caller: "person.jochen"})
return answered(a), err
}, "controller's records again"},
{"after it, declarations resolve from the mirror", func() (string, error) {
m.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
_, err := c.Reconcile()
return string(m.resolved(t, "g14", "dunst", "width").Value), err
}, "400"},
{"hand-back is gone after the lease write", func() (string, error) {
m.mirror(t, LeaseRead, "holder", `{"handed-to":"the operator's hand-over"}`)
_, err := c.HandBack(Call{Why: "again", Caller: "person.jochen"})
{"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
}, "gone"},
}, "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"},
{"the lease is written", func() (string, error) {
m.mirror(t, LeaseRead, "holder", `{"handed-to":"the operator's hand-over"}`)
return "lease", nil
}, "lease"},
{"the terminal sets no trusted value after the lease write", func() (string, error) {
_, err := c.Change(Call{Action: "set", Key: "managed", Module: "claude-code", Node: "g14", Value: map[string]any{},
Why: "x", Caller: ControllerCaller, By: "person.jochen"})
@@ -214,8 +194,8 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
t.Errorf("%s: said %q, want %q", s.name, text, s.want)
}
}
if !slices.Contains(m.handActs, "handed config back to the controller's records | a difference was found after the switch | config-hand-back") {
t.Errorf("the hand-back is not recorded: %v", m.handActs)
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)
@@ -225,7 +205,7 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
if _, err := c2.TakeOver(Call{Why: "now", Caller: "person.jochen"}); err != nil {
t.Fatal(err)
}
m2.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
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") {
-2
View File
@@ -36,8 +36,6 @@
"mesh-delivery-builder.builds",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.manifests",
"mesh-controller.predefined",
"mesh-controller.declared-config",
"mesh-delivery-indexer.predefined",
"mesh-delivery-indexer.config-candidates",
"mesh-controller.lease"