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
// 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
// difference, and on the warrant writes the take-over key naming the keeper. `hand-back` writes it back to the
// controller until the lease write, and is refused after it.
// difference, and on the warrant writes the take-over key naming the keeper. `hand-back` is gone (issue 774): nothing
// reads the take-over key any more, so it is answered as gone and changes nothing.
import (
"encoding/json"
@@ -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})
}
// 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) {
if strings.TrimSpace(why) == "" {
return nil, errors.New("a hand-back says why")
}
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
return nil, errors.New("hand-back is gone: the readers of conditions hear the condition keeper alone, and nothing " +
"reads the take-over key, so there is no controller to hand them back to (ADR 0564 clause 9)")
}
// writeTakeover writes the take-over key, and keeps it in history as the record of the switch.
@@ -1,8 +1,8 @@
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
// mesh's open conditions. It watches every observer's `findings`, the healers' `attempts`, the controller's lease and,
// until the lease write, the controller's `conditions`, and the assigner's wants and the nodes' haves for the hold. From
// mesh's open conditions. It watches every observer's `findings`, the healers' `attempts`, the controller's lease
// 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,
// 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).
@@ -46,11 +46,10 @@ var Observers = []string{health.WatchmanModule, health.SelfCheckModule, health.H
// The other states it reads, as `<module>.<name>`.
const (
AttemptsRead = health.HealersModule + "." + health.AttemptsState
ControllerConditions = "mesh-controller.conditions"
ControllerLease = "mesh-controller.lease"
ControllerLeaseKey = "holder"
WantsRead = delivery.AssignerModule + ".wants"
AttemptsRead = health.HealersModule + "." + health.AttemptsState
ControllerLease = "mesh-controller.lease"
ControllerLeaseKey = "holder"
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
// lead, 2026-10-29, for ADR 0564 clause 2's inputs).
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
// 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
// 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) {
r := newRig()
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") {
t.Errorf("take-over while compare differs: %v", err)
}
if _, err := r.k.HandBack("jochen", "back"); err != nil {
t.Errorf("hand-back before the lease write: %v", err)
}
_ = r.k.Lease(true)
if _, err := r.k.HandBack("jochen", "back"); err == nil {
t.Error("hand-back after the lease write was taken")
for _, away := range []bool{false, true} {
_ = r.k.Lease(away)
if _, err := r.k.HandBack("jochen", "back"); err == nil || !strings.Contains(err.Error(), "hand-back is gone") ||
!strings.Contains(err.Error(), "ADR 0564 clause 9") {
t.Errorf("hand-back with the lease away %v: %v, want it answered as gone naming its record", away, err)
}
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 {
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 {
watch(k, o+"."+health.FindingsState, o, func(c stdio.StateChange) error {
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
// 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,
// every act a tool that starts it.
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
@@ -29,14 +29,14 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
!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))
}
reads := []string{ControllerConditions, ControllerLease, WantsRead, HavesRead, AttemptsRead, PlacementRead}
reads := []string{ControllerLease, WantsRead, HavesRead, AttemptsRead, PlacementRead}
for _, o := range Observers {
reads = append(reads, o+"."+health.FindingsState)
}
slices.Sort(reads)
got := slices.Clone(m.Reads)
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)
}
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) {
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 " +
"is handed away.",
{Name: Seat + ".hand-back", Description: "Gone: the readers of conditions hear the keeper alone, so there is nothing " +
"to hand back; answered as gone and changes nothing (ADR 0564 clause 9).",
Input: obj(map[string]any{"why": str("why")}, "why"),
Run: func(args map[string]any) (any, error) { return k.HandBack(stdio.CallerOf(args), strArg(args, "why")) }},
{Name: Seat + ".resync", Description: "Read every finding again and raise and clear what differs.",
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-conditions
go 1.26.0
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
)
+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/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-1
View File
@@ -28,7 +28,6 @@
"mesh-provisioner.findings",
"artifact-store-tools.findings",
"mesh-healers.attempts",
"mesh-controller.conditions",
"mesh-controller.lease",
"mesh-delivery-assigner.wants",
"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
`<node>.<module>.<key>`, with its source: `default`, `mesh`, `node`, `module`, or `unset` for a required value
nothing sets.
- `mesh-config_predefined`: the list of predefined keys, published from the list the controller kept (its mirror
`mesh-controller.predefined`), the newest trunk build winning. A newer list dropping a key that still holds a
- `mesh-config_predefined`: the list of predefined keys, published from the indexer's list (the controller's mirror is no longer read, issue 774), the newest trunk
build winning. A newer list dropping a key that still holds a
value at the mesh or a node is withheld, and every listing says why, until that value is cleared.
- `mesh-config_proposals`: each value proposed for the operator's Approve, whole, with the value stored at its level
when it was proposed (what the operator is shown the change against), who proposed it, why, and what came of it.
@@ -66,16 +66,15 @@ Each directory or archive this module declares says how it treats a file it did
- `mesh-config_declared`: each placed module's declarations at the trunk, with the manifest and commit they come
from, written only when they differ.
- `mesh-config_takeover`: the key `switch` names the source that holds: none (the interim since mesh-catalog #558:
declarations from the modules' own manifests, the list of predefined keys and pull requests' config from the
controller), `indexer` after the operator's take-over (the list and pull requests' config from the indexer's
`predefined` and `config-candidates`; the controller's `pull-declares` refused), `controller` after a hand-back
(declarations, list and pull requests' config from the controller's records). Each take-over ask is kept beside it.
- `config_compare` lists every difference between the own sources and the controller's records: each module's
declarations, the list, each open pull request's config, and every placed module's value on every node resolved
both ways. `config_take_over` asks the operator's Approve, refused while any difference stands and switched on the
Approve only if none stands then. `config_hand_back` returns to the controller's records until its lease is handed
away. `config_declared` and `config_resync` read and write again what it keeps.
- `mesh-config_takeover`: the key `switch` names the source that holds: none (declarations from the modules' own
manifests, the list from the indexer's `predefined`, pull requests' config from the controller's `pull-declares`),
or `indexer` after the operator's take-over (pull requests' config from the indexer's `config-candidates`; the
controller's `pull-declares` refused). A switch still naming `controller` (the old hand-back) is read as `indexer` and
said gone. Each take-over ask is kept beside it.
- `config_compare` lists every difference between each open pull request's candidate at the indexer and what the
controller told by `pull-declares`; the controller's mirrors are no longer read (ADR 0565 §4, issue 774).
`config_take_over` asks the operator's Approve, refused while any difference stands and switched on the Approve only
if none stands then. `config_hand_back` is gone: it answers so and changes nothing. `config_declared` and `config_resync` read and write again what it keeps.
- From the controller's lease write a trusted value is set only through propose and the operator's Approve.
- Its sets, clears and warrants are recorded in the hand-act log of the seat `mesh-hand-acts`, falling back to the
controller's while that seat has no holder and the controller holds its lease.
@@ -244,10 +244,9 @@ const dunstDeclared = `{"module":"dunst","settings":{
"position":{"kind":"","default":"top-right","why":"","type":"string","description":"where notifications show","enum":["top-right","top-left"]},
"origin":{"kind":"","default":null,"why":"","type":"string","description":"the operator's own origin","required":true,"pattern":"[a-z]+"}}}`
// theList is the controller's predefined mirror, as it writes it.
const theList = `{"keys":{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}},
"repository":"mesh-catalog","commit":"aaaaaaaaaaaa","generation":5,"build":"b5"}`
// theList is the indexer's list of predefined keys, as predefined.json holds it.
const theList = `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`
func aMesh(t *testing.T) (*Config, *memStore) {
t.Helper()
@@ -256,7 +255,7 @@ func aMesh(t *testing.T) (*Config, *memStore) {
m.declare(t, "claude-code", `{"module":"claude-code","settings":{
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
indexerList(t, m, "aaaaaaaaaaaa", theList)
for _, node := range []string{"g14", "novox"} {
m.assign(t, node, "dunst")
}
@@ -507,7 +506,7 @@ func TestAChangeTheHandActLogRefusesIsSaid(t *testing.T) {
expect(t, m, "g14", "12", SourceMesh)
}
// **The newest list of predefined keys is published, and an older one heard later is not** (ADR 0348 §7).
// **The newest list of predefined keys is published** (ADR 0348 §7; the indexer never keeps an older merge's).
func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
c, m := aMesh(t)
published := func() map[string]Published {
@@ -521,24 +520,17 @@ func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
}
return out
}
if p := published(); len(p) != 2 || p["font-size"].Generation != 5 || !p["agent-managed-settings"].Trusted {
if p := published(); len(p) != 2 || p["font-size"].Commit != "aaaaaaaaaaaa" || !p["agent-managed-settings"].Trusted {
t.Fatalf("published %+v", p)
}
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"font-size":{"type":"number","description":"x"},
"theme":{"type":"string","description":"the theme"}},"commit":"bbbb","generation":7}`)
indexerList(t, m, "bbbb", `{"font-size":{"type":"number","description":"x"},
"theme":{"type":"string","description":"the theme"}}`)
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
if p := published(); len(p) != 2 || p["theme"].Generation != 7 || p["agent-managed-settings"].Type != "" {
if p := published(); len(p) != 2 || p["theme"].Commit != "bbbb" || p["agent-managed-settings"].Type != "" {
t.Fatalf("a newer list published %+v", p)
}
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) // generation 5, heard later
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
if p := published(); p["theme"].Generation != 7 {
t.Fatalf("an older list replaced the newer one: %+v", p)
}
}
// **A list dropping a key that still holds a value is withheld** (ADR 0348 §7): the list published stands, and the
@@ -546,8 +538,7 @@ func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T) {
c, m := aMesh(t)
set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"})
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"agent-managed-settings":{"type":"object",
"description":"managed settings","trusted":true}},"commit":"cccccccccc","generation":9}`)
indexerList(t, m, "cccccccccc", `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true}}`)
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
@@ -129,7 +129,7 @@ type PredefinedKey struct {
Trusted bool `json:"trusted,omitempty"`
}
// PredefinedList is the list the controller kept, as its predefined mirror holds it under PredefinedMirrorKey.
// PredefinedList is the list of predefined keys with the commit it came from, as the indexer keeps it.
type PredefinedList struct {
Keys map[string]PredefinedKey `json:"keys"`
Repository string `json:"repository"`
@@ -138,9 +138,6 @@ type PredefinedList struct {
Build string `json:"build"`
}
// PredefinedMirrorKey is the controller's predefined mirror's one key.
const PredefinedMirrorKey = "list"
// Published is one key of mesh-config_predefined: the key's declaration, and the build of the list it came from.
type Published struct {
PredefinedKey
+3 -3
View File
@@ -6,7 +6,7 @@
// asked under this module's own name, design 57 K4).
// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own
// manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the
// list of predefined keys still comes from the controller's mirror until the indexer keeps it (ADR 0565 §2). stdout is the MCP channel; what this module says, it says on stderr.
// list of predefined keys comes from the indexer's (ADR 0565 §2); the controller's records are no longer read (issue 774). stdout is the MCP channel; what this module says, it says on stderr.
package main
import (
@@ -132,8 +132,8 @@ func resolving(c *Config) {
}
// A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is
// every change of what a module declares where (issue 1953); the builds themselves are read, not watched.
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, PredefinedRead, IndexerPredefinedRead,
CandidatesRead, MirrorDeclaredRead, TakeoverState} {
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, IndexerPredefinedRead,
CandidatesRead, TakeoverState} {
// Asked again until the runtime takes it, each give-up said (mesh-sdk's Follow).
go stdio.State(read).Follow("", poke)
}
@@ -33,8 +33,7 @@ func TestEveryStateAndActHasItsTool(t *testing.T) {
ProposalsState: "config proposals", PullsState: "config list", KeptState: "config list", HistoryLog: "config history",
DeclaredState: DeclaredTool, TakeoverState: DeclaredTool,
PlacementRead: CompareTool, WantsRead: CompareTool, BuildsRead: CompareTool, ModulesRead: DeclaredTool,
ManifestsRead: DeclaredTool, PredefinedRead: CompareTool, MirrorDeclaredRead: CompareTool,
IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool, LeaseRead: HandBackTool,
ManifestsRead: DeclaredTool, IndexerPredefinedRead: CompareTool, CandidatesRead: CompareTool, LeaseRead: HandBackTool,
"a resolution": ResyncTool, "the indexer's candidates taken": ResyncTool, "a take-over decided": TakeOverTool,
}
served := map[string]bool{}
@@ -34,9 +34,6 @@ const (
// repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16).
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
// PredefinedRead is the controller's list of predefined keys, which ADR 0565 §2 moves to the indexer's
// `predefined`: read here until that state exists.
PredefinedRead = "mesh-controller.predefined"
// HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
HistoryLog = "history"
+11 -25
View File
@@ -14,17 +14,8 @@ import (
// sources reads everything a resolution reads, from the bus as it is now, from the source the switch names
// (takeover.go): which modules are placed where from the seat mesh-placement, and each placed module's declarations
// from its own manifests as built (issue 1953, ADR 0565 §3), or after a hand-back from the controller's mirror.
// from its own manifests as built (issue 1953, ADR 0565 §3); the controller's records are no longer read.
func (c *Config) sources() (Sources, error) {
sw, err := c.source()
if err != nil {
return Sources{}, err
}
return c.sourcesFrom(sw.Source)
}
// sourcesFrom reads the sources with declarations from one source: the own (interim and indexer) or the mirror.
func (c *Config) sourcesFrom(source string) (Sources, error) {
s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{},
Predefined: map[string]Published{}, Values: map[string]Value{}}
var problems []string
@@ -34,9 +25,6 @@ func (c *Config) sourcesFrom(source string) (Sources, error) {
if err := c.readDeclarations(&s); err != nil {
return s, fmt.Errorf("the modules' declarations cannot be read: %w", err)
}
if source == SourceController {
c.mirrorDeclarations(&s)
}
for _, u := range s.Unresolved {
problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why))
}
@@ -366,6 +354,8 @@ type Resolution struct {
// did after the take-over.
Declared []string `json:"declared,omitempty"`
Candidates []string `json:"candidates,omitempty"`
// SourceGone is said while the stored switch still names the controller, whose records are no longer read.
SourceGone string `json:"source_gone,omitempty"`
}
// Reconcile publishes the newest list of predefined keys and brings mesh-config_resolved to what the sources
@@ -375,6 +365,10 @@ func (c *Config) Reconcile() (Resolution, error) {
c.mu.Lock()
defer c.mu.Unlock()
var r Resolution
if sw, serr := c.source(); serr == nil && sw.Gone != "" {
r.SourceGone = sw.Gone
c.logf("[mesh-config] %s", sw.Gone)
}
published, err := c.publish()
if err != nil {
c.logf("[mesh-config] the list of predefined keys is not published: %v", err)
@@ -401,24 +395,16 @@ func (c *Config) Reconcile() (Resolution, error) {
return r, err
}
// publish makes mesh-config_predefined hold the list its source keeps — the indexer's after the take-over, the
// controller's mirror otherwise — when it is newer than the one published (ADR 0348 §7: the newest trunk build wins;
// publish makes mesh-config_predefined hold the indexer's list (the controller's mirror is no longer read, issue 774)
// when it is newer than the one published (ADR 0348 §7: the newest trunk build wins;
// the indexer never keeps an older merge's, so its list is newer when its commit differs). **A list dropping a key that still holds a value is withheld**: the
// list published stays, and why is said in every listing and in the log, because a value for a key no longer on
// the list could neither be resolved nor cleared by name (§7: removing a key still holding a value is refused).
// It answers what it published, empty when nothing changed.
func (c *Config) publish() (string, error) {
sw, err := c.source()
list, found, err := c.indexerList()
if err != nil {
return "", err
}
read, whose := c.mirrorList, "the controller's list"
if sw.Source == SourceIndexer {
read, whose = c.indexerList, "the indexer's list"
}
list, found, err := read()
if err != nil {
return "", fmt.Errorf("%s cannot be read: %w", whose, err)
return "", fmt.Errorf("the indexer's list cannot be read: %w", err)
}
if !found {
return "", nil // no list kept yet
@@ -41,11 +41,10 @@ func ownSwitchTools(c *Config) []stdio.Tool {
whyOf := func(a map[string]any) string { s, _ := a["why"].(string); return strings.TrimSpace(s) }
return []stdio.Tool{
{Name: CompareTool,
Description: "Every difference between config's own sources and the controller's records (novox/hq ADR 0565 §4): " +
"each placed module's declarations (declared against the mirror declared-config), the list of predefined " +
"keys (the indexer's against the mirror's), each open pull request's config (the indexer's candidate " +
"against the controller's pull-declares), and every placed module's value on every node resolved both " +
"ways; each with its reason. The take-over is refused while any stands.",
Description: "Every difference between the indexer's records and the controller's pull-declares (novox/hq ADR 0565 §4): " +
"each open pull request's config (the indexer's candidate against the controller's pull-declares), each " +
"with its reason. The controller's mirrors are no longer read, so nothing else is compared. The " +
"take-over is refused while any stands.",
Run: func(map[string]any) (any, error) { return c.Compare() }},
{Name: TakeOverTool,
Description: "Ask the operator's Approve, under mesh-config's own name, to switch config to its own sources: " +
@@ -56,8 +55,8 @@ func ownSwitchTools(c *Config) []stdio.Tool {
return c.TakeOver(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
}},
{Name: HandBackTool,
Description: "Return config to the controller's records (its mirrors declared-config and predefined, and " +
"pull-declares) until the controller hands its lease away; gone after it. Recorded as a hand act.",
Description: "Gone: the controller's records end with its lease and config no longer reads them (novox/hq ADR 0565 §4, " +
"issue 774); answers that, and changes nothing.",
Input: why,
Run: func(a map[string]any) (any, error) {
return c.HandBack(Call{Why: whyOf(a), Caller: stdio.CallerOf(a)})
+42 -216
View File
@@ -2,19 +2,19 @@ package main
// Config's records out of the controller (novox/hq ADR 0565 §3, §4, §13, §15, and the progressive insight of
// 2026-10-29): mesh-config keeps each placed module's declarations in its own state `declared`; `compare` lists every
// difference between its own sources and the controller's mirrors — each module's declarations, the predefined list,
// each open pull request's config declarations, and every placed module's resolved value on every node both ways;
// difference between each open pull request's candidate at the indexer and what the controller told by pull-declares;
// `take-over` asks the operator's Approve under mesh-config's own name and, refused while anything differs, writes the
// key `takeover.switch` naming the indexer; `hand-back` returns to the mirrors until the lease write.
// key `takeover.switch` naming the indexer.
//
// **The controller's records are no longer read** (issue 774, ADR 0571 §13): its mirrors `declared-config` and
// `predefined` are gone from the reads, so declarations come from each placed module's own manifests and the list of
// predefined keys from the indexer's `predefined`, whatever the switch says. `hand-back` is answered as gone (ADR 0565
// §4: it ends with the lease), and a switch that still names the controller is read as the indexer, said so.
//
// **Which source holds** is the key `switch` alone, read by mesh-config alone:
// - none written (the interim of mesh-catalog #558): declarations from each placed module's own manifests, the list
// of predefined keys from the controller's mirror, a pull request's declarations from the controller's
// pull-declares;
// - `indexer` (taken over): declarations as before, the list from the indexer's `predefined`, a pull request's from
// the indexer's `config-candidates`; pull-declares refused;
// - `controller` (handed back): declarations from the mirror `declared-config`, the list from the mirror,
// pull-declares taken.
// - none written: a pull request's declarations from the controller's pull-declares;
// - `indexer` (taken over): a pull request's from the indexer's `config-candidates`; pull-declares refused;
// - `controller` (the old hand-back): gone, read as `indexer`.
import (
"bytes"
@@ -38,9 +38,6 @@ const (
TakeoverState = "takeover"
SwitchKey = "switch"
// MirrorDeclaredRead is the controller's mirror of every registered module's declarations, one key per module:
// what `compare` holds the own source to, and the source after a hand-back.
MirrorDeclaredRead = "mesh-controller.declared-config"
// IndexerPredefinedRead and CandidatesRead are the indexer's states (ADR 0565 §2).
IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState
CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState
@@ -50,8 +47,9 @@ const (
// The sources the switch names.
const (
SourceInterim = "interim"
SourceIndexer = "indexer"
SourceInterim = "interim"
SourceIndexer = "indexer"
// SourceController is a switch the old hand-back wrote; the controller's records are gone, so it is read as the indexer.
SourceController = "controller"
)
@@ -69,8 +67,15 @@ type Switch struct {
By string `json:"by"`
Why string `json:"why"`
Ask string `json:"ask,omitempty"`
// Gone says the stored switch named the controller, whose records config no longer reads (ADR 0565 §4); the switch
// returned is then the indexer's. Never stored.
Gone string `json:"-"`
}
// goneSource is what is said of a switch that still names the controller.
const goneSource = "the switch names the controller, which is gone: config no longer reads its records (novox/hq ADR 0565 §4, " +
"issue 774) and reads the indexer's"
// source is the source that holds now.
func (c *Config) source() (Switch, error) {
e, err := c.Store.Get(TakeoverState, SwitchKey)
@@ -84,6 +89,9 @@ func (c *Config) source() (Switch, error) {
if err := json.Unmarshal(e.Value, &s); err != nil || (s.Source != SourceIndexer && s.Source != SourceController) {
return Switch{}, fmt.Errorf("the take-over's switch is not one: %s", e.Value)
}
if s.Source == SourceController {
s.Source, s.Gone = SourceIndexer, goneSource
}
return s, nil
}
@@ -172,31 +180,6 @@ func (c *Config) writeDeclared(s Sources) ([]string, error) {
return written, errors.Join(problems...)
}
// mirrorDeclarations makes the sources' declarations the controller's mirror's, for every placed module: the source
// after a hand-back, and the other side of compare.
func (c *Config) mirrorDeclarations(s *Sources) {
s.Declared, s.OnNode, s.Unresolved = map[string]ModuleDeclarations{}, map[string]ModuleDeclarations{}, nil
for _, module := range modulesOf(s.Assignments) {
e, err := c.Store.Get(MirrorDeclaredRead, module)
var d ModuleDeclarations
if err == nil && e != nil {
d, err = ParseModuleDeclarations(e.Value)
} else if err == nil {
err = errors.New("the mirror holds no key for it")
}
if err != nil {
for _, a := range s.Assignments {
if a.Module == module {
s.Unresolved = append(s.Unresolved, Unresolved{Node: a.Node, Module: module,
Why: "the controller's mirror declared-config: " + err.Error()})
}
}
continue
}
s.Declared[module] = d
}
}
// ---- the predefined list and the pull requests from the indexer ------------------------------------------
// indexerList is the indexer's predefined list as the mirror's list holds one: the keys, and the commit.
@@ -216,19 +199,6 @@ func (c *Config) indexerList() (PredefinedList, bool, error) {
return list, true, nil
}
// mirrorList is the controller's mirror's list.
func (c *Config) mirrorList() (PredefinedList, bool, error) {
e, err := c.Store.Get(PredefinedRead, PredefinedMirrorKey)
if err != nil || e == nil {
return PredefinedList{}, false, err
}
var list PredefinedList
if err := json.Unmarshal(e.Value, &list); err != nil || list.Keys == nil {
return PredefinedList{}, false, fmt.Errorf("the controller's list is not one: %s", e.Value)
}
return list, true, nil
}
// candidates is every open pull request's config declarations as the indexer keeps them, by pull key.
func (c *Config) candidates() (map[string]PullDecl, error) {
out := map[string]PullDecl{}
@@ -300,7 +270,7 @@ func declaresDigest(d map[string]map[string]Declaration) string {
// ---- compare ----------------------------------------------------------------------------------------------
// Difference is one thing the own sources and the controller's mirrors say differently, and why.
// Difference is one thing the indexer's records and the controller's pull-declares say differently, and why.
type Difference struct {
What string `json:"what"`
Module string `json:"module,omitempty"`
@@ -308,16 +278,12 @@ type Difference struct {
Key string `json:"key,omitempty"`
Pull string `json:"pull,omitempty"`
Own json.RawMessage `json:"own,omitempty"`
Mirror json.RawMessage `json:"mirror,omitempty"`
Why string `json:"why"`
}
// The kinds of difference.
const (
DiffDeclared = "declared"
DiffPredefined = "predefined"
DiffPull = "pull"
DiffValue = "value"
DiffPull = "pull"
)
// Comparison is what compare answers.
@@ -326,6 +292,8 @@ type Comparison struct {
Differences []Difference `json:"differences"`
Count int `json:"count"`
Said string `json:"said"`
// Gone is said when the stored switch still names the controller.
Gone string `json:"gone,omitempty"`
}
// Compare lists every difference (ADR 0565 §4 and its insight). The caller holds no lock.
@@ -335,93 +303,20 @@ func (c *Config) Compare() (*Comparison, error) {
return c.compare()
}
// compare is Compare; the caller holds the lock.
// compare is Compare; the caller holds the lock. The controller's mirrors are no longer read (issue 774, ADR 0565 §4),
// so the declarations, the list and the resolved values have no second side: what remains to compare is each open pull
// request's candidate at the indexer against what the controller told by pull-declares.
func (c *Config) compare() (*Comparison, error) {
sw, err := c.source()
if err != nil {
return nil, err
}
own, err := c.sourcesFrom(SourceInterim)
if err != nil {
return nil, fmt.Errorf("mesh-config's own sources cannot be read: %w", err)
var own Sources
if err := c.readPulls(&own); err != nil {
return nil, fmt.Errorf("mesh-config's own pull request records cannot be read: %w", err)
}
mirror := own
mirror.Declared, mirror.OnNode, mirror.Unresolved = nil, nil, nil
c.mirrorDeclarations(&mirror)
var diffs []Difference
// A placed module no catalog manifest names (mesh-wireguard, which the mesh ships computed) whose mirror key holds
// no config key declares nothing on both sides: nothing resolves from it, so nothing differs (issue 1953, L5d).
declaresNothing := map[string]bool{}
for module, d := range mirror.Declared {
if len(d.Config) == 0 {
// Only an indexer that was read and holds no modules.<module>: a failed read says nothing, and the row stays.
if key, kerr := delivery.ModulesKey(module); kerr == nil {
if e, err := c.Store.Get(ModulesRead, key); err == nil && e == nil {
declaresNothing[module] = true
}
}
}
}
// Each placed module's declarations: `declared` against the mirror's key for it.
for _, module := range modulesOf(own.Assignments) {
var ownRaw []byte
if e, err := c.Store.Get(DeclaredState, module); err == nil && e != nil {
var k DeclaredKept
if json.Unmarshal(e.Value, &k) == nil {
ownRaw, _ = json.Marshal(normal(k.Config))
}
}
var mirrorRaw []byte
if d, ok := mirror.Declared[module]; ok {
mirrorRaw, _ = json.Marshal(normal(d.Config))
}
switch {
case ownRaw == nil && declaresNothing[module]:
case ownRaw == nil && mirrorRaw == nil:
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Why: "neither side holds its declarations"})
case ownRaw == nil:
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Mirror: mirrorRaw,
Why: "the mirror holds its declarations and declared does not"})
case mirrorRaw == nil:
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Own: ownRaw,
Why: "declared holds its declarations and the mirror does not"})
case !bytes.Equal(ownRaw, mirrorRaw):
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Own: ownRaw, Mirror: mirrorRaw,
Why: "the trunk's manifest declares it otherwise than the mirror"})
}
}
// The predefined list: the indexer's against the mirror's.
il, iok, ierr := c.indexerList()
ml, mok, merr := c.mirrorList()
switch {
case ierr != nil || merr != nil:
diffs = append(diffs, Difference{What: DiffPredefined, Why: "a list cannot be read: " + errors.Join(ierr, merr).Error()})
case !iok || !mok:
if iok != mok {
diffs = append(diffs, Difference{What: DiffPredefined, Why: fmt.Sprintf("the indexer holds a list: %v; the "+
"mirror holds one: %v", iok, mok)})
}
default:
for _, k := range unionKeys(il.Keys, ml.Keys) {
a, aok := il.Keys[k]
b, bok := ml.Keys[k]
ar, _ := json.Marshal(a)
br, _ := json.Marshal(b)
switch {
case !aok:
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Mirror: br, Why: "only the mirror's list holds it"})
case !bok:
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Own: ar, Why: "only the indexer's list holds it"})
case !bytes.Equal(ar, br):
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Own: ar, Mirror: br,
Why: "the two lists declare it otherwise"})
}
}
}
// Each open pull request: the indexer's candidate against what the controller told by pull-declares.
cands, cerr := c.candidates()
if cerr != nil {
@@ -452,39 +347,6 @@ func (c *Config) compare() (*Comparison, error) {
}
}
// Every placed module's value on every node, resolved both ways.
ownValues, mirrorValues := Resolve(own), Resolve(mirror)
for _, k := range unionKeys(ownValues, mirrorValues) {
a, aok := ownValues[k]
b, bok := mirrorValues[k]
ar, _ := json.Marshal(a)
br, _ := json.Marshal(b)
node, module, key := a.Node, a.Module, a.Key
if !aok {
node, module, key = b.Node, b.Module, b.Key
}
d := Difference{What: DiffValue, Node: node, Module: module, Key: key}
switch {
case !aok:
d.Mirror, d.Why = br, "only the mirror's declarations resolve it on this node"
case !bok:
d.Own, d.Why = ar, "only mesh-config's own declarations (with this node's build) resolve it"
case !bytes.Equal(ar, br):
d.Own, d.Mirror, d.Why = ar, br, "it resolves to another value or source"
default:
continue
}
diffs = append(diffs, d)
}
for _, u := range mirror.Unresolved {
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from the mirror: " + u.Why})
}
for _, u := range own.Unresolved {
if declaresNothing[u.Module] {
continue
}
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from its own source: " + u.Why})
}
sort.SliceStable(diffs, func(i, j int) bool {
a, b := diffs[i], diffs[j]
return a.What+a.Module+a.Node+a.Key+a.Pull < b.What+b.Module+b.Node+b.Key+b.Pull
@@ -492,9 +354,9 @@ func (c *Config) compare() (*Comparison, error) {
if diffs == nil {
diffs = []Difference{}
}
cmp := &Comparison{Source: sw.Source, Differences: diffs, Count: len(diffs)}
cmp := &Comparison{Source: sw.Source, Differences: diffs, Count: len(diffs), Gone: sw.Gone}
if len(diffs) == 0 {
cmp.Said = "nothing differs: the take-over may be asked"
cmp.Said = "nothing differs: the take-over may be asked (the controller's mirrors are no longer read, so only the pull requests are compared)"
} else {
cmp.Said = fmt.Sprintf("%d difference(s): the take-over is refused while any stands", len(diffs))
}
@@ -548,20 +410,6 @@ func forgeNoteOf(v string) string {
return ""
}
// normal is declarations in one encoding: a null default as none.
func normal(d map[string]Declaration) map[string]Declaration {
out := map[string]Declaration{}
for k, decl := range d {
if !decl.HasDefault() {
decl.Default = nil
} else {
decl.Default = compact(decl.Default)
}
out[k] = decl
}
return out
}
func unionKeys[A, B any](a map[string]A, b map[string]B) []string {
seen := map[string]bool{}
for k := range a {
@@ -602,13 +450,12 @@ func (t TakeoverAsk) ask() asks.Ask {
why, _ := sayable("Why: " + t.Why)
a := asks.Ask{ID: t.ID, Headline: "Switch config to its own sources",
Explanation: strings.Join([]string{
"mesh-config would read the list of predefined keys and each pull request's config from the delivery's " +
"mesh-config would read each pull request's config from the delivery's " +
"indexer, no longer from the controller. Nothing any node resolves changes: compare showed no difference.",
point(why)}, "\n"),
Who: asks.Operator, Expires: t.At.Add(ProposalLasts),
OnExpiry: "nothing changes; config keeps reading the controller's records", About: "config.takeover",
Details: "Read what was compared with mesh-config's compare verb, through the mesh MCP server. hand-back returns " +
"to the controller's records until the controller's lease is handed away."}
OnExpiry: "nothing changes; a pull request's config keeps coming from the controller's pull-declares", About: "config.takeover",
Details: "Read what was compared with mesh-config's compare verb, through the mesh MCP server. hand-back is gone."}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", "config switches to its own sources at once, if nothing differs then"},
{OptionDecline, "Decline", "nothing changes"},
@@ -770,7 +617,8 @@ func approvedTakeover(w asks.Warrant) string {
return fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
}
// HandBack returns to the controller's records until the controller's lease is handed away.
// HandBack is answered as gone: the controller's records end with its lease, and config no longer reads them (novox/hq
// ADR 0565 §4, issue 774). Nothing is written.
func (c *Config) HandBack(call Call) (*TakeoverAnswer, error) {
if strings.TrimSpace(call.Why) == "" {
return nil, errors.New("say why config hands back: it is kept with the switch")
@@ -778,30 +626,8 @@ func (c *Config) HandBack(call Call) (*TakeoverAnswer, error) {
if call.Caller == "" || call.Caller == Unproven {
return nil, errors.New("the call names no proven caller")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.leaseAway() {
return nil, errors.New("hand-back is gone: the controller handed its lease away, and its records end with it " +
"(novox/hq ADR 0565 §4)")
}
sw, err := c.source()
if err != nil {
return nil, err
}
if sw.Source == SourceController {
return &TakeoverAnswer{Source: sw.Source, Said: "config reads the controller's records already"}, nil
}
now := c.now()
back := Switch{Source: SourceController, At: now.UTC(), By: call.Caller, Why: strings.TrimSpace(call.Why)}
if err := c.Store.Put(TakeoverState, SwitchKey, back); err != nil {
return nil, fmt.Errorf("the switch could not be written: %w", err)
}
answer := &TakeoverAnswer{Source: back.Source, Said: "config reads the controller's records again: its mirrors " +
"declared-config and predefined, and pull-declares"}
if err := c.Store.HandAct("handed config back to the controller's records", back.Why, "config-hand-back"); err != nil {
answer.Said += "; the hand-act log does not hold it: " + err.Error()
}
return answer, nil
return nil, errors.New("hand-back is gone: the controller's records end with its lease and config no longer reads " +
"them, so there is nothing to hand back to (novox/hq ADR 0565 §4); nothing was changed")
}
// Declared is the read of `declared`: every module's key, or one whole.
@@ -11,21 +11,17 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The trunk's manifests aMesh declares, as the controller's mirror declared-config holds the same declarations.
// The trunk's manifests aMesh declares.
const (
claudeDeclared = `{"module":"claude-code","settings":{
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`
oldDeclared = `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`
)
// anAgreeingMesh is aProposingMesh whose controller mirrors say what mesh-config's own sources say: the mirror's
// declarations of every placed module, and the indexer's predefined list equal to the mirror's.
// anAgreeingMesh is aProposingMesh with the indexer's predefined list in place and no open pull request: compare is empty.
func anAgreeingMesh(t *testing.T) (*Config, *memStore, *standIn) {
t.Helper()
c, m, r := aProposingMesh(t)
m.mirror(t, MirrorDeclaredRead, "dunst", dunstDeclared)
m.mirror(t, MirrorDeclaredRead, "claude-code", claudeDeclared)
m.mirror(t, MirrorDeclaredRead, "old", oldDeclared)
indexerList(t, m, "aaaaaaaaaaaa", `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`)
if _, err := c.Reconcile(); err != nil {
@@ -65,9 +61,9 @@ func kinds(cmp *Comparison) []string {
return out
}
// compare lists every difference with its reason — a module's declarations, the predefined list, a pull request's
// config, and each value a node resolves both ways — and the take-over is refused while one stands (ADR 0565 §4 and
// its insight of 2026-10-29; issue 1953 L5-C4).
// compare lists every difference with its reason — a pull request's config at the indexer against what the controller
// told — and the take-over is refused while one stands (ADR 0565 §4 and its insight of 2026-10-29; issue 1953 L5-C4).
// The controller's mirrors are no longer read, so no declaration, list or value is compared (issue 774, S9).
func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
cases := []struct {
name string
@@ -75,17 +71,6 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
want []string
}{
{"all agree", func(*testing.T, *memStore) {}, nil},
{"the mirror declares a default otherwise", func(t *testing.T, m *memStore) {
m.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
}, []string{"declared:dunst", "value:dunstwidth@g14", "value:dunstwidth@novox"}},
{"the mirror lacks a placed module", func(t *testing.T, m *memStore) { _ = m.Delete(MirrorDeclaredRead, "old") },
[]string{"declared:old", "value:old@g14", "value:oldworkers@g14"}},
{"a node's build adds a key the mirror lacks", func(t *testing.T, m *memStore) {
m.want(t, "g14", "dunst", "src2:"+strings.Repeat("ab", 32), strings.Replace(dunstDeclared, `"width"`, `"height":{"type":"number","default":5},"width"`, 1))
}, []string{"value:dunstheight@g14", "value:dunstheight@novox"}}, // a key only a build declares resolves on every node (#558)
{"the lists differ", func(t *testing.T, m *memStore) {
indexerList(t, m, "bbbbbbbbbbbb", `{"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`)
}, []string{"predefined:agent-managed-settings"}},
{"a pull request only the indexer holds", func(t *testing.T, m *memStore) {
candidate(t, m, "novox/mesh-catalog#9", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#9"}},
@@ -96,10 +81,6 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
{"a forge that cannot be asked keeps the row and says why in it", func(t *testing.T, m *memStore) {
candidate(t, m, "novox/mesh-catalog#12", "h1", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
}, []string{"pull:novox/mesh-catalog#12"}},
{"a module no manifest names and the mirror declares nothing of", func(t *testing.T, m *memStore) {
m.assign(t, "g14", "mesh-wireguard")
m.mirror(t, MirrorDeclaredRead, "mesh-wireguard", `{"module":"mesh-wireguard","settings":{}}`)
}, nil},
}
for _, tc := range cases {
c, m, r := anAgreeingMesh(t)
@@ -137,7 +118,7 @@ func TestCompareListsEveryDifferenceAndTheTakeOverWaitsForNone(t *testing.T) {
}
// The operator's Approve switches config to the indexer: pull-declares is refused, the list and the pull requests'
// config come from the indexer; hand-back returns to the mirrors until the lease write and is gone after it; from the
// config come from the indexer; hand-back is gone, and a switch naming the controller is read as the indexer; from the
// lease write a trusted value is set only on an Approve; declared is written once per manifest (ADR 0565 §3, §4, §13).
func TestTheTakeOverAndTheHandBack(t *testing.T) {
c, m, r := anAgreeingMesh(t)
@@ -184,20 +165,19 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
res, err := c.Reconcile()
return strings.Join(res.Candidates, "; "), err
}, "from the indexer"},
{"hand-back returns to the mirrors", func() (string, error) {
a, err := c.HandBack(Call{Why: "a difference was found after the switch", Caller: "person.jochen"})
return answered(a), err
}, "controller's records again"},
{"after it, declarations resolve from the mirror", func() (string, error) {
m.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
_, err := c.Reconcile()
return string(m.resolved(t, "g14", "dunst", "width").Value), err
}, "400"},
{"hand-back is gone after the lease write", func() (string, error) {
m.mirror(t, LeaseRead, "holder", `{"handed-to":"the operator's hand-over"}`)
_, err := c.HandBack(Call{Why: "again", Caller: "person.jochen"})
{"hand-back is gone, naming the record (774/S9/AC1)", func() (string, error) {
_, err := c.HandBack(Call{Why: "a difference was found after the switch", Caller: "person.jochen"})
return "", err
}, "gone"},
}, "ADR 0565 §4"},
{"a switch naming the controller is read as gone, and the indexer holds (774/S9/AC2)", func() (string, error) {
m.mirror(t, TakeoverState, SwitchKey, `{"source":"controller","by":"person.jochen","why":"old hand-back"}`)
res, err := c.Reconcile()
return res.SourceGone + "|" + string(m.resolved(t, "g14", "dunst", "width").Value), err
}, "is gone: config no longer reads its records"},
{"the lease is written", func() (string, error) {
m.mirror(t, LeaseRead, "holder", `{"handed-to":"the operator's hand-over"}`)
return "lease", nil
}, "lease"},
{"the terminal sets no trusted value after the lease write", func() (string, error) {
_, err := c.Change(Call{Action: "set", Key: "managed", Module: "claude-code", Node: "g14", Value: map[string]any{},
Why: "x", Caller: ControllerCaller, By: "person.jochen"})
@@ -214,8 +194,8 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
t.Errorf("%s: said %q, want %q", s.name, text, s.want)
}
}
if !slices.Contains(m.handActs, "handed config back to the controller's records | a difference was found after the switch | config-hand-back") {
t.Errorf("the hand-back is not recorded: %v", m.handActs)
if len(m.handActs) != 0 {
t.Errorf("a hand-back that is gone recorded an act: %v", m.handActs)
}
if e, _ := m.Get(DeclaredState, "dunst"); e == nil || !strings.Contains(string(e.Value), `"manifest":"`) || !strings.Contains(string(e.Value), `"commit":"c1"`) {
t.Errorf("declared holds no dunst with its manifest: %v", e)
@@ -225,7 +205,7 @@ func TestTheTakeOverAndTheHandBack(t *testing.T) {
if _, err := c2.TakeOver(Call{Why: "now", Caller: "person.jochen"}); err != nil {
t.Fatal(err)
}
m2.mirror(t, MirrorDeclaredRead, "dunst", strings.Replace(dunstDeclared, `"default":300`, `"default":400`, 1))
candidate(t, m2, "novox/mesh-catalog#9", "h9", `{"dunst":{"gap":{"kind":"","type":"number","default":2}}}`)
r2.choose(t, r2.lastAsk(t), OptionApprove, nil)
out, _ := c2.Decide()
if sw, _ := c2.source(); sw.Source != SourceInterim || !strings.Contains(strings.Join(out, ""), "difference") {
+1 -1
View File
@@ -2,7 +2,7 @@ module mesh-config
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 (
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/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-2
View File
@@ -36,8 +36,6 @@
"mesh-delivery-builder.builds",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.manifests",
"mesh-controller.predefined",
"mesh-controller.declared-config",
"mesh-delivery-indexer.predefined",
"mesh-delivery-indexer.config-candidates",
"mesh-controller.lease"
@@ -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
// 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
// `mesh-controller_hand-acts` stays its own until it loses its lease and is read, never written: `hand-acts` answers
// over both logs, and no act is written to both (ADR 0565 §11).
// `mesh-controller_hand-acts` is no longer read (issue 774, ADR 0571 §13): its acts were copied into this log once, and
// `hand-acts` answers from this log alone.
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
@@ -282,33 +281,16 @@ type Listing struct {
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 {
now := l.Now()
since := now.Add(-time.Duration(days) * 24 * time.Hour)
out := Listing{Acts: []Act{}, Repeated: map[string]int{}, Days: days, Kept: map[string]int{}}
var all []Act
// What was copied from the controller's log is in this one: it is read from this one only (copy.go).
copied := map[string]bool{}
for _, log := range []string{ActsLog, ControllerActs} {
acts, kept, err := l.read(log, now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin)))
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...)
all, kept, err := l.read(now.Add(-max(time.Duration(days)*24*time.Hour, RepeatedWithin)))
if err != nil {
out.Unread = map[string]string{ActsLog: err.Error()}
} else {
out.Kept[ActsLog] = kept
}
for _, a := range all {
if now.Sub(a.At) <= RepeatedWithin && a.Repair() {
@@ -331,56 +313,20 @@ func (l *Log) HandActs(days int, cause, who string) Listing {
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
// controller's bucket by its keys, each of which names its time, so only those within are read whole.
func (l *Log) read(log string, since time.Time) ([]Act, int, error) {
// read is every act of this seat's log since a moment, entry by entry, and how many it keeps.
func (l *Log) read(since time.Time) ([]Act, int, error) {
var out []Act
keep := func(raw []byte, from string) {
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)
entries, err := l.Store.Entries(ActsLog)
if err != nil {
return nil, 0, err
}
for _, k := range keys {
if at, ok := keyTime(k); ok && at.Before(since) {
for _, raw := range entries {
var a Act
if json.Unmarshal(raw, &a) != nil || a.At.Before(since) {
continue
}
raw, found, err := l.Store.Get(log, k)
if err != nil {
return nil, len(keys), err
}
if found {
keep(raw, handacts.LogController)
}
a.Log = handacts.LogSeat
out = append(out, a)
}
return out, len(keys), 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
return out, len(entries), nil
}
@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"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 twice in a fortnight across both, never a person's decision; a log it cannot read is said, never read as empty.
func TestHandActsReadsBothLogs(t *testing.T) {
// hand-acts answers from this seat's log alone, newest first, narrowed by days, cause and who, and counts each cause
// repaired by hand twice in a fortnight, never a person's decision; a log it cannot read is said, never read as empty.
// 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()
put := func(log, verb, cause, by string, ago time.Duration) {
at := now.Add(-ago)
@@ -243,21 +243,22 @@ func TestHandActsReadsBothLogs(t *testing.T) {
_ = store.Put(log, actKey(at), a)
}
put(ActsLog, VerbRecord, "relogin", "person.jochen", time.Hour)
put(ControllerActs, VerbRecord, "relogin", "shanks.claude-code", 3*24*time.Hour)
put(ControllerActs, VerbDrill, CauseDrill, "person.jochen", time.Hour)
put(ActsLog, VerbRecord, "relogin", "shanks.claude-code", 3*24*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", 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 {
name string
days int
cause, who string
want int
}{
{"a fortnight", 14, "", "", 6},
{"a day", 1, "", "", 5},
{"forty days", 40, "", "", 7},
{"a fortnight", 14, "", "", 5},
{"a day", 1, "", "", 4},
{"forty days", 40, "", "", 6},
{"by cause", 14, "relogin", "", 2},
{"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 ||
got.Fill.Entries != 4 {
if got := l.HandActs(14, "", ""); got.Acts[0].Log != Seat || got.Kept[ActsLog] != 6 || len(got.Kept) != 1 || got.Fill == nil ||
got.Fill.Entries != 6 {
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")
if got := l.HandActs(14, "", ""); got.Unread[ControllerActs] == "" || len(got.Acts) != 4 {
store.keysErr[ActsLog] = errors.New("the bus is away")
if got := l.HandActs(14, "", ""); got.Unread[ActsLog] == "" || len(got.Acts) != 0 {
t.Errorf("an unreadable log: %+v", got.Unread)
}
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
// never twice: a marker makes it once, a key already in the log is not appended again, a log that cannot be read or
// written leaves no marker so the next call copies again, and hand-acts counts a copied act once (research 072 row 26).
func TestTheControllersHandActsAreCopiedOnce(t *testing.T) {
base := time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC)
old := []string{fmt.Sprintf("act-%d-1", base.UnixNano()), fmt.Sprintf("act-%d-2", base.UnixNano()+1),
fmt.Sprintf("act-%d-3", base.UnixNano()+2), "warrant-x-y"}
cases := []struct {
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},
// The copy of the controller's hand acts is over: the tools answer so, with the marker of the copy that was made, and
// read and write nothing of the controller's bucket nor of the log (774/S10; research 072 row 26).
func TestTheCopyOfTheControllersHandActsIsOver(t *testing.T) {
l, store, _, now := newLog()
store.keysErr[ControllerActs] = errors.New("the controller's bucket must not be read")
st, err := l.Copy("test", true)
if err != nil || st.Done || !strings.Contains(st.Said, "no longer read") {
t.Errorf("without a marker: %+v, %v", st, err)
}
for _, tc := range cases {
l, store, _, _ := newLog()
for _, k := range old {
_ = store.Put(ControllerActs, k, Act{ID: k, At: base, By: "ace.mesh-config", Verb: VerbRecord, Why: "w", Cause: "c"})
}
if tc.junk {
_ = store.Put(ControllerActs, fmt.Sprintf("act-%d-4", base.UnixNano()+3), "not an act")
}
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)
}
_ = store.Put(CopiedState, CopyKey, CopyMarker{At: *now, By: "start", Source: ControllerActs, Found: 606, Copied: 606})
st, err = l.Copy("test", false)
if err != nil || !st.Done || st.Marker.Copied != 606 || !strings.Contains(st.Said, "no longer read") || len(store.logKeys(ActsLog)) != 0 {
t.Errorf("with a marker: %+v, %v", st, err)
}
if s, err := l.Copied(); err != nil || !s.Done || s.Marker == nil {
t.Errorf("copied says %+v, %v", s, err)
}
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+19 -121
View File
@@ -1,22 +1,14 @@
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
// §13). The controller kept every act of its life in its bucket `mesh-controller_hand-acts` (574 on 2026-10-31); from
// the lease write only this seat reads them, so they are copied once into `acts`, each as the controller wrote it: the
// same key, the same bytes, so its time and its author are what they were.
//
// **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.
// 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, issue 774). The controller kept every act of its life in its bucket `mesh-controller_hand-acts`; they were copied
// once into `acts`, each as the controller wrote it, by the earlier builds of this module, which left the marker
// `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.
import (
"encoding/json"
"errors"
"fmt"
"sort"
"time"
)
@@ -47,92 +39,32 @@ type CopyStatus struct {
Marker *CopyMarker `json:"marker,omitempty"`
// Said is a sentence for a person: what this call did or found.
Said string `json:"said"`
// Pending are the old acts not yet in this seat's log (counted only while the copy is not whole).
Pending *int `json:"pending,omitempty"`
Old *int `json:"old-log-keys,omitempty"`
New *int `json:"new-log-entries,omitempty"`
// New is how many acts this seat's log holds.
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
// the marker exists, appending only the acts the log does not hold yet (the controller's, written since the copy), and
// leaves the marker as it was. An old log that cannot be
// read, or an act that cannot be appended, is an error and leaves no marker, so the next call copies again.
// gone is what a call to copy answers.
const gone = "the controller's hand-act log is no longer read, so nothing can be copied (novox/hq ADR 0571 §13, issue 774); " +
"the acts copied before are in the log `acts`"
// 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) {
marked := false
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)
st, err := l.Copied()
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)
m := CopyMarker{By: by, Source: ControllerActs, Found: len(keys)}
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
st.Said = gone + "; " + st.Said
return st, 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) {
raw, found, err := l.Store.Get(CopiedState, CopyKey)
if err != nil {
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 {
var m CopyMarker
if err := json.Unmarshal(raw, &m); err != nil {
@@ -145,39 +77,5 @@ func (l *Log) Copied() (CopyStatus, error) {
n := int(f.Entries)
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
}
// 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
// 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
// `hand-acts`. stdout is the MCP channel; what this module says, it says on stderr.
// 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
// (issue 774). stdout is the MCP channel; what this module says, it says on stderr.
package main
import (
@@ -77,15 +77,6 @@ func (runtimeStore) Fill(log string) (Fill, error) {
func main() {
l := &Log{Store: runtimeStore{}, Now: time.Now, Logf: logf,
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 {
logf("%v", err)
os.Exit(1)
@@ -43,10 +43,10 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
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.
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)
}
readBy := map[string]string{ActsLog: HandActsTool, WarrantsState: HandActsTool, ControllerActs: HandActsTool,
readBy := map[string]string{ActsLog: HandActsTool, WarrantsState: HandActsTool,
RouterRecords: WarrantedTool, CopiedState: CopiedTool}
tools := map[string]bool{}
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"))
}},
{Name: HandActsTool,
Description: "What was done by hand lately, over both logs (this seat's and the controller's until its lease " +
"ends), newest first: each act with who, why, its cause and which log holds it, and the causes repaired by " +
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 " +
"hand twice or more within a fortnight (a healer is wanted). Narrowed by cause and who; warrants=true lists " +
"every warrant recorded instead.",
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"))
}},
{Name: CopiedTool,
Description: "Where the one-time copy of the controller's hand acts into this seat's log stands (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 " +
"full both logs are. Changes nothing.",
Description: "Where the one-time copy of the controller's hand acts into this seat's log ended (novox/hq research 072 " +
"row 26): the marker with when, by what and how many acts were copied, and how full the log is. The copy " +
"cannot run any more: the controller's log is no longer read. Changes nothing.",
Input: obj(map[string]any{}),
Run: func(map[string]any) (any, error) { return l.Copied() }},
{Name: CopyTool,
Description: "Copy the controller's hand acts (its bucket mesh-controller_hand-acts) into this seat's log once, each " +
"with its own key, time and author. Does nothing when the copy is already done; an act already in the log is " +
"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.",
Description: "Answers that the copy is over (the controller's hand-act log, its bucket mesh-controller_hand-acts, " +
"is no longer read, novox/hq ADR 0571 §13) with the marker of the copy that was made; copies nothing.",
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) {
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 {
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")
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)+
@@ -402,22 +402,17 @@ func TestTheHandActLogWholeRecordsOnTheBus(t *testing.T) {
t.Fatal(err)
}
}
for i := 0; ; i++ {
if c, err := p.call(CopiedTool, "person.jochen", map[string]any{}); err == nil && strings.Contains(string(c), `"done":true`) {
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 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`) {
t.Errorf("the copy's tool answered %s, %v", c, err)
}
if e, err := logStream.GetLastMsgForSubject(ctx, "mesh.log."+Module+"."+ActsLog+"."+oldKey); err != nil || !strings.Contains(string(e.Data), `"by":"ace.old"`) {
t.Errorf("the controller's act is not copied with its author: %v", err)
if _, err := logStream.GetLastMsgForSubject(ctx, "mesh.log."+Module+"."+ActsLog+"."+oldKey); err == nil {
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 {
t.Errorf("the log keeps %v, want the copied act, the act and one warrant (%v)", info, err)
if info, err := logStream.Info(ctx); err != nil || info.State.Msgs != 2 {
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{})
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)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-hand-acts
go 1.26.0
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
)
+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/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-1
View File
@@ -14,7 +14,6 @@
}
],
"reads": [
"mesh-controller.hand-acts",
"messenger.asks"
],
"logs": [
@@ -45,7 +45,6 @@ const (
LeaseRead = buscompose.ControllerLease
LeaseKey = buscompose.ControllerLeaseKey
ConditionsRead = "mesh-conditions.conditions"
HandActsRead = "mesh-controller.hand-acts"
ActedEvent = health.EventHealerActed
// ActedSubject is where the seat says it, which its consumers hear (catalogue.SeatSays).
ActedSubject = "mesh.seat." + Seat + ".event." + ActedEvent
@@ -374,16 +374,19 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) {
w := newWorld()
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})
w.h.HandActHeard(key, "put", raw)
w.h.ActRecorded(raw)
}
act("a1", "broker consumer-reset", "consumer-behind", 10*24*time.Hour)
if keys := w.store.keys(FindingsState); len(keys) != 0 {
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",
Verb: "broker consumer-reset", Why: "x", Cause: "consumer-behind"})
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.
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"})
@@ -408,9 +411,8 @@ func TestACauseRepairedByHandTwiceIsAHealerWanted(t *testing.T) {
if keys := w.store.keys(FindingsState); len(keys) != 0 {
t.Errorf("past the fortnight the finding stays: %v", keys)
}
w.h.HandActHeard("a5", "delete", nil)
if len(w.h.acts) != 7 {
t.Errorf("a deleted entry is kept: %d", len(w.h.acts))
if len(w.h.acts) != 8 {
t.Errorf("an act heard twice or listed after it was heard is kept twice: %d acts", 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,
// 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
// condition-raised and condition-changed on a durable consumer, watches the controller's lease, and watches the
// controller's hand-act log for S15. stdout is the MCP channel; what this module says, it says on stderr.
// condition-raised and condition-changed on a durable consumer, watches the controller's lease, and counts the seat's hand acts
// for S15. stdout is the MCP channel; what this module says, it says on stderr.
package main
import (
@@ -68,11 +68,25 @@ func main() {
h.WakeHeard(c.Key, c.Op)
return nil
})
go stdio.State(HandActsRead).Follow("", func(c stdio.StateChange) error {
h.HandActHeard(c.Key, c.Op, c.Value)
return nil
})
// S15 over both logs: the seat's act-recorded on a durable consumer, beside the controller's bucket's watch.
// S15 over the seat's log: the fortnight's acts asked of the seat once at the start, then act-recorded on a
// durable consumer (the controller's bucket is no longer read, issue 774).
go func() {
<-stdio.Served()
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() {
<-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) {
@@ -49,8 +49,8 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
}{
{"emits (healer-acted is the seat's, said on its subject)", m.Emits, nil},
{"consumes", m.Consumes, []string{RaisedEvent, ChangedEvent, RecordedEvent}},
{"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act"}},
{"reads", m.Reads, []string{LeaseRead, ConditionsRead, HandActsRead}},
{"invokes", m.Invokes, []string{AssertTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts"}},
{"reads", m.Reads, []string{LeaseRead, ConditionsRead}},
} {
if !slices.Equal(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
// 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
// watches it, the one of the four holders granted its read, counts each cause again on every put, and writes the
// finding `healer-wanted` per cause. **A heal is never a hand act**, and an act that is a person's decision by design
// (a drill above all) never counts. The finding clears when fewer than two acts of its cause remain within the
// fortnight: the key `fortnight` of the healers' wakes expires when the oldest counted act leaves it. From the log's home (issue 1953) they
// watch that instead.
// The hand-act log is the seat mesh-hand-acts' (ADR 0565 §10, §11), which says each act as act-recorded: mesh-healers
// hears them, counts each cause again on every act, and writes the finding `healer-wanted` per cause. At its start it
// asks the seat for the fortnight's acts once, so a restart does not forget them. The controller's bucket of hand acts
// 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
// design (a drill above all) never counts. The finding clears when fewer than two acts of its cause remain within the
// fortnight: the key `fortnight` of the healers' wakes expires when the oldest counted act leaves it.
import (
"encoding/json"
@@ -22,7 +22,7 @@ import (
// Within is how far back a repeated cause counts.
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 {
ID string `json:"id"`
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
// 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{
"warrant": nil,
"seat": {"seat-passed"},
@@ -68,34 +68,37 @@ func personsDecision(a HandAct) bool {
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
// log's, under a key of its own log, so an act is counted once whichever log holds it (ADR 0565 §11).
// ActRecorded is one act the seat mesh-hand-acts recorded, heard as act-recorded or listed by its verb `hand-acts`:
// 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) {
var a HandAct
if json.Unmarshal(body, &a) != nil || a.ID == "" {
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()
if h.acts == nil {
h.acts = map[string]HandAct{}
}
if op != "put" {
delete(h.acts, key)
} else {
var a HandAct
if json.Unmarshal(value, &a) == nil {
h.acts[key] = a
}
}
h.acts[handacts.LogSeat+":"+a.ID] = a
h.mu.Unlock()
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,
// deleting the rest; its wait `fortnight` expires when the oldest counted act of a wanted cause leaves the fortnight.
func (h *Healers) Wanted() []Observation {
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-healers
go 1.26.0
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
)
+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/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+3 -3
View File
@@ -20,12 +20,12 @@
],
"invokes": [
"mesh-grants.grants_assert",
"seat:mesh-hand-acts.hand-act"
"seat:mesh-hand-acts.hand-act",
"seat:mesh-hand-acts.hand-acts"
],
"reads": [
"mesh-controller.lease",
"mesh-conditions.conditions",
"mesh-controller.hand-acts"
"mesh-conditions.conditions"
],
"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 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
// carrying the run's id and counts":
//
// - The event is the mesh-controller seat's own, `doctor-heartbeat`, consumed as
// `mesh-controller.doctor-heartbeat` (subject `mesh.seat.mesh-controller.event.doctor-heartbeat`).
// - The event is the seat mesh-self-check's `checked`, consumed as `mesh-self-check.checked` (subject
// `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),
// `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
// as "5m") for the interval.
// - 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.
// - 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 (
"encoding/json"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// HeartbeatEvent is the event's local name under the controller's seat.
const (
HeartbeatEvent = "doctor-heartbeat"
ControllerSeat = "mesh-controller"
)
// HeartbeatEvent is the event's local name under the seat mesh-self-check.
const HeartbeatEvent = health.EventChecked
// Beat is one heartbeat.
type Beat struct {
@@ -38,8 +38,8 @@ type Beat struct {
Counts map[string]int
}
// isHeartbeat says whether an envelope's key is the controller's heartbeat.
func isHeartbeat(key string) bool { return key == ControllerSeat+"."+HeartbeatEvent }
// isHeartbeat says whether an envelope's key is the self-check's heartbeat.
func isHeartbeat(key string) bool { return key == health.SelfCheckModule+"."+HeartbeatEvent }
// DecodeBeat reads one heartbeat's body.
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
// node's runtime launches on one machine that is not the control node. It hears the controller's
// self-check heartbeat and makes a round trip through the bus every minute; when either goes silent
// node's runtime launches on one machine that is not the control node. It hears the self-check's
// 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
// 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.
@@ -100,10 +100,8 @@ func run(w *Watcher, l *listening, node string) {
time.Sleep(10 * time.Minute)
}
}()
follow := newTakeover()
go followTakeover(follow)
handle := func(e stdio.Envelope) error {
if !follow.heard(e.Key) {
if !isHeartbeat(e.Key) {
return nil
}
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
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle)
if err == nil {
err = stdio.Subscribe(health.SelfCheckModule+".*", handle)
}
err := stdio.Subscribe(health.SelfCheckModule+".*", handle)
if err == nil {
l.set("listening")
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",
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 " +
"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.",
Run: func(map[string]any) (any, error) { return w.Status(l.get()), nil }},
{Name: "watcher_last_heard",
@@ -34,7 +34,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
if err := json.Unmarshal(raw, &m); err != nil {
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)
}
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:
//
// - 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
// was made, not by when it arrived: a backlog delivered after a restart is old news, not a sign of
// life.
@@ -224,7 +224,7 @@ func (w *Watcher) Tick() {
func (w *Watcher) what(s *signal) string {
if s.name == SelfCheck {
return "the controller's self-check (doctor)"
return "the self-check"
}
return "the bus"
}
@@ -253,7 +253,7 @@ func TestTheHeartbeatAsTheDesignSaysIt(t *testing.T) {
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")
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ module mesh-watcher
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 (
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/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=
-2
View File
@@ -3,11 +3,9 @@
"version": "1",
"slug": "watch",
"consumes": [
"mesh-controller.doctor-heartbeat",
"mesh-self-check.checked"
],
"reads": [
"mesh-conditions.takeover",
"mesh-delivery-assigner.seat-holders"
],
"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`
(`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.
- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) and **consumes its
condition events**; the events are read in one place, `cmd/messenger/condition.go`.
- **Reads the condition keeper's open conditions** (`seat:mesh-conditions.conditions`) and **consumes its
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
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
@@ -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:
- **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
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
@@ -19,7 +19,7 @@ func (b *conditionBed) controllerEvent(t *testing.T, event string, body any) {
if err != nil {
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 {
t.Fatal(err)
}
+3 -3
View File
@@ -479,8 +479,8 @@ func answerLevel(a asks.Ask) asks.Level {
return asks.Acknowledge
}
// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which
// raises the conditions.
// ConditionOwner is the module whose asks may be shown on a condition's message: the controller, which raised the
// conditions; the condition keeper (ConditionSeat), which raises them now, is one too.
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
@@ -651,7 +651,7 @@ func (h *Holder) recordFor(asker, key string, a asks.Ask) string {
defer h.mu.Unlock()
// 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.
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" {
return r.Key
}
+15 -17
View File
@@ -1,14 +1,14 @@
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
// 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
// `condition-cleared`, consumed as `mesh-controller.<event>` (subject
// `mesh.seat.mesh-controller.event.<event>`), exactly as `applied` and `built-before` are.
// - The events are the seat mesh-conditions': `condition-raised`, `condition-changed` and
// `condition-cleared`, consumed as `mesh-conditions.<event>` (subject
// `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
// 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
@@ -27,7 +27,7 @@ package main
// - 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
// 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.
//
// An event this cannot read is refused by name — which event, which field, why — counted, said in
@@ -40,15 +40,16 @@ import (
"time"
"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 (
EventRaised = "condition-raised"
EventChanged = "condition-changed"
EventCleared = "condition-cleared"
// ControllerSeat is the role the events are stated under.
ControllerSeat = "mesh-controller"
// ConditionSeat is the role the events are stated under, and the verb `conditions` is asked of.
ConditionSeat = health.KeeperModule
)
// 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)
}
// eventOf is the local event name of an envelope's key: `mesh-controller.condition-raised` is
// `condition-raised`. Anything not of the controller's seat is not a condition event.
func eventOf(key string) (string, bool) { return eventOfSeat(key, ControllerSeat) }
// 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) {
// eventOf is the local event name of an envelope's key: `mesh-conditions.condition-raised` is
// `condition-raised`. Anything not of the keeper's seat is not a condition event.
func eventOf(key string) (string, bool) {
emitter, event, ok := strings.Cut(key, ".")
if !ok || emitter != seat {
if !ok || emitter != ConditionSeat {
return "", false
}
switch event {
@@ -6,19 +6,19 @@ import (
"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{
"mesh-controller.condition-raised": EventRaised,
"mesh-controller.condition-changed": EventChanged,
"mesh-controller.condition-cleared": EventCleared,
"mesh-conditions.condition-raised": EventRaised,
"mesh-conditions.condition-changed": EventChanged,
"mesh-conditions.condition-cleared": EventCleared,
} {
if got, ok := eventOf(key); !ok || got != want {
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 {
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) {
t.Helper()
for _, e := range backlog {
event, ok := eventOf(ControllerSeat + "." + e.event)
event, ok := eventOf(ConditionSeat + "." + e.event)
if !ok {
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"
"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...) }
@@ -413,12 +412,11 @@ func run(h *Holder, l *listening) {
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
// again in the loop below, and old events meanwhile are kept as state, never said.
follow := newTakeover()
h.Start()
syncFromController(h, follow)
syncFromKeeper(h)
go func() {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
@@ -431,11 +429,10 @@ func run(h *Holder, l *listening) {
}
if h.WantsSync() && now.Sub(lastAsk) >= time.Minute {
lastAsk = now
syncFromController(h, follow)
syncFromKeeper(h)
}
}
}()
go followTakeover(follow, h)
handle := func(e stdio.Envelope) error {
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).
@@ -449,9 +446,8 @@ func run(h *Holder, l *listening) {
}
return nil
}
// Only the seat the take-over key names is heard: the other's events are dropped, so the keeper's conditions
// are never held a second time beside the controller's (ADR 0564 clause 9).
event, ok := eventOfSeat(e.Key, follow.seat())
// Only the keeper's condition events are heard (ADR 0564 clause 9; the controller's are no longer consumed).
event, ok := eventOf(e.Key)
if !ok {
return nil
}
@@ -467,7 +463,7 @@ func run(h *Holder, l *listening) {
go takeAsks(h)
go answerProofs(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() {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
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) {
err := stdio.Subscribe(ControllerSeat+".*", handle)
if err == nil {
err = stdio.Subscribe(health.KeeperModule+".*", handle)
}
err := stdio.Subscribe(ConditionSeat+".*", handle)
if err == nil {
l.set("listening")
logf("[messenger] listening for the controller's condition events")
logf("[messenger] listening for the condition keeper's condition events")
return
}
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.
func syncFromController(h *Holder, follow *takeover) {
// syncFromKeeper reads the condition keeper's open conditions and gives them to the holder.
func syncFromKeeper(h *Holder) {
asked := h.Now()
type answer struct {
raw json.RawMessage
@@ -641,7 +634,7 @@ func syncFromController(h *Holder, follow *takeover) {
}
done := make(chan answer, 1)
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}
}()
var a answer
@@ -80,14 +80,21 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { //
t.Fatalf("uses %v", m.Uses)
}
if !reflect.DeepEqual(m.Consumes, []string{
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
"mesh-conditions." + EventRaised, "mesh-conditions." + EventChanged, "mesh-conditions." + EventCleared,
ViewModule + "." + EventMoving, ViewModule + "." + EventSettled, ViewModule + "." + EventStuck, "intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
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
// 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}) {
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) {
key = strings.TrimSpace(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)
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",
key, r.Origin, strings.Join(conditions, ", "))
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:
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)
}
if _, err := h.Preview("", ""); err == nil || !strings.Contains(err.Error(), "key is required") {
t.Errorf("no key: %v", err)
if _, err := h.Preview("", ""); err == nil || !strings.Contains(err.Error(), "key is required") ||
!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") {
t.Errorf("an unknown message: %v", err)
-8
View File
@@ -180,11 +180,3 @@ func (h *Holder) WantsSync() bool {
h.init()
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, " +
"mnt-recalbox (a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh " +
"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"}}},
resolved: "No failed services on shanks any more", popup: true,
title: "3 failed services on shanks",
+1 -1
View File
@@ -3,7 +3,7 @@ module messenger
go 1.26.0
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.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/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-7
View File
@@ -74,9 +74,6 @@
"channel"
],
"consumes": [
"mesh-controller.condition-raised",
"mesh-controller.condition-changed",
"mesh-controller.condition-cleared",
"mesh-conditions.condition-raised",
"mesh-conditions.condition-changed",
"mesh-conditions.condition-cleared",
@@ -92,14 +89,10 @@
"refused"
],
"invokes": [
"seat:mesh-controller.conditions",
"seat:mesh-conditions.conditions",
"seat:node-engine.root-free",
"seat:issue-tracker.tracking"
],
"reads": [
"mesh-conditions.takeover"
],
"state": [
"open",
"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() {
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)
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
// 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) {
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) {
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
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/nats-io/nats-server/v2 v2.11.17
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/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+7 -2
View File
@@ -31,7 +31,11 @@
"emits": [
"consumer-removed",
"bucket-keys-removed",
"stream-removed"
"stream-removed",
"bucket-expiry-set"
],
"invokes": [
"seat:mesh-hand-acts.hand-act"
],
"consumes": [],
"listens": [
@@ -56,7 +60,8 @@
"nats_snapshot_verify",
"nats_consumer_remove",
"nats_bucket_remove",
"nats_stream_remove"
"nats_stream_remove",
"nats_bucket_expiry"
],
"guards": [
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
`<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
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
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
@@ -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
// (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)
}
}
+44 -59
View File
@@ -20,18 +20,13 @@ import (
// through the manifest's `reads`.
const DeclaredPackagesState = SourceEngine
// ControllerDeclaredState is the controller's mirror of the same packages (ADR 0507 §5), the per-node fallback for a node
// whose engine has written no key yet, until the controller's lease is written away (ADR 0565 clause 6).
const ControllerDeclaredState = "mesh-controller.declared-packages"
// SourceEngine is the one source a node's declaration is read from, said in the answer: the node-engine's state, read by
// its manifest's name (mesh-host.packages, its bucket mesh-host_packages; mesh-sdk delivery.EnginePackagesRead). The
// 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
// 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).
// declaredValue is a node's key of `packages`, in mesh-sdk's layout.
type declaredValue = delivery.DeclaredPackages
// 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
}
// declaredOn is what each node's modules declare present, by node and package, with the declaring modules, and the source
// that answered for each node: its engine's key, else the controller's mirror; a node neither holds is missing. When the
// 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
// 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
// cannot be read either (ADR 0565 clause 6).
func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, string, error) {
// declaredOn is what each node's modules declare present, by node and package, with the source that answered for each
// node (its engine's key), the nodes whose engine has written none, and a word when the engine's state cannot be read at
// 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.
// Nothing else is read: the controller's mirror ended with its lease (ADR 0565 clause 6).
func (m *Machine) declaredOn(nodes []string) (map[string]map[string][]string, map[string]string, []string, string) {
out := map[string]map[string][]string{}
from := map[string]string{}
engineErr := ""
for _, source := range []string{SourceEngine, SourceController} {
r := m.States(source)
read := map[string]map[string][]string{}
readFrom := map[string]string{}
var failed error
for _, n := range nodes {
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
}
}
var none []string
r := m.States(SourceEngine)
for _, n := range nodes {
var v declaredValue
found, err := readValue(r, n, &v)
if err != nil {
return map[string]map[string][]string{}, map[string]string{}, nil, SourceEngine + " cannot be read: " + err.Error()
}
if failed != nil {
if source == SourceEngine {
engineErr = SourceEngine + " cannot be read: " + failed.Error()
continue
}
return nil, nil, "", failed
if !found {
none = append(none, n)
continue
}
for n, d := range read {
out[n], from[n] = d, readFrom[n]
from[n] = SourceEngine
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.
@@ -178,10 +162,10 @@ type NodeView struct {
Current bool `json:"current"`
NotCurrent *NotCurrent `json:"not_current,omitempty"`
// 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"`
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"`
}
@@ -201,7 +185,7 @@ type PackageOnNode struct {
Foreign bool `json:"foreign,omitempty"`
DeclaredBy []string `json:"declared_by"`
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"`
}
@@ -217,10 +201,7 @@ func (m *Machine) Packages(name string, limit int) (map[string]any, error) {
for _, ni := range all {
nodes = append(nodes, ni.Node)
}
declared, sources, engineErr, err := m.declaredOn(nodes)
if err != nil {
return nil, err
}
declared, sources, none, engineErr := m.declaredOn(nodes)
if name != "" {
on := []PackageOnNode{}
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}
if engineErr != "" {
answer["declared_engine_error"] = engineErr
} else if w := declaredWord(none); w != "" {
answer["declared_none"] = w
}
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}
if 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} {
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) {
states, m := viewStates(t)
_, m := viewStates(t)
r, err := m.Packages("zsh", 500)
if err != nil {
t.Fatal(err)
@@ -56,22 +56,22 @@ func TestOnePackageOnEveryNodeWithWhoDeclaresIt(t *testing.T) {
if a.DeclaredFrom != SourceEngine || 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.
var legacy, stale declaredValue
json.Unmarshal([]byte(`{"node":"node-b","packages":[{"package":"zsh","modules":["legacy"],"absent":false}]}`), &legacy)
json.Unmarshal([]byte(`{"node":"node-a","packages":[{"package":"zsh","modules":["stale"],"absent":false}]}`), &stale)
mirror := states.store(ControllerDeclaredState)
mirror.Put("node-b", legacy)
mirror.Put("node-a", stale)
// A node whose engine has written no key has no declaration, said so, and no controller state is read (774/S7/AC2);
// an engine whose state cannot be read at all is said once beside the sources, for every node (774/S7/AC1 reads the
// engine's key above).
read := m.States
m.States = func(name string) StateReader {
if name != SourceEngine && name != StateInstalled {
t.Errorf("state %s was read: only the engine's packages are", name)
}
return read(name)
}
r2, _ := m.Packages("zsh", 500)
on2 := r2["nodes"].([]PackageOnNode)
if on2[0].DeclaredFrom != SourceEngine || strings.Join(on2[0].DeclaredBy, ",") != "login,shell" ||
on2[1].DeclaredFrom != SourceController || strings.Join(on2[1].DeclaredBy, ",") != "legacy" {
t.Errorf("fallback: %+v", on2)
if on2[0].DeclaredFrom != SourceEngine || on2[1].DeclaredFrom != "" || len(on2[1].DeclaredBy) != 0 ||
!strings.Contains(fmt.Sprint(r2["declared_none"]), "no packages key for node-b") {
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 {
if name == SourceEngine {
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)
on3, _ := r3["nodes"].([]PackageOnNode)
if err3 != nil || len(on3) != 2 || on3[0].DeclaredFrom != SourceController || strings.Join(on3[0].DeclaredBy, ",") != "stale" ||
on3[1].DeclaredFrom != SourceController || !strings.Contains(fmt.Sprint(r3["declared_engine_error"]), "not on the bus yet") {
if err3 != nil || len(on3) != 2 || on3[0].DeclaredFrom != "" || on3[1].DeclaredFrom != "" ||
!strings.Contains(fmt.Sprint(r3["declared_engine_error"]), "not on the bus yet") {
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
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 {
+1 -1
View File
@@ -3,7 +3,7 @@ module pacman
go 1.26.0
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
)
+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/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -2
View File
@@ -12,8 +12,7 @@
}
],
"reads": [
"mesh-host.packages",
"mesh-controller.declared-packages"
"mesh-host.packages"
],
"uses": [
"operator-channel"