plans: the catalog and the limits arithmetic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+520
−0
internal/plans/catalog.go+263 −0internal/plans/catalog_test.go+204 −0internal/plans/testdata/example-catalog.json+53 −0
diff --git a/internal/plans/catalog.go b/internal/plans/catalog.go| new file mode 100644 |
| index 000000000..cd9c2734b |
| --- /dev/null |
| +++ b/internal/plans/catalog.go |
| @@ -0,0 +1,263 @@ |
| +// Package plans is the per-account plan catalog and the limits it implies |
| +// (spec docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md §4). |
| +// |
| +// A deployment opts in by configuring a catalog. With none, there are no |
| +// caps and no billing: nothing in this package is consulted. The console is |
| +// the only writer of an account's effective limits; boxes read them from |
| +// the record in record.go. |
| +package plans |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "slices" |
| +) |
| + |
| +// Limits is one set of caps. In a plan it is the plan's caps; in a pack it |
| +// is what one unit of the pack adds; in a catalog's Ceilings it is the most |
| +// any packed plan may reach. Zero means "not set" for sponsored overrides |
| +// and ceilings. |
| +type Limits struct { |
| + RunningThings int `json:"running_things,omitempty"` |
| + SidecarsPerApp int `json:"sidecars_per_app,omitempty"` |
| + IdleSeconds int `json:"idle_seconds,omitempty"` |
| + MemoryTier string `json:"memory_tier,omitempty"` // MemorySmall | MemoryDefault |
| + AwakeHoursMonth int `json:"awake_hours_month,omitempty"` |
| + DiskBytes int64 `json:"disk_bytes,omitempty"` // per running thing |
| + StorageBytes int64 `json:"storage_bytes,omitempty"` // per account |
| + StorageSelfServe bool `json:"storage_self_serve,omitempty"` |
| + EmailPerDay int `json:"email_per_day,omitempty"` |
| + BandwidthBytesMonth int64 `json:"bandwidth_bytes_month,omitempty"` |
| + CustomDomains bool `json:"custom_domains,omitempty"` |
| + Fleets bool `json:"fleets,omitempty"` |
| + MinScheduleSeconds int `json:"min_schedule_seconds,omitempty"` |
| + HistoryDays int `json:"history_days,omitempty"` |
| +} |
| + |
| +// The memory tiers a plan may name. The numbers behind them belong to the |
| +// host (internal/cgroup), not to the catalog. |
| +const ( |
| + MemorySmall = "small" |
| + MemoryDefault = "default" |
| +) |
| + |
| +// Plan is one plan in a catalog. |
| +type Plan struct { |
| + Paid bool `json:"paid"` |
| + Limits Limits `json:"limits"` |
| + Packs []string `json:"packs,omitempty"` // pack names this plan may buy |
| +} |
| + |
| +// Pack is one pack type: what a unit adds, and how many units one account |
| +// may hold. |
| +type Pack struct { |
| + Adds Limits `json:"adds"` |
| + MaxQuantity int `json:"max_quantity"` |
| +} |
| + |
| +// Catalog is a deployment's plan catalog. |
| +type Catalog struct { |
| + Version string `json:"version"` |
| + DefaultPlan string `json:"default_plan"` |
| + FreeAccountsPerPerson int `json:"free_accounts_per_person"` |
| + Plans map[string]Plan `json:"plans"` |
| + Packs map[string]Pack `json:"packs"` |
| + Ceilings Limits `json:"ceilings"` |
| + |
| + id string |
| +} |
| + |
| +var ( |
| + ErrUnknownPlan = errors.New("no such plan") |
| + ErrUnknownPack = errors.New("no such pack") |
| + ErrPackNotOffered = errors.New("this plan does not offer that pack") |
| + ErrPackQuantity = errors.New("pack quantity out of range") |
| +) |
| + |
| +// LoadCatalog reads and parses the catalog file at path. |
| +func LoadCatalog(path string) (*Catalog, error) { |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + c, err := ParseCatalog(b) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", path, err) |
| + } |
| + return c, nil |
| +} |
| + |
| +// ParseCatalog parses and validates a catalog. Unknown fields are refused: |
| +// a misspelt limit would otherwise read as zero. |
| +func ParseCatalog(b []byte) (*Catalog, error) { |
| + dec := json.NewDecoder(bytes.NewReader(b)) |
| + dec.DisallowUnknownFields() |
| + var c Catalog |
| + if err := dec.Decode(&c); err != nil { |
| + return nil, fmt.Errorf("plan catalog: %w", err) |
| + } |
| + if err := c.validate(); err != nil { |
| + return nil, fmt.Errorf("plan catalog: %w", err) |
| + } |
| + canon, err := json.Marshal(c) // map keys marshal sorted: canonical |
| + if err != nil { |
| + return nil, err |
| + } |
| + sum := sha256.Sum256(canon) |
| + c.id = c.Version + "@" + hex.EncodeToString(sum[:])[:12] |
| + return &c, nil |
| +} |
| + |
| +// ID names this catalog's exact content: its version and a digest. A |
| +// catalog edited without a version bump gets a different ID, so a record |
| +// can never claim a version whose content it does not carry. |
| +func (c *Catalog) ID() string { return c.id } |
| + |
| +func (c *Catalog) validate() error { |
| + if c.Version == "" { |
| + return errors.New("version is required") |
| + } |
| + if len(c.Plans) == 0 { |
| + return errors.New("at least one plan is required") |
| + } |
| + if _, ok := c.Plans[c.DefaultPlan]; !ok { |
| + return fmt.Errorf("default_plan %q is not a plan", c.DefaultPlan) |
| + } |
| + if c.FreeAccountsPerPerson < 0 { |
| + return errors.New("free_accounts_per_person is negative") |
| + } |
| + if err := c.Ceilings.nonNegative(); err != nil { |
| + return fmt.Errorf("ceilings: %w", err) |
| + } |
| + for name, p := range c.Plans { |
| + if err := p.Limits.nonNegative(); err != nil { |
| + return fmt.Errorf("plan %q: %w", name, err) |
| + } |
| + switch p.Limits.MemoryTier { |
| + case "", MemorySmall, MemoryDefault: |
| + default: |
| + return fmt.Errorf("plan %q: memory_tier %q is not %q or %q", name, p.Limits.MemoryTier, MemorySmall, MemoryDefault) |
| + } |
| + for _, pk := range p.Packs { |
| + if _, ok := c.Packs[pk]; !ok { |
| + return fmt.Errorf("plan %q offers pack %q, which is not defined", name, pk) |
| + } |
| + } |
| + } |
| + for name, pk := range c.Packs { |
| + if pk.MaxQuantity < 1 { |
| + return fmt.Errorf("pack %q: max_quantity must be at least 1", name) |
| + } |
| + if err := pk.Adds.nonNegative(); err != nil { |
| + return fmt.Errorf("pack %q: %w", name, err) |
| + } |
| + if pk.Adds != pk.Adds.additive() { |
| + return fmt.Errorf("pack %q may only add running_things, disk_bytes, storage_bytes, email_per_day or bandwidth_bytes_month", name) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (l Limits) nonNegative() error { |
| + if l.RunningThings < 0 || l.SidecarsPerApp < 0 || l.IdleSeconds < 0 || l.AwakeHoursMonth < 0 || |
| + l.DiskBytes < 0 || l.StorageBytes < 0 || l.EmailPerDay < 0 || l.BandwidthBytesMonth < 0 || |
| + l.MinScheduleSeconds < 0 || l.HistoryDays < 0 { |
| + return errors.New("limits may not be negative") |
| + } |
| + return nil |
| +} |
| + |
| +// additive is l with only the fields a pack may raise, and a ceiling clamps. |
| +func (l Limits) additive() Limits { |
| + return Limits{ |
| + RunningThings: l.RunningThings, |
| + DiskBytes: l.DiskBytes, |
| + StorageBytes: l.StorageBytes, |
| + EmailPerDay: l.EmailPerDay, |
| + BandwidthBytesMonth: l.BandwidthBytesMonth, |
| + } |
| +} |
| + |
| +// Effective is an account's limits: the plan's, plus each pack's quantity |
| +// times its adds, clamped to the catalog's ceilings, then merged with a |
| +// sponsored override taking the more generous value per field. Sponsored |
| +// values are not clamped: a sponsored figure above a ceiling is an explicit |
| +// operator decision (spec §8.7). |
| +func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits) (Limits, error) { |
| + p, ok := c.Plans[plan] |
| + if !ok { |
| + return Limits{}, fmt.Errorf("%w: %q", ErrUnknownPlan, plan) |
| + } |
| + l := p.Limits |
| + names := make([]string, 0, len(packs)) |
| + for name := range packs { |
| + names = append(names, name) |
| + } |
| + slices.Sort(names) |
| + for _, name := range names { |
| + qty := packs[name] |
| + pk, ok := c.Packs[name] |
| + if !ok { |
| + return Limits{}, fmt.Errorf("%w: %q", ErrUnknownPack, name) |
| + } |
| + if !slices.Contains(p.Packs, name) { |
| + return Limits{}, fmt.Errorf("%w: %q on %q", ErrPackNotOffered, name, plan) |
| + } |
| + if qty < 1 || qty > pk.MaxQuantity { |
| + return Limits{}, fmt.Errorf("%w: %q has %d, allowed 1 to %d", ErrPackQuantity, name, qty, pk.MaxQuantity) |
| + } |
| + l.RunningThings += qty * pk.Adds.RunningThings |
| + l.DiskBytes += int64(qty) * pk.Adds.DiskBytes |
| + l.StorageBytes += int64(qty) * pk.Adds.StorageBytes |
| + l.EmailPerDay += qty * pk.Adds.EmailPerDay |
| + l.BandwidthBytesMonth += int64(qty) * pk.Adds.BandwidthBytesMonth |
| + } |
| + ce := c.Ceilings |
| + l.RunningThings = clamp(l.RunningThings, ce.RunningThings) |
| + l.DiskBytes = clamp(l.DiskBytes, ce.DiskBytes) |
| + l.StorageBytes = clamp(l.StorageBytes, ce.StorageBytes) |
| + l.EmailPerDay = clamp(l.EmailPerDay, ce.EmailPerDay) |
| + l.BandwidthBytesMonth = clamp(l.BandwidthBytesMonth, ce.BandwidthBytesMonth) |
| + l.IdleSeconds = clamp(l.IdleSeconds, ce.IdleSeconds) |
| + if sponsored != nil { |
| + l = l.generous(*sponsored) |
| + } |
| + return l, nil |
| +} |
| + |
| +func clamp[T int | int64](v, ceiling T) T { |
| + if ceiling > 0 && v > ceiling { |
| + return ceiling |
| + } |
| + return v |
| +} |
| + |
| +// generous merges o into l field by field, keeping the more generous value. |
| +// Zero in o means unset. |
| +func (l Limits) generous(o Limits) Limits { |
| + l.RunningThings = max(l.RunningThings, o.RunningThings) |
| + l.SidecarsPerApp = max(l.SidecarsPerApp, o.SidecarsPerApp) |
| + l.IdleSeconds = max(l.IdleSeconds, o.IdleSeconds) |
| + l.AwakeHoursMonth = max(l.AwakeHoursMonth, o.AwakeHoursMonth) |
| + l.DiskBytes = max(l.DiskBytes, o.DiskBytes) |
| + l.StorageBytes = max(l.StorageBytes, o.StorageBytes) |
| + l.EmailPerDay = max(l.EmailPerDay, o.EmailPerDay) |
| + l.BandwidthBytesMonth = max(l.BandwidthBytesMonth, o.BandwidthBytesMonth) |
| + l.HistoryDays = max(l.HistoryDays, o.HistoryDays) |
| + l.StorageSelfServe = l.StorageSelfServe || o.StorageSelfServe |
| + l.CustomDomains = l.CustomDomains || o.CustomDomains |
| + l.Fleets = l.Fleets || o.Fleets |
| + if o.MemoryTier == MemoryDefault { |
| + l.MemoryTier = MemoryDefault |
| + } |
| + // A shorter minimum interval is the more generous one; zero is unset. |
| + if o.MinScheduleSeconds > 0 && (l.MinScheduleSeconds == 0 || o.MinScheduleSeconds < l.MinScheduleSeconds) { |
| + l.MinScheduleSeconds = o.MinScheduleSeconds |
| + } |
| + return l |
| +} |
diff --git a/internal/plans/catalog_test.go b/internal/plans/catalog_test.go| new file mode 100644 |
| index 000000000..ce6c86c0f |
| --- /dev/null |
| +++ b/internal/plans/catalog_test.go |
| @@ -0,0 +1,204 @@ |
| +package plans |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +const examplePath = "testdata/example-catalog.json" |
| + |
| +func exampleCatalog(t *testing.T) *Catalog { |
| + t.Helper() |
| + c, err := LoadCatalog(examplePath) |
| + if err != nil { |
| + t.Fatalf("load example catalog: %v", err) |
| + } |
| + return c |
| +} |
| + |
| +func exampleBytes(t *testing.T) string { |
| + t.Helper() |
| + b, err := os.ReadFile(examplePath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +func TestParseExampleCatalog(t *testing.T) { |
| + c := exampleCatalog(t) |
| + if got := c.Plans["free"].Limits.RunningThings; got != 3 { |
| + t.Fatalf("free running_things = %d, want 3", got) |
| + } |
| + if c.DefaultPlan != "free" || c.FreeAccountsPerPerson != 1 { |
| + t.Fatalf("default_plan %q, free_accounts_per_person %d", c.DefaultPlan, c.FreeAccountsPerPerson) |
| + } |
| +} |
| + |
| +func TestParseCatalogRefuses(t *testing.T) { |
| + base := exampleBytes(t) |
| + cases := map[string]string{ |
| + "empty version": strings.Replace(base, `"version": "2026-10-20-v1"`, `"version": ""`, 1), |
| + "default plan missing": strings.Replace(base, `"default_plan": "free"`, `"default_plan": "gold"`, 1), |
| + "pack offered not defined": strings.Replace(base, `["apps", "storage", "email", "bandwidth"]`, `["apps", "seats"]`, 1), |
| + "pack adds idle": strings.Replace(base, `{"running_things": 5}`, `{"running_things": 5, "idle_seconds": 60}`, 1), |
| + "pack adds memory tier": strings.Replace(base, `{"running_things": 5}`, `{"memory_tier": "default"}`, 1), |
| + "pack adds a bool": strings.Replace(base, `{"running_things": 5}`, `{"custom_domains": true}`, 1), |
| + "max quantity zero": strings.Replace(base, `{"running_things": 5}, "max_quantity": 20`, `{"running_things": 5}, "max_quantity": 0`, 1), |
| + "unknown memory tier": strings.Replace(base, `"memory_tier": "small"`, `"memory_tier": "tiny"`, 1), |
| + "unknown field": strings.Replace(base, `"default_plan": "free",`, `"default_plan": "free", "surprise": 1,`, 1), |
| + "no plans": `{"version": "v", "default_plan": "free", "plans": {}}`, |
| + "negative running things": strings.Replace(base, `"running_things": 3,`, `"running_things": -3,`, 1), |
| + "pack adds negative amount": strings.Replace(base, `{"email_per_day": 1000}`, `{"email_per_day": -1000}`, 1), |
| + } |
| + for name, body := range cases { |
| + if body == base { |
| + t.Fatalf("%s: replacement did not apply", name) |
| + } |
| + if _, err := ParseCatalog([]byte(body)); err == nil { |
| + t.Errorf("%s: ParseCatalog accepted it", name) |
| + } |
| + } |
| +} |
| + |
| +func TestCatalogIDChangesWithContent(t *testing.T) { |
| + base := exampleBytes(t) |
| + a, err := ParseCatalog([]byte(base)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + again, err := ParseCatalog([]byte(base)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if a.ID() != again.ID() { |
| + t.Fatalf("same bytes, different IDs: %s vs %s", a.ID(), again.ID()) |
| + } |
| + if !strings.HasPrefix(a.ID(), "2026-10-20-v1@") || len(a.ID()) != len("2026-10-20-v1@")+12 { |
| + t.Fatalf("ID shape: %q", a.ID()) |
| + } |
| + changed, err := ParseCatalog([]byte(strings.Replace(base, `"running_things": 3,`, `"running_things": 4,`, 1))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if changed.ID() == a.ID() { |
| + t.Fatal("content changed without a version bump but the ID did not move") |
| + } |
| + reformatted := strings.Join(strings.Fields(base), " ") |
| + reformatted = strings.Replace(reformatted, `"free_accounts_per_person": 1, "plans"`, `"plans"`, 1) |
| + reformatted = strings.Replace(reformatted, `"version": "2026-10-20-v1",`, `"free_accounts_per_person": 1, "version": "2026-10-20-v1",`, 1) |
| + r, err := ParseCatalog([]byte(reformatted)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if r.ID() != a.ID() { |
| + t.Fatalf("whitespace and key order changed the ID: %s vs %s", r.ID(), a.ID()) |
| + } |
| +} |
| + |
| +func TestEffectivePlanOnly(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("free", nil, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != c.Plans["free"].Limits { |
| + t.Fatalf("got %+v, want %+v", got, c.Plans["free"].Limits) |
| + } |
| +} |
| + |
| +func TestEffectiveAddsPacks(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("personal", map[string]int{"apps": 2, "email": 1}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.RunningThings != 35 || got.EmailPerDay != 2000 { |
| + t.Fatalf("running_things %d (want 35), email_per_day %d (want 2000)", got.RunningThings, got.EmailPerDay) |
| + } |
| +} |
| + |
| +func TestEffectiveClampsToCeilings(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("personal", map[string]int{"apps": 20, "email": 20}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.RunningThings != 125 { |
| + t.Fatalf("running_things %d, want 125", got.RunningThings) |
| + } |
| + if got.EmailPerDay != 10000 { |
| + t.Fatalf("email_per_day %d, want the 10000 ceiling", got.EmailPerDay) |
| + } |
| + c.Ceilings.RunningThings = 100 |
| + got, err = c.Effective("personal", map[string]int{"apps": 20}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.RunningThings != 100 { |
| + t.Fatalf("running_things %d, want the 100 ceiling", got.RunningThings) |
| + } |
| +} |
| + |
| +func TestEffectiveRefuses(t *testing.T) { |
| + c := exampleCatalog(t) |
| + cases := []struct { |
| + name string |
| + plan string |
| + packs map[string]int |
| + want error |
| + }{ |
| + {"unknown plan", "gold", nil, ErrUnknownPlan}, |
| + {"unknown pack", "personal", map[string]int{"seats": 1}, ErrUnknownPack}, |
| + {"pack not offered", "free", map[string]int{"apps": 1}, ErrPackNotOffered}, |
| + {"above max", "personal", map[string]int{"apps": 21}, ErrPackQuantity}, |
| + {"negative", "personal", map[string]int{"apps": -1}, ErrPackQuantity}, |
| + {"zero", "personal", map[string]int{"apps": 0}, ErrPackQuantity}, |
| + } |
| + for _, tc := range cases { |
| + if _, err := c.Effective(tc.plan, tc.packs, nil); !errors.Is(err, tc.want) { |
| + t.Errorf("%s: err %v, want %v", tc.name, err, tc.want) |
| + } |
| + } |
| +} |
| + |
| +func TestEffectiveSponsoredTakesTheMoreGenerous(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("personal", nil, &Limits{RunningThings: 300, EmailPerDay: 20}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.RunningThings != 300 { |
| + t.Fatalf("running_things %d, want 300 (sponsored is not clamped)", got.RunningThings) |
| + } |
| + if got.EmailPerDay != 1000 { |
| + t.Fatalf("email_per_day %d, want the plan's 1000", got.EmailPerDay) |
| + } |
| + got, err = c.Effective("free", nil, &Limits{CustomDomains: true, MemoryTier: "default"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.CustomDomains || got.MemoryTier != "default" { |
| + t.Fatalf("custom_domains %v, memory_tier %q", got.CustomDomains, got.MemoryTier) |
| + } |
| +} |
| + |
| +func TestEffectiveSponsoredZeroIsUnset(t *testing.T) { |
| + c := exampleCatalog(t) |
| + got, err := c.Effective("free", nil, &Limits{RunningThings: 40}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.MinScheduleSeconds != 900 || got.RunningThings != 40 { |
| + t.Fatalf("min_schedule_seconds %d (want 900), running_things %d (want 40)", got.MinScheduleSeconds, got.RunningThings) |
| + } |
| + got, err = c.Effective("free", nil, &Limits{MinScheduleSeconds: 60}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.MinScheduleSeconds != 60 { |
| + t.Fatalf("min_schedule_seconds %d, want 60", got.MinScheduleSeconds) |
| + } |
| +} |
diff --git a/internal/plans/testdata/example-catalog.json b/internal/plans/testdata/example-catalog.json| new file mode 100644 |
| index 000000000..688b2c94d |
| --- /dev/null |
| +++ b/internal/plans/testdata/example-catalog.json |
| @@ -0,0 +1,53 @@ |
| +{ |
| + "version": "2026-10-20-v1", |
| + "default_plan": "free", |
| + "free_accounts_per_person": 1, |
| + "plans": { |
| + "free": { |
| + "paid": false, |
| + "limits": { |
| + "running_things": 3, |
| + "sidecars_per_app": 1, |
| + "idle_seconds": 90, |
| + "memory_tier": "small", |
| + "awake_hours_month": 100, |
| + "disk_bytes": 1073741824, |
| + "storage_bytes": 1073741824, |
| + "email_per_day": 50, |
| + "bandwidth_bytes_month": 10737418240, |
| + "min_schedule_seconds": 900 |
| + } |
| + }, |
| + "personal": { |
| + "paid": true, |
| + "limits": { |
| + "running_things": 25, |
| + "sidecars_per_app": 5, |
| + "idle_seconds": 900, |
| + "memory_tier": "default", |
| + "awake_hours_month": 1500, |
| + "disk_bytes": 10737418240, |
| + "storage_bytes": 26843545600, |
| + "storage_self_serve": true, |
| + "email_per_day": 1000, |
| + "bandwidth_bytes_month": 214748364800, |
| + "custom_domains": true, |
| + "fleets": true, |
| + "min_schedule_seconds": 60, |
| + "history_days": 7 |
| + }, |
| + "packs": ["apps", "storage", "email", "bandwidth"] |
| + } |
| + }, |
| + "packs": { |
| + "apps": {"adds": {"running_things": 5}, "max_quantity": 20}, |
| + "storage": {"adds": {"disk_bytes": 10737418240, "storage_bytes": 53687091200}, "max_quantity": 20}, |
| + "email": {"adds": {"email_per_day": 1000}, "max_quantity": 20}, |
| + "bandwidth": {"adds": {"bandwidth_bytes_month": 214748364800}, "max_quantity": 20} |
| + }, |
| + "ceilings": { |
| + "running_things": 200, |
| + "email_per_day": 10000, |
| + "idle_seconds": 900 |
| + } |
| +} |