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::{RoiReport, roi_report};
14
15/// Frozen billing-plane-v1 usage record for a metering period. Carries only
16/// counts, sums, and provenance hashes — no paths, prompts, or content
17/// (inherited from [`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    /// Frozen billing-plane-v1 compatibility predicate.
68    ///
69    /// It remains exactly `signed && chain_valid`. In settlement-evidence-v2
70    /// that proves only source integrity; it does not prove quality, exclusive
71    /// attribution, contract validity, customer approval, or invoice authority.
72    #[must_use]
73    pub fn is_billable(&self) -> bool {
74        self.signed && self.chain_valid
75    }
76
77    /// Honest name for the exact frozen v1 predicate.
78    #[must_use]
79    pub fn source_integrity_verified(&self) -> bool {
80        self.is_billable()
81    }
82
83    /// Compact one-line metering headline.
84    #[must_use]
85    pub fn headline(&self) -> String {
86        format!(
87            "Usage[{}]: {} events, {} net tokens, ${:.4} ({}, {})",
88            self.period,
89            self.metered_events,
90            self.net_saved_tokens,
91            self.saved_usd,
92            if self.chain_valid {
93                "chain valid"
94            } else {
95                "chain BROKEN"
96            },
97            if self.source_integrity_verified() {
98                "source integrity verified"
99            } else {
100                "source integrity unverified"
101            },
102        )
103    }
104}
105
106/// Build a usage record over the whole local ledger. Read-only: signs a fresh
107/// batch in-memory (best-effort) to derive the metered aggregate, never
108/// mutating the ledger or the local experience.
109#[must_use]
110pub fn metered_usage(agent_id: &str) -> Usage {
111    Usage::from_roi(&roi_report(agent_id))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::core::savings_ledger::signed_batch::{BatchTotals, SignedSavingsBatchV1};
118
119    fn roi(events: usize, net: u64, usd: f64, signed: bool, chain_valid: bool) -> RoiReport {
120        let batch = SignedSavingsBatchV1 {
121            schema_version: 1,
122            kind: "lean-ctx.savings-batch".to_string(),
123            created_at: "2026-01-01T00:00:00Z".to_string(),
124            lean_ctx_version: "test".to_string(),
125            agent_id: "agent-1".to_string(),
126            period: "all".to_string(),
127            first_entry_hash: "genesis".to_string(),
128            last_entry_hash: "deadbeef".to_string(),
129            chain_valid,
130            totals: BatchTotals {
131                total_events: events,
132                saved_tokens: net + 10,
133                net_saved_tokens: net,
134                saved_usd: usd,
135                bounce_tokens: 10,
136                bounce_events: 1,
137                tokenizers: vec!["o200k_base".to_string()],
138                by_model: vec![("gpt".to_string(), net, usd)],
139                by_tool: vec![("ctx_read".to_string(), net)],
140                by_mechanism: vec![("compression".to_string(), net, usd)],
141            },
142            signer_public_key: signed.then(|| "pubkey".to_string()),
143            signature: signed.then(|| "sig".to_string()),
144        };
145        RoiReport::from_signed_batch(&batch)
146    }
147
148    #[test]
149    fn usage_mirrors_roi_aggregates() {
150        let u = Usage::from_roi(&roi(7, 7000, 0.14, true, true));
151        assert_eq!(u.metered_events, 7);
152        assert_eq!(u.net_saved_tokens, 7000);
153        assert!((u.saved_usd - 0.14).abs() < 1e-9);
154        assert_eq!(u.last_entry_hash, "deadbeef");
155        assert_eq!(u.schema_version, Usage::SCHEMA_VERSION);
156    }
157
158    #[test]
159    fn only_signed_intact_chains_are_billable() {
160        let usage = Usage::from_roi(&roi(1, 1, 0.0, true, true));
161        assert!(usage.is_billable());
162        assert!(usage.source_integrity_verified());
163        assert!(!Usage::from_roi(&roi(1, 1, 0.0, false, true)).is_billable());
164        assert!(!Usage::from_roi(&roi(1, 1, 0.0, true, false)).is_billable());
165    }
166
167    #[test]
168    fn usage_is_privacy_preserving() {
169        let json = serde_json::to_string(&Usage::from_roi(&roi(2, 100, 0.01, true, true))).unwrap();
170        for forbidden in ["path", "prompt", "content", "cwd", "\"file\""] {
171            assert!(!json.contains(forbidden), "usage leaked '{forbidden}'");
172        }
173    }
174}