---
id: w8/m1/t003
title: Metering loop in bex-api — hourly tick, idempotent upserts, restart catch-up
worker: worker8
status: done
estimate: 45m
depends_on: [w8/m1/t001, w8/m1/t002]
---

## Objective

Wire the metering loop into bex-api: every hour, for each known service, evaluate t002's queries for the just-closed window and upsert `usage_hourly` rows; on startup, catch up windows missed while down (bounded, e.g. last 48h — Prometheus retention covers it). The loop starts only when both the control-plane store (`BEX_CP_DB_URI`) and `BEX_PROM_URL` are configured; otherwise bex-api behaves exactly as today.

## Context

- bex-api's startup wiring lives in `lego/backend/cmd/api`; conditional subsystems (projector, internal API) already follow the "env set ⇒ start" pattern (docs/ADR003-control-plane.md).
- Catch-up: find the latest recorded `window_start` per kind, resume from there; first-ever run starts from the previous full hour, no deep backfill.
- Upserts ride t001's unique key — re-processing a window must be a no-op.
- Workspace attribution: resolve the service's workspace from the store (`tenants`/`apps` rows); default workspace until w1/m9 lands.
- If a tick interval knob is introduced for tests (e.g. `BEX_METERING_INTERVAL`), add it to the CLAUDE.md env table **and** mirror the name into `.env.example` / `.env.template` (repo rule); prefer a plain Go option over a public env var if tests are the only consumer.

## Steps

1. Add the loop to `internal/usage` (service struct with store + prom source deps; `Run(ctx)`).
2. Start it from `cmd/api` behind the store+prom condition.
3. Implement catch-up from the latest recorded window.
4. Log one summary line per tick (services metered, rows written) — no per-row noise.

## Files

- `lego/backend/internal/usage/`
- `lego/backend/cmd/api/`
- `CLAUDE.md` + `.env.example` + `.env.template` (only if a new env var is added)

## Acceptance criteria

- [ ] With store + prom configured, a tick writes rows for a running service; without either, the loop never starts and bex-api is unchanged.
- [ ] Killing and restarting bex-api mid-day re-fills missed windows exactly once (no duplicates, no gaps within the catch-up bound).

## Out of scope

- Build-Job metering (t004); any API surface (m2).
