Merge pull request 'Modules stop depending on the controller before the lease write (issue 774)' (#590) from feat/l3-removal into main

This commit was merged in pull request #590.
This commit is contained in:
2026-11-02 00:35:23 +00:00
84 changed files with 859 additions and 1055 deletions
+39
View File
@@ -0,0 +1,39 @@
// Package controllerdeps is the catalogue's side of the lease write's manifest test (novox/hq ADR 0571 §13, ADR 0582
// clause 2, issue 774): no module of this tree reads, invokes, consumes, waits on or uses the controller beyond what the
// named list of mesh-sdk's catalogue.KeepList keeps to the write itself, and the lease key, which names the lease's holder
// whoever that is. It decodes every manifest under root/modules and judges them with mesh-sdk's ManifestRefusals, the
// judge the grants part's grants_lease_check and the other two repositories' tests run, so the rule is said once.
package controllerdeps
import (
"fmt"
"os"
"path/filepath"
"sort"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Refusals is each reason the lease write's check gives over the manifests under root/modules in one phase
// (catalogue.PhaseBefore or catalogue.PhaseWrite), none when it passes. A module.json that does not decode is an error:
// the manifests check says which, and nothing here is judged on a tree it cannot read.
func Refusals(root, phase string) ([]string, error) {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
manifests := map[string]catalogue.Manifest{}
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
return nil, err
}
m, err := catalogue.Decode(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", f, err)
}
manifests[m.Module] = m
}
return catalogue.ManifestRefusals(manifests, phase), nil
}
@@ -0,0 +1,56 @@
package controllerdeps
import (
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// 774/S13/AC1: every manifest of the catalogue at this head, at the phase of the lease write's own delivery, reads,
// invokes and consumes nothing of the controller beyond the lease and the entries kept to the write.
func TestNoManifestDependsOnTheControllerBeyondTheLeaseAndTheEntriesKeptToTheWrite(t *testing.T) {
got, err := Refusals("../..", catalogue.PhaseWrite)
if err != nil {
t.Fatal(err)
}
if len(got) > 0 {
t.Errorf("the catalogue still declares on the controller (novox/hq ADR 0571 §13):\n%s", strings.Join(got, "\n"))
}
}
// The check refuses what it must and lets the lease and the kept entries through, on a tree of its own.
func TestRefusalsJudgeATreeByPhase(t *testing.T) {
for _, row := range []struct {
name, phase string
manifests map[string]string
want string // empty: passes
}{
{"the lease is no dependency", catalogue.PhaseWrite, map[string]string{"a": `{"module":"a","reads":["mesh-controller.lease"]}`}, ""},
{"a read of the controller's states", catalogue.PhaseWrite, map[string]string{"a": `{"module":"a","reads":["mesh-controller.conditions"]}`}, "a declares read mesh-controller.conditions"},
{"a kept entry passes before the delivery", catalogue.PhaseBefore, map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","invokes":["seat:mesh-controller.collect"]}`}, ""},
{"a kept-to-the-write entry passes at the write", catalogue.PhaseWrite, map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","invokes":["seat:mesh-controller.collect"]}`}, ""},
} {
t.Run(row.name, func(t *testing.T) {
root := t.TempDir()
for module, text := range row.manifests {
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := Refusals(root, row.phase)
if err != nil {
t.Fatal(err)
}
if joined := strings.Join(got, "\n"); (row.want == "") != (joined == "") || !strings.Contains(joined, row.want) {
t.Errorf("refusals %q, want %q", joined, row.want)
}
})
}
}
+14
View File
@@ -0,0 +1,14 @@
module checks/controllerdeps
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+16
View File
@@ -0,0 +1,16 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+3
View File
@@ -0,0 +1,3 @@
# What these tests read outside their package, as paths from the repository's root (issue 1936).
# every module's directory (its manifest), decoded and judged for what it still declares on the controller.
modules
@@ -4,8 +4,8 @@ package main
// lists the open keys of the keeper and of the controller, each difference with its reason; the kinds that end with the // 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 // controller, the kinds only the keeper raises and the keys held while their module is delivered are listed apart and
// not counted. `take-over` raises an approve ask under the keeper's own name (ADR 0259), refused while compare shows a // not counted. `take-over` raises an approve ask under the keeper's own name (ADR 0259), refused while compare shows a
// difference, and on the warrant writes the take-over key naming the keeper. `hand-back` writes it back to the // difference, and on the warrant writes the take-over key naming the keeper. `hand-back` is gone (issue 774): nothing
// controller until the lease write, and is refused after it. // reads the take-over key any more, so it is answered as gone and changes nothing.
import ( import (
"encoding/json" "encoding/json"
@@ -179,19 +179,12 @@ func (k *Keeper) Warranted(o asks.Option, w asks.Warrant) error {
return k.writeTakeover(health.Takeover{Follow: health.FollowKeeper, At: k.Now().UTC(), By: w.Says(), Ask: w.Ask}) return k.writeTakeover(health.Takeover{Follow: health.FollowKeeper, At: k.Now().UTC(), By: w.Says(), Ask: w.Ask})
} }
// HandBack switches the readers back to the controller, until the lease write. // 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) { func (k *Keeper) HandBack(by, why string) (map[string]any, error) {
if strings.TrimSpace(why) == "" { return nil, errors.New("hand-back is gone: the readers of conditions hear the condition keeper alone, and nothing " +
return nil, errors.New("a hand-back says why") "reads the take-over key, so there is no controller to hand them back to (ADR 0564 clause 9)")
}
if k.LeaseAway() {
return nil, errors.New("refused: the controller's lease is handed away, so there is no controller to hand back to")
}
t := health.Takeover{Follow: health.FollowController, At: k.Now().UTC(), By: by, Why: why}
if err := k.writeTakeover(t); err != nil {
return nil, err
}
return map[string]any{"follow": t.Follow, "at": t.At}, nil
} }
// writeTakeover writes the take-over key, and keeps it in history as the record of the switch. // writeTakeover writes the take-over key, and keeps it in history as the record of the switch.
@@ -1,8 +1,8 @@
package main package main
// The condition keeper (novox/hq ADR 0564 clauses 2, 3, 7, 8, 9 and 13; issue 1952 C2, C3, C7): the one writer of the // The condition keeper (novox/hq ADR 0564 clauses 2, 3, 7, 8, 9 and 13; issue 1952 C2, C3, C7): the one writer of the
// mesh's open conditions. It watches every observer's `findings`, the healers' `attempts`, the controller's lease and, // mesh's open conditions. It watches every observer's `findings`, the healers' `attempts`, the controller's lease
// until the lease write, the controller's `conditions`, and the assigner's wants and the nodes' haves for the hold. From // alone (its `conditions` are no longer read, issue 774), and the assigner's wants and the nodes' haves for the hold. From
// all of them it derives, per key, the condition that should be open, and writes the difference by compare-and-set, // all of them it derives, per key, the condition that should be open, and writes the difference by compare-and-set,
// saying each transition as the controller says it (go/health) and keeping it in `history`. Nothing clears a condition // saying each transition as the controller says it (go/health) and keeping it in `history`. Nothing clears a condition
// but its finding going (ADR 0231 §14). // but its finding going (ADR 0231 §14).
@@ -46,11 +46,10 @@ var Observers = []string{health.WatchmanModule, health.SelfCheckModule, health.H
// The other states it reads, as `<module>.<name>`. // The other states it reads, as `<module>.<name>`.
const ( const (
AttemptsRead = health.HealersModule + "." + health.AttemptsState AttemptsRead = health.HealersModule + "." + health.AttemptsState
ControllerConditions = "mesh-controller.conditions" ControllerLease = "mesh-controller.lease"
ControllerLease = "mesh-controller.lease" ControllerLeaseKey = "holder"
ControllerLeaseKey = "holder" WantsRead = delivery.AssignerModule + ".wants"
WantsRead = delivery.AssignerModule + ".wants"
// PlacementRead is the mesh-placement seat's placement, read for which module holds a seat on a node (hold.go; the // PlacementRead is the mesh-placement seat's placement, read for which module holds a seat on a node (hold.go; the
// lead, 2026-10-29, for ADR 0564 clause 2's inputs). // lead, 2026-10-29, for ADR 0564 clause 2's inputs).
PlacementRead = delivery.PlacementRead PlacementRead = delivery.PlacementRead
@@ -265,7 +265,7 @@ func TestTheKeeperRaisesChangesAndClearsFromFindings(t *testing.T) {
// ADR 0564 clause 9 (issue 1952 C5, C8; the lead 2026-10-29): compare counts only what both sides can raise. A key held // 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 // 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 while compare differs, and // apart; a kind with no home decided is a difference naming the gap; take-over is refused while compare differs, and
// hand-back after the lease write. // hand-back is gone (issue 774, review g1): answered as gone, naming its record, and writing nothing.
func TestCompareCountsOnlyWhatBothSidesRaise(t *testing.T) { func TestCompareCountsOnlyWhatBothSidesRaise(t *testing.T) {
r := newRig() r := newRig()
put := func(key, kind string) []byte { put := func(key, kind string) []byte {
@@ -335,11 +335,14 @@ func TestCompareCountsOnlyWhatBothSidesRaise(t *testing.T) {
if _, err := r.k.TakeOver(nil, "jochen", "now"); err == nil || !strings.Contains(err.Error(), "2 difference") { if _, err := r.k.TakeOver(nil, "jochen", "now"); err == nil || !strings.Contains(err.Error(), "2 difference") {
t.Errorf("take-over while compare differs: %v", err) t.Errorf("take-over while compare differs: %v", err)
} }
if _, err := r.k.HandBack("jochen", "back"); err != nil { for _, away := range []bool{false, true} {
t.Errorf("hand-back before the lease write: %v", err) _ = r.k.Lease(away)
} if _, err := r.k.HandBack("jochen", "back"); err == nil || !strings.Contains(err.Error(), "hand-back is gone") ||
_ = r.k.Lease(true) !strings.Contains(err.Error(), "ADR 0564 clause 9") {
if _, err := r.k.HandBack("jochen", "back"); err == nil { t.Errorf("hand-back with the lease away %v: %v, want it answered as gone naming its record", away, err)
t.Error("hand-back after the lease write was taken") }
if _, _, found, _ := r.k.Store.Get(TakeoverState, health.TakeoverKey); found {
t.Errorf("hand-back with the lease away %v wrote the take-over key", away)
}
} }
} }
@@ -119,12 +119,6 @@ func Keep(k *Keeper) {
watch(k, AttemptsRead, health.HealersModule+" attempts", func(c stdio.StateChange) error { watch(k, AttemptsRead, health.HealersModule+" attempts", func(c stdio.StateChange) error {
return said(k.Attempt(c.Key, c.Value, c.Op != "put")) return said(k.Attempt(c.Key, c.Value, c.Op != "put"))
}) })
watch(k, ControllerConditions, "", func(c stdio.StateChange) error {
if k.LeaseAway() {
return nil
}
return said(k.Controller(c.Key, c.Value, c.Op != "put"))
})
for _, o := range Observers { for _, o := range Observers {
watch(k, o+"."+health.FindingsState, o, func(c stdio.StateChange) error { watch(k, o+"."+health.FindingsState, o, func(c stdio.StateChange) error {
return said(k.Finding(o, c.Key, c.Value, c.Op != "put")) return said(k.Finding(o, c.Key, c.Value, c.Op != "put"))
@@ -12,7 +12,7 @@ import (
// ADR 0564 clauses 1, 2, 3 and 12 (issue 1952 C1): the manifest says what the code does. It claims the seat // ADR 0564 clauses 1, 2, 3 and 12 (issue 1952 C1): the manifest says what the code does. It claims the seat
// mesh-conditions once for the mesh with the verbs mesh-sdk defines for it; reads every observer's findings, the healers' // mesh-conditions once for the mesh with the verbs mesh-sdk defines for it; reads every observer's findings, the healers'
// attempts, the controller's conditions and lease, the wants and the haves, and never the healers' would; keeps its // attempts, the controller's lease alone (never its conditions, ADR 0564 clause 9), the wants and the haves, and never the healers' would; keeps its
// states, history ninety days and each delivery's bound as long as the bound; and every state has a tool that reads it, // states, history ninety days and each delivery's bound as long as the bound; and every state has a tool that reads it,
// every act a tool that starts it. // every act a tool that starts it.
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
@@ -29,14 +29,14 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
!slices.Equal(Verbs, catalogue.VerbNames(seat.Serves)) { !slices.Equal(Verbs, catalogue.VerbNames(seat.Serves)) {
t.Errorf("module %s runs as %s and claims %+v; the seat serves %v", m.Module, m.RunsAs, m.Claims, catalogue.VerbNames(seat.Serves)) t.Errorf("module %s runs as %s and claims %+v; the seat serves %v", m.Module, m.RunsAs, m.Claims, catalogue.VerbNames(seat.Serves))
} }
reads := []string{ControllerConditions, ControllerLease, WantsRead, HavesRead, AttemptsRead, PlacementRead} reads := []string{ControllerLease, WantsRead, HavesRead, AttemptsRead, PlacementRead}
for _, o := range Observers { for _, o := range Observers {
reads = append(reads, o+"."+health.FindingsState) reads = append(reads, o+"."+health.FindingsState)
} }
slices.Sort(reads) slices.Sort(reads)
got := slices.Clone(m.Reads) got := slices.Clone(m.Reads)
slices.Sort(got) slices.Sort(got)
if !slices.Equal(got, reads) || slices.Contains(m.Reads, health.HealersModule+"."+health.WouldState) { if slices.Contains(m.Reads, "mesh-controller.conditions") || !slices.Equal(got, reads) || slices.Contains(m.Reads, health.HealersModule+"."+health.WouldState) {
t.Errorf("reads %v, want %v and never the healers' would", got, reads) t.Errorf("reads %v, want %v and never the healers' would", got, reads)
} }
ttl, keyTTL := map[string]int{}, map[string]bool{} ttl, keyTTL := map[string]int{}, map[string]bool{}
@@ -79,8 +79,8 @@ func Tools(k *Keeper, a Asks) []stdio.Tool {
Run: func(args map[string]any) (any, error) { Run: func(args map[string]any) (any, error) {
return k.TakeOver(a, stdio.CallerOf(args), strArg(args, "why")) return k.TakeOver(a, stdio.CallerOf(args), strArg(args, "why"))
}}, }},
{Name: Seat + ".hand-back", Description: "Switch the readers of conditions back to the controller, until its lease " + {Name: Seat + ".hand-back", Description: "Gone: the readers of conditions hear the keeper alone, so there is nothing " +
"is handed away.", "to hand back; answered as gone and changes nothing (ADR 0564 clause 9).",
Input: obj(map[string]any{"why": str("why")}, "why"), 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")) }}, 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.", {Name: Seat + ".resync", Description: "Read every finding again and raise and clear what differs.",
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-conditions
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-1
View File
@@ -28,7 +28,6 @@
"mesh-provisioner.findings", "mesh-provisioner.findings",
"artifact-store-tools.findings", "artifact-store-tools.findings",
"mesh-healers.attempts", "mesh-healers.attempts",
"mesh-controller.conditions",
"mesh-controller.lease", "mesh-controller.lease",
"mesh-delivery-assigner.wants", "mesh-delivery-assigner.wants",
"node-engine.have", "node-engine.have",
+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 - `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 `<node>.<module>.<key>`, with its source: `default`, `mesh`, `node`, `module`, or `unset` for a required value
nothing sets. nothing sets.
- `mesh-config_predefined`: the list of predefined keys, published from the list the controller kept (its mirror - `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
`mesh-controller.predefined`), the newest trunk build winning. A newer list dropping a key that still holds a 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. 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 - `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. 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 - `mesh-config_declared`: each placed module's declarations at the trunk, with the manifest and commit they come
from, written only when they differ. from, written only when they differ.
- `mesh-config_takeover`: the key `switch` names the source that holds: none (the interim since mesh-catalog #558: - `mesh-config_takeover`: the key `switch` names the source that holds: none (declarations from the modules' own
declarations from the modules' own manifests, the list of predefined keys and pull requests' config from the manifests, the list from the indexer's `predefined`, pull requests' config from the controller's `pull-declares`),
controller), `indexer` after the operator's take-over (the list and pull requests' config from the indexer's or `indexer` after the operator's take-over (pull requests' config from the indexer's `config-candidates`; the
`predefined` and `config-candidates`; the controller's `pull-declares` refused), `controller` after a hand-back controller's `pull-declares` refused). A switch still naming `controller` (the old hand-back) is read as `indexer` and
(declarations, list and pull requests' config from the controller's records). Each take-over ask is kept beside it. said gone. 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 - `config_compare` lists every difference between each open pull request's candidate at the indexer and what the
declarations, the list, each open pull request's config, and every placed module's value on every node resolved controller told by `pull-declares`; the controller's mirrors are no longer read (ADR 0565 §4, issue 774).
both ways. `config_take_over` asks the operator's Approve, refused while any difference stands and switched on the `config_take_over` asks the operator's Approve, refused while any difference stands and switched on the Approve only
Approve only if none stands then. `config_hand_back` returns to the controller's records until its lease is handed 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.
away. `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. - 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 - 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. 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"]}, "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]+"}}}` "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. // theList is the indexer's list of predefined keys, as predefined.json holds it.
const theList = `{"keys":{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true}, 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}}, "font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`
"repository":"mesh-catalog","commit":"aaaaaaaaaaaa","generation":5,"build":"b5"}`
func aMesh(t *testing.T) (*Config, *memStore) { func aMesh(t *testing.T) (*Config, *memStore) {
t.Helper() t.Helper()
@@ -256,7 +255,7 @@ func aMesh(t *testing.T) (*Config, *memStore) {
m.declare(t, "claude-code", `{"module":"claude-code","settings":{ m.declare(t, "claude-code", `{"module":"claude-code","settings":{
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-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.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"} { for _, node := range []string{"g14", "novox"} {
m.assign(t, node, "dunst") m.assign(t, node, "dunst")
} }
@@ -507,7 +506,7 @@ func TestAChangeTheHandActLogRefusesIsSaid(t *testing.T) {
expect(t, m, "g14", "12", SourceMesh) 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) { func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
c, m := aMesh(t) c, m := aMesh(t)
published := func() map[string]Published { published := func() map[string]Published {
@@ -521,24 +520,17 @@ func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
} }
return out 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) t.Fatalf("published %+v", p)
} }
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"font-size":{"type":"number","description":"x"}, indexerList(t, m, "bbbb", `{"font-size":{"type":"number","description":"x"},
"theme":{"type":"string","description":"the theme"}},"commit":"bbbb","generation":7}`) "theme":{"type":"string","description":"the theme"}}`)
if _, err := c.Reconcile(); err != nil { if _, err := c.Reconcile(); err != nil {
t.Fatal(err) 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) 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 // **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) { func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T) {
c, m := aMesh(t) c, m := aMesh(t)
set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"}) set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"})
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"agent-managed-settings":{"type":"object", indexerList(t, m, "cccccccccc", `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true}}`)
"description":"managed settings","trusted":true}},"commit":"cccccccccc","generation":9}`)
if _, err := c.Reconcile(); err != nil { if _, err := c.Reconcile(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -129,7 +129,7 @@ type PredefinedKey struct {
Trusted bool `json:"trusted,omitempty"` 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 { type PredefinedList struct {
Keys map[string]PredefinedKey `json:"keys"` Keys map[string]PredefinedKey `json:"keys"`
Repository string `json:"repository"` Repository string `json:"repository"`
@@ -138,9 +138,6 @@ type PredefinedList struct {
Build string `json:"build"` 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. // Published is one key of mesh-config_predefined: the key's declaration, and the build of the list it came from.
type Published struct { type Published struct {
PredefinedKey PredefinedKey
+3 -3
View File
@@ -6,7 +6,7 @@
// asked under this module's own name, design 57 K4). // 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 // 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 // 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 package main
import ( 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 // 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. // 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, for _, read := range []string{PlacementRead, WantsRead, ModulesRead, IndexerPredefinedRead,
CandidatesRead, MirrorDeclaredRead, TakeoverState} { CandidatesRead, TakeoverState} {
// Asked again until the runtime takes it, each give-up said (mesh-sdk's Follow). // Asked again until the runtime takes it, each give-up said (mesh-sdk's Follow).
go stdio.State(read).Follow("", poke) 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", ProposalsState: "config proposals", PullsState: "config list", KeptState: "config list", HistoryLog: "config history",
DeclaredState: DeclaredTool, TakeoverState: DeclaredTool, DeclaredState: DeclaredTool, TakeoverState: DeclaredTool,
PlacementRead: CompareTool, WantsRead: CompareTool, BuildsRead: CompareTool, ModulesRead: DeclaredTool, PlacementRead: CompareTool, WantsRead: CompareTool, BuildsRead: CompareTool, ModulesRead: DeclaredTool,
ManifestsRead: DeclaredTool, PredefinedRead: CompareTool, MirrorDeclaredRead: CompareTool, ManifestsRead: DeclaredTool, IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool, LeaseRead: HandBackTool,
IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool, LeaseRead: HandBackTool,
"a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool, "a take-over decided": TakeOverTool, "a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool, "a take-over decided": TakeOverTool,
} }
served := map[string]bool{} 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). // repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16).
ModulesRead = delivery.IndexerModule + ".modules" ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests" 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 is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
HistoryLog = "history" 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 // 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 // (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) { 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{}, s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{},
Predefined: map[string]Published{}, Values: map[string]Value{}} Predefined: map[string]Published{}, Values: map[string]Value{}}
var problems []string var problems []string
@@ -34,9 +25,6 @@ func (c *Config) sourcesFrom(source string) (Sources, error) {
if err := c.readDeclarations(&s); err != nil { if err := c.readDeclarations(&s); err != nil {
return s, fmt.Errorf("the modules' declarations cannot be read: %w", err) return s, fmt.Errorf("the modules' declarations cannot be read: %w", err)
} }
if source == SourceController {
c.mirrorDeclarations(&s)
}
for _, u := range s.Unresolved { for _, u := range s.Unresolved {
problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why)) 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. // did after the take-over.
Declared []string `json:"declared,omitempty"` Declared []string `json:"declared,omitempty"`
Candidates []string `json:"candidates,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 // 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
var r Resolution 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() published, err := c.publish()
if err != nil { if err != nil {
c.logf("[mesh-config] the list of predefined keys is not published: %v", err) 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 return r, err
} }
// publish makes mesh-config_predefined hold the list its source keeps — the indexer's after the take-over, the // publish makes mesh-config_predefined hold the indexer's list (the controller's mirror is no longer read, issue 774)
// controller's mirror otherwise — when it is newer than the one published (ADR 0348 §7: the newest trunk build wins; // 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 // 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 // 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). // 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. // It answers what it published, empty when nothing changed.
func (c *Config) publish() (string, error) { func (c *Config) publish() (string, error) {
sw, err := c.source() list, found, err := c.indexerList()
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("the indexer's list cannot be read: %w", 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)
} }
if !found { if !found {
return "", nil // no list kept yet 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) } whyOf := func(a map[string]any) string { s, _ := a["why"].(string); return strings.TrimSpace(s) }
return []stdio.Tool{ return []stdio.Tool{
{Name: CompareTool, {Name: CompareTool,
Description: "Every difference between config's own sources and the controller's records (novox/hq ADR 0565 §4): " + Description: "Every difference between the indexer's records and the controller's pull-declares (novox/hq ADR 0565 §4): " +
"each placed module's declarations (declared against the mirror declared-config), the list of predefined " + "each open pull request's config (the indexer's candidate against the controller's pull-declares), each " +
"keys (the indexer's against the mirror's), each open pull request's config (the indexer's candidate " + "with its reason. The controller's mirrors are no longer read, so nothing else is compared. The " +
"against the controller's pull-declares), and every placed module's value on every node resolved both " + "take-over is refused while any stands.",
"ways; each with its reason. The take-over is refused while any stands.",
Run: func(map[string]any) (any, error) { return c.Compare() }}, Run: func(map[string]any) (any, error) { return c.Compare() }},
{Name: TakeOverTool, {Name: TakeOverTool,
Description: "Ask the operator's Approve, under mesh-config's own name, to switch config to its own sources: " + 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)}) return c.TakeOver(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
}}, }},
{Name: HandBackTool, {Name: HandBackTool,
Description: "Return config to the controller's records (its mirrors declared-config and predefined, and " + Description: "Gone: the controller's records end with its lease and config no longer reads them (novox/hq ADR 0565 §4, " +
"pull-declares) until the controller hands its lease away; gone after it. Recorded as a hand act.", "issue 774); answers that, and changes nothing.",
Input: why, Input: why,
Run: func(a map[string]any) (any, error) { Run: func(a map[string]any) (any, error) {
return c.HandBack(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)}) 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 // 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 // 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, // difference between each open pull request's candidate at the indexer and what the controller told by pull-declares;
// each open pull request's config declarations, and every placed module's resolved value on every node both ways;
// `take-over` asks the operator's Approve under mesh-config's own name and, refused while anything differs, writes the // `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: // **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 // - none written: a pull request's declarations from the controller's pull-declares;
// of predefined keys from the controller's mirror, a pull request's declarations from the controller's // - `indexer` (taken over): a pull request's from the indexer's `config-candidates`; pull-declares refused;
// pull-declares; // - `controller` (the old hand-back): gone, read as `indexer`.
// - `indexer` (taken over): declarations as before, the list from the indexer's `predefined`, a pull request's from
// the indexer's `config-candidates`; pull-declares refused;
// - `controller` (handed back): declarations from the mirror `declared-config`, the list from the mirror,
// pull-declares taken.
import ( import (
"bytes" "bytes"
@@ -38,9 +38,6 @@ const (
TakeoverState = "takeover" TakeoverState = "takeover"
SwitchKey = "switch" 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 and CandidatesRead are the indexer's states (ADR 0565 §2).
IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState
CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState
@@ -50,8 +47,9 @@ const (
// The sources the switch names. // The sources the switch names.
const ( const (
SourceInterim = "interim" SourceInterim = "interim"
SourceIndexer = "indexer" 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" SourceController = "controller"
) )
@@ -69,8 +67,15 @@ type Switch struct {
By string `json:"by"` By string `json:"by"`
Why string `json:"why"` Why string `json:"why"`
Ask string `json:"ask,omitempty"` 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. // source is the source that holds now.
func (c *Config) source() (Switch, error) { func (c *Config) source() (Switch, error) {
e, err := c.Store.Get(TakeoverState, SwitchKey) 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) { if err := json.Unmarshal(e.Value, &s); err != nil || (s.Source != SourceIndexer && s.Source != SourceController) {
return Switch{}, fmt.Errorf("the take-over's switch is not one: %s", e.Value) return 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 s, nil
} }
@@ -172,31 +180,6 @@ func (c *Config) writeDeclared(s Sources) ([]string, error) {
return written, errors.Join(problems...) 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 ------------------------------------------ // ---- 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. // 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 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. // 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) { func (c *Config) candidates() (map[string]PullDecl, error) {
out := map[string]PullDecl{} out := map[string]PullDecl{}
@@ -300,7 +270,7 @@ func declaresDigest(d map[string]map[string]Declaration) string {
// ---- compare ---------------------------------------------------------------------------------------------- // ---- 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 { type Difference struct {
What string `json:"what"` What string `json:"what"`
Module string `json:"module,omitempty"` Module string `json:"module,omitempty"`
@@ -308,16 +278,12 @@ type Difference struct {
Key string `json:"key,omitempty"` Key string `json:"key,omitempty"`
Pull string `json:"pull,omitempty"` Pull string `json:"pull,omitempty"`
Own json.RawMessage `json:"own,omitempty"` Own json.RawMessage `json:"own,omitempty"`
Mirror json.RawMessage `json:"mirror,omitempty"`
Why string `json:"why"` Why string `json:"why"`
} }
// The kinds of difference. // The kinds of difference.
const ( const (
DiffDeclared = "declared" DiffPull = "pull"
DiffPredefined = "predefined"
DiffPull = "pull"
DiffValue = "value"
) )
// Comparison is what compare answers. // Comparison is what compare answers.
@@ -326,6 +292,8 @@ type Comparison struct {
Differences []Difference `json:"differences"` Differences []Difference `json:"differences"`
Count int `json:"count"` Count int `json:"count"`
Said string `json:"said"` 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. // 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() 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) { func (c *Config) compare() (*Comparison, error) {
sw, err := c.source() sw, err := c.source()
if err != nil { if err != nil {
return nil, err return nil, err
} }
own, err := c.sourcesFrom(SourceInterim) var own Sources
if err != nil { if err := c.readPulls(&own); err != nil {
return nil, fmt.Errorf("mesh-config's own sources cannot be read: %w", err) 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 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. // Each open pull request: the indexer's candidate against what the controller told by pull-declares.
cands, cerr := c.candidates() cands, cerr := c.candidates()
if cerr != nil { 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 { sort.SliceStable(diffs, func(i, j int) bool {
a, b := diffs[i], diffs[j] 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 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 { if diffs == nil {
diffs = []Difference{} 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 { 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 { } else {
cmp.Said = fmt.Sprintf("%d difference(s): the take-over is refused while any stands", len(diffs)) 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 "" 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 { func unionKeys[A, B any](a map[string]A, b map[string]B) []string {
seen := map[string]bool{} seen := map[string]bool{}
for k := range a { for k := range a {
@@ -602,13 +450,12 @@ func (t TakeoverAsk) ask() asks.Ask {
why, _ := sayable("Why: " + t.Why) why, _ := sayable("Why: " + t.Why)
a := asks.Ask{ID: t.ID, Headline: "Switch config to its own sources", a := asks.Ask{ID: t.ID, Headline: "Switch config to its own sources",
Explanation: strings.Join([]string{ 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.", "indexer, no longer from the controller. Nothing any node resolves changes: compare showed no difference.",
point(why)}, "\n"), point(why)}, "\n"),
Who: asks.Operator, Expires: t.At.Add(ProposalLasts), Who: asks.Operator, Expires: t.At.Add(ProposalLasts),
OnExpiry: "nothing changes; config keeps reading the controller's records", About: "config.takeover", 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 returns " + Details: "Read what was compared with mesh-config's compare verb, through the mesh MCP server. hand-back is gone."}
"to the controller's records until the controller's lease is handed away."}
for _, o := range []struct{ id, label, does string }{ for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "config switches to its own sources at once, if nothing differs then"}, {OptionApprove, "Approve", "config switches to its own sources at once, if nothing differs then"},
{OptionDecline, "Decline", "nothing changes"}, {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) 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) { func (c *Config) HandBack(call Call) (*TakeoverAnswer, error) {
if strings.TrimSpace(call.Why) == "" { if strings.TrimSpace(call.Why) == "" {
return nil, errors.New("say why config hands back: it is kept with the switch") 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 { if call.Caller == "" || call.Caller == Unproven {
return nil, errors.New("the call names no proven caller") return nil, errors.New("the call names no proven caller")
} }
c.mu.Lock() return nil, errors.New("hand-back is gone: the controller's records end with its lease and config no longer reads " +
defer c.mu.Unlock() "them, so there is nothing to hand back to (novox/hq ADR 0565 §4); nothing was changed")
if c.leaseAway() {
return nil, errors.New("hand-back is gone: the controller handed its lease away, and its records end with it " +
"(novox/hq ADR 0565 §4)")
}
sw, err := c.source()
if err != nil {
return nil, err
}
if sw.Source == SourceController {
return &TakeoverAnswer{Source: sw.Source, Said: "config reads the controller's records already"}, nil
}
now := c.now()
back := Switch{Source: SourceController, At: now.UTC(), By: call.Caller, Why: strings.TrimSpace(call.Why)}
if err := c.Store.Put(TakeoverState, SwitchKey, back); err != nil {
return nil, fmt.Errorf("the switch could not be written: %w", err)
}
answer := &TakeoverAnswer{Source: back.Source, Said: "config reads the controller's records again: its mirrors " +
"declared-config and predefined, and pull-declares"}
if err := c.Store.HandAct("handed config back to the controller's records", back.Why, "config-hand-back"); err != nil {
answer.Said += "; the hand-act log does not hold it: " + err.Error()
}
return answer, nil
} }
// Declared is the read of `declared`: every module's key, or one whole. // 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" "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 ( const (
claudeDeclared = `{"module":"claude-code","settings":{ claudeDeclared = `{"module":"claude-code","settings":{
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-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"}}}` 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 // anAgreeingMesh is aProposingMesh with the indexer's predefined list in place and no open pull request: compare is empty.
// declarations of every placed module, and the indexer's predefined list equal to the mirror's.
func anAgreeingMesh(t *testing.T) (*Config, *memStore, *standIn) { func anAgreeingMesh(t *testing.T) (*Config, *memStore, *standIn) {
t.Helper() t.Helper()
c, m, r := aProposingMesh(t) 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}, 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}}`) "font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`)
if _, err := c.Reconcile(); err != nil { if _, err := c.Reconcile(); err != nil {
@@ -65,9 +61,9 @@ func kinds(cmp *Comparison) []string {
return out return out
} }
// compare lists every difference with its reason — a module's declarations, the predefined list, a pull request's // compare lists every difference with its reason — a pull request's config at the indexer against what the controller
// config, and each value a node resolves both ways — and the take-over is refused while one stands (ADR 0565 §4 and // told — and the take-over is refused while one stands (ADR 0565 §4 and its insight of 2026-10-29; issue 1953 L5-C4).
// 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) { func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -75,17 +71,6 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
want []string want []string
}{ }{
{"all agree", func(*testing.T, *memStore) {}, nil}, {"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) { {"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}}}`) candidate(t, m, "novox/mesh-catalog#9", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#9"}}, }, []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) { {"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}}}`) candidate(t, m, "novox/mesh-catalog#12", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#12"}}, }, []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 { for _, tc := range cases {
c, m, r := anAgreeingMesh(t) 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' // 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). // 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) { func TestTheTakeOverAndTheHandBack(t *testing.T) {
c, m, r := anAgreeingMesh(t) c, m, r := anAgreeingMesh(t)
@@ -184,20 +165,19 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
res, err := c.Reconcile() res, err := c.Reconcile()
return strings.Join(res.Candidates, "; "), err return strings.Join(res.Candidates, "; "), err
}, "from the indexer"}, }, "from the indexer"},
{"hand-back returns to the mirrors", func() (string, error) { {"hand-back is gone, naming the record (774/S9/AC1)", func() (string, error) {
a, err := c.HandBack(Call{Why: "a difference was found after the switch", Caller: "person.jochen"}) _, 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"})
return "", err 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) { {"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{}, _, err := c.Change(Call{Action: "set", Key: "managed", Module: "claude-code", Node: "g14", Value: map[string]any{},
Why: "x", Caller: ControllerCaller, By: "person.jochen"}) 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) 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") { if len(m.handActs) != 0 {
t.Errorf("the hand-back is not recorded: %v", m.handActs) 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"`) { 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) 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 { if _, err := c2.TakeOver(Call{Why: "now", Caller: "person.jochen"}); err != nil {
t.Fatal(err) 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) r2.choose(t, r2.lastAsk(t), OptionApprove, nil)
out, _ := c2.Decide() out, _ := c2.Decide()
if sw, _ := c2.source(); sw.Source != SourceInterim || !strings.Contains(strings.Join(out, ""), "difference") { if sw, _ := c2.source(); sw.Source != SourceInterim || !strings.Contains(strings.Join(out, ""), "difference") {
+1 -1
View File
@@ -2,7 +2,7 @@ module mesh-config
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-2
View File
@@ -36,8 +36,6 @@
"mesh-delivery-builder.builds", "mesh-delivery-builder.builds",
"mesh-delivery-indexer.modules", "mesh-delivery-indexer.modules",
"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.manifests",
"mesh-controller.predefined",
"mesh-controller.declared-config",
"mesh-delivery-indexer.predefined", "mesh-delivery-indexer.predefined",
"mesh-delivery-indexer.config-candidates", "mesh-delivery-indexer.config-candidates",
"mesh-controller.lease" "mesh-controller.lease"
@@ -10,15 +10,14 @@ package main
// **Who did it is the runtime's word, never the caller's**: `by` is the bus user the runtime proved. A warrant is the // **Who did it is the runtime's word, never the caller's**: `by` is the bus user the runtime proved. A warrant is the
// router's word, never the caller's: `warranted` reads the router's own record of the asker's ask and records who // router's word, never the caller's: `warranted` reads the router's own record of the asker's ask and records who
// chose, through which channel, with which proofs and which answer, once per ask. The controller's bucket // chose, through which channel, with which proofs and which answer, once per ask. The controller's bucket
// `mesh-controller_hand-acts` stays its own until it loses its lease and is read, never written: `hand-acts` answers // `mesh-controller_hand-acts` is no longer read (issue 774, ADR 0571 §13): its acts were copied into this log once, and
// over both logs, and no act is written to both (ADR 0565 §11). // `hand-acts` answers from this log alone.
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -282,33 +281,16 @@ type Listing struct {
Fill *Fill `json:"fill,omitempty"` Fill *Fill `json:"fill,omitempty"`
} }
// HandActs reads both logs. // HandActs reads this seat's log alone: what was copied from the controller's is in it (copy.go).
func (l *Log) HandActs(days int, cause, who string) Listing { func (l *Log) HandActs(days int, cause, who string) Listing {
now := l.Now() now := l.Now()
since := now.Add(-time.Duration(days) * 24 * time.Hour) since := now.Add(-time.Duration(days) * 24 * time.Hour)
out := Listing{Acts: []Act{}, Repeated: map[string]int{}, Days: days, Kept: map[string]int{}} out := Listing{Acts: []Act{}, Repeated: map[string]int{}, Days: days, Kept: map[string]int{}}
var all []Act all, kept, err := l.read(now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin)))
// What was copied from the controller's log is in this one: it is read from this one only (copy.go). if err != nil {
copied := map[string]bool{} out.Unread = map[string]string{ActsLog: err.Error()}
for _, log := range []string{ActsLog, ControllerActs} { } else {
acts, kept, err := l.read(log, now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin))) out.Kept[ActsLog] = kept
if log == ActsLog && err == nil {
for _, a := range acts {
copied[a.ID] = a.ID != ""
}
}
if log == ControllerActs {
acts = slices.DeleteFunc(acts, func(a Act) bool { return a.ID != "" && copied[a.ID] })
}
if err != nil {
if out.Unread == nil {
out.Unread = map[string]string{}
}
out.Unread[log] = err.Error()
continue
}
out.Kept[log] = kept
all = append(all, acts...)
} }
for _, a := range all { for _, a := range all {
if now.Sub(a.At) <= RepeatedWithin && a.Repair() { if now.Sub(a.At) <= RepeatedWithin && a.Repair() {
@@ -331,56 +313,20 @@ func (l *Log) HandActs(days int, cause, who string) Listing {
return out return out
} }
// read is every act of one log since a moment, and how many it keeps: this seat's log entry by entry, the // read is every act of this seat's log since a moment, entry by entry, and how many it keeps.
// controller's bucket by its keys, each of which names its time, so only those within are read whole. func (l *Log) read(since time.Time) ([]Act, int, error) {
func (l *Log) read(log string, since time.Time) ([]Act, int, error) {
var out []Act var out []Act
keep := func(raw []byte, from string) { entries, err := l.Store.Entries(ActsLog)
var a Act
if json.Unmarshal(raw, &a) != nil || a.At.Before(since) {
return
}
a.Log = from
out = append(out, a)
}
if log == ActsLog {
entries, err := l.Store.Entries(ActsLog)
if err != nil {
return nil, 0, err
}
for _, raw := range entries {
keep(raw, handacts.LogSeat)
}
return out, len(entries), nil
}
keys, err := l.Store.Keys(log)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
for _, k := range keys { for _, raw := range entries {
if at, ok := keyTime(k); ok && at.Before(since) { var a Act
if json.Unmarshal(raw, &a) != nil || a.At.Before(since) {
continue continue
} }
raw, found, err := l.Store.Get(log, k) a.Log = handacts.LogSeat
if err != nil { out = append(out, a)
return nil, len(keys), err
}
if found {
keep(raw, handacts.LogController)
}
} }
return out, len(keys), nil return out, len(entries), nil
}
// keyTime is the time an act's key names (`act-<ns>-<n>`), when it names one.
func keyTime(key string) (time.Time, bool) {
parts := strings.Split(key, "-")
if len(parts) != 3 || parts[0] != "act" {
return time.Time{}, false
}
ns, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(0, ns), true
} }
@@ -3,7 +3,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -229,9 +228,10 @@ func TestWarrantedRecordsTheRoutersWordOnce(t *testing.T) {
} }
} }
// hand-acts answers over both logs, newest first, narrowed by days, cause and who, and counts each cause repaired by // hand-acts answers from this seat's log alone, newest first, narrowed by days, cause and who, and counts each cause
// hand twice in a fortnight across both, never a person's decision; a log it cannot read is said, never read as empty. // repaired by hand twice in a fortnight, never a person's decision; a log it cannot read is said, never read as empty.
func TestHandActsReadsBothLogs(t *testing.T) { // The controller's bucket holds acts here and is made to fail: it is not read (774/S10/AC1).
func TestHandActsReadsItsOwnLogAlone(t *testing.T) {
l, store, _, now := newLog() l, store, _, now := newLog()
put := func(log, verb, cause, by string, ago time.Duration) { put := func(log, verb, cause, by string, ago time.Duration) {
at := now.Add(-ago) at := now.Add(-ago)
@@ -243,21 +243,22 @@ func TestHandActsReadsBothLogs(t *testing.T) {
_ = store.Put(log, actKey(at), a) _ = store.Put(log, actKey(at), a)
} }
put(ActsLog, VerbRecord, "relogin", "person.jochen", time.Hour) put(ActsLog, VerbRecord, "relogin", "person.jochen", time.Hour)
put(ControllerActs, VerbRecord, "relogin", "shanks.claude-code", 3*24*time.Hour) put(ActsLog, VerbRecord, "relogin", "shanks.claude-code", 3*24*time.Hour)
put(ControllerActs, VerbDrill, CauseDrill, "person.jochen", time.Hour)
put(ActsLog, VerbDrill, CauseDrill, "person.jochen", 2*time.Hour) put(ActsLog, VerbDrill, CauseDrill, "person.jochen", 2*time.Hour)
put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", time.Hour) put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", time.Hour)
put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", 2*time.Hour) put(ActsLog, VerbWarrant, CauseOperatorAnswer, "the operator", 2*time.Hour)
put(ControllerActs, VerbRecord, "config", "ace.mesh-config", 30*24*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")
cases := []struct { cases := []struct {
name string name string
days int days int
cause, who string cause, who string
want int want int
}{ }{
{"a fortnight", 14, "", "", 6}, {"a fortnight", 14, "", "", 5},
{"a day", 1, "", "", 5}, {"a day", 1, "", "", 4},
{"forty days", 40, "", "", 7}, {"forty days", 40, "", "", 6},
{"by cause", 14, "relogin", "", 2}, {"by cause", 14, "relogin", "", 2},
{"by who", 14, "", "claude-code", 1}, {"by who", 14, "", "claude-code", 1},
} }
@@ -272,12 +273,12 @@ func TestHandActsReadsBothLogs(t *testing.T) {
} }
} }
} }
if got := l.HandActs(14, "", ""); got.Acts[0].Log != Seat || got.Kept[ControllerActs] != 3 || got.Fill == nil || if got := l.HandActs(14, "", ""); got.Acts[0].Log != Seat || got.Kept[ActsLog] != 6 || len(got.Kept) != 1 || got.Fill == nil ||
got.Fill.Entries != 4 { got.Fill.Entries != 6 {
t.Errorf("the log of an act or the kept counts are not said: %+v", got.Kept) t.Errorf("the log of an act or the kept counts are not said: %+v", got.Kept)
} }
store.keysErr[ControllerActs] = errors.New("the bus is away") store.keysErr[ActsLog] = errors.New("the bus is away")
if got := l.HandActs(14, "", ""); got.Unread[ControllerActs] == "" || len(got.Acts) != 4 { if got := l.HandActs(14, "", ""); got.Unread[ActsLog] == "" || len(got.Acts) != 0 {
t.Errorf("an unreadable log: %+v", got.Unread) t.Errorf("an unreadable log: %+v", got.Unread)
} }
w, err := l.Warrants() w, err := l.Warrants()
@@ -286,86 +287,21 @@ func TestHandActsReadsBothLogs(t *testing.T) {
} }
} }
// The controller's hand acts are copied into this seat's log once, each with its own key, time and author, and // The copy of the controller's hand acts is over: the tools answer so, with the marker of the copy that was made, and
// never twice: a marker makes it once, a key already in the log is not appended again, a log that cannot be read or // read and write nothing of the controller's bucket nor of the log (774/S10; research 072 row 26).
// written leaves no marker so the next call copies again, and hand-acts counts a copied act once (research 072 row 26). func TestTheCopyOfTheControllersHandActsIsOver(t *testing.T) {
func TestTheControllersHandActsAreCopiedOnce(t *testing.T) { l, store, _, now := newLog()
base := time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC) store.keysErr[ControllerActs] = errors.New("the controller's bucket must not be read")
old := []string{fmt.Sprintf("act-%d-1", base.UnixNano()), fmt.Sprintf("act-%d-2", base.UnixNano()+1), st, err := l.Copy("test", true)
fmt.Sprintf("act-%d-3", base.UnixNano()+2), "warrant-x-y"} if err != nil || st.Done || !strings.Contains(st.Said, "no longer read") {
cases := []struct { t.Errorf("without a marker: %+v, %v", st, err)
name string
already []string // keys in the new log before the copy
oldErr, newErr error
junk bool
wantErr string
wantCopied, wantAlready int
wantMarker bool
}{
{"all", nil, nil, nil, false, "", 4, 0, true},
{"finishes a half copy", []string{old[0], old[1]}, nil, nil, false, "", 2, 2, true},
{"an entry that is no act is copied unchanged", nil, nil, nil, true, "", 5, 0, true},
{"the old log cannot be read", nil, errors.New("away"), nil, false, "cannot be read", 0, 0, false},
{"the new log cannot be written", nil, nil, errors.New("full"), false, "could not be copied", 0, 0, false},
} }
for _, tc := range cases { _ = store.Put(CopiedState, CopyKey, CopyMarker{At: *now, By: "start", Source: ControllerActs, Found: 606, Copied: 606})
l, store, _, _ := newLog() st, err = l.Copy("test", false)
for _, k := range old { if err != nil || !st.Done || st.Marker.Copied != 606 || !strings.Contains(st.Said, "no longer read") || len(store.logKeys(ActsLog)) != 0 {
_ = store.Put(ControllerActs, k, Act{ID: k, At: base, By: "ace.mesh-config", Verb: VerbRecord, Why: "w", Cause: "c"}) t.Errorf("with a marker: %+v, %v", st, err)
} }
if tc.junk { if s, err := l.Copied(); err != nil || !s.Done || s.Marker == nil {
_ = store.Put(ControllerActs, fmt.Sprintf("act-%d-4", base.UnixNano()+3), "not an act") t.Errorf("copied says %+v, %v", s, err)
}
for _, k := range tc.already {
_ = store.Append(ActsLog, k, Act{ID: k})
}
store.keysErr[ControllerActs], store.keysErr[ActsLog] = tc.oldErr, tc.newErr
st, err := l.Copy("test", false)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("%s: %v", tc.name, err)
}
} else if err != nil || !st.Done || st.Marker.Copied != tc.wantCopied || st.Marker.Already != tc.wantAlready {
t.Fatalf("%s: %+v, %v", tc.name, st, err)
}
_, marked, _ := store.Get(CopiedState, CopyKey)
if marked != tc.wantMarker || len(store.logKeys(ActsLog)) != map[bool]int{true: tc.wantCopied + tc.wantAlready, false: len(tc.already)}[tc.wantMarker] {
t.Errorf("%s: marker %v, log %v", tc.name, marked, store.logKeys(ActsLog))
}
if !tc.wantMarker {
continue
}
// Each act is kept with its own time and author, as the controller wrote it.
raw, _, _ := store.Last(ActsLog, old[2])
var kept Act
_ = json.Unmarshal(raw, &kept)
if kept.By != "ace.mesh-config" || !kept.At.Equal(base) {
t.Errorf("%s: kept %+v", tc.name, kept)
}
// Asked again: nothing more is appended, and hand-acts counts each act once.
before := len(store.logKeys(ActsLog))
if again, err := l.Copy("test", false); err != nil || !again.Done || !strings.Contains(again.Said, "already copied") ||
len(store.logKeys(ActsLog)) != before {
t.Errorf("%s: a second copy: %+v, %v", tc.name, again, err)
}
if got := l.HandActs(30, "", ""); got.Kept[ControllerActs] != len(old)+boolInt(tc.junk) || len(got.Acts) != len(old) {
t.Errorf("%s: %d acts listed from %d kept: %+v", tc.name, len(got.Acts), before, got.Kept)
}
// An act the controller wrote since is appended by a top-up, and only that one.
late := fmt.Sprintf("act-%d-9", base.UnixNano()+9)
_ = store.Put(ControllerActs, late, Act{ID: late, At: base, By: "ace.late", Verb: VerbRecord, Why: "w", Cause: "c"})
if up, err := l.Copy("test", true); err != nil || up.Marker.Copied != 1 || len(store.logKeys(ActsLog)) != before+1 {
t.Errorf("%s: a top-up: %+v, %v", tc.name, up, err)
}
if s, err := l.Copied(); err != nil || !s.Done || s.Marker == nil {
t.Errorf("%s: copied says %+v, %v", tc.name, s, err)
}
} }
} }
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+19 -121
View File
@@ -1,22 +1,14 @@
package main package main
// The one-time copy of the controller's hand-act log into this seat's log (novox/hq research 072 row 26, ADR 0571 // The one-time copy of the controller's hand-act log into this seat's log is over (novox/hq research 072 row 26, ADR 0571
// §13). The controller kept every act of its life in its bucket `mesh-controller_hand-acts` (574 on 2026-10-31); from // §13, issue 774). The controller kept every act of its life in its bucket `mesh-controller_hand-acts`; they were copied
// the lease write only this seat reads them, so they are copied once into `acts`, each as the controller wrote it: the // once into `acts`, each as the controller wrote it, by the earlier builds of this module, which left the marker
// same key, the same bytes, so its time and its author are what they were. // `controller-hand-acts` of the state `copied`. This module no longer reads that bucket, so the copy cannot run: the
// // tools `hand_acts_copy` and `hand_acts_copied` stay and say so, with the marker's figures when it exists.
// **When it runs.** The lease write has no hook to run from (it is a person's step, ADR 0561 §6), so the copy runs at
// this module's start while the old log is readable and no marker says it was copied, and on the tool
// `hand_acts_copy`. Both do the same thing, and neither does it twice (a `top_up` of the tool appends only what is new): the marker `controller-hand-acts` of the state
// `copied` is created once, after every act is in the log; an act already in the log (by its key) is never appended
// again, so a copy that stopped half way is finished by the next one. Until the marker exists `hand-acts` hides an
// old act that is already copied, so the two logs never count one act twice. `hand_acts_copied` says where it stands.
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"sort"
"time" "time"
) )
@@ -47,92 +39,32 @@ type CopyStatus struct {
Marker *CopyMarker `json:"marker,omitempty"` Marker *CopyMarker `json:"marker,omitempty"`
// Said is a sentence for a person: what this call did or found. // Said is a sentence for a person: what this call did or found.
Said string `json:"said"` Said string `json:"said"`
// Pending are the old acts not yet in this seat's log (counted only while the copy is not whole). // New is how many acts this seat's log holds.
Pending *int `json:"pending,omitempty"` New *int `json:"new-log-entries,omitempty"`
Old *int `json:"old-log-keys,omitempty"`
New *int `json:"new-log-entries,omitempty"`
} }
// Copy copies the old log into this one, once. by says what asked (`start` or a caller). With topUp it also runs after // gone is what a call to copy answers.
// the marker exists, appending only the acts the log does not hold yet (the controller's, written since the copy), and const gone = "the controller's hand-act log is no longer read, so nothing can be copied (novox/hq ADR 0571 §13, issue 774); " +
// leaves the marker as it was. An old log that cannot be "the acts copied before are in the log `acts`"
// read, or an act that cannot be appended, is an error and leaves no marker, so the next call copies again.
// Copy answers that the copy is over: it reads and writes nothing of the controller's. The marker, when the copy once
// ran to its end, is returned with the answer.
func (l *Log) Copy(by string, topUp bool) (CopyStatus, error) { func (l *Log) Copy(by string, topUp bool) (CopyStatus, error) {
marked := false st, err := l.Copied()
if raw, found, err := l.Store.Get(CopiedState, CopyKey); err != nil {
return CopyStatus{}, fmt.Errorf("whether the controller's hand acts were copied cannot be read: %w", err)
} else if found {
var m CopyMarker
if err := json.Unmarshal(raw, &m); err != nil {
return CopyStatus{}, fmt.Errorf("the copy's marker is not readable: %w", err)
}
if !topUp {
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("already copied at %s: %d acts of %s, nothing done",
m.At.Format(time.RFC3339), m.Copied+m.Already, m.Source)}, nil
}
marked = true
}
keys, err := l.Store.Keys(ControllerActs)
if err != nil { if err != nil {
return CopyStatus{}, fmt.Errorf("the controller's hand acts cannot be read, so none were copied: %w", err) return st, err
} }
sortByTime(keys) st.Said = gone + "; " + st.Said
m := CopyMarker{By: by, Source: ControllerActs, Found: len(keys)} return st, nil
l.Logf("[%s] copying the controller's hand acts into %s: %d found in %s", Module, ActsLog, len(keys), ControllerActs)
for i, k := range keys {
raw, found, err := l.Store.Get(ControllerActs, k)
if err != nil {
return CopyStatus{}, fmt.Errorf("%s of %s cannot be read after %d of %d acts were copied: %w", k, ControllerActs, m.Copied, len(keys), err)
}
if !found {
continue // removed since it was listed
}
if _, there, err := l.Store.Last(ActsLog, k); err != nil {
return CopyStatus{}, fmt.Errorf("whether %s is in %s cannot be read, after %d acts were copied: %w", k, ActsLog, m.Copied, err)
} else if there {
m.Already++
continue
}
var a Act
if json.Unmarshal(raw, &a) != nil {
m.Unreadable = append(m.Unreadable, k)
l.Logf("[%s] %s of %s is not an act; copied unchanged", Module, k, ControllerActs)
}
if err := l.Store.Append(ActsLog, k, json.RawMessage(raw)); err != nil {
return CopyStatus{}, fmt.Errorf("%s could not be copied into %s after %d acts: %w", k, ActsLog, m.Copied, err)
}
m.Copied++
if m.FirstKey == "" {
m.FirstKey = k
}
m.LastKey = k
if (i+1)%100 == 0 {
l.Logf("[%s] copied %d of %d", Module, i+1, len(keys))
}
}
m.At = l.Now().UTC()
if marked {
l.Logf("[%s] topped up the copy: %d acts copied, %d already there, of %d found", Module, m.Copied, m.Already, m.Found)
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("topped up: %d acts copied that the first copy did not hold (%d already there)", m.Copied, m.Already)}, nil
}
if err := l.Store.Create(CopiedState, CopyKey, m); errors.Is(err, ErrExists) {
return CopyStatus{Said: "another instance finished the copy first; nothing more done"}, nil
} else if err != nil {
return CopyStatus{}, fmt.Errorf("every act was copied but the marker could not be written, so the next call checks them again: %w", err)
}
l.Logf("[%s] copied the controller's hand acts: %d copied, %d already there, %d not acts, of %d found (%s .. %s)",
Module, m.Copied, m.Already, len(m.Unreadable), m.Found, m.FirstKey, m.LastKey)
return CopyStatus{Done: true, Marker: &m, Said: fmt.Sprintf("copied %d acts (%d were already there) of %d found in %s",
m.Copied, m.Already, m.Found, ControllerActs)}, nil
} }
// Copied says where the copy stands without doing anything: the marker, or how many old acts wait. // Copied says where the copy ended: the marker with its figures, or that none was left, and how many acts the log holds.
func (l *Log) Copied() (CopyStatus, error) { func (l *Log) Copied() (CopyStatus, error) {
raw, found, err := l.Store.Get(CopiedState, CopyKey) raw, found, err := l.Store.Get(CopiedState, CopyKey)
if err != nil { if err != nil {
return CopyStatus{}, fmt.Errorf("the copy's marker cannot be read: %w", err) return CopyStatus{}, fmt.Errorf("the copy's marker cannot be read: %w", err)
} }
var st CopyStatus st := CopyStatus{Said: "no marker of a finished copy is left"}
if found { if found {
var m CopyMarker var m CopyMarker
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
@@ -145,39 +77,5 @@ func (l *Log) Copied() (CopyStatus, error) {
n := int(f.Entries) n := int(f.Entries)
st.New = &n st.New = &n
} }
keys, err := l.Store.Keys(ControllerActs)
if err != nil {
if !st.Done {
st.Said = "not copied yet, and the controller's log cannot be read: " + err.Error()
}
return st, nil
}
n := len(keys)
st.Old = &n
if !st.Done {
pending := 0
for _, k := range keys {
if _, there, err := l.Store.Last(ActsLog, k); err != nil || !there {
pending++
}
}
st.Pending = &pending
st.Said = fmt.Sprintf("not copied yet: %d of %d old acts wait; hand_acts_copy copies them", pending, n)
}
return st, nil return st, nil
} }
// sortByTime orders keys oldest first by the time they name, those that name none after, by key.
func sortByTime(keys []string) {
sort.SliceStable(keys, func(i, j int) bool {
ti, oi := keyTime(keys[i])
tj, oj := keyTime(keys[j])
switch {
case oi && oj && !ti.Equal(tj):
return ti.Before(tj)
case oi != oj:
return oi
}
return keys[i] < keys[j]
})
}
@@ -1,7 +1,7 @@
// mesh-hand-acts: the holder of the seat mesh-hand-acts (novox/hq ADR 0565 §10). A Go bundle served by a runtime of // mesh-hand-acts: the holder of the seat mesh-hand-acts (novox/hq ADR 0565 §10). A Go bundle served by a runtime of
// its own, on its own bus account (`runs-as`), so that its account alone writes the hand-act log (`acts`, its module // its own, on its own bus account (`runs-as`), so that its account alone writes the hand-act log (`acts`, its module
// log: a stream that keeps every act in order) and `warrants`. It reads the router's record of each ask for a warrant and the controller's log for // log: a stream that keeps every act in order) and `warrants`. It reads the router's record of each ask for a warrant; the controller's log is no longer read
// `hand-acts`. stdout is the MCP channel; what this module says, it says on stderr. // (issue 774). stdout is the MCP channel; what this module says, it says on stderr.
package main package main
import ( import (
@@ -77,15 +77,6 @@ func (runtimeStore) Fill(log string) (Fill, error) {
func main() { func main() {
l := &Log{Store: runtimeStore{}, Now: time.Now, Logf: logf, l := &Log{Store: runtimeStore{}, Now: time.Now, Logf: logf,
Publish: stdio.SeatPublish} Publish: stdio.SeatPublish}
// No hook of the lease write runs this module, so the one-time copy of the controller's hand acts runs at start
// and on the tool hand_acts_copy; the marker makes it once (copy.go).
go func() {
if st, err := l.Copy("start", false); err != nil {
logf("[%s] the copy of the controller's hand acts is NOT done: %v", Module, err)
} else {
logf("[%s] %s", Module, st.Said)
}
}()
if err := stdio.Serve("", Tools(l)); err != nil { if err := stdio.Serve("", Tools(l)); err != nil {
logf("%v", err) logf("%v", err)
os.Exit(1) os.Exit(1)
@@ -43,10 +43,10 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
t.Errorf("claims %+v", m.Claims) t.Errorf("claims %+v", m.Claims)
} }
// act-recorded is the seat's event, said on the seat's subject: a module lists no event of a seat it claims. // act-recorded is the seat's event, said on the seat's subject: a module lists no event of a seat it claims.
if len(m.Emits) != 0 || !slices.Equal(m.Reads, []string{ControllerActs, RouterRecords}) { if len(m.Emits) != 0 || !slices.Equal(m.Reads, []string{RouterRecords}) {
t.Errorf("emits %v, reads %v", m.Emits, m.Reads) t.Errorf("emits %v, reads %v", m.Emits, m.Reads)
} }
readBy := map[string]string{ActsLog: HandActsTool, WarrantsState: HandActsTool, ControllerActs: HandActsTool, readBy := map[string]string{ActsLog: HandActsTool, WarrantsState: HandActsTool,
RouterRecords: WarrantedTool, CopiedState: CopiedTool} RouterRecords: WarrantedTool, CopiedState: CopiedTool}
tools := map[string]bool{} tools := map[string]bool{}
for _, tool := range Tools(&Log{}) { for _, tool := range Tools(&Log{}) {
@@ -52,8 +52,7 @@ func Tools(l *Log) []stdio.Tool {
return l.HandAct(stdio.CallerOf(a), arg(a, "verb"), arg(a, "what"), arg(a, "why"), arg(a, "cause"), arg(a, "condition")) return l.HandAct(stdio.CallerOf(a), arg(a, "verb"), arg(a, "what"), arg(a, "why"), arg(a, "cause"), arg(a, "condition"))
}}, }},
{Name: HandActsTool, {Name: HandActsTool,
Description: "What was done by hand lately, over both logs (this seat's and the controller's until its lease " + Description: "What was done by hand lately, from this seat's log (the controller's acts were copied into it), newest first: each act with who, why, its cause and which log holds it, and the causes repaired by " +
"ends), newest first: each act with who, why, its cause and which log holds it, and the causes repaired by " +
"hand twice or more within a fortnight (a healer is wanted). Narrowed by cause and who; warrants=true lists " + "hand twice or more within a fortnight (a healer is wanted). Narrowed by cause and who; warrants=true lists " +
"every warrant recorded instead.", "every warrant recorded instead.",
Input: obj(map[string]any{"days": map[string]any{"type": "number", "description": "how many days back (default 14)"}, Input: obj(map[string]any{"days": map[string]any{"type": "number", "description": "how many days back (default 14)"},
@@ -74,16 +73,14 @@ func Tools(l *Log) []stdio.Tool {
return l.Warranted(stdio.CallerOf(a), arg(a, "asker"), arg(a, "ask")) return l.Warranted(stdio.CallerOf(a), arg(a, "asker"), arg(a, "ask"))
}}, }},
{Name: CopiedTool, {Name: CopiedTool,
Description: "Where the one-time copy of the controller's hand acts into this seat's log stands (novox/hq research 072 " + Description: "Where the one-time copy of the controller's hand acts into this seat's log ended (novox/hq research 072 " +
"row 26): done, with when, by what and how many acts were copied, or how many old acts still wait, and how " + "row 26): the marker with when, by what and how many acts were copied, and how full the log is. The copy " +
"full both logs are. Changes nothing.", "cannot run any more: the controller's log is no longer read. Changes nothing.",
Input: obj(map[string]any{}), Input: obj(map[string]any{}),
Run: func(map[string]any) (any, error) { return l.Copied() }}, Run: func(map[string]any) (any, error) { return l.Copied() }},
{Name: CopyTool, {Name: CopyTool,
Description: "Copy the controller's hand acts (its bucket mesh-controller_hand-acts) into this seat's log once, each " + Description: "Answers that the copy is over (the controller's hand-act log, its bucket mesh-controller_hand-acts, " +
"with its own key, time and author. Does nothing when the copy is already done; an act already in the log is " + "is no longer read, novox/hq ADR 0571 §13) with the marker of the copy that was made; copies nothing.",
"never appended again, so a copy that stopped is finished by asking again. With top_up=true it also runs after the " +
"copy is done and appends only the acts written since. This also runs at the module's start.",
Input: obj(map[string]any{"top_up": str("\"true\": after the copy is done, append the acts written since")}), Input: obj(map[string]any{"top_up": str("\"true\": after the copy is done, append the acts written since")}),
Run: func(a map[string]any) (any, error) { Run: func(a map[string]any) (any, error) {
return l.Copy("tool, called by "+stdio.CallerOf(a), a["top_up"] == true || strings.EqualFold(arg(a, "top_up"), "true")) return l.Copy("tool, called by "+stdio.CallerOf(a), a["top_up"] == true || strings.EqualFold(arg(a, "top_up"), "true"))
@@ -354,7 +354,7 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// One act of the controller's, kept before the module starts: the start copies it into the log. // One act of the controller's, kept before the module starts: the module no longer reads that bucket, so it never copies it.
oldActs, _ := js.KeyValue(ctx, "mesh-controller_hand-acts") oldActs, _ := js.KeyValue(ctx, "mesh-controller_hand-acts")
oldKey := fmt.Sprintf("act-%d-1", time.Now().Add(-time.Hour).UnixNano()) oldKey := fmt.Sprintf("act-%d-1", time.Now().Add(-time.Hour).UnixNano())
if _, err := oldActs.Put(ctx, oldKey, []byte(`{"id":"`+oldKey+`","at":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+ if _, err := oldActs.Put(ctx, oldKey, []byte(`{"id":"`+oldKey+`","at":"`+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)+
@@ -402,22 +402,17 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
for i := 0; ; i++ { if c, err := p.call(CopyTool, "person.jochen", map[string]any{}); err != nil || !strings.Contains(string(c), "no longer read") || strings.Contains(string(c), `"done":true`) {
if c, err := p.call(CopiedTool, "person.jochen", map[string]any{}); err == nil && strings.Contains(string(c), `"done":true`) { t.Errorf("the copy's tool answered %s, %v", c, err)
break
} else if i > 50 {
t.Fatalf("the controller's hand acts were not copied at the start: %s, %v", c, err)
}
time.Sleep(200 * time.Millisecond)
} }
if e, err := logStream.GetLastMsgForSubject(ctx, "mesh.log."+Module+"."+ActsLog+"."+oldKey); err != nil || !strings.Contains(string(e.Data), `"by":"ace.old"`) { if _, err := logStream.GetLastMsgForSubject(ctx, "mesh.log."+Module+"."+ActsLog+"."+oldKey); err == nil {
t.Errorf("the controller's act is not copied with its author: %v", err) t.Errorf("the controller's act was copied although its bucket is no longer read")
} }
if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 3 { if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 2 {
t.Errorf("the log keeps %v, want the copied act, the act and one warrant (%v)", info, err) t.Errorf("the log keeps %v, want the act and one warrant (%v)", info, err)
} }
listed, err := p.call(HandActsTool, "person.jochen", map[string]any{}) listed, err := p.call(HandActsTool, "person.jochen", map[string]any{})
if err != nil || !strings.Contains(string(listed), "warrant-claude-code-a1") || !strings.Contains(string(listed), r.Act.ID) { if err != nil || !strings.Contains(string(listed), "warrant-claude-code-a1") || !strings.Contains(string(listed), r.Act.ID) || strings.Contains(string(listed), oldKey) {
t.Errorf("hand-acts answered %s, %v", listed, err) t.Errorf("hand-acts answered %s, %v", listed, err)
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-hand-acts
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-1
View File
@@ -14,7 +14,6 @@
} }
], ],
"reads": [ "reads": [
"mesh-controller.hand-acts",
"messenger.asks" "messenger.asks"
], ],
"logs": [ "logs": [
@@ -45,7 +45,6 @@ const (
LeaseRead = buscompose.ControllerLease LeaseRead = buscompose.ControllerLease
LeaseKey = buscompose.ControllerLeaseKey LeaseKey = buscompose.ControllerLeaseKey
ConditionsRead = "mesh-conditions.conditions" ConditionsRead = "mesh-conditions.conditions"
HandActsRead = "mesh-controller.hand-acts"
ActedEvent = health.EventHealerActed ActedEvent = health.EventHealerActed
// ActedSubject is where the seat says it, which its consumers hear (catalogue.SeatSays). // ActedSubject is where the seat says it, which its consumers hear (catalogue.SeatSays).
ActedSubject = "mesh.seat." + Seat + ".event." + ActedEvent ActedSubject = "mesh.seat." + Seat + ".event." + ActedEvent
@@ -374,16 +374,19 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) {
w := newWorld() w := newWorld()
act := func(key, verb, cause string, ago time.Duration) { act := func(key, verb, cause string, ago time.Duration) {
raw, _ := json.Marshal(HandAct{ID: key, At: w.now.Add(-ago), By: "person.jochen", Verb: verb, Why: "x", Cause: cause}) raw, _ := json.Marshal(HandAct{ID: key, At: w.now.Add(-ago), By: "person.jochen", Verb: verb, Why: "x", Cause: cause})
w.h.HandActHeard(key, "put", raw) w.h.ActRecorded(raw)
} }
act("a1", "broker consumer-reset", "consumer-behind", 10*24*time.Hour) act("a1", "broker consumer-reset", "consumer-behind", 10*24*time.Hour)
if keys := w.store.keys(FindingsState); len(keys) != 0 { if keys := w.store.keys(FindingsState); len(keys) != 0 {
t.Fatalf("one act is a finding: %v", keys) t.Fatalf("one act is a finding: %v", keys)
} }
// The second of the cause is the seat's own act, heard as act-recorded: one in each log counts two (ADR 0565 §11). // The second of the cause is heard as act-recorded; an act heard again and listed by the seat counts once (774/S10/AC2).
seatAct, _ := json.Marshal(HandAct{ID: "a2", At: w.now.Add(-24 * time.Hour), By: "person.jochen", seatAct, _ := json.Marshal(HandAct{ID: "a2", At: w.now.Add(-24 * time.Hour), By: "person.jochen",
Verb: "broker consumer-reset", Why: "x", Cause: "consumer-behind"}) Verb: "broker consumer-reset", Why: "x", Cause: "consumer-behind"})
w.h.ActRecorded(seatAct) w.h.ActRecorded(seatAct)
if n, err := w.h.Seeded([]byte(`{"acts":[` + string(seatAct) + `]}`)); err != nil || n != 1 {
t.Fatalf("the seat's listing was not taken: %d, %v", n, err)
}
// A drill in each log counts none. // A drill in each log counts none.
act("a3", "hand-act drill", "drill", time.Hour) act("a3", "hand-act drill", "drill", time.Hour)
drill, _ := json.Marshal(HandAct{ID: "a4", At: w.now.Add(-2 * time.Hour), Verb: "hand-act drill", Why: "x", Cause: "drill"}) drill, _ := json.Marshal(HandAct{ID: "a4", At: w.now.Add(-2 * time.Hour), Verb: "hand-act drill", Why: "x", Cause: "drill"})
@@ -408,9 +411,8 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) {
if keys := w.store.keys(FindingsState); len(keys) != 0 { if keys := w.store.keys(FindingsState); len(keys) != 0 {
t.Errorf("past the fortnight the finding stays: %v", keys) t.Errorf("past the fortnight the finding stays: %v", keys)
} }
w.h.HandActHeard("a5", "delete", nil) if len(w.h.acts) != 8 {
if len(w.h.acts) != 7 { t.Errorf("an act heard twice or listed after it was heard is kept twice: %d acts", len(w.h.acts))
t.Errorf("a deleted entry is kept: %d", len(w.h.acts))
} }
} }
+21 -7
View File
@@ -2,8 +2,8 @@
// own, on its own bus account (`runs-as`), so that its account alone writes its buckets: `attempts` (every act begun, // own, on its own bus account (`runs-as`), so that its account alone writes its buckets: `attempts` (every act begun,
// made and finished, and every escalation, kept a week), `would` (what it would have done while the controller holds // made and finished, and every escalation, kept a week), `would` (what it would have done while the controller holds
// its lease, kept a week) and `findings` (the brake, its blindness, and S15). It hears the condition keeper's // its lease, kept a week) and `findings` (the brake, its blindness, and S15). It hears the condition keeper's
// condition-raised and condition-changed on a durable consumer, watches the controller's lease, and watches the // condition-raised and condition-changed on a durable consumer, watches the controller's lease, and counts the seat's hand acts
// controller's hand-act log for S15. stdout is the MCP channel; what this module says, it says on stderr. // for S15. stdout is the MCP channel; what this module says, it says on stderr.
package main package main
import ( import (
@@ -68,11 +68,25 @@ func main() {
h.WakeHeard(c.Key, c.Op) h.WakeHeard(c.Key, c.Op)
return nil return nil
}) })
go stdio.State(HandActsRead).Follow("", func(c stdio.StateChange) error { // S15 over the seat's log: the fortnight's acts asked of the seat once at the start, then act-recorded on a
h.HandActHeard(c.Key, c.Op, c.Value) // durable consumer (the controller's bucket is no longer read, issue 774).
return nil go func() {
}) <-stdio.Served()
// S15 over both logs: the seat's act-recorded on a durable consumer, beside the controller's bucket's watch. for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
answer, err := stdio.Ask("seat:mesh-hand-acts.hand-acts", map[string]any{"days": int(Within.Hours() / 24)})
if err == nil {
n, err := h.Seeded(answer)
if err == nil {
logf("[%s] took %d hand acts of the last fortnight from the seat", Module, n)
return
}
logf("[%s] %v", Module, err)
} else {
logf("[%s] the seat's hand acts cannot be asked yet (%v); asking again in %s", Module, err, wait)
}
time.Sleep(wait)
}
}()
go func() { go func() {
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard <-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: an event sent meanwhile is heard
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
@@ -49,8 +49,8 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
}{ }{
{"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil}, {"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil},
{"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}}, {"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}},
{"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act"}}, {"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts"}},
{"reads", m.Reads, []string{LeaseRead, ConditionsRead, HandActsRead}}, {"reads", m.Reads, []string{LeaseRead, ConditionsRead}},
} { } {
if !slices.Equal(c.got, c.want) { if !slices.Equal(c.got, c.want) {
t.Errorf("%s %v, want %v", c.name, c.got, c.want) t.Errorf("%s %v, want %v", c.name, c.got, c.want)
+26 -23
View File
@@ -1,12 +1,12 @@
package main package main
// S15: a cause repaired by hand twice within a fortnight is a healer wanted (ADR 0231 §27, §28; ADR 0564 Limits 2). // S15: a cause repaired by hand twice within a fortnight is a healer wanted (ADR 0231 §27, §28; ADR 0564 Limits 2).
// The hand-act log is the controller's bucket mesh-controller_hand-acts, a key-value bucket with no events: mesh-healers // The hand-act log is the seat mesh-hand-acts' (ADR 0565 §10, §11), which says each act as act-recorded: mesh-healers
// watches it, the one of the four holders granted its read, counts each cause again on every put, and writes the // hears them, counts each cause again on every act, and writes the finding `healer-wanted` per cause. At its start it
// finding `healer-wanted` per cause. **A heal is never a hand act**, and an act that is a person's decision by design // asks the seat for the fortnight's acts once, so a restart does not forget them. The controller's bucket of hand acts
// (a drill above all) never counts. The finding clears when fewer than two acts of its cause remain within the // is no longer read (issue 774, ADR 0571 §13). **A heal is never a hand act**, and an act that is a person's decision by
// fortnight: the key `fortnight` of the healers' wakes expires when the oldest counted act leaves it. From the log's home (issue 1953) they // design (a drill above all) never counts. The finding clears when fewer than two acts of its cause remain within the
// watch that instead. // fortnight: the key `fortnight` of the healers' wakes expires when the oldest counted act leaves it.
import ( import (
"encoding/json" "encoding/json"
@@ -22,7 +22,7 @@ import (
// Within is how far back a repeated cause counts. // Within is how far back a repeated cause counts.
const Within = 14 * 24 * time.Hour const Within = 14 * 24 * time.Hour
// HandAct is what S15 reads of one entry of the controller's hand-act log (mesh-controller internal/link HandAct). // HandAct is what S15 reads of one act of the hand-act log (mesh-sdk handacts.Act).
type HandAct struct { type HandAct struct {
ID string `json:"id"` ID string `json:"id"`
At time.Time `json:"at"` At time.Time `json:"at"`
@@ -35,7 +35,7 @@ type HandAct struct {
} }
// decided are the verbs whose entries are a person's decision by design, with the causes they hold for (every cause // decided are the verbs whose entries are a person's decision by design, with the causes they hold for (every cause
// when none): the controller's handActVerbs, read as S15 reads them. A verb not listed counts as a repair. // when none): the controller's handActVerbs as the log still names them, read as S15 reads them. A verb not listed counts as a repair.
var decided = map[string][]string{ var decided = map[string][]string{
"warrant": nil, "warrant": nil,
"seat": {"seat-passed"}, "seat": {"seat-passed"},
@@ -68,34 +68,37 @@ func personsDecision(a HandAct) bool {
return len(causes) == 0 || slices.Contains(causes, a.Cause) return len(causes) == 0 || slices.Contains(causes, a.Cause)
} }
// ActRecorded is one act the seat mesh-hand-acts recorded, heard as act-recorded: counted beside the controller's // ActRecorded is one act the seat mesh-hand-acts recorded, heard as act-recorded or listed by its verb `hand-acts`:
// log's, under a key of its own log, so an act is counted once whichever log holds it (ADR 0565 §11). // kept under a key of its own log, so an act heard and listed is counted once (ADR 0565 §11).
func (h *Healers) ActRecorded(body []byte) { func (h *Healers) ActRecorded(body []byte) {
var a HandAct var a HandAct
if json.Unmarshal(body, &a) != nil || a.ID == "" { if json.Unmarshal(body, &a) != nil || a.ID == "" {
return return
} }
h.HandActHeard(handacts.LogSeat+":"+a.ID, "put", body)
}
// HandActHeard is one change of the hand-act log: kept or forgotten, and every cause counted again.
func (h *Healers) HandActHeard(key, op string, value []byte) {
h.mu.Lock() h.mu.Lock()
if h.acts == nil { if h.acts == nil {
h.acts = map[string]HandAct{} h.acts = map[string]HandAct{}
} }
if op != "put" { h.acts[handacts.LogSeat+":"+a.ID] = a
delete(h.acts, key)
} else {
var a HandAct
if json.Unmarshal(value, &a) == nil {
h.acts[key] = a
}
}
h.mu.Unlock() h.mu.Unlock()
h.Wanted() h.Wanted()
} }
// Seeded takes the acts of the seat's answer to `hand-acts` (its `acts` list) as heard, at the healers' start; it
// answers how many it took.
func (h *Healers) Seeded(answer []byte) (int, error) {
var l struct {
Acts []json.RawMessage `json:"acts"`
}
if err := json.Unmarshal(answer, &l); err != nil {
return 0, fmt.Errorf("the seat's hand-acts answer is not a list of acts: %w", err)
}
for _, raw := range l.Acts {
h.ActRecorded(raw)
}
return len(l.Acts), nil
}
// Wanted counts every cause repaired by hand within the fortnight and writes a finding for each counted twice or more, // Wanted counts every cause repaired by hand within the fortnight and writes a finding for each counted twice or more,
// deleting the rest; its wait `fortnight` expires when the oldest counted act of a wanted cause leaves the fortnight. // deleting the rest; its wait `fortnight` expires when the oldest counted act of a wanted cause leaves the fortnight.
func (h *Healers) Wanted() []Observation { func (h *Healers) Wanted() []Observation {
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-healers
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027 h1:a33dsavoaqllh40eVICbhxi6Kero1EGSjt1V+2fa2Hk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101085317-790d1c82d027/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+3 -3
View File
@@ -20,12 +20,12 @@
], ],
"invokes": [ "invokes": [
"mesh-grants.grants_assert", "mesh-grants.grants_assert",
"seat:mesh-hand-acts.hand-act" "seat:mesh-hand-acts.hand-act",
"seat:mesh-hand-acts.hand-acts"
], ],
"reads": [ "reads": [
"mesh-controller.lease", "mesh-controller.lease",
"mesh-conditions.conditions", "mesh-conditions.conditions"
"mesh-controller.hand-acts"
], ],
"state": [ "state": [
{ {
@@ -2,33 +2,33 @@ package main
// The self-check's heartbeat, read in this one place (novox/hq to-be 45 §4, S10). // The self-check's heartbeat, read in this one place (novox/hq to-be 45 §4, S10).
// //
// **The contract this reads, and every assumption it makes**, because the controller's side was built // **The contract this reads, and every assumption it makes**, because the self-check's side was built
// at the same time from the same design, which says only "every run ends with a heartbeat event // at the same time from the same design, which says only "every run ends with a heartbeat event
// carrying the run's id and counts": // carrying the run's id and counts":
// //
// - The event is the mesh-controller seat's own, `doctor-heartbeat`, consumed as // - The event is the seat mesh-self-check's `checked`, consumed as `mesh-self-check.checked` (subject
// `mesh-controller.doctor-heartbeat` (subject `mesh.seat.mesh-controller.event.doctor-heartbeat`). // `mesh.seat.mesh-self-check.event.checked`). The controller's `doctor-heartbeat` is no longer heard (novox/hq ADR
// 0564 clause 9, issue 774).
// - The body is one JSON object: `run` (the run's id), `at` (when the run ended, RFC 3339), // - The body is one JSON object: `run` (the run's id), `at` (when the run ended, RFC 3339),
// `interval-seconds` (how often doctor runs), and `counts` ({pass, fail, failed-to-run}). // `interval-seconds` (how often the check runs), and `counts` ({pass, fail, failed-to-run}).
// `finished`/`time` are read for `at`; `interval_seconds`, `interval` (seconds, or a duration such // `finished`/`time` are read for `at`; `interval_seconds`, `interval` (seconds, or a duration such
// as "5m") for the interval. // as "5m") for the interval.
// - Without a time, the heartbeat is taken as made when it arrived (said in the status as such). // - Without a time, the heartbeat is taken as made when it arrived (said in the status as such).
// Without an interval, the design's five minutes stand. // Without an interval, the design's five minutes stand.
// - The counts are kept for the status only. A run that found failures is still a heartbeat: the // - The counts are kept for the status only. A run that found failures is still a heartbeat: the
// watcher watches that the checker runs; what it finds is the controller's to raise. // watcher watches that the checker runs; what it finds is the checker's to raise.
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/health"
) )
// HeartbeatEvent is the event's local name under the controller's seat. // HeartbeatEvent is the event's local name under the seat mesh-self-check.
const ( const HeartbeatEvent = health.EventChecked
HeartbeatEvent = "doctor-heartbeat"
ControllerSeat = "mesh-controller"
)
// Beat is one heartbeat. // Beat is one heartbeat.
type Beat struct { type Beat struct {
@@ -38,8 +38,8 @@ type Beat struct {
Counts map[string]int Counts map[string]int
} }
// isHeartbeat says whether an envelope's key is the controller's heartbeat. // isHeartbeat says whether an envelope's key is the self-check's heartbeat.
func isHeartbeat(key string) bool { return key == ControllerSeat+"."+HeartbeatEvent } func isHeartbeat(key string) bool { return key == health.SelfCheckModule+"."+HeartbeatEvent }
// DecodeBeat reads one heartbeat's body. // DecodeBeat reads one heartbeat's body.
func DecodeBeat(body []byte) (Beat, error) { func DecodeBeat(body []byte) (Beat, error) {
+5 -10
View File
@@ -1,6 +1,6 @@
// mesh-watcher: the watcher's watcher (novox/hq to-be 45 §5, S10, ADR 0227 rule 6). A Go bundle the // mesh-watcher: the watcher's watcher (novox/hq to-be 45 §5, S10, ADR 0227 rule 6). A Go bundle the
// node's runtime launches on one machine that is not the control node. It hears the controller's // node's runtime launches on one machine that is not the control node. It hears the self-check's
// self-check heartbeat and makes a round trip through the bus every minute; when either goes silent // heartbeat and makes a round trip through the bus every minute; when either goes silent
// past its bound, it tells the operator on Telegram directly over HTTPS — the one sender that does not // past its bound, it tells the operator on Telegram directly over HTTPS — the one sender that does not
// pass through the control node — and tells them again when it returns. stdout is the MCP channel; // pass through the control node — and tells them again when it returns. stdout is the MCP channel;
// what this module says, it says on stderr. // what this module says, it says on stderr.
@@ -100,10 +100,8 @@ func run(w *Watcher, l *listening, node string) {
time.Sleep(10 * time.Minute) time.Sleep(10 * time.Minute)
} }
}() }()
follow := newTakeover()
go followTakeover(follow)
handle := func(e stdio.Envelope) error { handle := func(e stdio.Envelope) error {
if !follow.heard(e.Key) { if !isHeartbeat(e.Key) {
return nil return nil
} }
b, err := DecodeBeat(e.Body) b, err := DecodeBeat(e.Body)
@@ -116,10 +114,7 @@ func run(w *Watcher, l *listening, node string) {
} }
<-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: a heartbeat sent meanwhile is heard <-stdio.Served() // subscribed as soon as the runtime serves, never after a guessed wait: a heartbeat sent meanwhile is heard
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle) err := stdio.Subscribe(health.SelfCheckModule+".*", handle)
if err == nil {
err = stdio.Subscribe(health.SelfCheckModule+".*", handle)
}
if err == nil { if err == nil {
l.set("listening") l.set("listening")
logf("[mesh-watcher] listening for the self-check's heartbeat; watching from %s", node) logf("[mesh-watcher] listening for the self-check's heartbeat; watching from %s", node)
@@ -199,7 +194,7 @@ func tools(w *Watcher, l *listening) []stdio.Tool {
{Name: "watcher_status", {Name: "watcher_status",
Description: "The watcher's own health first — whether it can tell the operator anything (the Telegram token and " + Description: "The watcher's own health first — whether it can tell the operator anything (the Telegram token and " +
"chat id), whether it runs on the machine it watches, whether heartbeats reach it — then each watched " + "chat id), whether it runs on the machine it watches, whether heartbeats reach it — then each watched " +
"signal (the controller's self-check heartbeat, a round trip through the bus): last heard, how long ago, " + "signal (the self-check's heartbeat, a round trip through the bus): last heard, how long ago, " +
"its bound, whether it is said silent, and any message not sent yet.", "its bound, whether it is said silent, and any message not sent yet.",
Run: func(map[string]any) (any, error) { return w.Status(l.get()), nil }}, Run: func(map[string]any) (any, error) { return w.Status(l.get()), nil }},
{Name: "watcher_last_heard", {Name: "watcher_last_heard",
@@ -34,7 +34,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !reflect.DeepEqual(m.Consumes, []string{ControllerSeat + "." + HeartbeatEvent, "mesh-self-check.checked"}) { if !reflect.DeepEqual(m.Consumes, []string{"mesh-self-check.checked"}) {
t.Fatalf("consumes %v", m.Consumes) t.Fatalf("consumes %v", m.Consumes)
} }
if !reflect.DeepEqual(m.Invokes, []string{m.Module + ".watcher_ping"}) { if !reflect.DeepEqual(m.Invokes, []string{m.Module + ".watcher_ping"}) {
@@ -1,59 +0,0 @@
package main
import (
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/health"
)
// takeover is which self-check run the watcher hears: the controller's `doctor-heartbeat` until the keeper's
// take-over key names the keeper, then mesh-self-check's `checked`, whose body is the same run (novox/hq ADR 0564
// clause 9, issue 1952 W1). An absent key follows the controller.
type takeover struct {
mu sync.Mutex
t health.Takeover
}
func newTakeover() *takeover {
return &takeover{t: health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}}
}
// heard says whether key is the followed self-check event.
func (f *takeover) heard(key string) bool {
f.mu.Lock()
defer f.mu.Unlock()
seat, event := f.t.SelfCheckEvent()
return key == seat+"."+event
}
func (f *takeover) set(value []byte, found bool) error {
t, err := health.DecodeTakeover(value, found)
if err != nil {
return err
}
f.mu.Lock()
f.t = t
f.mu.Unlock()
return nil
}
// followTakeover watches the key for the life of the process; one it cannot read leaves the event it heard.
func followTakeover(f *takeover) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.State(health.KeeperModule+"."+health.TakeoverState).Watch(health.TakeoverKey, func(c stdio.StateChange) error {
if err := f.set(c.Value, c.Op != "delete" && len(c.Value) > 0); err != nil {
logf("[mesh-watcher] the take-over key is unreadable (%v); no change in what it hears", err)
} else {
logf("[mesh-watcher] the take-over key now follows %s", f.t.Follow)
}
return nil
})
if err == nil {
return
}
logf("[mesh-watcher] not watching the take-over key yet (%v)", err)
time.Sleep(wait)
}
}
@@ -7,7 +7,7 @@ package main
// //
// Two signals: // Two signals:
// //
// - self-check: the controller's `doctor` heartbeat. Bound: twice the interval the heartbeat says // - self-check: the self-check's heartbeat (mesh-self-check.checked). Bound: twice the interval the heartbeat says
// it runs at, else twice five minutes (to-be 45 §3, S10). Heard by the time the heartbeat says it // it runs at, else twice five minutes (to-be 45 §3, S10). Heard by the time the heartbeat says it
// was made, not by when it arrived: a backlog delivered after a restart is old news, not a sign of // was made, not by when it arrived: a backlog delivered after a restart is old news, not a sign of
// life. // life.
@@ -224,7 +224,7 @@ func (w *Watcher) Tick() {
func (w *Watcher) what(s *signal) string { func (w *Watcher) what(s *signal) string {
if s.name == SelfCheck { if s.name == SelfCheck {
return "the controller's self-check (doctor)" return "the self-check"
} }
return "the bus" return "the bus"
} }
@@ -253,7 +253,7 @@ func TestTheHeartbeatAsTheDesignSaysIt(t *testing.T) {
t.Errorf("%s: %v", body, err) t.Errorf("%s: %v", body, err)
} }
} }
if !isHeartbeat("mesh-controller.doctor-heartbeat") || isHeartbeat("mesh-controller.applied") { if !isHeartbeat("mesh-self-check.checked") || isHeartbeat("mesh-controller.doctor-heartbeat") {
t.Fatalf("the heartbeat's key") t.Fatalf("the heartbeat's key")
} }
} }
+1 -1
View File
@@ -2,7 +2,7 @@ module mesh-watcher
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
require ( require (
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
-2
View File
@@ -3,11 +3,9 @@
"version": "1", "version": "1",
"slug": "watch", "slug": "watch",
"consumes": [ "consumes": [
"mesh-controller.doctor-heartbeat",
"mesh-self-check.checked" "mesh-self-check.checked"
], ],
"reads": [ "reads": [
"mesh-conditions.takeover",
"mesh-delivery-assigner.seat-holders" "mesh-delivery-assigner.seat-holders"
], ],
"invokes": [ "invokes": [
+3 -3
View File
@@ -10,8 +10,8 @@ channel's client and no channel's secret.
a record each asker reads under its own name), and the two kinded benches its channels hold, `channel` a record each asker reads under its own name), and the two kinded benches its channels hold, `channel`
(`show`, `edit`, `send`, each under the holder's kind) and `intake` (`choice`, `link`, `failed`, `standing`, (`show`, `edit`, `send`, each under the holder's kind) and `intake` (`choice`, `link`, `failed`, `standing`,
and the proof `code`, request and reply only). Only `channel` and `intake` may be kinded. and the proof `code`, request and reply only). Only `channel` and `intake` may be kinded.
- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) and **consumes its - **Reads the condition keeper's open conditions** (`seat:mesh-conditions.conditions`) and **consumes its
condition events**; the events are read in one place, `cmd/messenger/condition.go`. condition events** (the controller's are no longer heard, issue 774); the events are read in one place, `cmd/messenger/condition.go`.
- **The channels are modules of their own**: `telegram` (kind `telegram`, reaches the operator away, proves - **The channels are modules of their own**: `telegram` (kind `telegram`, reaches the operator away, proves
its sender) and `desk-channel` (kind `desktop`, the machines' notifiers, proves nothing). Which kinds exist its sender) and `desk-channel` (kind `desktop`, the machines' notifiers, proves nothing). Which kinds exist
and what each promises is the controller's word, in this module's membership; whether each can send now and what each promises is the controller's word, in this module's membership; whether each can send now
@@ -94,7 +94,7 @@ handed over three hours of raised-and-cleared conditions at once, and each was s
what is said is decided by the state now and by when a thing happened, never by when it arrived: what is said is decided by the state now and by when a thing happened, never by when it arrived:
- **The state first.** On start, every 10 min, and at once when an old event arrives, the holder reads - **The state first.** On start, every 10 min, and at once when an old event arrives, the holder reads
what is open now from the controller (`mesh-controller.conditions`). An event older than that what is open now from the condition keeper (`mesh-conditions.conditions`). An event older than that
reading is already in it and is not acted on. What is open here and not there ended meanwhile: it is reading is already in it and is not acted on. What is open here and not there ended meanwhile: it is
forgotten quietly (Telegram's message is edited, which notifies nobody; the desktop is not woken). An forgotten quietly (Telegram's message is edited, which notifies nobody; the desktop is not woken). An
urgent condition open and never said is said — all of them in **one** message. A warning raised in urgent condition open and never said is said — all of them in **one** message. A warning raised in
@@ -19,7 +19,7 @@ func (b *conditionBed) controllerEvent(t *testing.T, event string, body any) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := b.r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": ControllerSeat + "." + event, if _, err := b.r.request("mesh/event", map[string]any{"envelope": map[string]any{"key": ConditionSeat + "." + event,
"node": "test", "body": json.RawMessage(raw)}}); err != nil { "node": "test", "body": json.RawMessage(raw)}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+3 -3
View File
@@ -479,8 +479,8 @@ func answerLevel(a asks.Ask) asks.Level {
return asks.Acknowledge return asks.Acknowledge
} }
// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which // ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which raised the
// raises the conditions. // conditions; the condition keeper (ConditionSeat), which raises them now, is one too.
const ConditionOwner = "mesh-controller" const ConditionOwner = "mesh-controller"
// TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus // TakeAsk takes one ask or cancel from the seat's queue. asker is the subject's last token, which the bus
@@ -651,7 +651,7 @@ func (h *Holder) recordFor(asker, key string, a asks.Ask) string {
defer h.mu.Unlock() defer h.mu.Unlock()
// Shown on a condition's message only when asked by the condition's owner: another asker naming a // Shown on a condition's message only when asked by the condition's owner: another asker naming a
// condition's key does not take its message over. // condition's key does not take its message over.
if a.About != "" && asker == ConditionOwner { if a.About != "" && (asker == ConditionOwner || asker == ConditionSeat) {
if r := h.open[a.About]; r != nil && r.Cleared.IsZero() && r.Origin == "condition" { if r := h.open[a.About]; r != nil && r.Cleared.IsZero() && r.Origin == "condition" {
return r.Key return r.Key
} }
+15 -17
View File
@@ -1,14 +1,14 @@
package main package main
// The controller's condition events, read in this one place (novox/hq to-be 45 §2). // The condition keeper's condition events, read in this one place (novox/hq to-be 45 §2, ADR 0564 clause 9).
// //
// **The contract this reads, and every assumption it makes about it**, because the controller's // **The contract this reads, and every assumption it makes about it**, because the keeper's
// side was built at the same time from the same design and the design names the events and the // side was built at the same time from the same design and the design names the events and the
// fields but not their spelling on the wire: // fields but not their spelling on the wire (the controller's own events are no longer heard, issue 774):
// //
// - The events are the mesh-controller seat's own: `condition-raised`, `condition-changed` and // - The events are the seat mesh-conditions': `condition-raised`, `condition-changed` and
// `condition-cleared`, consumed as `mesh-controller.<event>` (subject // `condition-cleared`, consumed as `mesh-conditions.<event>` (subject
// `mesh.seat.mesh-controller.event.<event>`), exactly as `applied` and `built-before` are. // `mesh.seat.mesh-conditions.event.<event>`).
// - The body is the condition as the store holds it, one JSON object, with the field names of the // - The body is the condition as the store holds it, one JSON object, with the field names of the
// design's table in kebab-case: key, kind, subject, severity, summary, evidence, source, raised, // design's table in kebab-case: key, kind, subject, severity, summary, evidence, source, raised,
// last-observed, observations, tried, resolver, silenced, epoch. snake_case and camelCase // last-observed, observations, tried, resolver, silenced, epoch. snake_case and camelCase
@@ -27,7 +27,7 @@ package main
// - Every event carries `at`, when the transition happened (the controller's conditions.Event). // - Every event carries `at`, when the transition happened (the controller's conditions.Event).
// It is how this holder tells news from history: a stream replays what it holds to a consumer // It is how this holder tells news from history: a stream replays what it holds to a consumer
// that is new or was away, and an event's delivery time says nothing about when it happened. // that is new or was away, and an event's delivery time says nothing about when it happened.
// - The `conditions` verb of the controller's seat answers `{conditions: [...], open, note}`, each // - The `conditions` verb of the keeper's seat answers `{conditions: [...], open, note}`, each
// entry the condition as the store holds it — the same shape as an event's body. // entry the condition as the store holds it — the same shape as an event's body.
// //
// An event this cannot read is refused by name — which event, which field, why — counted, said in // An event this cannot read is refused by name — which event, which field, why — counted, said in
@@ -40,15 +40,16 @@ import (
"time" "time"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/health"
) )
// The events, by their local names under the controller's seat. // The events, by their local names under the keeper's seat.
const ( const (
EventRaised = "condition-raised" EventRaised = "condition-raised"
EventChanged = "condition-changed" EventChanged = "condition-changed"
EventCleared = "condition-cleared" EventCleared = "condition-cleared"
// ControllerSeat is the role the events are stated under. // ConditionSeat is the role the events are stated under, and the verb `conditions` is asked of.
ControllerSeat = "mesh-controller" ConditionSeat = health.KeeperModule
) )
// Severities: two levels, no more (to-be 45 §2). // Severities: two levels, no more (to-be 45 §2).
@@ -120,14 +121,11 @@ func (c Condition) SilencedAt(now time.Time) bool {
return !c.SilencedTill.IsZero() && now.Before(c.SilencedTill) return !c.SilencedTill.IsZero() && now.Before(c.SilencedTill)
} }
// eventOf is the local event name of an envelope's key: `mesh-controller.condition-raised` is // eventOf is the local event name of an envelope's key: `mesh-conditions.condition-raised` is
// `condition-raised`. Anything not of the controller's seat is not a condition event. // `condition-raised`. Anything not of the keeper's seat is not a condition event.
func eventOf(key string) (string, bool) { return eventOfSeat(key, ControllerSeat) } func eventOf(key string) (string, bool) {
// eventOfSeat is eventOf for the seat the take-over key says is followed: the controller's, or the keeper's (ADR 0564 clause 9).
func eventOfSeat(key, seat string) (string, bool) {
emitter, event, ok := strings.Cut(key, ".") emitter, event, ok := strings.Cut(key, ".")
if !ok || emitter != seat { if !ok || emitter != ConditionSeat {
return "", false return "", false
} }
switch event { switch event {
@@ -6,19 +6,19 @@ import (
"time" "time"
) )
// The contract with the controller's condition events (to-be 45 §2), as condition.go states it. // The contract with the condition keeper's condition events (to-be 45 §2), as condition.go states it.
func TestTheEventsAreTheControllersSeat(t *testing.T) { func TestTheEventsAreTheKeepersSeat(t *testing.T) {
for key, want := range map[string]string{ for key, want := range map[string]string{
"mesh-controller.condition-raised": EventRaised, "mesh-conditions.condition-raised": EventRaised,
"mesh-controller.condition-changed": EventChanged, "mesh-conditions.condition-changed": EventChanged,
"mesh-controller.condition-cleared": EventCleared, "mesh-conditions.condition-cleared": EventCleared,
} { } {
if got, ok := eventOf(key); !ok || got != want { if got, ok := eventOf(key); !ok || got != want {
t.Errorf("%s: %q %v", key, got, ok) t.Errorf("%s: %q %v", key, got, ok)
} }
} }
for _, key := range []string{"mesh-controller.applied", "gitea.condition-raised", "condition-raised"} { for _, key := range []string{"mesh-controller.condition-raised", "mesh-conditions.applied", "gitea.condition-raised", "condition-raised"} {
if _, ok := eventOf(key); ok { if _, ok := eventOf(key); ok {
t.Errorf("%s read as a condition event", key) t.Errorf("%s read as a condition event", key)
} }
@@ -86,7 +86,7 @@ func openAt(t *testing.T, backlog []backlogEvent, at time.Time) []Condition {
func replay(t *testing.T, h *Holder, backlog []backlogEvent, skip func(Condition, string) bool) { func replay(t *testing.T, h *Holder, backlog []backlogEvent, skip func(Condition, string) bool) {
t.Helper() t.Helper()
for _, e := range backlog { for _, e := range backlog {
event, ok := eventOf(ControllerSeat + "." + e.event) event, ok := eventOf(ConditionSeat + "." + e.event)
if !ok { if !ok {
t.Fatalf("%s is not a condition event", e.event) t.Fatalf("%s is not a condition event", e.event)
} }
+11 -18
View File
@@ -18,7 +18,6 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go" stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/health"
) )
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) } func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
@@ -413,12 +412,11 @@ func run(h *Holder, l *listening) {
logf("[messenger] %s cannot send: %v", ch.Name(), err) logf("[messenger] %s cannot send: %v", ch.Name(), err)
} }
} }
// The state first: what is open now is read from the controller before any event is taken, so // The state first: what is open now is read from the keeper before any event is taken, so
// what the stream replays is history (novox/hq issue 271). Not reachable yet, it is asked for // what the stream replays is history (novox/hq issue 271). Not reachable yet, it is asked for
// again in the loop below, and old events meanwhile are kept as state, never said. // again in the loop below, and old events meanwhile are kept as state, never said.
follow := newTakeover()
h.Start() h.Start()
syncFromController(h, follow) syncFromKeeper(h)
go func() { go func() {
tick := time.NewTicker(5 * time.Second) tick := time.NewTicker(5 * time.Second)
defer tick.Stop() defer tick.Stop()
@@ -431,11 +429,10 @@ func run(h *Holder, l *listening) {
} }
if h.WantsSync() && now.Sub(lastAsk) >= time.Minute { if h.WantsSync() && now.Sub(lastAsk) >= time.Minute {
lastAsk = now lastAsk = now
syncFromController(h, follow) syncFromKeeper(h)
} }
} }
}() }()
go followTakeover(follow, h)
handle := func(e stdio.Envelope) error { handle := func(e stdio.Envelope) error {
if event, ok := strings.CutPrefix(e.Key, ViewModule+"."); ok { if event, ok := strings.CutPrefix(e.Key, ViewModule+"."); ok {
// A module in motion on a node, settled or stuck: a walk's start or end now that plans are gone (ADR 0308 §1, ADR 0539 §17). // A module in motion on a node, settled or stuck: a walk's start or end now that plans are gone (ADR 0308 §1, ADR 0539 §17).
@@ -449,9 +446,8 @@ func run(h *Holder, l *listening) {
} }
return nil return nil
} }
// Only the seat the take-over key names is heard: the other's events are dropped, so the keeper's conditions // Only the keeper's condition events are heard (ADR 0564 clause 9; the controller's are no longer consumed).
// are never held a second time beside the controller's (ADR 0564 clause 9). event, ok := eventOf(e.Key)
event, ok := eventOfSeat(e.Key, follow.seat())
if !ok { if !ok {
return nil return nil
} }
@@ -467,7 +463,7 @@ func run(h *Holder, l *listening) {
go takeAsks(h) go takeAsks(h)
go answerProofs(h) go answerProofs(h)
go followKinds(h) go followKinds(h)
// The delivery view's events say what is in motion (ADR 0539 §17), heard beside the controller's condition events. // The delivery view's events say what is in motion (ADR 0539 §17), heard beside the keeper's condition events.
go func() { go func() {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.Subscribe(ViewModule+".*", handle); err == nil { if err := stdio.Subscribe(ViewModule+".*", handle); err == nil {
@@ -480,13 +476,10 @@ func run(h *Holder, l *listening) {
} }
}() }()
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle) err := stdio.Subscribe(ConditionSeat+".*", handle)
if err == nil {
err = stdio.Subscribe(health.KeeperModule+".*", handle)
}
if err == nil { if err == nil {
l.set("listening") l.set("listening")
logf("[messenger] listening for the controller's condition events") logf("[messenger] listening for the condition keeper's condition events")
return return
} }
l.set("not yet: " + err.Error()) l.set("not yet: " + err.Error())
@@ -632,8 +625,8 @@ func followKinds(h *Holder) {
} }
} }
// syncFromController reads the controller's open conditions and gives them to the holder. // syncFromKeeper reads the condition keeper's open conditions and gives them to the holder.
func syncFromController(h *Holder, follow *takeover) { func syncFromKeeper(h *Holder) {
asked := h.Now() asked := h.Now()
type answer struct { type answer struct {
raw json.RawMessage raw json.RawMessage
@@ -641,7 +634,7 @@ func syncFromController(h *Holder, follow *takeover) {
} }
done := make(chan answer, 1) done := make(chan answer, 1)
go func() { go func() {
raw, err := stdio.Ask("seat:"+follow.seat()+".conditions", map[string]any{}) raw, err := stdio.Ask("seat:"+ConditionSeat+".conditions", map[string]any{})
done <- answer{raw, err} done <- answer{raw, err}
}() }()
var a answer var a answer
@@ -80,14 +80,21 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
t.Fatalf("uses %v", m.Uses) t.Fatalf("uses %v", m.Uses)
} }
if !reflect.DeepEqual(m.Consumes, []string{ if !reflect.DeepEqual(m.Consumes, []string{
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
"mesh-conditions." + EventRaised, "mesh-conditions." + EventChanged, "mesh-conditions." + EventCleared, "mesh-conditions." + EventRaised, "mesh-conditions." + EventChanged, "mesh-conditions." + EventCleared,
ViewModule + "." + EventMoving, ViewModule + "." + EventSettled, ViewModule + "." + EventStuck, "intake.choice", "intake.link", "intake.failed", "intake.standing"}) { ViewModule + "." + EventMoving, ViewModule + "." + EventSettled, ViewModule + "." + EventStuck, "intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
t.Fatalf("consumes %v", m.Consumes) t.Fatalf("consumes %v", m.Consumes)
} }
// It consumes no mesh-controller event and invokes no mesh-controller verb (774/S12/AC2).
for _, list := range [][]string{m.Consumes, m.Invokes} {
for _, x := range list {
if strings.Contains(x, "mesh-controller") {
t.Errorf("%s names the controller", x)
}
}
}
// It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues // It performs nothing: the verbs it calls read the open conditions, whether root is free, and which issues
// track which conditions. // track which conditions.
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions", if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{
"seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) { "seat:mesh-conditions.conditions", "seat:" + EngineSeat + ".root-free", TrackingVerb}) {
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes) t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
} }
+2 -2
View File
@@ -35,7 +35,7 @@ var previewable = []string{"raised", "reminder", "quiet-reminder", "escalated",
func (h *Holder) Preview(key, what string) (map[string]any, error) { func (h *Holder) Preview(key, what string) (map[string]any, error) {
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
if key == "" { if key == "" {
return nil, errorf("key is required: the condition's key, as the controller names it — `mesh-controller.conditions` says which are open") return nil, errorf("key is required: the condition's key, as the keeper names it — `mesh-conditions.conditions` says which are open")
} }
what = strings.TrimSpace(what) what = strings.TrimSpace(what)
if what == "" { if what == "" {
@@ -75,7 +75,7 @@ func (h *Holder) Preview(key, what string) (map[string]any, error) {
return nil, errorf("%s is not a condition but this router's own %s record, which is said as part of an ask, never on its own; it holds the conditions %s", return nil, errorf("%s is not a condition but this router's own %s record, which is said as part of an ask, never on its own; it holds the conditions %s",
key, r.Origin, strings.Join(conditions, ", ")) key, r.Origin, strings.Join(conditions, ", "))
case len(conditions) == 0: case len(conditions) == 0:
return nil, errorf("no condition %s is open here, and this router holds none: `mesh-controller.conditions` says what the controller holds open", key) return nil, errorf("no condition %s is open here, and this router holds none: `mesh-conditions.conditions` says what the keeper holds open", key)
default: default:
return nil, errorf("no condition %s is open here; it holds %s", key, strings.Join(conditions, ", ")) return nil, errorf("no condition %s is open here; it holds %s", key, strings.Join(conditions, ", "))
} }
@@ -195,8 +195,9 @@ func TestAPreviewOfWhatThisRouterDoesNotHoldIsRefusedByName(t *testing.T) {
t.Errorf("the refusal says neither what was asked nor what is held: %v", err) t.Errorf("the refusal says neither what was asked nor what is held: %v", err)
} }
if _, err := h.Preview("", ""); err == nil || !strings.Contains(err.Error(), "key is required") { if _, err := h.Preview("", ""); err == nil || !strings.Contains(err.Error(), "key is required") ||
t.Errorf("no key: %v", err) !strings.Contains(err.Error(), "mesh-conditions.conditions") || strings.Contains(err.Error(), "mesh-controller") {
t.Errorf("no key: %v (it says which conditions are open by the keeper's verb, never the controller's; 774/S12/AC1)", err)
} }
if _, err := h.Preview(k.Key, "shouted"); err == nil || !strings.Contains(err.Error(), "shouted") { if _, err := h.Preview(k.Key, "shouted"); err == nil || !strings.Contains(err.Error(), "shouted") {
t.Errorf("an unknown message: %v", err) t.Errorf("an unknown message: %v", err)
-8
View File
@@ -180,11 +180,3 @@ func (h *Holder) WantsSync() bool {
h.init() h.init()
return h.wantSync || h.syncedAt.IsZero() || h.Now().Sub(h.syncedAt) >= ResyncEvery return h.wantSync || h.syncedAt.IsZero() || h.Now().Sub(h.syncedAt) >= ResyncEvery
} }
// AskSync has the next pass of the loop read the open conditions again (the take-over key named another seat).
func (h *Holder) AskSync() {
h.mu.Lock()
h.init()
h.wantSync = true
h.mu.Unlock()
}
@@ -1,59 +0,0 @@
package main
import (
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/health"
)
// takeover is which seat's condition events the messenger hears: the controller's until the keeper's take-over key
// says otherwise (novox/hq ADR 0564 clause 9, issue 1952 R1). An absent key follows the controller.
type takeover struct {
mu sync.Mutex
t health.Takeover
}
func newTakeover() *takeover {
return &takeover{t: health.Takeover{V: health.TakeoverVersion, Follow: health.FollowController}}
}
func (f *takeover) seat() string { f.mu.Lock(); defer f.mu.Unlock(); return f.t.Seat() }
// set takes the key as a watch handed it and says whether the followed seat changed.
func (f *takeover) set(value []byte, found bool) (changed bool, err error) {
t, err := health.DecodeTakeover(value, found)
if err != nil {
return false, err
}
f.mu.Lock()
defer f.mu.Unlock()
changed = f.t.Seat() != t.Seat()
f.t = t
return changed, nil
}
// followTakeover watches the take-over key for the life of the process, and asks the holder to read the followed
// seat's open conditions again when it changes. A key it cannot read leaves the seat it followed.
func followTakeover(f *takeover, h *Holder) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.State(health.KeeperModule+"."+health.TakeoverState).Watch(health.TakeoverKey, func(c stdio.StateChange) error {
changed, err := f.set(c.Value, c.Op != "delete" && len(c.Value) > 0)
if err != nil {
logf("[messenger] the take-over key is unreadable (%v); still hearing %s", err, f.seat())
return nil
}
if changed {
logf("[messenger] the take-over key now names %s", f.seat())
h.AskSync()
}
return nil
})
if err == nil {
return
}
logf("[messenger] not watching the take-over key yet (%v); hearing %s", err, f.seat())
time.Sleep(wait)
}
}
@@ -98,7 +98,7 @@ var examples = []example{{
explanation: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, " + explanation: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, " +
"mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh " + "mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh " +
"does not repair them.", "does not repair them.",
actions: []Action{{Label: "Silence for a week", Verb: "mesh-controller.conditions", actions: []Action{{Label: "Silence for a week", Verb: "mesh-conditions.conditions",
Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}}}, Arguments: map[string]string{"silence": "machine.shanks.units", "for": "7d", "why": "", "cause": "operator-answer"}}},
resolved: "No failed services on shanks any more", popup: true, resolved: "No failed services on shanks any more", popup: true,
title: "3 failed services on shanks", title: "3 failed services on shanks",
+1 -1
View File
@@ -3,7 +3,7 @@ module messenger
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-7
View File
@@ -74,9 +74,6 @@
"channel" "channel"
], ],
"consumes": [ "consumes": [
"mesh-controller.condition-raised",
"mesh-controller.condition-changed",
"mesh-controller.condition-cleared",
"mesh-conditions.condition-raised", "mesh-conditions.condition-raised",
"mesh-conditions.condition-changed", "mesh-conditions.condition-changed",
"mesh-conditions.condition-cleared", "mesh-conditions.condition-cleared",
@@ -92,14 +89,10 @@
"refused" "refused"
], ],
"invokes": [ "invokes": [
"seat:mesh-controller.conditions",
"seat:mesh-conditions.conditions", "seat:mesh-conditions.conditions",
"seat:node-engine.root-free", "seat:node-engine.root-free",
"seat:issue-tracker.tracking" "seat:issue-tracker.tracking"
], ],
"reads": [
"mesh-conditions.takeover"
],
"state": [ "state": [
"open", "open",
"sent", "sent",
+158
View File
@@ -0,0 +1,158 @@
package main
// The tool of this module that sets and restores a key-value bucket's expiry (novox/hq ADR 0582, "How it is
// checked": the bus module's expiry tool sets and restores a bucket's expiry and records the act; issue 774). The
// grants part calls it at the lease write, to take the 15 seconds off the lease bucket, and at the hand-back, to put
// them back. Like the removal tools it refuses without `why`, reads what it changes first, reads it back after, and
// says what it did as an event of the module; it also records the act through the seat mesh-hand-acts (ADR 0565
// clause 11), because it changes what the mesh keeps.
import (
"context"
"errors"
"fmt"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/handacts"
"github.com/nats-io/nats.go/jetstream"
)
const bucketExpirySet = "bucket-expiry-set"
// kvPrefix is the name a bucket's stream carries.
const kvPrefix = "KV_"
// Recorded records one hand act through the seat mesh-hand-acts.
type Recorded func(handacts.Request) error
// RecordedBySeat records an act through the runtime, to the seat mesh-hand-acts alone.
func RecordedBySeat(q handacts.Request) error {
_, err := handacts.Recorder{Ask: stdio.Ask}.Record(q)
return err
}
// BucketExpirySet is what nats_bucket_expiry answers and says.
type BucketExpirySet struct {
Bucket string `json:"bucket"`
Was string `json:"was"`
Now string `json:"now"`
Why string `json:"why"`
Warrant string `json:"warrant,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
Changed bool `json:"changed"`
Said string `json:"said"`
At time.Time `json:"at"`
}
// expiryText names an expiry: a duration, or "none".
func expiryText(d time.Duration) string {
if d == 0 {
return "none"
}
return d.String()
}
// parseExpiry reads `max_age`: "none" removes the expiry, otherwise a positive duration such as 15s.
func parseExpiry(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "none" {
return 0, nil
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return 0, fmt.Errorf("max_age is required: a positive duration such as 15s, or none to remove the expiry (got %q): nothing was changed", s)
}
return d, nil
}
// SetBucketExpiry sets one key-value bucket's expiry (MaxAge of its stream) after reading it, reads it back, records
// the act and says so. A bucket already at that expiry is answered as such and records nothing: asking again is safe.
// A name that is no key-value bucket is refused and nothing changes.
func SetBucketExpiry(ctx context.Context, bus Bus, said Said, record Recorded, a map[string]any) (any, error) {
w, err := why(a)
if err != nil {
return nil, err
}
bucket := arg(a, "bucket")
if !name.MatchString(bucket) {
return nil, errors.New("bucket is required, one bucket's name as nats_buckets lists it (letters, digits, - and _): nothing was changed")
}
maxAge, err := parseExpiry(arg(a, "max_age"))
if err != nil {
return nil, err
}
dry, _ := a["dry_run"].(bool)
js, closeBus, err := bus(ctx)
if err != nil {
return nil, err
}
defer closeBus()
stream := kvPrefix + bucket
out := BucketExpirySet{Bucket: bucket, Why: w, Warrant: strings.TrimSpace(arg(a, "warrant")), DryRun: dry, At: time.Now().UTC()}
sctx, cancel := step(ctx)
s, err := js.Stream(sctx, stream)
cancel()
if errors.Is(err, jetstream.ErrStreamNotFound) {
return nil, fmt.Errorf("%s is no key-value bucket (there is no stream %s): nothing was changed", bucket, stream)
}
if err != nil {
return nil, unanswered(err, fmt.Sprintf("the bucket %s could not be read, so its expiry was not changed", bucket))
}
cfg := s.CachedInfo().Config
out.Was, out.Now = expiryText(cfg.MaxAge), expiryText(maxAge)
if cfg.MaxAge == maxAge {
out.Said = fmt.Sprintf("%s already expires after %s: nothing to change", bucket, out.Now)
return out, nil
}
if dry {
out.Said = fmt.Sprintf("%s expires after %s; it would be set to %s", bucket, out.Was, out.Now)
return out, nil
}
cfg.MaxAge = maxAge
// A stream refuses a window of duplicates longer than its expiry; a bucket made with 15 s carries 15 s.
if maxAge > 0 && (cfg.Duplicates == 0 || cfg.Duplicates > maxAge) {
cfg.Duplicates = maxAge
}
sctx, cancel = step(ctx)
_, err = js.UpdateStream(sctx, cfg)
cancel()
if err != nil {
return nil, unanswered(err, fmt.Sprintf("the expiry of %s was not changed", bucket))
}
sctx, cancel = step(ctx)
s, err = js.Stream(sctx, stream)
cancel()
if err != nil {
return nil, unanswered(err, fmt.Sprintf("the new expiry of %s was accepted but could not be read back", bucket))
}
if got := s.CachedInfo().Config.MaxAge; got != maxAge {
return nil, fmt.Errorf("%s reads back with an expiry of %s after the change to %s was accepted", bucket, expiryText(got), out.Now)
}
out.Changed = true
out.Said = fmt.Sprintf("%s expiry set from %s to %s", bucket, out.Was, out.Now)
what := fmt.Sprintf("set the expiry of the bucket %s from %s to %s", bucket, out.Was, out.Now)
if out.Warrant != "" {
what += " (warrant " + out.Warrant + ")"
}
if err := record(handacts.Request{What: what, Why: w, Cause: "bucket-expiry"}); err != nil {
return nil, fmt.Errorf("the expiry of %s WAS changed (%s) but the hand act could not be recorded: %w", bucket, what, err)
}
if err := said(bucketExpirySet, out); err != nil {
return nil, fmt.Errorf("the expiry of %s WAS changed (%s) but the audit event could not be said: %w", bucket, w, err)
}
return out, nil
}
// expiryTools is the module's tool that sets a bucket's expiry.
func expiryTools(bus Bus, said Said, record Recorded) []stdio.Tool {
return []stdio.Tool{{Name: "nats_bucket_expiry",
Description: "Sets or restores the expiry of one key-value bucket: `max_age` is a duration such as 15s, or `none` to keep keys with no expiry (e.g. the lease bucket of mesh-controller at the lease write, back to 15s at the hand-back; novox/hq ADR 0582). Needs `why`, kept in the audit trail and recorded as a hand act through mesh-hand-acts (`warrant` names the operator's ask when there is one); refuses a name that is no key-value bucket; reads the expiry first and back after, and a bucket already at that expiry is answered as such. `dry_run` only reads. The bus refuses any bucket the module's credential is not granted.",
Input: map[string]any{"bucket": str("the bucket as nats_buckets lists it, e.g. mesh-controller_lease"), "max_age": str("a duration such as 15s, or none to remove the expiry"),
"why": str("why it is changed, for the audit trail (required)"), "warrant": str("the operator's ask this follows, when there is one"),
"dry_run": map[string]any{"type": "boolean", "description": "read the expiry, change nothing"}},
Run: func(a map[string]any) (any, error) {
return SetBucketExpiry(context.Background(), bus, said, record, a)
}}}
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/handacts"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// A stand-in bus: the upstream server the mesh runs, in this process, with a bucket made as the controller makes its
// lease bucket (15 seconds) and a plain stream that is no bucket.
func expiryBus(t *testing.T) (Bus, jetstream.JetStream) {
t.Helper()
s, err := server.NewServer(&server.Options{Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(5 * time.Second) {
t.Fatal("the server did not start")
}
t.Cleanup(s.Shutdown)
nc, err := nats.Connect(s.ClientURL())
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, _ := jetstream.New(nc)
ctx := context.Background()
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-controller_lease", TTL: 15 * time.Second}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ASSIGNMENTS", Subjects: []string{"a.>"}}); err != nil {
t.Fatal(err)
}
return func(context.Context) (jetstream.JetStream, func(), error) { return js, func() {}, nil }, js
}
func maxAge(t *testing.T, js jetstream.JetStream, stream string) time.Duration {
t.Helper()
s, err := js.Stream(context.Background(), stream)
if err != nil {
t.Fatal(err)
}
return s.CachedInfo().Config.MaxAge
}
// 774/S6/AC1: a bucket with a 15 s expiry set to none reads back with none, one hand act and bucket-expiry-set;
// restored to 15 s it reads back with 15 s again; asking for what it already has records nothing.
func TestABucketsExpiryIsSetAndRestoredAndRecorded(t *testing.T) {
bus, js := expiryBus(t)
var acts []handacts.Request
var events []string
record := func(q handacts.Request) error { acts = append(acts, q); return nil }
said := func(e string, _ any) error { events = append(events, e); return nil }
call := func(max string, extra map[string]any) (BucketExpirySet, error) {
a := map[string]any{"bucket": "mesh-controller_lease", "max_age": max, "why": "the lease write", "warrant": "ask-1"}
for k, v := range extra {
a[k] = v
}
out, err := SetBucketExpiry(context.Background(), bus, said, record, a)
if err != nil {
return BucketExpirySet{}, err
}
return out.(BucketExpirySet), nil
}
if out, err := call("none", map[string]any{"dry_run": true}); err != nil || out.Changed || maxAge(t, js, "KV_mesh-controller_lease") != 15*time.Second {
t.Fatalf("a dry run changed the bucket or failed: %+v %v", out, err)
}
out, err := call("none", nil)
if err != nil || !out.Changed || out.Was != "15s" || out.Now != "none" || maxAge(t, js, "KV_mesh-controller_lease") != 0 {
t.Fatalf("none: %+v %v", out, err)
}
if len(acts) != 1 || !strings.Contains(acts[0].What, "mesh-controller_lease from 15s to none") || !strings.Contains(acts[0].What, "ask-1") || acts[0].Why != "the lease write" {
t.Errorf("the hand act is %+v", acts)
}
if len(events) != 1 || events[0] != "bucket-expiry-set" {
t.Errorf("events are %v", events)
}
if out, err = call("none", nil); err != nil || out.Changed || len(acts) != 1 || len(events) != 1 {
t.Errorf("asking again for none changed or recorded something: %+v %v acts %d events %d", out, err, len(acts), len(events))
}
if out, err = call("15s", nil); err != nil || !out.Changed || maxAge(t, js, "KV_mesh-controller_lease") != 15*time.Second || len(acts) != 2 {
t.Errorf("restore: %+v %v", out, err)
}
// A hand act the seat does not take is said loudly, not as success.
_, err = SetBucketExpiry(context.Background(), bus, said, func(handacts.Request) error { return errors.New("no responders") }, map[string]any{"bucket": "mesh-controller_lease", "max_age": "none", "why": "x"})
if err == nil || !strings.Contains(err.Error(), "WAS changed") {
t.Errorf("an unrecorded act was answered %v", err)
}
}
// 774/S6/AC2: no why, a stream that is no bucket, and a bad expiry change nothing and say why.
func TestABucketExpiryRefusesWithoutWhyOrABucket(t *testing.T) {
bus, js := expiryBus(t)
record := func(handacts.Request) error { t.Fatal("an act was recorded for a refused call"); return nil }
said := func(string, any) error { t.Fatal("an event was said for a refused call"); return nil }
for _, c := range []struct {
args map[string]any
want string
}{
{map[string]any{"bucket": "mesh-controller_lease", "max_age": "none"}, "why is required"},
{map[string]any{"bucket": "mesh-controller_lease", "max_age": "none", "why": " "}, "why is required"},
{map[string]any{"bucket": "ASSIGNMENTS", "max_age": "none", "why": "x"}, "no key-value bucket"},
{map[string]any{"bucket": "nothing", "max_age": "none", "why": "x"}, "no key-value bucket"},
{map[string]any{"bucket": "a.b", "max_age": "none", "why": "x"}, "bucket is required"},
{map[string]any{"bucket": "mesh-controller_lease", "max_age": "", "why": "x"}, "max_age is required"},
{map[string]any{"bucket": "mesh-controller_lease", "max_age": "-5s", "why": "x"}, "max_age is required"},
} {
if _, err := SetBucketExpiry(context.Background(), bus, said, record, c.args); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%v: %v, want a refusal saying %q", c.args, err, c.want)
}
}
if got := maxAge(t, js, "KV_mesh-controller_lease"); got != 15*time.Second {
t.Errorf("a refused call left the expiry at %s", got)
}
}
+1 -1
View File
@@ -152,7 +152,7 @@ func tools(monitor Monitor, users func(context.Context) ([]byte, error), written
} }
func main() { func main() {
if err := stdio.Serve("", append(tools(LiveMonitor(), ReadUsers, UsersWritten), removalTools(LiveBus(), SaidByTheRuntime)...)); err != nil { if err := stdio.Serve("", append(append(tools(LiveMonitor(), ReadUsers, UsersWritten), removalTools(LiveBus(), SaidByTheRuntime)...), expiryTools(LiveBus(), SaidByTheRuntime, RecordedBySeat)...)); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
+2 -2
View File
@@ -112,12 +112,12 @@ func TestTheBussManifestMountsItsProgramsIntoTheUpstreamImage(t *testing.T) {
} }
// The tools that change the bus (issue 1952 N1-N3): listed, their audit events declared, and the credential // The tools that change the bus (issue 1952 N1-N3): listed, their audit events declared, and the credential
// they dial with the module's own secret `broker`. // they dial with the module's own secret `broker`.
for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove", "nats_stream_remove"} { for _, want := range []string{"nats_consumer_remove", "nats_bucket_remove", "nats_stream_remove", "nats_bucket_expiry"} {
if !slices.Contains(m.Tools, want) { if !slices.Contains(m.Tools, want) {
t.Errorf("tools do not list %s", want) t.Errorf("tools do not list %s", want)
} }
} }
for _, want := range []string{"consumer-removed", "bucket-keys-removed", "stream-removed"} { for _, want := range []string{"consumer-removed", "bucket-keys-removed", "stream-removed", "bucket-expiry-set"} {
if !slices.Contains(m.Emits, want) { if !slices.Contains(m.Emits, want) {
t.Errorf("emits do not list %s: the removal tools say it as the audit line", want) t.Errorf("emits do not list %s: the removal tools say it as the audit line", want)
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module nats-tools
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
github.com/klauspost/compress v1.20.0 github.com/klauspost/compress v1.20.0
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915 h1:x8k7IeMirMTxUo8BH+xUO+MDnHYgU+636jl1NIw1wvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101022206-8af6e7266915/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+7 -2
View File
@@ -31,7 +31,11 @@
"emits": [ "emits": [
"consumer-removed", "consumer-removed",
"bucket-keys-removed", "bucket-keys-removed",
"stream-removed" "stream-removed",
"bucket-expiry-set"
],
"invokes": [
"seat:mesh-hand-acts.hand-act"
], ],
"consumes": [], "consumes": [],
"listens": [ "listens": [
@@ -56,7 +60,8 @@
"nats_snapshot_verify", "nats_snapshot_verify",
"nats_consumer_remove", "nats_consumer_remove",
"nats_bucket_remove", "nats_bucket_remove",
"nats_stream_remove" "nats_stream_remove",
"nats_bucket_expiry"
], ],
"guards": [ "guards": [
8222 8222
+1 -1
View File
@@ -35,7 +35,7 @@ that seat (ADR 0207).
`package.changed`. A list over the bus's cap on a value is not written: the key keeps its last value and `package.changed`. A list over the bus's cap on a value is not written: the key keeps its last value and
`<node>.not-current` says the size and since when, which the health says too. `pacman_packages`, `<node>.not-current` says the size and since when, which the health says too. `pacman_packages`,
`pacman_updates`, `pacman_installed` and `pacman_foreign` answer every node from these states, and what no `pacman_updates`, `pacman_installed` and `pacman_foreign` answer every node from these states, and what no
module declares from the node-engine's `packages` (read as `mesh-host.packages`, the engine's manifest's name), else the controller's `declared-packages` until its lease is written, without asking a machine. module declares from the node-engine's `packages` (read as `mesh-host.packages`, the engine's manifest's name), without asking a machine; a node whose engine has written no key is named in the answer (`declared_none`) and the controller is not read (ADR 0565 clause 6).
- **Upgrades as runs, one node at a time (ADR 0508).** Every full upgrade and every rollback is a run in the - **Upgrades as runs, one node at a time (ADR 0508).** Every full upgrade and every rollback is a run in the
state `runs` at `<node>.<run>`, written at each step and emitted as it: the package record taken and read back state `runs` at `<node>.<run>`, written at each step and emitted as it: the package record taken and read back
(`records`, kept `record-days`), the DKMS gate before the transaction (a kernel an installed DKMS module's own (`records`, kept `record-days`), the DKMS gate before the transaction (a kernel an installed DKMS module's own
@@ -135,7 +135,7 @@ func TestTheKeeperDeclaresTheModulesStatesAndEvents(t *testing.T) {
} }
// The verbs read which packages each node's modules declare; the process reads no other module's state // The verbs read which packages each node's modules declare; the process reads no other module's state
// (ADR 0507 §5, ADR 0436 §1). // (ADR 0507 §5, ADR 0436 §1).
if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{delivery.EnginePackagesRead, "mesh-controller.declared-packages"}) { if got := strs(raw["reads"]); !reflect.DeepEqual(got, []string{delivery.EnginePackagesRead}) {
t.Errorf("reads: %v", got) t.Errorf("reads: %v", got)
} }
} }
+44 -59
View File
@@ -20,18 +20,13 @@ import (
// through the manifest's `reads`. // through the manifest's `reads`.
const DeclaredPackagesState = SourceEngine const DeclaredPackagesState = SourceEngine
// ControllerDeclaredState is the controller's mirror of the same packages (ADR 0507 §5), the per-node fallback for a node // SourceEngine is the one source a node's declaration is read from, said in the answer: the node-engine's state, read by
// whose engine has written no key yet, until the controller's lease is written away (ADR 0565 clause 6). // its manifest's name (mesh-host.packages, its bucket mesh-host_packages; mesh-sdk delivery.EnginePackagesRead). The
const ControllerDeclaredState = "mesh-controller.declared-packages" // controller's mirror of the same packages ended with its lease (ADR 0565 clause 6, issue 774): a node whose engine has
// written no key has no declaration here, and the answer says so.
const SourceEngine = delivery.EnginePackagesRead
// The sources a node's declaration is read from, said in the answer: the node-engine's state, read by its manifest's // declaredValue is a node's key of `packages`, in mesh-sdk's layout.
// name (mesh-host.packages, its bucket mesh-host_packages; mesh-sdk delivery.EnginePackagesRead), and the mirror.
const (
SourceEngine = delivery.EnginePackagesRead
SourceController = "mesh-controller.declared-packages"
)
// declaredValue is a node's key of `packages`, in mesh-sdk's layout (the controller's mirror unchanged).
type declaredValue = delivery.DeclaredPackages type declaredValue = delivery.DeclaredPackages
// nodeInstalled is one node's key of `installed` with its not-current mark. // nodeInstalled is one node's key of `installed` with its not-current mark.
@@ -118,53 +113,42 @@ func (m *Machine) installedOn(node string) ([]nodeInstalled, error) {
return out, nil return out, nil
} }
// declaredOn is what each node's modules declare present, by node and package, with the declaring modules, and the source // declaredOn is what each node's modules declare present, by node and package, with the source that answered for each
// that answered for each node: its engine's key, else the controller's mirror; a node neither holds is missing. When the // node (its engine's key), the nodes whose engine has written none, and a word when the engine's state cannot be read at
// engine's state cannot be read at all (its bucket is not on the bus yet, or the read is refused) the mirror answers for // all (its bucket is not on the bus yet, or the read is refused): then no node has a declaration and the word says why.
// every node and the engine's error is returned as a word to say beside the sources; it is an error only when the mirror // Nothing else is read: the controller's mirror ended with its lease (ADR 0565 clause 6).
// cannot be read either (ADR 0565 clause 6). func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, []string, string) {
func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, string, error) {
out := map[string]map[string][]string{} out := map[string]map[string][]string{}
from := map[string]string{} from := map[string]string{}
engineErr := "" var none []string
for _, source := range []string{SourceEngine, SourceController} { r := m.States(SourceEngine)
r := m.States(source) for _, n := range nodes {
read := map[string]map[string][]string{} var v declaredValue
readFrom := map[string]string{} found, err := readValue(r, n, &v)
var failed error if err != nil {
for _, n := range nodes { return map[string]map[string][]string{}, map[string]string{}, nil, SourceEngine + " cannot be read: " + err.Error()
if from[n] != "" {
continue
}
var v declaredValue
found, err := readValue(r, n, &v)
if err != nil {
failed = err
break
}
if !found {
continue
}
readFrom[n] = source
read[n] = map[string][]string{}
for _, p := range v.Packages {
if !p.Absent {
read[n][p.Package] = p.Modules
}
}
} }
if failed != nil { if !found {
if source == SourceEngine { none = append(none, n)
engineErr = SourceEngine + " cannot be read: " + failed.Error() continue
continue
}
return nil, nil, "", failed
} }
for n, d := range read { from[n] = SourceEngine
out[n], from[n] = d, readFrom[n] out[n] = map[string][]string{}
for _, p := range v.Packages {
if !p.Absent {
out[n][p.Package] = p.Modules
}
} }
} }
return out, from, engineErr, nil return out, from, none, ""
}
// declaredWord says which nodes' engines have written no packages key.
func declaredWord(none []string) string {
if len(none) == 0 {
return ""
}
return "the node-engine has written no packages key for " + strings.Join(none, ", ") + ": no declaration is shown for them"
} }
// NodeView is one node in the view. // NodeView is one node in the view.
@@ -178,10 +162,10 @@ type NodeView struct {
Current bool `json:"current"` Current bool `json:"current"`
NotCurrent *NotCurrent `json:"not_current,omitempty"` NotCurrent *NotCurrent `json:"not_current,omitempty"`
// Undeclared is the packages installed explicitly from a repository that no module assigned to the node // Undeclared is the packages installed explicitly from a repository that no module assigned to the node
// declares; null when neither the node-engine nor the controller's mirror holds a declaration of the node. // declares; null when the node-engine holds no declaration of the node.
Undeclared []PackageVersion `json:"undeclared"` Undeclared []PackageVersion `json:"undeclared"`
Foreign []PackageVersion `json:"foreign"` Foreign []PackageVersion `json:"foreign"`
// DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one. // DeclaredFrom is the source that answered for the node's declaration; empty when the engine holds none.
DeclaredFrom string `json:"declared_from,omitempty"` DeclaredFrom string `json:"declared_from,omitempty"`
} }
@@ -201,7 +185,7 @@ type PackageOnNode struct {
Foreign bool `json:"foreign,omitempty"` Foreign bool `json:"foreign,omitempty"`
DeclaredBy []string `json:"declared_by"` DeclaredBy []string `json:"declared_by"`
Current bool `json:"current"` Current bool `json:"current"`
// DeclaredFrom is the source that answered for the node's declaration; empty when neither holds one. // DeclaredFrom is the source that answered for the node's declaration; empty when the engine holds none.
DeclaredFrom string `json:"declared_from,omitempty"` DeclaredFrom string `json:"declared_from,omitempty"`
} }
@@ -217,10 +201,7 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
for _, ni := range all { for _, ni := range all {
nodes = append(nodes, ni.Node) nodes = append(nodes, ni.Node)
} }
declared, sources, engineErr, err := m.declaredOn(nodes) declared, sources, none, engineErr := m.declaredOn(nodes)
if err != nil {
return nil, err
}
if name != "" { if name != "" {
on := []PackageOnNode{} on := []PackageOnNode{}
for _, ni := range all { for _, ni := range all {
@@ -242,6 +223,8 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
answer := map[string]any{"package": name, "nodes": on} answer := map[string]any{"package": name, "nodes": on}
if engineErr != "" { if engineErr != "" {
answer["declared_engine_error"] = engineErr answer["declared_engine_error"] = engineErr
} else if w := declaredWord(none); w != "" {
answer["declared_none"] = w
} }
return answer, nil return answer, nil
} }
@@ -294,6 +277,8 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
out := map[string]any{"nodes": views} out := map[string]any{"nodes": views}
if engineErr != "" { if engineErr != "" {
out["declared_engine_error"] = engineErr out["declared_engine_error"] = engineErr
} else if w := declaredWord(none); w != "" {
out["declared_none"] = w
} }
for key, list := range map[string][]Spread{"differing": differing, "some_nodes": some} { for key, list := range map[string][]Spread{"differing": differing, "some_nodes": some} {
b := bound(key, list, limit) b := bound(key, list, limit)
+16 -25
View File
@@ -43,7 +43,7 @@ func viewStates(t *testing.T) (*fakeStates, *Machine) {
} }
func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) { func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
states, m := viewStates(t) _, m := viewStates(t)
r, err := m.Packages("zsh", 500) r, err := m.Packages("zsh", 500)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -56,22 +56,22 @@ func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
if a.DeclaredFrom != SourceEngine || b.DeclaredFrom != "" { if a.DeclaredFrom != SourceEngine || b.DeclaredFrom != "" {
t.Errorf("sources: %q, %q", a.DeclaredFrom, b.DeclaredFrom) t.Errorf("sources: %q, %q", a.DeclaredFrom, b.DeclaredFrom)
} }
// A node whose engine has written no key reads the controller's mirror, said so; a node with both reads its engine's. // A node whose engine has written no key has no declaration, said so, and no controller state is read (774/S7/AC2);
var legacy, stale declaredValue // an engine whose state cannot be read at all is said once beside the sources, for every node (774/S7/AC1 reads the
json.Unmarshal([]byte(`{"node":"node-b","packages":[{"package":"zsh","modules":["legacy"],"absent":false}]}`), &legacy) // engine's key above).
json.Unmarshal([]byte(`{"node":"node-a","packages":[{"package":"zsh","modules":["stale"],"absent":false}]}`), &stale) read := m.States
mirror := states.store(ControllerDeclaredState) m.States = func(name string) StateReader {
mirror.Put("node-b", legacy) if name != SourceEngine && name != StateInstalled {
mirror.Put("node-a", stale) t.Errorf("state %s was read: only the engine's packages are", name)
}
return read(name)
}
r2, _ := m.Packages("zsh", 500) r2, _ := m.Packages("zsh", 500)
on2 := r2["nodes"].([]PackageOnNode) on2 := r2["nodes"].([]PackageOnNode)
if on2[0].DeclaredFrom != SourceEngine || strings.Join(on2[0].DeclaredBy, ",") != "login,shell" || if on2[0].DeclaredFrom != SourceEngine || on2[1].DeclaredFrom != "" || len(on2[1].DeclaredBy) != 0 ||
on2[1].DeclaredFrom != SourceController || strings.Join(on2[1].DeclaredBy, ",") != "legacy" { !strings.Contains(fmt.Sprint(r2["declared_none"]), "no packages key for node-b") {
t.Errorf("fallback: %+v", on2) t.Errorf("a node with no key: %+v %v", on2, r2["declared_none"])
} }
// The engine's state cannot be read (its bucket is not on the bus yet): the mirror answers for every node, and the
// answer says why beside declared_from; a mirror that cannot be read either is an error.
read := m.States
m.States = func(name string) StateReader { m.States = func(name string) StateReader {
if name == SourceEngine { if name == SourceEngine {
return failingReader{errors.New("is issued and its bucket is not on the bus yet")} return failingReader{errors.New("is issued and its bucket is not on the bus yet")}
@@ -80,19 +80,10 @@ func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
} }
r3, err3 := m.Packages("zsh", 500) r3, err3 := m.Packages("zsh", 500)
on3, _ := r3["nodes"].([]PackageOnNode) on3, _ := r3["nodes"].([]PackageOnNode)
if err3 != nil || len(on3) != 2 || on3[0].DeclaredFrom != SourceController || strings.Join(on3[0].DeclaredBy, ",") != "stale" || if err3 != nil || len(on3) != 2 || on3[0].DeclaredFrom != "" || on3[1].DeclaredFrom != "" ||
on3[1].DeclaredFrom != SourceController || !strings.Contains(fmt.Sprint(r3["declared_engine_error"]), "not on the bus yet") { !strings.Contains(fmt.Sprint(r3["declared_engine_error"]), "not on the bus yet") {
t.Errorf("engine unreadable: %+v %v", r3, err3) t.Errorf("engine unreadable: %+v %v", r3, err3)
} }
m.States = func(name string) StateReader {
if name == SourceEngine || name == SourceController {
return failingReader{errors.New("refused")}
}
return read(name)
}
if _, err := m.Packages("zsh", 500); err == nil {
t.Error("neither state could be read and no error was said")
}
m.States = read m.States = read
if a.Node != "node-a" || !a.Installed || a.Version != "5.9-2" || !a.Explicit || a.Repository != "extra" || if a.Node != "node-a" || !a.Installed || a.Version != "5.9-2" || !a.Explicit || a.Repository != "extra" ||
strings.Join(a.DeclaredBy, ",") != "login,shell" || !a.Current { strings.Join(a.DeclaredBy, ",") != "login,shell" || !a.Current {
+1 -1
View File
@@ -3,7 +3,7 @@ module pacman
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
) )
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 h1:xeuMBwOHNKrVUdGVg7Bdv5tkdueBvngwJFyk6n10rp0= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4 h1:xeuMBwOHNKrVUdGVg7Bdv5tkdueBvngwJFyk6n10rp0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101151945-b2a27ed5cec4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c h1:/YCxh+NYfqry3PxZYY1nQXaccupobxpsFt8c9BYuGBg=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101235037-c21d8fd9692c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -2
View File
@@ -12,8 +12,7 @@
} }
], ],
"reads": [ "reads": [
"mesh-host.packages", "mesh-host.packages"
"mesh-controller.declared-packages"
], ],
"uses": [ "uses": [
"operator-channel" "operator-channel"