| 1 | // Package plans is the per-account plan catalog and the limits it implies |
| 2 | // (spec docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md §4). |
| 3 | // |
| 4 | // A deployment opts in by configuring a catalog. With none, there are no |
| 5 | // caps and no billing: nothing in this package is consulted. The console is |
| 6 | // the only writer of an account's effective limits; boxes read them from |
| 7 | // the record in record.go. |
| 8 | package plans |
| 9 | |
| 10 | import ( |
| 11 | "bytes" |
| 12 | "crypto/sha256" |
| 13 | "encoding/hex" |
| 14 | "encoding/json" |
| 15 | "errors" |
| 16 | "fmt" |
| 17 | "math" |
| 18 | "os" |
| 19 | "slices" |
| 20 | ) |
| 21 | |
| 22 | // Limits is one set of caps. In a plan it is the plan's caps; in a pack it |
| 23 | // is what one unit of the pack adds; in a catalog's Ceilings it is the most |
| 24 | // any packed plan may reach. Zero means "not set" for sponsored overrides |
| 25 | // and ceilings. |
| 26 | type Limits struct { |
| 27 | RunningThings int `json:"running_things,omitempty"` |
| 28 | SidecarsPerApp int `json:"sidecars_per_app,omitempty"` |
| 29 | IdleSeconds int `json:"idle_seconds,omitempty"` |
| 30 | MemoryTier string `json:"memory_tier,omitempty"` // MemorySmall | MemoryDefault |
| 31 | AwakeHoursMonth int `json:"awake_hours_month,omitempty"` |
| 32 | DiskBytes int64 `json:"disk_bytes,omitempty"` // per running thing |
| 33 | StorageBytes int64 `json:"storage_bytes,omitempty"` // per account |
| 34 | StorageSelfServe bool `json:"storage_self_serve,omitempty"` |
| 35 | EmailPerDay int `json:"email_per_day,omitempty"` |
| 36 | BandwidthBytesMonth int64 `json:"bandwidth_bytes_month,omitempty"` |
| 37 | CustomDomains bool `json:"custom_domains,omitempty"` |
| 38 | Fleets bool `json:"fleets,omitempty"` |
| 39 | MinScheduleSeconds int `json:"min_schedule_seconds,omitempty"` |
| 40 | HistoryDays int `json:"history_days,omitempty"` |
| 41 | } |
| 42 | |
| 43 | // The memory tiers a plan may name. The numbers behind them belong to the |
| 44 | // host (internal/cgroup), not to the catalog. |
| 45 | const ( |
| 46 | MemorySmall = "small" |
| 47 | MemoryDefault = "default" |
| 48 | ) |
| 49 | |
| 50 | // Plan is one plan in a catalog. |
| 51 | type Plan struct { |
| 52 | Paid bool `json:"paid"` |
| 53 | Limits Limits `json:"limits"` |
| 54 | Packs []string `json:"packs,omitempty"` // pack names this plan may buy |
| 55 | } |
| 56 | |
| 57 | // Pack is one pack type: what a unit adds, and how many units one account |
| 58 | // may hold. |
| 59 | type Pack struct { |
| 60 | Adds Limits `json:"adds"` |
| 61 | MaxQuantity int `json:"max_quantity"` |
| 62 | } |
| 63 | |
| 64 | // Catalog is a deployment's plan catalog. |
| 65 | type Catalog struct { |
| 66 | Version string `json:"version"` |
| 67 | DefaultPlan string `json:"default_plan"` |
| 68 | FreeAccountsPerPerson int `json:"free_accounts_per_person"` |
| 69 | Plans map[string]Plan `json:"plans"` |
| 70 | Packs map[string]Pack `json:"packs"` |
| 71 | Ceilings Limits `json:"ceilings"` |
| 72 | |
| 73 | id string |
| 74 | } |
| 75 | |
| 76 | var ( |
| 77 | ErrUnknownPlan = errors.New("no such plan") |
| 78 | ErrUnknownPack = errors.New("no such pack") |
| 79 | ErrPackNotOffered = errors.New("this plan does not offer that pack") |
| 80 | ErrPackQuantity = errors.New("pack quantity out of range") |
| 81 | ) |
| 82 | |
| 83 | // LoadCatalog reads and parses the catalog file at path. |
| 84 | func LoadCatalog(path string) (*Catalog, error) { |
| 85 | b, err := os.ReadFile(path) |
| 86 | if err != nil { |
| 87 | return nil, err |
| 88 | } |
| 89 | c, err := ParseCatalog(b) |
| 90 | if err != nil { |
| 91 | return nil, fmt.Errorf("%s: %w", path, err) |
| 92 | } |
| 93 | return c, nil |
| 94 | } |
| 95 | |
| 96 | // ParseCatalog parses and validates a catalog. Unknown fields are refused: |
| 97 | // a misspelt limit would otherwise read as zero. |
| 98 | func ParseCatalog(b []byte) (*Catalog, error) { |
| 99 | dec := json.NewDecoder(bytes.NewReader(b)) |
| 100 | dec.DisallowUnknownFields() |
| 101 | var c Catalog |
| 102 | if err := dec.Decode(&c); err != nil { |
| 103 | return nil, fmt.Errorf("plan catalog: %w", err) |
| 104 | } |
| 105 | if err := c.validate(); err != nil { |
| 106 | return nil, fmt.Errorf("plan catalog: %w", err) |
| 107 | } |
| 108 | canon, err := json.Marshal(c) // map keys marshal sorted: canonical |
| 109 | if err != nil { |
| 110 | return nil, err |
| 111 | } |
| 112 | sum := sha256.Sum256(canon) |
| 113 | c.id = c.Version + "@" + hex.EncodeToString(sum[:])[:12] |
| 114 | return &c, nil |
| 115 | } |
| 116 | |
| 117 | // ID names this catalog's exact content: its version and a digest. A |
| 118 | // catalog edited without a version bump gets a different ID, so a record |
| 119 | // can never claim a version whose content it does not carry. |
| 120 | func (c *Catalog) ID() string { return c.id } |
| 121 | |
| 122 | func (c *Catalog) validate() error { |
| 123 | if c.Version == "" { |
| 124 | return errors.New("version is required") |
| 125 | } |
| 126 | if len(c.Plans) == 0 { |
| 127 | return errors.New("at least one plan is required") |
| 128 | } |
| 129 | if _, ok := c.Plans[c.DefaultPlan]; !ok { |
| 130 | return fmt.Errorf("default_plan %q is not a plan", c.DefaultPlan) |
| 131 | } |
| 132 | if c.FreeAccountsPerPerson < 0 { |
| 133 | return errors.New("free_accounts_per_person is negative") |
| 134 | } |
| 135 | if err := c.Ceilings.nonNegative(); err != nil { |
| 136 | return fmt.Errorf("ceilings: %w", err) |
| 137 | } |
| 138 | for name, p := range c.Plans { |
| 139 | if err := p.Limits.nonNegative(); err != nil { |
| 140 | return fmt.Errorf("plan %q: %w", name, err) |
| 141 | } |
| 142 | switch p.Limits.MemoryTier { |
| 143 | case "", MemorySmall, MemoryDefault: |
| 144 | default: |
| 145 | return fmt.Errorf("plan %q: memory_tier %q is not %q or %q", name, p.Limits.MemoryTier, MemorySmall, MemoryDefault) |
| 146 | } |
| 147 | for _, pk := range p.Packs { |
| 148 | if _, ok := c.Packs[pk]; !ok { |
| 149 | return fmt.Errorf("plan %q offers pack %q, which is not defined", name, pk) |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | for name, pk := range c.Packs { |
| 154 | if pk.MaxQuantity < 1 { |
| 155 | return fmt.Errorf("pack %q: max_quantity must be at least 1", name) |
| 156 | } |
| 157 | if err := pk.Adds.nonNegative(); err != nil { |
| 158 | return fmt.Errorf("pack %q: %w", name, err) |
| 159 | } |
| 160 | if pk.Adds != pk.Adds.additive() { |
| 161 | return fmt.Errorf("pack %q may only add running_things, disk_bytes, storage_bytes, email_per_day or bandwidth_bytes_month", name) |
| 162 | } |
| 163 | } |
| 164 | return nil |
| 165 | } |
| 166 | |
| 167 | func (l Limits) nonNegative() error { |
| 168 | if l.RunningThings < 0 || l.SidecarsPerApp < 0 || l.IdleSeconds < 0 || l.AwakeHoursMonth < 0 || |
| 169 | l.DiskBytes < 0 || l.StorageBytes < 0 || l.EmailPerDay < 0 || l.BandwidthBytesMonth < 0 || |
| 170 | l.MinScheduleSeconds < 0 || l.HistoryDays < 0 { |
| 171 | return errors.New("limits may not be negative") |
| 172 | } |
| 173 | return nil |
| 174 | } |
| 175 | |
| 176 | // additive is l with only the fields a pack may raise, and a ceiling clamps. |
| 177 | func (l Limits) additive() Limits { |
| 178 | return Limits{ |
| 179 | RunningThings: l.RunningThings, |
| 180 | DiskBytes: l.DiskBytes, |
| 181 | StorageBytes: l.StorageBytes, |
| 182 | EmailPerDay: l.EmailPerDay, |
| 183 | BandwidthBytesMonth: l.BandwidthBytesMonth, |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // Effective is an account's limits: the plan's, plus each pack's quantity |
| 188 | // times its adds, clamped to the catalog's ceilings, then merged with a |
| 189 | // sponsored override taking the more generous value per field. Sponsored |
| 190 | // values are not clamped: a sponsored figure above a ceiling is an explicit |
| 191 | // operator decision (spec §8.7). |
| 192 | func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits) (Limits, error) { |
| 193 | p, ok := c.Plans[plan] |
| 194 | if !ok { |
| 195 | return Limits{}, fmt.Errorf("%w: %q", ErrUnknownPlan, plan) |
| 196 | } |
| 197 | l := p.Limits |
| 198 | names := make([]string, 0, len(packs)) |
| 199 | for name := range packs { |
| 200 | names = append(names, name) |
| 201 | } |
| 202 | slices.Sort(names) |
| 203 | for _, name := range names { |
| 204 | qty := packs[name] |
| 205 | pk, ok := c.Packs[name] |
| 206 | if !ok { |
| 207 | return Limits{}, fmt.Errorf("%w: %q", ErrUnknownPack, name) |
| 208 | } |
| 209 | if !slices.Contains(p.Packs, name) { |
| 210 | return Limits{}, fmt.Errorf("%w: %q on %q", ErrPackNotOffered, name, plan) |
| 211 | } |
| 212 | if qty < 1 || qty > pk.MaxQuantity { |
| 213 | return Limits{}, fmt.Errorf("%w: %q has %d, allowed 1 to %d", ErrPackQuantity, name, qty, pk.MaxQuantity) |
| 214 | } |
| 215 | fits := true |
| 216 | l.RunningThings = addPack(l.RunningThings, qty, pk.Adds.RunningThings, &fits) |
| 217 | l.DiskBytes = addPack(l.DiskBytes, qty, pk.Adds.DiskBytes, &fits) |
| 218 | l.StorageBytes = addPack(l.StorageBytes, qty, pk.Adds.StorageBytes, &fits) |
| 219 | l.EmailPerDay = addPack(l.EmailPerDay, qty, pk.Adds.EmailPerDay, &fits) |
| 220 | l.BandwidthBytesMonth = addPack(l.BandwidthBytesMonth, qty, pk.Adds.BandwidthBytesMonth, &fits) |
| 221 | if !fits { |
| 222 | return Limits{}, fmt.Errorf("%w: %q overflows", ErrPackQuantity, name) |
| 223 | } |
| 224 | } |
| 225 | ce := c.Ceilings |
| 226 | l.RunningThings = clamp(l.RunningThings, ce.RunningThings) |
| 227 | l.DiskBytes = clamp(l.DiskBytes, ce.DiskBytes) |
| 228 | l.StorageBytes = clamp(l.StorageBytes, ce.StorageBytes) |
| 229 | l.EmailPerDay = clamp(l.EmailPerDay, ce.EmailPerDay) |
| 230 | l.BandwidthBytesMonth = clamp(l.BandwidthBytesMonth, ce.BandwidthBytesMonth) |
| 231 | l.IdleSeconds = clamp(l.IdleSeconds, ce.IdleSeconds) |
| 232 | if sponsored != nil { |
| 233 | l = l.generous(*sponsored) |
| 234 | } |
| 235 | return l, nil |
| 236 | } |
| 237 | |
| 238 | func clamp[T int | int64](v, ceiling T) T { |
| 239 | if ceiling > 0 && v > ceiling { |
| 240 | return ceiling |
| 241 | } |
| 242 | return v |
| 243 | } |
| 244 | |
| 245 | // generous merges o into l field by field, keeping the more generous value. |
| 246 | // Zero in o means unset. |
| 247 | func (l Limits) generous(o Limits) Limits { |
| 248 | l.RunningThings = max(l.RunningThings, o.RunningThings) |
| 249 | l.SidecarsPerApp = max(l.SidecarsPerApp, o.SidecarsPerApp) |
| 250 | l.IdleSeconds = max(l.IdleSeconds, o.IdleSeconds) |
| 251 | l.AwakeHoursMonth = max(l.AwakeHoursMonth, o.AwakeHoursMonth) |
| 252 | l.DiskBytes = max(l.DiskBytes, o.DiskBytes) |
| 253 | l.StorageBytes = max(l.StorageBytes, o.StorageBytes) |
| 254 | l.EmailPerDay = max(l.EmailPerDay, o.EmailPerDay) |
| 255 | l.BandwidthBytesMonth = max(l.BandwidthBytesMonth, o.BandwidthBytesMonth) |
| 256 | l.HistoryDays = max(l.HistoryDays, o.HistoryDays) |
| 257 | l.StorageSelfServe = l.StorageSelfServe || o.StorageSelfServe |
| 258 | l.CustomDomains = l.CustomDomains || o.CustomDomains |
| 259 | l.Fleets = l.Fleets || o.Fleets |
| 260 | if o.MemoryTier == MemoryDefault { |
| 261 | l.MemoryTier = MemoryDefault |
| 262 | } |
| 263 | // A shorter minimum interval is the more generous one; zero is unset. |
| 264 | if o.MinScheduleSeconds > 0 && (l.MinScheduleSeconds == 0 || o.MinScheduleSeconds < l.MinScheduleSeconds) { |
| 265 | l.MinScheduleSeconds = o.MinScheduleSeconds |
| 266 | } |
| 267 | return l |
| 268 | } |
| 269 | |
| 270 | // addPack is v + qty*add, refusing (ok = false) anything that would overflow |
| 271 | // int64. Values are non-negative (validate), so only the upper bound matters; |
| 272 | // int is 64-bit on every platform this builds for. |
| 273 | func addPack[T int | int64](v T, qty int, add T, fits *bool) T { |
| 274 | if add != 0 && int64(qty) > (math.MaxInt64-int64(v))/int64(add) { |
| 275 | *fits = false |
| 276 | return v |
| 277 | } |
| 278 | return v + T(qty)*add |
| 279 | } |
| 280 | |