carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
8package plans
9
10import (
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.
26type 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.
45const (
46 MemorySmall = "small"
47 MemoryDefault = "default"
48)
49
50// Plan is one plan in a catalog.
51type 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.
59type Pack struct {
60 Adds Limits `json:"adds"`
61 MaxQuantity int `json:"max_quantity"`
62}
63
64// Catalog is a deployment's plan catalog.
65type 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
76var (
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.
84func 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.
98func 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.
120func (c *Catalog) ID() string { return c.id }
121
122func (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
167func (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.
177func (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).
192func (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
238func 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.
247func (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.
273func 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