Merge remote-tracking branch 'origin/main' into feat/47-G-join11

This commit is contained in:
2026-11-12 11:28:35 +01:00
11 changed files with 135 additions and 93 deletions
+6 -5
View File
@@ -32,7 +32,8 @@ from a file on this module's node or sealed by a node's `claude_code_add_api_key
## What it keeps on the bus
Each piece asked for in `requires` by its template (ADR 0595), read by `licences_kept`:
Each state asked for in `requires` by its template (ADR 0595), and the two logs declared in the `logs` section, the
form the runtime issues today (design 39 bus-and-switch C3); each read by `licences_kept`:
| name | template | what |
|---|---|---|
@@ -54,10 +55,10 @@ reads alone and answers every act with "the carry-over has not ended"; a failed
## Switching on usage
Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session,
weekly and sonnet windows, each with its reset time. A licence's room is 100 less the higher of its session and weekly
percentages, known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
a licence with consumers at or above `switch_at_pct`, every consumer bound to it moves, in one act, to the licence
with the most room whose windows are both below `room_below_pct`: one `binding.changed` per consumer, one
weekly and sonnet windows, each with its reset time. A licence's room is 100 less its weekly percentage alone (the session
window takes no part in room, the switch, the warning or the conditions: the vendor retired the session limit), known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
a licence with consumers at or above `switch_at_pct` in its weekly window, every consumer bound to it moves, in one act, to the licence
with the most room whose weekly window is below `room_below_pct`: one `binding.changed` per consumer, one
`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule.
Its conditions are said on the operator channel's condition subject, the router deciding where they go:
@@ -227,16 +227,16 @@ func (m *Manager) unread(l Licence, s Settings) (string, bool) {
func (m *Manager) roomWords(o Licence, s Settings) string {
room, known := Room(o, m.Now(), s)
switch {
case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow):
return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow)
case known && *o.Usage.WeeklyPct >= s.RoomBelow:
return fmt.Sprintf("has %g %% room, its weekly window not below %g %%", room, s.RoomBelow)
case known:
return fmt.Sprintf("has %g %% room", room)
case o.Kind != "subscription":
return "is an API key, whose room is never known"
case o.Usage == nil || o.Usage.ReadAt == "":
return "has no reading"
case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil:
return "has no session or weekly window in its reading"
case o.Usage.WeeklyPct == nil:
return "has no weekly window in its reading"
case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()):
return "does not refresh"
}
@@ -140,7 +140,7 @@ func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
ctx := context.Background()
mm := fourOnA(t)
_, said := saying(mm)
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 20)))
_ = mm.store.SaveLicence(ctx, sub("b", read(20, 95)))
_ = mm.store.SaveLicence(ctx, sub("c", read(40, 99)))
for range 2 {
_ = mm.m.SayConditions(ctx, false)
@@ -158,7 +158,7 @@ func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" ||
c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" ||
!strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") ||
!strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") ||
!strings.Contains(c.Explanation, "b has 5 % room, its weekly window not below 90 %") ||
c.Resolved != "ace, g14, laptop, server have a licence with room again" {
t.Fatalf("no-room %+v", c)
}
@@ -178,7 +178,7 @@ func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
key := "licence.a.near-limit"
var last oc.Condition
for _, w := range []float64{80, 89, 91, 91, 95} {
_ = mm.store.SaveLicence(ctx, sub("a", read(10, w)))
_ = mm.store.SaveLicence(ctx, sub("a", read(95, w))) // a session at 95 % takes no part (C4)
_ = mm.m.SayConditions(ctx, false)
for _, s := range *said {
if s.Key == key {
@@ -191,8 +191,8 @@ func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
!strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") {
t.Fatalf("said %v, last %+v", of(*said, key), last)
}
_ = mm.store.SaveLicence(ctx, sub("b", read(95, 0)))
_ = mm.store.SaveLicence(ctx, sub("c", read(95, 0)))
_ = mm.store.SaveLicence(ctx, sub("b", read(0, 95)))
_ = mm.store.SaveLicence(ctx, sub("c", read(0, 95)))
_ = mm.m.SayConditions(ctx, false)
if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" {
t.Fatalf("with no target said %+v", c)
@@ -10,7 +10,8 @@ import (
)
// design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare`
// step — and asks for each piece of its kept data by template.
// step — and asks for each state it keeps by template; its two logs are in the `logs` section with their bounds, the
// form the runtime on main issues (C3).
func TestTheManifestKeepsNoOldForm(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
@@ -44,15 +45,32 @@ func TestTheManifestKeepsNoOldForm(t *testing.T) {
}
}
for name, template := range map[string]string{"licences": "state", "bindings": "state", "leases": "state",
"offered": "state", "usage": "log", "audit": "log", "carried": "state", "conditions": "state"} {
"offered": "state", "usage": "", "audit": "", "carried": "state", "conditions": "state"} {
if kept[name] != template {
t.Errorf("bus-state entry %s is %q, want template %s", name, kept[name], template)
t.Errorf("bus-state entry %s is %q in requires, want %q", name, kept[name], template)
}
}
var logs []json.RawMessage
_ = json.Unmarshal(m["logs"], &logs)
declared := logsSection(logs)
for name, bounds := range map[string]map[string]float64{"usage": {"max-age-days": 35, "max-mib": 256},
"audit": {"max-mib": 64}} {
l, ok := declared[name]
if !ok {
t.Errorf("the log %s is not in the logs section", name)
continue
}
for k, v := range bounds {
if l[k] != v {
t.Errorf("the log %s has %s %v, want %v", name, k, l[k], v)
}
}
}
}
// issued is what the manager's own runtime is issued from its manifest (design 39 bus-and-switch C1): its account, the
// states and logs it keeps (`requires` by template), what it may ask (`invokes`) and say (`emits`).
// states it keeps (`requires` by template), the logs it keeps (the `logs` section, the form the runtime on main issues,
// C3; a log asked for by template in `requires` is issued nothing), what it may ask (`invokes`) and say (`emits`).
type issued struct {
RunsAs, SecretsOwner string
OwnSecrets map[string]string
@@ -72,6 +90,7 @@ func manifestIssued(t testing.TB) issued {
SecretsOwner string `json:"secrets-owner"`
OwnSecrets map[string]string `json:"own-secrets"`
Requires []json.RawMessage `json:"requires"`
Logs []json.RawMessage `json:"logs"`
Resources []map[string]any `json:"resources"`
Invokes []string `json:"invokes"`
Emits []string `json:"emits"`
@@ -86,13 +105,13 @@ func manifestIssued(t testing.TB) issued {
if json.Unmarshal(r, &e) != nil {
continue
}
switch e.Storage {
case "key-value":
if e.Storage == "key-value" {
out.States[e.Name] = true
case "stream":
out.Logs[e.Name] = true
}
}
for name := range logsSection(m.Logs) {
out.Logs[name] = true
}
for _, k := range m.Invokes {
out.Invokes[k] = true
}
@@ -102,6 +121,25 @@ func manifestIssued(t testing.TB) issued {
return out
}
// logsSection is the manifest's `logs` section by name: each a name alone or {name, max-age-days, max-mib}.
func logsSection(logs []json.RawMessage) map[string]map[string]any {
out := map[string]map[string]any{}
for _, l := range logs {
var name string
if json.Unmarshal(l, &name) == nil {
out[name] = map[string]any{}
continue
}
var e map[string]any
if json.Unmarshal(l, &e) == nil {
if n, _ := e["name"].(string); n != "" {
out[n] = e
}
}
}
return out
}
// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`<module>.<tool>@<node>`)
// is invoked as `<module>.<tool>`.
func (i issued) mayAsk(key string) bool {
@@ -1,18 +1,15 @@
package main
// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in
// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
// A licence's room (design 39 bus-and-switch §25, §48, C4): 100 less its weekly percentage in its latest reading, the
// session window taking no part (the vendor retired the session limit), known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known,
// so it is never a target.
import (
"math"
"time"
)
import "time"
// Room is a licence's room at now, and whether it is known.
func Room(l Licence, now time.Time, s Settings) (float64, bool) {
if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil {
if l.Kind != "subscription" || l.Usage == nil || l.Usage.WeeklyPct == nil {
return 0, false
}
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
@@ -22,5 +19,5 @@ func Room(l Licence, now time.Time, s Settings) (float64, bool) {
if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) {
return 0, false
}
return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true
return 100 - *l.Usage.WeeklyPct, true
}
@@ -89,7 +89,7 @@ func TestAFailedReadingKeepsThePercentages(t *testing.T) {
}
}
// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives.
// design 39 bus-and-switch §25/AC1, §48, C4: a table of readings, ages and healths and the room each gives.
func TestTheRoomOfEachLicence(t *testing.T) {
now := t0
pct := func(v float64) *float64 { return &v }
@@ -102,8 +102,10 @@ func TestTheRoomOfEachLicence(t *testing.T) {
room float64
known bool
}{
{"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
{"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true},
{"the weekly window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
{"the session takes no part (C4)", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 80, true},
{"no session window, weekly 33 (C4)", Licence{Kind: "subscription", Usage: &UsageReading{WeeklyPct: pct(33),
ReadAt: now.Format(time.RFC3339Nano)}}, 67, true},
{"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true},
{"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false},
{"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading {
@@ -1,9 +1,9 @@
package main
// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and
// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every
// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below
// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
// L, with one or more consumers bound to it, is at or above switch_at_pct in its weekly window (C4: the session window takes
// no part), every consumer bound to L moves, in one act, to the other licence with known room whose weekly window is
// below room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off
// a licence at its threshold, so none flaps between two.
@@ -61,24 +61,17 @@ func consumersOf(licence string, bindings []Binding) []string {
return consumers
}
// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name,
// percentage and reset; ok false when neither is.
// windowAt is l's weekly window when it is at or above pct (C4: the session window takes no part): its name,
// percentage and reset; ok false when it is not.
func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) {
if u == nil {
if u == nil || u.WeeklyPct == nil || *u.WeeklyPct < pct {
return "", 0, "", false
}
session, weekly := u.SessionPct, u.WeeklyPct
switch {
case session != nil && *session >= pct && (weekly == nil || *session >= *weekly):
return "session", *session, u.SessionResetsAt, true
case weekly != nil && *weekly >= pct:
return "weekly", *weekly, u.WeeklyResetsAt, true
}
return "", 0, "", false
return "weekly", *u.WeeklyPct, u.WeeklyResetsAt, true
}
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below
// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room.
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose weekly window is below
// room_below_pct (C4), the one with the most room, the first by name on a tie; empty when none has room.
func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) {
others := append([]Licence(nil), licences...)
sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name })
@@ -88,7 +81,7 @@ func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string,
continue
}
room, known := Room(o, m.Now(), s)
if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
if !known || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
continue
}
best, to = room, o.Name
@@ -15,11 +15,18 @@ func read(session, weekly float64) *UsageReading {
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
}
// weekly is a reading with no session window, as the vendor answers since the session limit was retired (C4).
func weekly(pct float64) *UsageReading {
u := read(0, pct)
u.SessionPct, u.SessionResetsAt = nil, ""
return u
}
func sub(name string, u *UsageReading) Licence {
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
}
// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives.
// design 39 bus-and-switch §26/AC1, §25, §48, C4: a table of licences, readings and bindings and the moves each gives.
func TestTheRuleMovesEachTable(t *testing.T) {
mm := newMesh(t)
on := func(consumers ...string) []Binding {
@@ -42,12 +49,15 @@ func TestTheRuleMovesEachTable(t *testing.T) {
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
on("x", "y", "z", "w"), "c", "weekly", false, 4},
{"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1},
{"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))},
on("x"), "b", "session", false, 1},
{"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)),
sub("c", read(89, 89))}, on("x"), "c", "session", false, 1},
{"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)),
{"the session window at 100 moves nothing (C4)", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))},
on("x"), "", "", false, 0},
{"no session window, weekly 99, to weekly 33 (C4)", []Licence{sub("a", weekly(99)), sub("b", weekly(33))},
on("x", "y"), "b", "weekly", false, 2},
{"a tie goes to the first by name", []Licence{sub("a", read(0, 99)), sub("c", read(10, 10)), sub("b", read(10, 10))},
on("x"), "b", "weekly", false, 1},
{"a target's weekly below room_below_pct, its session not counted (C4)", []Licence{sub("a", read(0, 99)),
sub("b", read(0, 90)), sub("c", read(95, 89))}, on("x"), "c", "weekly", false, 1},
{"no other licence has room", []Licence{sub("a", read(0, 99)), sub("b", read(0, 95)),
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
} {
@@ -66,7 +76,7 @@ func TestTheRuleMovesEachTable(t *testing.T) {
func fourOnA(t *testing.T) *miniMesh {
mm := newMesh(t)
ctx := context.Background()
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} {
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 30)), sub("c", read(40, 40))} {
_ = mm.store.SaveLicence(ctx, l)
}
for _, c := range []string{"ace", "g14", "laptop", "server"} {
@@ -203,8 +203,14 @@ func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) {
}
var m struct {
Requires []json.RawMessage `json:"requires"`
Logs []json.RawMessage `json:"logs"`
}
_ = json.Unmarshal(raw, &m)
// The logs are declared in the logs section (C3), each read as a log.
for name := range logsSection(m.Logs) {
r, _ := json.Marshal(map[string]string{"name": name, "template": "log"})
m.Requires = append(m.Requires, r)
}
mm := newMesh(t)
ctx := context.Background()
_, _ = saying(mm)
@@ -211,13 +211,13 @@ func fillTodaysRows(t *testing.T, conn *pgx.Conn) {
type vendor struct {
mu sync.Mutex
calls int
logins map[string][2]string // refresh token: the next access token and the account
usage map[string][2]float64 // access token: session and weekly percentages
logins map[string][2]string // refresh token: the next access token and the account
usage map[string]float64 // access token: the weekly percentage; the session window is answered empty (C4)
server *httptest.Server
}
func newVendor(t *testing.T) *vendor {
v := &vendor{logins: map[string][2]string{}, usage: map[string][2]float64{}}
v := &vendor{logins: map[string][2]string{}, usage: map[string]float64{}}
v.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v.mu.Lock()
defer v.mu.Unlock()
@@ -240,16 +240,17 @@ func newVendor(t *testing.T) *vendor {
return
}
resets := time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339)
_ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": u[0], "resets_at": resets},
"seven_day": map[string]any{"utilization": u[1], "resets_at": resets}})
// As the vendor answers since it retired the session limit: the session window with no utilization (C4).
_ = json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": nil, "resets_at": nil},
"seven_day": map[string]any{"utilization": u, "resets_at": resets}})
}))
t.Cleanup(v.server.Close)
return v
}
func (v *vendor) set(access string, session, weekly float64) {
func (v *vendor) set(access string, weekly float64) {
v.mu.Lock()
v.usage[access] = [2]float64{session, weekly}
v.usage[access] = weekly
v.mu.Unlock()
}
@@ -1065,8 +1066,8 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
m.mu.Lock()
carried := m.count("binding.changed")
m.mu.Unlock()
m.vendor.set("at-b", 10, 20)
m.vendor.set("at-a", 99, 50)
m.vendor.set("at-b", 20)
m.vendor.set("at-a", 99)
p.must("licences_read_usage", map[string]any{"licence": licenceB})
p.must("licences_read_usage", map[string]any{"licence": licenceA})
m.until("the four consumers moved", func() bool { return m.count("binding.changed") >= carried+4 })
@@ -1098,8 +1099,8 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
// Every licence at its threshold: the consumers have nowhere to go.
noRoom := "licence." + KeyFor(licenceB) + ".no-room"
m.vendor.set("at-a", 99, 50)
m.vendor.set("at-b", 99, 60)
m.vendor.set("at-a", 99)
m.vendor.set("at-b", 99)
p.must("licences_read_usage", map[string]any{"licence": licenceA})
p.must("licences_read_usage", map[string]any{"licence": licenceB})
m.until("no-room is raised", func() bool { return len(m.conditionsOf(noRoom)) > 0 })
@@ -1110,7 +1111,7 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) {
t.Errorf("no-room said %+v", r)
}
// A reading with room: the next reading of the full licence moves its consumers, and no-room is cleared.
m.vendor.set("at-a", 5, 30)
m.vendor.set("at-a", 30)
p.must("licences_read_usage", map[string]any{"licence": licenceA})
p.must("licences_read_usage", map[string]any{"licence": licenceB})
m.until("no-room is cleared", func() bool {
@@ -1159,8 +1160,8 @@ func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) {
}
// The full licence is read first: nothing has room yet, so its consumer stays.
m.vendor.set("at-old", 99, 40)
m.vendor.set("at-team", 10, 5)
m.vendor.set("at-old", 99)
m.vendor.set("at-team", 5)
p.must("licences_read_usage", map[string]any{"licence": licenceA})
p.must("licences_read_usage", map[string]any{"licence": "team@example.org"})
var listed []map[string]any
+15 -21
View File
@@ -40,23 +40,6 @@
"key-ttl": true
}
},
{
"name": "usage",
"storage": "stream",
"template": "log",
"options": {
"max-age-days": 35,
"max-mib": 256
}
},
{
"name": "audit",
"storage": "stream",
"template": "log",
"options": {
"max-mib": 64
}
},
{
"name": "carried",
"storage": "key-value",
@@ -74,6 +57,17 @@
}
}
],
"logs": [
{
"name": "usage",
"max-age-days": 35,
"max-mib": 256
},
{
"name": "audit",
"max-mib": 64
}
],
"contributes": {
"postgres-database": {
"name": "claude_licences",
@@ -192,13 +186,13 @@
},
"switch_auto": {
"type": "boolean",
"description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
"description": "Whether the manager moves every consumer off a licence whose weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
"trusted": true,
"default": true
},
"switch_at_pct": {
"type": "number",
"description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
"description": "The weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
"minimum": 1,
"maximum": 100,
"trusted": true,
@@ -206,7 +200,7 @@
},
"room_below_pct": {
"type": "number",
"description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
"description": "What the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
"minimum": 1,
"maximum": 100,
"trusted": true,
@@ -214,7 +208,7 @@
},
"warn_at_pct": {
"type": "number",
"description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.",
"description": "The weekly usage of a licence with consumers, in percent, at which it is warned about once.",
"minimum": 1,
"maximum": 100,
"trusted": true,