carlos / platform Public

docs: unit-economics implementation plan (16 TDD tasks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d28572d40024aa62c0a362a5c4f255ace496e10b parent 9a5db5b
1 file changed, +2335 −0
  • docs/superpowers/plans/2026-08-11-unit-economics.md +2335 −0
diff --git a/docs/superpowers/plans/2026-08-11-unit-economics.md b/docs/superpowers/plans/2026-08-11-unit-economics.md
new file mode 100644
index 000000000..237e72fc3
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-11-unit-economics.md
@@ -0,0 +1,2335 @@
+# Unit Economics 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:** Meter platform usage (edge traffic, storage, census, CPU/mem), price it against a tweakable rate card with saved scenarios, and render operator-only economics dashboards with a daily Cost Explorer bill reconcile.
+
+**Architecture:** Extends the existing metrics pipeline: the edge counts per-route requests/bytes and a host loop CAS-merges them into daily bucket objects; console daily passes write census, storage, per-account statements and the pulled bill; console pages aggregate at render behind a 60s cache with client-side knob recompute. Spec: `docs/superpowers/specs/2026-08-11-unit-economics-design.md`.
+
+**Tech Stack:** Go stdlib + aws-sdk-go-v2 (S3 existing; costexplorer is a NEW module dependency), sqlite console store, Go html/template with one inline `<script>` per page (no external subresources — enforced by `TestConsoleTemplatesFetchNothingOffOrigin`).
+
+## Global Constraints
+
+- Gate before any commit: `go build ./... && go vet ./... && go test ./... -count=1`, `gofmt -l` clean on touched files, and `GOOS=darwin go build ./...` compiles.
+- Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod`; `GOPROXY=off` EXCEPT the one step that runs `go get` (needs network); go commands may need sandbox disabled.
+- All bucket shapes are ADDITIVE. New object families only: `usage/*`, `economics/*`. Never change an existing shape; the only parser of the new shapes is the console (plus the box-side usage writer for `usage/edge/*`).
+- Operator gating is byte-identical 404: `s.session(r)` → `s.store.IsOperator(addr)` → `http.NotFound(w, r)` on every non-operator path. Never `renderMessage` for the gate.
+- Templates: no `<script src`, no `<link`, no external URLs (icons_test.go enforces). All JS inline.
+- Conditional PUT: `release.Store.Put(ctx, key, body, ifMatch)` — `ifMatch==""` is create-only (If-None-Match: *), non-empty is If-Match CAS; lost race returns `release.ErrCASConflict`.
+- Meter vocabulary is FROZEN once merged (billing readiness): `cpu_core_hours`, `mem_gb_hours`, `egress_gb`, `requests_1k`, `storage_gb_month`, `app_months`, `instance_hours`.
+- Money is `float64` USD everywhere (modelling instrument, decision-grade — spec).
+- One deliberate simplification vs the spec's collection sketch: edge usage skips separate hourly chunk objects. The flush CAS-merges deltas straight into `usage/edge/<box>/days/<date>.json` (an `hours` map inside, mirroring `metrics.DayRollup`), giving the same ≤5-min durability with one object family and no pruning pass. A CAS retry after a lost-response Put can double-count one 5-min window — acceptable at modelling grade, noted in the package doc.
+
+---
+
+### Task 1: `internal/usage` — meter vocabulary + edge day objects
+
+**Files:**
+- Create: `internal/usage/usage.go`
+- Create: `internal/usage/edge.go`
+- Test: `internal/usage/edge_test.go`
+
+**Interfaces:**
+- Consumes: `release.Store` (`internal/release/store.go:37-55`), `release.ErrCASConflict`.
+- Produces: meter constants; `DayOf`, `MonthOf`, `HourLabel`; `EdgeRow`, `EdgeDay`, `EdgeDayKey(box, date)`, `(*EdgeDay).AddDeltas(hour, deltas)`, `UpsertEdgeDay(ctx, s, box, now, deltas)`. Tasks 5, 6, 10, 11 consume these exact names.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/usage/edge_test.go`:
+
+```go
+package usage
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestEdgeDayKeyAndLabels(t *testing.T) {
+ if got := EdgeDayKey("box1", "2026-08-11"); got != "usage/edge/box1/days/2026-08-11.json" {
+ t.Fatalf("EdgeDayKey = %q", got)
+ }
+ at := time.Date(2026, 8, 11, 9, 4, 0, 0, time.UTC)
+ if got := DayOf(at); got != "2026-08-11" {
+ t.Fatalf("DayOf = %q", got)
+ }
+ if got := MonthOf(at); got != "2026-08" {
+ t.Fatalf("MonthOf = %q", got)
+ }
+ if got := HourLabel(at); got != "09" {
+ t.Fatalf("HourLabel = %q", got)
+ }
+}
+
+func TestUpsertEdgeDayMergesAcrossFlushes(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ at := time.Date(2026, 8, 11, 9, 10, 0, 0, time.UTC)
+
+ d1 := map[string]EdgeRow{
+ "a.example.com": {Account: "bab", App: "hello", Requests: 10, BytesOut: 1000},
+ }
+ if err := UpsertEdgeDay(ctx, s, "box1", at, d1); err != nil {
+ t.Fatal(err)
+ }
+ // Second flush, same hour: counts must ADD, not replace.
+ if err := UpsertEdgeDay(ctx, s, "box1", at.Add(5*time.Minute), d1); err != nil {
+ t.Fatal(err)
+ }
+ // Third flush, next hour and a second host.
+ d2 := map[string]EdgeRow{
+ "a.example.com": {Account: "bab", App: "hello", Requests: 1, BytesOut: 50},
+ "b.example.com": {Account: "ops", App: "site", Requests: 2, BytesOut: 20},
+ }
+ if err := UpsertEdgeDay(ctx, s, "box1", at.Add(time.Hour), d2); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, _, err := s.Get(ctx, EdgeDayKey("box1", "2026-08-11"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var day EdgeDay
+ if err := json.Unmarshal(raw, &day); err != nil {
+ t.Fatal(err)
+ }
+ if day.V != 1 || day.Box != "box1" || day.Date != "2026-08-11" {
+ t.Fatalf("header = %+v", day)
+ }
+ h9 := day.Hours["09"]["a.example.com"]
+ if h9.Requests != 20 || h9.BytesOut != 2000 || h9.Account != "bab" || h9.App != "hello" {
+ t.Fatalf("hour 09 row = %+v", h9)
+ }
+ if day.Hours["10"]["b.example.com"].Requests != 2 {
+ t.Fatalf("hour 10 = %+v", day.Hours["10"])
+ }
+}
+
+func TestUpsertEdgeDayEmptyDeltasIsNoop(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ if err := UpsertEdgeDay(context.Background(), s, "box1", time.Now().UTC(), nil); err != nil {
+ t.Fatal(err)
+ }
+ if keys, _ := s.List(context.Background(), "usage/"); len(keys) != 0 {
+ t.Fatalf("noop wrote %v", keys)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/usage/ -count=1`
+Expected: FAIL — package does not exist / undefined symbols.
+
+- [ ] **Step 3: Write the implementation**
+
+`internal/usage/usage.go`:
+
+```go
+// Package usage holds the platform's metering shapes: what a box or console
+// pass writes under usage/ in the deployment bucket, and the canonical meter
+// vocabulary every consumer — statements, scenarios, dashboards, a future
+// billing engine — speaks. Meter names are FROZEN once merged: renaming one
+// orphans every statement already written (spec: billing readiness).
+//
+// Everything here is decision-grade, not invoice-grade (spec: modelling
+// instrument): CAS-merge retries can in rare cases double-count one 5-minute
+// window, and days a writer was down are simply absent. Consumers must treat
+// missing days as reduced coverage, never as zero usage.
+package usage
+
+import "time"
+
+// Meter identifiers — the canonical billing vocabulary.
+const (
+ MeterCPUCoreHours = "cpu_core_hours"
+ MeterMemGBHours = "mem_gb_hours"
+ MeterEgressGB = "egress_gb"
+ MeterRequests1k = "requests_1k"
+ MeterStorageGBMonth = "storage_gb_month"
+ MeterAppMonths = "app_months"
+ MeterInstanceHours = "instance_hours"
+)
+
+// DayOf is the UTC date label used in every usage key.
+func DayOf(t time.Time) string { return t.UTC().Format("2006-01-02") }
+
+// MonthOf is the UTC month label used in statement and bill keys.
+func MonthOf(t time.Time) string { return t.UTC().Format("2006-01") }
+
+// HourLabel is the two-digit UTC hour used inside day objects.
+func HourLabel(t time.Time) string { return t.UTC().Format("15") }
+```
+
+`internal/usage/edge.go`:
+
+```go
+package usage
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// EdgeRow is one route's traffic within one hour on one box.
+type EdgeRow struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Requests int64 `json:"requests"`
+ BytesOut int64 `json:"bytes_out"`
+}
+
+// EdgeDay is usage/edge/<box>/days/<date>.json — a box's edge traffic for
+// one UTC day, hour-bucketed like metrics.DayRollup. Unlike metrics there is
+// no separate raw-chunk family: the 5-minute flush CAS-merges deltas
+// straight into this object, which is both the durability write and the
+// rollup.
+type EdgeDay struct {
+ V int `json:"v"` // 1
+ Box string `json:"box"`
+ Date string `json:"date"`
+ Hours map[string]map[string]EdgeRow `json:"hours"` // "09" -> host -> row
+}
+
+// EdgeDayKey is the bucket key for a box's day object.
+func EdgeDayKey(box, date string) string {
+ return "usage/edge/" + box + "/days/" + date + ".json"
+}
+
+// EdgeDaysPrefix is the List prefix for a box's day objects.
+func EdgeDaysPrefix(box string) string { return "usage/edge/" + box + "/days/" }
+
+// EdgeBoxesPrefix is the ListDirs prefix that discovers metering boxes.
+const EdgeBoxesPrefix = "usage/edge/"
+
+// AddDeltas folds drained per-host deltas into the day's hour bucket,
+// summing counters and refreshing the route identity (a host re-pointed to
+// a new app mid-hour lands on the later identity — modelling grade).
+func (d *EdgeDay) AddDeltas(hour string, deltas map[string]EdgeRow) {
+ if d.Hours == nil {
+ d.Hours = map[string]map[string]EdgeRow{}
+ }
+ rows := d.Hours[hour]
+ if rows == nil {
+ rows = map[string]EdgeRow{}
+ d.Hours[hour] = rows
+ }
+ for host, delta := range deltas {
+ row := rows[host]
+ row.Account, row.App = delta.Account, delta.App
+ row.Requests += delta.Requests
+ row.BytesOut += delta.BytesOut
+ rows[host] = row
+ }
+}
+
+// UpsertEdgeDay CAS-merges deltas into the box's day object for now's UTC
+// day and hour: read (etag), add, conditional put; on ErrCASConflict re-read
+// and retry, three attempts. Empty deltas write nothing.
+func UpsertEdgeDay(ctx context.Context, s release.Store, box string, now time.Time, deltas map[string]EdgeRow) error {
+ if len(deltas) == 0 {
+ return nil
+ }
+ date, hour := DayOf(now), HourLabel(now)
+ key := EdgeDayKey(box, date)
+ for attempt := 0; attempt < 3; attempt++ {
+ day := EdgeDay{V: 1, Box: box, Date: date}
+ raw, etag, err := s.Get(ctx, key)
+ switch {
+ case err == nil:
+ if uerr := json.Unmarshal(raw, &day); uerr != nil {
+ return fmt.Errorf("usage: parse %s: %w", key, uerr)
+ }
+ case errors.Is(err, release.ErrNotFound):
+ etag = "" // create-only Put
+ default:
+ return err
+ }
+ day.AddDeltas(hour, deltas)
+ out, err := json.Marshal(day)
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, out, etag); err == nil {
+ return nil
+ } else if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ }
+ return fmt.Errorf("usage: %s: lost CAS race three times", key)
+}
+```
+
+NOTE: check the exact not-found sentinel before building — `internal/release/store.go` defines how `Get` reports a missing key (grep `ErrNotFound` in `internal/release`; if `Get` returns a wrapped S3 NoSuchKey instead, mirror how `metrics` package's `currentRawEtag` (`internal/metrics/chunk.go:78`) distinguishes not-found, and use the same check here).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/usage/ -count=1`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/usage/
+git commit -m "usage: meter vocabulary and edge day objects"
+```
+
+---
+
+### Task 2: `internal/usage` — census, storage, statements
+
+**Files:**
+- Create: `internal/usage/census.go`
+- Create: `internal/usage/storage.go`
+- Create: `internal/usage/statement.go`
+- Test: `internal/usage/passes_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `DayOf`/`MonthOf`, `release.Store`.
+- Produces: `CensusAccount{Apps, Routes int}`, `CensusDay{V, Date, Accounts map[string]CensusAccount}`, `CensusDayKey(date)`; `AppStorage{BucketBytes, StoreBytes int64, StoreUnswept bool}`, `StorageDay{V, Date, Apps map[string]AppStorage}` (key `"<acct>/<app>"`), `StorageDayKey(date)`; `Statement{V, Account, Month, Meters map[string]float64, CoveredDays, DaysInMonth int, UpdatedAt time.Time}`, `StatementKey(acct, month)`, `StatementsPrefix(acct)`, `UpsertStatement(ctx, s, st)` (CAS replace, 3 attempts, same loop shape as `UpsertEdgeDay`), `CreateDayObject(ctx, s, key, v any) (created bool, err error)` — create-only helper returning `(false, nil)` on `ErrCASConflict` (someone else wrote today).
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/usage/passes_test.go`:
+
+```go
+package usage
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestKeys(t *testing.T) {
+ if got := CensusDayKey("2026-08-11"); got != "usage/census/2026-08-11.json" {
+ t.Fatalf("census key = %q", got)
+ }
+ if got := StorageDayKey("2026-08-11"); got != "usage/storage/2026-08-11.json" {
+ t.Fatalf("storage key = %q", got)
+ }
+ if got := StatementKey("bab", "2026-08"); got != "usage/accounts/bab/2026-08.json" {
+ t.Fatalf("statement key = %q", got)
+ }
+}
+
+func TestCreateDayObjectIsCreateOnly(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ day := CensusDay{V: 1, Date: "2026-08-11", Accounts: map[string]CensusAccount{"bab": {Apps: 2, Routes: 3}}}
+ created, err := CreateDayObject(ctx, s, CensusDayKey(day.Date), day)
+ if err != nil || !created {
+ t.Fatalf("first create = %v %v", created, err)
+ }
+ // Second console racing the same day: no error, no overwrite.
+ loser := CensusDay{V: 1, Date: "2026-08-11", Accounts: map[string]CensusAccount{"bab": {Apps: 99}}}
+ created, err = CreateDayObject(ctx, s, CensusDayKey(day.Date), loser)
+ if err != nil || created {
+ t.Fatalf("second create = %v %v", created, err)
+ }
+ raw, _, _ := s.Get(ctx, CensusDayKey(day.Date))
+ var got CensusDay
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Accounts["bab"].Apps != 2 {
+ t.Fatalf("winner overwritten: %+v", got)
+ }
+}
+
+func TestUpsertStatementReplaces(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ st := Statement{V: 1, Account: "bab", Month: "2026-08",
+ Meters: map[string]float64{MeterEgressGB: 1.5}, CoveredDays: 10, DaysInMonth: 31,
+ UpdatedAt: time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC)}
+ if err := UpsertStatement(ctx, s, st); err != nil {
+ t.Fatal(err)
+ }
+ st.Meters[MeterEgressGB] = 2.5
+ st.CoveredDays = 11
+ if err := UpsertStatement(ctx, s, st); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, _ := s.Get(ctx, StatementKey("bab", "2026-08"))
+ var got Statement
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Meters[MeterEgressGB] != 2.5 || got.CoveredDays != 11 {
+ t.Fatalf("statement = %+v", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/usage/ -count=1`
+Expected: FAIL — undefined: CensusDay etc.
+
+- [ ] **Step 3: Write the implementation**
+
+`internal/usage/census.go`:
+
+```go
+package usage
+
+// CensusAccount is one account's existence-count on one day.
+type CensusAccount struct {
+ Apps int `json:"apps"`
+ Routes int `json:"routes"`
+}
+
+// CensusDay is usage/census/<date>.json — the whole deployment's per-account
+// counts, snapshotted once per UTC day by the console's economics pass so
+// existence-metering has history even though the registry holds only live
+// state.
+type CensusDay struct {
+ V int `json:"v"` // 1
+ Date string `json:"date"`
+ Accounts map[string]CensusAccount `json:"accounts"` // account name ("" normalized to "ops")
+}
+
+// CensusDayKey is the bucket key for a day's census.
+func CensusDayKey(date string) string { return "usage/census/" + date + ".json" }
+
+// CensusPrefix is the List prefix for census days.
+const CensusPrefix = "usage/census/"
+```
+
+`internal/usage/storage.go`:
+
+```go
+package usage
+
+// AppStorage is one app's occupancy on one day: deployment-bucket bytes
+// under the app's prefix, plus its tenant object-store bucket when one is
+// provisioned. StoreUnswept marks a store the pass could not size (bad
+// credentials, endpoint down) — the dashboard must show "unswept", never 0.
+type AppStorage struct {
+ BucketBytes int64 `json:"bucket_bytes"`
+ StoreBytes int64 `json:"store_bytes,omitempty"`
+ StoreUnswept bool `json:"store_unswept,omitempty"`
+}
+
+// StorageDay is usage/storage/<date>.json, keyed "<acct>/<app>".
+type StorageDay struct {
+ V int `json:"v"` // 1
+ Date string `json:"date"`
+ Apps map[string]AppStorage `json:"apps"`
+}
+
+// StorageDayKey is the bucket key for a day's storage sweep.
+func StorageDayKey(date string) string { return "usage/storage/" + date + ".json" }
+
+// StoragePrefix is the List prefix for storage days.
+const StoragePrefix = "usage/storage/"
+```
+
+`internal/usage/statement.go`:
+
+```go
+package usage
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Statement is usage/accounts/<acct>/<month>.json — one account's
+// month-to-date totals per meter, the durable input a future billing engine
+// consumes (statement × price sheet = invoice; spec: billing readiness).
+// Meters is keyed by the Meter* vocabulary. CoveredDays/DaysInMonth carry
+// the honesty label every rendered total must show.
+type Statement struct {
+ V int `json:"v"` // 1
+ Account string `json:"account"`
+ Month string `json:"month"` // "2006-01"
+ Meters map[string]float64 `json:"meters"`
+ CoveredDays int `json:"covered_days"`
+ DaysInMonth int `json:"days_in_month"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// StatementKey is the bucket key for an account's month statement.
+func StatementKey(acct, month string) string {
+ return "usage/accounts/" + acct + "/" + month + ".json"
+}
+
+// StatementsPrefix lists an account's statements.
+func StatementsPrefix(acct string) string { return "usage/accounts/" + acct + "/" }
+
+// AccountsPrefix discovers accounts that have statements.
+const AccountsPrefix = "usage/accounts/"
+
+// UpsertStatement CAS-replaces the account's month statement (3 attempts).
+func UpsertStatement(ctx context.Context, s release.Store, st Statement) error {
+ key := StatementKey(st.Account, st.Month)
+ body, err := json.Marshal(st)
+ if err != nil {
+ return err
+ }
+ for attempt := 0; attempt < 3; attempt++ {
+ _, etag, err := s.Get(ctx, key)
+ if err != nil {
+ if !errors.Is(err, release.ErrNotFound) {
+ return err
+ }
+ etag = ""
+ }
+ if _, err := s.Put(ctx, key, body, etag); err == nil {
+ return nil
+ } else if !errors.Is(err, release.ErrCASConflict) {
+ return err
+ }
+ }
+ return fmt.Errorf("usage: %s: lost CAS race three times", key)
+}
+
+// CreateDayObject writes key create-only. (false, nil) means another writer
+// won today — the daily-pass idempotence rule (spec: error handling).
+func CreateDayObject(ctx context.Context, s release.Store, key string, v any) (bool, error) {
+ body, err := json.Marshal(v)
+ if err != nil {
+ return false, err
+ }
+ if _, err := s.Put(ctx, key, body, ""); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
+```
+
+Same NOTE as Task 1 on the `release.ErrNotFound` sentinel — use whatever `internal/release` actually exports for "no such key", consistently in both files.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/usage/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/usage/
+git commit -m "usage: census, storage and statement shapes"
+```
+
+---
+
+### Task 3: `internal/economics` — scenarios, rate card, bills
+
+**Files:**
+- Create: `internal/economics/scenario.go`
+- Create: `internal/economics/bill.go`
+- Test: `internal/economics/scenario_test.go`
+
+**Interfaces:**
+- Consumes: nothing platform-side (pure shapes) except `release.Store` for key helpers.
+- Produces (Tasks 4, 11, 12, 13, 14 consume): `LineItem`, `BoxSpec`, `RateCard`, `Allocation`, `RateSheet`, `FreeTier`, `Scenario` with `DefaultScenario()` and `(*Scenario).Validate() error`; `ScenarioKey(name)`, `ScenariosPrefix`, `ActiveKey`, `Active{Schema, Name}`; `Bill{Schema, Month, TotalUSD, Services map[string]float64, Source, PulledAt}`, `BillKey(month)`, `BillsPrefix`, `SourceManual`/`SourceCostExplorer` consts, `ValidScenarioName(string) bool`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/economics/scenario_test.go`:
+
+```go
+package economics
+
+import "testing"
+
+func TestKeys(t *testing.T) {
+ if got := ScenarioKey("launch-pricing"); got != "economics/scenarios/launch-pricing.json" {
+ t.Fatalf("ScenarioKey = %q", got)
+ }
+ if got := BillKey("2026-08"); got != "economics/bills/2026-08.json" {
+ t.Fatalf("BillKey = %q", got)
+ }
+ if ActiveKey != "economics/active.json" {
+ t.Fatalf("ActiveKey = %q", ActiveKey)
+ }
+}
+
+func TestDefaultScenarioValidates(t *testing.T) {
+ sc := DefaultScenario()
+ if err := sc.Validate(); err != nil {
+ t.Fatalf("default scenario invalid: %v", err)
+ }
+ if sc.Mode != "cost-plus" || sc.Margin != 0.8 {
+ t.Fatalf("default = mode %q margin %v", sc.Mode, sc.Margin)
+ }
+ if _, ok := sc.RateCard.Boxes[""]; !ok {
+ t.Fatal("default rate card must carry a \"\" default box spec")
+ }
+}
+
+func TestValidateRejects(t *testing.T) {
+ cases := []func(*Scenario){
+ func(s *Scenario) { s.Margin = 1.0 }, // price would be infinite
+ func(s *Scenario) { s.Margin = -0.1 },
+ func(s *Scenario) { s.Mode = "surge" },
+ func(s *Scenario) { s.Allocation.Policy = "vibes" },
+ func(s *Scenario) { s.Allocation.Policy = "blend"; s.Allocation.CPUWeight = 1.5 },
+ func(s *Scenario) { s.Name = "Bad Name!" },
+ func(s *Scenario) { s.RateCard.Boxes = nil },
+ }
+ for i, mutate := range cases {
+ sc := DefaultScenario()
+ sc.Name = "test"
+ mutate(&sc)
+ if err := sc.Validate(); err == nil {
+ t.Fatalf("case %d: mutation validated", i)
+ }
+ }
+}
+
+func TestValidScenarioName(t *testing.T) {
+ for name, want := range map[string]bool{
+ "launch-pricing": true, "v2": true, "": false, "UP": false,
+ "has space": false, "-lead": false, "trail-": false,
+ "waaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaay-too-long": false,
+ } {
+ if got := ValidScenarioName(name); got != want {
+ t.Fatalf("ValidScenarioName(%q) = %v", name, got)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/economics/ -count=1`
+Expected: FAIL — package does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+`internal/economics/scenario.go`:
+
+```go
+// Package economics prices metered usage: rate cards (what things cost us),
+// scenarios (tweakable pricing/margin/free-tier knob-sets), and the maths
+// that turns an account's statement into cost, would-be revenue and margin.
+// Pure computation + bucket shapes; collection lives in internal/usage.
+package economics
+
+import (
+ "fmt"
+ "regexp"
+)
+
+// LineItem is one fixed monthly overhead (domains, DNSimple, ...).
+type LineItem struct {
+ Name string `json:"name"`
+ USDMonth float64 `json:"usd_month"`
+}
+
+// BoxSpec is one box's cost and capacity in the rate card. The "" key is
+// the default spec a box without its own entry falls back to.
+type BoxSpec struct {
+ HourUSD float64 `json:"hour_usd"`
+ Cores float64 `json:"cores"`
+ MemGB float64 `json:"mem_gb"`
+ EBSGB float64 `json:"ebs_gb"`
+}
+
+// RateCard is what the platform pays, per unit.
+type RateCard struct {
+ Boxes map[string]BoxSpec `json:"boxes"` // box label -> spec; "" = default
+ EBSGBMonthUSD float64 `json:"ebs_gb_month_usd"`
+ StorageGBMonthUSD float64 `json:"storage_gb_month_usd"`
+ RequestsPer1kUSD float64 `json:"requests_per_1k_usd"`
+ EgressGBUSD float64 `json:"egress_gb_usd"`
+ Overheads []LineItem `json:"overheads"`
+}
+
+// Allocation says how a box's shared cost is split across its tenants.
+type Allocation struct {
+ Policy string `json:"policy"` // "cpu" | "mem" | "blend"
+ CPUWeight float64 `json:"cpu_weight"` // blend only, 0..1 (weight of CPU share)
+}
+
+// RateSheet is explicit customer pricing (mode "rate-sheet") — and the
+// shape a promoted scenario publishes when billing arrives.
+type RateSheet struct {
+ AppMonthUSD float64 `json:"app_month_usd"`
+ EgressGBUSD float64 `json:"egress_gb_usd"`
+ StorageGBMonthUSD float64 `json:"storage_gb_month_usd"`
+ InstanceHourUSD float64 `json:"instance_hour_usd"`
+}
+
+// FreeTier is per-account monthly allowances, subtracted from meters before
+// revenue is computed (spec: a subtraction, not a flag).
+type FreeTier struct {
+ Apps float64 `json:"apps"`
+ EgressGB float64 `json:"egress_gb"`
+ MemGBHours float64 `json:"mem_gb_hours"`
+ StorageGB float64 `json:"storage_gb"`
+}
+
+// Scenario is one complete knob-set, stored at ScenarioKey(Name).
+type Scenario struct {
+ Schema int `json:"schema"` // 1
+ Name string `json:"name"`
+ RateCard RateCard `json:"rate_card"`
+ Allocation Allocation `json:"allocation"`
+ Mode string `json:"mode"` // "cost-plus" | "rate-sheet"
+ Margin float64 `json:"margin"` // cost-plus: (price-cost)/price, so 0.8 = 5x cost
+ Sheet RateSheet `json:"sheet"`
+ FreeTier FreeTier `json:"free_tier"`
+}
+
+// Active is economics/active.json: which scenario the dashboards load.
+type Active struct {
+ Schema int `json:"schema"` // 1
+ Name string `json:"name"`
+}
+
+const (
+ ScenariosPrefix = "economics/scenarios/"
+ ActiveKey = "economics/active.json"
+)
+
+// ScenarioKey is the bucket key for a named scenario.
+func ScenarioKey(name string) string { return ScenariosPrefix + name + ".json" }
+
+var scenarioNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+
+// ValidScenarioName bounds names to the account-name shape: 1-63 chars of
+// a-z 0-9 hyphen, no leading/trailing hyphen — safe in a bucket key and a URL.
+func ValidScenarioName(name string) bool { return scenarioNameRe.MatchString(name) }
+
+// DefaultScenario is the built-in fallback: seeded from real 2026-08 AWS
+// pricing for the flagship/tito boxes (t4g.small on-demand eu-west-1 ≈
+// $0.0184/h, 2 vCPU, 2 GB; gp3 $0.088/GB-month eu-west-1; S3 standard
+// $0.023/GB-month, PUT-heavy blended requests ≈ $0.005/1k; egress
+// $0.09/GB), an 80% target margin, and a free tier of one small always-on
+// app. Every number is a starting point for the knobs, not a commitment.
+func DefaultScenario() Scenario {
+ return Scenario{
+ Schema: 1,
+ Name: "default",
+ RateCard: RateCard{
+ Boxes: map[string]BoxSpec{
+ "": {HourUSD: 0.0184, Cores: 2, MemGB: 2, EBSGB: 30},
+ },
+ EBSGBMonthUSD: 0.088,
+ StorageGBMonthUSD: 0.023,
+ RequestsPer1kUSD: 0.005,
+ EgressGBUSD: 0.09,
+ Overheads: []LineItem{{Name: "domains + DNS", USDMonth: 5}},
+ },
+ Allocation: Allocation{Policy: "blend", CPUWeight: 0.5},
+ Mode: "cost-plus",
+ Margin: 0.8,
+ Sheet: RateSheet{
+ AppMonthUSD: 5,
+ EgressGBUSD: 0.15,
+ StorageGBMonthUSD: 0.05,
+ InstanceHourUSD: 0.01,
+ },
+ FreeTier: FreeTier{Apps: 1, EgressGB: 10, MemGBHours: 400, StorageGB: 1},
+ }
+}
+
+// Validate rejects a scenario the maths cannot price.
+func (sc *Scenario) Validate() error {
+ if !ValidScenarioName(sc.Name) {
+ return fmt.Errorf("economics: scenario name %q: 1-63 chars of a-z 0-9 hyphen", sc.Name)
+ }
+ if sc.Mode != "cost-plus" && sc.Mode != "rate-sheet" {
+ return fmt.Errorf("economics: mode %q: want cost-plus or rate-sheet", sc.Mode)
+ }
+ if sc.Margin < 0 || sc.Margin >= 1 {
+ return fmt.Errorf("economics: margin %v: want [0,1) — margin is (price-cost)/price", sc.Margin)
+ }
+ switch sc.Allocation.Policy {
+ case "cpu", "mem":
+ case "blend":
+ if sc.Allocation.CPUWeight < 0 || sc.Allocation.CPUWeight > 1 {
+ return fmt.Errorf("economics: blend cpu_weight %v: want [0,1]", sc.Allocation.CPUWeight)
+ }
+ default:
+ return fmt.Errorf("economics: allocation policy %q: want cpu, mem or blend", sc.Allocation.Policy)
+ }
+ if _, ok := sc.RateCard.Boxes[""]; !ok {
+ return fmt.Errorf("economics: rate card needs a %q default box spec", "")
+ }
+ return nil
+}
+```
+
+`internal/economics/bill.go`:
+
+```go
+package economics
+
+import "time"
+
+// Bill sources.
+const (
+ SourceManual = "manual"
+ SourceCostExplorer = "costexplorer"
+)
+
+// Bill is economics/bills/<month>.json — the real AWS bill for one month,
+// whole-account by-service (attribution to CARLOS is a dashboard knob, not
+// a pull-time filter — both AWS accounts hold non-CARLOS resources).
+type Bill struct {
+ Schema int `json:"schema"` // 1
+ Month string `json:"month"` // "2006-01"
+ TotalUSD float64 `json:"total_usd"`
+ Services map[string]float64 `json:"services"`
+ Source string `json:"source"` // SourceManual | SourceCostExplorer
+ PulledAt time.Time `json:"pulled_at,omitempty"`
+}
+
+// BillsPrefix lists stored bills.
+const BillsPrefix = "economics/bills/"
+
+// BillKey is the bucket key for a month's bill.
+func BillKey(month string) string { return BillsPrefix + month + ".json" }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/economics/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/economics/
+git commit -m "economics: scenario, rate card and bill shapes"
+```
+
+---
+
+### Task 4: `internal/economics` — the maths (golden numbers)
+
+**Files:**
+- Create: `internal/economics/compute.go`
+- Test: `internal/economics/compute_test.go`
+
+**Interfaces:**
+- Consumes: Task 3's types; Task 2's meter vocabulary (`usage.Meter*`).
+- Produces (Tasks 11, 13, 14 consume — and the pages' inline JS mirrors these formulas EXACTLY, they are duplicated by design and cross-checked in Task 13):
+ - `type BoxAccountLoad struct{ CPUCoreHours, MemGBHours float64 }`
+ - `type BoxLoad struct{ Box string; Hours float64; ByAccount map[string]BoxAccountLoad }`
+ - `func AllocateBox(rc RateCard, al Allocation, b BoxLoad) (perAccount map[string]float64, idleUSD, totalUSD float64)`
+ - `type AccountEconomics struct{ Account string; CostUSD, RevenueUSD, MarginFrac float64; FreeTierPos string }`
+ - `func PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics`
+ - `func Project(mtd float64, coveredDays, daysInMonth int) float64`
+ - `const HoursPerMonth = 730.0`
+
+**The formulas (canonical — Go and page JS must both implement these):**
+
+- Box period cost: `total = spec.HourUSD*Hours + spec.EBSGB*rc.EBSGBMonthUSD*(Hours/730)`.
+- Account share of a box: `cpuShare = CPUCoreHours/(spec.Cores*Hours)`, `memShare = MemGBHours/(spec.MemGB*Hours)`; policy `cpu` → cpuShare, `mem` → memShare, `blend` → `w*cpuShare + (1-w)*memShare`. `perAccount = share*total`; `idle = total - Σ perAccount` (clamped ≥ 0). Idle is NEVER redistributed.
+- Direct cost: `egress_gb*rc.EgressGBUSD + requests_1k*rc.RequestsPer1kUSD + storage_gb_month*rc.StorageGBMonthUSD`.
+- Free tier: `billable(m) = max(0, meters[m] - allowance(m))` for `app_months`↔Apps, `egress_gb`↔EgressGB, `mem_gb_hours`↔MemGBHours, `storage_gb_month`↔StorageGB; meters without an allowance are fully billable. Billable compute fraction: `computeFrac = billable(mem_gb_hours)/meters[mem_gb_hours]` (1 if the meter is 0).
+- Cost-plus revenue: `billableCost = allocatedUSD*computeFrac + billable(egress_gb)*rc.EgressGBUSD + requests_1k*rc.RequestsPer1kUSD + billable(storage_gb_month)*rc.StorageGBMonthUSD`; `revenue = billableCost/(1-margin)`.
+- Rate-sheet revenue: `billable(app_months)*AppMonthUSD + billable(egress_gb)*EgressGBUSD + billable(storage_gb_month)*StorageGBMonthUSD + instance_hours*InstanceHourUSD` (instance-hours have no free-tier axis in v1 — the Apps allowance is the seat axis).
+- `MarginFrac = (revenue-cost)/revenue`, 0 when revenue is 0.
+- `FreeTierPos`: revenue 0 and any meter > 0 → `"inside"`; revenue > 0 and any allowance clipped a meter → `"partial"`; otherwise `"paying"`.
+- `Project(mtd, covered, days) = mtd * days/covered` (0 covered → 0).
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/economics/compute_test.go`:
+
+```go
+package economics
+
+import (
+ "math"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+func approx(t *testing.T, name string, got, want float64) {
+ t.Helper()
+ if math.Abs(got-want) > 1e-9 {
+ t.Fatalf("%s = %v, want %v", name, got, want)
+ }
+}
+
+// A 2-core/2GB box at $0.10/h with no EBS, 100h period, one tenant using
+// 50 core-hours (25%% of 200 available) and 100 GB-hours (50%% of 200).
+func testBox() (RateCard, BoxLoad) {
+ rc := RateCard{Boxes: map[string]BoxSpec{"": {HourUSD: 0.10, Cores: 2, MemGB: 2}}}
+ b := BoxLoad{Box: "box1", Hours: 100, ByAccount: map[string]BoxAccountLoad{
+ "bab": {CPUCoreHours: 50, MemGBHours: 100},
+ }}
+ return rc, b
+}
+
+func TestAllocateBoxPolicies(t *testing.T) {
+ rc, b := testBox()
+ per, idle, total := AllocateBox(rc, Allocation{Policy: "cpu"}, b)
+ approx(t, "total", total, 10.0)
+ approx(t, "cpu share", per["bab"], 2.5) // 25% of $10
+ approx(t, "cpu idle", idle, 7.5)
+
+ per, idle, _ = AllocateBox(rc, Allocation{Policy: "mem"}, b)
+ approx(t, "mem share", per["bab"], 5.0) // 50% of $10
+ approx(t, "mem idle", idle, 5.0)
+
+ per, idle, _ = AllocateBox(rc, Allocation{Policy: "blend", CPUWeight: 0.5}, b)
+ approx(t, "blend share", per["bab"], 3.75) // (0.5*25% + 0.5*50%) of $10
+ approx(t, "blend idle", idle, 6.25)
+}
+
+func TestAllocateBoxEBSAndFallbackSpec(t *testing.T) {
+ rc := RateCard{
+ Boxes: map[string]BoxSpec{"": {HourUSD: 0.10, Cores: 2, MemGB: 2, EBSGB: 73}},
+ EBSGBMonthUSD: 0.10,
+ }
+ b := BoxLoad{Box: "unlisted-box", Hours: 730, ByAccount: nil}
+ _, idle, total := AllocateBox(rc, Allocation{Policy: "cpu"}, b)
+ // 730h of $0.10 = $73, plus 73GB*$0.10/GB-month * (730/730) = $7.30.
+ approx(t, "total", total, 80.3)
+ approx(t, "all idle", idle, 80.3)
+}
+
+func TestPriceAccountCostPlusEightyMarginIsFiveX(t *testing.T) {
+ sc := DefaultScenario()
+ sc.RateCard.EgressGBUSD = 0.10
+ sc.RateCard.RequestsPer1kUSD = 0
+ sc.RateCard.StorageGBMonthUSD = 0
+ sc.FreeTier = FreeTier{} // no free tier: pure margin check
+ meters := map[string]float64{usage.MeterEgressGB: 10} // $1 of egress cost
+ ae := PriceAccount(sc, "bab", meters, 0)
+ approx(t, "cost", ae.CostUSD, 1.0)
+ approx(t, "revenue", ae.RevenueUSD, 5.0) // 80% margin = 5x cost
+ approx(t, "margin", ae.MarginFrac, 0.8)
+ if ae.FreeTierPos != "paying" {
+ t.Fatalf("pos = %q", ae.FreeTierPos)
+ }
+}
+
+func TestPriceAccountFreeTierSubtraction(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "rate-sheet"
+ sc.Sheet = RateSheet{AppMonthUSD: 5, EgressGBUSD: 0.15}
+ sc.FreeTier = FreeTier{Apps: 1, EgressGB: 10}
+ meters := map[string]float64{usage.MeterAppMonths: 2, usage.MeterEgressGB: 14}
+ ae := PriceAccount(sc, "bab", meters, 0)
+ // Billable: 1 app-month, 4 GB egress = $5 + $0.60.
+ approx(t, "revenue", ae.RevenueUSD, 5.6)
+ if ae.FreeTierPos != "partial" {
+ t.Fatalf("pos = %q", ae.FreeTierPos)
+ }
+
+ inside := PriceAccount(sc, "tiny", map[string]float64{usage.MeterAppMonths: 1, usage.MeterEgressGB: 2}, 0)
+ approx(t, "inside revenue", inside.RevenueUSD, 0)
+ if inside.FreeTierPos != "inside" {
+ t.Fatalf("inside pos = %q", inside.FreeTierPos)
+ }
+}
+
+func TestPriceAccountComputeFraction(t *testing.T) {
+ sc := DefaultScenario()
+ sc.Mode = "cost-plus"
+ sc.Margin = 0.5
+ sc.RateCard.EgressGBUSD, sc.RateCard.RequestsPer1kUSD, sc.RateCard.StorageGBMonthUSD = 0, 0, 0
+ sc.FreeTier = FreeTier{MemGBHours: 100}
+ meters := map[string]float64{usage.MeterMemGBHours: 400}
+ // $8 allocated, 300/400 billable -> $6 billable cost -> $12 revenue at 50%.
+ ae := PriceAccount(sc, "bab", meters, 8)
+ approx(t, "cost", ae.CostUSD, 8)
+ approx(t, "revenue", ae.RevenueUSD, 12)
+}
+
+func TestProject(t *testing.T) {
+ approx(t, "project", Project(10, 10, 30), 30)
+ approx(t, "zero covered", Project(10, 0, 30), 0)
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/economics/ -count=1`
+Expected: FAIL — undefined: AllocateBox etc.
+
+- [ ] **Step 3: Write the implementation**
+
+`internal/economics/compute.go` — implement exactly the formulas above:
+
+```go
+package economics
+
+import "github.com/carlosframework/platform/internal/usage"
+
+// HoursPerMonth prorates GB-month rates over hour-denominated periods.
+const HoursPerMonth = 730.0
+
+// BoxAccountLoad is one account's consumption on one box over the period.
+type BoxAccountLoad struct {
+ CPUCoreHours float64
+ MemGBHours float64
+}
+
+// BoxLoad is one box over the period: metered hours and per-account load.
+type BoxLoad struct {
+ Box string
+ Hours float64
+ ByAccount map[string]BoxAccountLoad
+}
+
+// AllocateBox splits a box's period cost across accounts by resource share.
+// The unallocated remainder — idle headroom — is returned separately and
+// must be shown as its own line, never smeared across tenants (spec).
+func AllocateBox(rc RateCard, al Allocation, b BoxLoad) (perAccount map[string]float64, idleUSD, totalUSD float64) {
+ spec, ok := rc.Boxes[b.Box]
+ if !ok {
+ spec = rc.Boxes[""]
+ }
+ totalUSD = spec.HourUSD*b.Hours + spec.EBSGB*rc.EBSGBMonthUSD*(b.Hours/HoursPerMonth)
+ perAccount = map[string]float64{}
+ if b.Hours <= 0 {
+ return perAccount, totalUSD, totalUSD
+ }
+ allocated := 0.0
+ for acct, load := range b.ByAccount {
+ var cpuShare, memShare float64
+ if spec.Cores > 0 {
+ cpuShare = load.CPUCoreHours / (spec.Cores * b.Hours)
+ }
+ if spec.MemGB > 0 {
+ memShare = load.MemGBHours / (spec.MemGB * b.Hours)
+ }
+ var share float64
+ switch al.Policy {
+ case "cpu":
+ share = cpuShare
+ case "mem":
+ share = memShare
+ default: // blend
+ share = al.CPUWeight*cpuShare + (1-al.CPUWeight)*memShare
+ }
+ usd := share * totalUSD
+ perAccount[acct] = usd
+ allocated += usd
+ }
+ idleUSD = totalUSD - allocated
+ if idleUSD < 0 {
+ idleUSD = 0
+ }
+ return perAccount, idleUSD, totalUSD
+}
+
+// AccountEconomics is one account priced under one scenario.
+type AccountEconomics struct {
+ Account string
+ CostUSD float64
+ RevenueUSD float64
+ MarginFrac float64 // (revenue-cost)/revenue, 0 when revenue is 0
+ FreeTierPos string // "inside" | "partial" | "paying"
+}
+
+func billable(meters map[string]float64, meter string, allowance float64) float64 {
+ v := meters[meter] - allowance
+ if v < 0 {
+ return 0
+ }
+ return v
+}
+
+// PriceAccount prices one account's meters plus its allocated box cost
+// under sc. These formulas are mirrored verbatim by the dashboard's inline
+// knob JS (economics.html) — change both together or the page will disagree
+// with the server it loaded from.
+func PriceAccount(sc Scenario, account string, meters map[string]float64, allocatedUSD float64) AccountEconomics {
+ rc := sc.RateCard
+ cost := allocatedUSD +
+ meters[usage.MeterEgressGB]*rc.EgressGBUSD +
+ meters[usage.MeterRequests1k]*rc.RequestsPer1kUSD +
+ meters[usage.MeterStorageGBMonth]*rc.StorageGBMonthUSD
+
+ ft := sc.FreeTier
+ bEgress := billable(meters, usage.MeterEgressGB, ft.EgressGB)
+ bStorage := billable(meters, usage.MeterStorageGBMonth, ft.StorageGB)
+ bApps := billable(meters, usage.MeterAppMonths, ft.Apps)
+ bMem := billable(meters, usage.MeterMemGBHours, ft.MemGBHours)
+ computeFrac := 1.0
+ if m := meters[usage.MeterMemGBHours]; m > 0 {
+ computeFrac = bMem / m
+ }
+
+ var revenue float64
+ if sc.Mode == "rate-sheet" {
+ revenue = bApps*sc.Sheet.AppMonthUSD +
+ bEgress*sc.Sheet.EgressGBUSD +
+ bStorage*sc.Sheet.StorageGBMonthUSD +
+ meters[usage.MeterInstanceHours]*sc.Sheet.InstanceHourUSD
+ } else { // cost-plus
+ billableCost := allocatedUSD*computeFrac +
+ bEgress*rc.EgressGBUSD +
+ meters[usage.MeterRequests1k]*rc.RequestsPer1kUSD +
+ bStorage*rc.StorageGBMonthUSD
+ revenue = billableCost / (1 - sc.Margin)
+ }
+
+ margin := 0.0
+ if revenue > 0 {
+ margin = (revenue - cost) / revenue
+ }
+
+ anyUsage := false
+ for _, v := range meters {
+ if v > 0 {
+ anyUsage = true
+ break
+ }
+ }
+ clipped := bEgress < meters[usage.MeterEgressGB] ||
+ bStorage < meters[usage.MeterStorageGBMonth] ||
+ bApps < meters[usage.MeterAppMonths] ||
+ bMem < meters[usage.MeterMemGBHours]
+ pos := "paying"
+ switch {
+ case revenue == 0 && anyUsage:
+ pos = "inside"
+ case clipped:
+ pos = "partial"
+ }
+
+ return AccountEconomics{Account: account, CostUSD: cost, RevenueUSD: revenue, MarginFrac: margin, FreeTierPos: pos}
+}
+
+// Project scales a month-to-date figure to a full month by covered days.
+func Project(mtd float64, coveredDays, daysInMonth int) float64 {
+ if coveredDays <= 0 {
+ return 0
+ }
+ return mtd * float64(daysInMonth) / float64(coveredDays)
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/economics/ -count=1`
+Expected: PASS — these golden numbers are the contract for the page JS too.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/economics/
+git commit -m "economics: allocation, pricing and free-tier maths with golden tests"
+```
+
+---
+
+### Task 5: Edge usage meter — counters + counting writer
+
+**Files:**
+- Create: `internal/edge/usagemeter.go`
+- Modify: `internal/edge/edge.go` (field on `Edge` ~line 100; count site in `ServeHTTP` around lines 516-625)
+- Test: `internal/edge/usagemeter_test.go`
+
+**Interfaces:**
+- Consumes: `registry.Route` (fields `Host`, `App`, `Account`, `Kind` — `internal/registry/registry.go:31-189`), `release.RouteAccount(rt)` (`internal/release/adopt.go:564-569`), `usage.EdgeRow` (Task 1).
+- Produces: `type UsageMeter struct{...}` with `NewUsageMeter() *UsageMeter`, `(*UsageMeter).Record(rt registry.Route, requests, bytes int64)`, `(*UsageMeter).Drain() map[string]usage.EdgeRow`; `(*Edge).EnableUsage(m *UsageMeter)` (atomic, callable after serving starts). Task 6 consumes `Drain`; `RunAgent` wiring consumes `EnableUsage`.
+
+**Placement rules (from the edge survey):**
+- Today's `recordingResponseWriter` (edge.go:888-953) is built only on the cacheable/hibernating branches; the plain path (edge.go:625) writes to `w` unwrapped. The counter therefore gets its OWN thin wrapper, applied to ALL of the three dispatch branches by wrapping `w` once, immediately after route lookup succeeds and before the upgrade/cache branching.
+- Excluded: `Kind == "status"` routes (the platform's own status page — not billable), the tunnel registration hostname and store door (they return before route lookup, edge.go:531-544). Everything else counts — including static, hibernating wakes, and tunnel-BACKED route traffic (which does flow through `ServeHTTP`; the spec's "tunnel unmetered" caveat is about fleet boxes that don't run the flush loop, not about this code path).
+- Upgraded (hijacked) connections count their request and any bytes written before the hijack; post-hijack stream bytes are not metered in v1. Do not implement `ReadFrom` on the wrapper (io.Copy then falls back to `Write`, which counts).
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/edge/usagemeter_test.go`:
+
+```go
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestUsageMeterRecordAndDrain(t *testing.T) {
+ m := NewUsageMeter()
+ rt := registry.Route{Host: "a.example.com", App: "hello", Account: "bab"}
+ m.Record(rt, 1, 100)
+ m.Record(rt, 1, 50)
+ m.Record(registry.Route{Host: "b.example.com", App: "site"}, 1, 10) // Account "" -> "ops"
+
+ got := m.Drain()
+ a := got["a.example.com"]
+ if a.Requests != 2 || a.BytesOut != 150 || a.Account != "bab" || a.App != "hello" {
+ t.Fatalf("a row = %+v", a)
+ }
+ if got["b.example.com"].Account != "ops" {
+ t.Fatalf("b row = %+v", got["b.example.com"])
+ }
+ if again := m.Drain(); len(again) != 0 {
+ t.Fatalf("drain did not reset: %+v", again)
+ }
+}
+
+func TestUsageMeterConcurrent(t *testing.T) {
+ m := NewUsageMeter()
+ rt := registry.Route{Host: "a.example.com", App: "hello", Account: "bab"}
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 100; j++ {
+ m.Record(rt, 1, 10)
+ }
+ }()
+ }
+ wg.Wait()
+ row := m.Drain()["a.example.com"]
+ if row.Requests != 5000 || row.BytesOut != 50000 {
+ t.Fatalf("row = %+v", row)
+ }
+}
+
+func TestCountingWriterCountsBytes(t *testing.T) {
+ var n int64
+ rec := httptest.NewRecorder()
+ w := &countingWriter{ResponseWriter: rec, n: &n}
+ w.WriteHeader(http.StatusTeapot)
+ w.Write([]byte("hello "))
+ w.Write([]byte("world"))
+ if n != 11 {
+ t.Fatalf("counted %d bytes", n)
+ }
+ if rec.Code != http.StatusTeapot || rec.Body.String() != "hello world" {
+ t.Fatalf("forwarding broken: %d %q", rec.Code, rec.Body.String())
+ }
+}
+```
+
+Also extend the package's existing end-to-end style: find the existing test in `internal/edge/edge_test.go` that serves a static or unix route through a full `Edge` (grep `httptest.NewServer` / `ServeHTTP` in that file) and add one test that (a) constructs the Edge the same way, (b) calls `e.EnableUsage(NewUsageMeter())`, (c) makes 2 GET requests to a route host, (d) asserts `Drain()` shows `Requests == 2` and `BytesOut` equal to the served body length, and (e) makes a request to a `Kind == "status"` route and asserts it is absent from the next `Drain()`.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/edge/ -run 'UsageMeter|CountingWriter' -count=1`
+Expected: FAIL — undefined: NewUsageMeter, countingWriter.
+
+- [ ] **Step 3: Write the implementation**
+
+`internal/edge/usagemeter.go`:
+
+```go
+package edge
+
+import (
+ "net/http"
+ "sync"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// UsageMeter accumulates per-route request/byte counters between flushes.
+// Record sits on the serving path: one mutex'd map update per request —
+// no allocation, no I/O. Drain swaps the map out for the usage flush loop
+// (internal/host), so a slow bucket write never blocks serving.
+type UsageMeter struct {
+ mu sync.Mutex
+ m map[string]usage.EdgeRow
+}
+
+// NewUsageMeter returns an empty meter.
+func NewUsageMeter() *UsageMeter {
+ return &UsageMeter{m: map[string]usage.EdgeRow{}}
+}
+
+// Record adds one served exchange for rt.
+func (u *UsageMeter) Record(rt registry.Route, requests, bytes int64) {
+ u.mu.Lock()
+ row := u.m[rt.Host]
+ row.Account = release.RouteAccount(rt)
+ row.App = rt.App
+ row.Requests += requests
+ row.BytesOut += bytes
+ u.m[rt.Host] = row
+ u.mu.Unlock()
+}
+
+// Drain returns the accumulated rows and resets the meter.
+func (u *UsageMeter) Drain() map[string]usage.EdgeRow {
+ u.mu.Lock()
+ out := u.m
+ u.m = map[string]usage.EdgeRow{}
+ u.mu.Unlock()
+ return out
+}
+
+// countingWriter counts response body bytes. Deliberately does NOT
+// implement io.ReaderFrom: io.Copy falls back to Write, which counts.
+// Hijack/Flush forward via the interfaces the wrapped writer provides,
+// mirroring recordingResponseWriter (edge.go).
+type countingWriter struct {
+ http.ResponseWriter
+ n *int64
+}
+
+func (c *countingWriter) Write(p []byte) (int, error) {
+ n, err := c.ResponseWriter.Write(p)
+ *c.n += int64(n)
+ return n, err
+}
+```
+
+Copy the `Hijack`/`Flush` forwarding methods from `recordingResponseWriter` (edge.go:888-953) verbatim onto `countingWriter` — same receivers, same type assertions. (The `*c.n += int64(n)` is safe unsynchronized because each request has its own counter cell; the shared map is only touched in `Record` after the handler returns.)
+
+Wiring in `edge.go`:
+
+1. Field on `Edge` (near `staticStore`, ~line 100): `usageMeter atomic.Pointer[UsageMeter]` (import `sync/atomic`).
+2. Setter:
+
+```go
+// EnableUsage arms per-route request/byte metering. Called by the agent
+// role after construction (mirroring how tunnelLoop attaches via the ready
+// hook); the bare `carlos edge` role never calls it, so pure-proxy edges
+// meter nothing.
+func (e *Edge) EnableUsage(m *UsageMeter) { e.usageMeter.Store(m) }
+```
+
+3. In `ServeHTTP`, immediately after the `up, ok := e.current()[host]` lookup succeeds (before the upgrade check at edge.go:561), insert:
+
+```go
+ if m := e.usageMeter.Load(); m != nil && up.route.Kind != "status" {
+ var bytes int64
+ w = &countingWriter{ResponseWriter: w, n: &bytes}
+ defer func() { m.Record(up.route, 1, bytes) }()
+ }
+```
+
+The `defer` runs after whichever of the three dispatch branches served, so all paths count through the one site.
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/edge/ -count=1` (full package — the serving tests must still pass with the wrapper in place)
+Expected: PASS, including the new end-to-end count test and the status-route exclusion.
+
+- [ ] **Step 5: Run the race detector over the new concurrency**
+
+Run: `go test ./internal/edge/ -run 'UsageMeter' -race -count=1`
+Expected: PASS, no data races.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/edge/
+git commit -m "edge: per-route request/egress metering behind EnableUsage"
+```
+
+---
+
+### Task 6: Usage flush loop in the agent
+
+**Files:**
+- Create: `internal/host/usage_loop.go`
+- Modify: `internal/host/agent.go` (the `ready` hook near line 316-318 and the loop launches near lines 294-308)
+- Test: `internal/host/usage_loop_test.go`
+
+**Interfaces:**
+- Consumes: `usage.UpsertEdgeDay` (Task 1), `edge.UsageMeter.Drain` (Task 5), existing unexported helpers `openStatusStore(ctx, os.Getenv)`, `resolveStatusPrefix(os.Getenv)`, `metricsBoxLabel(os.Getenv, readMachineID())`, `release.NewPrefixStore` — all exactly as `metricsLoop` uses them (`internal/host/metrics_loop.go:757-796`).
+- Produces: `usageLoop(drain func() map[string]usage.EdgeRow)` (process-lifetime goroutine) and the testable core `usageFlush(ctx, s, box, drain, now) error`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/host/usage_loop_test.go`:
+
+```go
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+func TestUsageFlushWritesAndReflushesMerge(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ at := time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC)
+ rows := map[string]usage.EdgeRow{
+ "a.example.com": {Account: "bab", App: "hello", Requests: 3, BytesOut: 300},
+ }
+ if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return rows }, at); err != nil {
+ t.Fatal(err)
+ }
+ if err := usageFlush(ctx, s, "box1", func() map[string]usage.EdgeRow { return rows }, at.Add(5*time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, usage.EdgeDayKey("box1", "2026-08-11"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var day usage.EdgeDay
+ if err := json.Unmarshal(raw, &day); err != nil {
+ t.Fatal(err)
+ }
+ if got := day.Hours["09"]["a.example.com"].Requests; got != 6 {
+ t.Fatalf("requests = %d, want 6", got)
+ }
+}
+
+func TestUsageFlushEmptyDrainWritesNothing(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ err := usageFlush(context.Background(), s, "box1", func() map[string]usage.EdgeRow { return nil }, time.Now().UTC())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if keys, _ := s.List(context.Background(), "usage/"); len(keys) != 0 {
+ t.Fatalf("wrote %v", keys)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/host/ -run UsageFlush -count=1`
+Expected: FAIL — undefined: usageFlush.
+
+- [ ] **Step 3: Write the implementation**
+
+`internal/host/usage_loop.go`:
+
+```go
+package host
+
+import (
+ "context"
+ "log"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// usageFlush drains the edge meter and CAS-merges the deltas into the box's
+// usage/edge day object. One flush; the loop below owns cadence.
+func usageFlush(ctx context.Context, s release.Store, box string, drain func() map[string]usage.EdgeRow, now time.Time) error {
+ return usage.UpsertEdgeDay(ctx, s, box, now, drain())
+}
+
+// usageLoop flushes edge usage every 5 minutes, forever — the metricsLoop
+// conventions exactly: recover guard, one log line if the box has no status
+// store, errors logged and never fatal (the drained window is lost, the
+// next one is not; modelling grade tolerates the gap).
+func usageLoop(drain func() map[string]usage.EdgeRow) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("agent: usage loop panic, edge metering stopped for this process: %v", r)
+ }
+ }()
+ ctx := context.Background()
+ raw, target, err := openStatusStore(ctx, os.Getenv)
+ if err != nil {
+ log.Printf("agent: usage loop disabled: %v", err)
+ return
+ }
+ s := release.NewPrefixStore(raw, resolveStatusPrefix(os.Getenv))
+ box := metricsBoxLabel(os.Getenv, readMachineID())
+ log.Printf("agent: usage loop armed (5min flush), publishing to %s", target)
+ for {
+ time.Sleep(5 * time.Minute)
+ if err := usageFlush(ctx, s, box, drain, time.Now()); err != nil {
+ log.Printf("agent: usage flush: %v", err)
+ }
+ }
+}
+```
+
+Wiring in `agent.go` — extend the existing ready hook (currently `func(e *edge.Edge) { go tunnelLoop(e) }`, agent.go:316-318):
+
+```go
+ edge.RunWithActivatorReady(args, act, cache, func(e *edge.Edge) {
+ go tunnelLoop(e)
+ m := edge.NewUsageMeter()
+ e.EnableUsage(m)
+ go usageLoop(m.Drain)
+ })
+```
+
+(Match the hook's actual current body when editing — if other lines have joined it since, keep them.)
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/host/ -count=1 && go build ./...`
+Expected: PASS + clean build (agent wiring compiles).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/host/
+git commit -m "host: usage flush loop rides the agent, 5min cadence"
+```
+
+---
+
+### Task 7: `release.SizeLister` — sized listings for the storage sweep
+
+**Files:**
+- Modify: `internal/release/store.go` (next to `ModLister`, ~line 148-163)
+- Test: `internal/release/store_test.go` (append)
+
+**Interfaces:**
+- Consumes: existing `S3Store` (`Client *awss3.Client, Bucket string`), `DirStore`.
+- Produces (Task 9 consumes): `type KeySize struct{ Key string; Size int64 }`; `type SizeLister interface{ ListSizes(ctx context.Context, prefix string) ([]KeySize, error) }`; implemented by `*S3Store` (ListObjectsV2 pagination, `Size` from each object) and `*DirStore` (`os.Stat`). NOT forwarded through `prefixStore` (same rule as `ModLister`) — the console holds the raw store, so this is fine.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/release/store_test.go` (match its existing helper style — read the file's DirStore tests first):
+
+```go
+func TestDirStoreListSizes(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := s.Put(ctx, "apps/bab/hello/one.bin", []byte("12345"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, "apps/bab/hello/two.bin", []byte("1234567890"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, "apps/other/x.bin", []byte("1"), ""); err != nil {
+ t.Fatal(err)
+ }
+ sizes, err := s.ListSizes(ctx, "apps/bab/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ total := int64(0)
+ for _, ks := range sizes {
+ total += ks.Size
+ }
+ if len(sizes) != 2 || total != 15 {
+ t.Fatalf("sizes = %+v", sizes)
+ }
+ var _ SizeLister = s // interface satisfaction
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/release/ -run ListSizes -count=1`
+Expected: FAIL — undefined: SizeLister.
+
+- [ ] **Step 3: Write the implementation**
+
+In `internal/release/store.go`, directly under the `ModLister` block:
+
+```go
+// KeySize is one object and its byte size.
+type KeySize struct {
+ Key string
+ Size int64
+}
+
+// SizeLister is the optional sized-listing extension the economics storage
+// sweep needs. Like ModLister it is NOT part of Store and NOT forwarded by
+// NewPrefixStore — callers type-assert and degrade ("unswept") when the
+// backing store cannot answer.
+type SizeLister interface {
+ ListSizes(ctx context.Context, prefix string) ([]KeySize, error)
+}
+```
+
+`DirStore.ListSizes`: mirror `DirStore.List`'s walk (same file), collecting `KeySize{Key: rel, Size: info.Size()}` from the walked `fs.FileInfo`.
+
+`S3Store.ListSizes`: mirror `S3Store.List`'s ListObjectsV2 pagination loop exactly (same input struct, same continuation-token handling), but collect `KeySize{Key: aws.ToString(obj.Key), Size: aws.ToInt64(obj.Size)}` per object instead of key alone.
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/release/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/
+git commit -m "release: SizeLister optional extension for sized listings"
+```
+
+---
+
+### Task 8: Console economics pass — scaffolding + census
+
+**Files:**
+- Create: `internal/console/economicspass.go`
+- Modify: `internal/console/store.go` (add `Accounts()`)
+- Modify: `cmd/console/main.go` (start the pass next to `StartAlertWatcher`, ~line 260)
+- Test: `internal/console/economicspass_test.go`
+
+**Interfaces:**
+- Consumes: `openRegistry(s.cfg.Registry)` + `reg.List()` (`internal/console/view.go:311-323`, `internal/registry` `List() ([]Route, error)`), `release.RouteAccount`, `usage.CensusDay`/`CreateDayObject` (Task 2), `s.cfg.Releases release.Store`.
+- Produces: `(*Server).StartEconomicsPass(ctx context.Context)` — hourly ticker, run-immediately-then-tick, `log.Printf` on error, started from `serve()` with `go srv.StartEconomicsPass(context.Background())` (NEVER from `NewServer` — test servers must not grow goroutines); the testable `(*Server).economicsPass(ctx, now) error` running census (this task) then storage (Task 9), statements (Task 10) and bill pull (Task 12); `(*Store).Accounts() ([]Account, error)` listing ALL accounts. Census counts by ACCOUNT NAME (`release.RouteAccount` normalizes "" to "ops").
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/console/economicspass_test.go` — use the existing fixture (`newConsoleFixture(t)`, `server_test.go:31-89`; its `fx.rel` is the bucket, `fx.regPath` the registry with seeded routes — read `buildFixtures` in `view_test.go:66-91` to see which routes/accounts it seeds and assert on THOSE, not invented ones):
+
+```go
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+func TestEconomicsPassWritesCensusOnce(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC)
+
+ if err := fx.srv.economicsPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var day usage.CensusDay
+ if err := json.Unmarshal(raw, &day); err != nil {
+ t.Fatal(err)
+ }
+ if len(day.Accounts) == 0 {
+ t.Fatal("census counted nothing — fixture routes should appear")
+ }
+ // Idempotence: a second run the same day must not error or rewrite.
+ before := string(raw)
+ if err := fx.srv.economicsPass(ctx, now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ after, _, _ := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11"))
+ if string(after) != before {
+ t.Fatal("second pass rewrote the census")
+ }
+}
+
+func TestStoreAccountsListsAll(t *testing.T) {
+ fx := newConsoleFixture(t)
+ accts, err := fx.st.Accounts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The fixture mints at least one account; every row must carry id+sqid+name.
+ for _, a := range accts {
+ if a.ID == 0 || a.Sqid == "" || a.Name == "" {
+ t.Fatalf("incomplete account row: %+v", a)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/console/ -run 'EconomicsPass|StoreAccounts' -count=1`
+Expected: FAIL — undefined: economicsPass, Accounts.
+
+- [ ] **Step 3: Write the implementation**
+
+`(*Store).Accounts()` in `store.go`, next to `AccountsFor` (~line 736), same scanning style:
+
+```go
+// Accounts lists every account — the economics pass's iteration set (an
+// account with no statements yet must still appear in the census).
+func (s *Store) Accounts() ([]Account, error) {
+ rows, err := s.db.Query(`SELECT id, sqid, name, created_at FROM accounts ORDER BY name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Account
+ for rows.Next() {
+ var a Account
+ var created int64
+ if err := rows.Scan(&a.ID, &a.Sqid, &a.Name, &created); err != nil {
+ return nil, err
+ }
+ a.CreatedAt = time.Unix(created, 0)
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+```
+
+(Match the accounts table's actual column list in the `CREATE TABLE` at store.go:136-141 before writing the query.)
+
+`internal/console/economicspass.go`:
+
+```go
+package console
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// economicsPassInterval is how often the pass wakes. Each day's objects are
+// create-only, so waking hourly costs one conditional GET per family once
+// the day is written — and catches up promptly after a console restart.
+const economicsPassInterval = time.Hour
+
+// StartEconomicsPass runs economicsPass immediately and then hourly until
+// ctx is done. Started from cmd/console's serve(), deliberately NOT from
+// NewServer — the StartAlertWatcher rule: test servers never grow
+// goroutines (alertwatch.go:44).
+func (s *Server) StartEconomicsPass(ctx context.Context) {
+ t := time.NewTicker(economicsPassInterval)
+ defer t.Stop()
+ for {
+ if err := s.economicsPass(ctx, time.Now()); err != nil {
+ log.Printf("console: economics pass: %v", err)
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ }
+ }
+}
+
+// economicsPass runs the daily economics writes for now's UTC day: census,
+// storage sweep, statements, bill pull. Each stage degrades independently —
+// a failed stage logs and the next still runs, because a missing census day
+// must not cost the month's statements (spec: error handling).
+func (s *Server) economicsPass(ctx context.Context, now time.Time) error {
+ date := usage.DayOf(now)
+ if err := s.censusPass(ctx, date); err != nil {
+ log.Printf("console: census pass %s: %v", date, err)
+ }
+ // Task 9 adds: s.storagePass(ctx, date)
+ // Task 10 adds: s.statementsPass(ctx, now)
+ // Task 12 adds: s.billPass(ctx, now)
+ return nil
+}
+
+// censusPass snapshots per-account app/route counts, create-only.
+func (s *Server) censusPass(ctx context.Context, date string) error {
+ key := usage.CensusDayKey(date)
+ if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil {
+ return nil // today already written (by us or a sibling console)
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return err
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return err
+ }
+ accounts := map[string]usage.CensusAccount{}
+ apps := map[string]map[string]bool{}
+ for _, rt := range routes {
+ acct := release.RouteAccount(rt)
+ c := accounts[acct]
+ c.Routes++
+ if apps[acct] == nil {
+ apps[acct] = map[string]bool{}
+ }
+ if rt.App != "" && !apps[acct][rt.App] {
+ apps[acct][rt.App] = true
+ c.Apps++
+ }
+ accounts[acct] = c
+ }
+ day := usage.CensusDay{V: 1, Date: date, Accounts: accounts}
+ _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day)
+ return err
+}
+```
+
+Note the early-return `Get` probe: `CreateDayObject` alone is correct, but the probe makes the hourly wake ~free. Keep both.
+
+Wiring in `cmd/console/main.go` `serve()`, directly under the `StartAlertWatcher` launch (~line 260):
+
+```go
+ // The economics pass writes the daily usage/census/storage/statement
+ // objects and pulls the AWS bill (internal/console/economicspass.go).
+ // Same placement rule as the alert watcher: started here, never in
+ // NewServer, so constructing a Server stays goroutine-free.
+ go srv.StartEconomicsPass(context.Background())
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS (existing suite + the two new tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ cmd/console/
+git commit -m "console: hourly economics pass with create-only daily census"
+```
+
+---
+
+### Task 9: Storage sweep pass
+
+**Files:**
+- Modify: `internal/console/economicspass.go` (add `storagePass`, uncomment its call site)
+- Test: `internal/console/economicspass_test.go` (append)
+
+**Interfaces:**
+- Consumes: `release.SizeLister` (Task 7), `release.AppPrefix(acct, app)`, `s.cfg.Releases.ListDirs`, `objstore.Get(ctx, s, acct, app)` + `objstore.Record.Provisioned` (`internal/objstore/objstore.go:62-84`), `usage.StorageDay`/`CreateDayObject` (Task 2).
+- Produces: `(*Server).storagePass(ctx, date) error`; a seam field on `Server`: `storeSizer func(ctx context.Context, p *objstore.Provisioned) (int64, error)` — nil means "use the real S3 sizer"; tests inject a fake. Real impl `sizeProvisionedStore` builds an `awss3.Client` the way `objstore.NewAWSProvisioner` does (`internal/objstore/aws.go:76` — region + optional endpoint via the same option layering) and sums paginated `ListObjectsV2` sizes.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `economicspass_test.go`:
+
+```go
+func TestStoragePassSizesAppsAndDegradesStores(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+
+ // The fixture bucket already has releases under apps/<acct>/<app>/ from
+ // buildFixtures; add a known-size object under one of those app prefixes
+ // and read its acct/app from the fixture rather than hardcoding.
+ // (grep buildFixtures for the seeded account/app names and use them.)
+ if _, err := fx.rel.Put(ctx, "apps/"+fixtureAcct+"/"+fixtureApp+"/releases/x.tgz", make([]byte, 2048), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ fx.srv.storeSizer = func(ctx context.Context, p *objstore.Provisioned) (int64, error) {
+ return 0, errors.New("endpoint down")
+ }
+
+ if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var day usage.StorageDay
+ if err := json.Unmarshal(raw, &day); err != nil {
+ t.Fatal(err)
+ }
+ app := day.Apps[fixtureAcct+"/"+fixtureApp]
+ if app.BucketBytes < 2048 {
+ t.Fatalf("bucket bytes = %d, want >= 2048", app.BucketBytes)
+ }
+}
+```
+
+Replace `fixtureAcct`/`fixtureApp` with the real names `buildFixtures` seeds (declare them as consts in the test file after reading `view_test.go:66-91`). If the fixture seeds an app with a provisioned `store.json`, also assert `StoreUnswept == true` for it; if it seeds none, add a second sub-test that writes a minimal `objstore.Record` with a `Provisioned` block via `objstore.Put` and asserts the unswept flag.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/console/ -run StoragePass -count=1`
+Expected: FAIL — undefined: storagePass, storeSizer.
+
+- [ ] **Step 3: Write the implementation**
+
+Add to `economicspass.go` (and add the `storeSizer` field to `Server` in `server.go` next to `metricsCache`):
+
+```go
+// storagePass sizes every app's deployment-bucket prefix, plus its tenant
+// object store when one is provisioned, create-only per day. Requires the
+// bucket to support sized listings (release.SizeLister); a store that
+// cannot answer marks the day's sweep absent rather than lying with zeros.
+func (s *Server) storagePass(ctx context.Context, date string) error {
+ key := usage.StorageDayKey(date)
+ if _, _, err := s.cfg.Releases.Get(ctx, key); err == nil {
+ return nil
+ }
+ sizer, ok := s.cfg.Releases.(release.SizeLister)
+ if !ok {
+ log.Printf("console: storage pass: bucket store does not support sized listings; skipping")
+ return nil
+ }
+ accts, err := s.cfg.Releases.ListDirs(ctx, "apps/")
+ if err != nil {
+ return err
+ }
+ day := usage.StorageDay{V: 1, Date: date, Apps: map[string]usage.AppStorage{}}
+ for _, acct := range accts {
+ apps, err := s.cfg.Releases.ListDirs(ctx, "apps/"+acct+"/")
+ if err != nil {
+ return err
+ }
+ for _, app := range apps {
+ sizes, err := sizer.ListSizes(ctx, release.AppPrefix(acct, app))
+ if err != nil {
+ return err
+ }
+ var st usage.AppStorage
+ for _, ks := range sizes {
+ st.BucketBytes += ks.Size
+ }
+ if rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct, app); err == nil && rec != nil && rec.Provisioned != nil {
+ n, serr := s.sizeStore(ctx, rec.Provisioned)
+ if serr != nil {
+ log.Printf("console: storage pass: store %s/%s: %v", acct, app, serr)
+ st.StoreUnswept = true
+ } else {
+ st.StoreBytes = n
+ }
+ }
+ day.Apps[acct+"/"+app] = st
+ }
+ }
+ _, err = usage.CreateDayObject(ctx, s.cfg.Releases, key, day)
+ return err
+}
+
+// sizeStore routes through the test seam or the real S3 sizer.
+func (s *Server) sizeStore(ctx context.Context, p *objstore.Provisioned) (int64, error) {
+ if s.storeSizer != nil {
+ return s.storeSizer(ctx, p)
+ }
+ return sizeProvisionedStore(ctx, p)
+}
+```
+
+`sizeProvisionedStore`: build the S3 client exactly the way `objstore.NewAWSProvisioner` (`internal/objstore/aws.go:76-93`) builds its `s3` client from `(region, endpoint)` — read that constructor and copy its config/option layering — then loop `ListObjectsV2` over `p.Bucket` with continuation tokens, summing `aws.ToInt64(obj.Size)`. If the constructor isn't reusable as-is, export a small helper from `objstore` (e.g. `func S3ClientFor(ctx, region, endpoint string) (*awss3.Client, error)`) rather than duplicating the quirk layering — whichever is the smaller diff.
+
+Uncomment the call in `economicsPass`:
+
+```go
+ if err := s.storagePass(ctx, date); err != nil {
+ log.Printf("console: storage pass %s: %v", date, err)
+ }
+```
+
+ListDirs note: verify `DirStore.ListDirs`/`S3Store.ListDirs` return bare child names (not full prefixes) by reading their implementations in `internal/release/store.go` before writing the loop — adjust the string concatenation to match what they actually return.
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/
+git commit -m "console: daily storage sweep with unswept degradation"
+```
+
+---
+
+### Task 10: Statements pass — unit attribution + meter maths
+
+**Files:**
+- Modify: `internal/console/economicspass.go` (add `statementsPass`, `unitOwner`, uncomment call)
+- Test: `internal/console/economicspass_test.go` (append)
+
+**Interfaces:**
+- Consumes: `metrics.DayKey(box, date)` + `metrics.DayRollup`/`HourAgg`/`Agg` (`internal/metrics/rollup.go`), `usage.EdgeDayKey`/`EdgeDay`, `usage.StorageDayKey`, `usage.CensusDayKey`, `usage.Statement`/`UpsertStatement` (Tasks 1-2), registry routes via `openRegistry` (Task 8), `s.store.Accounts()` (Task 8), the box-discovery prefix `metrics/boxes/` (`metricsview.go:98 metricsBoxesPrefix`).
+- Produces: `(*Server).statementsPass(ctx, now) error` writing one `usage.Statement` per account for `usage.MonthOf(now)`; helper `unitOwner(routes []registry.Route, unit string) (acct, app string, ok bool)`.
+
+**Unit→route matching rules** (mirror `OwnerAppMetrics`'s matching in `metricsview.go:670-712` — read it first; the rules are):
+- `carlos-app@<host>.service` and `carlos-exec@<host>.service` → route with that `Host` (unit names systemd-escape the host: match both the raw and unescaped forms the same way OwnerAppMetrics does).
+- A route with a non-empty `Unit` column → that exact unit name.
+- Unmatched units (the console's own unit, system units) attribute to account `"ops"`, app `""` — platform overhead.
+
+**Meter derivations for the month-to-date statement** (canonical):
+- Iterate every UTC day of the month up to and including today. A day is COVERED for an account when at least one source object for that day exists (metrics day rollup on any box, edge day, storage day). `CoveredDays` = count of covered days; `DaysInMonth` from `time.Date(y, m+1, 0)`.
+- From each box's `metrics.DayRollup` day: for each unit and hour, `minutes = Agg.N` (samples are 60s apart), so `cpu_core_hours += hours' units[unit]["cpu"].Avg * N / 60`, `mem_gb_hours += units[unit]["mem_bytes"].Avg * N / 60 / 1e9`, `instance_hours += N/60` — attributed via `unitOwner`.
+- From each box's `usage.EdgeDay`: `egress_gb += BytesOut/1e9`, `requests_1k += Requests/1000`, attributed by the row's own Account.
+- From each `usage.StorageDay`: per app `bytesTotal = BucketBytes + StoreBytes`; account's `storage_gb_month += (bytesTotal/1e9) / daysInMonth` (so a full month of stable storage sums to the average GB held — a GB-month).
+- From each `usage.CensusDay`: `app_months += apps / daysInMonth`.
+- GB is decimal (1e9) throughout — billing convention, and the dashboard labels it GB.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `economicspass_test.go` — build a golden fixture entirely from bucket objects (no live boxes needed):
+
+```go
+func TestStatementsPassGolden(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC)
+
+ // One box, one day of metrics: unit carlos-app@a.example.com with
+ // cpu avg 0.5 for 60 samples in hour 09 (= 0.5 core-hours), mem avg
+ // 1e9 bytes (= 1 GB-hour, 1 instance-hour).
+ cpu, mem := 0.5, 1e9
+ roll := metrics.DayRollup{V: 1, Box: "box1", Date: "2026-08-10", Hours: map[string]metrics.HourAgg{
+ "09": {Units: map[string]map[string]metrics.Agg{
+ "carlos-app@a.example.com.service": {
+ "cpu": {Avg: cpu, N: 60},
+ "mem_bytes": {Avg: mem, N: 60},
+ },
+ }},
+ }}
+ putJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-10"), roll)
+
+ // Edge day: 2 GB out, 4000 requests for the same route's account.
+ putJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-08-10"), usage.EdgeDay{
+ V: 1, Box: "box1", Date: "2026-08-10",
+ Hours: map[string]map[string]usage.EdgeRow{"09": {
+ "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9},
+ }},
+ })
+
+ // The fixture registry must hold a route Host=a.example.com owned by
+ // fixtureAcct/fixtureApp — seed one via the same helper buildFixtures
+ // uses if it does not already.
+
+ if err := fx.srv.statementsPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+
+ raw, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var st usage.Statement
+ if err := json.Unmarshal(raw, &st); err != nil {
+ t.Fatal(err)
+ }
+ approxF := func(name string, got, want float64) {
+ t.Helper()
+ if math.Abs(got-want) > 1e-9 {
+ t.Fatalf("%s = %v, want %v", name, got, want)
+ }
+ }
+ approxF("cpu", st.Meters[usage.MeterCPUCoreHours], 0.5)
+ approxF("mem", st.Meters[usage.MeterMemGBHours], 1.0)
+ approxF("instance_hours", st.Meters[usage.MeterInstanceHours], 1.0)
+ approxF("egress", st.Meters[usage.MeterEgressGB], 2.0)
+ approxF("requests", st.Meters[usage.MeterRequests1k], 4.0)
+ if st.DaysInMonth != 31 || st.CoveredDays < 1 {
+ t.Fatalf("coverage = %d/%d", st.CoveredDays, st.DaysInMonth)
+ }
+}
+```
+
+With a small `putJSON(t, s, key, v)` helper (marshal + `s.Put(ctx, key, raw, "")`, fatal on error) added once to this test file.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/console/ -run StatementsPass -count=1`
+Expected: FAIL — undefined: statementsPass.
+
+- [ ] **Step 3: Write the implementation**
+
+`statementsPass` in `economicspass.go`, structured exactly as the derivations above: discover boxes via `ListDirs(ctx, "metrics/boxes/")` and `ListDirs(ctx, usage.EdgeBoxesPrefix)`; for each day of the month so far, `Get` each box's `metrics.DayKey`/`usage.EdgeDayKey` plus the day's storage and census objects (missing = skip, uncovered); accumulate `map[account]map[meter]float64` per the formulas; then for every account in the union of `s.store.Accounts()` names and accumulated keys, `usage.UpsertStatement` with the account's meters (an account with no usage gets a statement with zero meters — the accounts page must list it).
+
+`unitOwner`:
+
+```go
+// unitOwner attributes a sampled systemd unit to its route's account/app.
+// Matching mirrors OwnerAppMetrics (metricsview.go): carlos-app@/carlos-exec@
+// instance units match on the route host; a route's stamped Unit column
+// matches exactly; anything else is platform overhead under ops.
+func unitOwner(routes []registry.Route, unit string) (acct, app string, ok bool) {
+ host := ""
+ for _, prefix := range []string{"carlos-app@", "carlos-exec@"} {
+ if strings.HasPrefix(unit, prefix) {
+ host = strings.TrimSuffix(strings.TrimPrefix(unit, prefix), ".service")
+ }
+ }
+ for _, rt := range routes {
+ if (host != "" && rt.Host == host) || (rt.Unit != "" && rt.Unit == unit) {
+ return release.RouteAccount(rt), rt.App, true
+ }
+ }
+ return "ops", "", false
+}
+```
+
+(Read `OwnerAppMetrics`'s actual escaping handling first — if it unescapes systemd unit names (`\x2d` etc.) reuse the same helper; hosts with hyphens will hit this.)
+
+- [ ] **Step 4: Run tests**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS, golden numbers exact.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/
+git commit -m "console: month-to-date account statements from metrics, edge and storage days"
+```
+
+---
+
+### Task 11: Economics view assembly + cache
+
+**Files:**
+- Create: `internal/console/economicsview.go`
+- Modify: `internal/console/economicspass.go` (extract `sweepUsage` out of `statementsPass` — behavior-preserving, Task 10's tests stay green)
+- Modify: `internal/console/server.go` (add `economicsCache` field next to `metricsCache`)
+- Test: `internal/console/economicsview_test.go`
+
+**Interfaces:**
+- Consumes: everything Task 10 reads, `economics.*` (Tasks 3-4), the `metricsCache` pattern (`metricsview.go:187-391`) for TTL/stale-while-revalidate.
+- Produces (Tasks 13, 14 consume):
+ - `func (s *Server) sweepUsage(ctx context.Context, from, to time.Time) (*usageSweep, error)` where
+
+ ```go
+ type usageSweep struct {
+ PerAccount map[string]map[string]float64 // account -> meter -> value
+ PerBox map[string]economics.BoxLoad // box -> period load
+ BoxDays map[string]int // box -> days with any metrics rollup
+ CoveredDays, PeriodDays int
+ EgressMetered map[string]bool // box -> wrote any usage/edge day
+ }
+ ```
+ (statementsPass becomes a thin caller: sweep the month, add storage/census meters, upsert statements.)
+ - `func (s *Server) LoadEconomics(ctx context.Context, period string) (*EconomicsView, error)` — periods `"mtd"` (default), `"last"`, `"7d"`; 60s-TTL cache keyed by period, same three-state freshness logic as `LoadMetrics` (copy its shape, including `startBackgroundSweep`'s detached-context recover-guarded goroutine).
+ - ```go
+ type EconomicsView struct {
+ GeneratedAt time.Time
+ Period string
+ Scenario economics.Scenario // active (bucket) or DefaultScenario fallback
+ ScenarioWarning string // set when active.json named something unloadable
+ Scenarios []string // names under economics/scenarios/
+ Accounts []economics.AccountEconomics // priced under Scenario, sorted by CostUSD desc
+ Meters map[string]map[string]float64 // account -> meter (for embedding)
+ Boxes []BoxEconomics
+ Bills []economics.Bill // this month + previous, if present
+ CoveredDays, PeriodDays int
+ DataJSON template.JS // the embedded payload (see below)
+ }
+ type BoxEconomics struct {
+ Box string
+ Hours float64
+ CostUSD float64
+ IdleUSD float64
+ RevenueUSD float64 // Σ over tenants: their revenue × (this box's allocated share / their total allocated cost)
+ Utilization float64 // 1 - IdleUSD/CostUSD (0 when CostUSD is 0)
+ EgressMetered bool
+ }
+ ```
+ - `DataJSON` marshals `{scenario, meters, boxLoads, boxSpecs, bills, coveredDays, periodDays}` via `json.Marshal` into a `template.JS` for one `<script>` block. Build it with a dedicated struct (`economicsPayload`) so the page JS and Go agree on field names — document each JSON field name in the struct tags.
+
+- [ ] **Step 1: Write the failing test** — `economicsview_test.go`: seed the fixture bucket with the same golden objects as Task 10's test plus a scenario at `economics.ScenarioKey("test")` + `ActiveKey` naming it; call `LoadEconomics(ctx, "mtd")`; assert: active scenario loaded (Name == "test"), one `AccountEconomics` row with `CostUSD > 0`, box row for "box1" with `Hours == 1` and `IdleUSD + allocated ≈ CostUSD`, and `DataJSON` containing `"meters"`. Add a second test: corrupt `ActiveKey` (write `{"schema":1,"name":"missing"}`) → `LoadEconomics` returns the DefaultScenario with `ScenarioWarning != ""` (spec: malformed active falls back with a visible warning).
+- [ ] **Step 2: Run** `go test ./internal/console/ -run Economics -count=1` — expect FAIL (undefined).
+- [ ] **Step 3: Implement** per the interface block. Period bounds: `mtd` = first of month → now; `last` = whole previous month; `7d` = now-7d → now. Revenue attribution to a box: for each account, `boxShare = allocated[box][acct] / totalAllocated[acct]` (0 when total is 0), `box.RevenueUSD += acct.RevenueUSD * boxShare`. Egress-unmetered boxes: a box present in metrics but absent from `usage/edge/` gets `EgressMetered: false` — the page shows "unmetered", never zero (Global Constraints honesty rule).
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — expect PASS (including Task 10's untouched tests, proving the sweep extraction preserved behavior).
+- [ ] **Step 5: Commit** — `git add internal/console/ && git commit -m "console: economics assembly with cached period sweep"`
+
+---
+
+### Task 12: Bill pull (Cost Explorer) + scenario/bill write endpoints
+
+**Files:**
+- Create: `internal/console/cepull.go`
+- Modify: `internal/console/economicspass.go` (add `billPass`, uncomment call)
+- Modify: `internal/console/server.go` (register routes; add `ceCosts` seam field)
+- Modify: `go.mod` (new dependency)
+- Test: `internal/console/economicsbill_test.go`
+
+**Interfaces:**
+- Consumes: `economics.Bill`/`BillKey`/sources (Task 3), `s.session` + `IsOperator` gate, CSRF pattern (`subtle.ConstantTimeCompare` vs raw session token — `server.go:1012-1039` `handleNewAccount` is the model), `s.cliUser` bearer helper (`apicli.go`) for the API door.
+- Produces:
+ - `(*Server).billPass(ctx, now) error` — pulls current + previous month via the `ceCosts` seam, writes each with `UpsertBill` semantics: Get existing; if `Source == economics.SourceManual`, skip (manual wins); else CAS-put the fresh pull.
+ - Seam on Server: `ceCosts func(ctx context.Context, start, end time.Time) (map[string]float64, error)` (service → USD). nil → real client, built lazily ONCE and cached; a construction or AccessDenied error logs once per process (`sync.Once` around the warning) and bill pulls stay silently off — manual mode (spec: a deployment that never pulled shows manual entry, not an error).
+ - Real impl in `cepull.go`: `costexplorer.NewFromConfig(awsconfig.LoadDefaultConfig(ctx))`, `GetCostAndUsage` with `Granularity: MONTHLY`, `Metrics: ["UnblendedCost"]`, `GroupBy: [{Type: DIMENSION, Key: SERVICE}]`, `TimePeriod: {Start: start.Format("2006-01-02"), End: end.Format("2006-01-02")}`; sum group amounts into the map.
+ - `POST /fleet/economics/scenarios` — form: `csrf`, `name`, `scenario` (JSON), optional `activate=1`. Gate (session→operator→404) THEN CSRF. Decode into `economics.Scenario`, force `sc.Name = name`, `Validate()`, CAS-write `ScenarioKey(name)` (read-etag-then-put like `applyAt`, `internal/appconfig/store.go:162-190`); on `activate`, CAS-write `ActiveKey`. Flash + redirect to `/fleet/economics` (the `back` closure pattern, `fleets.go:1204-1240`).
+ - `POST /fleet/economics/bill` — form: `csrf`, `month` (`2006-01`), `total` (float), `lines` (textarea, one `Service=123.45` per line). Same gate order. Writes `economics.Bill{Source: SourceManual}` — manual always overwrites.
+ - `POST /api/economics/bill` — bearer door for the CLI: `s.cliUser` → `IsOperator` → non-operator gets `http.NotFound` (byte-identical with unknown API routes). JSON body `{"month": "2026-08", "total_usd": 123.45, "services": {"AmazonEC2": 55.0}}`, same manual-write semantics, replies 200 with the stored bill JSON.
+
+- [ ] **Step 1: Write failing tests** — in `economicsbill_test.go`:
+ - `TestBillPassPullsAndManualWins`: fixture; `fx.srv.ceCosts` fake returning `{"AmazonEC2": 55.5}`; `billPass` writes both months with `Source == "costexplorer"` and correct totals; then write a manual bill for the current month via `s.cfg.Releases.Put` and re-run `billPass` — the manual object survives byte-identical.
+ - `TestBillPassNoCEIsQuiet`: `ceCosts` returns an error → `billPass` returns nil (logged, not fatal), nothing written.
+ - `TestScenarioSaveGateAndValidation`: signed-in non-operator POST → 404 byte-identical with `newConsoleFixture`'s ghost-route 404 (copy `TestFleetOperatorOnly`'s comparison shape, `server_test.go:1531-1570`); operator + bad JSON → 4xx flash, nothing written; operator + valid scenario + `activate=1` → both objects present and `Active.Name` correct.
+ - `TestAPIBillDoor`: find how existing `/api` tests mint a bearer (grep `Bearer ` in `internal/console/*_test.go` and copy that fixture helper); non-operator token → 404; operator token → 200 and bill stored.
+- [ ] **Step 2: Run** `go test ./internal/console/ -run 'BillPass|ScenarioSave|APIBill' -count=1` — expect FAIL.
+- [ ] **Step 3: Add the dependency** — needs network, run OUTSIDE the sandbox without `GOPROXY=off`:
+
+```bash
+GOFLAGS=-mod=mod go get github.com/aws/aws-sdk-go-v2/service/costexplorer@latest && GOFLAGS=-mod=mod go mod tidy
+```
+
+- [ ] **Step 4: Implement** per the interface block. Route registrations next to the fleet pair (`server.go:355-356`):
+
+```go
+ s.mux.HandleFunc("POST /fleet/economics/scenarios", s.handleEconomicsScenarioSave)
+ s.mux.HandleFunc("POST /fleet/economics/bill", s.handleEconomicsBillSave)
+ s.mux.HandleFunc("POST /api/economics/bill", s.handleAPIEconomicsBill)
+```
+
+- [ ] **Step 5: Run** `go test ./internal/console/ -count=1 && go build ./... && GOOS=darwin go build ./...` — expect PASS (darwin compile guards the new SDK dep).
+- [ ] **Step 6: Commit** — `git add go.mod go.sum internal/console/ && git commit -m "console: CE bill pull with manual override, scenario/bill write doors"`
+
+---
+
+### Task 13: `/fleet/economics` page
+
+**Files:**
+- Create: `internal/console/templates/economics.html`
+- Modify: `internal/console/server.go` (route + handler + `page` struct field `Economics *EconomicsView`)
+- Test: `internal/console/economicspage_test.go`
+
+**Interfaces:**
+- Consumes: `LoadEconomics` (Task 11), gate + render patterns, `page` struct, `{{icon "..."}}` (existing vendored set only — add none unless one is genuinely needed, then vendor per `icons.go` rules).
+- Produces: `GET /fleet/economics` (`handleEconomics`), template name `economics`.
+
+**Page structure** (all server-rendered, one inline `<script>`; NO external subresources — `TestConsoleTemplatesFetchNothingOffOrigin` will fail the build otherwise):
+1. Headline tiles: would-be revenue, cost, profit, achieved margin (period totals from `EconomicsView.Accounts` + idle + overheads). Every total carries the coverage label: `{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered`.
+2. Per-box table: box, hours, cost, utilization bar (CSS width, no JS), idle line, attributed revenue, profit. Egress column shows `unmetered` when `!EgressMetered`.
+3. Per-service breakdown vs bill: model side = EC2 (box hours), EBS, S3 storage, S3 requests, egress, overheads (computed from rate card × usage); bill side = the month's `Bill.Services` with per-line "counts as CARLOS" checkboxes (state saved into the scenario JSON as `bill_services` — add `BillServices []string \`json:"bill_services,omitempty"\`` to `economics.Scenario` in this task, additive); delta line at the bottom. If no bill: the manual-entry form (month/total/lines + csrf).
+4. Knob drawer (`<details>`): every rate-card number, allocation policy radio + blend weight, mode radio, margin slider (with the live `×N multiple` label — margin 0.8 must display "5.0× cost"), rate sheet inputs, free-tier inputs. Save-scenario form (name + activate checkbox) POSTs the serialized JSON.
+5. Period selector: three links (`?period=mtd|last|7d`).
+
+**The inline JS** (single `<script>` at the end of the template): reads the embedded payload from `<script type="application/json" id="econ-data">{{.Economics.DataJSON}}</script>`, binds every knob input, and on change recomputes tiles, box table and breakdown IN PLACE using functions that transliterate `AllocateBox`/`PriceAccount`/`Project` from `internal/economics/compute.go` — keep the function and variable names aligned with the Go so drift is reviewable. Progressive enhancement rule (layout.html precedent): with JS off the page still shows the server-rendered numbers and the knob drawer degrades to the save-scenario form round-trip.
+
+- [ ] **Step 1: Write failing tests** — `economicspage_test.go`:
+ - Gate parity: copy `TestFleetOperatorOnly`'s shape for `/fleet/economics` (ghost-404 byte-compare, grant bit, 200, revoke, 404 again).
+ - Content: seed golden objects (reuse Task 11's seeding helper), fetch as operator, assert the body contains the coverage label (`"days metered"`), the box label, an account name, and `id="econ-data"`.
+ - Honesty: with a box in metrics but no `usage/edge` day, assert the body contains `unmetered`.
+- [ ] **Step 2: Run** `go test ./internal/console/ -run EconomicsPage -count=1` — expect FAIL.
+- [ ] **Step 3: Implement** — handler is `handleFleetMetrics` verbatim with `LoadEconomics(r.Context(), r.URL.Query().Get("period"))` and `s.render(w, http.StatusOK, "economics", p)`; register `GET /fleet/economics` next to the fleet routes. Template per the structure above; match the console's existing table/tile markup classes (read `metrics.html` and `fleet.html` first and reuse their classes — no new stylesheet).
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — expect PASS including the off-origin subresource test.
+- [ ] **Step 5: Commit** — `git add internal/console/ && git commit -m "console: /fleet/economics dashboard with live scenario knobs"`
+
+---
+
+### Task 14: `/fleet/economics/accounts` page
+
+**Files:**
+- Create: `internal/console/templates/economics_accounts.html`
+- Create: `internal/console/econlistquery.go`
+- Modify: `internal/console/server.go` (route + handler + page field `EconAccounts econAccountsPage`)
+- Test: `internal/console/econlistquery_test.go` (+ gate test appended to `economicspage_test.go`)
+
+**Interfaces:**
+- Consumes: `LoadEconomics` (rows priced under the active scenario), `listquery.go`'s shapes as the model (`instanceQuery`/`pageInstanceRows`/cursor rules, `listquery.go` whole file).
+- Produces: `GET /fleet/economics/accounts` (`handleEconomicsAccounts`); `econAccountQuery{Q, Pos, Cursor string}`, `pageEconAccounts(rows []econAccountRow, q econAccountQuery, limit int) econAccountsPage` with `econAccountRow{Account string; Meters map[string]float64; Econ economics.AccountEconomics}`; page size 50; cursor = base64 RawURL JSON `{"account": "..."}`, decode-failure = no cursor (the `listquery.go:68-78` rule); `Pos` filters on `FreeTierPos` (`inside|partial|paying`), with per-position count links like `healthLink`.
+
+- [ ] **Step 1: Write failing tests** — `econlistquery_test.go`, pure like `pageInstanceRows`'s own tests (grep for them and mirror): 120 synthetic rows; assert page 1 = 50 rows + NextCursor; page 2 resumes after the cursor; `Q` substring-filters account names; `Pos: "inside"` filters; counts map totals per position; garbage cursor = page 1. Gate test: `/fleet/economics/accounts` 404-parity clone.
+- [ ] **Step 2: Run** `go test ./internal/console/ -run EconAccounts -count=1` — expect FAIL.
+- [ ] **Step 3: Implement** — sort rows by account name for a stable cursor order (cursor = last account name, resume strictly-greater — same rule `pageInstanceRows` applies to Host). Template columns: account, apps, instances, CPU-h, GB-h, egress GB, storage GB-month, cost, would-be revenue, margin, free-tier chip. Search box + position filter links reuse `app_instances.html`'s form markup (read it first). The page shares the `econ-data` embed + knob JS include pattern from Task 13 so knob changes recompute rows here too.
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` — expect PASS.
+- [ ] **Step 5: Commit** — `git add internal/console/ && git commit -m "console: per-account economics table with search and cursor pagination"`
+
+---
+
+### Task 15: `carlos economics bill` CLI verb
+
+**Files:**
+- Create: `economics.go` (repo root, package main)
+- Create: `economics_test.go`
+- Modify: `main.go` (dispatch case + the `usage()` command list)
+
+**Interfaces:**
+- Consumes: `apiSession` machinery (`apimode.go`): `resolveMode(consoleFlag)`, `(s *apiSession).postJSON(ctx, path, in)` (`apimode.go:291`), `apiErrorFromBody`.
+- Produces: `carlos economics bill -month 2026-08 -total 123.45 [-line AmazonEC2=55.00 -line AmazonS3=12.00 ...] [-console origin]` → `POST /api/economics/bill`; prints the stored bill one service per line. `runEconomics(args)` dispatching the `bill` sub-verb (the `carlos version target` judgment call: a word that names a different action).
+
+- [ ] **Step 1: Write the failing test** — `economics_test.go`, pure parsing first (mirror how sibling verb tests structure flag parsing — read `restart_test.go` for the pattern): a repeated `-line` flag collector (`flag.Var` with a `map[string]float64` value type) parses `AmazonEC2=55.00`, rejects `NoEquals` and `X=notanumber`; month regexp `^\d{4}-\d{2}$` rejects `2026-8`.
+- [ ] **Step 2: Run** `go test . -run Economics -count=1` — expect FAIL.
+- [ ] **Step 3: Implement** — `runEconomics` with `flag.NewFlagSet("economics bill", flag.ExitOnError)`, usage string in the house style (read `instancesEnableUsage`, `instances_cli.go:29`, and match its shape: what the verb does, each flag, one paragraph of context). Signed-in mode only (no store mode — the bill is a console object): `resolveMode` then `postJSON(ctx, "/api/economics/bill", body)`; non-2xx → `apiErrorFromBody`. Add `case "economics": runEconomics(args)` in `main.go` and one line in `usage()`'s deployment-administration group: `economics record the month's AWS bill for the economics dashboard`.
+- [ ] **Step 4: Run** `go test . -count=1 && go build ./...` — expect PASS.
+- [ ] **Step 5: Commit** — `git add economics.go economics_test.go main.go && git commit -m "cli: carlos economics bill records the month's AWS bill"`
+
+---
+
+### Task 16: Cost Explorer IAM (tofu), docs, final gate
+
+**Files:**
+- Modify: `infra/modules/region-host/main.tf` (+ `variables.tf`)
+- Modify: `docs/superpowers/specs/2026-08-11-unit-economics-design.md` (rollout section: mark IAM step with the exact tofu knob name)
+- Test: none (tofu is operator-applied; `tofu validate` locally if available, else review-only)
+
+- [ ] **Step 1: Add the guarded policy** in `infra/modules/region-host/main.tf`, after `host_route53` (~line 143), following `host_route53`'s `count` guard pattern:
+
+```hcl
+# Cost Explorer read for the economics dashboard's daily bill pull
+# (internal/console/cepull.go). Off by default: only the box that runs the
+# console needs it, and ce:GetCostAndUsage has no resource-level scoping —
+# it reads the whole account's cost lines, so the grant is deliberate.
+resource "aws_iam_role_policy" "host_costexplorer" {
+ count = var.enable_cost_explorer ? 1 : 0
+ name = "${var.iam_name}-costexplorer"
+ role = aws_iam_role.host.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["ce:GetCostAndUsage"]
+ Resource = "*"
+ },
+ ]
+ })
+}
+```
+
+And in `variables.tf` (match the file's existing variable style):
+
+```hcl
+variable "enable_cost_explorer" {
+ description = "Grant ce:GetCostAndUsage so a console on this box can pull the monthly bill for the economics dashboard"
+ type = bool
+ default = false
+}
+```
+
+- [ ] **Step 2: Update the spec's rollout note** to name the knob: `enable_cost_explorer = true` on the console box's `region-host` module block — pinfra (flagship) and `infra/tito` (tito), both operator-queue applies.
+- [ ] **Step 3: Full gate**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1
+gofmt -l $(git diff --name-only origin/main | grep '\.go$')
+GOOS=darwin go build ./...
+```
+
+Expected: all clean, gofmt prints nothing.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add infra/ docs/
+git commit -m "infra: optional ce:GetCostAndUsage grant for the console box"
+```
+
+- [ ] **Step 5: Branch finish** — use superpowers:finishing-a-development-branch (PR to main). PR body must flag the two operator-queue items: the tofu apply (both deployments) and the deploy order (boxes first for edge metering, consoles after; data accrues from the moment each side lands — dashboard shows honest partial coverage meanwhile).
+
+---
+
+## Deploy / follow-ups (not tasks — recorded for the PR body and memory)
+
+- Deploy: ship new `carlos` binary to boxes (edge metering + usage loop), then consoles (`cmd/console` build — NEVER the root binary, console-app-artifact rule); console restart per the stamped-route auto-cycle or manual rule.
+- Operator queue: `enable_cost_explorer = true` + apply in pinfra (flagship) and `infra/tito` (tito — mind the DRIFT WARNING: targeted apply only until PRs #2/#3/#7/#12 reconcile).
+- Website batch (standing rule): feature card + docs once live.
+- Filed follow-ups from the spec: read-side `carlos economics` summary verb; cost-allocation tagging; tunnel/non-edge metering.
+
+## Self-review checklist (run after writing, fixed inline)
+
+- Spec coverage: collection (Tasks 1-2-5-6-9), rate card/scenarios (3), maths+margin/free-tier rulings (4), sized listings (7), census (8), statements/vocabulary/billing-readiness (2-10), assembly+cache+honesty (11), CE pull+manual override+doors (12), dashboard+knobs+reconcile UI (13), accounts page+pagination (14), CLI verb (15), IAM+rollout (16). Idle-not-smeared: Task 4. 404-parity: 12-13-14 tests. Statements-page cheapness: statements exist (Task 10) though the page recomputes from the sweep cache — acceptable, statements remain billing's durable input.
+- Known intentional deviation from spec: no separate hourly edge chunks (Global Constraints, justified); `bill_services` knob added to Scenario in Task 13 (additive, schema 1 keeps parsing).
+- Type consistency: `usage.EdgeRow` field names checked across Tasks 1/5/6/10; `economics.Scenario` across 3/4/11/12/13; `ceCosts` seam across 12; `fixtureAcct/fixtureApp` are per-test consts the implementer resolves from `buildFixtures` (flagged in Tasks 9-10).
+- Open verification points delegated to implementers (flagged inline where they bite): the `release` not-found sentinel (Tasks 1-2), `ListDirs` return shape (Task 9), systemd unit-name unescaping (Task 10), bearer-test fixture helper (Task 12).