pricing: implementation plan for invoice-grade statements
The first of four plans decomposing the pricing spec. This one is build steps 1-3, the console-side metering prerequisites, and it ships working software on its own: it makes the cost dashboard correct whether or not anyone ever pays. Three tasks. Coverage is counted per collection source rather than by the any-source flag, so a statement can no longer report a full month while missing every day of egress. Unit ownership moves into the daily census, which already exists so existence-metering has history the live registry cannot give it, and the sweep asks the day's census instead of today's registry. And statementsPass gains a previous-month period on billPass's pattern, closing the month past the same three-day grace so it stops moving under a bill. The self-review records one piece of step 3 deliberately deferred: the box-side durable usage writes belong with the two new collectors in the meters plan, and until they land MeterComplete is necessary but not sufficient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+756
−0
docs/superpowers/plans/2026-09-08-invoice-grade-statements.md+756 −0
diff --git a/docs/superpowers/plans/2026-09-08-invoice-grade-statements.md b/docs/superpowers/plans/2026-09-08-invoice-grade-statements.md| new file mode 100644 |
| index 000000000..8741ecb22 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-08-invoice-grade-statements.md |
| @@ -0,0 +1,756 @@ |
| +# Invoice-grade statements — Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Make a monthly account statement safe to bill from — a closed month that stops changing, ownership resolved as of the day the usage happened, and per-source completeness a reader can check instead of a single any-source flag. |
| + |
| +**Architecture:** Three independent pieces, all in the existing hourly economics pass. (1) The sweep records which *collection source* produced an object on each day, not just whether any did, and the statement carries those counts so a caller can ask whether a specific meter's month is complete. (2) The daily census — already a create-only snapshot that exists precisely so metering has history the live registry cannot give it — gains a unit→owner map, and the sweep resolves each day's units against that day's census instead of today's registry. (3) `statementsPass` gains a previous-month period on `billPass`'s pattern, and a statement closes after a grace period so it stops moving under a bill. |
| + |
| +**Tech Stack:** Go, no new dependencies. Deployment-bucket JSON objects via `release.Store`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-07-carloku-pricing-plans-design.md` — this plan is build steps 1–3 of that spec's "Needs building" list, the first three of six prerequisites that gate raising any invoice. |
| + |
| +## Global Constraints |
| + |
| +- **Wire shapes are additive.** A `usage/accounts/<acct>/<month>.json` or `usage/census/<date>.json` written before this change must still parse afterwards, and one written after must still parse on an older binary. Never remove or retype an existing field; new fields are added with `omitempty`. (AGENTS.md, "Rules this repo has paid for".) |
| +- **A missing source is never a zero.** Package `usage`'s standing posture, stated in `usageSweep`'s doc comment (`internal/console/economicspass.go:241-259`): a day with no object contributes nothing and is counted uncovered, rather than a zero that reads as "confirmed none". Every field this plan adds follows it — absent means unknown, never "complete". |
| +- **Each pass stage degrades independently.** `economicsPass` (`internal/console/economicspass.go:58-77`) logs each stage's error and returns nil. Do not change that; a failure in one stage must not stop the others. |
| +- **Build gate before any commit lands:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean tree-wide. `make test` covers the last three. |
| +- **Sandbox note (AGENTS.md:167):** run Go commands with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go may need the sandbox disabled, and "read-only file system" from the toolchain is the tell. |
| +- **CI is the `amadan` remote, not GitHub.** A branch pushed only to `origin` has been gated by nothing. Push to `amadan` and read the run before calling it green. |
| +- **Nothing in this plan bills anyone, talks to Stripe, or reads a plan definition.** Those are later plans. This one makes the numbers trustworthy; it does not act on them. |
| +- **No new `carlos` command**, so the `docs/site/cli.md` docs gate does not apply to this plan. |
| + |
| +--- |
| + |
| +### Task 1: Per-source day coverage |
| + |
| +`CoveredDays` counts a day as covered when *any* of metrics, edge or storage produced an object (`internal/console/economicspass.go:356`, `:403`, `:429`, accumulated at `:433-435`). So a storage sweep that ran on a day when edge collection failed still marks that day covered, and a statement reporting full coverage can be missing an entire day of egress. That is fine for modelling and wrong for billing. |
| + |
| +This task records coverage per source and lets a caller ask about one meter. |
| + |
| +**Files:** |
| +- Modify: `internal/usage/usage.go` (append after the meter block ending at :42) |
| +- Modify: `internal/usage/statement.go:19-27` (the `Statement` struct) |
| +- Modify: `internal/console/economicspass.go` (`usageSweep` at :241-259, the day loop at :343-437, `statementsPass` write at :800-820) |
| +- Test: `internal/usage/passes_test.go`, `internal/console/economicspass_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from earlier tasks. |
| +- Produces: |
| + - `usage.SourceMetrics`, `usage.SourceEdge`, `usage.SourceStorage`, `usage.SourceCensus` — string constants. |
| + - `func usage.MeterSource(meter string) string` — the source a meter derives from, `""` if none. |
| + - `usage.Statement.SourceDays map[string]int` — source → days in the month that produced an object. |
| + - `func (st Statement) MeterComplete(meter string) bool` — Task 3 and the later billing plan consume this. |
| + - `usageSweep.SourceDays map[string]int` (unexported type, same package as Task 2 and 3's changes). |
| + |
| +- [ ] **Step 1: Write the failing test for `MeterSource` and `MeterComplete`** |
| + |
| +In `internal/usage/passes_test.go`, append: |
| + |
| +```go |
| +func TestMeterSource(t *testing.T) { |
| + cases := map[string]string{ |
| + MeterCPUCoreHours: SourceMetrics, |
| + MeterMemGBHours: SourceMetrics, |
| + MeterInstanceHours: SourceMetrics, |
| + MeterFleetBoxHoursServer: SourceMetrics, |
| + MeterFleetBoxHoursDevice: SourceMetrics, |
| + MeterEgressGB: SourceEdge, |
| + MeterRequests1k: SourceEdge, |
| + MeterBackhaulGB: SourceEdge, |
| + MeterBackhaulRequests1k: SourceEdge, |
| + MeterStorageGBMonth: SourceStorage, |
| + MeterAppMonths: SourceCensus, |
| + "not_a_meter": "", |
| + } |
| + for meter, want := range cases { |
| + if got := MeterSource(meter); got != want { |
| + t.Errorf("MeterSource(%q) = %q, want %q", meter, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestMeterCompleteNeedsEveryDay(t *testing.T) { |
| + full := Statement{ |
| + DaysInMonth: 31, |
| + SourceDays: map[string]int{SourceMetrics: 31, SourceEdge: 30}, |
| + } |
| + if !full.MeterComplete(MeterInstanceHours) { |
| + t.Error("instance_hours with 31/31 metrics days should be complete") |
| + } |
| + if full.MeterComplete(MeterEgressGB) { |
| + t.Error("egress_gb with 30/31 edge days must NOT be complete") |
| + } |
| + if full.MeterComplete("not_a_meter") { |
| + t.Error("a meter with no source is never complete") |
| + } |
| + // A statement written before this change has no SourceDays at all. |
| + // Unknown must read as incomplete, never as complete. |
| + old := Statement{DaysInMonth: 31, CoveredDays: 31} |
| + if old.MeterComplete(MeterEgressGB) { |
| + t.Error("a statement with no SourceDays must not report complete") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/usage/ -run 'TestMeterSource|TestMeterComplete' -count=1 -v` |
| + |
| +Expected: FAIL to compile — `undefined: SourceMetrics`, `undefined: MeterSource`, `st.SourceDays undefined`. |
| + |
| +- [ ] **Step 3: Add the sources and `MeterSource`** |
| + |
| +In `internal/usage/usage.go`, after the meter const block that ends at line 42: |
| + |
| +```go |
| +// Collection sources — the pass-visible daily object a meter's value is |
| +// derived from. Billing needs to know that a specific meter saw every day |
| +// of a month, which CoveredDays (any source, any box) cannot answer. |
| +const ( |
| + SourceMetrics = "metrics" // metrics/<box>/days/<date>.json |
| + SourceEdge = "edge" // usage/edge/<box>/days/<date>.json |
| + SourceStorage = "storage" // usage/storage/<date>.json |
| + SourceCensus = "census" // usage/census/<date>.json |
| +) |
| + |
| +// MeterSource names the collection source a meter derives from, or "" for a |
| +// meter no daily object produces. Fleet box-hours are derived from the same |
| +// metrics rollups as everything else in SourceMetrics. |
| +func MeterSource(meter string) string { |
| + switch meter { |
| + case MeterCPUCoreHours, MeterMemGBHours, MeterInstanceHours, |
| + MeterFleetBoxHoursServer, MeterFleetBoxHoursDevice: |
| + return SourceMetrics |
| + case MeterEgressGB, MeterRequests1k, MeterBackhaulGB, MeterBackhaulRequests1k: |
| + return SourceEdge |
| + case MeterStorageGBMonth: |
| + return SourceStorage |
| + case MeterAppMonths: |
| + return SourceCensus |
| + } |
| + return "" |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Add `SourceDays` and `MeterComplete` to the statement** |
| + |
| +In `internal/usage/statement.go`, add one field to `Statement` after `CoveredDays` (line 24), keeping every existing field and tag untouched: |
| + |
| +```go |
| + // SourceDays counts, per collection source, how many UTC days of the |
| + // month produced that source's object. CoveredDays answers "did any |
| + // source report", which is the modelling question; this answers "did |
| + // THIS meter's source report every day", which is the billing one. |
| + // Absent (a statement written before this field existed) reads as |
| + // unknown, never as complete. |
| + SourceDays map[string]int `json:"source_days,omitempty"` |
| +``` |
| + |
| +Then append to the file: |
| + |
| +```go |
| +// MeterComplete reports whether every day of the statement's month produced |
| +// the source this meter derives from. False when the source is unknown, when |
| +// the statement predates SourceDays, or when any day is missing — the |
| +// package's "a missing source is never a zero" posture, applied to billing. |
| +func (st Statement) MeterComplete(meter string) bool { |
| + src := MeterSource(meter) |
| + if src == "" || st.DaysInMonth <= 0 || st.SourceDays == nil { |
| + return false |
| + } |
| + return st.SourceDays[src] >= st.DaysInMonth |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/usage/ -run 'TestMeterSource|TestMeterComplete' -count=1 -v` |
| + |
| +Expected: PASS, both tests. |
| + |
| +- [ ] **Step 6: Write the failing pass test for per-source counting** |
| + |
| +In `internal/console/economicspass_test.go`, append. This follows `TestStatementsPassGolden` at `:288` — same fixture, same seeding helpers. |
| + |
| +```go |
| +// A day with only an edge object must count for edge and NOT for metrics, |
| +// so a statement can tell "we saw egress every day" from "we saw something |
| +// every day". |
| +func TestStatementsPassCountsSourcesSeparately(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 3, 6, 0, 0, 0, time.UTC) |
| + |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "a.example.com", Kind: "instance", Status: "ready", |
| + App: fixtureApp, Version: "v1", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg.Close() |
| + |
| + // 1 Aug: metrics only. 2 Aug: edge only. 3 Aug: neither. |
| + putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-01"), metrics.DayRollup{}) |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-08-02"), usage.EdgeDay{}) |
| + |
| + 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) |
| + } |
| + if got := st.SourceDays[usage.SourceMetrics]; got != 1 { |
| + t.Errorf("metrics source days = %d, want 1", got) |
| + } |
| + if got := st.SourceDays[usage.SourceEdge]; got != 1 { |
| + t.Errorf("edge source days = %d, want 1", got) |
| + } |
| + if st.CoveredDays != 2 { |
| + t.Errorf("CoveredDays = %d, want 2 (either source counts)", st.CoveredDays) |
| + } |
| + if st.MeterComplete(usage.MeterEgressGB) { |
| + t.Error("egress must not be complete with 1 of 3 edge days") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 7: Run it to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp go test ./internal/console/ -run TestStatementsPassCountsSourcesSeparately -count=1 -v` |
| + |
| +Expected: FAIL — `st.SourceDays` is nil, so the metrics assertion reports 0. |
| + |
| +- [ ] **Step 8: Carry per-source counts through the sweep** |
| + |
| +In `internal/console/economicspass.go`, add one field to `usageSweep` (after `CoveredDays, PeriodDays int` at :255): |
| + |
| +```go |
| + // SourceDays counts, per usage.Source*, the days in [from, to] that |
| + // produced that source's object on any box. CoveredDays is the OR of |
| + // these; this is the per-source detail billing needs. |
| + SourceDays map[string]int |
| +``` |
| + |
| +Initialise it in the `sweep := &usageSweep{...}` literal at :308: |
| + |
| +```go |
| + SourceDays: map[string]int{}, |
| +``` |
| + |
| +In the day loop, replace line 344 (`covered := false`) with: |
| + |
| +```go |
| + covered := false |
| + srcSeen := map[string]bool{} |
| +``` |
| + |
| +Add a `srcSeen` line beside each of the three existing `covered = true` statements: |
| + |
| +- at `:356` (metrics arm, after `covered = true`): `srcSeen[usage.SourceMetrics] = true` |
| +- at `:403` (edge arm, after `covered = true`): `srcSeen[usage.SourceEdge] = true` |
| +- at `:429` (storage arm, inside the `jerr == nil` branch, after `covered = true`): `srcSeen[usage.SourceStorage] = true` |
| + |
| +Then add a census arm immediately before the `if covered {` block at :433, so `app_months` has a completeness signal too: |
| + |
| +```go |
| + if _, _, gerr := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); gerr == nil { |
| + srcSeen[usage.SourceCensus] = true |
| + covered = true |
| + } |
| + |
| + for src := range srcSeen { |
| + sweep.SourceDays[src]++ |
| + } |
| +``` |
| + |
| +- [ ] **Step 9: Write `SourceDays` onto the statement** |
| + |
| +In `statementsPass`, alongside `coveredDays := sweep.CoveredDays` at :776: |
| + |
| +```go |
| + sourceDays := sweep.SourceDays |
| +``` |
| + |
| +and add the field to the `usage.Statement{...}` literal in the write loop at :800-820: |
| + |
| +```go |
| + SourceDays: sourceDays, |
| +``` |
| + |
| +- [ ] **Step 10: Run the console tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp go test ./internal/console/ -run TestStatementsPass -count=1 -v` |
| + |
| +Expected: PASS, including the pre-existing `TestStatementsPassGolden` — this change adds a field and must not move any existing number. |
| + |
| +- [ ] **Step 11: Run the whole gate** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp make test` |
| + |
| +Expected: vet clean, gofmt clean, all packages pass. |
| + |
| +- [ ] **Step 12: Commit** |
| + |
| +```bash |
| +git add internal/usage/usage.go internal/usage/statement.go internal/usage/passes_test.go internal/console/economicspass.go internal/console/economicspass_test.go |
| +git commit -m "usage: count statement coverage per source, not per day |
| + |
| +CoveredDays marks a day covered when any of metrics, edge or storage |
| +produced an object, so a statement can report full coverage while an |
| +entire day of egress is missing. Fine for modelling, wrong for billing. |
| + |
| +Statements now carry SourceDays, and MeterComplete answers whether one |
| +meter's source reported every day of the month. A statement written |
| +before this field reads as unknown rather than complete." |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Ownership resolved as of the usage day |
| + |
| +`unitOwner` resolves a unit against the **live** registry (`internal/console/economicspass.go:211`, called from the metrics arm at `:361`), and the sweep re-attributes every day of the month on every hourly pass. Retired routes are hard-deleted, not tombstoned (`internal/provision/reconcile.go:1005`). So an account that deletes an app mid-month stops being billed for hours it already used: on the next pass its units match nothing and fall to `release.OpsAccount`. |
| + |
| +The fix follows a pattern this repo already has. `CensusDay` exists, in its own words, "so existence-metering has history even though the registry holds only live state" (`internal/usage/census.go:9-12`). It gains unit ownership, and the sweep asks the day's census who owned a unit that day. |
| + |
| +**Files:** |
| +- Modify: `internal/usage/census.go` (the `CensusDay` struct at :13-18) |
| +- Modify: `internal/console/economicspass.go` (`censusPass`, and `sweepUsage`'s metrics arm at :361) |
| +- Test: `internal/console/economicspass_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from Task 1. |
| +- Produces: |
| + - `usage.CensusUnit{Account, App string}` |
| + - `usage.CensusDay.Units map[string]CensusUnit` |
| + - `func (s *Server) dayOwner(ctx context.Context, date, unit string, routes []registry.Route) (acct, app string)` — unexported, used by `sweepUsage`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `internal/console/economicspass_test.go`, append: |
| + |
| +```go |
| +// Usage already recorded against an app must keep its owner after the route |
| +// is deleted. Before this, the next pass reattributed those hours to ops. |
| +func TestSweepKeepsOwnerAfterRouteDeleted(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + // Day 1: the route exists, and the census records who owns its unit. |
| + putStoreJSON(t, fx.rel, usage.CensusDayKey("2026-08-01"), usage.CensusDay{ |
| + V: 1, Date: "2026-08-01", |
| + Accounts: map[string]usage.CensusAccount{fixtureAcct: {Apps: 1, Routes: 1}}, |
| + Units: map[string]usage.CensusUnit{ |
| + "carlos-app@a.example.com": {Account: fixtureAcct, App: fixtureApp}, |
| + }, |
| + }) |
| + putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-01"), metrics.DayRollup{ |
| + Hours: []metrics.HourRollup{{ |
| + Units: map[string]map[string]metrics.Stat{ |
| + "carlos-app@a.example.com": {"cpu": {Avg: 0.5, N: 60}}, |
| + }, |
| + }}, |
| + }) |
| + |
| + // Day 2: the route is gone from the registry entirely — no Upsert here. |
| + now := time.Date(2026, 8, 2, 6, 0, 0, 0, time.UTC) |
| + 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.Fatalf("statement for %s missing: %v", fixtureAcct, err) |
| + } |
| + var st usage.Statement |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := st.Meters[usage.MeterCPUCoreHours]; !approxF(got, 0.5) { |
| + t.Errorf("cpu_core_hours = %v, want 0.5 — the deleted route's day must stay attributed", got) |
| + } |
| +} |
| +``` |
| + |
| +Note on the fixture: `fixtureAcct` is `release.OpsAccount` (`economicspass_test.go:41-44`), which is also `unitOwner`'s fallback. If that made the test pass either way, change the census entry and the assertion to a non-ops account name and assert the ops statement does **not** carry the hours. |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp go test ./internal/console/ -run TestSweepKeepsOwnerAfterRouteDeleted -count=1 -v` |
| + |
| +Expected: FAIL to compile — `unknown field Units in struct literal of type usage.CensusDay`. |
| + |
| +- [ ] **Step 3: Add `Units` to the census** |
| + |
| +In `internal/usage/census.go`, add above `CensusDay`: |
| + |
| +```go |
| +// CensusUnit is the owner of one systemd unit on one day. Recorded because |
| +// the registry holds only live state: a route deleted today would otherwise |
| +// take this month's already-recorded usage with it. |
| +type CensusUnit struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| +} |
| +``` |
| + |
| +and one field to `CensusDay`, after `Accounts`: |
| + |
| +```go |
| + // Units maps every unit name a route answers to — its own Unit plus the |
| + // activator's derived names — to that route's owner, as of the moment |
| + // this day's census was written. Absent on days written before this |
| + // field existed; callers fall back to the live registry for those. |
| + Units map[string]CensusUnit `json:"units,omitempty"` |
| +``` |
| + |
| +- [ ] **Step 4: Populate it in `censusPass`** |
| + |
| +In `internal/console/economicspass.go`, inside `censusPass`'s route loop, alongside the existing per-account counting, record every name `routeForUnit` (`:224`) would match: |
| + |
| +```go |
| + for _, name := range []string{rt.Unit, activator.UnitName(rt.Host), activator.ExecName(rt.Host)} { |
| + if name == "" { |
| + continue |
| + } |
| + if units == nil { |
| + units = map[string]usage.CensusUnit{} |
| + } |
| + units[name] = usage.CensusUnit{Account: acct, App: rt.App} |
| + } |
| +``` |
| + |
| +Declare `var units map[string]usage.CensusUnit` beside the existing `accounts` and `apps` maps, and add `Units: units` to the `usage.CensusDay{...}` literal. |
| + |
| +If `internal/console` does not already import `activator`, add it. Verify with: |
| + |
| +Run: `grep -n 'internal/activator' internal/console/economicspass.go` |
| + |
| +- [ ] **Step 5: Resolve ownership per day in the sweep** |
| + |
| +Add this method to `internal/console/economicspass.go`, next to `unitOwner` (`:211`). It reads the day's census once and returns a lookup, matching the shape of `sweepUsage`'s existing `touch` and `boxLoad` closures (`:322-338`). |
| + |
| +```go |
| +// dayOwnerFunc returns a lookup answering who owned a unit on one UTC day, |
| +// from that day's census, reading the census object once per day rather than |
| +// once per unit. |
| +// |
| +// It falls back to the live registry when the day has no census or no Units |
| +// map — days written before Units existed, and the current day before its |
| +// census is written. That fallback IS the old behaviour, so this is strictly |
| +// an improvement rather than a new failure mode. |
| +// |
| +// Granularity is one day: the census is create-only and written by the first |
| +// pass of each day, so a route created or deleted later that day carries its |
| +// first-pass owner for the whole day. That is the honest bound, and it is |
| +// what lets a closed month stop moving. |
| +func (s *Server) dayOwnerFunc(ctx context.Context, date string, routes []registry.Route) func(string) (string, string) { |
| + var cd usage.CensusDay |
| + haveUnits := false |
| + if raw, _, err := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); err == nil { |
| + if json.Unmarshal(raw, &cd) == nil && cd.Units != nil { |
| + haveUnits = true |
| + } |
| + } |
| + return func(unit string) (string, string) { |
| + if haveUnits { |
| + if cu, ok := cd.Units[unit]; ok { |
| + return cu.Account, cu.App |
| + } |
| + // The census exists and knows its units, so a unit absent from |
| + // it genuinely had no route that day. |
| + return release.OpsAccount, "" |
| + } |
| + acct, app, _ := unitOwner(routes, unit) |
| + return acct, app |
| + } |
| +} |
| +``` |
| + |
| +Immediately after `date := usage.DayOf(d)` in the day loop (`:343`), add: |
| + |
| +```go |
| + owner := s.dayOwnerFunc(ctx, date, routes) |
| +``` |
| + |
| +Then replace the call at `:361`: |
| + |
| +```go |
| + acct, _ := owner(unit) |
| +``` |
| + |
| +Leave `unitOwner` in place — it is still the fallback, and `foldStorageAndCensus` and `fleetMeters` may call it too. Check before assuming it is now unused: |
| + |
| +Run: `grep -n 'unitOwner(' internal/console/*.go` |
| + |
| +- [ ] **Step 6: Run the test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp go test ./internal/console/ -run 'TestSweep|TestStatementsPass|TestCensus' -count=1 -v` |
| + |
| +Expected: PASS, including every pre-existing pass test. Days with no census still resolve through `unitOwner`, so the goldens must not move. |
| + |
| +- [ ] **Step 7: Run the whole gate** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp make test` |
| + |
| +Expected: all packages pass. |
| + |
| +- [ ] **Step 8: Commit** |
| + |
| +```bash |
| +git add internal/usage/census.go internal/console/economicspass.go internal/console/economicspass_test.go |
| +git commit -m "usage: resolve unit ownership as of the usage day |
| + |
| +The sweep re-attributed every day of the month against the live |
| +registry on every pass, and retired routes are hard-deleted. So an |
| +account that removed an app mid-month stopped being billed for the |
| +hours it had already used: its units matched nothing and fell to ops. |
| + |
| +The daily census already exists so existence-metering has history the |
| +live registry cannot give it. It now records unit ownership too, and |
| +the sweep asks the day's census. Days with no census fall back to the |
| +old behaviour, so nothing regresses." |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Previous-month catch-up and closure |
| + |
| +`statementsPass` derives one month from `now` (`internal/console/economicspass.go:748-752`) and never revisits it. Host metrics only roll an hour up *after* it ends (`internal/host/metrics_loop.go:179-208`), so the last hours of a month land in the next month, when nothing is updating the old one. A bill drawn from that statement is short by up to a day. |
| + |
| +`billPass` already solved the same problem for Cost Explorer pulls with a two-period loop and a day-3 grace (`:853-870`, `billCEDayGrace = 3` at `:824`). This task gives `statementsPass` the same shape, and then closes the month so it stops moving under a bill. |
| + |
| +**Files:** |
| +- Modify: `internal/usage/statement.go` (the `Statement` struct) |
| +- Modify: `internal/console/economicspass.go` (`statementsPass` at :738-820) |
| +- Test: `internal/console/economicspass_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `usage.Statement.SourceDays` from Task 1 (the closed statement carries it); `dayOwnerFunc` from Task 2. |
| +- Produces: `usage.Statement.Closed bool` — the later billing plan invoices only closed statements. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +In `internal/console/economicspass_test.go`, append: |
| + |
| +```go |
| +// Data that arrives after a month ends must still reach that month's |
| +// statement, until the month closes. |
| +func TestStatementsPassUpdatesPreviousMonth(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-31"), metrics.DayRollup{ |
| + Hours: []metrics.HourRollup{{ |
| + Units: map[string]map[string]metrics.Stat{ |
| + "carlos-app@a.example.com": {"cpu": {Avg: 0.5, N: 60}}, |
| + }, |
| + }}, |
| + }) |
| + |
| + // 1 September: the August rollup only became readable now. |
| + now := time.Date(2026, 9, 1, 6, 0, 0, 0, time.UTC) |
| + 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.Fatalf("August statement missing on 1 September: %v", err) |
| + } |
| + var st usage.Statement |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := st.Meters[usage.MeterCPUCoreHours]; !approxF(got, 0.5) { |
| + t.Errorf("august cpu_core_hours = %v, want 0.5", got) |
| + } |
| + if st.Closed { |
| + t.Error("August must not be closed on 1 September — inside the grace") |
| + } |
| +} |
| + |
| +// Past the grace, the month closes and stops changing. |
| +func TestStatementsPassClosesAfterGrace(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + now := time.Date(2026, 9, 5, 6, 0, 0, 0, time.UTC) |
| + 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) |
| + } |
| + if !st.Closed { |
| + t.Fatal("August must be closed on 5 September — past the grace") |
| + } |
| + |
| + // Late data after closure must not move a closed month. |
| + putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-31"), metrics.DayRollup{ |
| + Hours: []metrics.HourRollup{{ |
| + Units: map[string]map[string]metrics.Stat{ |
| + "carlos-app@a.example.com": {"cpu": {Avg: 0.5, N: 60}}, |
| + }, |
| + }}, |
| + }) |
| + if err := fx.srv.statementsPass(ctx, now.AddDate(0, 0, 1)); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw2, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var st2 usage.Statement |
| + if err := json.Unmarshal(raw2, &st2); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := st2.Meters[usage.MeterCPUCoreHours]; got != 0 { |
| + t.Errorf("closed August moved to %v — a closed month must not change", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp go test ./internal/console/ -run 'TestStatementsPassUpdatesPreviousMonth|TestStatementsPassClosesAfterGrace' -count=1 -v` |
| + |
| +Expected: FAIL — the August statement does not exist at all on a September `now`, and `st.Closed` is undefined. |
| + |
| +- [ ] **Step 3: Add `Closed` to the statement** |
| + |
| +In `internal/usage/statement.go`, after `SourceDays`: |
| + |
| +```go |
| + // Closed marks a month that will not be recomputed again: past the |
| + // grace, every source has had time to land its final hours. Billing |
| + // reads only closed statements, so a bill cannot be drawn from a month |
| + // still receiving data. |
| + Closed bool `json:"closed,omitempty"` |
| +``` |
| + |
| +- [ ] **Step 4: Give `statementsPass` two periods** |
| + |
| +In `internal/console/economicspass.go`, add beside `billCEDayGrace` (:824): |
| + |
| +```go |
| +// statementCloseDayGrace is the day of the following month after which the |
| +// previous month stops being recomputed. Matches billCEDayGrace: the same |
| +// argument (late-arriving data has landed by now) and no reason to differ. |
| +const statementCloseDayGrace = 3 |
| +``` |
| + |
| +Restructure `statementsPass` (:738) so its body from the month derivation onward runs once per period. Replace lines :748-752 with: |
| + |
| +```go |
| + nowUTC := now.UTC() |
| + firstOfThisMonth := time.Date(nowUTC.Year(), nowUTC.Month(), 1, 0, 0, 0, 0, time.UTC) |
| + firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0) |
| + |
| + periods := []struct { |
| + month string |
| + start, end time.Time |
| + closing bool |
| + }{ |
| + {usage.MonthOf(nowUTC), firstOfThisMonth, nowUTC, false}, |
| + {usage.MonthOf(firstOfLastMonth), firstOfLastMonth, firstOfThisMonth.AddDate(0, 0, -1), nowUTC.Day() > statementCloseDayGrace}, |
| + } |
| + |
| + for _, p := range periods { |
| + if err := s.statementsForMonth(ctx, p.month, p.start, p.end, nowUTC, p.closing, routes); err != nil { |
| + log.Printf("console: statements pass: %s: %v", p.month, err) |
| + } |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +Move the rest of the old body into a new method whose signature is: |
| + |
| +```go |
| +func (s *Server) statementsForMonth(ctx context.Context, month string, start, end, now time.Time, closing bool, routes []registry.Route) error { |
| +``` |
| + |
| +Inside it, compute `daysInMonth` from the period's own month rather than from `now`: |
| + |
| +```go |
| + y, mo, _ := start.Date() |
| + daysInMonth := time.Date(y, mo+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| +``` |
| + |
| +and use `start`/`end` where the old body used `monthStart`/`nowUTC` for the sweep and fold calls. |
| + |
| +- [ ] **Step 5: Skip a month that is already closed, and set `Closed` when closing** |
| + |
| +In the write loop inside `statementsForMonth`, before the upsert: |
| + |
| +```go |
| + if raw, _, err := s.cfg.Releases.Get(ctx, usage.StatementKey(acct, month)); err == nil { |
| + var prev usage.Statement |
| + if json.Unmarshal(raw, &prev) == nil && prev.Closed { |
| + continue // a closed month never changes again |
| + } |
| + } |
| +``` |
| + |
| +and add to the `usage.Statement{...}` literal: |
| + |
| +```go |
| + Closed: closing, |
| +``` |
| + |
| +- [ ] **Step 6: Run the tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp go test ./internal/console/ -run TestStatementsPass -count=1 -v` |
| + |
| +Expected: PASS, all statements tests including the goldens. `TestStatementsPassGolden` uses `now = 2026-08-11`, so its previous-month period covers July, finds no objects, and writes empty July statements — if that trips the golden, assert on the August key specifically rather than on the store's whole key list. |
| + |
| +- [ ] **Step 7: Run the whole gate** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off TMPDIR=/tmp make test` |
| + |
| +Expected: all packages pass. |
| + |
| +- [ ] **Step 8: Commit** |
| + |
| +```bash |
| +git add internal/usage/statement.go internal/console/economicspass.go internal/console/economicspass_test.go |
| +git commit -m "usage: close the month, and catch up the one before it |
| + |
| +statementsPass derived one month from now and never revisited it, but |
| +host metrics roll an hour up after it ends, so a month's final hours |
| +land after nothing is updating that month any more. A bill drawn from |
| +that statement is short by up to a day. |
| + |
| +It now runs two periods on billPass's pattern, and past the same |
| +three-day grace the previous month closes and stops being recomputed. |
| +Billing reads only closed statements." |
| +``` |
| + |
| +--- |
| + |
| +## Self-review |
| + |
| +**Spec coverage.** Build step 1 (previous-month catch-up and closure) is Task 3. Step 2 (ownership at collection time) is Task 2. Step 3 (invoice-grade collection: per-meter completeness) is Task 1's `SourceDays` and `MeterComplete`. |
| + |
| +**One part of step 3 is deliberately not here.** The spec also names durable usage writes: `usageFlush` passes `drain()` as an argument so counters are emptied whether or not the write succeeds, and the caller only logs the failure (`internal/host/usage_loop.go:24`, doc comment at `:51` conceding the window is lost). Fixing that means a retry queue on the box, in `internal/host`, with its own cross-compile exposure — a different subsystem from the console-side pass this plan touches, and one whose analytics half already has a `Requeue` precedent to follow. It belongs in the meters plan alongside the two new collectors, not here. **Until it lands, `MeterComplete` can report a day covered whose counters were silently dropped**, so it is necessary but not yet sufficient for billing. |
| + |
| +**Placeholders:** none. Every step names the file, the line, and the code. |
| + |
| +**Type consistency:** `SourceDays map[string]int` is the same shape on `usageSweep` and `usage.Statement`. `MeterSource` returns the same four constants `SourceDays` is keyed by. `dayOwnerFunc` returns `(string, string)` and the call site at `:361` binds `acct, _`. |