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