Skip to main content

khive_runtime/
portability.rs

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