9.0 KiB
Contract: Billing Plane v2 — Metered Add-ons (billing-plane-v2)
Status: stable · Plane: commercial (Team/Cloud) · Base: billing-plane-v1
Source: engine rust/src/core/billing/metering.rs · control plane lean-ctx-cloud/src/metering.rs
An additive extension of billing-plane-v1: 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)
- 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 includedhosted_index_mbquota. It is server-measured (the team server's/v1/storagereport), so it needs no client signature. - A
meteringblock on the control-plane team payloads (/api/account/team/storageand/api/account/team/usage), computed from the already-measured storage figures + a configurable rate. - A Stripe Billing Meter (
event_name = leanctx_hosted_index_storage_gb, aggregationlast) and a linked metered price, provisioned bystripe-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-meteringrefuses 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.
{
"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.quotaBytesisnullandoverageBytes/projectedCostCentsare0for 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):
0(none) is never conflated withUNBOUNDED(unlimited) — a0quota yieldsstate = "none"with no cost; an unlimited quota yieldsstate = "unlimited"with no overage.overageBytes = max(0, used − quota); unlimited ⇒0.projectedCostCents = 0(and is suppressed) wheneverbillingActive = false— display-first never bills.- The
meteringblock is privacy-preserving (numbers only). - Only
signed && chain_validsavings-derived usage is ever billable (unchanged from v1Usage::is_billable); the storage dimension is server-measured and additive. - 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)
{
"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_BYTESenv override →storageQuotaBytesfromteam.json(rendered per plan by provisioning, #282: Team 5 GiB, Enterprise unbounded per catalog) → Team-tier 5 GiB default. (Note: "50 GiB" was the initial team-server provisioning default; the Enterprise entitlement isUNBOUNDEDperbilling-plane-v1-catalog.json.) A concrete quota keeps the control plane's metering out of the degeneratequota = 0 ⇒ state "none"path.components: the server data root (audit log, savings store, hosted indices) plus each workspace's.lean-ctxstate 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)
{
"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 }
}
savingsaggregates each signer's latest signed batch (same no-double-count rule as/v1/savings/summary).toolCallsis the sum of measured ledger events — every entry is one measured agent action, so this is the honest call figure.- The
storageblock is deliberatelysnake_case: that is the spellingmetering.rs::from_usageparses (the dedicated report above stayscamelCase); 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).
{
"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_toolcome from the member's latest signed batch; theseriesreplays the member's full snapshot history (carry-forward, same geometry as the team series — member-only, so the last point equalstotals).400 invalid_signerfor ids outside[A-Za-z0-9_-]{1,64}(the id derives a store filename — validated before any filesystem access);404 unknown_memberwhen 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:
- Fetches
/v1/storagefrom the team server. - Persists a
billing_storage_samplesrow (usage trend + audit). - Checks threshold crossings (50/80/100%) and sends an idempotent email alert (one per threshold per billing period, via SMTP/ZeptoMail).
- Pushes a meter event with the current overage in GB (including
0when cleared — required by Stripelastaggregation 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.