Skip to main content

lean_ctx/core/billing/
metering.rs

1//! Usage-based metering derived from the signed savings ledger
2//! (`billing-plane-v1`, EPIC 13.6).
3//!
4//! The commercial plane meters on a **privacy-preserving, signed aggregate** —
5//! never on raw activity. [`Usage`] is built strictly from [`RoiReport`] (EPIC
6//! 12.20), which is itself derived from the Ed25519
7//! [`SignedSavingsBatchV1`](crate::core::savings_ledger::signed_batch::SignedSavingsBatchV1).
8//! Producing a usage record is **read-only** and never gates or mutates the
9//! local experience.
10
11use serde::{Deserialize, Serialize};
12
13use crate::core::savings_ledger::{roi_report, RoiReport};
14
15/// A billable usage record for a metering period. Carries only counts, sums,
16/// and provenance hashes — no paths, prompts, or content (inherited from
17/// [`RoiReport`]'s privacy guarantee).
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct Usage {
20    /// Schema version of the metering record.
21    pub schema_version: u32,
22    /// Coverage window (`"all"` today).
23    pub period: String,
24    /// When the record was produced.
25    pub created_at: String,
26    /// The agent/machine identity the ledger belongs to.
27    pub agent_id: String,
28
29    // --- billable signal ---
30    /// Number of metered events (tool invocations that produced savings).
31    pub metered_events: usize,
32    /// Net tokens saved over the period — the primary usage signal.
33    pub net_saved_tokens: u64,
34    /// USD value of the savings over the period.
35    pub saved_usd: f64,
36
37    // --- provenance (makes the meter auditable, not trust-me) ---
38    /// Chain head committing the full event history.
39    pub last_entry_hash: String,
40    /// Whether the SHA-256 chain verified intact.
41    pub chain_valid: bool,
42    /// Whether the source aggregate was Ed25519-signed.
43    pub signed: bool,
44}
45
46impl Usage {
47    /// Schema version emitted by this build.
48    pub const SCHEMA_VERSION: u32 = 1;
49
50    /// Derive a usage record from an ROI report.
51    #[must_use]
52    pub fn from_roi(roi: &RoiReport) -> Self {
53        Self {
54            schema_version: Self::SCHEMA_VERSION,
55            period: roi.period.clone(),
56            created_at: roi.created_at.clone(),
57            agent_id: roi.agent_id.clone(),
58            metered_events: roi.total_events,
59            net_saved_tokens: roi.net_saved_tokens,
60            saved_usd: roi.saved_usd,
61            last_entry_hash: roi.last_entry_hash.clone(),
62            chain_valid: roi.chain_valid,
63            signed: roi.signed,
64        }
65    }
66
67    /// Whether this usage record is safe to bill on: it must derive from an
68    /// intact, signed chain. Unsigned/broken aggregates are observable locally
69    /// but are **not** billable (fail-closed for *billing*, never for the user).
70    #[must_use]
71    pub fn is_billable(&self) -> bool {
72        self.signed && self.chain_valid
73    }
74
75    /// Compact one-line metering headline.
76    #[must_use]
77    pub fn headline(&self) -> String {
78        format!(
79            "Usage[{}]: {} events, {} net tokens, ${:.4} ({}, {})",
80            self.period,
81            self.metered_events,
82            self.net_saved_tokens,
83            self.saved_usd,
84            if self.chain_valid {
85                "chain valid"
86            } else {
87                "chain BROKEN"
88            },
89            if self.is_billable() {
90                "billable"
91            } else {
92                "not billable"
93            },
94        )
95    }
96}
97
98/// Build a usage record over the whole local ledger. Read-only: signs a fresh
99/// batch in-memory (best-effort) to derive the metered aggregate, never
100/// mutating the ledger or the local experience.
101#[must_use]
102pub fn metered_usage(agent_id: &str) -> Usage {
103    Usage::from_roi(&roi_report(agent_id))
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::core::savings_ledger::signed_batch::{BatchTotals, SignedSavingsBatchV1};
110
111    fn roi(events: usize, net: u64, usd: f64, signed: bool, chain_valid: bool) -> RoiReport {
112        let batch = SignedSavingsBatchV1 {
113            schema_version: 1,
114            kind: "lean-ctx.savings-batch".to_string(),
115            created_at: "2026-01-01T00:00:00Z".to_string(),
116            lean_ctx_version: "test".to_string(),
117            agent_id: "agent-1".to_string(),
118            period: "all".to_string(),
119            first_entry_hash: "genesis".to_string(),
120            last_entry_hash: "deadbeef".to_string(),
121            chain_valid,
122            totals: BatchTotals {
123                total_events: events,
124                saved_tokens: net + 10,
125                net_saved_tokens: net,
126                saved_usd: usd,
127                bounce_tokens: 10,
128                bounce_events: 1,
129                tokenizers: vec!["o200k_base".to_string()],
130                by_model: vec![("gpt".to_string(), net, usd)],
131                by_tool: vec![("ctx_read".to_string(), net)],
132            },
133            signer_public_key: signed.then(|| "pubkey".to_string()),
134            signature: signed.then(|| "sig".to_string()),
135        };
136        RoiReport::from_signed_batch(&batch)
137    }
138
139    #[test]
140    fn usage_mirrors_roi_aggregates() {
141        let u = Usage::from_roi(&roi(7, 7000, 0.14, true, true));
142        assert_eq!(u.metered_events, 7);
143        assert_eq!(u.net_saved_tokens, 7000);
144        assert!((u.saved_usd - 0.14).abs() < 1e-9);
145        assert_eq!(u.last_entry_hash, "deadbeef");
146        assert_eq!(u.schema_version, Usage::SCHEMA_VERSION);
147    }
148
149    #[test]
150    fn only_signed_intact_chains_are_billable() {
151        assert!(Usage::from_roi(&roi(1, 1, 0.0, true, true)).is_billable());
152        assert!(!Usage::from_roi(&roi(1, 1, 0.0, false, true)).is_billable());
153        assert!(!Usage::from_roi(&roi(1, 1, 0.0, true, false)).is_billable());
154    }
155
156    #[test]
157    fn usage_is_privacy_preserving() {
158        let json = serde_json::to_string(&Usage::from_roi(&roi(2, 100, 0.01, true, true))).unwrap();
159        for forbidden in ["path", "prompt", "content", "cwd", "\"file\""] {
160            assert!(!json.contains(forbidden), "usage leaked '{forbidden}'");
161        }
162    }
163}