docs: economics-v2 implementation plan (fleet meters, knobs, pool ledger, tiers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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). |