Skip to main content

logicpearl_conformance/
lib.rs

1// SPDX-License-Identifier: MIT
2//! Conformance receipts for runtime and artifact contracts.
3//!
4//! This crate helps produce and validate reproducibility evidence: source file
5//! fingerprints, runtime parity manifests, artifact freshness checks, and
6//! signed conformance reports. It supports audit workflows; it does not
7//! replace trace review or artifact inspection.
8
9use 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    // Build JSON manually because secret_key_hex is skip_serializing to prevent
467    // accidental leaks via Debug/Serialize. The key file on disk must contain it.
468    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}