// 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" "math" "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) } fits := true l.RunningThings = addPack(l.RunningThings, qty, pk.Adds.RunningThings, &fits) l.DiskBytes = addPack(l.DiskBytes, qty, pk.Adds.DiskBytes, &fits) l.StorageBytes = addPack(l.StorageBytes, qty, pk.Adds.StorageBytes, &fits) l.EmailPerDay = addPack(l.EmailPerDay, qty, pk.Adds.EmailPerDay, &fits) l.BandwidthBytesMonth = addPack(l.BandwidthBytesMonth, qty, pk.Adds.BandwidthBytesMonth, &fits) if !fits { return Limits{}, fmt.Errorf("%w: %q overflows", ErrPackQuantity, name) } } 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 } // addPack is v + qty*add, refusing (ok = false) anything that would overflow // int64. Values are non-negative (validate), so only the upper bound matters; // int is 64-bit on every platform this builds for. func addPack[T int | int64](v T, qty int, add T, fits *bool) T { if add != 0 && int64(qty) > (math.MaxInt64-int64(v))/int64(add) { *fits = false return v } return v + T(qty)*add }