1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
10use logicpearl_core::{LogicPearlError, Result, RuleMask};
11use logicpearl_ir::{ComparisonOperator, Expression, LogicPearlGateIr};
12use logicpearl_runtime::{evaluate_gate, parse_input_payload};
13use rand_core::OsRng;
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16use sha2::{Digest, Sha256};
17use std::collections::{BTreeMap, BTreeSet, HashMap};
18use std::fs;
19use std::path::Path;
20use std::process::Command;
21use tempfile::NamedTempFile;
22use zeroize::{Zeroize, ZeroizeOnDrop};
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct FileFingerprint {
26 pub path: String,
27 pub size_bytes: u64,
28 pub mtime_ns: u128,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct ArtifactManifest {
33 pub manifest_version: String,
34 pub generated_at: String,
35 pub source_control: BTreeMap<String, String>,
36 pub source_files: BTreeMap<String, FileFingerprint>,
37 pub data_files: BTreeMap<String, FileFingerprint>,
38 pub artifacts: BTreeMap<String, FileFingerprint>,
39}
40
41#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
42pub struct FreshnessReport {
43 pub fresh: bool,
44 pub problems: Vec<String>,
45}
46
47#[derive(Debug, Clone, Serialize, PartialEq)]
48pub struct RuntimeParityReport {
49 pub total_rows: usize,
50 pub matching_rows: usize,
51 pub parity: f64,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct DecisionTraceRow {
56 pub features: BTreeMap<String, Value>,
57 pub allowed: bool,
58}
59
60#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
61pub struct ReceiptSigningKeyFile {
62 pub algorithm: String,
63 #[serde(skip_serializing)]
64 pub secret_key_hex: String,
65 pub public_key_hex: String,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ReceiptPublicKeyFile {
70 pub algorithm: String,
71 pub public_key_hex: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DecisionReceipt {
76 pub receipt_version: String,
77 pub generated_at: String,
78 pub gate_id: String,
79 pub pearl_ir_sha256: String,
80 pub input_sha256: String,
81 pub bitmasks: Vec<RuleMask>,
82 pub all_allowed: bool,
83 #[serde(default)]
84 pub native_cross_check: Option<ReceiptNativeCrossCheck>,
85 pub signer_public_key_hex: String,
86 pub signature_hex: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ReceiptNativeCrossCheck {
91 pub verified: bool,
92 pub native_binary_sha256: String,
93}
94
95#[derive(Debug, Clone, Serialize)]
96pub struct ReceiptVerificationReport {
97 pub valid: bool,
98 pub problems: Vec<String>,
99}
100
101#[derive(Debug, Clone, Serialize)]
102pub struct RuntimeCrossCheckRow {
103 pub row_index: usize,
104 pub runtime_bitmask: RuleMask,
105 pub native_bitmask: RuleMask,
106 pub expected_allowed: bool,
107 pub features: BTreeMap<String, Value>,
108}
109
110#[derive(Debug, Clone, Serialize)]
111pub struct RuntimeCrossCheckReport {
112 pub total_rows: usize,
113 pub runtime_matching_rows: usize,
114 pub native_matching_rows: usize,
115 pub runtime_parity: f64,
116 pub native_parity: f64,
117 pub runtime_native_matching_rows: usize,
118 pub runtime_native_parity: f64,
119 pub disagreements: Vec<RuntimeCrossCheckRow>,
120}
121
122#[derive(Debug, Clone, Serialize)]
123pub struct ReviewPack {
124 pub total_rows: usize,
125 pub matching_rows: usize,
126 pub parity: f64,
127 pub mismatch_count: usize,
128 pub mismatches: Vec<ReviewMismatch>,
129 pub boundary_scenarios: Vec<BoundaryScenario>,
130}
131
132#[derive(Debug, Clone, Serialize)]
133pub struct ReviewMismatch {
134 pub row_index: usize,
135 pub runtime_bitmask: RuleMask,
136 pub expected_allowed: bool,
137 pub predicted_allowed: bool,
138 pub triggered_rule_ids: Vec<String>,
139 pub features: BTreeMap<String, Value>,
140}
141
142#[derive(Debug, Clone, Serialize)]
143pub struct BoundaryScenario {
144 pub scenario_id: String,
145 pub rule_id: String,
146 pub feature: String,
147 pub rationale: String,
148 pub features: BTreeMap<String, Value>,
149 pub runtime_bitmask: RuleMask,
150 pub triggered_rule_ids: Vec<String>,
151}
152
153pub fn status() -> Result<&'static str> {
154 Ok("artifact manifest validation, signed receipts, review packs, and runtime cross-checks available")
155}
156
157pub fn fingerprint_path(path: &Path) -> Result<FileFingerprint> {
158 let stat = path.metadata()?;
159 Ok(FileFingerprint {
160 path: path.display().to_string(),
161 size_bytes: stat.len(),
162 mtime_ns: stat
163 .modified()?
164 .duration_since(std::time::UNIX_EPOCH)
165 .map_err(|err| LogicPearlError::message(format!("could not fingerprint mtime: {err}")))?
166 .as_nanos(),
167 })
168}
169
170pub fn write_artifact_manifest(manifest: &ArtifactManifest, path: &Path) -> Result<()> {
171 fs::write(path, serde_json::to_string_pretty(manifest)? + "\n")?;
172 Ok(())
173}
174
175pub fn build_artifact_manifest(
176 generated_at: String,
177 source_control: BTreeMap<String, String>,
178 source_files: BTreeMap<String, String>,
179 data_files: BTreeMap<String, String>,
180 artifacts: BTreeMap<String, String>,
181) -> Result<ArtifactManifest> {
182 Ok(ArtifactManifest {
183 manifest_version: "1.0".to_string(),
184 generated_at,
185 source_control,
186 source_files: fingerprint_group(source_files)?,
187 data_files: fingerprint_group(data_files)?,
188 artifacts: fingerprint_group(artifacts)?,
189 })
190}
191
192pub fn load_artifact_manifest(path: &Path) -> Result<ArtifactManifest> {
193 Ok(serde_json::from_str(&fs::read_to_string(path)?)?)
194}
195
196pub fn validate_artifact_manifest(path: &Path) -> Result<FreshnessReport> {
197 let manifest = load_artifact_manifest(path)?;
198 let mut problems = Vec::new();
199 validate_group("source_files", &manifest.source_files, &mut problems);
200 validate_group("data_files", &manifest.data_files, &mut problems);
201 validate_group("artifacts", &manifest.artifacts, &mut problems);
202 Ok(FreshnessReport {
203 fresh: problems.is_empty(),
204 problems,
205 })
206}
207
208pub fn compare_runtime_parity(
209 gate: &LogicPearlGateIr,
210 rows: &[DecisionTraceRow],
211) -> Result<RuntimeParityReport> {
212 if rows.is_empty() {
213 return Err(LogicPearlError::message(
214 "runtime parity requires at least one labeled decision trace row",
215 ));
216 }
217 let runtime_bitmasks = evaluate_rows(gate, rows)?;
218 let matching_rows = runtime_bitmasks
219 .iter()
220 .zip(rows)
221 .filter(|(bitmask, row)| bitmask.is_zero() == row.allowed)
222 .count();
223 let parity = matching_rows as f64 / rows.len() as f64;
224 Ok(RuntimeParityReport {
225 total_rows: rows.len(),
226 matching_rows,
227 parity,
228 })
229}
230
231pub fn cross_check_runtime_with_native_binary(
232 gate: &LogicPearlGateIr,
233 native_binary: &Path,
234 rows: &[DecisionTraceRow],
235) -> Result<RuntimeCrossCheckReport> {
236 if rows.is_empty() {
237 return Err(LogicPearlError::message(
238 "runtime cross-check requires at least one labeled decision trace row",
239 ));
240 }
241
242 let runtime_bitmasks = evaluate_rows(gate, rows)?;
243 let native_bitmasks = evaluate_native_binary(native_binary, rows)?;
244 if native_bitmasks.len() != rows.len() {
245 return Err(LogicPearlError::message(format!(
246 "native binary returned {} results for {} input rows",
247 native_bitmasks.len(),
248 rows.len()
249 )));
250 }
251
252 let mut runtime_matching_rows = 0usize;
253 let mut native_matching_rows = 0usize;
254 let mut runtime_native_matching_rows = 0usize;
255 let mut disagreements = Vec::new();
256
257 for (index, ((runtime_bitmask, native_bitmask), row)) in runtime_bitmasks
258 .iter()
259 .zip(&native_bitmasks)
260 .zip(rows.iter())
261 .enumerate()
262 {
263 let runtime_allowed = runtime_bitmask.is_zero();
264 let native_allowed = native_bitmask.is_zero();
265 if runtime_allowed == row.allowed {
266 runtime_matching_rows += 1;
267 }
268 if native_allowed == row.allowed {
269 native_matching_rows += 1;
270 }
271 if runtime_bitmask == native_bitmask {
272 runtime_native_matching_rows += 1;
273 } else {
274 disagreements.push(RuntimeCrossCheckRow {
275 row_index: index,
276 runtime_bitmask: runtime_bitmask.clone(),
277 native_bitmask: native_bitmask.clone(),
278 expected_allowed: row.allowed,
279 features: row.features.clone(),
280 });
281 }
282 }
283
284 Ok(RuntimeCrossCheckReport {
285 total_rows: rows.len(),
286 runtime_matching_rows,
287 native_matching_rows,
288 runtime_parity: runtime_matching_rows as f64 / rows.len() as f64,
289 native_parity: native_matching_rows as f64 / rows.len() as f64,
290 runtime_native_matching_rows,
291 runtime_native_parity: runtime_native_matching_rows as f64 / rows.len() as f64,
292 disagreements,
293 })
294}
295
296pub fn build_review_pack(
297 gate: &LogicPearlGateIr,
298 rows: &[DecisionTraceRow],
299 max_boundary_scenarios: usize,
300) -> Result<ReviewPack> {
301 if rows.is_empty() {
302 return Err(LogicPearlError::message(
303 "review pack generation requires at least one labeled decision trace row",
304 ));
305 }
306
307 let runtime_bitmasks = evaluate_rows(gate, rows)?;
308 let matching_rows = runtime_bitmasks
309 .iter()
310 .zip(rows)
311 .filter(|(bitmask, row)| bitmask.is_zero() == row.allowed)
312 .count();
313
314 let mismatches: Vec<ReviewMismatch> = runtime_bitmasks
315 .iter()
316 .zip(rows.iter())
317 .enumerate()
318 .filter_map(|(index, (bitmask, row))| {
319 let predicted_allowed = bitmask.is_zero();
320 if predicted_allowed == row.allowed {
321 return None;
322 }
323 Some(ReviewMismatch {
324 row_index: index,
325 runtime_bitmask: bitmask.clone(),
326 expected_allowed: row.allowed,
327 predicted_allowed,
328 triggered_rule_ids: triggered_rule_ids(gate, bitmask),
329 features: row.features.clone(),
330 })
331 })
332 .collect();
333
334 let baseline = baseline_features(rows);
335 let boundary_scenarios = generate_boundary_scenarios(gate, &baseline, max_boundary_scenarios)?;
336
337 Ok(ReviewPack {
338 total_rows: rows.len(),
339 matching_rows,
340 parity: matching_rows as f64 / rows.len() as f64,
341 mismatch_count: mismatches.len(),
342 mismatches,
343 boundary_scenarios,
344 })
345}
346
347pub fn generate_receipt_keypair() -> ReceiptSigningKeyFile {
348 let signing_key = SigningKey::generate(&mut OsRng);
349 let verifying_key = signing_key.verifying_key();
350 ReceiptSigningKeyFile {
351 algorithm: "ed25519".to_string(),
352 secret_key_hex: hex::encode(signing_key.to_bytes()),
353 public_key_hex: hex::encode(verifying_key.to_bytes()),
354 }
355}
356
357pub fn public_key_from_signing_key(
358 keypair: &ReceiptSigningKeyFile,
359) -> Result<ReceiptPublicKeyFile> {
360 validate_key_algorithm(&keypair.algorithm)?;
361 let signing_key = signing_key_from_file(keypair)?;
362 Ok(ReceiptPublicKeyFile {
363 algorithm: "ed25519".to_string(),
364 public_key_hex: hex::encode(signing_key.verifying_key().to_bytes()),
365 })
366}
367
368pub fn create_signed_decision_receipt(
369 gate: &LogicPearlGateIr,
370 pearl_ir_path: &Path,
371 input_payload: &Value,
372 signing_key_file: &ReceiptSigningKeyFile,
373 native_binary_path: Option<&Path>,
374 generated_at: String,
375) -> Result<DecisionReceipt> {
376 validate_key_algorithm(&signing_key_file.algorithm)?;
377 let signing_key = signing_key_from_file(signing_key_file)?;
378 let inputs = parse_input_payload(input_payload.clone())?;
379 let bitmasks = evaluate_payloads(gate, &inputs)?;
380 let pearl_ir_sha256 = sha256_hex_path(pearl_ir_path)?;
381 let input_sha256 = sha256_hex_bytes(&serde_json::to_vec(input_payload)?);
382 let native_cross_check = if let Some(path) = native_binary_path {
383 let native_bitmasks = evaluate_native_binary_payload(path, input_payload)?;
384 if native_bitmasks != bitmasks {
385 return Err(LogicPearlError::message(
386 "native binary disagreed with the runtime while generating the receipt",
387 ));
388 }
389 Some(ReceiptNativeCrossCheck {
390 verified: true,
391 native_binary_sha256: sha256_hex_path(path)?,
392 })
393 } else {
394 None
395 };
396
397 let unsigned = UnsignedDecisionReceipt {
398 receipt_version: "1.0".to_string(),
399 generated_at,
400 gate_id: gate.gate_id.clone(),
401 pearl_ir_sha256,
402 input_sha256,
403 bitmasks,
404 all_allowed: true,
405 native_cross_check,
406 signer_public_key_hex: hex::encode(signing_key.verifying_key().to_bytes()),
407 };
408 let unsigned = UnsignedDecisionReceipt {
409 all_allowed: unsigned.bitmasks.iter().all(RuleMask::is_zero),
410 ..unsigned
411 };
412 let signature = signing_key.sign(&serde_json::to_vec(&unsigned)?);
413 Ok(DecisionReceipt {
414 receipt_version: unsigned.receipt_version,
415 generated_at: unsigned.generated_at,
416 gate_id: unsigned.gate_id,
417 pearl_ir_sha256: unsigned.pearl_ir_sha256,
418 input_sha256: unsigned.input_sha256,
419 bitmasks: unsigned.bitmasks,
420 all_allowed: unsigned.all_allowed,
421 native_cross_check: unsigned.native_cross_check,
422 signer_public_key_hex: unsigned.signer_public_key_hex,
423 signature_hex: hex::encode(signature.to_bytes()),
424 })
425}
426
427pub fn verify_decision_receipt(
428 receipt: &DecisionReceipt,
429 public_key: &ReceiptPublicKeyFile,
430) -> Result<ReceiptVerificationReport> {
431 validate_key_algorithm(&public_key.algorithm)?;
432 let mut problems = Vec::new();
433 if receipt.signer_public_key_hex != public_key.public_key_hex {
434 problems
435 .push("receipt signer public key did not match the provided public key".to_string());
436 }
437
438 let verifying_key = verifying_key_from_file(public_key)?;
439 let signature_bytes = hex::decode(&receipt.signature_hex)
440 .map_err(|err| LogicPearlError::message(format!("invalid signature hex: {err}")))?;
441 let signature = Signature::from_slice(&signature_bytes)
442 .map_err(|err| LogicPearlError::message(format!("invalid signature bytes: {err}")))?;
443 let unsigned = UnsignedDecisionReceipt {
444 receipt_version: receipt.receipt_version.clone(),
445 generated_at: receipt.generated_at.clone(),
446 gate_id: receipt.gate_id.clone(),
447 pearl_ir_sha256: receipt.pearl_ir_sha256.clone(),
448 input_sha256: receipt.input_sha256.clone(),
449 bitmasks: receipt.bitmasks.clone(),
450 all_allowed: receipt.all_allowed,
451 native_cross_check: receipt.native_cross_check.clone(),
452 signer_public_key_hex: receipt.signer_public_key_hex.clone(),
453 };
454
455 if let Err(err) = verifying_key.verify(&serde_json::to_vec(&unsigned)?, &signature) {
456 problems.push(format!("signature verification failed: {err}"));
457 }
458
459 Ok(ReceiptVerificationReport {
460 valid: problems.is_empty(),
461 problems,
462 })
463}
464
465pub fn write_receipt_signing_key(keypair: &ReceiptSigningKeyFile, path: &Path) -> Result<()> {
466 let mut map = serde_json::Map::new();
469 map.insert("algorithm".into(), Value::String(keypair.algorithm.clone()));
470 map.insert(
471 "secret_key_hex".into(),
472 Value::String(keypair.secret_key_hex.clone()),
473 );
474 map.insert(
475 "public_key_hex".into(),
476 Value::String(keypair.public_key_hex.clone()),
477 );
478 fs::write(path, serde_json::to_string_pretty(&map)? + "\n")?;
479 Ok(())
480}
481
482pub fn write_receipt_public_key(public_key: &ReceiptPublicKeyFile, path: &Path) -> Result<()> {
483 fs::write(path, serde_json::to_string_pretty(public_key)? + "\n")?;
484 Ok(())
485}
486
487pub fn load_receipt_signing_key(path: &Path) -> Result<ReceiptSigningKeyFile> {
488 Ok(serde_json::from_str(&fs::read_to_string(path)?)?)
489}
490
491pub fn load_receipt_public_key(path: &Path) -> Result<ReceiptPublicKeyFile> {
492 Ok(serde_json::from_str(&fs::read_to_string(path)?)?)
493}
494
495pub fn write_review_pack(review_pack: &ReviewPack, path: &Path) -> Result<()> {
496 fs::write(path, serde_json::to_string_pretty(review_pack)? + "\n")?;
497 Ok(())
498}
499
500fn evaluate_rows(gate: &LogicPearlGateIr, rows: &[DecisionTraceRow]) -> Result<Vec<RuleMask>> {
501 rows.iter()
502 .map(|row| {
503 evaluate_gate(
504 gate,
505 &row.features.clone().into_iter().collect::<HashMap<_, _>>(),
506 )
507 })
508 .collect()
509}
510
511fn evaluate_payloads(
512 gate: &LogicPearlGateIr,
513 inputs: &[HashMap<String, Value>],
514) -> Result<Vec<RuleMask>> {
515 inputs
516 .iter()
517 .map(|input| evaluate_gate(gate, input))
518 .collect()
519}
520
521fn evaluate_native_binary(
522 native_binary: &Path,
523 rows: &[DecisionTraceRow],
524) -> Result<Vec<RuleMask>> {
525 let payload = Value::Array(
526 rows.iter()
527 .map(|row| {
528 let mut object = Map::new();
529 for (key, value) in &row.features {
530 object.insert(key.clone(), value.clone());
531 }
532 Value::Object(object)
533 })
534 .collect(),
535 );
536 evaluate_native_binary_payload(native_binary, &payload)
537}
538
539fn evaluate_native_binary_payload(native_binary: &Path, payload: &Value) -> Result<Vec<RuleMask>> {
540 let mut temp = NamedTempFile::new()?;
541 serde_json::to_writer_pretty(temp.as_file_mut(), payload)?;
542 let output = Command::new(native_binary)
543 .arg(temp.path())
544 .output()
545 .map_err(|err| {
546 LogicPearlError::message(format!(
547 "failed to execute native binary {}: {err}",
548 native_binary.display()
549 ))
550 })?;
551 if !output.status.success() {
552 return Err(LogicPearlError::message(format!(
553 "native binary {} failed: {}",
554 native_binary.display(),
555 String::from_utf8_lossy(&output.stderr).trim()
556 )));
557 }
558
559 let stdout = String::from_utf8_lossy(&output.stdout);
560 let expected_outputs = match payload {
561 Value::Array(items) => items.len(),
562 _ => 1,
563 };
564 parse_native_bitmask_output(stdout.trim(), expected_outputs)
565}
566
567fn parse_native_bitmask_output(raw: &str, expected_outputs: usize) -> Result<Vec<RuleMask>> {
568 if raw.is_empty() {
569 return Err(LogicPearlError::message(
570 "native binary produced empty output",
571 ));
572 }
573 let value: Value = serde_json::from_str(raw)?;
574 if expected_outputs <= 1 {
575 return Ok(vec![RuleMask::from_json_value(&value)?]);
576 }
577 let Value::Array(items) = value else {
578 return Err(LogicPearlError::message(
579 "native binary did not return a JSON array for batched evaluation",
580 ));
581 };
582 items
583 .iter()
584 .map(RuleMask::from_json_value)
585 .collect::<Result<Vec<_>>>()
586}
587
588fn baseline_features(rows: &[DecisionTraceRow]) -> BTreeMap<String, Value> {
589 let mut baseline = BTreeMap::new();
590 for row in rows {
591 for (key, value) in &row.features {
592 baseline.entry(key.clone()).or_insert_with(|| value.clone());
593 }
594 }
595 baseline
596}
597
598fn generate_boundary_scenarios(
599 gate: &LogicPearlGateIr,
600 baseline: &BTreeMap<String, Value>,
601 max_boundary_scenarios: usize,
602) -> Result<Vec<BoundaryScenario>> {
603 let mut scenarios = Vec::new();
604 let mut seen = BTreeSet::new();
605
606 for rule in &gate.rules {
607 let Expression::Comparison(comparison) = &rule.deny_when else {
608 continue;
609 };
610 let Some(threshold) = comparison.value.literal().and_then(Value::as_f64) else {
611 continue;
612 };
613 if !matches!(
614 comparison.op,
615 ComparisonOperator::Gt
616 | ComparisonOperator::Gte
617 | ComparisonOperator::Lt
618 | ComparisonOperator::Lte
619 | ComparisonOperator::Eq
620 ) {
621 continue;
622 }
623
624 for (candidate, rationale_suffix) in numeric_boundary_candidates(threshold) {
625 let signature = format!("{}:{candidate}", rule.id);
626 if !seen.insert(signature) {
627 continue;
628 }
629 let mut features = baseline.clone();
630 features.insert(comparison.feature.clone(), Value::from(candidate));
631 let bitmask = evaluate_gate(
632 gate,
633 &features.clone().into_iter().collect::<HashMap<_, _>>(),
634 )?;
635 scenarios.push(BoundaryScenario {
636 scenario_id: format!("{}-{}", rule.id, scenarios.len()),
637 rule_id: rule.id.clone(),
638 feature: comparison.feature.clone(),
639 rationale: format!(
640 "Review {} around threshold {} ({})",
641 comparison.feature, threshold, rationale_suffix
642 ),
643 triggered_rule_ids: triggered_rule_ids(gate, &bitmask),
644 runtime_bitmask: bitmask,
645 features,
646 });
647 if scenarios.len() >= max_boundary_scenarios {
648 return Ok(scenarios);
649 }
650 }
651 }
652
653 Ok(scenarios)
654}
655
656fn numeric_boundary_candidates(threshold: f64) -> Vec<(f64, &'static str)> {
657 vec![
658 (threshold - 1.0, "just below"),
659 (threshold, "at threshold"),
660 (threshold + 1.0, "just above"),
661 ]
662}
663
664fn triggered_rule_ids(gate: &LogicPearlGateIr, bitmask: &RuleMask) -> Vec<String> {
665 gate.rules
666 .iter()
667 .filter(|rule| bitmask.test_bit(rule.bit))
668 .map(|rule| rule.id.clone())
669 .collect()
670}
671
672fn validate_group(
673 group_name: &str,
674 fingerprints: &BTreeMap<String, FileFingerprint>,
675 problems: &mut Vec<String>,
676) {
677 for (label, fingerprint) in fingerprints {
678 let path = Path::new(&fingerprint.path);
679 if !path.exists() {
680 problems.push(format!("{group_name}:{label} missing: {}", path.display()));
681 continue;
682 }
683 match fingerprint_path(path) {
684 Ok(actual) => {
685 if actual.size_bytes != fingerprint.size_bytes
686 || actual.mtime_ns != fingerprint.mtime_ns
687 {
688 problems.push(format!(
689 "{group_name}:{label} changed: {} (expected size={}, mtime_ns={}; found size={}, mtime_ns={})",
690 path.display(),
691 fingerprint.size_bytes,
692 fingerprint.mtime_ns,
693 actual.size_bytes,
694 actual.mtime_ns
695 ));
696 }
697 }
698 Err(err) => {
699 problems.push(format!(
700 "{group_name}:{label} could not be fingerprinted: {} ({err})",
701 path.display()
702 ));
703 }
704 }
705 }
706}
707
708fn fingerprint_group(
709 entries: BTreeMap<String, String>,
710) -> Result<BTreeMap<String, FileFingerprint>> {
711 entries
712 .into_iter()
713 .map(|(label, path)| {
714 let fingerprint = fingerprint_path(Path::new(&path))?;
715 Ok((label, fingerprint))
716 })
717 .collect()
718}
719
720fn validate_key_algorithm(algorithm: &str) -> Result<()> {
721 if algorithm == "ed25519" {
722 Ok(())
723 } else {
724 Err(LogicPearlError::message(format!(
725 "unsupported receipt signing algorithm: {algorithm}"
726 )))
727 }
728}
729
730fn signing_key_from_file(keypair: &ReceiptSigningKeyFile) -> Result<SigningKey> {
731 let secret = hex::decode(&keypair.secret_key_hex)
732 .map_err(|err| LogicPearlError::message(format!("invalid secret key hex: {err}")))?;
733 let bytes: [u8; 32] = secret
734 .try_into()
735 .map_err(|_| LogicPearlError::message("secret key must be 32 bytes"))?;
736 Ok(SigningKey::from_bytes(&bytes))
737}
738
739fn verifying_key_from_file(public_key: &ReceiptPublicKeyFile) -> Result<VerifyingKey> {
740 let public = hex::decode(&public_key.public_key_hex)
741 .map_err(|err| LogicPearlError::message(format!("invalid public key hex: {err}")))?;
742 let bytes: [u8; 32] = public
743 .try_into()
744 .map_err(|_| LogicPearlError::message("public key must be 32 bytes"))?;
745 VerifyingKey::from_bytes(&bytes)
746 .map_err(|err| LogicPearlError::message(format!("invalid public key: {err}")))
747}
748
749fn sha256_hex_path(path: &Path) -> Result<String> {
750 sha256_hex_bytes(&fs::read(path)?).pipe(Ok)
751}
752
753fn sha256_hex_bytes(bytes: &[u8]) -> String {
754 let mut hasher = Sha256::new();
755 hasher.update(bytes);
756 hex::encode(hasher.finalize())
757}
758
759#[derive(Debug, Clone, Serialize)]
760struct UnsignedDecisionReceipt {
761 receipt_version: String,
762 generated_at: String,
763 gate_id: String,
764 pearl_ir_sha256: String,
765 input_sha256: String,
766 bitmasks: Vec<RuleMask>,
767 all_allowed: bool,
768 #[serde(skip_serializing_if = "Option::is_none")]
769 native_cross_check: Option<ReceiptNativeCrossCheck>,
770 signer_public_key_hex: String,
771}
772
773trait Pipe: Sized {
774 fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
775 f(self)
776 }
777}
778
779impl<T> Pipe for T {}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784 use logicpearl_ir::LogicPearlGateIr;
785 use serde_json::json;
786
787 fn simple_gate() -> LogicPearlGateIr {
788 LogicPearlGateIr::from_json_str(
789 &serde_json::to_string(&json!({
790 "ir_version": "1.0",
791 "gate_id": "demo",
792 "gate_type": "bitmask_gate",
793 "input_schema": {
794 "features": [
795 {"id": "flag", "type": "int", "description": null, "values": null, "min": null, "max": null, "editable": null}
796 ]
797 },
798 "rules": [{
799 "id": "rule_000",
800 "kind": "predicate",
801 "bit": 0,
802 "deny_when": {"feature": "flag", "op": ">", "value": 0},
803 "label": null,
804 "message": null,
805 "severity": null,
806 "counterfactual_hint": null,
807 "verification_status": "pipeline_unverified"
808 }],
809 "evaluation": {"combine": "bitwise_or", "allow_when_bitmask": 0},
810 "verification": null,
811 "provenance": null
812 }))
813 .unwrap(),
814 )
815 .unwrap()
816 }
817
818 #[test]
819 fn validates_fresh_manifest() {
820 let dir = tempfile::tempdir().unwrap();
821 let source = dir.path().join("source.txt");
822 std::fs::write(&source, "hello\n").unwrap();
823 let manifest_path = dir.path().join("artifact_manifest.json");
824 let fingerprint = fingerprint_path(&source).unwrap();
825 let manifest = ArtifactManifest {
826 manifest_version: "1.0".to_string(),
827 generated_at: "2026-01-01T00:00:00Z".to_string(),
828 source_control: BTreeMap::new(),
829 source_files: BTreeMap::from([("source".to_string(), fingerprint)]),
830 data_files: BTreeMap::new(),
831 artifacts: BTreeMap::new(),
832 };
833 std::fs::write(
834 &manifest_path,
835 serde_json::to_string_pretty(&manifest).unwrap(),
836 )
837 .unwrap();
838
839 let report = validate_artifact_manifest(&manifest_path).unwrap();
840 assert!(report.fresh);
841 assert!(report.problems.is_empty());
842 }
843
844 #[test]
845 fn runtime_parity_matches_simple_gate() {
846 let gate = simple_gate();
847 let rows = vec![
848 DecisionTraceRow {
849 features: BTreeMap::from([("flag".to_string(), Value::from(0))]),
850 allowed: true,
851 },
852 DecisionTraceRow {
853 features: BTreeMap::from([("flag".to_string(), Value::from(1))]),
854 allowed: false,
855 },
856 ];
857
858 let report = compare_runtime_parity(&gate, &rows).unwrap();
859 assert_eq!(report.total_rows, 2);
860 assert_eq!(report.matching_rows, 2);
861 assert_eq!(report.parity, 1.0);
862 }
863
864 #[test]
865 fn review_pack_includes_mismatches_and_boundary_cases() {
866 let gate = simple_gate();
867 let rows = vec![
868 DecisionTraceRow {
869 features: BTreeMap::from([("flag".to_string(), Value::from(0))]),
870 allowed: false,
871 },
872 DecisionTraceRow {
873 features: BTreeMap::from([("flag".to_string(), Value::from(1))]),
874 allowed: false,
875 },
876 ];
877 let review_pack = build_review_pack(&gate, &rows, 3).unwrap();
878 assert_eq!(review_pack.mismatch_count, 1);
879 assert!(!review_pack.boundary_scenarios.is_empty());
880 }
881
882 #[test]
883 fn receipts_round_trip() {
884 let dir = tempfile::tempdir().unwrap();
885 let gate_path = dir.path().join("pearl.ir.json");
886 let gate = simple_gate();
887 std::fs::write(&gate_path, serde_json::to_string_pretty(&gate).unwrap()).unwrap();
888 let keypair = generate_receipt_keypair();
889 let public_key = public_key_from_signing_key(&keypair).unwrap();
890 let receipt = create_signed_decision_receipt(
891 &gate,
892 &gate_path,
893 &json!({"flag": 0}),
894 &keypair,
895 None,
896 "unix:1".to_string(),
897 )
898 .unwrap();
899
900 let report = verify_decision_receipt(&receipt, &public_key).unwrap();
901 assert!(report.valid, "{:?}", report.problems);
902 }
903}