1use chrono::TimeZone;
2use serde::Serialize;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6static PROOFS_COLLECTED: AtomicU64 = AtomicU64::new(0);
7static PROOFS_WRITTEN: AtomicU64 = AtomicU64::new(0);
8static LAST_WRITTEN_UNIX_MS: AtomicU64 = AtomicU64::new(0);
9
10#[derive(Debug, Clone, Serialize)]
11pub struct ProofStatsSnapshot {
12 pub collected: u64,
13 pub written: u64,
14 pub last_written_at: Option<String>,
15}
16
17pub fn proof_stats_snapshot() -> ProofStatsSnapshot {
18 let collected = PROOFS_COLLECTED.load(Ordering::Relaxed);
19 let written = PROOFS_WRITTEN.load(Ordering::Relaxed);
20 let last_ms = LAST_WRITTEN_UNIX_MS.load(Ordering::Relaxed);
21 let last_written_at = if last_ms == 0 {
22 None
23 } else {
24 chrono::Utc
25 .timestamp_millis_opt(last_ms as i64)
26 .single()
27 .map(|t| t.to_rfc3339())
28 };
29 ProofStatsSnapshot {
30 collected,
31 written,
32 last_written_at,
33 }
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct ContextProofV1 {
38 pub schema_version: u32,
39 pub created_at: String,
40 pub lean_ctx_version: String,
41 pub session_id: Option<String>,
42 pub project: ProjectIdentity,
43 pub role: RoleIdentity,
44 pub profile: ProfileIdentity,
45 pub budgets: crate::core::budget_tracker::BudgetSnapshot,
46 pub slo: crate::core::slo::SloSnapshot,
47 pub pipeline: crate::core::pipeline::PipelineStats,
48 pub verification: crate::core::output_verification::VerificationSnapshot,
49 pub ledger: LedgerSummary,
50 pub evidence: Vec<EvidenceReceipt>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct ProjectIdentity {
55 pub project_root_hash: Option<String>,
56 pub project_identity_hash: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct RoleIdentity {
61 pub name: String,
62 pub policy_md5: String,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct ProfileIdentity {
67 pub name: String,
68 pub policy_md5: String,
69}
70
71#[derive(Debug, Clone, Serialize)]
72pub struct LedgerSummary {
73 pub window_size: usize,
74 pub entries: usize,
75 pub total_tokens_sent: usize,
76 pub total_tokens_saved: usize,
77 pub compression_ratio: f64,
78 pub top_files_by_sent_tokens: Vec<LedgerFileSummary>,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct LedgerFileSummary {
83 pub path: String,
84 pub mode: String,
85 pub sent_tokens: usize,
86 pub original_tokens: usize,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct EvidenceReceipt {
91 pub tool: Option<String>,
92 pub input_md5: Option<String>,
93 pub output_md5: Option<String>,
94 pub agent_id: Option<String>,
95 pub client_name: Option<String>,
96 pub timestamp: String,
97}
98
99#[derive(Debug, Clone, Copy, Default)]
100pub struct ProofOptions {
101 pub max_evidence: usize,
102 pub max_ledger_files: usize,
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct ProofSources {
107 pub project_root: Option<String>,
108 pub session: Option<crate::core::session::SessionState>,
109 pub pipeline: Option<crate::core::pipeline::PipelineStats>,
110 pub ledger: Option<crate::core::context_ledger::ContextLedger>,
111}
112
113pub fn collect_v1(sources: ProofSources, opts: ProofOptions) -> ContextProofV1 {
114 PROOFS_COLLECTED.fetch_add(1, Ordering::Relaxed);
115 let created_at = chrono::Utc::now().to_rfc3339();
116
117 let role_name = crate::core::roles::active_role_name();
118 let role = crate::core::roles::active_role();
119
120 let profile_name = crate::core::profiles::active_profile_name();
121 let profile = crate::core::profiles::active_profile();
122
123 let role_policy_md5 =
124 crate::core::hasher::hash_str(&serde_json::to_string(&role).unwrap_or_default());
125 let profile_policy_md5 =
126 crate::core::hasher::hash_str(&serde_json::to_string(&profile).unwrap_or_default());
127
128 let project_root = sources.project_root.clone().or_else(|| {
129 sources
130 .session
131 .as_ref()
132 .and_then(|s| s.project_root.clone())
133 });
134
135 let (project_root_hash, project_identity_hash) = if let Some(ref root) = project_root {
136 let root_hash = crate::core::project_hash::hash_project_root(root);
137 let identity = crate::core::project_hash::project_identity(root);
138 let identity_hash = identity.as_deref().map(crate::core::hasher::hash_str);
139 (Some(root_hash), identity_hash)
140 } else {
141 (None, None)
142 };
143
144 let budgets = crate::core::budget_tracker::BudgetTracker::global().check();
145 let slo = crate::core::slo::evaluate_quiet();
146 let verification = crate::core::output_verification::stats_snapshot();
147
148 let pipeline = sources
149 .pipeline
150 .unwrap_or_else(crate::core::pipeline::PipelineStats::load);
151
152 let ledger = sources
153 .ledger
154 .unwrap_or_else(crate::core::context_ledger::ContextLedger::load);
155
156 let top_files = ledger
157 .files_by_token_cost()
158 .into_iter()
159 .take(opts.max_ledger_files.max(1))
160 .filter_map(|(path, _)| ledger.entries.iter().find(|e| e.path == path).cloned())
161 .map(|e| LedgerFileSummary {
162 path: e.path,
163 mode: e.mode,
164 sent_tokens: e.sent_tokens,
165 original_tokens: e.original_tokens,
166 })
167 .collect::<Vec<_>>();
168
169 let ledger_summary = LedgerSummary {
170 window_size: ledger.window_size,
171 entries: ledger.entries.len(),
172 total_tokens_sent: ledger.total_tokens_sent,
173 total_tokens_saved: ledger.total_tokens_saved,
174 compression_ratio: ledger.compression_ratio(),
175 top_files_by_sent_tokens: top_files,
176 };
177
178 let session = sources.session.or_else(|| {
179 project_root
180 .as_deref()
181 .and_then(crate::core::session::SessionState::load_latest_for_project_root)
182 .or_else(crate::core::session::SessionState::load_latest)
183 });
184
185 let (session_id, evidence) = if let Some(s) = session {
186 let mut receipts = s
187 .evidence
188 .into_iter()
189 .filter(|e| matches!(e.kind, crate::core::session::EvidenceKind::ToolCall))
190 .rev()
191 .take(opts.max_evidence.max(1))
192 .map(|e| EvidenceReceipt {
193 tool: e.tool,
194 input_md5: e.input_md5,
195 output_md5: e.output_md5,
196 agent_id: e.agent_id,
197 client_name: e.client_name,
198 timestamp: e.timestamp.to_rfc3339(),
199 })
200 .collect::<Vec<_>>();
201 receipts.reverse();
202 (Some(s.id), receipts)
203 } else {
204 (None, Vec::new())
205 };
206
207 ContextProofV1 {
208 schema_version: crate::core::contracts::CONTEXT_PROOF_V1_SCHEMA_VERSION,
209 created_at,
210 lean_ctx_version: env!("CARGO_PKG_VERSION").to_string(),
211 session_id,
212 project: ProjectIdentity {
213 project_root_hash,
214 project_identity_hash,
215 },
216 role: RoleIdentity {
217 name: role_name,
218 policy_md5: role_policy_md5,
219 },
220 profile: ProfileIdentity {
221 name: profile_name,
222 policy_md5: profile_policy_md5,
223 },
224 budgets,
225 slo,
226 pipeline,
227 verification,
228 ledger: ledger_summary,
229 evidence,
230 }
231}
232
233pub fn write_project_proof(
234 project_root: &Path,
235 proof: &ContextProofV1,
236 filename: Option<&str>,
237) -> Result<PathBuf, String> {
238 let proofs_dir = project_root.join(".lean-ctx").join("proofs");
239 std::fs::create_dir_all(&proofs_dir).map_err(|e| e.to_string())?;
240
241 let ts = chrono::Utc::now().format("%Y-%m-%d_%H%M%S");
242 let name = filename.map_or_else(
243 || format!("context-proof-v1_{ts}.json"),
244 std::string::ToString::to_string,
245 );
246 let path = proofs_dir.join(name);
247
248 let json = serde_json::to_string_pretty(proof).map_err(|e| e.to_string())?;
249 let json = crate::core::redaction::redact_text(&json);
251 crate::config_io::write_atomic(&path, &json)?;
252
253 PROOFS_WRITTEN.fetch_add(1, Ordering::Relaxed);
254 let ms = std::time::SystemTime::now()
255 .duration_since(std::time::SystemTime::UNIX_EPOCH)
256 .map_or(0, |d| d.as_millis() as u64);
257 LAST_WRITTEN_UNIX_MS.store(ms, Ordering::Relaxed);
258
259 Ok(path)
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn proof_has_required_fields() {
268 let proof = collect_v1(
269 ProofSources {
270 project_root: Some(".".to_string()),
271 ..Default::default()
272 },
273 ProofOptions {
274 max_evidence: 5,
275 max_ledger_files: 3,
276 },
277 );
278 let v = serde_json::to_value(&proof).unwrap();
279 assert_eq!(v["schema_version"], 1);
280 assert!(v["created_at"].as_str().unwrap_or_default().contains('T'));
281 assert!(v["lean_ctx_version"].as_str().unwrap_or_default().len() >= 3);
282 }
283
284 #[test]
285 fn write_project_proof_creates_file() {
286 let dir = tempfile::tempdir().unwrap();
287 let proof = collect_v1(
288 ProofSources {
289 project_root: Some(dir.path().to_string_lossy().to_string()),
290 ..Default::default()
291 },
292 ProofOptions {
293 max_evidence: 3,
294 max_ledger_files: 2,
295 },
296 );
297 let path = write_project_proof(dir.path(), &proof, None).unwrap();
298 assert!(path.exists());
299 let content = std::fs::read_to_string(path).unwrap();
300 assert!(content.contains("\"schema_version\": 1"));
301 }
302}