carlos / platform Public

docs: economics-v2 implementation plan (fleet meters, knobs, pool ledger, tiers)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 197f682248413f0abe0fb9e92df084cf4b48f7a1 parent 0b98152
1 file changed, +291 −0
  • docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md +291 −0
diff --git a/docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md b/docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md
new file mode 100644
index 000000000..464b8a5d6
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-15-economics-v2-fleet-pool.md
@@ -0,0 +1,291 @@
+# Economics v2 (fleet meters, pricing axes, pool ledger, tiers) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the business-model spec measurable: fleet box-hours metered per account with device classes, the new pricing knobs (box-months, mem overage, pool), the pool ledger on `/fleet/economics`, and an account-tier marker.
+
+**Architecture:** Pure extension of the live unit-economics machinery (`2026-08-11-unit-economics-design.md` + this week's code): two new frozen meter names derived in the statements pass from registry fleet rows × metrics presence; additive `Scenario` fields with `Validate` guards; tier as a console-store column consumed by the view; pool ledger as arithmetic in `buildEconomicsView` + one tile. Spec: `docs/superpowers/specs/2026-08-13-carloku-business-model-design.md`.
+
+**Tech Stack:** Go stdlib, sqlite console store, existing `release.Store` bucket I/O, Go templates + inline JS twins (no external subresources).
+
+## Global Constraints
+
+- Gate before any commit: `go build ./... && go vet ./... && go test ./... -count=1`, `gofmt -l` clean on touched files, `GOOS=darwin go build ./...`. `internal/activator` has a known unrelated timing flake — isolated rerun acceptable.
+- Env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; sandbox-disable on "read-only file system".
+- Branch: `business-model` (spec lives there; PR #197). Everything lands through that PR — never merge to main locally (repo rule 2026-08-10).
+- FROZEN meter names, chosen once here: `fleet_box_hours_server`, `fleet_box_hours_device`. Additive to `internal/usage`'s vocabulary; never rename.
+- All bucket/JSON shapes additive; `Scenario.Schema` stays 1; old scenarios must parse (new fields omitempty/zero-default with semantics "feature off").
+- DEVICE CLASS RULING (adjusted from spec — tag grammar has no `=`): a fleet box is device-class iff its `FleetBox.Tags` comma-list contains the literal tag `device`; otherwise server-class. Update the spec's "Device class is a tag" bullet to name the bare `device` tag in Task 8.
+- Tier values: `""` (unset, treated as `pool`), `try`, `pool`, `hosted`, `fleet`, `dedicated`. Revenue is computed ONLY for `hosted`/`fleet`/`dedicated`; `try`/`pool`/`""` accounts price to zero revenue and their COST is the pool draw.
+- Console gates: operator pages/doors keep the exact patterns now in `economicspass.go` (method-check first on POST doors, then session → IsOperator → bare 404, then CSRF).
+- Volume break applies when average concurrent fleet boxes ≥ threshold: `(server_hours+device_hours)/730 >= MinBoxes`.
+- Money float64 USD; JS twins in `econ_maths_js` must mirror Go formulas exactly.
+
+---
+
+### Task 1: Meter names + scenario knobs
+
+**Files:**
+- Modify: `internal/usage/usage.go` (two constants)
+- Modify: `internal/economics/scenario.go`
+- Test: `internal/economics/scenario_test.go` (append)
+
+**Interfaces:**
+- Produces (later tasks consume verbatim): `usage.MeterFleetBoxHoursServer = "fleet_box_hours_server"`, `usage.MeterFleetBoxHoursDevice = "fleet_box_hours_device"`; on `RateSheet`: `BoxMonthServerUSD float64 \`json:"box_month_server_usd"\``, `BoxMonthDeviceUSD float64 \`json:"box_month_device_usd"\``, `MemGBHourUSD float64 \`json:"mem_gb_hour_usd"\``; new structs on `Scenario`: `FleetBreak struct { MinBoxes float64 \`json:"min_boxes"\`; Discount float64 \`json:"discount"\` } \`json:"fleet_break"\`` and `Pool struct { RevenueFrac float64 \`json:"revenue_frac"\`; TopupUSD float64 \`json:"topup_usd"\` } \`json:"pool"\``.
+- `DefaultScenario()` gains: sheet box prices 5 and 1, mem 0 (off), `FleetBreak{MinBoxes: 10, Discount: 0.4}`, `Pool{RevenueFrac: 0.10, TopupUSD: 200}`.
+- `Validate()` additions: `FleetBreak.Discount` in [0,1); `MinBoxes >= 0`; `Pool.RevenueFrac` in [0,1]; `TopupUSD >= 0`; all three sheet prices `>= 0`.
+
+- [ ] **Step 1: Failing tests** — append to `scenario_test.go`:
+
+```go
+func TestDefaultScenarioV2Knobs(t *testing.T) {
+ sc := DefaultScenario()
+ if sc.Sheet.BoxMonthServerUSD != 5 || sc.Sheet.BoxMonthDeviceUSD != 1 {
+ t.Fatalf("box prices = %v/%v", sc.Sheet.BoxMonthServerUSD, sc.Sheet.BoxMonthDeviceUSD)
+ }
+ if sc.FleetBreak.MinBoxes != 10 || sc.FleetBreak.Discount != 0.4 {
+ t.Fatalf("fleet break = %+v", sc.FleetBreak)
+ }
+ if sc.Pool.RevenueFrac != 0.10 || sc.Pool.TopupUSD != 200 {
+ t.Fatalf("pool = %+v", sc.Pool)
+ }
+ if err := sc.Validate(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestValidateRejectsV2(t *testing.T) {
+ cases := []func(*Scenario){
+ func(s *Scenario) { s.FleetBreak.Discount = 1.0 },
+ func(s *Scenario) { s.FleetBreak.Discount = -0.1 },
+ func(s *Scenario) { s.FleetBreak.MinBoxes = -1 },
+ func(s *Scenario) { s.Pool.RevenueFrac = 1.5 },
+ func(s *Scenario) { s.Pool.TopupUSD = -5 },
+ func(s *Scenario) { s.Sheet.MemGBHourUSD = -0.01 },
+ }
+ for i, mutate := range cases {
+ sc := DefaultScenario()
+ sc.Name = "test"
+ mutate(&sc)
+ if err := sc.Validate(); err == nil {
+ t.Fatalf("case %d validated", i)
+ }
+ }
+}
+
+func TestOldScenarioStillParses(t *testing.T) {
+ old := `{"schema":1,"name":"x","rate_card":{"boxes":{"":{"hour_usd":0.02,"cores":2,"mem_gb":2}}},"allocation":{"policy":"cpu"},"mode":"cost-plus","margin":0.8}`
+ var sc Scenario
+ if err := json.Unmarshal([]byte(old), &sc); err != nil {
+ t.Fatal(err)
+ }
+ if sc.Pool.RevenueFrac != 0 || sc.FleetBreak.MinBoxes != 0 {
+ t.Fatalf("zero-defaults broken: %+v %+v", sc.Pool, sc.FleetBreak)
+ }
+}
+```
+
+(`encoding/json` import may be needed in the test file.)
+- [ ] **Step 2: Run** `go test ./internal/economics/ -count=1` — FAIL (undefined fields).
+- [ ] **Step 3: Implement** — the two constants in `usage.go` beside the existing `Meter*` block; the fields/structs/defaults/validation exactly as the Interfaces block states. Zero values must mean "off/permissive" so `TestOldScenarioStillParses` holds (a zero `FleetBreak` never discounts because `MinBoxes 0` with `Discount 0` is a no-op discount; guard division nowhere needed).
+- [ ] **Step 4: Run** `go test ./internal/economics/ ./internal/usage/ -count=1` — PASS.
+- [ ] **Step 5: Commit** `git add internal/usage/ internal/economics/ && git commit -m "economics: v2 knobs — fleet box prices, mem overage, break, pool"`
+
+---
+
+### Task 2: PriceAccount v2 — fleet pricing, break, mem overage (golden)
+
+**Files:**
+- Modify: `internal/economics/compute.go`
+- Test: `internal/economics/compute_test.go` (append)
+
+**Interfaces:**
+- Consumes: Task 1's fields; existing `PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics` — signature UNCHANGED (meters carry the new keys).
+- Canonical formulas (JS twin mirrors in Task 6):
+ - `fleetRevenue = (m[fleet_box_hours_server]/730)*Sheet.BoxMonthServerUSD + (m[fleet_box_hours_device]/730)*Sheet.BoxMonthDeviceUSD`
+ - break: `avgBoxes = (m[server]+m[device])/730`; if `FleetBreak.MinBoxes > 0 && avgBoxes >= MinBoxes` then `fleetRevenue *= (1 - Discount)`.
+ - Fleet revenue adds in BOTH modes (fleet boxes bill per-box regardless of cost-plus/rate-sheet for apps — the one rule).
+ - mem overage (rate-sheet mode only, since cost-plus already recovers mem via allocated cost): `overage = max(0, m[mem_gb_hours] - FreeTier.MemGBHours) * Sheet.MemGBHourUSD` added to revenue.
+ - FreeTierPos unchanged (fleet meters have no allowance axis).
+
+- [ ] **Step 1: Failing tests** — append (uses the file's existing `approx` helper):
+
+```go
+func TestPriceAccountFleetBoxes(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{BoxMonthServerUSD: 5, BoxMonthDeviceUSD: 1}
+ sc.FreeTier = FreeTier{}
+ // 2 server boxes + 5 device boxes, full month.
+ meters := map[string]float64{
+ usage.MeterFleetBoxHoursServer: 2 * 730,
+ usage.MeterFleetBoxHoursDevice: 5 * 730,
+ }
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "fleet revenue", ae.RevenueUSD, 2*5+5*1) // $15
+}
+
+func TestPriceAccountFleetVolumeBreak(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{BoxMonthServerUSD: 5}
+ sc.FleetBreak = FleetBreak{MinBoxes: 10, Discount: 0.4}
+ sc.FreeTier = FreeTier{}
+ meters := map[string]float64{usage.MeterFleetBoxHoursServer: 12 * 730}
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "discounted", ae.RevenueUSD, 12*5*0.6) // $36
+ // 9.99 avg boxes: no break.
+ meters[usage.MeterFleetBoxHoursServer] = 9.99 * 730
+ ae = PriceAccount(sc, "bae", meters, 0)
+ approx(t, "no break", ae.RevenueUSD, 9.99*5)
+}
+
+func TestPriceAccountFleetAddsInCostPlusToo(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "cost-plus"
+ sc.Margin = 0.5
+ sc.Sheet.BoxMonthServerUSD = 5
+ sc.RateCard.EgressGBUSD = 0.10
+ sc.RateCard.RequestsPer1kUSD, sc.RateCard.StorageGBMonthUSD = 0, 0
+ sc.FreeTier = FreeTier{}
+ meters := map[string]float64{
+ usage.MeterEgressGB: 10, // $1 cost -> $2 cost-plus revenue
+ usage.MeterFleetBoxHoursServer: 1 * 730, // + $5 box-month
+ }
+ ae := PriceAccount(sc, "bae", meters, 0)
+ approx(t, "combined", ae.RevenueUSD, 2+5)
+}
+
+func TestPriceAccountMemOverage(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{MemGBHourUSD: 0.01}
+ sc.FreeTier = FreeTier{MemGBHours: 100}
+ meters := map[string]float64{usage.MeterMemGBHours: 400}
+ ae := PriceAccount(sc, "bab", meters, 0)
+ approx(t, "overage", ae.RevenueUSD, 3.0) // 300 over * $0.01
+}
+```
+
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement** in `PriceAccount` exactly per the canonical formulas; fleet revenue computed once, added after the mode switch (both modes); mem overage inside the rate-sheet branch. Update `PriceAccount`'s doc comment's JS-mirror warning to name the new formulas.
+- [ ] **Step 4: Run** `go test ./internal/economics/ -count=1` — PASS (all pre-existing goldens untouched).
+- [ ] **Step 5: Commit** `git commit -am "economics: fleet box-month pricing with volume break, mem overage"`
+
+---
+
+### Task 3: Fleet hours derivation in the statements pass
+
+**Files:**
+- Modify: `internal/console/economicspass.go`
+- Test: `internal/console/economicspass_test.go` (append)
+
+**Interfaces:**
+- Consumes: `registry.Registry.ListFleets() ([]registry.Fleet, error)` and `ListFleetBoxes(fleet string) ([]registry.FleetBox, error)` (`internal/registry/fleets.go:215,405`); `FleetBox.Tags` comma-list (`device` tag = device class); `Fleet.Account` (sqid domain, same as Route.Account); metrics day rollups already read by `sweepUsage` (per-box `metrics.DayRollup.Hours[hour].Units` — a box's PRESENT hour = any hour key in its rollup, hours counted as `len(Hours)`... NO: presence must count sample time. RULING: a fleet box's presence for a day = `Σ over hours of (max unit N in that hour)/60` — but fleet rollups may carry only host-level aggregates; USE the host CPU agg: `HourAgg.Host["cpu"].N` minutes when present, else the max N across `Host` entries; fallback when Host empty: 60 minutes per present hour key. Implement helper `rollupPresenceHours(d metrics.DayRollup) float64` with exactly that rule and unit-test it).
+- Produces: `(*Server).fleetMeters(ctx, from, to time.Time) (map[string]map[string]float64, error)` — account sqid → {fleet_box_hours_server, fleet_box_hours_device}; called by `statementsPass` (merged into each account's meters before `UpsertStatement`) and by `buildEconomicsView` (merged into `sweep.PerAccount` copies — Task 5 wires the view side).
+- Mapping rule: metrics box label → fleet box: build `map[label]struct{acct string; device bool}` from `ListFleets`×`ListFleetBoxes` (ALL statuses — a detached box's past hours still bill); label collisions across fleets: first fleet wins alphabetically + one log line per pass (registry enforces per-fleet uniqueness only).
+- Boxes that are NOT fleet boxes (platform boxes) contribute nothing here.
+
+- [ ] **Step 1: Failing tests** — append: `TestRollupPresenceHours` (rollup with 2 hours: one with Host cpu N=60 → 1.0h, one with empty Host map → 1.0h fallback; total 2.0); `TestStatementsPassFleetMeters`: seed fixture registry with a fleet (`fx` helper — check how fleets tests seed `registry` fleets: grep `UpsertFleet\|AddFleetBox` in `internal/console/*_test.go` and reuse that helper) owning account `"bae"`, two boxes `bx-server` (no tags) + `bx-pi` (tags `"pi,device"`); write metrics day rollups for boxes `bx-server` (24 present hours, Host cpu N=60 each) and `bx-pi` (12 hours) via `putStoreJSON`; run `statementsPass`; assert statement for `bae` has `fleet_box_hours_server == 24` and `fleet_box_hours_device == 12`.
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement** — `rollupPresenceHours` + `fleetMeters` (open registry via the existing `openPassRegistry`; sweep `metrics/boxes/` day objects for the range — REUSE the day-reading that `sweepUsage` already does rather than re-listing: extend `usageSweep` with `FleetHours map[string]map[string]float64` populated during its metrics-day loop from a label-map parameter, OR do a second bounded read in `fleetMeters`; choose whichever keeps `sweepUsage`'s existing tests green unmodified and note the choice); merge into `statementsPass` meters before upsert.
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — PASS including all pre-existing statements tests unmodified.
+- [ ] **Step 5: Commit** `git commit -am "console: fleet box-hours metered per account with device class"`
+
+---
+
+### Task 4: Account tier column + operator door
+
+**Files:**
+- Modify: `internal/console/store.go` (migration + methods)
+- Modify: `internal/console/economicspass.go` (the door, beside the other economics doors)
+- Modify: `internal/console/server.go` (route registration)
+- Test: `internal/console/economicspass_test.go` (append)
+
+**Interfaces:**
+- Migration (append to the migration list, pattern of `store.go:209`): `ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT '';`
+- `func (s *Store) SetAccountTier(accountID int64, tier string) error` — validates tier ∈ {"", "try", "pool", "hosted", "fleet", "dedicated"}; `func (s *Store) AccountTiers() (map[string]string, error)` — sqid → tier (all accounts, deleted excluded, same filter as `Accounts()`).
+- Door: `POST /fleet/economics/tier` (bare-registered; handler checks method FIRST → NotFound, then session → IsOperator → bare 404, then CSRF via `subtle.ConstantTimeCompare`) — form fields `csrf`, `account` (sqid), `tier`; resolves sqid via `AccountAnyBySqid`-style lookup (grep for the right store method that finds an account by sqid regardless of viewer); flash + redirect to `/fleet/economics/accounts`.
+- Produces for Task 5: `AccountTiers()`.
+
+- [ ] **Step 1: Failing tests** — append: `TestAccountTierStore` (set on fixture account → AccountTiers shows it; invalid tier rejected; unknown id error or no-op — match store conventions, assert what you implement); `TestEconomicsTierDoor` (ghost-404 parity for GET + signed-out POST + non-operator POST; operator POST sets tier and redirects — clone the `TestEconomicsWriteDoorsRejectNonPOST`/gate patterns already in the file).
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement** per Interfaces.
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — PASS.
+- [ ] **Step 5: Commit** `git commit -am "console: account tier column with operator door"`
+
+---
+
+### Task 5: View v2 — tier-aware revenue, fleet meters, pool ledger
+
+**Files:**
+- Modify: `internal/console/economicsview.go`
+- Test: `internal/console/economicsview_test.go` (append)
+
+**Interfaces:**
+- Consumes: Tasks 1-4 (`fleetMeters`, `AccountTiers`, v2 scenario fields, v2 `PriceAccount`).
+- `buildEconomicsView` changes: (a) merge `fleetMeters` results into each account's meters before pricing; (b) load `AccountTiers()`; (c) revenue rule: accounts with tier `hosted`/`fleet`/`dedicated` price normally; tier `try`/`pool`/`""` accounts get `RevenueUSD = 0, MarginFrac = 0, FreeTierPos` computed as today (their cost stands); (d) compute `PoolLedger`.
+- Produces:
+ ```go
+ type PoolLedger struct {
+ PoolUSD, DrawUSD, HeadroomUSD float64
+ PayingRevenueUSD float64 // the revenue the frac applies to
+ }
+ ```
+ `EconomicsView.Pool PoolLedger` + `EconomicsView.Tiers map[string]string`; `AccountEconomics` rows unchanged in shape; `econAccountRow` gains tier at render via `view.Tiers` (Task 6).
+ Formula: `PayingRevenueUSD = Σ revenue of hosted/fleet/dedicated accounts` (period figure); `PoolUSD = Scenario.Pool.RevenueFrac × PayingRevenueUSD + Scenario.Pool.TopupUSD × (PeriodDays/DaysInMonth)` (top-up prorated to the period so mtd reads sensibly); `DrawUSD = Σ CostUSD of try/pool/"" accounts`; `Headroom = Pool − Draw` (may be negative — render honestly).
+- `economicsPayload` additions (frozen keys): `"tiers": map[string]string`, `"pool": {"revenue_frac","topup_usd"}` comes via scenario already; ledger recomputed client-side from tiers+scenario+meters — document that the JS twin computes the ledger the same way (Task 6).
+
+- [ ] **Step 1: Failing tests** — append: `TestLoadEconomicsTierRevenueAndPool`: fixture seeds golden usage (reuse existing helpers) + fleet meters via Task 3's seeding + tiers: fixture acct → `hosted`, a second minted account → `pool` with seeded cost; assert hosted account has revenue > 0, pool account revenue == 0 with cost > 0; assert `view.Pool.PoolUSD == 0.10*PayingRevenue + 200*(PeriodDays/DaysInMonth)` (compute expected from the view's own PayingRevenueUSD then assert Draw/Headroom arithmetic exactly); assert `DataJSON` contains `"tiers"`.
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement** per Interfaces (tier zero-value: absent from map = "" = pool semantics — one helper `tierOf(tiers, acct)`).
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — PASS, pre-existing view tests unmodified (they seed no tiers → every fixture account is pool-tier → their REVENUE assertions... CAUTION: existing tests assert CostUSD>0 and revenue under active scenario. `TestLoadEconomicsGolden` asserts `acctRow.CostUSD` and revenue — if it asserts revenue > 0 it BREAKS under tier-aware pricing. Resolution, decided here: untiered fixture accounts defaulting to pool-tier zero revenue would break honest tests — so the golden test gets its account explicitly tiered `hosted` in seeding (one-line addition to THAT test only, using Task 4's SetAccountTier; this is a sanctioned test modification, note it in the commit body). No production behavior compromise.)
+- [ ] **Step 5: Commit** `git commit -am "console: tier-aware revenue, fleet meters in the view, pool ledger"`
+
+---
+
+### Task 6: Dashboard v2 — pool tile, knobs, tier chips
+
+**Files:**
+- Modify: `internal/console/templates/economics.html` (pool tile + knob rows + JS)
+- Modify: `internal/console/templates/economics_accounts.html` (tier chip column + tier-set form per row for operators)
+- Modify: `internal/console/econlistquery.go` (tier filter? NO — YAGNI; Pos filter exists. Only the chip column renders.)
+- Test: `internal/console/economicspage_test.go` (append)
+
+**Interfaces:**
+- Pool tile on `/fleet/economics`: "POOL $X · draw $Y · headroom $Z" with the coverage label; negative headroom rendered with the page's existing warning styling.
+- Knob drawer additions (server-rendered values + JS recompute): `box_month_server_usd`, `box_month_device_usd`, `mem_gb_hour_usd`, `fleet_break.min_boxes`, `fleet_break.discount`, `pool.revenue_frac`, `pool.topup_usd`.
+- JS twins in `econ_maths_js`: extend `priceAccount` with the Task 2 formulas verbatim (fleet revenue both modes, break, mem overage — same names); new `poolLedger(scenario, tiers, rows)` mirroring Task 5's formula; recompute updates the pool tile; tier read from `payload.tiers` with `"" → pool`.
+- Accounts page: tier chip (`chip chip-tier-<tier>` classes, colors in the template CSS near the freetier chips) + a small per-row operator form POSTing to `/fleet/economics/tier` (account hidden field + tier `<select>` + csrf) — progressive: works without JS.
+
+- [ ] **Step 1: Failing tests** — append: content test asserting the rendered `/fleet/economics` contains `id="tile-pool"` and the pool figures; accounts page contains `chip-tier-hosted` for the tiered fixture account and the tier form posts to `/fleet/economics/tier`; off-origin subresource test stays green (runs automatically).
+- [ ] **Step 2: Run** — FAIL.
+- [ ] **Step 3: Implement**; reuse existing tile/knob/chip markup classes; keep every new JSON key read in JS matching the Go tags exactly (list them in the template header comment).
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — PASS.
+- [ ] **Step 5: Commit** `git commit -am "console: pool tile, v2 knobs and tier chips on the economics pages"`
+
+---
+
+### Task 7: Scenario refresh + spec truth-up
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-08-13-carloku-business-model-design.md` (device-tag ruling: bare `device` tag, not `class=`; pool top-up proration note; mark items 1-4 of "What exists today vs what this needs" as BUILT-BY this plan)
+- No code.
+
+- [ ] **Step 1:** Edit the spec's "Device class is a tag" bullet to: tag grammar has no `=`; device class = literal `device` tag present (registry `ValidBoxTags` grammar), server otherwise. Add one sentence to the pool section: the top-up prorates to the viewed period (`× PeriodDays/DaysInMonth`) so month-to-date ledgers read honestly. Update the follow-ups list.
+- [ ] **Step 2: Commit** `git commit -am "business model: device-tag grammar ruling, pool proration, v2 build status"`
+
+---
+
+### Task 8: Full gate + PR update
+
+- [ ] **Step 1:** Full gate: `go build ./... && go vet ./... && go test ./... -count=1`; `gofmt -l` on all branch-touched `.go` files (expect empty); `GOOS=darwin go build ./...`.
+- [ ] **Step 2:** Push the branch; PR #197 now carries spec + implementation. Comment on the PR summarizing the v2 slice (meters, knobs, ledger, tiers) and the two deliberate scope-outs (durability machinery = own spec; `carlos economics tier/scenario` CLI verbs = gap #182 family).
+- [ ] **Step 3:** After merge+deploy (operator flow, same as unit-economics): update the live `ladder-launch`/`ladder-lean` scenarios with the v2 knob values (box 5/1, break 10/0.4, pool 0.10/200, mem 0) — recorded here as a deploy-time step, not a task.
+
+## Self-review notes (run inline at write time)
+
+- Spec coverage: items 1 (fleet meter + class → T1/T3), 2 (box prices + break → T1/T2/T6), 3 (pool ledger → T5/T6), 4 (tier marker → T4/T5/T6), mem overage (T1/T2/T6), durability = explicitly out (own spec), device-tag grammar fix (T7).
+- Known sanctioned deviations: bare `device` tag (grammar reality); pool top-up prorated by period; `TestLoadEconomicsGolden` gains one tier-seeding line (T5 step 4 records why).
+- Type consistency: meter constant names identical in T1/T2/T3/T6 JS; `PoolLedger` fields T5/T6; door path `/fleet/economics/tier` T4/T6.
+- Open verification points flagged inline: fleet test seeding helper (T3), sqid-lookup store method for the door (T4), sweepUsage-extension vs second-read choice (T3).