package main // The free pool, slice 1 (docs/free-pool.md): a GAUGE, no behavior change. // Every meter sweep estimates what the free tier costs per day (from a tiny // config-declared cost model), derives the pool's daily budget from the // percent-of-total-cost ceiling + net revenue + an operator floor, and records // one pressure reading. Nothing here gates a create or touches an instance — // admission control and demo coupling are later slices, taken only once this // gauge has been watched for a while (the reaper's dry-run-then-arm pattern). // // The readings are ESTIMATES by construction: per-instance cost is derived // from declared constants (awake fractions, slot/storage rates), not metered. // That's deliberate — real cost attribution is heavy machinery for cents — // but every surface that shows a reading must say so. import ( "fmt" "log" "math" "net/http" "os" "strconv" "strings" "time" ) // poolCosts is the declared cost model (LCHAT_POOL_COSTS): euro rates and the // assumed awake fractions. Defaults are the 2026-07 snapshot from // docs/free-pool.md; formulas are the contract, these numbers drift and get // re-measured at tier-economics re-derivations. type poolCosts struct { fixedDay float64 // whole cloud plane per day (box + router/home/push/cloud) slotDay float64 // one materialized instance slot per day reqDay float64 // S3 request tail of one always-awake instance per day storageGBDay float64 // one stored GB per day (replica x retention) awakeFree float64 // assumed awake fraction of a free/paid space awakeDemo float64 // assumed awake fraction of a demo (short-lived, poked) } func defaultPoolCosts() poolCosts { return poolCosts{ fixedDay: 1.17, slotDay: 0.027, reqDay: 0.002, storageGBDay: 0.0014, awakeFree: 0.05, awakeDemo: 0.30, } } // parsePoolCosts reads LCHAT_POOL_COSTS ("fixed_day=1.17,slot_day=0.027,…"). // Absent keys keep their defaults; an unknown key or bad value is a deployment // mistake and errors rather than silently mismeasuring. func parsePoolCosts(v string) (poolCosts, error) { c := defaultPoolCosts() if strings.TrimSpace(v) == "" { return c, nil } fields := map[string]*float64{ "fixed_day": &c.fixedDay, "slot_day": &c.slotDay, "req_day": &c.reqDay, "storage_gb_day": &c.storageGBDay, "awake_free": &c.awakeFree, "awake_demo": &c.awakeDemo, } for _, pair := range strings.Split(v, ",") { k, val, ok := strings.Cut(strings.TrimSpace(pair), "=") if !ok { return c, fmt.Errorf("bad entry %q (want key=value)", pair) } dst, known := fields[strings.TrimSpace(k)] if !known { return c, fmt.Errorf("unknown cost %q", k) } n, err := strconv.ParseFloat(strings.TrimSpace(val), 64) if err != nil || n < 0 { return c, fmt.Errorf("cost %q: bad value %q", k, val) } *dst = n } return c, nil } // poolConfig is the budget's shape: X caps the subsidy as a share of total // cost, Y indexes it to revenue, the floor keeps a pool alive at zero revenue, // and D reserves a slice for demos (unused until the demo tier exists). type poolConfig struct { maxShare int // X: free-spend ceiling as % of total cost; 0 = pool off revenueShare int // Y: % of net revenue that funds the pool baseEURDay float64 demoShare int // D: % of the pool reserved for demos dryRun bool // slice-2 rollout gate: compute + log, refuse nothing costs poolCosts } func (c poolConfig) enabled() bool { return c.maxShare > 0 } // poolRow is one ledger space as the budget math sees it. NetEURYear is // resolved from the price book by the caller (0 for free/demo rows and for // tiers whose price-book entry carries no net_eur_year); Band picks which of // the tier's honor prices the subscription actually pays. type poolRow struct { Entitlement string Tier string Band string Seats int // billed head count on per-seat tiers; 0 on flat ones StorageBytes int64 NetEURYear float64 } // poolReading is one sweep's gauge output — what pool_meter stores. type poolReading struct { FreeCount int DemoCount int PaidCount int FreeCostDay float64 TotalCostDay float64 NetRevenueDay float64 BudgetDay float64 DemoBudgetDay float64 Pressure float64 } // computePool is the budget math (docs/free-pool.md § The budget formula), // pure for testing: // // budget_day = min( X% x total_cost_day, max(floor, Y% x net_revenue_day) ) // pressure = free_cost_day / budget_day func computePool(cfg poolConfig, rows []poolRow) poolReading { var r poolReading for _, row := range rows { awake := cfg.costs.awakeFree // "demo" is the entitlement the demo tier will use (docs/demo-spaces.md); // counting it here means the gauge is demo-ready the day that lands. if row.Entitlement == "demo" { awake = cfg.costs.awakeDemo } cost := awake*(cfg.costs.slotDay+cfg.costs.reqDay) + float64(row.StorageBytes)/1e9*cfg.costs.storageGBDay r.TotalCostDay += cost switch row.Entitlement { case entPaid: r.PaidCount++ r.NetRevenueDay += row.NetEURYear / 365 case "demo": r.DemoCount++ r.FreeCostDay += cost default: r.FreeCount++ r.FreeCostDay += cost } } r.TotalCostDay += cfg.costs.fixedDay r.BudgetDay, r.DemoBudgetDay, r.Pressure = poolBudget(cfg, r.TotalCostDay, r.FreeCostDay, r.NetRevenueDay) return r } // poolBudget derives the budget and pressure from a cost picture and a // revenue figure — split out so the sweep can re-derive with SMOOTHED revenue // while computePool stays pure over instantaneous inputs. func poolBudget(cfg poolConfig, totalCostDay, freeCostDay, revenueDay float64) (budget, demoBudget, pressure float64) { invariantCap := float64(cfg.maxShare) / 100 * totalCostDay revenuePool := float64(cfg.revenueShare) / 100 * revenueDay budget = math.Min(invariantCap, math.Max(cfg.baseEURDay, revenuePool)) demoBudget = float64(cfg.demoShare) / 100 * budget if budget > 0 { pressure = freeCostDay / budget } return budget, demoBudget, pressure } // smoothRevenue averages the current instantaneous revenue with the trailing // sweeps' stored readings (~90 days at the 6h cadence), so one big customer // churning shrinks the budget gradually — and a shrink only ever gates // admissions, never existing spaces (docs/free-pool.md § the budget formula). func smoothRevenue(current float64, history []float64) float64 { sum, n := current, 1 for _, v := range history { sum += v n++ } return sum / float64(n) } // poolState names a pressure for humans. The thresholds are the pre-agreed // policy from docs/free-pool.md; in this slice they label the gauge and // nothing else — no threshold actuates anything yet. func poolState(pressure float64) string { switch { case pressure < 0.8: return "green" case pressure < 1.0: return "amber" case pressure < 1.5: return "red" default: return "deep_red" } } // netForTier resolves a paid space's net revenue from the price book's // additive net_eur_year field (0 when billing is off or the entry has none). // The band picks the honor price actually paid — a Reduced subscription must // feed the pool budget its real net, not base's (docs/pricing-bands.md). // On per-seat tiers net_eur_year is per seat, so the figure scales by the // billed head count (docs/tier-economics.md § Per-seat). func (s *cloudServer) netForTier(tier, band string, seats int) float64 { if s.billing == nil { return 0 } t, ok := s.billing.tier(tier) if !ok { return 0 } net := t.NetEURYear if _, n, ok := bandPrice(t, band); ok { net = n } // else: a recorded band the book no longer names — base's figure beats zero. if t.PerSeat { net *= float64(max(seats, 1)) } return net } // poolSweep computes and records one gauge reading from the ledger. Called at // the end of each meter sweep (the storage figures it reads were just // refreshed); reads only the ledger — never controld, never an instance. func (s *cloudServer) poolSweep(now time.Time) { rows, err := s.db.poolRows() if err != nil { log.Printf("cloud: pool: ledger query: %v", err) return } for i := range rows { if rows[i].Entitlement == entPaid { rows[i].NetEURYear = s.netForTier(rows[i].Tier, rows[i].Band, rows[i].Seats) } } r := computePool(s.pool, rows) // Budget + pressure use SMOOTHED revenue (the trailing ~90d of stored // readings); the row keeps the instantaneous net_revenue_day so history // stays honest raw material for future smoothing. if hist, err := s.db.poolRevenueHistory(now.Add(-90 * 24 * time.Hour).Unix()); err == nil { r.BudgetDay, r.DemoBudgetDay, r.Pressure = poolBudget(s.pool, r.TotalCostDay, r.FreeCostDay, smoothRevenue(r.NetRevenueDay, hist)) } else { log.Printf("cloud: pool: revenue history: %v (using instantaneous)", err) } if r.BudgetDay <= 0 { log.Printf("cloud: pool: budget is zero (set LCHAT_POOL_BASE_EUR_DAY or add revenue) — gauge recorded, pressure meaningless") } if err := s.db.insertPoolMeter(now.Unix(), r); err != nil { log.Printf("cloud: pool: record: %v", err) return } log.Printf("cloud: pool %s — free spend ~€%.3f/day of €%.3f/day budget (pressure %.2f; %d free, %d demo, %d paid; est.)", poolState(r.Pressure), r.FreeCostDay, r.BudgetDay, r.Pressure, r.FreeCount, r.DemoCount, r.PaidCount) } // freePoolFullMsg is the honest register for a paused free create — relayed // verbatim by home (its 503 passthrough) and shown by clients. 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." // poolAdmitFree answers whether a new FREE space may be created right now — // the pool's only actuator (docs/free-pool.md § the control loop). Fails OPEN // on any uncertainty: pool off, dry-run, no reading yet, or a read error // never blocks a create — refusing must be a deliberate, measured state. // Demos are exempt (their carve-out + TTL bound them; docs/demo-spaces.md // wants the try-it path alive even at red); paid creates are never gated // (they fund the pool); existing spaces are never touched, ever. func (s *cloudServer) poolAdmitFree() bool { if !s.pool.enabled() { return true } _, r, ok, err := s.db.latestPoolMeter() if err != nil || !ok || r.Pressure < 1 { return true } if s.pool.dryRun { log.Printf("cloud: pool: WOULD refuse a free create (pressure %.2f) — dry-run", r.Pressure) return true } log.Printf("cloud: pool: refusing free create (pressure %.2f)", r.Pressure) return false } // poolHandler: GET /api/pool (admin token) — the latest gauge reading, for the // operator and, later, the status page. Everything in it is an estimate from // the declared cost model, and the payload says so. func (s *cloudServer) poolHandler(w http.ResponseWriter, r *http.Request) { if !s.pool.enabled() { writeJSON(w, http.StatusOK, map[string]any{"enabled": false}) return } ts, reading, ok, err := s.db.latestPoolMeter() if err != nil { writeControlErr(w, http.StatusInternalServerError, "pool_failed", "ledger error") return } if !ok { writeJSON(w, http.StatusOK, map[string]any{"enabled": true, "measured": false}) return } writeJSON(w, http.StatusOK, map[string]any{ "enabled": true, "measured": true, "estimate": true, // derived from the declared cost model, not metered "ts": ts, "state": poolState(reading.Pressure), "pressure": reading.Pressure, "free_count": reading.FreeCount, "demo_count": reading.DemoCount, "paid_count": reading.PaidCount, "free_cost_day": reading.FreeCostDay, "total_cost_day": reading.TotalCostDay, "net_revenue_day": reading.NetRevenueDay, "budget_day": reading.BudgetDay, "demo_budget_day": reading.DemoBudgetDay, "config": map[string]any{ "max_share": s.pool.maxShare, "revenue_share": s.pool.revenueShare, "base_eur_day": s.pool.baseEURDay, "demo_share": s.pool.demoShare, "dry_run": s.pool.dryRun, }, }) } // envFloat mirrors envInt for fractional knobs. func envFloat(key string, def float64) float64 { if v := os.Getenv(key); v != "" { if n, err := strconv.ParseFloat(v, 64); err == nil { return n } } return def }