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