| 1 | package main |
| 2 | |
| 3 | // The free pool, slice 1 (docs/free-pool.md): a GAUGE, no behavior change. |
| 4 | // Every meter sweep estimates what the free tier costs per day (from a tiny |
| 5 | // config-declared cost model), derives the pool's daily budget from the |
| 6 | // percent-of-total-cost ceiling + net revenue + an operator floor, and records |
| 7 | // one pressure reading. Nothing here gates a create or touches an instance — |
| 8 | // admission control and demo coupling are later slices, taken only once this |
| 9 | // gauge has been watched for a while (the reaper's dry-run-then-arm pattern). |
| 10 | // |
| 11 | // The readings are ESTIMATES by construction: per-instance cost is derived |
| 12 | // from declared constants (awake fractions, slot/storage rates), not metered. |
| 13 | // That's deliberate — real cost attribution is heavy machinery for cents — |
| 14 | // but every surface that shows a reading must say so. |
| 15 | |
| 16 | import ( |
| 17 | "fmt" |
| 18 | "log" |
| 19 | "math" |
| 20 | "net/http" |
| 21 | "os" |
| 22 | "strconv" |
| 23 | "strings" |
| 24 | "time" |
| 25 | ) |
| 26 | |
| 27 | // poolCosts is the declared cost model (LCHAT_POOL_COSTS): euro rates and the |
| 28 | // assumed awake fractions. Defaults are the 2026-07 snapshot from |
| 29 | // docs/free-pool.md; formulas are the contract, these numbers drift and get |
| 30 | // re-measured at tier-economics re-derivations. |
| 31 | type poolCosts struct { |
| 32 | fixedDay float64 // whole cloud plane per day (box + router/home/push/cloud) |
| 33 | slotDay float64 // one materialized instance slot per day |
| 34 | reqDay float64 // S3 request tail of one always-awake instance per day |
| 35 | storageGBDay float64 // one stored GB per day (replica x retention) |
| 36 | awakeFree float64 // assumed awake fraction of a free/paid space |
| 37 | awakeDemo float64 // assumed awake fraction of a demo (short-lived, poked) |
| 38 | } |
| 39 | |
| 40 | func defaultPoolCosts() poolCosts { |
| 41 | return poolCosts{ |
| 42 | fixedDay: 1.17, |
| 43 | slotDay: 0.027, |
| 44 | reqDay: 0.002, |
| 45 | storageGBDay: 0.0014, |
| 46 | awakeFree: 0.05, |
| 47 | awakeDemo: 0.30, |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // parsePoolCosts reads LCHAT_POOL_COSTS ("fixed_day=1.17,slot_day=0.027,…"). |
| 52 | // Absent keys keep their defaults; an unknown key or bad value is a deployment |
| 53 | // mistake and errors rather than silently mismeasuring. |
| 54 | func parsePoolCosts(v string) (poolCosts, error) { |
| 55 | c := defaultPoolCosts() |
| 56 | if strings.TrimSpace(v) == "" { |
| 57 | return c, nil |
| 58 | } |
| 59 | fields := map[string]*float64{ |
| 60 | "fixed_day": &c.fixedDay, |
| 61 | "slot_day": &c.slotDay, |
| 62 | "req_day": &c.reqDay, |
| 63 | "storage_gb_day": &c.storageGBDay, |
| 64 | "awake_free": &c.awakeFree, |
| 65 | "awake_demo": &c.awakeDemo, |
| 66 | } |
| 67 | for _, pair := range strings.Split(v, ",") { |
| 68 | k, val, ok := strings.Cut(strings.TrimSpace(pair), "=") |
| 69 | if !ok { |
| 70 | return c, fmt.Errorf("bad entry %q (want key=value)", pair) |
| 71 | } |
| 72 | dst, known := fields[strings.TrimSpace(k)] |
| 73 | if !known { |
| 74 | return c, fmt.Errorf("unknown cost %q", k) |
| 75 | } |
| 76 | n, err := strconv.ParseFloat(strings.TrimSpace(val), 64) |
| 77 | if err != nil || n < 0 { |
| 78 | return c, fmt.Errorf("cost %q: bad value %q", k, val) |
| 79 | } |
| 80 | *dst = n |
| 81 | } |
| 82 | return c, nil |
| 83 | } |
| 84 | |
| 85 | // poolConfig is the budget's shape: X caps the subsidy as a share of total |
| 86 | // cost, Y indexes it to revenue, the floor keeps a pool alive at zero revenue, |
| 87 | // and D reserves a slice for demos (unused until the demo tier exists). |
| 88 | type poolConfig struct { |
| 89 | maxShare int // X: free-spend ceiling as % of total cost; 0 = pool off |
| 90 | revenueShare int // Y: % of net revenue that funds the pool |
| 91 | baseEURDay float64 |
| 92 | demoShare int // D: % of the pool reserved for demos |
| 93 | dryRun bool // slice-2 rollout gate: compute + log, refuse nothing |
| 94 | costs poolCosts |
| 95 | } |
| 96 | |
| 97 | func (c poolConfig) enabled() bool { return c.maxShare > 0 } |
| 98 | |
| 99 | // poolRow is one ledger space as the budget math sees it. NetEURYear is |
| 100 | // resolved from the price book by the caller (0 for free/demo rows and for |
| 101 | // tiers whose price-book entry carries no net_eur_year); Band picks which of |
| 102 | // the tier's honor prices the subscription actually pays. |
| 103 | type poolRow struct { |
| 104 | Entitlement string |
| 105 | Tier string |
| 106 | Band string |
| 107 | Seats int // billed head count on per-seat tiers; 0 on flat ones |
| 108 | StorageBytes int64 |
| 109 | NetEURYear float64 |
| 110 | } |
| 111 | |
| 112 | // poolReading is one sweep's gauge output — what pool_meter stores. |
| 113 | type poolReading struct { |
| 114 | FreeCount int |
| 115 | DemoCount int |
| 116 | PaidCount int |
| 117 | FreeCostDay float64 |
| 118 | TotalCostDay float64 |
| 119 | NetRevenueDay float64 |
| 120 | BudgetDay float64 |
| 121 | DemoBudgetDay float64 |
| 122 | Pressure float64 |
| 123 | } |
| 124 | |
| 125 | // computePool is the budget math (docs/free-pool.md § The budget formula), |
| 126 | // pure for testing: |
| 127 | // |
| 128 | // budget_day = min( X% x total_cost_day, max(floor, Y% x net_revenue_day) ) |
| 129 | // pressure = free_cost_day / budget_day |
| 130 | func computePool(cfg poolConfig, rows []poolRow) poolReading { |
| 131 | var r poolReading |
| 132 | for _, row := range rows { |
| 133 | awake := cfg.costs.awakeFree |
| 134 | // "demo" is the entitlement the demo tier will use (docs/demo-spaces.md); |
| 135 | // counting it here means the gauge is demo-ready the day that lands. |
| 136 | if row.Entitlement == "demo" { |
| 137 | awake = cfg.costs.awakeDemo |
| 138 | } |
| 139 | cost := awake*(cfg.costs.slotDay+cfg.costs.reqDay) + |
| 140 | float64(row.StorageBytes)/1e9*cfg.costs.storageGBDay |
| 141 | r.TotalCostDay += cost |
| 142 | switch row.Entitlement { |
| 143 | case entPaid: |
| 144 | r.PaidCount++ |
| 145 | r.NetRevenueDay += row.NetEURYear / 365 |
| 146 | case "demo": |
| 147 | r.DemoCount++ |
| 148 | r.FreeCostDay += cost |
| 149 | default: |
| 150 | r.FreeCount++ |
| 151 | r.FreeCostDay += cost |
| 152 | } |
| 153 | } |
| 154 | r.TotalCostDay += cfg.costs.fixedDay |
| 155 | r.BudgetDay, r.DemoBudgetDay, r.Pressure = poolBudget(cfg, r.TotalCostDay, r.FreeCostDay, r.NetRevenueDay) |
| 156 | return r |
| 157 | } |
| 158 | |
| 159 | // poolBudget derives the budget and pressure from a cost picture and a |
| 160 | // revenue figure — split out so the sweep can re-derive with SMOOTHED revenue |
| 161 | // while computePool stays pure over instantaneous inputs. |
| 162 | func poolBudget(cfg poolConfig, totalCostDay, freeCostDay, revenueDay float64) (budget, demoBudget, pressure float64) { |
| 163 | invariantCap := float64(cfg.maxShare) / 100 * totalCostDay |
| 164 | revenuePool := float64(cfg.revenueShare) / 100 * revenueDay |
| 165 | budget = math.Min(invariantCap, math.Max(cfg.baseEURDay, revenuePool)) |
| 166 | demoBudget = float64(cfg.demoShare) / 100 * budget |
| 167 | if budget > 0 { |
| 168 | pressure = freeCostDay / budget |
| 169 | } |
| 170 | return budget, demoBudget, pressure |
| 171 | } |
| 172 | |
| 173 | // smoothRevenue averages the current instantaneous revenue with the trailing |
| 174 | // sweeps' stored readings (~90 days at the 6h cadence), so one big customer |
| 175 | // churning shrinks the budget gradually — and a shrink only ever gates |
| 176 | // admissions, never existing spaces (docs/free-pool.md § the budget formula). |
| 177 | func smoothRevenue(current float64, history []float64) float64 { |
| 178 | sum, n := current, 1 |
| 179 | for _, v := range history { |
| 180 | sum += v |
| 181 | n++ |
| 182 | } |
| 183 | return sum / float64(n) |
| 184 | } |
| 185 | |
| 186 | // poolState names a pressure for humans. The thresholds are the pre-agreed |
| 187 | // policy from docs/free-pool.md; in this slice they label the gauge and |
| 188 | // nothing else — no threshold actuates anything yet. |
| 189 | func poolState(pressure float64) string { |
| 190 | switch { |
| 191 | case pressure < 0.8: |
| 192 | return "green" |
| 193 | case pressure < 1.0: |
| 194 | return "amber" |
| 195 | case pressure < 1.5: |
| 196 | return "red" |
| 197 | default: |
| 198 | return "deep_red" |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // netForTier resolves a paid space's net revenue from the price book's |
| 203 | // additive net_eur_year field (0 when billing is off or the entry has none). |
| 204 | // The band picks the honor price actually paid — a Reduced subscription must |
| 205 | // feed the pool budget its real net, not base's (docs/pricing-bands.md). |
| 206 | // On per-seat tiers net_eur_year is per seat, so the figure scales by the |
| 207 | // billed head count (docs/tier-economics.md § Per-seat). |
| 208 | func (s *cloudServer) netForTier(tier, band string, seats int) float64 { |
| 209 | if s.billing == nil { |
| 210 | return 0 |
| 211 | } |
| 212 | t, ok := s.billing.tier(tier) |
| 213 | if !ok { |
| 214 | return 0 |
| 215 | } |
| 216 | net := t.NetEURYear |
| 217 | if _, n, ok := bandPrice(t, band); ok { |
| 218 | net = n |
| 219 | } |
| 220 | // else: a recorded band the book no longer names — base's figure beats zero. |
| 221 | if t.PerSeat { |
| 222 | net *= float64(max(seats, 1)) |
| 223 | } |
| 224 | return net |
| 225 | } |
| 226 | |
| 227 | // poolSweep computes and records one gauge reading from the ledger. Called at |
| 228 | // the end of each meter sweep (the storage figures it reads were just |
| 229 | // refreshed); reads only the ledger — never controld, never an instance. |
| 230 | func (s *cloudServer) poolSweep(now time.Time) { |
| 231 | rows, err := s.db.poolRows() |
| 232 | if err != nil { |
| 233 | log.Printf("cloud: pool: ledger query: %v", err) |
| 234 | return |
| 235 | } |
| 236 | for i := range rows { |
| 237 | if rows[i].Entitlement == entPaid { |
| 238 | rows[i].NetEURYear = s.netForTier(rows[i].Tier, rows[i].Band, rows[i].Seats) |
| 239 | } |
| 240 | } |
| 241 | r := computePool(s.pool, rows) |
| 242 | // Budget + pressure use SMOOTHED revenue (the trailing ~90d of stored |
| 243 | // readings); the row keeps the instantaneous net_revenue_day so history |
| 244 | // stays honest raw material for future smoothing. |
| 245 | if hist, err := s.db.poolRevenueHistory(now.Add(-90 * 24 * time.Hour).Unix()); err == nil { |
| 246 | r.BudgetDay, r.DemoBudgetDay, r.Pressure = poolBudget(s.pool, r.TotalCostDay, r.FreeCostDay, smoothRevenue(r.NetRevenueDay, hist)) |
| 247 | } else { |
| 248 | log.Printf("cloud: pool: revenue history: %v (using instantaneous)", err) |
| 249 | } |
| 250 | if r.BudgetDay <= 0 { |
| 251 | log.Printf("cloud: pool: budget is zero (set LCHAT_POOL_BASE_EUR_DAY or add revenue) — gauge recorded, pressure meaningless") |
| 252 | } |
| 253 | if err := s.db.insertPoolMeter(now.Unix(), r); err != nil { |
| 254 | log.Printf("cloud: pool: record: %v", err) |
| 255 | return |
| 256 | } |
| 257 | log.Printf("cloud: pool %s — free spend ~€%.3f/day of €%.3f/day budget (pressure %.2f; %d free, %d demo, %d paid; est.)", |
| 258 | poolState(r.Pressure), r.FreeCostDay, r.BudgetDay, r.Pressure, r.FreeCount, r.DemoCount, r.PaidCount) |
| 259 | } |
| 260 | |
| 261 | // freePoolFullMsg is the honest register for a paused free create — relayed |
| 262 | // verbatim by home (its 503 passthrough) and shown by clients. |
| 263 | const freePoolFullMsg = "The free pool is full right now. Paying supports free spaces for those who can't — as more people upgrade, the pool grows. Check back soon, or upgrade to create a space now." |
| 264 | |
| 265 | // poolAdmitFree answers whether a new FREE space may be created right now — |
| 266 | // the pool's only actuator (docs/free-pool.md § the control loop). Fails OPEN |
| 267 | // on any uncertainty: pool off, dry-run, no reading yet, or a read error |
| 268 | // never blocks a create — refusing must be a deliberate, measured state. |
| 269 | // Demos are exempt (their carve-out + TTL bound them; docs/demo-spaces.md |
| 270 | // wants the try-it path alive even at red); paid creates are never gated |
| 271 | // (they fund the pool); existing spaces are never touched, ever. |
| 272 | func (s *cloudServer) poolAdmitFree() bool { |
| 273 | if !s.pool.enabled() { |
| 274 | return true |
| 275 | } |
| 276 | _, r, ok, err := s.db.latestPoolMeter() |
| 277 | if err != nil || !ok || r.Pressure < 1 { |
| 278 | return true |
| 279 | } |
| 280 | if s.pool.dryRun { |
| 281 | log.Printf("cloud: pool: WOULD refuse a free create (pressure %.2f) — dry-run", r.Pressure) |
| 282 | return true |
| 283 | } |
| 284 | log.Printf("cloud: pool: refusing free create (pressure %.2f)", r.Pressure) |
| 285 | return false |
| 286 | } |
| 287 | |
| 288 | // poolHandler: GET /api/pool (admin token) — the latest gauge reading, for the |
| 289 | // operator and, later, the status page. Everything in it is an estimate from |
| 290 | // the declared cost model, and the payload says so. |
| 291 | func (s *cloudServer) poolHandler(w http.ResponseWriter, r *http.Request) { |
| 292 | if !s.pool.enabled() { |
| 293 | writeJSON(w, http.StatusOK, map[string]any{"enabled": false}) |
| 294 | return |
| 295 | } |
| 296 | ts, reading, ok, err := s.db.latestPoolMeter() |
| 297 | if err != nil { |
| 298 | writeControlErr(w, http.StatusInternalServerError, "pool_failed", "ledger error") |
| 299 | return |
| 300 | } |
| 301 | if !ok { |
| 302 | writeJSON(w, http.StatusOK, map[string]any{"enabled": true, "measured": false}) |
| 303 | return |
| 304 | } |
| 305 | writeJSON(w, http.StatusOK, map[string]any{ |
| 306 | "enabled": true, |
| 307 | "measured": true, |
| 308 | "estimate": true, // derived from the declared cost model, not metered |
| 309 | "ts": ts, |
| 310 | "state": poolState(reading.Pressure), |
| 311 | "pressure": reading.Pressure, |
| 312 | "free_count": reading.FreeCount, |
| 313 | "demo_count": reading.DemoCount, |
| 314 | "paid_count": reading.PaidCount, |
| 315 | "free_cost_day": reading.FreeCostDay, |
| 316 | "total_cost_day": reading.TotalCostDay, |
| 317 | "net_revenue_day": reading.NetRevenueDay, |
| 318 | "budget_day": reading.BudgetDay, |
| 319 | "demo_budget_day": reading.DemoBudgetDay, |
| 320 | "config": map[string]any{ |
| 321 | "max_share": s.pool.maxShare, |
| 322 | "revenue_share": s.pool.revenueShare, |
| 323 | "base_eur_day": s.pool.baseEURDay, |
| 324 | "demo_share": s.pool.demoShare, |
| 325 | "dry_run": s.pool.dryRun, |
| 326 | }, |
| 327 | }) |
| 328 | } |
| 329 | |
| 330 | // envFloat mirrors envInt for fractional knobs. |
| 331 | func envFloat(key string, def float64) float64 { |
| 332 | if v := os.Getenv(key); v != "" { |
| 333 | if n, err := strconv.ParseFloat(v, 64); err == nil { |
| 334 | return n |
| 335 | } |
| 336 | } |
| 337 | return def |
| 338 | } |
| 339 | |