Skip to main content

forge_pilot/
export.rs

1use crate::error::PilotError;
2#[cfg(feature = "governance")]
3use claim_ledger::{ids, ExportReceipt};
4use forge_engine::{export_bundle, ExperimentEvidenceBundle, ForgeStore};
5use forge_memory_bridge::transform_envelope_v3;
6use semantic_memory::{MemoryStore, ProjectionImportResult};
7use semantic_memory_forge::ExportEnvelopeV3;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RoundtripResult {
12    pub envelope: ExportEnvelopeV3,
13    pub import_result: ProjectionImportResult,
14    /// `claim_ledger::ExportReceipt` recording the canonical export/import roundtrip.
15    /// Captures the bundle digest, envelope digest, and import receipt linkage.
16    /// `None` when the `governance` feature is disabled.
17    #[cfg(feature = "governance")]
18    pub export_receipt: Option<ExportReceipt>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ImportBootstrapReport {
23    pub namespace: String,
24    pub forge_bundle_count: usize,
25    pub imported_bundle_ids: Vec<String>,
26    /// `claim_ledger::ExportReceipt` per imported bundle, in import order.
27    /// `None` when the `governance` feature is disabled.
28    #[cfg(feature = "governance")]
29    pub export_receipts: Vec<ExportReceipt>,
30}
31
32/// Build a `claim_ledger::ExportReceipt` that binds the bundle hash and
33/// envelope hash of a canonical roundtrip. The receipt starts in
34/// `pending` state; callers must update `status` to `"success"` or
35/// `"failure"` based on the operation outcome. The receipt must be
36/// emitted on every path — including failures — so audit consumers can
37/// reconstruct the full history.
38///
39/// No-op when the `governance` feature is disabled — call site must not
40/// rely on the receipt.
41#[cfg(feature = "governance")]
42fn build_pending_export_receipt(bundle_id: &str, envelope: &ExportEnvelopeV3) -> ExportReceipt {
43    let envelope_json = serde_json::to_string(envelope).unwrap_or_default();
44    let envelope_digest = ids::sha256_text(&envelope_json);
45    let mut receipt = ExportReceipt::new(
46        "forge_pilot_canonical_roundtrip",
47        vec![bundle_id.to_string()],
48        envelope.envelope_id.to_string(),
49    );
50    receipt
51        .input_digests
52        .insert("bundle".to_string(), envelope_digest.clone());
53    // The output is the envelope itself, which exists before the
54    // import step. Bind it here so both success and failure receipts
55    // carry the envelope as a recoverable artifact.
56    receipt.bind_output(
57        format!("export_envelope:{}", envelope.envelope_id),
58        envelope_digest,
59    );
60    receipt.status = "pending".to_string();
61    receipt
62}
63
64pub async fn canonical_roundtrip(
65    bundle: &ExperimentEvidenceBundle,
66    namespace: &str,
67    forge_store: &ForgeStore,
68    memory_store: &MemoryStore,
69) -> Result<RoundtripResult, PilotError> {
70    let envelope = export_bundle(bundle, namespace, forge_store).await?;
71    let batch = transform_envelope_v3(&envelope)?;
72
73    // Build the receipt in `pending` state *before* the import so we
74    // can mark it `success` or `failure` based on the outcome, and
75    // return it on either path. This is the doctrinal fix for the
76    // audit-trail gap: every material operation must produce a
77    // receipt, including failures.
78    #[cfg(feature = "governance")]
79    let mut export_receipt = Some(build_pending_export_receipt(&bundle.bundle_id, &envelope));
80
81    let import_outcome = memory_store.import_projection_batch(&batch).await;
82    let import_result = match import_outcome {
83        Ok(r) => {
84            #[cfg(feature = "governance")]
85            if let Some(ref mut r) = export_receipt {
86                r.status = "success".to_string();
87            }
88            r
89        }
90        Err(e) => {
91            #[cfg(feature = "governance")]
92            if let Some(ref mut r) = export_receipt {
93                r.status = "failure".to_string();
94                r.degradation.push(format!("import_error: {e}"));
95            }
96            return Err(PilotError::from(e));
97        }
98    };
99    Ok(RoundtripResult {
100        envelope,
101        import_result,
102        #[cfg(feature = "governance")]
103        export_receipt,
104    })
105}
106
107pub async fn import_recent_forge_bundles(
108    namespace: &str,
109    forge_store: &ForgeStore,
110    memory_store: &MemoryStore,
111    limit: usize,
112) -> Result<ImportBootstrapReport, PilotError> {
113    let mut bundle_ids = forge_store.list_recent_evidence_bundle_ids(limit)?;
114    let forge_bundle_count = bundle_ids.len();
115    bundle_ids.reverse();
116
117    let mut imported_bundle_ids = Vec::with_capacity(bundle_ids.len());
118    #[cfg(feature = "governance")]
119    let mut export_receipts = Vec::with_capacity(bundle_ids.len());
120    for bundle_id in bundle_ids {
121        let bundle_row = forge_store
122            .get_evidence_bundle(&bundle_id)?
123            .ok_or_else(|| PilotError::Other(format!("missing Forge bundle row {bundle_id}")))?;
124        let bundle = bundle_row.local_bundle()?;
125        let result = canonical_roundtrip(&bundle, namespace, forge_store, memory_store).await?;
126        #[cfg(feature = "governance")]
127        if let Some(receipt) = result.export_receipt.clone() {
128            export_receipts.push(receipt);
129        }
130        imported_bundle_ids.push(bundle_id);
131    }
132
133    Ok(ImportBootstrapReport {
134        namespace: namespace.into(),
135        forge_bundle_count,
136        imported_bundle_ids,
137        #[cfg(feature = "governance")]
138        export_receipts,
139    })
140}