carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev c30d059b5256d65d7e9938d7d17785f25612d95a parent c16f920
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, _`.