# Contract: Billing Plane v2 — Metered Add-ons (`billing-plane-v2`)

Status: stable · Plane: commercial (Team/Cloud) · Base: [`billing-plane-v1`](billing-plane-v1.md)
Source: engine `rust/src/core/billing/metering.rs` · control plane `lean-ctx-cloud/src/metering.rs`

An **additive** extension of [`billing-plane-v1`](billing-plane-v1.md): it adds a
usage-metered **hosted-index storage-overage** add-on on top of the flat plans,
without changing any plan, entitlement, or the local experience. Everything in
v1 still holds.

> Local-Free Invariant (RFC §4/§6): the Personal (local) plane is free, ungated,
> best-in-class — forever. Metering only **describes** hosted usage; it never
> gates, throttles, or bills a local capability.

## What v2 adds (over v1)

1. A second metering **dimension** alongside the v1 savings-ledger `Usage`:
   **hosted-index storage overage** — bytes stored in the hosted retrieval index
   above the plan's included `hosted_index_mb` quota. It is **server-measured**
   (the team server's `/v1/storage` report), so it needs no client signature.
2. A `metering` block on the control-plane team payloads
   (`/api/account/team/storage` and `/api/account/team/usage`), computed from the
   already-measured storage figures + a configurable rate.
3. A **Stripe Billing Meter** (`event_name = leanctx_hosted_index_storage_gb`,
   aggregation `last`) and a linked metered price, provisioned by
   `stripe-setup.py --storage-metering`.

## Rollout: display-first (opt-in, no surprise bills)

Metering ships **visibility-first**. The rate lives in the control plane env
`LEANCTX_BILLING_STORAGE_OVERAGE_CENTS_PER_GB` (cents per GB / month):

- **Unset / `0`** ⇒ `billing_active = false`. Usage, quota headroom, and the
  threshold state are surfaced; **no projected cost is shown and nothing is
  billed**. `stripe-setup.py --storage-metering` refuses to invent a price.
- **Positive rate** ⇒ `billing_active = true`. A *projected* monthly cost is
  shown for any overage, clearly labelled "estimated · not yet billed" until the
  metered usage-record push is enabled (a deliberate follow-up).

## The `metering` block (`camelCase`)

Carries only numbers — no paths, prompts, or content — so it is safe to surface
and to reconcile against billing.

```json
{
  "usedBytes": 6000000000,
  "quotaBytes": 5000000000,
  "overageBytes": 1000000000,
  "percent": 120.0,
  "unlimited": false,
  "state": "over",
  "rateCentsPerGb": 50,
  "billingActive": true,
  "projectedCostCents": 50
}
```

- `state` ∈ `none | ok | warn (≥50%) | critical (≥80%) | over (≥100%) | unlimited`.
- `quotaBytes` is `null` and `overageBytes`/`projectedCostCents` are `0` for an
  **unlimited** (Enterprise) quota.
- Billing convention: 1 GB = 1e9 bytes (decimal), matching Stripe metered units.

## Invariants (test-enforced)

All of `billing-plane-v1`'s invariants, plus
(`lean-ctx-cloud/src/metering.rs` tests):

1. **`0` (none) is never conflated with `UNBOUNDED` (unlimited)** — a `0` quota
   yields `state = "none"` with no cost; an unlimited quota yields
   `state = "unlimited"` with no overage.
2. `overageBytes = max(0, used − quota)`; unlimited ⇒ `0`.
3. `projectedCostCents = 0` (and is suppressed) whenever `billingActive = false`
   — display-first never bills.
4. The `metering` block is privacy-preserving (numbers only).
5. Only `signed && chain_valid` savings-derived usage is ever billable
   (unchanged from v1 `Usage::is_billable`); the storage dimension is
   server-measured and additive.
6. Nothing in the metering path gates a local feature (Local-Free preserved).

## Team-server report endpoints (GL #463)

The team server serves both reports itself (`rust/src/http_server/team_billing.rs`);
the control plane proxies them. Both are gated by the `audit` scope — the same
sensitivity class as `/v1/metrics`, and the scope of the audit-only control token.
Sizing is allocated-blocks-based (`st_blocks * 512` on Unix), symlinks are not
followed, hard links count once, and reports are cached for 60 s per process.

### `GET /v1/storage` (camelCase)

```json
{
  "schemaVersion": 1,
  "measuredAt": "2026-06-10T08:00:00Z",
  "usedBytes": 123456789,
  "quotaBytes": 5000000000,
  "components": [
    { "id": "server-data", "bytes": 120000000 },
    { "id": "workspace:acme-api", "bytes": 3456789 }
  ],
  "cacheAgeSeconds": 0
}
```

- `usedBytes` (required) is what the metering job samples and bills against.
- `quotaBytes` (always present) resolves as: `LEANCTX_TEAM_STORAGE_QUOTA_BYTES`
  env override → `storageQuotaBytes` from `team.json` (rendered per plan by
  provisioning, #282: Team 5 GiB, Enterprise 50 GiB) → Team-tier 5 GiB default.
  A concrete quota keeps the control plane's metering out of the degenerate
  `quota = 0 ⇒ state "none"` path.
- `components`: the server data root (audit log, savings store, hosted indices)
  plus each workspace's `.lean-ctx` state dir; workspace dirs nested inside the
  data root are skipped so nothing is counted twice.

### `GET /v1/usage` (savings roll-up + `snake_case` storage block)

```json
{
  "schemaVersion": 1,
  "generatedAt": "2026-06-10T08:00:00Z",
  "savings": {
    "memberCount": 4,
    "savedTokens": 81000000,
    "netSavedTokens": 78000000,
    "savedUsd": 196.42
  },
  "toolCalls": 36001,
  "storage": { "used_bytes": 123456789, "quota_bytes": 5000000000 }
}
```

- `savings` aggregates each signer's **latest** signed batch (same
  no-double-count rule as `/v1/savings/summary`).
- `toolCalls` is the sum of measured ledger events — every entry is one
  measured agent action, so this is the honest call figure.
- The `storage` block is deliberately `snake_case`: that is the spelling
  `metering.rs::from_usage` parses (the dedicated report above stays
  `camelCase`); both carry the same measured numbers.

### `GET /v1/savings/member/{signer}` (per-member drilldown, GL #389)

`signer` is the truncated public key from `by_member[].signer` in
`/v1/savings/summary`. Audit-scoped like the summary (same sensitivity class).

```json
{
  "schema_version": 1,
  "generated_at": "2026-06-10T08:00:00Z",
  "signer": "aaaaaaaaaaaaaaaa",
  "agent_id": "dev-laptop",
  "last_reported": "2026-06-08T00:00:00Z",
  "totals": { "saved_tokens": 4200, "net_saved_tokens": 4200, "saved_usd": 0.042, "total_events": 7 },
  "by_model": [{ "model": "claude-opus", "saved_tokens": 4200, "saved_usd": 0.042 }],
  "by_tool": [{ "tool": "ctx_read", "saved_tokens": 4200 }],
  "series": [{ "date": "2026-06-08", "net_saved_tokens": 4200, "saved_usd": 0.042, "total_events": 7 }],
  "window_days": 90
}
```

- `totals`/`by_model`/`by_tool` come from the member's **latest** signed batch;
  the `series` replays the member's full snapshot history (carry-forward, same
  geometry as the team series — member-only, so the last point equals `totals`).
- `400 invalid_signer` for ids outside `[A-Za-z0-9_-]{1,64}` (the id derives a
  store filename — validated before any filesystem access); `404 unknown_member`
  when the signer never reported a batch.
- Control plane: `GET /api/billing/team/{user_id}/savings/member/{signer}` →
  edge: `GET /api/account/team/savings/member/{signer}` (dashboard drilldown).

## Meter Events (Stripe Billing Meters API)

Usage is pushed via the Stripe Billing Meters API (`POST /v1/billing/meter_events`),
not the legacy subscription-item usage records (removed in Stripe 2025-03-31.basil).
The billing service runs an hourly background job (`metering_job`) that, for each
active team account with a provisioned server and control token:

1. Fetches `/v1/storage` from the team server.
2. Persists a `billing_storage_samples` row (usage trend + audit).
3. Checks threshold crossings (50/80/100%) and sends an idempotent email alert
   (one per threshold per billing period, via SMTP/ZeptoMail).
4. Pushes a meter event with the **current** overage in GB (including `0` when
   cleared — required by Stripe `last` aggregation to avoid stale overbilling),
   rounded up to 0.01 GB.

## Data Durability

Hosted team servers store workspaces, audit logs, and retrieval indices in `/data`.
Coolify v4 (beta.455) silently drops `-v` mounts from `custom_docker_run_options`,
so the provisioning code registers a durable named Docker volume by writing to
Coolify's `local_persistent_volumes` table (the same row the UI creates). This is
a contained, idempotent, additive coupling — it can be swapped for the REST API
once Coolify ships application-storage endpoints. Without `COOLIFY_DB_URL`, new
instances deploy with ephemeral `/data` (logged, non-fatal, recoverable).

## Versioning

Named `v2` because it introduces a new **metered add-on surface** (a billable
dimension + the `metering` block + a metered price + meter events), even though
it is additive and changes no v1 plan/entitlement or local-free semantics.
Adding further metered dimensions (connector sync volume, retrieval queries)
under the same display-first, signed/server-measured, Local-Free rules stays
`v2`.
