Skip to main content

forge_memory_bridge/
transform.rs

1#![allow(deprecated)]
2
3//! Transformation from export envelopes to projection import batches.
4//!
5//! The bridge consumes validated export envelopes and produces import batches.
6//! It preserves envelope ID, content digest, lineage, receipt refs, and trace
7//! metadata. It rejects malformed exports before memory writes begin.
8//!
9//! ## Phase status: current / implemented now
10
11use crate::batch::*;
12use crate::error::BridgeError;
13use semantic_memory_forge::{
14    DispatchOutcomeV1, EpisodeBundleV1, ExecutionContextV1, ExportEnvelopeV1, ExportEnvelopeV2,
15    ExportEnvelopeV3, ExportRecord, ExportRecordV3, EXPORT_ENVELOPE_V1_SCHEMA,
16    EXPORT_ENVELOPE_V2_SCHEMA, EXPORT_ENVELOPE_V3_SCHEMA,
17};
18use stack_ids::{ClaimId, ClaimVersionId, RelationVersionId, TraceCtx};
19
20/// Transform an `ExportEnvelopeV1` into a `ProjectionImportBatchV1`.
21///
22/// This is a compatibility-only bridge operation. It:
23/// 1. Validates the export envelope (structure + digest).
24/// 2. Transforms each record into the import projection format.
25/// 3. Assigns version IDs where the source did not provide them.
26/// 4. Preserves provenance throughout.
27///
28/// New integrations should use [`transform_envelope_v3()`].
29///
30/// ## Claim supersession lineage
31///
32/// When the export carries `supersedes_claim_version_id`, the bridge preserves
33/// it verbatim. If only claim-level supersession is present, the bridge leaves
34/// `ImportClaimVersion::supersedes_claim_version_id` empty. No synthetic values
35/// are minted.
36///
37/// Returns an error if the envelope is malformed or incompatible.
38///
39/// Phase status: migration-only
40/// Removal condition: remove when all consumers have migrated to `transform_envelope_v3()` and `ProjectionImportBatchV3`
41#[deprecated(
42    since = "0.2.0",
43    note = "transform_envelope() is compatibility-only. Use transform_envelope_v3() and ProjectionImportBatchV3."
44)]
45pub fn transform_envelope(
46    envelope: &ExportEnvelopeV1,
47) -> Result<ProjectionImportBatchV1, BridgeError> {
48    // Step 1: Validate
49    envelope.validate()?;
50
51    let now = chrono::Utc::now().to_rfc3339();
52
53    // Step 2: Transform records
54    let records = envelope
55        .records
56        .iter()
57        .map(|record| transform_record(record, envelope))
58        .collect::<Result<Vec<_>, _>>()?;
59
60    Ok(ProjectionImportBatchV1 {
61        source_envelope_id: envelope.envelope_id.clone(),
62        schema_version: PROJECTION_IMPORT_BATCH_V1_SCHEMA.into(),
63        export_schema_version: Some(envelope.schema_version.clone()),
64        content_digest: envelope.content_digest.clone(),
65        source_authority: envelope.source_authority.clone(),
66        scope_key: envelope.scope_key.clone(),
67        trace_ctx: envelope.trace_ctx.clone(),
68        source_exported_at: envelope.exported_at.clone(),
69        transformed_at: now,
70        records,
71    })
72}
73
74/// Transform an `ExportEnvelopeV2` into a `ProjectionImportBatchV2`.
75///
76/// This is compatibility-only. It preserves schema-level metadata from `V2`
77/// exports into a `V2` projection batch, but it is not the normal integration
78/// path. New integrations should upgrade through `ExportEnvelopeV3` and use
79/// `transform_envelope_v3()`.
80///
81/// V2 preserves export-time metadata alongside the typed projection records.
82/// The bridge copies it through unchanged and still does not invent meaning.
83///
84/// Phase status: migration-only
85/// Removal condition: remove when all consumers have migrated to `transform_envelope_v3()` and `ProjectionImportBatchV3`
86#[deprecated(
87    since = "0.2.0",
88    note = "transform_envelope_v2() is compatibility-only. Use transform_envelope_v3() and ProjectionImportBatchV3."
89)]
90pub fn transform_envelope_v2(
91    envelope: &ExportEnvelopeV2,
92) -> Result<ProjectionImportBatchV2, BridgeError> {
93    envelope.validate()?;
94
95    let now = chrono::Utc::now().to_rfc3339();
96    let records = envelope
97        .records
98        .iter()
99        .map(|record| transform_record_v2(record, envelope))
100        .collect::<Result<Vec<_>, _>>()?;
101
102    let execution_context = derive_execution_context_v2(envelope);
103    let episode_bundle = derive_episode_bundle_v2(envelope, &execution_context)?;
104
105    Ok(ProjectionImportBatchV2 {
106        source_envelope_id: envelope.envelope_id.clone(),
107        schema_version: PROJECTION_IMPORT_BATCH_V2_SCHEMA.into(),
108        export_schema_version: Some(envelope.schema_version.clone()),
109        content_digest: envelope.content_digest.clone(),
110        source_authority: envelope.source_authority.clone(),
111        scope_key: envelope.scope_key.clone(),
112        trace_ctx: envelope.trace_ctx.clone(),
113        source_exported_at: envelope.exported_at.clone(),
114        transformed_at: now,
115        export_meta: envelope.export_meta.clone(),
116        evidence_bundle: envelope.evidence_bundle.clone(),
117        episode_bundle,
118        execution_context: Some(execution_context),
119        records,
120    })
121}
122
123/// Transform an `ExportEnvelopeV3` into a `ProjectionImportBatchV3`.
124///
125/// V3 is the kernel-oriented path. The bridge preserves the richer record
126/// semantics exactly as exported and still does not invent missing lineage,
127/// causal roles, or contradiction grouping.
128pub fn transform_envelope_v3(
129    envelope: &ExportEnvelopeV3,
130) -> Result<ProjectionImportBatchV3, BridgeError> {
131    envelope.validate()?;
132
133    let now = chrono::Utc::now().to_rfc3339();
134    let records = envelope
135        .records
136        .iter()
137        .map(|record| transform_record_v3(record, envelope))
138        .collect::<Result<Vec<_>, _>>()?;
139
140    let execution_context = derive_execution_context_v3(envelope);
141    let episode_bundle = derive_episode_bundle_v3(envelope, &execution_context)?;
142
143    Ok(ProjectionImportBatchV3 {
144        source_envelope_id: envelope.envelope_id.clone(),
145        schema_version: PROJECTION_IMPORT_BATCH_V3_SCHEMA.into(),
146        export_schema_version: Some(envelope.schema_version.clone()),
147        content_digest: envelope.content_digest.clone(),
148        source_authority: envelope.source_authority.clone(),
149        scope_key: envelope.scope_key.clone(),
150        trace_ctx: envelope.trace_ctx.clone(),
151        source_exported_at: envelope.exported_at.clone(),
152        transformed_at: now,
153        export_meta: envelope.export_meta.clone(),
154        evidence_bundle: envelope.evidence_bundle.clone(),
155        episode_bundle,
156        execution_context: Some(execution_context),
157        support_sets: envelope.support_sets.clone(),
158        contradiction_witnesses: envelope.contradiction_witnesses.clone(),
159        retraction_records: envelope.retraction_records.clone(),
160        claim_states_v13: envelope.claim_states_v13.clone(),
161        intervention_bundles_v14: envelope.intervention_bundles_v14.clone(),
162        outcome_schemas_v14: envelope.outcome_schemas_v14.clone(),
163        cohort_contracts_v14: envelope.cohort_contracts_v14.clone(),
164        counterfactual_slices_v14: envelope.counterfactual_slices_v14.clone(),
165        experiment_cases_v14: envelope.experiment_cases_v14.clone(),
166        comparability_matrices_v14: envelope.comparability_matrices_v14.clone(),
167        decision_traces_v14: envelope.decision_traces_v14.clone(),
168        refuter_suites_v14: envelope.refuter_suites_v14.clone(),
169        refuter_results_v14: envelope.refuter_results_v14.clone(),
170        experiment_budgets_v14: envelope.experiment_budgets_v14.clone(),
171        rollout_decisions_v14: envelope.rollout_decisions_v14.clone(),
172        rollback_decisions_v14: envelope.rollback_decisions_v14.clone(),
173        attestation_envelopes_v15: envelope.attestation_envelopes_v15.clone(),
174        trust_root_sets_v15: envelope.trust_root_sets_v15.clone(),
175        artifact_admission_policies_v15: envelope.artifact_admission_policies_v15.clone(),
176        transparency_receipts_v15: envelope.transparency_receipts_v15.clone(),
177        attestation_revocations_v15: envelope.attestation_revocations_v15.clone(),
178        attestation_supersessions_v15: envelope.attestation_supersessions_v15.clone(),
179        remote_oracle_leases_v15: envelope.remote_oracle_leases_v15.clone(),
180        remote_slice_requests_v15: envelope.remote_slice_requests_v15.clone(),
181        remote_slice_results_v15: envelope.remote_slice_results_v15.clone(),
182        cross_runtime_replay_tickets_v15: envelope.cross_runtime_replay_tickets_v15.clone(),
183        dispute_bundles_v15: envelope.dispute_bundles_v15.clone(),
184        disclosure_policies_v15: envelope.disclosure_policies_v15.clone(),
185        disclosure_budgets_v15: envelope.disclosure_budgets_v15.clone(),
186        records,
187    })
188}
189
190fn derive_execution_context_v2(envelope: &ExportEnvelopeV2) -> ExecutionContextV1 {
191    let mut ctx = ExecutionContextV1::new(
192        envelope
193            .trace_ctx
194            .clone()
195            .unwrap_or_else(TraceCtx::generate),
196    );
197    ctx.replay_link = envelope
198        .evidence_bundle
199        .as_ref()
200        .and_then(|bundle| bundle.replay_handle.clone());
201    ctx.attempt_id = envelope
202        .evidence_bundle
203        .as_ref()
204        .and_then(|bundle| bundle.attempt_id.clone());
205    ctx.trial_id = envelope
206        .evidence_bundle
207        .as_ref()
208        .and_then(|bundle| bundle.trial_id.clone());
209    ctx.workload_class = Some("forge_export".into());
210    ctx.environment_fingerprint = envelope.export_meta.as_ref().and_then(|meta| {
211        meta.comparability_snapshot_version
212            .as_ref()
213            .map(|value| format!("comparability_snapshot:{value}"))
214    });
215    ctx.provider_route = envelope
216        .export_meta
217        .as_ref()
218        .map(|meta| meta.authority.as_str().into());
219    if envelope.trace_ctx.is_none() {
220        ctx.degradation_markers
221            .push("missing_source_trace_ctx".into());
222        ctx.dispatch_outcome = DispatchOutcomeV1::Degraded;
223    }
224    ctx
225}
226
227fn derive_execution_context_v3(envelope: &ExportEnvelopeV3) -> ExecutionContextV1 {
228    derive_execution_context_v2(&ExportEnvelopeV2 {
229        envelope_id: envelope.envelope_id.clone(),
230        schema_version: EXPORT_ENVELOPE_V2_SCHEMA.into(),
231        content_digest: envelope.content_digest.clone(),
232        source_authority: envelope.source_authority.clone(),
233        scope_key: envelope.scope_key.clone(),
234        trace_ctx: envelope.trace_ctx.clone(),
235        exported_at: envelope.exported_at.clone(),
236        export_meta: envelope.export_meta.clone(),
237        evidence_bundle: envelope.evidence_bundle.clone(),
238        records: envelope
239            .records
240            .iter()
241            .map(|record| record.record.clone())
242            .collect(),
243    })
244}
245
246fn derive_episode_bundle_v2(
247    envelope: &ExportEnvelopeV2,
248    execution_context: &ExecutionContextV1,
249) -> Result<Option<EpisodeBundleV1>, BridgeError> {
250    let Some(bundle) = envelope.evidence_bundle.as_ref() else {
251        return Ok(None);
252    };
253    let episode_record = envelope
254        .records
255        .iter()
256        .find_map(|record| match record {
257            ExportRecord::Episode(episode) => Some(episode),
258            _ => None,
259        })
260        .ok_or_else(|| BridgeError::InvalidRecord {
261            reason: "canonical bundle-bearing export is missing an episode record".into(),
262        })?;
263    let episode_id =
264        episode_record
265            .episode_id
266            .clone()
267            .ok_or_else(|| BridgeError::InvalidRecord {
268                reason: "canonical bundle-bearing export is missing episode_id".into(),
269            })?;
270    let claim_version_ids = envelope
271        .records
272        .iter()
273        .filter_map(|record| match record {
274            ExportRecord::Claim(claim) => claim.claim_version_id.as_ref().map(|id| id.to_string()),
275            _ => None,
276        })
277        .collect();
278    let relation_version_ids = envelope
279        .records
280        .iter()
281        .filter_map(|record| match record {
282            ExportRecord::Relation(relation) => relation
283                .relation_version_id
284                .as_ref()
285                .map(|id| id.to_string()),
286            _ => None,
287        })
288        .collect();
289    let source_evidence_pointers = envelope
290        .records
291        .iter()
292        .filter_map(|record| match record {
293            ExportRecord::EvidenceRef(reference) => Some(reference.fetch_handle.clone()),
294            _ => None,
295        })
296        .collect();
297    let source_receipt_digests = bundle
298        .raw_receipt_handle
299        .as_ref()
300        .map(|value| vec![value.clone()])
301        .unwrap_or_default();
302    Ok(Some(EpisodeBundleV1 {
303        schema_version: semantic_memory_forge::EPISODE_BUNDLE_V1_SCHEMA.into(),
304        bundle_id: bundle.id.to_string(),
305        episode_id,
306        primary_document_id: episode_record.document_id.clone(),
307        namespace: envelope.scope_key.namespace.clone(),
308        scope_key: envelope.scope_key.clone(),
309        valid_from: envelope.records.iter().find_map(|record| match record {
310            ExportRecord::Claim(claim) => claim.valid_from.clone(),
311            _ => None,
312        }),
313        valid_to: envelope.records.iter().find_map(|record| match record {
314            ExportRecord::Claim(claim) => claim.valid_to.clone(),
315            _ => None,
316        }),
317        exported_at: envelope.exported_at.clone(),
318        recorded_at: None,
319        source_envelope_id: envelope.envelope_id.clone(),
320        content_digest: envelope.content_digest.clone(),
321        source_evidence_pointers,
322        source_receipt_digests,
323        claim_version_ids,
324        relation_version_ids,
325        verification_summary: bundle.verification_summary.clone(),
326        refutation_artifact_ids: bundle
327            .refutation_artifacts
328            .iter()
329            .map(|artifact| artifact.artifact_id.clone())
330            .collect(),
331        control_plane_refs: envelope
332            .export_meta
333            .as_ref()
334            .and_then(|meta| meta.run_id.clone())
335            .map(|run_id| vec![format!("forge_run:{run_id}")])
336            .unwrap_or_default(),
337        execution_context: execution_context.clone(),
338        thin_export: envelope.evidence_bundle.is_none(),
339        supersedes_bundle_id: None,
340        evidence_bundle_id: Some(bundle.id.to_string()),
341    }))
342}
343
344fn derive_episode_bundle_v3(
345    envelope: &ExportEnvelopeV3,
346    execution_context: &ExecutionContextV1,
347) -> Result<Option<EpisodeBundleV1>, BridgeError> {
348    derive_episode_bundle_v2(
349        &ExportEnvelopeV2 {
350            envelope_id: envelope.envelope_id.clone(),
351            schema_version: EXPORT_ENVELOPE_V2_SCHEMA.into(),
352            content_digest: envelope.content_digest.clone(),
353            source_authority: envelope.source_authority.clone(),
354            scope_key: envelope.scope_key.clone(),
355            trace_ctx: envelope.trace_ctx.clone(),
356            exported_at: envelope.exported_at.clone(),
357            export_meta: envelope.export_meta.clone(),
358            evidence_bundle: envelope.evidence_bundle.clone(),
359            records: envelope
360                .records
361                .iter()
362                .map(|record| record.record.clone())
363                .collect(),
364        },
365        execution_context,
366    )
367}
368
369fn claim_projection_state(
370    metadata: Option<&serde_json::Value>,
371) -> (ClaimState, ProjectionFreshness, ContradictionStatus) {
372    let Some(summary) = metadata.and_then(|metadata| metadata.get("verification_summary")) else {
373        return (
374            ClaimState::Active,
375            ProjectionFreshness::Current,
376            ContradictionStatus::None,
377        );
378    };
379
380    let lifecycle_state = summary
381        .get("lifecycle_state")
382        .and_then(serde_json::Value::as_str);
383    let notes = summary
384        .get("notes")
385        .and_then(serde_json::Value::as_array)
386        .map(|notes| {
387            notes
388                .iter()
389                .filter_map(serde_json::Value::as_str)
390                .collect::<Vec<_>>()
391                .join("; ")
392        })
393        .filter(|notes| !notes.is_empty());
394
395    match lifecycle_state {
396        Some("unverified") => (
397            ClaimState::PendingReview,
398            ProjectionFreshness::Current,
399            ContradictionStatus::None,
400        ),
401        Some("verified") => (
402            ClaimState::Active,
403            ProjectionFreshness::Current,
404            ContradictionStatus::None,
405        ),
406        Some("contradicted") => (
407            ClaimState::Disputed,
408            ProjectionFreshness::Current,
409            ContradictionStatus::PossibleContradiction {
410                description: notes.unwrap_or_else(|| {
411                    "exported verification summary marked claim as contradicted".into()
412                }),
413            },
414        ),
415        Some("superseded") => (
416            ClaimState::Superseded,
417            ProjectionFreshness::Superseded,
418            ContradictionStatus::None,
419        ),
420        _ => (
421            ClaimState::Active,
422            ProjectionFreshness::Current,
423            ContradictionStatus::None,
424        ),
425    }
426}
427
428/// Transform a single export record into an import projection record.
429fn transform_record(
430    record: &ExportRecord,
431    envelope: &ExportEnvelopeV1,
432) -> Result<ImportProjectionRecord, BridgeError> {
433    match record {
434        ExportRecord::Claim(claim) => {
435            let claim_id = claim.claim_id.clone().unwrap_or_else(ClaimId::generate);
436            let claim_version_id = claim
437                .claim_version_id
438                .clone()
439                .unwrap_or_else(ClaimVersionId::generate);
440            let (claim_state, freshness, contradiction_status) =
441                claim_projection_state(claim.metadata.as_ref());
442
443            Ok(ImportProjectionRecord::ClaimVersion(ImportClaimVersion {
444                claim_id,
445                claim_version_id,
446                claim_state,
447                projection_family: claim.projection_family.clone(),
448                subject_entity_id: claim.subject_entity_id.clone(),
449                predicate: claim.predicate.clone(),
450                object_anchor: claim.object_anchor.clone(),
451                scope_key: envelope.scope_key.clone(),
452                valid_from: claim.valid_from.clone(),
453                valid_to: claim.valid_to.clone(),
454                preferred_open: claim.valid_to.is_none(),
455                source_envelope_id: envelope.envelope_id.clone(),
456                source_authority: envelope.source_authority.clone(),
457                trace_ctx: envelope.trace_ctx.clone(),
458                freshness,
459                contradiction_status,
460                supersedes_claim_version_id: claim.supersedes_claim_version_id.clone(),
461                content: claim.content.clone(),
462                confidence: claim.confidence,
463                metadata: claim.metadata.clone(),
464            }))
465        }
466
467        ExportRecord::Relation(rel) => {
468            let relation_version_id = rel
469                .relation_version_id
470                .clone()
471                .unwrap_or_else(RelationVersionId::generate);
472
473            Ok(ImportProjectionRecord::RelationVersion(
474                ImportRelationVersion {
475                    relation_version_id,
476                    subject_entity_id: rel.subject_entity_id.clone(),
477                    predicate: rel.predicate.clone(),
478                    object_anchor: rel.object_anchor.clone(),
479                    scope_key: envelope.scope_key.clone(),
480                    claim_id: rel.source_claim_id.clone(),
481                    source_episode_id: rel.source_episode_id.clone(),
482                    valid_from: rel.valid_from.clone(),
483                    valid_to: rel.valid_to.clone(),
484                    preferred_open: rel.valid_to.is_none(),
485                    supersedes_relation_version_id: rel.supersedes_relation_version_id.clone(),
486                    contradiction_status: ContradictionStatus::None,
487                    source_confidence: rel.confidence,
488                    projection_family: rel.projection_family.clone(),
489                    source_envelope_id: envelope.envelope_id.clone(),
490                    source_authority: envelope.source_authority.clone(),
491                    trace_ctx: envelope.trace_ctx.clone(),
492                    freshness: ProjectionFreshness::Current,
493                    metadata: rel.metadata.clone(),
494                },
495            ))
496        }
497
498        ExportRecord::Episode(ep) => {
499            let episode_id =
500                ep.episode_id
501                    .clone()
502                    .ok_or_else(|| BridgeError::MissingEpisodeIdentity {
503                        record_context: format!(
504                            "legacy import at {}",
505                            ep.experiment_id.as_deref().unwrap_or("unknown")
506                        ),
507                    })?;
508
509            Ok(ImportProjectionRecord::Episode(ImportEpisodeRecord {
510                episode_id,
511                document_id: ep.document_id.clone(),
512                cause_ids: ep.cause_ids.clone(),
513                effect_type: ep.effect_type.clone(),
514                outcome: ep.outcome.clone(),
515                confidence: ep.confidence,
516                experiment_id: ep.experiment_id.clone(),
517                source_envelope_id: envelope.envelope_id.clone(),
518                source_authority: envelope.source_authority.clone(),
519                trace_ctx: envelope.trace_ctx.clone(),
520                metadata: ep.metadata.clone(),
521            }))
522        }
523
524        ExportRecord::EntityAlias(alias) => {
525            let scope = alias
526                .scope
527                .clone()
528                .unwrap_or_else(|| envelope.scope_key.clone());
529
530            Ok(ImportProjectionRecord::EntityAlias(ImportEntityAlias {
531                canonical_entity_id: alias.canonical_entity_id.clone(),
532                alias_text: alias.alias_text.clone(),
533                alias_source: alias.alias_source.clone(),
534                match_evidence: alias.match_evidence.clone(),
535                confidence: alias.confidence,
536                merge_decision: MergeDecision::PendingReview,
537                scope,
538                review_state: ReviewState::PendingReview,
539                is_human_confirmed: false,
540                is_human_confirmed_final: false,
541                superseded_by_entity_id: alias.superseded_by_entity_id.clone(),
542                split_from_entity_id: alias.split_from_entity_id.clone(),
543                source_envelope_id: envelope.envelope_id.clone(),
544            }))
545        }
546
547        ExportRecord::EvidenceRef(ev) => {
548            Ok(ImportProjectionRecord::EvidenceRef(ImportEvidenceRef {
549                claim_id: ev.claim_id.clone(),
550                claim_version_id: ev.claim_version_id.clone(),
551                fetch_handle: ev.fetch_handle.clone(),
552                source_authority: ev.source_authority.clone(),
553                source_envelope_id: envelope.envelope_id.clone(),
554                metadata: ev.metadata.clone(),
555            }))
556        }
557    }
558}
559
560fn transform_record_v3(
561    record: &ExportRecordV3,
562    envelope: &ExportEnvelopeV3,
563) -> Result<ImportProjectionRecordV3, BridgeError> {
564    let record_only_envelope = ExportEnvelopeV2 {
565        envelope_id: envelope.envelope_id.clone(),
566        schema_version: EXPORT_ENVELOPE_V2_SCHEMA.into(),
567        content_digest: envelope.content_digest.clone(),
568        source_authority: envelope.source_authority.clone(),
569        scope_key: envelope.scope_key.clone(),
570        trace_ctx: envelope.trace_ctx.clone(),
571        exported_at: envelope.exported_at.clone(),
572        export_meta: envelope.export_meta.clone(),
573        evidence_bundle: envelope.evidence_bundle.clone(),
574        records: vec![record.record.clone()],
575    };
576
577    let import_record = transform_record_v2(&record.record, &record_only_envelope)?;
578    Ok(ImportProjectionRecordV3 {
579        record: import_record,
580        semantics: record.semantics.clone(),
581    })
582}
583
584fn transform_record_v2(
585    record: &ExportRecord,
586    envelope: &ExportEnvelopeV2,
587) -> Result<ImportProjectionRecord, BridgeError> {
588    match record {
589        ExportRecord::Claim(claim) => {
590            let claim_id = claim.claim_id.clone().unwrap_or_else(ClaimId::generate);
591            let claim_version_id = claim
592                .claim_version_id
593                .clone()
594                .unwrap_or_else(ClaimVersionId::generate);
595            let (claim_state, freshness, contradiction_status) =
596                claim_projection_state(claim.metadata.as_ref());
597
598            Ok(ImportProjectionRecord::ClaimVersion(ImportClaimVersion {
599                claim_id,
600                claim_version_id,
601                claim_state,
602                projection_family: claim.projection_family.clone(),
603                subject_entity_id: claim.subject_entity_id.clone(),
604                predicate: claim.predicate.clone(),
605                object_anchor: claim.object_anchor.clone(),
606                scope_key: envelope.scope_key.clone(),
607                valid_from: claim.valid_from.clone(),
608                valid_to: claim.valid_to.clone(),
609                preferred_open: claim.valid_to.is_none(),
610                source_envelope_id: envelope.envelope_id.clone(),
611                source_authority: envelope.source_authority.clone(),
612                trace_ctx: envelope.trace_ctx.clone(),
613                freshness,
614                contradiction_status,
615                supersedes_claim_version_id: claim.supersedes_claim_version_id.clone(),
616                content: claim.content.clone(),
617                confidence: claim.confidence,
618                metadata: claim.metadata.clone(),
619            }))
620        }
621        ExportRecord::Relation(rel) => {
622            let relation_version_id = rel
623                .relation_version_id
624                .clone()
625                .unwrap_or_else(RelationVersionId::generate);
626
627            Ok(ImportProjectionRecord::RelationVersion(
628                ImportRelationVersion {
629                    relation_version_id,
630                    subject_entity_id: rel.subject_entity_id.clone(),
631                    predicate: rel.predicate.clone(),
632                    object_anchor: rel.object_anchor.clone(),
633                    scope_key: envelope.scope_key.clone(),
634                    claim_id: rel.source_claim_id.clone(),
635                    source_episode_id: rel.source_episode_id.clone(),
636                    valid_from: rel.valid_from.clone(),
637                    valid_to: rel.valid_to.clone(),
638                    preferred_open: rel.valid_to.is_none(),
639                    supersedes_relation_version_id: rel.supersedes_relation_version_id.clone(),
640                    contradiction_status: ContradictionStatus::None,
641                    source_confidence: rel.confidence,
642                    projection_family: rel.projection_family.clone(),
643                    source_envelope_id: envelope.envelope_id.clone(),
644                    source_authority: envelope.source_authority.clone(),
645                    trace_ctx: envelope.trace_ctx.clone(),
646                    freshness: ProjectionFreshness::Current,
647                    metadata: rel.metadata.clone(),
648                },
649            ))
650        }
651        ExportRecord::Episode(ep) => {
652            let episode_id =
653                ep.episode_id
654                    .clone()
655                    .ok_or_else(|| BridgeError::MissingEpisodeIdentity {
656                        record_context: format!(
657                            "legacy import at {}",
658                            ep.experiment_id.as_deref().unwrap_or("unknown")
659                        ),
660                    })?;
661            Ok(ImportProjectionRecord::Episode(ImportEpisodeRecord {
662                episode_id,
663                document_id: ep.document_id.clone(),
664                cause_ids: ep.cause_ids.clone(),
665                effect_type: ep.effect_type.clone(),
666                outcome: ep.outcome.clone(),
667                confidence: ep.confidence,
668                experiment_id: ep.experiment_id.clone(),
669                source_envelope_id: envelope.envelope_id.clone(),
670                source_authority: envelope.source_authority.clone(),
671                trace_ctx: envelope.trace_ctx.clone(),
672                metadata: ep.metadata.clone(),
673            }))
674        }
675        ExportRecord::EntityAlias(alias) => {
676            let scope = alias
677                .scope
678                .clone()
679                .unwrap_or_else(|| envelope.scope_key.clone());
680            Ok(ImportProjectionRecord::EntityAlias(ImportEntityAlias {
681                canonical_entity_id: alias.canonical_entity_id.clone(),
682                alias_text: alias.alias_text.clone(),
683                alias_source: alias.alias_source.clone(),
684                match_evidence: alias.match_evidence.clone(),
685                confidence: alias.confidence,
686                merge_decision: MergeDecision::PendingReview,
687                scope,
688                review_state: ReviewState::PendingReview,
689                is_human_confirmed: false,
690                is_human_confirmed_final: false,
691                superseded_by_entity_id: alias.superseded_by_entity_id.clone(),
692                split_from_entity_id: alias.split_from_entity_id.clone(),
693                source_envelope_id: envelope.envelope_id.clone(),
694            }))
695        }
696        ExportRecord::EvidenceRef(ev) => {
697            Ok(ImportProjectionRecord::EvidenceRef(ImportEvidenceRef {
698                claim_id: ev.claim_id.clone(),
699                claim_version_id: ev.claim_version_id.clone(),
700                fetch_handle: ev.fetch_handle.clone(),
701                source_authority: ev.source_authority.clone(),
702                source_envelope_id: envelope.envelope_id.clone(),
703                metadata: ev.metadata.clone(),
704            }))
705        }
706    }
707}
708
709/// Validate that an export envelope's version is compatible with this bridge.
710pub fn is_compatible_version(schema_version: &str) -> bool {
711    matches!(
712        schema_version,
713        EXPORT_ENVELOPE_V1_SCHEMA | EXPORT_ENVELOPE_V2_SCHEMA | EXPORT_ENVELOPE_V3_SCHEMA
714    )
715}
716
717/// Create a trace context for the bridge transformation, chaining from
718/// the source envelope's trace context if available.
719pub fn bridge_trace_ctx(source: Option<&TraceCtx>) -> TraceCtx {
720    match source {
721        Some(parent) => {
722            let span_id = &uuid::Uuid::new_v4().as_simple().to_string()[..16];
723            parent.child(span_id)
724        }
725        None => TraceCtx::generate(),
726    }
727}
728
729#[cfg(test)]
730#[path = "transform_tests.rs"]
731mod tests;