carlos / platform Public

plans: the catalog and the limits arithmetic

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bec0c789a18e76309f0d31d4ac969f1d63f97af7 parent 567600d
3 files changed, +520 −0
  • internal/plans/catalog.go +263 −0
  • internal/plans/catalog_test.go +204 −0
  • internal/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
+ }
+}