M-12: the switch's settings, read again at every reading (issue 1409)

The settings gain switch_auto, switch_at_pct, room_below_pct, warn_at_pct,
usage_minutes and usage_stale_minutes with the manifest's defaults and
bounds, a value outside its bound keeping the default. The settings file is
read again at every reading, each changed value written in the audit under
settings, and the usage timer follows usage_minutes. A room or warning above
switch_at_pct is found as an invalid pair naming both values.

Builds: design 39 bus-and-switch §33, §24

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-11 23:57:38 +01:00
parent 6cf98386e9
commit 954e3afe56
5 changed files with 281 additions and 61 deletions
@@ -86,12 +86,13 @@ func manager() (*Manager, error) {
}
return a, json.Unmarshal(raw, &a)
},
Reported: heard.Of,
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
Reported: heard.Of,
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"),
}
return built, nil
}
@@ -196,7 +197,12 @@ func daemon() {
}()
refresh := time.NewTicker(time.Minute)
usage := time.NewTicker(5 * time.Minute)
// Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings.
every := Defaults.UsageEvery
if m, err := manager(); err == nil {
every = m.settings().UsageEvery
}
usage := time.NewTimer(every)
for {
select {
case <-refresh.C:
@@ -216,7 +222,9 @@ func daemon() {
if err := m.ReadUsage(context.Background()); err != nil {
say("reading usage failed: %v", err)
}
every = m.settings().UsageEvery
}
usage.Reset(every)
}
}
}
@@ -23,6 +23,7 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"
)
@@ -123,49 +124,6 @@ func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) er
return err
}
// Settings are the manager's own, declared with defaults (design 39 §8).
type Settings struct {
Cadence time.Duration // rotate a grant once older than this
Floor time.Duration // refresh in any case with less than this left
FailuresToNotify int
Cooldown time.Duration // at most one notification per licence in this window
RefreshWarn time.Duration // warn this long before a refresh token itself expires
// UsageStale is how old a licence's latest successful reading may be for its room to be known (§25, §33).
UsageStale time.Duration
}
// Defaults are the settings a fresh mesh runs with.
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour,
RefreshWarn: 72 * time.Hour, UsageStale: 15 * time.Minute}
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
func SettingsFrom(path string) Settings {
s := Defaults
raw, err := os.ReadFile(path)
if err != nil {
return s
}
var f map[string]float64
if json.Unmarshal(raw, &f) != nil {
return s
}
set := func(k string, unit time.Duration, into *time.Duration) {
if v := f[k]; v > 0 {
*into = time.Duration(v * float64(unit))
}
}
set("cadence_minutes", time.Minute, &s.Cadence)
set("floor_minutes", time.Minute, &s.Floor)
set("cooldown_hours", time.Hour, &s.Cooldown)
if v := f["refresh_warn_days"]; v > 0 {
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
}
if v := f["failures_to_notify"]; v > 0 {
s.FailuresToNotify = int(v)
}
return s
}
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
type GrantAnswer struct {
Sealed *SealedBox `json:"sealed"`
@@ -193,8 +151,12 @@ type Manager struct {
Now func() time.Time
Log func(format string, args ...any)
// Holder names this process in a lease, so a second run is told apart.
Holder string
Settings Settings
Holder string
// Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile
// keeps them.
Settings Settings
SettingsFile string
settingsMu sync.RWMutex
}
const leaseFor = 2 * time.Minute
@@ -595,7 +557,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
}
now := m.Now()
if !force && !Due(*l, now, m.Settings) {
if !force && !Due(*l, now, m.settings()) {
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
}
g, err := m.grantOf(ctx, *l)
@@ -610,7 +572,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil {
return nil, err
}
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.settings().FailuresToNotify)
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
}
@@ -622,7 +584,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
if l.RefreshExpiresAt != 0 {
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
left < m.Settings.RefreshWarn)
left < m.settings().RefreshWarn)
}
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
@@ -654,7 +616,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) {
return
}
now := m.Now().UnixMilli()
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() {
return
}
l.NotifiedAt = now
@@ -669,7 +631,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
}
var out []map[string]any
for _, l := range all {
if Due(l, m.Now(), m.Settings) {
if Due(l, m.Now(), m.settings()) {
r, err := m.Rotate(ctx, l.Name, false)
if err != nil {
return out, err
@@ -682,6 +644,9 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
func (m *Manager) ReadUsage(ctx context.Context) error {
if err := m.ReadSettings(ctx); err != nil {
return err
}
all, err := m.Store.Licences(ctx)
if err != nil {
return err
@@ -748,7 +713,7 @@ func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any,
return nil, err
}
out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation,
"health": Health(*l, m.Now(), m.Settings)}
"health": Health(*l, m.Now(), m.settings())}
if l.AccessExpiresAt != 0 {
out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)
}
@@ -263,11 +263,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
mm.now = t0.Add(5 * time.Hour)
second := *mm.m
second.Holder = "another run"
second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys,
AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run",
Settings: mm.m.Settings}
var wg sync.WaitGroup
results := make([]map[string]any, 2)
for i, m := range []*Manager{mm.m, &second} {
for i, m := range []*Manager{mm.m, second} {
wg.Add(1)
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
}
@@ -0,0 +1,147 @@
package main
// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each
// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the
// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a
// value read is written in the audit under `settings`.
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"time"
)
// Settings are the manager's own, declared with defaults.
type Settings struct {
Cadence time.Duration // rotate a grant once older than this
Floor time.Duration // refresh in any case with less than this left
FailuresToNotify int
Cooldown time.Duration // at most one notification per licence in this window
RefreshWarn time.Duration // warn this long before a refresh token itself expires
// SwitchAuto is whether a reading at its threshold moves the licence's consumers (§26).
SwitchAuto bool
// SwitchAt, RoomBelow and WarnAt are percentages: where a licence's consumers move, what a target's windows must
// both be below, and where a licence with consumers is warned about.
SwitchAt, RoomBelow, WarnAt float64
// UsageEvery is how often each subscription's usage is read.
UsageEvery time.Duration
// UsageStale is how old a licence's latest successful reading may be for its room to be known (§25).
UsageStale time.Duration
}
// Defaults are the settings a fresh mesh runs with.
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour,
RefreshWarn: 72 * time.Hour, SwitchAuto: true, SwitchAt: 99, RoomBelow: 90, WarnAt: 90, UsageEvery: 5 * time.Minute,
UsageStale: 15 * time.Minute}
// SettingsFrom reads the settings file. Today's five keep a default for anything absent or not positive; the six of
// the switch keep a default for anything absent or outside the bound the manifest declares (§33).
func SettingsFrom(path string) Settings {
s := Defaults
raw, err := os.ReadFile(path)
if err != nil {
return s
}
var f map[string]any
if json.Unmarshal(raw, &f) != nil {
return s
}
num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok }
set := func(k string, unit time.Duration, into *time.Duration) {
if v, ok := num(k); ok && v > 0 {
*into = time.Duration(v * float64(unit))
}
}
set("cadence_minutes", time.Minute, &s.Cadence)
set("floor_minutes", time.Minute, &s.Floor)
set("cooldown_hours", time.Hour, &s.Cooldown)
set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn)
if v, ok := num("failures_to_notify"); ok && v > 0 {
s.FailuresToNotify = int(v)
}
if v, ok := f["switch_auto"].(bool); ok {
s.SwitchAuto = v
}
bounded := func(k string, lo, hi float64) (float64, bool) {
v, ok := num(k)
return v, ok && v >= lo && v <= hi
}
for _, p := range []struct {
key string
into *float64
}{{"switch_at_pct", &s.SwitchAt}, {"room_below_pct", &s.RoomBelow}, {"warn_at_pct", &s.WarnAt}} {
if v, ok := bounded(p.key, 1, 100); ok {
*p.into = v
}
}
if v, ok := bounded("usage_minutes", 1, 60); ok {
s.UsageEvery = time.Duration(v * float64(time.Minute))
}
if v, ok := bounded("usage_stale_minutes", 5, 180); ok {
s.UsageStale = time.Duration(v * float64(time.Minute))
}
return s
}
// Values are the settings by their names in the manifest's `config`, in its units.
func (s Settings) Values() map[string]any {
return map[string]any{"cadence_minutes": s.Cadence.Minutes(), "floor_minutes": s.Floor.Minutes(),
"failures_to_notify": float64(s.FailuresToNotify), "cooldown_hours": s.Cooldown.Hours(),
"refresh_warn_days": s.RefreshWarn.Hours() / 24, "switch_auto": s.SwitchAuto, "switch_at_pct": s.SwitchAt,
"room_below_pct": s.RoomBelow, "warn_at_pct": s.WarnAt, "usage_minutes": s.UsageEvery.Minutes(),
"usage_stale_minutes": s.UsageStale.Minutes()}
}
// Invalid is a setting above switch_at_pct, which moves no consumer until it is fixed (§33): the setting and both
// values.
type Invalid struct {
Setting string
Value float64
SwitchAt float64
}
// Invalid is every setting of the pairs §33 names that is above switch_at_pct: room_below_pct, then warn_at_pct.
func (s Settings) Invalid() []Invalid {
var out []Invalid
if s.RoomBelow > s.SwitchAt {
out = append(out, Invalid{Setting: "room_below_pct", Value: s.RoomBelow, SwitchAt: s.SwitchAt})
}
if s.WarnAt > s.SwitchAt {
out = append(out, Invalid{Setting: "warn_at_pct", Value: s.WarnAt, SwitchAt: s.SwitchAt})
}
return out
}
// settings are those in force.
func (m *Manager) settings() Settings {
m.settingsMu.RLock()
defer m.settingsMu.RUnlock()
return m.Settings
}
// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit
// under `settings`, its value before and now, and the settings read are those in force from then on.
func (m *Manager) ReadSettings(ctx context.Context) error {
if m.SettingsFile == "" {
return nil
}
next := SettingsFrom(m.SettingsFile)
m.settingsMu.Lock()
before := m.Settings
m.Settings = next
m.settingsMu.Unlock()
was, now := before.Values(), next.Values()
changed := map[string]any{}
for k, v := range now {
if !reflect.DeepEqual(was[k], v) {
changed[k] = map[string]any{"from": was[k], "to": v}
}
}
if len(changed) == 0 {
return nil
}
return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed)
}
@@ -0,0 +1,99 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
)
// design 39 bus-and-switch §33/AC1: each setting's default is the manifest's, a value at its bound is taken, and one
// outside its bound or absent keeps the default.
func TestEachSettingsDefaultAndBound(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Config map[string]struct {
Default any `json:"default"`
Minimum *float64 `json:"minimum"`
Maximum *float64 `json:"maximum"`
Trusted bool `json:"trusted"`
} `json:"config"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
defaults := Defaults.Values()
if len(m.Config) != len(defaults) {
t.Fatalf("the manifest declares %d settings, the code %d", len(m.Config), len(defaults))
}
file := filepath.Join(t.TempDir(), "settings.json")
read := func(k string, v any) any {
b, _ := json.Marshal(map[string]any{k: v})
_ = os.WriteFile(file, b, 0o600)
return SettingsFrom(file).Values()[k]
}
for k, c := range m.Config {
if !c.Trusted || c.Default != defaults[k] {
t.Errorf("%s: the manifest's default %v (trusted %v), the code's %v", k, c.Default, c.Trusted, defaults[k])
}
if c.Maximum == nil {
continue
}
lo, hi := *c.Minimum, *c.Maximum
for v, want := range map[float64]any{lo: lo, hi: hi, lo - 1: defaults[k], hi + 1: defaults[k]} {
if got := read(k, v); got != want {
t.Errorf("%s set to %v reads %v, want %v", k, v, got, want)
}
}
}
if read("switch_auto", false) != false || read("switch_at_pct", "high") != 99.0 {
t.Error("switch_auto off, or a value of the wrong type, is not read as written")
}
}
// design 39 bus-and-switch §33: room_below_pct or warn_at_pct above switch_at_pct is an invalid pair naming both
// values; equal is valid.
func TestAnInvalidPairIsFound(t *testing.T) {
s := Defaults
if len(s.Invalid()) != 0 {
t.Fatal("the defaults are invalid")
}
s.SwitchAt, s.RoomBelow, s.WarnAt = 80, 85, 80
got := s.Invalid()
if len(got) != 1 || got[0] != (Invalid{Setting: "room_below_pct", Value: 85, SwitchAt: 80}) {
t.Fatalf("invalid %+v", got)
}
s.WarnAt = 95
if got := s.Invalid(); len(got) != 2 || got[1].Setting != "warn_at_pct" {
t.Fatalf("invalid %+v", got)
}
}
// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is
// written once in the audit under settings.
func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600)
if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 {
t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err)
}
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600)
_ = mm.m.ReadUsage(ctx)
_ = mm.m.ReadUsage(ctx)
if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 {
t.Fatalf("in force %+v", s)
}
got := mm.audit.under("settings")
if len(got) != 1 {
t.Fatalf("audited %v", got)
}
if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 {
t.Fatalf("audited %v", got[0])
}
}