Skip to main content

khive_runtime/
portability.rs

1// Copyright 2026 khive contributors. Licensed under Apache-2.0.
2//
3//! KG export / import — portable JSON archive for namespace-scoped knowledge graphs.
4//!
5//! Implements the v1 portability format described in ADR-010. Embeddings are
6//! intentionally excluded: they are regenerable from the embedding model + text
7//! and their inclusion would lock the format to a specific model.
8//!
9//! # Edge namespace enumeration
10//!
11//! `GraphStore::query_edges` has no namespace column — edges are linked to entities,
12//! not namespaces. Export collects all entity IDs in the namespace first, then
13//! queries edges where source_id is in that set. This covers every edge whose
14//! source entity belongs to the namespace, which is the correct definition of
15//! "edges in a namespace" for an export that preserves referential integrity.
16
17use std::collections::HashSet;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use khive_storage::types::{EdgeFilter, LinkId, PageRequest};
24use khive_storage::{EdgeRelation, EntityFilter};
25
26use crate::error::{RuntimeError, RuntimeResult};
27use crate::runtime::KhiveRuntime;
28
29// ── Archive types ─────────────────────────────────────────────────────────────
30
31/// Portable JSON archive of a namespace-scoped knowledge graph.
32///
33/// The `format` field is always `"khive-kg"`. The `version` field identifies
34/// the serialization schema; parsers should reject unknown versions.
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct KgArchive {
37    pub format: String,
38    pub version: String,
39    pub namespace: String,
40    pub exported_at: DateTime<Utc>,
41    pub entities: Vec<ExportedEntity>,
42    pub edges: Vec<ExportedEdge>,
43}
44
45/// An entity record in the portable archive.
46#[derive(Clone, Debug, Serialize, Deserialize)]
47pub struct ExportedEntity {
48    pub id: Uuid,
49    /// Pack-owned kind string (e.g. `"concept"`, `"person"`).
50    pub kind: String,
51    pub name: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub description: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub properties: Option<serde_json::Value>,
56    #[serde(default)]
57    pub tags: Vec<String>,
58    pub created_at: DateTime<Utc>,
59    pub updated_at: DateTime<Utc>,
60}
61
62/// A directed edge record in the portable archive.
63#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct ExportedEdge {
65    /// Stable edge identity across export/import cycles (ADR-048 D1).
66    ///
67    /// Old archives (pre-0.2) omit this field. `serde(default)` assigns a fresh
68    /// UUID on import so backward-compatible archives are accepted as-is.
69    #[serde(default = "Uuid::new_v4")]
70    pub edge_id: Uuid,
71    pub source: Uuid,
72    pub target: Uuid,
73    /// One of the 13 canonical relations defined in ADR-002.
74    pub relation: EdgeRelation,
75    pub weight: f64,
76}
77
78/// Outcome of a successful import operation.
79#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct ImportSummary {
81    pub entities_imported: usize,
82    pub edges_imported: usize,
83    /// Number of edges that were skipped because one or both endpoint UUIDs
84    /// were not found in the target namespace after entity import.
85    ///
86    /// A non-zero value indicates the archive contained dangling edges (edges
87    /// referencing entities not present in the archive or the existing graph).
88    pub edges_skipped: usize,
89}
90
91// ── KhiveRuntime impl ─────────────────────────────────────────────────────────
92
93impl KhiveRuntime {
94    /// Export all entities and edges in a namespace to a portable JSON archive.
95    ///
96    /// Edge collection: all entity IDs in the namespace are gathered first;
97    /// `query_edges` is then called with those IDs as `source_ids`. This
98    /// captures every edge whose source entity belongs to the namespace.
99    pub async fn export_kg(&self, namespace: Option<&str>) -> RuntimeResult<KgArchive> {
100        let ns = self.ns(namespace).to_string();
101
102        // 1. Collect all entities in the namespace.
103        let entity_page = self
104            .entities(Some(&ns))?
105            .query_entities(
106                &ns,
107                EntityFilter::default(),
108                PageRequest {
109                    offset: 0,
110                    limit: u32::MAX,
111                },
112            )
113            .await?;
114
115        let entities: Vec<ExportedEntity> = entity_page
116            .items
117            .into_iter()
118            .map(|e| {
119                let created_at =
120                    DateTime::from_timestamp_micros(e.created_at).unwrap_or_else(Utc::now);
121                let updated_at =
122                    DateTime::from_timestamp_micros(e.updated_at).unwrap_or_else(Utc::now);
123                ExportedEntity {
124                    id: e.id,
125                    kind: e.kind.to_string(),
126                    name: e.name,
127                    description: e.description,
128                    properties: e.properties,
129                    tags: e.tags,
130                    created_at,
131                    updated_at,
132                }
133            })
134            .collect();
135
136        // 2. Collect edges whose source is any entity in this namespace.
137        let source_ids: Vec<Uuid> = entities.iter().map(|e| e.id).collect();
138        let edges = if source_ids.is_empty() {
139            Vec::new()
140        } else {
141            let filter = EdgeFilter {
142                source_ids: source_ids.clone(),
143                ..Default::default()
144            };
145            let edge_page = self
146                .graph(Some(&ns))?
147                .query_edges(
148                    filter,
149                    Vec::new(),
150                    PageRequest {
151                        offset: 0,
152                        limit: u32::MAX,
153                    },
154                )
155                .await?;
156
157            let id_set: HashSet<Uuid> = source_ids.into_iter().collect();
158            edge_page
159                .items
160                .into_iter()
161                .filter(|e| id_set.contains(&e.source_id))
162                .map(|e| ExportedEdge {
163                    edge_id: e.id.into(),
164                    source: e.source_id,
165                    target: e.target_id,
166                    relation: e.relation,
167                    weight: e.weight,
168                })
169                .collect()
170        };
171
172        Ok(KgArchive {
173            format: "khive-kg".to_string(),
174            version: "0.1".to_string(),
175            namespace: ns,
176            exported_at: Utc::now(),
177            entities,
178            edges,
179        })
180    }
181
182    /// Export to a JSON string (convenience wrapper around `export_kg`).
183    pub async fn export_kg_json(&self, namespace: Option<&str>) -> RuntimeResult<String> {
184        let archive = self.export_kg(namespace).await?;
185        serde_json::to_string(&archive).map_err(|e| RuntimeError::InvalidInput(e.to_string()))
186    }
187
188    /// Import an archive into `target_namespace`.
189    ///
190    /// If `target_namespace` is `None`, the archive's own namespace is used.
191    ///
192    /// - Entities: upserted by ID; existing records are overwritten.
193    /// - Edges: upserted; existing records are overwritten.
194    /// - Validation: `format != "khive-kg"` or unsupported version → `InvalidInput`.
195    ///   Invalid edge relations are caught at JSON deserialization time.
196    pub async fn import_kg(
197        &self,
198        archive: &KgArchive,
199        target_namespace: Option<&str>,
200    ) -> RuntimeResult<ImportSummary> {
201        // Format validation.
202        if archive.format != "khive-kg" {
203            return Err(RuntimeError::InvalidInput(format!(
204                "unsupported archive format {:?}; expected \"khive-kg\"",
205                archive.format
206            )));
207        }
208        if archive.version != "0.1" {
209            return Err(RuntimeError::InvalidInput(format!(
210                "unsupported archive version {:?}; supported: \"0.1\"",
211                archive.version
212            )));
213        }
214
215        let ns = target_namespace.unwrap_or(&archive.namespace).to_string();
216
217        // Import entities.
218        let store = self.entities(Some(&ns))?;
219        let mut entities_imported = 0usize;
220        for ee in &archive.entities {
221            let created_micros = ee.created_at.timestamp_micros();
222            let updated_micros = ee.updated_at.timestamp_micros();
223            let entity = khive_storage::entity::Entity {
224                id: ee.id,
225                namespace: ns.clone(),
226                kind: ee.kind.clone(),
227                name: ee.name.clone(),
228                description: ee.description.clone(),
229                properties: ee.properties.clone(),
230                tags: ee.tags.clone(),
231                created_at: created_micros,
232                updated_at: updated_micros,
233                deleted_at: None,
234            };
235            store.upsert_entity(entity.clone()).await?;
236            // Index into FTS5 (and vector store if a model is configured) so that
237            // imported entities are visible to hybrid_search immediately.
238            self.reindex_entity(Some(&ns), &entity).await?;
239            entities_imported += 1;
240        }
241
242        // Import edges — validate both endpoints before inserting.
243        //
244        // An untrusted archive may contain edges whose source or target UUIDs
245        // do not correspond to any entity in the target namespace. Inserting
246        // such edges would leave dangling references in the graph store. We
247        // therefore check each endpoint with `get_entity` (namespace-scoped,
248        // fail-closed) and skip any edge whose source or target is absent.
249        let graph = self.graph(Some(&ns))?;
250        let mut edges_imported = 0usize;
251        let mut edges_skipped = 0usize;
252        for ee in &archive.edges {
253            let source_ok = self.get_entity(Some(&ns), ee.source).await?.is_some();
254            if !source_ok {
255                tracing::warn!(
256                    source = %ee.source,
257                    target = %ee.target,
258                    relation = ?ee.relation,
259                    "import_kg: skipping edge — source entity not found in namespace {ns:?}"
260                );
261                edges_skipped += 1;
262                continue;
263            }
264            let target_ok = self.get_entity(Some(&ns), ee.target).await?.is_some();
265            if !target_ok {
266                tracing::warn!(
267                    source = %ee.source,
268                    target = %ee.target,
269                    relation = ?ee.relation,
270                    "import_kg: skipping edge — target entity not found in namespace {ns:?}"
271                );
272                edges_skipped += 1;
273                continue;
274            }
275            let edge = khive_storage::types::Edge {
276                id: LinkId::from(ee.edge_id),
277                source_id: ee.source,
278                target_id: ee.target,
279                relation: ee.relation,
280                weight: ee.weight,
281                created_at: Utc::now(),
282                metadata: None,
283            };
284            graph.upsert_edge(edge).await?;
285            edges_imported += 1;
286        }
287
288        Ok(ImportSummary {
289            entities_imported,
290            edges_imported,
291            edges_skipped,
292        })
293    }
294
295    /// Import from a JSON string (convenience wrapper around `import_kg`).
296    pub async fn import_kg_json(
297        &self,
298        json: &str,
299        target_namespace: Option<&str>,
300    ) -> RuntimeResult<ImportSummary> {
301        let archive: KgArchive =
302            serde_json::from_str(json).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
303        self.import_kg(&archive, target_namespace).await
304    }
305}
306
307// ── Tests ─────────────────────────────────────────────────────────────────────
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::runtime::KhiveRuntime;
313    use khive_storage::EdgeRelation;
314
315    async fn make_rt() -> KhiveRuntime {
316        KhiveRuntime::memory().expect("in-memory runtime")
317    }
318
319    /// 1. Roundtrip: 3 entities + 2 edges survive export → import on a fresh runtime.
320    #[tokio::test]
321    async fn roundtrip_entities_and_edges() {
322        let src = make_rt().await;
323        let e1 = src
324            .create_entity(
325                None,
326                "concept",
327                "FlashAttention",
328                Some("fast attention"),
329                None,
330                vec![],
331            )
332            .await
333            .unwrap();
334        let e2 = src
335            .create_entity(None, "concept", "FlashAttention-2", None, None, vec![])
336            .await
337            .unwrap();
338        let e3 = src
339            .create_entity(None, "person", "Tri Dao", None, None, vec!["author".into()])
340            .await
341            .unwrap();
342        src.link(None, e2.id, e1.id, EdgeRelation::Extends, 1.0)
343            .await
344            .unwrap();
345        src.link(None, e1.id, e3.id, EdgeRelation::IntroducedBy, 0.9)
346            .await
347            .unwrap();
348
349        let archive = src.export_kg(None).await.unwrap();
350        assert_eq!(archive.entities.len(), 3);
351        assert_eq!(archive.edges.len(), 2);
352        assert_eq!(archive.format, "khive-kg");
353        assert_eq!(archive.version, "0.1");
354
355        let dst = make_rt().await;
356        let summary = dst.import_kg(&archive, None).await.unwrap();
357        assert_eq!(summary.entities_imported, 3);
358        assert_eq!(summary.edges_imported, 2);
359
360        // Spot-check: the imported entity is retrievable.
361        let got = dst.get_entity(None, e1.id).await.unwrap();
362        assert!(got.is_some());
363        let got = got.unwrap();
364        assert_eq!(got.name, "FlashAttention");
365        assert_eq!(got.description.as_deref(), Some("fast attention"));
366    }
367
368    /// 2. JSON roundtrip: export_kg_json → import_kg_json produces equivalent state.
369    #[tokio::test]
370    async fn json_roundtrip() {
371        let src = make_rt().await;
372        let e1 = src
373            .create_entity(
374                None,
375                "concept",
376                "LoRA",
377                Some("low-rank adaptation"),
378                Some(serde_json::json!({"year": "2021"})),
379                vec!["fine-tuning".into()],
380            )
381            .await
382            .unwrap();
383        let e2 = src
384            .create_entity(None, "concept", "QLoRA", None, None, vec![])
385            .await
386            .unwrap();
387        src.link(None, e2.id, e1.id, EdgeRelation::VariantOf, 0.9)
388            .await
389            .unwrap();
390
391        let json_str = src.export_kg_json(None).await.unwrap();
392        assert!(json_str.contains("khive-kg"));
393
394        let dst = make_rt().await;
395        let summary = dst.import_kg_json(&json_str, None).await.unwrap();
396        assert_eq!(summary.entities_imported, 2);
397        assert_eq!(summary.edges_imported, 1);
398
399        let got = dst.get_entity(None, e1.id).await.unwrap().unwrap();
400        assert_eq!(got.tags, vec!["fine-tuning"]);
401    }
402
403    /// 3. Namespace targeting: export from namespace "a", import into namespace "b" on a
404    ///    fresh runtime — entities land in "b", and the source runtime's "a" is unaffected.
405    ///
406    ///    Note: source and destination are separate runtimes (separate in-memory DBs).
407    ///    Same-DB cross-namespace copy is not a portability use case — portability is about
408    ///    moving graphs between instances, not between namespaces within one instance.
409    #[tokio::test]
410    async fn namespace_targeting() {
411        let src = make_rt().await;
412        src.create_entity(Some("a"), "concept", "Sinkhorn", None, None, vec![])
413            .await
414            .unwrap();
415
416        let archive = src.export_kg(Some("a")).await.unwrap();
417        assert_eq!(archive.namespace, "a");
418
419        // Import into a fresh runtime, targeting namespace "b".
420        let dst = make_rt().await;
421        let summary = dst.import_kg(&archive, Some("b")).await.unwrap();
422        assert_eq!(summary.entities_imported, 1);
423
424        // Entity is in "b" on the destination runtime.
425        let in_b = dst.list_entities(Some("b"), None, 100, 0).await.unwrap();
426        assert_eq!(in_b.len(), 1);
427        assert_eq!(in_b[0].name, "Sinkhorn");
428
429        // Namespace "a" on the source runtime is unchanged.
430        let in_a = src.list_entities(Some("a"), None, 100, 0).await.unwrap();
431        assert_eq!(in_a.len(), 1);
432
433        // Namespace "a" on the destination runtime has nothing (only "b" was written).
434        let dst_a = dst.list_entities(Some("a"), None, 100, 0).await.unwrap();
435        assert_eq!(dst_a.len(), 0);
436    }
437
438    /// 4. Format validation: wrong `format` field → InvalidInput.
439    #[tokio::test]
440    async fn format_validation_rejects_wrong_format() {
441        let rt = make_rt().await;
442        let bad = KgArchive {
443            format: "wrong".to_string(),
444            version: "0.1".to_string(),
445            namespace: "local".to_string(),
446            exported_at: Utc::now(),
447            entities: vec![],
448            edges: vec![],
449        };
450        let err = rt.import_kg(&bad, None).await.unwrap_err();
451        assert!(matches!(err, RuntimeError::InvalidInput(_)));
452    }
453
454    /// 5. Unsupported archive version → InvalidInput.
455    #[tokio::test]
456    async fn import_unsupported_archive_version_returns_error() {
457        let rt = make_rt().await;
458        let bad = KgArchive {
459            format: "khive-kg".to_string(),
460            version: "999.0".to_string(),
461            namespace: "local".to_string(),
462            exported_at: Utc::now(),
463            entities: vec![],
464            edges: vec![],
465        };
466        let err = rt.import_kg(&bad, None).await.unwrap_err();
467        assert!(
468            matches!(err, RuntimeError::InvalidInput(_)),
469            "expected InvalidInput, got {err:?}"
470        );
471        if let RuntimeError::InvalidInput(msg) = err {
472            assert!(
473                msg.contains("999.0"),
474                "error message should mention the unsupported version, got: {msg:?}"
475            );
476        }
477    }
478
479    /// 6. Invalid relation in archive → InvalidInput.
480    #[test]
481    fn invalid_relation_rejected_at_deserialize() {
482        let json = r#"{
483            "format":"khive-kg","version":"0.1","namespace":"local",
484            "exported_at":"2026-01-01T00:00:00Z",
485            "entities":[],
486            "edges":[{"edge_id":"00000000-0000-0000-0000-000000000099",
487                       "source":"00000000-0000-0000-0000-000000000001",
488                       "target":"00000000-0000-0000-0000-000000000002",
489                       "relation":"related_to","weight":0.5}]
490        }"#;
491        let result: Result<KgArchive, _> = serde_json::from_str(json);
492        assert!(
493            result.is_err(),
494            "non-canonical relation should fail to deserialize"
495        );
496    }
497
498    // ── Dangling-edge validation tests (issue #28) ────────────────────────────
499
500    /// 6. Edge with dangling source (source UUID not in entity table) is skipped.
501    ///
502    /// The archive has one entity + one edge whose source is a phantom UUID.
503    /// Import succeeds, entities_imported=1, edges_imported=0, edges_skipped=1.
504    #[tokio::test]
505    async fn import_edge_with_dangling_source_is_skipped() {
506        let phantom_source = Uuid::parse_str("deadbeef-dead-4ead-dead-deadbeefcafe").unwrap();
507
508        let rt = make_rt().await;
509        // Create an entity that will be the real target.
510        let real = rt
511            .create_entity(None, "concept", "Real", None, None, vec![])
512            .await
513            .unwrap();
514
515        // Build archive manually: one real entity, one edge with phantom source.
516        let archive = KgArchive {
517            format: "khive-kg".to_string(),
518            version: "0.1".to_string(),
519            namespace: "local".to_string(),
520            exported_at: Utc::now(),
521            entities: vec![ExportedEntity {
522                id: real.id,
523                kind: "concept".to_string(),
524                name: "Real".to_string(),
525                description: None,
526                properties: None,
527                tags: vec![],
528                created_at: Utc::now(),
529                updated_at: Utc::now(),
530            }],
531            edges: vec![ExportedEdge {
532                edge_id: Uuid::new_v4(),
533                source: phantom_source,
534                target: real.id,
535                relation: EdgeRelation::Extends,
536                weight: 1.0,
537            }],
538        };
539
540        let dst = make_rt().await;
541        let summary = dst.import_kg(&archive, None).await.unwrap();
542        assert_eq!(summary.entities_imported, 1);
543        assert_eq!(
544            summary.edges_imported, 0,
545            "dangling source must not be imported"
546        );
547        assert_eq!(
548            summary.edges_skipped, 1,
549            "dangling source must be counted as skipped"
550        );
551    }
552
553    /// 7. Edge with dangling target (target UUID not in entity table) is skipped.
554    ///
555    /// The archive has one entity + one edge whose target is a phantom UUID.
556    /// Import succeeds, entities_imported=1, edges_imported=0, edges_skipped=1.
557    #[tokio::test]
558    async fn import_edge_with_dangling_target_is_skipped() {
559        let phantom_target = Uuid::parse_str("cafebabe-cafe-4abe-cafe-cafebabecafe").unwrap();
560
561        let rt = make_rt().await;
562        let real = rt
563            .create_entity(None, "concept", "Source", None, None, vec![])
564            .await
565            .unwrap();
566
567        let archive = KgArchive {
568            format: "khive-kg".to_string(),
569            version: "0.1".to_string(),
570            namespace: "local".to_string(),
571            exported_at: Utc::now(),
572            entities: vec![ExportedEntity {
573                id: real.id,
574                kind: "concept".to_string(),
575                name: "Source".to_string(),
576                description: None,
577                properties: None,
578                tags: vec![],
579                created_at: Utc::now(),
580                updated_at: Utc::now(),
581            }],
582            edges: vec![ExportedEdge {
583                edge_id: Uuid::new_v4(),
584                source: real.id,
585                target: phantom_target,
586                relation: EdgeRelation::DependsOn,
587                weight: 0.8,
588            }],
589        };
590
591        let dst = make_rt().await;
592        let summary = dst.import_kg(&archive, None).await.unwrap();
593        assert_eq!(summary.entities_imported, 1);
594        assert_eq!(
595            summary.edges_imported, 0,
596            "dangling target must not be imported"
597        );
598        assert_eq!(
599            summary.edges_skipped, 1,
600            "dangling target must be counted as skipped"
601        );
602    }
603
604    /// 8. Mixed batch: some valid edges and some dangling edges — correct counts reported.
605    ///
606    /// Archive has 3 entities, 2 valid edges, and 1 dangling edge (phantom target).
607    /// Import succeeds with edges_imported=2, edges_skipped=1.
608    #[tokio::test]
609    async fn import_mixed_edges_reports_correct_counts() {
610        let phantom = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
611
612        let src = make_rt().await;
613        let a = src
614            .create_entity(None, "concept", "A", None, None, vec![])
615            .await
616            .unwrap();
617        let b = src
618            .create_entity(None, "concept", "B", None, None, vec![])
619            .await
620            .unwrap();
621        let c = src
622            .create_entity(None, "concept", "C", None, None, vec![])
623            .await
624            .unwrap();
625
626        // Build archive with 3 entities and 3 edges: 2 valid, 1 dangling.
627        let archive = KgArchive {
628            format: "khive-kg".to_string(),
629            version: "0.1".to_string(),
630            namespace: "local".to_string(),
631            exported_at: Utc::now(),
632            entities: vec![
633                ExportedEntity {
634                    id: a.id,
635                    kind: "concept".to_string(),
636                    name: "A".to_string(),
637                    description: None,
638                    properties: None,
639                    tags: vec![],
640                    created_at: Utc::now(),
641                    updated_at: Utc::now(),
642                },
643                ExportedEntity {
644                    id: b.id,
645                    kind: "concept".to_string(),
646                    name: "B".to_string(),
647                    description: None,
648                    properties: None,
649                    tags: vec![],
650                    created_at: Utc::now(),
651                    updated_at: Utc::now(),
652                },
653                ExportedEntity {
654                    id: c.id,
655                    kind: "concept".to_string(),
656                    name: "C".to_string(),
657                    description: None,
658                    properties: None,
659                    tags: vec![],
660                    created_at: Utc::now(),
661                    updated_at: Utc::now(),
662                },
663            ],
664            edges: vec![
665                // Valid: A → B
666                ExportedEdge {
667                    edge_id: Uuid::new_v4(),
668                    source: a.id,
669                    target: b.id,
670                    relation: EdgeRelation::Extends,
671                    weight: 1.0,
672                },
673                // Valid: B → C
674                ExportedEdge {
675                    edge_id: Uuid::new_v4(),
676                    source: b.id,
677                    target: c.id,
678                    relation: EdgeRelation::DependsOn,
679                    weight: 0.9,
680                },
681                // Dangling: A → phantom
682                ExportedEdge {
683                    edge_id: Uuid::new_v4(),
684                    source: a.id,
685                    target: phantom,
686                    relation: EdgeRelation::Enables,
687                    weight: 0.5,
688                },
689            ],
690        };
691
692        let dst = make_rt().await;
693        let summary = dst.import_kg(&archive, None).await.unwrap();
694        assert_eq!(summary.entities_imported, 3);
695        assert_eq!(
696            summary.edges_imported, 2,
697            "only valid edges must be imported"
698        );
699        assert_eq!(
700            summary.edges_skipped, 1,
701            "one dangling edge must be reported"
702        );
703    }
704
705    /// 9. All-valid edges produce edges_skipped=0 (no regression on the happy path).
706    #[tokio::test]
707    async fn import_all_valid_edges_reports_zero_skipped() {
708        let src = make_rt().await;
709        let e1 = src
710            .create_entity(None, "concept", "E1", None, None, vec![])
711            .await
712            .unwrap();
713        let e2 = src
714            .create_entity(None, "concept", "E2", None, None, vec![])
715            .await
716            .unwrap();
717        src.link(None, e1.id, e2.id, EdgeRelation::VariantOf, 0.7)
718            .await
719            .unwrap();
720
721        let archive = src.export_kg(None).await.unwrap();
722        let dst = make_rt().await;
723        let summary = dst.import_kg(&archive, None).await.unwrap();
724        assert_eq!(summary.edges_imported, 1);
725        assert_eq!(
726            summary.edges_skipped, 0,
727            "no edges should be skipped when all endpoints exist"
728        );
729    }
730
731    // ── edge_id contract tests (ADR-048 D1) ──────────────────────────────────
732
733    /// 10. export_kg sets edge_id in the archive to the LinkId returned by link.
734    #[tokio::test]
735    async fn export_kg_preserves_edge_id() {
736        let rt = make_rt().await;
737        let a = rt
738            .create_entity(None, "concept", "Alpha", None, None, vec![])
739            .await
740            .unwrap();
741        let b = rt
742            .create_entity(None, "concept", "Beta", None, None, vec![])
743            .await
744            .unwrap();
745        let stored_edge = rt
746            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
747            .await
748            .unwrap();
749        let stored_id: Uuid = stored_edge.id.into();
750
751        let archive = rt.export_kg(None).await.unwrap();
752        assert_eq!(archive.edges.len(), 1);
753        assert_eq!(
754            archive.edges[0].edge_id, stored_id,
755            "exported edge_id must equal the LinkId returned by link"
756        );
757    }
758
759    /// 11. import_kg writes the archive edge_id as the stored LinkId.
760    #[tokio::test]
761    async fn import_kg_persists_edge_id() {
762        let src = make_rt().await;
763        let a = src
764            .create_entity(None, "concept", "Alpha", None, None, vec![])
765            .await
766            .unwrap();
767        let b = src
768            .create_entity(None, "concept", "Beta", None, None, vec![])
769            .await
770            .unwrap();
771        let stored_edge = src
772            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
773            .await
774            .unwrap();
775        let original_id: Uuid = stored_edge.id.into();
776
777        let archive = src.export_kg(None).await.unwrap();
778        let dst = make_rt().await;
779        dst.import_kg(&archive, None).await.unwrap();
780
781        // The imported edge must carry the same UUID as the original.
782        let imported_edge = dst.get_edge(None, original_id).await.unwrap();
783        assert!(
784            imported_edge.is_some(),
785            "imported edge must be retrievable by the original edge_id"
786        );
787        let imported_edge = imported_edge.unwrap();
788        assert_eq!(
789            Uuid::from(imported_edge.id),
790            original_id,
791            "stored edge id must equal the archive edge_id"
792        );
793    }
794
795    /// 12. Old archive (no edge_id field) deserializes, imports, and re-exports with the
796    ///     same generated UUID — proving the generated ID survives the full round trip.
797    ///
798    ///     The fixture includes two entities so the edge is not skipped during import.
799    #[tokio::test]
800    async fn old_archive_missing_edge_id_round_trips() {
801        // Two entity UUIDs that will appear in both the fixture and the entity list.
802        let src_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
803        let tgt_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
804
805        // Simulate a pre-0.2 archive JSON where the edge lacks an edge_id field.
806        let json = format!(
807            r#"{{
808                "format": "khive-kg",
809                "version": "0.1",
810                "namespace": "local",
811                "exported_at": "2026-01-01T00:00:00Z",
812                "entities": [
813                    {{"id":"{src_id}","kind":"concept","name":"SrcNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}},
814                    {{"id":"{tgt_id}","kind":"concept","name":"TgtNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}
815                ],
816                "edges": [
817                    {{
818                        "source": "{src_id}",
819                        "target": "{tgt_id}",
820                        "relation": "extends",
821                        "weight": 0.9
822                    }}
823                ]
824            }}"#
825        );
826
827        // Deserialize: serde(default) must assign a non-nil UUID.
828        let archive: KgArchive = serde_json::from_str(&json)
829            .expect("old archive without edge_id must deserialize successfully");
830        assert_eq!(archive.edges.len(), 1);
831        let generated_id = archive.edges[0].edge_id;
832        assert_ne!(
833            generated_id,
834            Uuid::nil(),
835            "missing edge_id in old archive must get a fresh non-nil UUID"
836        );
837
838        // Import into a fresh runtime and verify the generated ID is persisted.
839        let rt = make_rt().await;
840        let summary = rt.import_kg(&archive, None).await.unwrap();
841        assert_eq!(summary.entities_imported, 2);
842        assert_eq!(
843            summary.edges_imported, 1,
844            "edge must be imported when both endpoints exist"
845        );
846
847        let stored = rt.get_edge(None, generated_id).await.unwrap();
848        assert!(
849            stored.is_some(),
850            "imported edge must be retrievable by the generated edge_id"
851        );
852        assert_eq!(
853            Uuid::from(stored.unwrap().id),
854            generated_id,
855            "stored edge id must equal the generated edge_id"
856        );
857
858        // Re-export and verify the same UUID appears in the archive.
859        let re_archive = rt.export_kg(None).await.unwrap();
860        assert_eq!(re_archive.edges.len(), 1);
861        assert_eq!(
862            re_archive.edges[0].edge_id, generated_id,
863            "re-exported edge_id must equal the ID generated on first import"
864        );
865    }
866
867    /// 13. Explicit export → import → export equality: the edge_id is unchanged across
868    ///     a full round trip when the source archive already contains an edge_id.
869    ///
870    ///     Verifies by (source, target, relation) key that re-export emits the original ID.
871    #[tokio::test]
872    async fn export_import_export_edge_id_equality() {
873        // Build a graph on the source runtime.
874        let src = make_rt().await;
875        let a = src
876            .create_entity(None, "concept", "NodeA", None, None, vec![])
877            .await
878            .unwrap();
879        let b = src
880            .create_entity(None, "concept", "NodeB", None, None, vec![])
881            .await
882            .unwrap();
883        let stored = src
884            .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
885            .await
886            .unwrap();
887        let original_edge_id: Uuid = stored.id.into();
888
889        // First export.
890        let archive1 = src.export_kg(None).await.unwrap();
891        assert_eq!(archive1.edges.len(), 1);
892        assert_eq!(
893            archive1.edges[0].edge_id, original_edge_id,
894            "first export must carry the stored edge_id"
895        );
896
897        // Import into a fresh runtime.
898        let dst = make_rt().await;
899        dst.import_kg(&archive1, None).await.unwrap();
900
901        // Second export from the destination runtime.
902        let archive2 = dst.export_kg(None).await.unwrap();
903        assert_eq!(archive2.edges.len(), 1);
904
905        // Find the edge by (source, target, relation) and assert the ID is unchanged.
906        let re_edge = archive2
907            .edges
908            .iter()
909            .find(|e| e.source == a.id && e.target == b.id && e.relation == EdgeRelation::Extends)
910            .expect(
911                "re-exported archive must contain the original edge by (source,target,relation)",
912            );
913        assert_eq!(
914            re_edge.edge_id, original_edge_id,
915            "edge_id must be identical across export → import → export"
916        );
917    }
918}