1use std::collections::BTreeSet;
9use std::fmt::Write as _;
10use std::fs;
11use std::path::{Component, Path};
12
13use base64::{Engine as _, engine::general_purpose::STANDARD};
14use ed25519_dalek::{Signer, SigningKey};
15use serde_json::{Value, json};
16use sha2::{Digest, Sha256};
17
18const SCHEMA_VERSION: &str = "leanctx.customer-proof-evidence-bundle/v2";
19const MAX_ITEMS: usize = 128;
20const MAX_ITEM_BYTES: usize = 8 * 1024 * 1024;
21const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CustomerProofArtifactKind {
27 ArmReceipt,
28 ReceiptPredecessor,
29 QualityMeasurement,
30 ReplayInput,
31 ReplayResult,
32 RunMetadata,
33 ClaimBasis,
34 FrozenAuditBundleV1,
35 TaskEnvelope,
36 ExecutionPlan,
37 EngineInvocation,
38 EngineObservation,
39 AcceptedOutcome,
40 Measurement,
41 Assumption,
42 Formula,
43 PriceTable,
44 Invoice,
45 AcceptanceEvidence,
46}
47
48impl CustomerProofArtifactKind {
49 fn as_str(self) -> &'static str {
50 match self {
51 Self::ArmReceipt => "arm_receipt",
52 Self::ReceiptPredecessor => "receipt_predecessor",
53 Self::QualityMeasurement => "quality_measurement",
54 Self::ReplayInput => "replay_input",
55 Self::ReplayResult => "replay_result",
56 Self::RunMetadata => "run_metadata",
57 Self::ClaimBasis => "claim_basis",
58 Self::FrozenAuditBundleV1 => "frozen_audit_bundle_v1",
59 Self::TaskEnvelope => "task_envelope",
60 Self::ExecutionPlan => "execution_plan",
61 Self::EngineInvocation => "engine_invocation",
62 Self::EngineObservation => "engine_observation",
63 Self::AcceptedOutcome => "accepted_outcome",
64 Self::Measurement => "measurement",
65 Self::Assumption => "assumption",
66 Self::Formula => "formula",
67 Self::PriceTable => "price_table",
68 Self::Invoice => "invoice",
69 Self::AcceptanceEvidence => "acceptance_evidence",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum CustomerProofRedactionClass {
78 None,
79 Pseudonymized,
80 MetadataOnly,
81 ContentRemoved,
82 SecretRemoved,
83 Aggregated,
84}
85
86impl CustomerProofRedactionClass {
87 fn as_str(self) -> &'static str {
88 match self {
89 Self::None => "none",
90 Self::Pseudonymized => "pseudonymized",
91 Self::MetadataOnly => "metadata_only",
92 Self::ContentRemoved => "content_removed",
93 Self::SecretRemoved => "secret_removed",
94 Self::Aggregated => "aggregated",
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
102pub struct CustomerProofArtifact {
103 pub kind: CustomerProofArtifactKind,
104 pub path: String,
105 pub redaction_class: CustomerProofRedactionClass,
106 pub bytes: Vec<u8>,
107}
108
109impl CustomerProofArtifact {
110 #[must_use]
111 pub(crate) fn digest(&self) -> String {
112 sha256_digest(&self.bytes)
113 }
114
115 #[must_use]
116 pub(crate) fn reference(&self) -> String {
117 format!("id:{}", self.digest())
118 }
119}
120
121#[derive(Debug, Clone)]
128pub struct CustomerProofDraftV2 {
129 pub created_at: String,
130 pub status: String,
131 pub subject: Value,
132 pub matched_arms: Value,
133 pub quality: Value,
134 pub replay: Value,
135 pub limitations: Value,
136 pub redaction: Value,
137 pub claims: Value,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum CustomerProofTrustBasis {
144 CustomerConfigured,
145 OutOfBand,
146}
147
148impl CustomerProofTrustBasis {
149 fn as_str(self) -> &'static str {
150 match self {
151 Self::CustomerConfigured => "customer_configured",
152 Self::OutOfBand => "out_of_band",
153 }
154 }
155}
156
157#[derive(Clone, Copy)]
160pub struct CustomerProofSigner<'a> {
161 pub signing_key: &'a SigningKey,
162 pub trust_basis: CustomerProofTrustBasis,
163}
164
165#[derive(Debug, Clone)]
168pub struct AssembledCustomerProofV2 {
169 pub bundle_id: String,
170 pub bundle_digest: String,
171 pub(crate) canonical_json: Vec<u8>,
172 artifacts: Vec<CustomerProofArtifact>,
173}
174
175impl AssembledCustomerProofV2 {
176 pub fn write_to(&self, output: &Path) -> Result<(), String> {
179 if output.exists() {
180 return Err(format!(
181 "refusing to overwrite existing proof directory {}",
182 output.display()
183 ));
184 }
185 let parent = output
186 .parent()
187 .filter(|parent| !parent.as_os_str().is_empty())
188 .ok_or_else(|| format!("proof output {} has no parent directory", output.display()))?;
189 fs::create_dir_all(parent).map_err(|error| format!("create proof parent: {error}"))?;
190 let leaf = output
191 .file_name()
192 .and_then(|name| name.to_str())
193 .filter(|name| !name.is_empty())
194 .ok_or_else(|| "proof output must have a UTF-8 directory name".to_string())?;
195 let staging = parent.join(format!(".{leaf}.{}.staging", uuid::Uuid::new_v4().simple()));
196 fs::create_dir(&staging)
197 .map_err(|error| format!("create proof staging directory: {error}"))?;
198 let result = self.write_staging(&staging);
199 if let Err(error) = result {
200 let _ = fs::remove_dir_all(&staging);
201 return Err(error);
202 }
203 fs::rename(&staging, output).map_err(|error| {
204 let _ = fs::remove_dir_all(&staging);
205 format!("atomically publish proof directory: {error}")
206 })
207 }
208
209 fn write_staging(&self, staging: &Path) -> Result<(), String> {
210 fs::write(staging.join("customer-proof.json"), &self.canonical_json)
211 .map_err(|error| format!("write canonical proof JSON: {error}"))?;
212 for artifact in &self.artifacts {
213 let destination = staging.join(&artifact.path);
214 let parent = destination
215 .parent()
216 .ok_or_else(|| "artifact destination has no parent".to_string())?;
217 fs::create_dir_all(parent)
218 .map_err(|error| format!("create artifact directory: {error}"))?;
219 fs::write(destination, &artifact.bytes)
220 .map_err(|error| format!("write proof artifact: {error}"))?;
221 }
222 Ok(())
223 }
224}
225
226pub fn assemble_customer_proof_v2(
229 draft: &CustomerProofDraftV2,
230 artifacts: Vec<CustomerProofArtifact>,
231 signer: CustomerProofSigner<'_>,
232) -> Result<AssembledCustomerProofV2, String> {
233 validate_draft(draft)?;
234 let inventory = build_inventory(&artifacts)?;
235 validate_declared_artifact_refs(draft, &inventory.references)?;
236
237 let key_id = format!(
238 "id:{}",
239 sha256_digest(signer.signing_key.verifying_key().as_bytes())
240 );
241 let mut document = json!({
242 "schema_version": SCHEMA_VERSION,
243 "bundle_kind": "customer-proof",
244 "created_at": draft.created_at,
245 "status": draft.status,
246 "subject": draft.subject,
247 "matched_arms": draft.matched_arms,
248 "inventory": inventory.value,
249 "quality": draft.quality,
250 "replay": draft.replay,
251 "limitations": draft.limitations,
252 "redaction": draft.redaction,
253 "claims": draft.claims,
254 "signing": {
255 "algorithm": "Ed25519",
256 "trusted_signer_ref": format!("signer:{key_id}"),
257 "key_id": key_id,
258 "trust_basis": signer.trust_basis.as_str(),
259 "signed_digest": "",
260 "signature": ""
261 }
262 });
263 let unsigned = unsigned_document(&document)?;
264 let bundle_digest = sha256_digest(&crate::core::canonical::canonical_json_bytes(&unsigned));
265 let root = document
266 .as_object_mut()
267 .ok_or_else(|| "assembled proof must be an object".to_string())?;
268 root.insert(
269 "bundle_id".to_string(),
270 Value::String(format!("id:{bundle_digest}")),
271 );
272 root.insert(
273 "bundle_digest".to_string(),
274 Value::String(bundle_digest.clone()),
275 );
276 let signing = root
277 .get_mut("signing")
278 .and_then(Value::as_object_mut)
279 .ok_or_else(|| "assembled proof signing must be an object".to_string())?;
280 signing.insert(
281 "signed_digest".to_string(),
282 Value::String(bundle_digest.clone()),
283 );
284 signing.insert(
285 "signature".to_string(),
286 Value::String(
287 STANDARD.encode(
288 signer
289 .signing_key
290 .sign(&crate::core::canonical::canonical_json_bytes(&unsigned))
291 .to_bytes(),
292 ),
293 ),
294 );
295 Ok(AssembledCustomerProofV2 {
296 bundle_id: format!("id:{bundle_digest}"),
297 bundle_digest,
298 canonical_json: crate::core::canonical::canonical_json_bytes(&document),
299 artifacts,
300 })
301}
302
303struct Inventory {
304 value: Value,
305 references: BTreeSet<String>,
306}
307
308fn build_inventory(artifacts: &[CustomerProofArtifact]) -> Result<Inventory, String> {
309 if artifacts.is_empty() || artifacts.len() > MAX_ITEMS {
310 return Err("customer proof requires 1..=128 present artifacts".to_string());
311 }
312 let mut artifacts = artifacts.to_vec();
313 artifacts.sort_by(|left, right| left.path.cmp(&right.path));
314 let mut paths = BTreeSet::new();
315 let mut references = BTreeSet::new();
316 let mut total_bytes = 0_usize;
317 let items: Result<Vec<Value>, String> = artifacts
318 .iter()
319 .map(|artifact| {
320 if !is_safe_relative_path(&artifact.path) {
321 return Err(format!("unsafe proof artifact path {}", artifact.path));
322 }
323 if artifact.bytes.len() > MAX_ITEM_BYTES {
324 return Err(format!("proof artifact {} exceeds 8 MiB", artifact.path));
325 }
326 if !paths.insert(artifact.path.clone()) {
327 return Err(format!("duplicate proof artifact path {}", artifact.path));
328 }
329 total_bytes = total_bytes
330 .checked_add(artifact.bytes.len())
331 .ok_or_else(|| "proof artifact total overflows".to_string())?;
332 if total_bytes > MAX_TOTAL_BYTES {
333 return Err("proof artifacts exceed 64 MiB".to_string());
334 }
335 let digest = artifact.digest();
336 let reference = artifact.reference();
337 if !references.insert(reference.clone()) {
338 return Err(
339 "proof artifacts must not have duplicate content references".to_string()
340 );
341 }
342 Ok(json!({
343 "ref": reference,
344 "kind": artifact.kind.as_str(),
345 "path": artifact.path,
346 "digest": digest,
347 "size_bytes": artifact.bytes.len(),
348 "availability": "present",
349 "redaction_class": artifact.redaction_class.as_str(),
350 }))
351 })
352 .collect();
353 Ok(Inventory {
354 value: json!({
355 "max_items": MAX_ITEMS,
356 "item_count": artifacts.len(),
357 "total_bytes": total_bytes,
358 "items": items?,
359 }),
360 references,
361 })
362}
363
364fn validate_draft(draft: &CustomerProofDraftV2) -> Result<(), String> {
365 if !is_timestamp(&draft.created_at) {
366 return Err("customer proof created_at must be a UTC second timestamp".to_string());
367 }
368 if !matches!(draft.status.as_str(), "complete" | "partial" | "invalid") {
369 return Err("customer proof status is invalid".to_string());
370 }
371 for (name, value) in [
372 ("subject", &draft.subject),
373 ("matched_arms", &draft.matched_arms),
374 ("quality", &draft.quality),
375 ("replay", &draft.replay),
376 ("limitations", &draft.limitations),
377 ("redaction", &draft.redaction),
378 ] {
379 if !value.is_object() {
380 return Err(format!("customer proof {name} must be an object"));
381 }
382 }
383 if !draft.claims.is_array() {
384 return Err("customer proof claims must be an array".to_string());
385 }
386 Ok(())
387}
388
389fn validate_declared_artifact_refs(
390 draft: &CustomerProofDraftV2,
391 references: &BTreeSet<String>,
392) -> Result<(), String> {
393 let mut declared = Vec::new();
394 for arm in ["control", "treatment"] {
395 let refs = draft
396 .matched_arms
397 .pointer(&format!("/{arm}/evidence_refs"))
398 .ok_or_else(|| format!("matched_arms.{arm}.evidence_refs is required"))?;
399 declared.extend(string_refs(
400 refs,
401 &format!("matched_arms.{arm}.evidence_refs"),
402 )?);
403 }
404 declared.extend(string_refs(
405 draft
406 .quality
407 .get("evidence_refs")
408 .ok_or_else(|| "quality.evidence_refs is required".to_string())?,
409 "quality.evidence_refs",
410 )?);
411 for field in ["input_refs", "result_refs"] {
412 declared.extend(string_refs(
413 draft
414 .replay
415 .get(field)
416 .ok_or_else(|| format!("replay.{field} is required"))?,
417 &format!("replay.{field}"),
418 )?);
419 }
420 let claims = draft
421 .claims
422 .as_array()
423 .ok_or_else(|| "claims must be an array".to_string())?;
424 for (index, claim) in claims.iter().enumerate() {
425 declared.extend(string_refs(
426 claim
427 .get("basis_refs")
428 .ok_or_else(|| format!("claims[{index}].basis_refs is required"))?,
429 &format!("claims[{index}].basis_refs"),
430 )?);
431 }
432 if let Some(missing) = declared
433 .iter()
434 .find(|reference| !references.contains(*reference))
435 {
436 return Err(format!(
437 "proof body references artifact not supplied to assembler: {missing}"
438 ));
439 }
440 Ok(())
441}
442
443fn string_refs(value: &Value, label: &str) -> Result<Vec<String>, String> {
444 let values = value
445 .as_array()
446 .ok_or_else(|| format!("{label} must be an array"))?;
447 values
448 .iter()
449 .map(|value| {
450 value
451 .as_str()
452 .map(str::to_owned)
453 .ok_or_else(|| format!("{label} must contain only strings"))
454 })
455 .collect()
456}
457
458fn unsigned_document(document: &Value) -> Result<Value, String> {
459 let mut unsigned = document.clone();
460 let root = unsigned
461 .as_object_mut()
462 .ok_or_else(|| "proof document must be an object".to_string())?;
463 root.remove("bundle_id");
464 root.remove("bundle_digest");
465 let signing = root
466 .get_mut("signing")
467 .and_then(Value::as_object_mut)
468 .ok_or_else(|| "proof document signing must be an object".to_string())?;
469 signing.remove("signed_digest");
470 signing.remove("signature");
471 Ok(unsigned)
472}
473
474fn sha256_digest(bytes: &[u8]) -> String {
475 let digest = Sha256::digest(bytes);
476 let mut hex = String::with_capacity(digest.len() * 2);
477 for byte in digest {
478 write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail");
479 }
480 format!("sha256:{hex}")
481}
482
483fn is_safe_relative_path(path: &str) -> bool {
484 !path.is_empty()
485 && path.len() <= 256
486 && path
487 .bytes()
488 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
489 && !Path::new(path).is_absolute()
490 && Path::new(path)
491 .components()
492 .all(|component| matches!(component, Component::Normal(_)))
493}
494
495fn is_timestamp(value: &str) -> bool {
496 let bytes = value.as_bytes();
497 bytes.len() == 20
498 && bytes[4] == b'-'
499 && bytes[7] == b'-'
500 && bytes[10] == b'T'
501 && bytes[13] == b':'
502 && bytes[16] == b':'
503 && bytes[19] == b'Z'
504 && bytes
505 .iter()
506 .enumerate()
507 .filter(|(index, _)| !matches!(index, 4 | 7 | 10 | 13 | 16 | 19))
508 .all(|(_, byte)| byte.is_ascii_digit())
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn artifact(
516 path: &str,
517 kind: CustomerProofArtifactKind,
518 bytes: &[u8],
519 ) -> CustomerProofArtifact {
520 CustomerProofArtifact {
521 kind,
522 path: path.to_string(),
523 redaction_class: CustomerProofRedactionClass::MetadataOnly,
524 bytes: bytes.to_vec(),
525 }
526 }
527
528 fn draft(artifacts: &[CustomerProofArtifact]) -> CustomerProofDraftV2 {
529 let control = artifacts[0].reference();
530 let treatment = artifacts[1].reference();
531 let quality = artifacts[2].reference();
532 let replay_input = artifacts[3].reference();
533 let replay_result = artifacts[4].reference();
534 CustomerProofDraftV2 {
535 created_at: "2026-08-22T09:00:00Z".to_string(),
536 status: "complete".to_string(),
537 subject: json!({
538 "customer_ref": "customer:acme-labs",
539 "project_ref": "project:lean-ctx",
540 "workload_ref": "id:sha256:42d8afd8fea1184e1cb2a42075e602611597f2486636929f0a128fc907f489d5"
541 }),
542 matched_arms: json!({
543 "match_id": "id:sha256:edcaf288f759371516be564ba8e346981a0ed7068581bb11d1da3fd38e585864",
544 "match_basis": ["provider", "model", "source_commit", "workload_digest"],
545 "shared_identity": {"provider":"openai","model":"gpt-5.6-luna","source_commit":"git:6d4fefa595000000000000000000000000000000","workload_digest":"sha256:42d8afd8fea1184e1cb2a42075e602611597f2486636929f0a128fc907f489d5"},
546 "control": {"role":"control","arm_id":"id:sha256:660fb46674b8a16166026551e50b032a1ccf58a4d260c4bb4e42182350660830","identity":{"provider":"openai","model":"gpt-5.6-luna","source_commit":"git:6d4fefa595000000000000000000000000000000","workload_digest":"sha256:42d8afd8fea1184e1cb2a42075e602611597f2486636929f0a128fc907f489d5"},"status":"complete","measurements":{"input_tokens":12000,"cached_input_tokens":0,"output_tokens":900,"latency_ms":820,"cost":{"currency":"USD","amount_micros":1000000,"status":"observed"},"status":"observed"},"evidence_refs":[control]},
547 "treatment": {"role":"treatment","arm_id":"id:sha256:b90873fbdffe04dad346c267e9dbdcf3c7a6f121e917620bc80a2531b78e4691","identity":{"provider":"openai","model":"gpt-5.6-luna","source_commit":"git:6d4fefa595000000000000000000000000000000","workload_digest":"sha256:42d8afd8fea1184e1cb2a42075e602611597f2486636929f0a128fc907f489d5"},"status":"complete","measurements":{"input_tokens":8000,"cached_input_tokens":4000,"output_tokens":900,"latency_ms":790,"cost":{"currency":"USD","amount_micros":700000,"status":"observed"},"status":"observed"},"evidence_refs":[treatment]}
548 }),
549 quality: json!({"status":"preserved","metric":"score_milli","control_score_milli":920,"treatment_score_milli":920,"confidence":"high","method":"automated","evidence_refs":[quality]}),
550 replay: json!({"status":"partial","mode":"offline","determinism":"same_inputs_expected","input_refs":[replay_input],"result_refs":[replay_result],"notes":"fixture"}),
551 limitations: json!({"known_limitations":[],"unproven":["omission_before_capture"]}),
552 redaction: json!({"class":"metadata_only","policy":"content_minimization","reversible":false,"notes":"fixture"}),
553 claims: json!([
554 {"claim_id":"id:sha256:178e80b928614a26b58c35af69d77e01f917bbb85263ee1251106b050f0b8bfc","claim_type":"cost_reduction","statement":"Treatment cost was lower.","claim_validity":"supported","scope":"matched_run","basis_refs":[artifacts[0].reference(), artifacts[1].reference()]},
555 {"claim_id":"id:sha256:528e6c9b4f275560ef2456e5ca68601b124e215c3494055187467cc30a670371","claim_type":"quality_preserved","statement":"Treatment quality was preserved.","claim_validity":"supported","scope":"matched_run","basis_refs":[artifacts[2].reference()]}
556 ]),
557 }
558 }
559
560 fn artifacts() -> Vec<CustomerProofArtifact> {
561 vec![
562 artifact(
563 "arms/control.json",
564 CustomerProofArtifactKind::ArmReceipt,
565 b"control",
566 ),
567 artifact(
568 "arms/treatment.json",
569 CustomerProofArtifactKind::ArmReceipt,
570 b"treatment",
571 ),
572 artifact(
573 "quality/comparison.json",
574 CustomerProofArtifactKind::QualityMeasurement,
575 b"quality",
576 ),
577 artifact(
578 "replay/input.json",
579 CustomerProofArtifactKind::ReplayInput,
580 b"input",
581 ),
582 artifact(
583 "replay/result.json",
584 CustomerProofArtifactKind::ReplayResult,
585 b"result",
586 ),
587 ]
588 }
589
590 #[test]
591 fn assembly_is_deterministic_and_signs_canonical_body() {
592 let artifacts = artifacts();
593 let draft = draft(&artifacts);
594 let key = SigningKey::from_bytes(&[17; 32]);
595 let first = assemble_customer_proof_v2(
596 &draft,
597 artifacts.clone(),
598 CustomerProofSigner {
599 signing_key: &key,
600 trust_basis: CustomerProofTrustBasis::OutOfBand,
601 },
602 )
603 .unwrap();
604 let second = assemble_customer_proof_v2(
605 &draft,
606 artifacts,
607 CustomerProofSigner {
608 signing_key: &key,
609 trust_basis: CustomerProofTrustBasis::OutOfBand,
610 },
611 )
612 .unwrap();
613 assert_eq!(first.canonical_json, second.canonical_json);
614 assert_eq!(first.bundle_id, format!("id:{}", first.bundle_digest));
615 }
616
617 #[test]
618 fn write_to_publishes_a_complete_new_proof_directory() {
619 let artifacts = artifacts();
620 let signing_key = SigningKey::from_bytes(&[7_u8; 32]);
621 let assembled = assemble_customer_proof_v2(
622 &draft(&artifacts),
623 artifacts.clone(),
624 CustomerProofSigner {
625 signing_key: &signing_key,
626 trust_basis: CustomerProofTrustBasis::CustomerConfigured,
627 },
628 )
629 .unwrap();
630 let temporary = tempfile::tempdir().unwrap();
631 let output = temporary.path().join("proof");
632
633 assembled.write_to(&output).unwrap();
634
635 assert_eq!(
636 fs::read(output.join("customer-proof.json")).unwrap(),
637 assembled.canonical_json
638 );
639 assert_eq!(
640 fs::read(output.join(&artifacts[0].path)).unwrap(),
641 artifacts[0].bytes
642 );
643 assert!(assembled.write_to(&output).is_err());
644 }
645
646 #[test]
647 fn assembly_rejects_dangling_or_unsafe_artifact_inputs() {
648 let mut artifacts = artifacts();
649 artifacts[0].path = "../escape.json".to_string();
650 let draft = draft(&artifacts);
651 let key = SigningKey::from_bytes(&[17; 32]);
652 let error = assemble_customer_proof_v2(
653 &draft,
654 artifacts,
655 CustomerProofSigner {
656 signing_key: &key,
657 trust_basis: CustomerProofTrustBasis::CustomerConfigured,
658 },
659 )
660 .unwrap_err();
661 assert!(error.contains("unsafe proof artifact path"));
662 }
663}