Files
yvgude--lean-ctx/docs/contracts/billing-plane-v2.md
T
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

9.0 KiB
Raw Blame History

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)

  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 / 0billing_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 ratebilling_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
}
  • statenone | 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)

{
  "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 unbounded per catalog) → Team-tier 5 GiB default. (Note: "50 GiB" was the initial team-server provisioning default; the Enterprise entitlement is UNBOUNDED per billing-plane-v1-catalog.json.) 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)

{
  "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).

{
  "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.