docs: unit-economics implementation plan (16 TDD tasks)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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). |