| index 8741ecb22..b32a3f3e3 100644 |
| --- a/docs/superpowers/plans/2026-09-08-invoice-grade-statements.md |
| +++ b/docs/superpowers/plans/2026-09-08-invoice-grade-statements.md |
| @@ -25,14 +25,16 @@ |
| |
| ### 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. |
| +`CoveredDays` counts a day as covered when *any* of metrics, edge or storage produced an object (`internal/console/economicspass.go:355`, `:398`, `: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. |
| |
| +**A day counts only when every discovered box of that source reported it** (codex 3). A per-source count that means "any box" lets a healthy box conceal a silent one: an account whose app lives on the silent box loses a day while the statement reads complete. Strict counting fails safe — it refuses to call a month complete rather than billing a short one. The known bound is that a decommissioned box, which `ListDirs` still discovers because it once wrote, blocks completeness until it is pruned; that failure direction is "we do not bill", which is the right way round, and pruning belongs with the later billing plan. |
| + |
| **Files:** |
| -- Modify: `internal/usage/usage.go` (append after the meter block ending at :42) |
| +- Modify: `internal/usage/usage.go` (append after the meter block ending at :39) |
| - 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) |
| +- Modify: `internal/console/economicspass.go` (`usageSweep` at :241-258, the day loop at :341-435, `statementsPass` write at :800-820) |
| - Test: `internal/usage/passes_test.go`, `internal/console/economicspass_test.go` |
| |
| **Interfaces:** |
| @@ -51,11 +53,14 @@ 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, |
| + MeterCPUCoreHours: SourceMetrics, |
| + MeterMemGBHours: SourceMetrics, |
| + MeterInstanceHours: SourceMetrics, |
| + // Fleet box-hours come from fleetMeters, which reads per-fleet |
| + // objects this sweep never looks at. No source here means never |
| + // complete, which is the honest answer until they get their own. |
| + MeterFleetBoxHoursServer: "", |
| + MeterFleetBoxHoursDevice: "", |
| MeterEgressGB: SourceEdge, |
| MeterRequests1k: SourceEdge, |
| MeterBackhaulGB: SourceEdge, |
| @@ -102,7 +107,7 @@ Expected: FAIL to compile — `undefined: SourceMetrics`, `undefined: MeterSourc |
| |
| - [ ] **Step 3: Add the sources and `MeterSource`** |
| |
| -In `internal/usage/usage.go`, after the meter const block that ends at line 42: |
| +In `internal/usage/usage.go`, after the meter const block that ends at line 39: |
| |
| ```go |
| // Collection sources — the pass-visible daily object a meter's value is |
| @@ -116,12 +121,17 @@ const ( |
| ) |
| |
| // 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. |
| +// meter whose completeness this sweep cannot vouch for. |
| +// |
| +// The fleet box-hour meters return "" deliberately: fleetMeters |
| +// (console/economicspass.go) derives them from per-fleet objects that |
| +// sweepUsage never reads, so platform metrics coverage says nothing about |
| +// them. Claiming SourceMetrics would let complete platform data vouch for |
| +// fleet days that never arrived. They need their own coverage signal, which |
| +// belongs with the plan that bills fleet boxes. |
| func MeterSource(meter string) string { |
| switch meter { |
| - case MeterCPUCoreHours, MeterMemGBHours, MeterInstanceHours, |
| - MeterFleetBoxHoursServer, MeterFleetBoxHoursDevice: |
| + case MeterCPUCoreHours, MeterMemGBHours, MeterInstanceHours: |
| return SourceMetrics |
| case MeterEgressGB, MeterRequests1k, MeterBackhaulGB, MeterBackhaulRequests1k: |
| return SourceEdge |
| @@ -224,6 +234,65 @@ func TestStatementsPassCountsSourcesSeparately(t *testing.T) { |
| t.Error("egress must not be complete with 1 of 3 edge days") |
| } |
| } |
| + |
| +// One box reporting must never vouch for a box that went silent. |
| +func TestStatementsPassSourceDaysNeedsEveryBox(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC) |
| + |
| + // Two edge boxes are discoverable; only one reports 1 August. |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-07-31"), usage.EdgeDay{}) |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("box2", "2026-07-31"), usage.EdgeDay{}) |
| + putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-08-01"), 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.SourceEdge]; got != 0 { |
| + t.Errorf("edge source days = %d, want 0 — box2 reported nothing on 1 Aug", got) |
| + } |
| + if st.CoveredDays != 1 { |
| + t.Errorf("CoveredDays = %d, want 1 — box1 alone still covers for modelling", st.CoveredDays) |
| + } |
| +} |
| + |
| +// A storage day that could not size a tenant store is covered, never complete. |
| +func TestStatementsPassUnsweptStorageIsNotComplete(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC) |
| + |
| + putStoreJSON(t, fx.rel, usage.StorageDayKey("2026-08-01"), usage.StorageDay{ |
| + V: 1, Date: "2026-08-01", |
| + Apps: map[string]usage.AppStorage{ |
| + fixtureAcct + "/" + fixtureApp: {BucketBytes: 1000, StoreUnswept: true}, |
| + }, |
| + }) |
| + |
| + 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.SourceStorage]; got != 0 { |
| + t.Errorf("storage source days = %d, want 0 — a store went unswept", got) |
| + } |
| +} |
| ``` |
| |
| - [ ] **Step 7: Run it to verify it fails** |
| @@ -234,44 +303,76 @@ 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): |
| +In `internal/console/economicspass.go`, add one field to `usageSweep` (after `CoveredDays, PeriodDays int` at :251): |
| |
| ```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 counts, per usage.Source*, the days in [from, to] on which |
| + // EVERY discovered box of that source produced its object. CoveredDays |
| + // asks "did anything report"; this asks "did nothing go missing", which |
| + // is the only version a bill can rest on — one healthy box must never |
| + // vouch for a silent one. |
| SourceDays map[string]int |
| ``` |
| |
| -Initialise it in the `sweep := &usageSweep{...}` literal at :308: |
| +Initialise it in the `sweep := &usageSweep{...}` literal at :307: |
| |
| ```go |
| SourceDays: map[string]int{}, |
| ``` |
| |
| -In the day loop, replace line 344 (`covered := false`) with: |
| +In the day loop, replace line 342 (`covered := false`) with — note the line number, 341 is `date := usage.DayOf(d)` and must be left alone: |
| |
| ```go |
| covered := false |
| - srcSeen := map[string]bool{} |
| + metricsDays, edgeDays := 0, 0 |
| + storageOK, censusOK := false, false |
| ``` |
| |
| -Add a `srcSeen` line beside each of the three existing `covered = true` statements: |
| +Then, beside each existing `covered = true`, count the box that reported: |
| |
| -- 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` |
| +- at `:355` (metrics arm, after `covered = true`): `metricsDays++` |
| +- at `:398` (edge arm, after `covered = true`): `edgeDays++` |
| +- at `:429` (storage arm, inside the `jerr == nil` branch, after `covered = true`): replace with the unswept guard below |
| |
| -Then add a census arm immediately before the `if covered {` block at :433, so `app_months` has a completeness signal too: |
| +The storage arm needs more than a flag (codex 4). A `StorageDay` that decodes cleanly can still be missing tenant-store occupancy: `AppStorage.StoreUnswept` marks a store the pass could not size, and the fold adds `BucketBytes + StoreBytes` with the latter left at zero (`internal/usage/storage.go:5-11`). Thirty-one such days would report storage complete while understating every tenant bucket. So replace the body of the `jerr == nil` branch at :428-430 with: |
| + |
| +```go |
| + if jerr := json.Unmarshal(raw, &sd); jerr == nil { |
| + covered = true |
| + storageOK = true |
| + for _, as := range sd.Apps { |
| + if as.StoreUnswept { |
| + // A store the pass could not size contributes a |
| + // zero that reads as "no bytes". The day is |
| + // covered for modelling, never complete for |
| + // billing. |
| + storageOK = false |
| + break |
| + } |
| + } |
| + } |
| +``` |
| + |
| +Then add a census arm immediately before the `if covered {` block at :433, so `app_months` has a completeness signal too, and accumulate strictly: |
| |
| ```go |
| if _, _, gerr := s.cfg.Releases.Get(ctx, usage.CensusDayKey(date)); gerr == nil { |
| - srcSeen[usage.SourceCensus] = true |
| + censusOK = true |
| covered = true |
| } |
| |
| - for src := range srcSeen { |
| - sweep.SourceDays[src]++ |
| + // Strict: every discovered box must have reported this day. |
| + if len(metricsBoxes) > 0 && metricsDays == len(metricsBoxes) { |
| + sweep.SourceDays[usage.SourceMetrics]++ |
| + } |
| + if len(edgeBoxes) > 0 && edgeDays == len(edgeBoxes) { |
| + sweep.SourceDays[usage.SourceEdge]++ |
| + } |
| + if storageOK { |
| + sweep.SourceDays[usage.SourceStorage]++ |
| + } |
| + if censusOK { |
| + sweep.SourceDays[usage.SourceCensus]++ |
| } |
| ``` |
| |
| @@ -334,7 +435,7 @@ The fix follows a pattern this repo already has. `CensusDay` exists, in its own |
| - 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`. |
| + - `func (s *Server) dayOwnerFunc(ctx context.Context, date string, routes []registry.Route) func(string) (string, string)` — unexported, used by `sweepUsage`'s day loop. |
| |
| - [ ] **Step 1: Write the failing test** |
| |
| @@ -383,7 +484,63 @@ func TestSweepKeepsOwnerAfterRouteDeleted(t *testing.T) { |
| } |
| ``` |
| |
| -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. |
| +**The fixture account will make this test pass either way, so do not use it.** `fixtureAcct` is `release.OpsAccount` (`economicspass_test.go:41-44`), which is exactly what `unitOwner` returns when it finds nothing — so a broken implementation and a correct one produce the same statement. Use a distinct account instead: seed the census `Units` entry with `{Account: "acme", App: fixtureApp}`, assert `usage.StatementKey("acme", "2026-08")` carries the 0.5, and assert the ops statement carries **zero** `cpu_core_hours`. Without both halves the test proves nothing. |
| + |
| +Add a second test for the created-after-snapshot case the fallback protects: |
| + |
| +```go |
| +// A route created after the day's census was written must still be |
| +// attributed to its owner, not to ops. |
| +func TestSweepFallsBackForRouteMissingFromCensus(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + // The census exists for the day but predates this route. |
| + putStoreJSON(t, fx.rel, usage.CensusDayKey("2026-08-01"), usage.CensusDay{ |
| + V: 1, Date: "2026-08-01", |
| + Accounts: map[string]usage.CensusAccount{}, |
| + Units: map[string]usage.CensusUnit{"carlos-app@other.example.com": {Account: "acme", App: "other"}}, |
| + }) |
| + putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-01"), metrics.DayRollup{ |
| + Hours: []metrics.HourRollup{{ |
| + Units: map[string]map[string]metrics.Stat{ |
| + "carlos-app@late.example.com": {"cpu": {Avg: 0.5, N: 60}}, |
| + }, |
| + }}, |
| + }) |
| + |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "late.example.com", Kind: "instance", Status: "ready", |
| + App: "late", Account: "acme", Version: "v1", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg.Close() |
| + |
| + if err := fx.srv.statementsPass(ctx, time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC)); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := fx.rel.Get(ctx, usage.StatementKey("acme", "2026-08")) |
| + if err != nil { |
| + t.Fatalf("acme statement missing — the late route fell to ops: %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("acme cpu_core_hours = %v, want 0.5", got) |
| + } |
| +} |
| +``` |
| + |
| +Confirm the account field name and how `release.RouteAccount` normalises it before writing the `registry.Route` literal: |
| + |
| +Run: `grep -n 'func RouteAccount' internal/release/*.go` |
| |
| - [ ] **Step 2: Run it to verify it fails** |
| |
| @@ -468,16 +625,23 @@ func (s *Server) dayOwnerFunc(ctx context.Context, date string, routes []registr |
| 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, "" |
| } |
| + // Absence from the census is NOT evidence of platform ownership |
| + // (codex 2). censusPass writes once per day and returns early |
| + // afterwards (economicspass.go:99), so a route created after that |
| + // day's snapshot is legitimately missing from it while the live |
| + // registry knows exactly who owns it. Falling through to the |
| + // registry keeps the win — a DELETED route is present in the |
| + // census and keeps its owner — without inventing a regression for |
| + // a CREATED one. |
| acct, app, _ := unitOwner(routes, unit) |
| return acct, app |
| } |
| } |
| ``` |
| |
| +The asymmetry is the whole point, and it is worth being explicit about why it is safe. A route that existed at snapshot time is in the census, so deleting it later cannot move its usage. A route created after snapshot time is absent from the census but present in the registry, so it resolves correctly too. The only case still wrong is a route both created *and* deleted between two snapshots, which loses its usage to ops exactly as it does today — no better, no worse, and it needs the collection-time attribution the meters plan carries. |
| + |
| Immediately after `date := usage.DayOf(d)` in the day loop (`:343`), add: |
| |
| ```go |
| @@ -529,7 +693,7 @@ old behaviour, so nothing regresses." |
| |
| `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. |
| +`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 `:827`). 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) |
| @@ -647,7 +811,7 @@ In `internal/usage/statement.go`, after `SourceDays`: |
| |
| - [ ] **Step 4: Give `statementsPass` two periods** |
| |
| -In `internal/console/economicspass.go`, add beside `billCEDayGrace` (:824): |
| +In `internal/console/economicspass.go`, add beside `billCEDayGrace` (:827): |
| |
| ```go |
| // statementCloseDayGrace is the day of the following month after which the |
| @@ -696,25 +860,88 @@ Inside it, compute `daysInMonth` from the period's own month rather than from `n |
| |
| 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** |
| +- [ ] **Step 5: Make closure immutable inside the CAS loop** |
| + |
| +A check in the caller cannot hold (codex 1). `UpsertStatement` runs its own read-modify-write: it Gets purely for the ETag and never inspects the body (`internal/usage/statement.go:47-60`). So writer A can read an open statement, writer B can close it, and A's Put still succeeds against B's ETag on retry — overwriting a closed month. A failed preliminary Get in the caller would skip the guard too. Two consoles run this pass, so this is a live race, not a theoretical one. |
| |
| -In the write loop inside `statementsForMonth`, before the upsert: |
| +Enforce it where the ETag is taken. In `internal/usage/statement.go`, replace the loop body: |
| |
| ```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 |
| + for attempt := 0; attempt < 3; attempt++ { |
| + raw, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + etag = "" |
| + } else { |
| + // Closure is enforced HERE, against the same read that |
| + // produced the ETag we are about to write against — a check |
| + // in the caller races with another console's close. |
| + var prev Statement |
| + if jerr := json.Unmarshal(raw, &prev); jerr != nil { |
| + return fmt.Errorf("usage: %s: %w", key, jerr) |
| + } |
| + if prev.Closed { |
| + return nil // a closed month never changes again |
| } |
| } |
| + if _, err := s.Put(ctx, key, body, etag); err == nil { |
| + return nil |
| + } else if !errors.Is(err, release.ErrCASConflict) { |
| + return err |
| + } |
| + } |
| ``` |
| |
| -and add to the `usage.Statement{...}` literal: |
| +Note the parse error is now returned rather than ignored: a statement we cannot read is not a statement we may overwrite. |
| + |
| +Then add to the `usage.Statement{...}` literal in `statementsForMonth`: |
| |
| ```go |
| Closed: closing, |
| ``` |
| |
| +No caller-side guard is needed, and none should be added — one place enforces this. |
| + |
| +- [ ] **Step 5b: Test the race directly** |
| + |
| +In `internal/usage/passes_test.go`: |
| + |
| +```go |
| +func TestUpsertStatementRefusesClosedMonth(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + |
| + closed := Statement{V: 1, Account: "acme", Month: "2026-08", Closed: true, |
| + Meters: map[string]float64{MeterInstanceHours: 10}} |
| + if err := UpsertStatement(ctx, s, closed); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A writer that read the month before it closed now tries to write. |
| + late := Statement{V: 1, Account: "acme", Month: "2026-08", |
| + Meters: map[string]float64{MeterInstanceHours: 999}} |
| + if err := UpsertStatement(ctx, s, late); err != nil { |
| + t.Fatalf("a refused write is not an error: %v", err) |
| + } |
| + raw, _, err := s.Get(ctx, StatementKey("acme", "2026-08")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got Statement |
| + if err := json.Unmarshal(raw, &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Meters[MeterInstanceHours] != 10 { |
| + t.Errorf("closed statement moved to %v — closure is not immutable", got.Meters[MeterInstanceHours]) |
| + } |
| +} |
| +``` |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/usage/ -run TestUpsertStatementRefusesClosedMonth -count=1 -v` |
| + |
| +Expected: FAIL before the change (999 overwrites 10), PASS after. |
| + |
| - [ ] **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` |
| @@ -749,7 +976,7 @@ Billing reads only closed statements." |
| |
| **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. |
| +**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 `:49-50` 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. |
| |