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::{HashMap, HashSet};
7
8use anyhow::Context;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use khive_storage::types::{EdgeFilter, LinkId, PageRequest};
14use khive_storage::{EdgeRelation, EntityFilter};
15
16use crate::error::{RuntimeError, RuntimeResult};
17use crate::runtime::{KhiveRuntime, NamespaceToken};
18
19/// Parse an optional archive timestamp, using `fallback` only when absent.
20///
21/// Present values must be RFC3339 and are converted to UTC. Invalid values keep
22/// their parser error with the original timestamp as context.
23pub fn parse_archive_timestamp(
24    value: Option<&str>,
25    fallback: DateTime<Utc>,
26) -> anyhow::Result<DateTime<Utc>> {
27    let Some(raw) = value else {
28        return Ok(fallback);
29    };
30    DateTime::parse_from_rfc3339(raw)
31        .map(|dt| dt.with_timezone(&Utc))
32        .with_context(|| format!("timestamp {raw:?} must be RFC3339"))
33}
34
35// ── Archive types ─────────────────────────────────────────────────────────────
36
37/// Portable JSON archive of a namespace-scoped knowledge graph.
38///
39/// The `format` field is always `"khive-kg"`. The `version` field identifies
40/// the serialization schema; parsers should reject unknown versions.
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct KgArchive {
43    pub format: String,
44    pub version: String,
45    pub namespace: String,
46    pub exported_at: DateTime<Utc>,
47    pub entities: Vec<ExportedEntity>,
48    pub edges: Vec<ExportedEdge>,
49}
50
51/// An entity record in the portable archive.
52#[derive(Clone, Debug, Serialize, Deserialize)]
53pub struct ExportedEntity {
54    pub id: Uuid,
55    /// Pack-owned kind string (e.g. `"concept"`, `"person"`).
56    pub kind: String,
57    /// Pack-governed subtype token (e.g. `"paper"`, `"snapshot"`).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub entity_type: Option<String>,
60    pub name: String,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub description: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub properties: Option<serde_json::Value>,
65    #[serde(default)]
66    pub tags: Vec<String>,
67    pub created_at: DateTime<Utc>,
68    pub updated_at: DateTime<Utc>,
69}
70
71/// A directed edge record in the portable archive.
72#[derive(Clone, Debug, Serialize, Deserialize)]
73pub struct ExportedEdge {
74    /// Stable edge identity across export/import cycles.
75    ///
76    /// Old archives (pre-0.2) omit this field. `serde(default)` assigns a fresh
77    /// UUID on import so backward-compatible archives are accepted as-is.
78    #[serde(default = "Uuid::new_v4")]
79    pub edge_id: Uuid,
80    pub source: Uuid,
81    pub target: Uuid,
82    /// One of the canonical edge relations (closed enum).
83    pub relation: EdgeRelation,
84    pub weight: f64,
85    /// Portable edge metadata, named `properties` in the ADR-020 wire shape.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub properties: Option<serde_json::Value>,
88    /// Edge creation time. Older archives may omit it; those imports use the
89    /// time at which the archive is decoded.
90    #[serde(
91        default = "default_import_timestamp",
92        deserialize_with = "deserialize_import_timestamp"
93    )]
94    pub created_at: DateTime<Utc>,
95    /// Edge last-update time. Kept independent from `created_at` so imported
96    /// provenance is not collapsed during a round trip.
97    #[serde(
98        default = "default_import_timestamp",
99        deserialize_with = "deserialize_import_timestamp"
100    )]
101    pub updated_at: DateTime<Utc>,
102}
103
104fn default_import_timestamp() -> DateTime<Utc> {
105    Utc::now()
106}
107
108fn deserialize_import_timestamp<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
109where
110    D: serde::Deserializer<'de>,
111{
112    let raw = String::deserialize(deserializer)?;
113    DateTime::parse_from_rfc3339(&raw)
114        .map(|timestamp| timestamp.with_timezone(&Utc))
115        .map_err(|error| serde::de::Error::custom(format!("timestamp must be RFC3339: {error}")))
116}
117
118/// Why an archive edge was skipped during a successful import.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ImportEdgeSkipCause {
122    /// The source entity was not found during the initial endpoint check.
123    MissingSource,
124    /// The source exists, but the target entity was not found.
125    MissingTarget,
126    /// The later endpoint-contract check returned InvalidInput or NotFound.
127    EndpointContract,
128}
129
130/// One skipped archive edge, retaining its identity and endpoint-contract error.
131#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
132pub struct ImportSkippedEdge {
133    pub edge_id: Uuid,
134    pub source: Uuid,
135    pub target: Uuid,
136    pub relation: EdgeRelation,
137    pub cause: ImportEdgeSkipCause,
138    /// The original RuntimeError display text for EndpointContract only.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub error: Option<String>,
141}
142
143/// Outcome of a successful import operation.
144#[derive(Clone, Debug, Serialize, Deserialize)]
145pub struct ImportSummary {
146    pub entities_imported: usize,
147    pub edges_imported: usize,
148    /// Number of edges skipped for missing endpoints or endpoint-contract refusal.
149    ///
150    /// Newly successful imports set this to skipped_edges.len(). Historical
151    /// summaries can retain a non-zero count without individual details.
152    pub edges_skipped: usize,
153    /// Every skipped entry in archive order, including duplicates. Missing-source
154    /// takes precedence when both endpoints are absent. Empty lists are omitted
155    /// from JSON to preserve the previous zero-skip summary shape.
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub skipped_edges: Vec<ImportSkippedEdge>,
158    /// Aggregate actual outcomes from reindexing imported entities. Source
159    /// text remains complete in the entity store and FTS when this is non-zero.
160    #[serde(default)]
161    pub embedding_truncation: crate::retrieval::EmbeddingTruncationReport,
162}
163
164// ── KhiveRuntime impl ─────────────────────────────────────────────────────────
165
166impl KhiveRuntime {
167    /// Export all entities and edges in a namespace to a portable JSON archive.
168    ///
169    /// Edge collection: all entity IDs in the namespace are gathered first;
170    /// `query_edges` is then called with those IDs as `source_ids`. This
171    /// captures every edge whose source entity belongs to the namespace.
172    pub async fn export_kg(&self, token: &NamespaceToken) -> RuntimeResult<KgArchive> {
173        let ns = token.namespace().as_str().to_owned();
174
175        let entity_page = self
176            .entities(token)?
177            .query_entities(
178                &ns,
179                EntityFilter::default(),
180                PageRequest {
181                    offset: 0,
182                    limit: u32::MAX,
183                },
184            )
185            .await?;
186
187        let entities: Vec<ExportedEntity> = entity_page
188            .items
189            .into_iter()
190            .map(|e| {
191                let created_at =
192                    DateTime::from_timestamp_micros(e.created_at).unwrap_or_else(Utc::now);
193                let updated_at =
194                    DateTime::from_timestamp_micros(e.updated_at).unwrap_or_else(Utc::now);
195                ExportedEntity {
196                    id: e.id,
197                    kind: e.kind.to_string(),
198                    entity_type: e.entity_type,
199                    name: e.name,
200                    description: e.description,
201                    properties: e.properties,
202                    tags: e.tags,
203                    created_at,
204                    updated_at,
205                }
206            })
207            .collect();
208
209        let source_ids: Vec<Uuid> = entities.iter().map(|e| e.id).collect();
210        let edges = if source_ids.is_empty() {
211            Vec::new()
212        } else {
213            let filter = EdgeFilter {
214                source_ids: source_ids.clone(),
215                ..Default::default()
216            };
217            let edge_page = self
218                .graph(token)?
219                .query_edges(
220                    filter,
221                    Vec::new(),
222                    PageRequest {
223                        offset: 0,
224                        limit: u32::MAX,
225                    },
226                )
227                .await?;
228
229            let id_set: HashSet<Uuid> = source_ids.into_iter().collect();
230            edge_page
231                .items
232                .into_iter()
233                .filter(|e| id_set.contains(&e.source_id))
234                .map(|e| ExportedEdge {
235                    edge_id: e.id.into(),
236                    source: e.source_id,
237                    target: e.target_id,
238                    relation: e.relation,
239                    weight: e.weight,
240                    properties: e.metadata,
241                    created_at: e.created_at,
242                    updated_at: e.updated_at,
243                })
244                .collect()
245        };
246
247        Ok(KgArchive {
248            format: "khive-kg".to_string(),
249            version: "0.1".to_string(),
250            namespace: ns,
251            exported_at: Utc::now(),
252            entities,
253            edges,
254        })
255    }
256
257    /// Export to a JSON string (convenience wrapper around `export_kg`).
258    pub async fn export_kg_json(&self, token: &NamespaceToken) -> RuntimeResult<String> {
259        let archive = self.export_kg(token).await?;
260        serde_json::to_string(&archive).map_err(|e| RuntimeError::InvalidInput(e.to_string()))
261    }
262
263    /// Import an archive into `target_namespace`.
264    ///
265    /// If `target_namespace` is `None`, the archive's own namespace is used.
266    ///
267    /// - Entities: upserted by ID; existing records are overwritten.
268    /// - Edges: upserted; existing records are overwritten.
269    /// - Validation: `format != "khive-kg"` or unsupported version → `InvalidInput`.
270    ///   Invalid edge relations are caught at JSON deserialization time.
271    pub async fn import_kg(
272        &self,
273        archive: &KgArchive,
274        token: &NamespaceToken,
275    ) -> RuntimeResult<ImportSummary> {
276        if archive.format != "khive-kg" {
277            return Err(RuntimeError::InvalidInput(format!(
278                "unsupported archive format {:?}; expected \"khive-kg\"",
279                archive.format
280            )));
281        }
282        if archive.version != "0.1" {
283            return Err(RuntimeError::InvalidInput(format!(
284                "unsupported archive version {:?}; supported: \"0.1\"",
285                archive.version
286            )));
287        }
288
289        let ns = token.namespace().as_str().to_owned();
290
291        // Complete deterministic validation before opening a store or issuing
292        // the first write. Endpoint existence and namespace checks remain at
293        // write time because they depend on mutable target state.
294        for (index, entity) in archive.entities.iter().enumerate() {
295            self.validate_entity_kind(&entity.kind)?;
296            // Archive content is caller-controlled input: the runtime-owned
297            // `khive:secret_gate` property key is reservation-only on import,
298            // exactly as on every other properties-bearing write path
299            // (ADR-115 Amendment 1 §3). Import never consumes exemptions.
300            crate::secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref())?;
301            if entity.name.trim().is_empty() {
302                return Err(RuntimeError::InvalidInput(format!(
303                    "archive entity {index} ({}) name must be non-blank",
304                    entity.id
305                )));
306            }
307        }
308        for (index, edge) in archive.edges.iter().enumerate() {
309            let record = format!("edge[{index}]");
310            crate::operations::validate_edge_weight(edge.weight)?;
311            // Edge properties are caller-controlled input: the runtime-owned
312            // `khive:secret_gate` key is reservation-only on import, and edge
313            // metadata is in ADR-115 Amendment 1 §3's unchanged blocking-scanner
314            // class, so credential-shaped values are rejected here as well.
315            crate::secret_gate::reject_reserved_secret_gate_property(edge.properties.as_ref())?;
316            if let Some(p) = edge.properties.as_ref() {
317                crate::secret_gate::check_json_at(p, &record, "properties")?;
318            }
319        }
320
321        let store = self.entities(token)?;
322        let mut entities_imported = 0usize;
323        let mut embedding_truncation = crate::retrieval::EmbeddingTruncationReport::default();
324        for page in archive
325            .entities
326            .chunks(crate::retrieval::EMBEDDING_BATCH_PAGE_SIZE)
327        {
328            let entities: Vec<khive_storage::entity::Entity> = page
329                .iter()
330                .map(|ee| khive_storage::entity::Entity {
331                    entity_type: ee.entity_type.clone(),
332                    description: ee.description.clone(),
333                    properties: ee.properties.clone(),
334                    tags: ee.tags.clone(),
335                    ..khive_storage::entity::Entity::minimal(
336                        ee.id,
337                        ns.clone(),
338                        ee.kind.clone(),
339                        ee.name.clone(),
340                        ee.created_at.timestamp_micros(),
341                        ee.updated_at.timestamp_micros(),
342                    )
343                })
344                .collect();
345            let texts: Vec<String> = entities
346                .iter()
347                .map(crate::curation::entity_embedding_text)
348                .collect();
349            let mut embeddings: Vec<HashMap<String, crate::retrieval::DocumentEmbeddingOutcome>> =
350                (0..entities.len()).map(|_| HashMap::new()).collect();
351            for model_name in self.registered_embedding_model_names() {
352                match self
353                    .embed_document_batch_with_model_outcomes_for_token(token, &model_name, &texts)
354                    .await
355                {
356                    Ok(outcomes) => {
357                        for (slot, outcome) in embeddings.iter_mut().zip(outcomes) {
358                            slot.insert(model_name.clone(), outcome);
359                        }
360                    }
361                    Err(error) => {
362                        tracing::warn!(
363                            model = %model_name,
364                            error = %error,
365                            "import_kg: batch embed failed; retrying records individually"
366                        );
367                    }
368                }
369            }
370            for (entity, outcomes) in entities.into_iter().zip(embeddings) {
371                store.upsert_entity(entity.clone()).await?;
372                // Publish each record through the same guarded FTS/vector writer
373                // as an ordinary reindex, including its best-effort model failures.
374                embedding_truncation.merge(
375                    self.reindex_entity_with_precomputed(token, &entity, outcomes)
376                        .await?,
377                );
378                entities_imported += 1;
379            }
380        }
381
382        // Untrusted archives may reference entities absent from the target namespace;
383        // check both endpoints and skip edges with a missing source or target to avoid
384        // dangling references in the graph store.
385        let graph = self.graph(token)?;
386        let mut edges_imported = 0usize;
387        let mut skipped_edges = Vec::new();
388        for ee in &archive.edges {
389            let source_ok = match self.get_entity(token, ee.source).await {
390                Ok(_) => true,
391                Err(RuntimeError::NotFound(_)) => false,
392                Err(e) => return Err(e),
393            };
394            if !source_ok {
395                tracing::warn!(
396                    source = %ee.source,
397                    target = %ee.target,
398                    relation = ?ee.relation,
399                    "import_kg: skipping edge — source entity not found in namespace {ns:?}"
400                );
401                skipped_edges.push(ImportSkippedEdge {
402                    edge_id: ee.edge_id,
403                    source: ee.source,
404                    target: ee.target,
405                    relation: ee.relation,
406                    cause: ImportEdgeSkipCause::MissingSource,
407                    error: None,
408                });
409                continue;
410            }
411            let target_ok = match self.get_entity(token, ee.target).await {
412                Ok(_) => true,
413                Err(RuntimeError::NotFound(_)) => false,
414                Err(e) => return Err(e),
415            };
416            if !target_ok {
417                tracing::warn!(
418                    source = %ee.source,
419                    target = %ee.target,
420                    relation = ?ee.relation,
421                    "import_kg: skipping edge — target entity not found in namespace {ns:?}"
422                );
423                skipped_edges.push(ImportSkippedEdge {
424                    edge_id: ee.edge_id,
425                    source: ee.source,
426                    target: ee.target,
427                    relation: ee.relation,
428                    cause: ImportEdgeSkipCause::MissingTarget,
429                    error: None,
430                });
431                continue;
432            }
433            // Only a contract verdict (InvalidInput) or an endpoint-resolution
434            // miss (NotFound) is a property of the edge being imported; any
435            // other error is an operational failure of the check itself and
436            // must abort the import rather than silently discard a valid edge.
437            match self
438                .validate_edge_relation_endpoints(token, ee.source, ee.target, ee.relation)
439                .await
440            {
441                Ok(_) => {}
442                Err(e @ (RuntimeError::InvalidInput(_) | RuntimeError::NotFound(_))) => {
443                    tracing::warn!(
444                        source = %ee.source,
445                        target = %ee.target,
446                        relation = ?ee.relation,
447                        error = %e,
448                        "import_kg: skipping edge — endpoint contract violation in namespace {ns:?}"
449                    );
450                    skipped_edges.push(ImportSkippedEdge {
451                        edge_id: ee.edge_id,
452                        source: ee.source,
453                        target: ee.target,
454                        relation: ee.relation,
455                        cause: ImportEdgeSkipCause::EndpointContract,
456                        error: Some(e.to_string()),
457                    });
458                    continue;
459                }
460                Err(e) => return Err(e),
461            }
462            let edge = khive_storage::types::Edge {
463                id: LinkId::from(ee.edge_id),
464                namespace: ns.clone(),
465                source_id: ee.source,
466                target_id: ee.target,
467                relation: ee.relation,
468                weight: ee.weight,
469                created_at: ee.created_at,
470                updated_at: ee.updated_at,
471                deleted_at: None,
472                metadata: ee.properties.clone(),
473                target_backend: None,
474            };
475            graph.upsert_edge(edge).await?;
476            edges_imported += 1;
477        }
478
479        Ok(ImportSummary {
480            entities_imported,
481            edges_imported,
482            edges_skipped: skipped_edges.len(),
483            skipped_edges,
484            embedding_truncation,
485        })
486    }
487
488    /// Import from a JSON string (convenience wrapper around `import_kg`).
489    pub async fn import_kg_json(
490        &self,
491        json: &str,
492        token: &NamespaceToken,
493    ) -> RuntimeResult<ImportSummary> {
494        let archive: KgArchive =
495            serde_json::from_str(json).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
496        self.import_kg(&archive, token).await
497    }
498}
499
500// ── Tests ─────────────────────────────────────────────────────────────────────
501
502// Kept inline: these tests exercise round-trip invariants over private encoding
503// helpers that would otherwise need to be made pub to test from tests/.
504#[cfg(test)]
505mod tests {
506    use std::sync::atomic::{AtomicUsize, Ordering};
507    use std::sync::Arc;
508
509    use super::*;
510    use crate::runtime::{KhiveRuntime, NamespaceToken};
511    use crate::{EmbedderProvider, Namespace};
512    use async_trait::async_trait;
513    use khive_storage::EdgeRelation;
514    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService, MAX_TEXT_BYTES};
515
516    const IMPORT_TEST_MODEL: &str = "all-minilm-l6-v2";
517    const IMPORT_BATCH_MODEL: &str = "import-batch-model";
518    const IMPORT_BATCH_MODEL_TWO: &str = "import-batch-model-two";
519
520    struct CountingImportService {
521        calls: Arc<AtomicUsize>,
522        reject_poison: bool,
523    }
524
525    #[async_trait]
526    impl EmbeddingService for CountingImportService {
527        async fn embed(
528            &self,
529            texts: &[String],
530            _model: EmbeddingModel,
531        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
532            self.calls.fetch_add(1, Ordering::SeqCst);
533            if self.reject_poison
534                && (texts.len() > 1 || texts.iter().any(|text| text.contains("poison")))
535            {
536                return Err(EmbedError::InferenceFailed("poison input".into()));
537            }
538            Ok(texts
539                .iter()
540                .map(|text| {
541                    vec![
542                        text.len() as f32,
543                        text.bytes().map(u32::from).sum::<u32>() as f32,
544                        text.as_bytes().first().copied().unwrap_or_default() as f32,
545                        text.as_bytes().last().copied().unwrap_or_default() as f32,
546                    ]
547                })
548                .collect())
549        }
550
551        fn supports_model(&self, _model: EmbeddingModel) -> bool {
552            true
553        }
554
555        fn name(&self) -> &'static str {
556            "import-batch-counting-service"
557        }
558    }
559
560    struct CountingImportProvider {
561        name: &'static str,
562        calls: Arc<AtomicUsize>,
563        reject_poison: bool,
564    }
565
566    #[async_trait]
567    impl EmbedderProvider for CountingImportProvider {
568        fn name(&self) -> &str {
569            self.name
570        }
571
572        fn dimensions(&self) -> usize {
573            4
574        }
575
576        async fn build(&self) -> std::result::Result<Arc<dyn EmbeddingService>, RuntimeError> {
577            Ok(Arc::new(CountingImportService {
578                calls: Arc::clone(&self.calls),
579                reject_poison: self.reject_poison,
580            }))
581        }
582    }
583
584    fn batch_archive(names: impl IntoIterator<Item = String>) -> KgArchive {
585        KgArchive {
586            format: "khive-kg".to_string(),
587            version: "0.1".to_string(),
588            namespace: "local".to_string(),
589            exported_at: Utc::now(),
590            entities: names
591                .into_iter()
592                .map(|name| ExportedEntity {
593                    id: Uuid::new_v4(),
594                    kind: "concept".to_string(),
595                    entity_type: None,
596                    name,
597                    description: Some("batch body".to_string()),
598                    properties: None,
599                    tags: vec![],
600                    created_at: Utc::now(),
601                    updated_at: Utc::now(),
602                })
603                .collect(),
604            edges: vec![],
605        }
606    }
607
608    struct ImportEmbeddingService;
609
610    #[async_trait]
611    impl EmbeddingService for ImportEmbeddingService {
612        async fn embed(
613            &self,
614            texts: &[String],
615            _model: EmbeddingModel,
616        ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
617            let dimensions = EmbeddingModel::AllMiniLmL6V2.dimensions();
618            Ok(texts.iter().map(|_| vec![1.0; dimensions]).collect())
619        }
620
621        fn supports_model(&self, _model: EmbeddingModel) -> bool {
622            true
623        }
624
625        fn name(&self) -> &'static str {
626            "kg-import-truncation-test"
627        }
628    }
629
630    struct ImportEmbeddingProvider;
631
632    #[async_trait]
633    impl EmbedderProvider for ImportEmbeddingProvider {
634        fn name(&self) -> &str {
635            IMPORT_TEST_MODEL
636        }
637
638        fn dimensions(&self) -> usize {
639            EmbeddingModel::AllMiniLmL6V2.dimensions()
640        }
641
642        async fn build(&self) -> std::result::Result<Arc<dyn EmbeddingService>, RuntimeError> {
643            Ok(Arc::new(ImportEmbeddingService))
644        }
645    }
646
647    async fn make_rt() -> KhiveRuntime {
648        KhiveRuntime::memory().expect("in-memory runtime")
649    }
650
651    fn make_rt_with_embedder() -> KhiveRuntime {
652        let runtime = KhiveRuntime::memory().expect("in-memory runtime");
653        runtime.register_embedder(ImportEmbeddingProvider);
654        runtime
655    }
656
657    #[test]
658    fn archive_timestamp_falls_back_only_when_absent_and_converts_offsets_to_utc() {
659        let fallback = DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
660            .unwrap()
661            .with_timezone(&Utc);
662        assert_eq!(parse_archive_timestamp(None, fallback).unwrap(), fallback);
663
664        let utc = parse_archive_timestamp(Some("2024-05-06T07:08:09Z"), fallback).unwrap();
665        assert_eq!(utc.to_rfc3339(), "2024-05-06T07:08:09+00:00");
666
667        let offset = parse_archive_timestamp(Some("2024-05-06T09:08:09+02:00"), fallback).unwrap();
668        assert_eq!(offset, utc);
669
670        let err = parse_archive_timestamp(Some("2024-05-06 07:08:09"), fallback).unwrap_err();
671        let text = format!("{err:#}");
672        assert!(text.contains("\"2024-05-06 07:08:09\""), "{text}");
673        assert!(text.contains("must be RFC3339"), "{text}");
674        assert!(
675            err.chain().count() > 1,
676            "parser source error is kept: {text}"
677        );
678    }
679
680    #[tokio::test]
681    async fn import_batches_provider_calls_and_matches_per_record_reindex_vectors() {
682        let token = NamespaceToken::local();
683        let archive = batch_archive((0..257).map(|index| format!("Batch entity {index}")));
684        let ids: Vec<Uuid> = archive.entities.iter().map(|entity| entity.id).collect();
685        let calls = Arc::new(AtomicUsize::new(0));
686        let runtime = KhiveRuntime::memory().unwrap();
687        runtime.register_embedder(CountingImportProvider {
688            name: IMPORT_BATCH_MODEL,
689            calls: Arc::clone(&calls),
690            reject_poison: false,
691        });
692        let usage = crate::usage::UsageContext::new();
693        let summary = crate::usage::scope(usage.clone(), runtime.import_kg(&archive, &token))
694            .await
695            .unwrap();
696        assert_eq!(summary.entities_imported, ids.len());
697        assert_eq!(usage.snapshot()["embed_calls"], ids.len() as u64);
698        assert_eq!(
699            calls.load(Ordering::SeqCst),
700            2,
701            "257 records need two provider batches"
702        );
703
704        let singles = KhiveRuntime::memory().unwrap();
705        let singleton_calls = Arc::new(AtomicUsize::new(0));
706        singles.register_embedder(CountingImportProvider {
707            name: IMPORT_BATCH_MODEL,
708            calls: Arc::clone(&singleton_calls),
709            reject_poison: false,
710        });
711        for id in &ids {
712            let entity = runtime.get_entity(&token, *id).await.unwrap();
713            singles
714                .entities(&token)
715                .unwrap()
716                .upsert_entity(entity.clone())
717                .await
718                .unwrap();
719            singles.reindex_entity(&token, &entity).await.unwrap();
720        }
721        assert_eq!(singleton_calls.load(Ordering::SeqCst), ids.len());
722        let batched_vectors = runtime
723            .vectors_for_model(&token, IMPORT_BATCH_MODEL)
724            .unwrap()
725            .get_vectors(&ids, "local", "entity.body")
726            .await
727            .unwrap();
728        let singleton_vectors = singles
729            .vectors_for_model(&token, IMPORT_BATCH_MODEL)
730            .unwrap()
731            .get_vectors(&ids, "local", "entity.body")
732            .await
733            .unwrap();
734        assert_eq!(batched_vectors.len(), ids.len());
735        assert_eq!(batched_vectors, singleton_vectors);
736    }
737
738    #[tokio::test]
739    async fn import_batches_each_registered_model_once_per_page() {
740        let token = NamespaceToken::local();
741        let archive = batch_archive(["alpha", "beta", "gamma"].map(str::to_string));
742        let calls_a = Arc::new(AtomicUsize::new(0));
743        let calls_b = Arc::new(AtomicUsize::new(0));
744        let runtime = KhiveRuntime::memory().unwrap();
745        for (name, calls) in [
746            (IMPORT_BATCH_MODEL, Arc::clone(&calls_a)),
747            (IMPORT_BATCH_MODEL_TWO, Arc::clone(&calls_b)),
748        ] {
749            runtime.register_embedder(CountingImportProvider {
750                name,
751                calls,
752                reject_poison: false,
753            });
754        }
755        runtime.import_kg(&archive, &token).await.unwrap();
756        assert_eq!(calls_a.load(Ordering::SeqCst), 1);
757        assert_eq!(calls_b.load(Ordering::SeqCst), 1);
758        let ids: Vec<Uuid> = archive.entities.iter().map(|entity| entity.id).collect();
759        for model in [IMPORT_BATCH_MODEL, IMPORT_BATCH_MODEL_TWO] {
760            assert_eq!(
761                runtime
762                    .vectors_for_model(&token, model)
763                    .unwrap()
764                    .get_vectors(&ids, "local", "entity.body")
765                    .await
766                    .unwrap()
767                    .len(),
768                ids.len()
769            );
770        }
771    }
772
773    #[tokio::test]
774    async fn import_failed_batch_isolates_poison_without_duplicate_vector_writes() {
775        use khive_storage::types::{SqlStatement, SqlValue};
776
777        let token = NamespaceToken::local();
778        let archive = batch_archive(["good one", "poison", "good two"].map(str::to_string));
779        let ids: Vec<Uuid> = archive.entities.iter().map(|entity| entity.id).collect();
780        let calls = Arc::new(AtomicUsize::new(0));
781        let runtime = KhiveRuntime::memory().unwrap();
782        runtime.register_embedder(CountingImportProvider {
783            name: IMPORT_BATCH_MODEL,
784            calls: Arc::clone(&calls),
785            reject_poison: true,
786        });
787        let summary = runtime.import_kg(&archive, &token).await.unwrap();
788        assert_eq!(summary.entities_imported, 3);
789        assert_eq!(
790            calls.load(Ordering::SeqCst),
791            4,
792            "one failed page plus three singleton retries"
793        );
794        let vectors = runtime
795            .vectors_for_model(&token, IMPORT_BATCH_MODEL)
796            .unwrap()
797            .get_vectors(&ids, "local", "entity.body")
798            .await
799            .unwrap();
800        assert_eq!(vectors.len(), 2);
801        assert!(!vectors.contains_key(&ids[1]));
802        let mut reader = runtime.sql().reader().await.unwrap();
803        let rows = reader
804            .query_all(SqlStatement {
805                sql: "SELECT COUNT(*) FROM ann_write_log WHERE embedding_model = ?1 AND op = 'upsert'".into(),
806                params: vec![SqlValue::Text(IMPORT_BATCH_MODEL.into())],
807                label: Some("import-batch-upsert-count".into()),
808            })
809            .await
810            .unwrap();
811        assert!(matches!(&rows[0].columns[0].value, SqlValue::Integer(2)));
812        let provenance = reader
813            .query_scalar(SqlStatement {
814                sql: "SELECT COUNT(*) FROM vector_provenance WHERE model_key = ?1".into(),
815                params: vec![SqlValue::Text(crate::config::sanitize_key(
816                    IMPORT_BATCH_MODEL,
817                ))],
818                label: Some("import-batch-provenance-count".into()),
819            })
820            .await
821            .unwrap();
822        assert!(matches!(provenance, Some(SqlValue::Integer(0))));
823    }
824
825    #[tokio::test]
826    async fn import_summary_reports_actual_embedding_truncation() {
827        let runtime = make_rt_with_embedder();
828        let token = NamespaceToken::local();
829        let archive = KgArchive {
830            format: "khive-kg".to_string(),
831            version: "0.1".to_string(),
832            namespace: "local".to_string(),
833            exported_at: Utc::now(),
834            entities: vec![ExportedEntity {
835                id: Uuid::new_v4(),
836                kind: "concept".to_string(),
837                entity_type: None,
838                name: "Imported long entity".to_string(),
839                description: Some("x".repeat(MAX_TEXT_BYTES + 1)),
840                properties: None,
841                tags: vec![],
842                created_at: Utc::now(),
843                updated_at: Utc::now(),
844            }],
845            edges: vec![],
846        };
847
848        let summary = runtime
849            .import_kg(&archive, &token)
850            .await
851            .expect("import with bounded embedding input");
852
853        assert_eq!(summary.entities_imported, 1);
854        assert_eq!(summary.embedding_truncation.truncated, 1);
855        assert!(summary.embedding_truncation.discarded_bytes > 0);
856        let wire = serde_json::to_value(&summary).expect("serialize import summary");
857        assert_eq!(wire["embedding_truncation"]["truncated"], 1);
858    }
859
860    #[tokio::test]
861    async fn import_rejects_whitespace_name_before_any_entity_write() {
862        let runtime = make_rt().await;
863        let token = NamespaceToken::local();
864        let valid_id = Uuid::new_v4();
865        let archive = KgArchive {
866            format: "khive-kg".to_string(),
867            version: "0.1".to_string(),
868            namespace: "local".to_string(),
869            exported_at: Utc::now(),
870            entities: vec![
871                ExportedEntity {
872                    id: valid_id,
873                    kind: "concept".to_string(),
874                    entity_type: None,
875                    name: "Must not be written".to_string(),
876                    description: None,
877                    properties: None,
878                    tags: vec![],
879                    created_at: Utc::now(),
880                    updated_at: Utc::now(),
881                },
882                ExportedEntity {
883                    id: Uuid::new_v4(),
884                    kind: "concept".to_string(),
885                    entity_type: None,
886                    name: " \t\n ".to_string(),
887                    description: None,
888                    properties: None,
889                    tags: vec![],
890                    created_at: Utc::now(),
891                    updated_at: Utc::now(),
892                },
893            ],
894            edges: vec![],
895        };
896
897        let err = runtime
898            .import_kg(&archive, &token)
899            .await
900            .expect_err("whitespace-only entity names must fail the whole import");
901        assert!(
902            err.to_string().contains("non-blank"),
903            "error must explain the name invariant: {err}"
904        );
905        assert!(
906            runtime.get_entity(&token, valid_id).await.is_err(),
907            "deterministic validation must finish before the first entity write"
908        );
909    }
910
911    /// 1. Roundtrip: 3 entities + 2 edges survive export → import on a fresh runtime.
912    #[tokio::test]
913    async fn roundtrip_entities_and_edges() {
914        let src = make_rt().await;
915        let tok = NamespaceToken::local();
916        let e1 = src
917            .create_entity(
918                &tok,
919                "concept",
920                None,
921                "FlashAttention",
922                Some("fast attention"),
923                None,
924                vec![],
925            )
926            .await
927            .unwrap();
928        let e2 = src
929            .create_entity(
930                &tok,
931                "concept",
932                None,
933                "FlashAttention-2",
934                None,
935                None,
936                vec![],
937            )
938            .await
939            .unwrap();
940        let e3 = src
941            .create_entity(
942                &tok,
943                "person",
944                None,
945                "Tri Dao",
946                None,
947                None,
948                vec!["author".into()],
949            )
950            .await
951            .unwrap();
952        src.link(&tok, e2.id, e1.id, EdgeRelation::Extends, 1.0, None)
953            .await
954            .unwrap();
955        src.link(&tok, e1.id, e3.id, EdgeRelation::IntroducedBy, 0.9, None)
956            .await
957            .unwrap();
958
959        let archive = src.export_kg(&tok).await.unwrap();
960        assert_eq!(archive.entities.len(), 3);
961        assert_eq!(archive.edges.len(), 2);
962        assert_eq!(archive.format, "khive-kg");
963        assert_eq!(archive.version, "0.1");
964
965        let dst = make_rt().await;
966        let summary = dst.import_kg(&archive, &tok).await.unwrap();
967        assert_eq!(summary.entities_imported, 3);
968        assert_eq!(summary.edges_imported, 2);
969
970        let got = dst.get_entity(&tok, e1.id).await.unwrap();
971        assert_eq!(got.name, "FlashAttention");
972        assert_eq!(got.description.as_deref(), Some("fast attention"));
973    }
974
975    /// 2. JSON roundtrip: export_kg_json → import_kg_json produces equivalent state.
976    #[tokio::test]
977    async fn json_roundtrip() {
978        let src = make_rt().await;
979        let tok = NamespaceToken::local();
980        let e1 = src
981            .create_entity(
982                &tok,
983                "concept",
984                None,
985                "LoRA",
986                Some("low-rank adaptation"),
987                Some(serde_json::json!({"year": "2021"})),
988                vec!["fine-tuning".into()],
989            )
990            .await
991            .unwrap();
992        let e2 = src
993            .create_entity(&tok, "concept", None, "QLoRA", None, None, vec![])
994            .await
995            .unwrap();
996        src.link(&tok, e2.id, e1.id, EdgeRelation::VariantOf, 0.9, None)
997            .await
998            .unwrap();
999
1000        let json_str = src.export_kg_json(&tok).await.unwrap();
1001        assert!(json_str.contains("khive-kg"));
1002
1003        let dst = make_rt().await;
1004        let summary = dst.import_kg_json(&json_str, &tok).await.unwrap();
1005        assert_eq!(summary.entities_imported, 2);
1006        assert_eq!(summary.edges_imported, 1);
1007
1008        let got = dst.get_entity(&tok, e1.id).await.unwrap();
1009        assert_eq!(got.tags, vec!["fine-tuning"]);
1010    }
1011
1012    /// 3. Namespace targeting: export from namespace "a", import into namespace "b" on a
1013    ///    fresh runtime — entities land in "b", and the source runtime's "a" is unaffected.
1014    ///
1015    ///    Note: source and destination are separate runtimes (separate in-memory DBs).
1016    ///    Same-DB cross-namespace copy is not a portability use case — portability is about
1017    ///    moving graphs between instances, not between namespaces within one instance.
1018    #[tokio::test]
1019    async fn namespace_targeting() {
1020        let src = make_rt().await;
1021        let tok_a = NamespaceToken::for_namespace(Namespace::parse("a").unwrap());
1022        let tok_b = NamespaceToken::for_namespace(Namespace::parse("b").unwrap());
1023        src.create_entity(&tok_a, "concept", None, "Sinkhorn", None, None, vec![])
1024            .await
1025            .unwrap();
1026
1027        let archive = src.export_kg(&tok_a).await.unwrap();
1028        assert_eq!(archive.namespace, "a");
1029
1030        let dst = make_rt().await;
1031        let summary = dst.import_kg(&archive, &tok_b).await.unwrap();
1032        assert_eq!(summary.entities_imported, 1);
1033
1034        let in_b = dst.list_entities(&tok_b, None, None, 100, 0).await.unwrap();
1035        assert_eq!(in_b.len(), 1);
1036        assert_eq!(in_b[0].name, "Sinkhorn");
1037
1038        let in_a = src.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
1039        assert_eq!(in_a.len(), 1);
1040
1041        let dst_a = dst.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
1042        assert_eq!(dst_a.len(), 0);
1043    }
1044
1045    /// 4. Format validation: wrong `format` field → InvalidInput.
1046    #[tokio::test]
1047    async fn format_validation_rejects_wrong_format() {
1048        let rt = make_rt().await;
1049        let tok = NamespaceToken::local();
1050        let bad = KgArchive {
1051            format: "wrong".to_string(),
1052            version: "0.1".to_string(),
1053            namespace: "local".to_string(),
1054            exported_at: Utc::now(),
1055            entities: vec![],
1056            edges: vec![],
1057        };
1058        let err = rt.import_kg(&bad, &tok).await.unwrap_err();
1059        assert!(matches!(err, RuntimeError::InvalidInput(_)));
1060    }
1061
1062    /// 5. Unsupported archive version → InvalidInput.
1063    #[tokio::test]
1064    async fn import_unsupported_archive_version_returns_error() {
1065        let rt = make_rt().await;
1066        let tok = NamespaceToken::local();
1067        let bad = KgArchive {
1068            format: "khive-kg".to_string(),
1069            version: "999.0".to_string(),
1070            namespace: "local".to_string(),
1071            exported_at: Utc::now(),
1072            entities: vec![],
1073            edges: vec![],
1074        };
1075        let err = rt.import_kg(&bad, &tok).await.unwrap_err();
1076        assert!(
1077            matches!(err, RuntimeError::InvalidInput(_)),
1078            "expected InvalidInput, got {err:?}"
1079        );
1080        if let RuntimeError::InvalidInput(msg) = err {
1081            assert!(
1082                msg.contains("999.0"),
1083                "error message should mention the unsupported version, got: {msg:?}"
1084            );
1085        }
1086    }
1087
1088    /// ADR-115 Amendment 1 §3: an archive entity carrying the reserved
1089    /// `khive:secret_gate` property key must be rejected before any upsert —
1090    /// import is a caller-controlled write path and stays reservation-only.
1091    #[tokio::test]
1092    async fn import_entity_with_reserved_secret_gate_property_is_rejected() {
1093        let rt = make_rt().await;
1094        let tok = NamespaceToken::local();
1095        let archive = KgArchive {
1096            format: "khive-kg".to_string(),
1097            version: "0.1".to_string(),
1098            namespace: "local".to_string(),
1099            exported_at: Utc::now(),
1100            entities: vec![ExportedEntity {
1101                id: Uuid::new_v4(),
1102                kind: "concept".to_string(),
1103                entity_type: None,
1104                name: "ReservedKeyImport".to_string(),
1105                description: None,
1106                properties: Some(serde_json::json!({
1107                    "khive:secret_gate": "exempted:content-sha256-manifest-v1"
1108                })),
1109                tags: vec![],
1110                created_at: Utc::now(),
1111                updated_at: Utc::now(),
1112            }],
1113            edges: vec![],
1114        };
1115
1116        let err = rt.import_kg(&archive, &tok).await.unwrap_err();
1117        assert!(
1118            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate") && msg.contains("runtime-owned")),
1119            "expected a reservation rejection, got {err:?}"
1120        );
1121
1122        let entities = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
1123        assert!(
1124            !entities.iter().any(|e| e.name == "ReservedKeyImport"),
1125            "rejected archive import must not create the entity"
1126        );
1127    }
1128
1129    /// ADR-115 Amendment 1 §3: an archive edge carrying the reserved
1130    /// `khive:secret_gate` property key is rejected in the same deterministic
1131    /// pre-write pass as entities — edge metadata is a properties-bearing
1132    /// write path.
1133    #[tokio::test]
1134    async fn import_edge_with_reserved_secret_gate_property_is_rejected() {
1135        let rt = make_rt().await;
1136        let tok = NamespaceToken::local();
1137        let a = Uuid::new_v4();
1138        let b = Uuid::new_v4();
1139        let mk = |id: Uuid, name: &str| ExportedEntity {
1140            id,
1141            kind: "concept".to_string(),
1142            entity_type: None,
1143            name: name.to_string(),
1144            description: None,
1145            properties: None,
1146            tags: vec![],
1147            created_at: Utc::now(),
1148            updated_at: Utc::now(),
1149        };
1150        let archive = KgArchive {
1151            format: "khive-kg".to_string(),
1152            version: "0.1".to_string(),
1153            namespace: "local".to_string(),
1154            exported_at: Utc::now(),
1155            entities: vec![mk(a, "EdgeGateA"), mk(b, "EdgeGateB")],
1156            edges: vec![ExportedEdge {
1157                edge_id: Uuid::new_v4(),
1158                source: a,
1159                target: b,
1160                relation: EdgeRelation::Extends,
1161                weight: 0.5,
1162                properties: Some(serde_json::json!({
1163                    "khive:secret_gate": "exempted:content-sha256-manifest-v1"
1164                })),
1165                created_at: Utc::now(),
1166                updated_at: Utc::now(),
1167            }],
1168        };
1169
1170        let err = rt.import_kg(&archive, &tok).await.unwrap_err();
1171        assert!(
1172            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate") && msg.contains("runtime-owned")),
1173            "expected a reservation rejection, got {err:?}"
1174        );
1175
1176        // The rejection happens before any write, so the archive's entities
1177        // must not have been created either.
1178        let entities = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
1179        assert!(
1180            !entities.iter().any(|e| e.name == "EdgeGateA"),
1181            "rejected archive import must not create any records"
1182        );
1183    }
1184
1185    /// ADR-115 Amendment 1 §3: edge metadata is in the unchanged
1186    /// blocking-scanner class, so a credential-shaped value in edge
1187    /// properties fails the import in the same pre-write pass.
1188    #[tokio::test]
1189    async fn import_edge_with_credential_shaped_property_is_rejected() {
1190        let rt = make_rt().await;
1191        let tok = NamespaceToken::local();
1192        let a = Uuid::new_v4();
1193        let b = Uuid::new_v4();
1194        let mk = |id: Uuid, name: &str| ExportedEntity {
1195            id,
1196            kind: "concept".to_string(),
1197            entity_type: None,
1198            name: name.to_string(),
1199            description: None,
1200            properties: None,
1201            tags: vec![],
1202            created_at: Utc::now(),
1203            updated_at: Utc::now(),
1204        };
1205        let archive = KgArchive {
1206            format: "khive-kg".to_string(),
1207            version: "0.1".to_string(),
1208            namespace: "local".to_string(),
1209            exported_at: Utc::now(),
1210            entities: vec![mk(a, "EdgeScanA"), mk(b, "EdgeScanB")],
1211            edges: vec![ExportedEdge {
1212                edge_id: Uuid::new_v4(),
1213                source: a,
1214                target: b,
1215                relation: EdgeRelation::Extends,
1216                weight: 0.5,
1217                properties: Some(serde_json::json!({
1218                    "api_key": "AKIAFAKEKEY1234567890"
1219                })),
1220                created_at: Utc::now(),
1221                updated_at: Utc::now(),
1222            }],
1223        };
1224
1225        let err = rt.import_kg(&archive, &tok).await.unwrap_err();
1226        assert!(
1227            matches!(err, RuntimeError::SecretDetected(_)),
1228            "expected the blocking scanner to reject the value, got {err:?}"
1229        );
1230
1231        let entities = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
1232        assert!(
1233            !entities.iter().any(|e| e.name == "EdgeScanA"),
1234            "rejected archive import must not create any records"
1235        );
1236    }
1237
1238    /// 6. Invalid relation in archive → InvalidInput.
1239    #[test]
1240    fn invalid_relation_rejected_at_deserialize() {
1241        let json = r#"{
1242            "format":"khive-kg","version":"0.1","namespace":"local",
1243            "exported_at":"2026-01-01T00:00:00Z",
1244            "entities":[],
1245            "edges":[{"edge_id":"00000000-0000-0000-0000-000000000099",
1246                       "source":"00000000-0000-0000-0000-000000000001",
1247                       "target":"00000000-0000-0000-0000-000000000002",
1248                       "relation":"related_to","weight":0.5}]
1249        }"#;
1250        let result: Result<KgArchive, _> = serde_json::from_str(json);
1251        assert!(
1252            result.is_err(),
1253            "non-canonical relation should fail to deserialize"
1254        );
1255    }
1256
1257    // ── Dangling-edge validation tests ────────────────────────────────────────
1258
1259    /// 6. Edge with dangling source (source UUID not in entity table) is skipped.
1260    ///
1261    /// The archive has one entity + one edge whose source is a phantom UUID.
1262    /// Import succeeds, entities_imported=1, edges_imported=0, edges_skipped=1.
1263    #[tokio::test]
1264    async fn import_edge_with_dangling_source_is_skipped() {
1265        let phantom_source = Uuid::parse_str("deadbeef-dead-4ead-dead-deadbeefcafe").unwrap();
1266
1267        let rt = make_rt().await;
1268        let tok = NamespaceToken::local();
1269        let real = rt
1270            .create_entity(&tok, "concept", None, "Real", None, None, vec![])
1271            .await
1272            .unwrap();
1273
1274        let archive = KgArchive {
1275            format: "khive-kg".to_string(),
1276            version: "0.1".to_string(),
1277            namespace: "local".to_string(),
1278            exported_at: Utc::now(),
1279            entities: vec![ExportedEntity {
1280                id: real.id,
1281                kind: "concept".to_string(),
1282                entity_type: None,
1283                name: "Real".to_string(),
1284                description: None,
1285                properties: None,
1286                tags: vec![],
1287                created_at: Utc::now(),
1288                updated_at: Utc::now(),
1289            }],
1290            edges: vec![ExportedEdge {
1291                edge_id: Uuid::new_v4(),
1292                source: phantom_source,
1293                target: real.id,
1294                relation: EdgeRelation::Extends,
1295                weight: 1.0,
1296                properties: None,
1297                created_at: Utc::now(),
1298                updated_at: Utc::now(),
1299            }],
1300        };
1301
1302        let dst = make_rt().await;
1303        let summary = dst.import_kg(&archive, &tok).await.unwrap();
1304        assert_eq!(summary.entities_imported, 1);
1305        assert_eq!(
1306            summary.edges_imported, 0,
1307            "dangling source must not be imported"
1308        );
1309        assert_eq!(
1310            summary.edges_skipped, 1,
1311            "dangling source must be counted as skipped"
1312        );
1313    }
1314
1315    /// 7. Edge with dangling target (target UUID not in entity table) is skipped.
1316    ///
1317    /// The archive has one entity + one edge whose target is a phantom UUID.
1318    /// Import succeeds, entities_imported=1, edges_imported=0, edges_skipped=1.
1319    #[tokio::test]
1320    async fn import_edge_with_dangling_target_is_skipped() {
1321        let phantom_target = Uuid::parse_str("cafebabe-cafe-4abe-cafe-cafebabecafe").unwrap();
1322
1323        let rt = make_rt().await;
1324        let tok = NamespaceToken::local();
1325        let real = rt
1326            .create_entity(&tok, "concept", None, "Source", None, None, vec![])
1327            .await
1328            .unwrap();
1329
1330        let archive = KgArchive {
1331            format: "khive-kg".to_string(),
1332            version: "0.1".to_string(),
1333            namespace: "local".to_string(),
1334            exported_at: Utc::now(),
1335            entities: vec![ExportedEntity {
1336                id: real.id,
1337                kind: "concept".to_string(),
1338                entity_type: None,
1339                name: "Source".to_string(),
1340                description: None,
1341                properties: None,
1342                tags: vec![],
1343                created_at: Utc::now(),
1344                updated_at: Utc::now(),
1345            }],
1346            edges: vec![ExportedEdge {
1347                edge_id: Uuid::new_v4(),
1348                source: real.id,
1349                target: phantom_target,
1350                relation: EdgeRelation::DependsOn,
1351                weight: 0.8,
1352                properties: None,
1353                created_at: Utc::now(),
1354                updated_at: Utc::now(),
1355            }],
1356        };
1357
1358        let dst = make_rt().await;
1359        let summary = dst.import_kg(&archive, &tok).await.unwrap();
1360        assert_eq!(summary.entities_imported, 1);
1361        assert_eq!(
1362            summary.edges_imported, 0,
1363            "dangling target must not be imported"
1364        );
1365        assert_eq!(
1366            summary.edges_skipped, 1,
1367            "dangling target must be counted as skipped"
1368        );
1369    }
1370
1371    /// 8. Mixed batch: some valid edges and some dangling edges — correct counts reported.
1372    ///
1373    /// Archive has 3 entities, 2 valid edges, and 1 dangling edge (phantom target).
1374    /// Import succeeds with edges_imported=2, edges_skipped=1.
1375    #[tokio::test]
1376    async fn import_mixed_edges_reports_correct_counts() {
1377        let phantom = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
1378
1379        let src = make_rt().await;
1380        let tok = NamespaceToken::local();
1381        let a = src
1382            .create_entity(&tok, "concept", None, "A", None, None, vec![])
1383            .await
1384            .unwrap();
1385        let b = src
1386            .create_entity(&tok, "concept", None, "B", None, None, vec![])
1387            .await
1388            .unwrap();
1389        let c = src
1390            .create_entity(&tok, "concept", None, "C", None, None, vec![])
1391            .await
1392            .unwrap();
1393
1394        let archive = KgArchive {
1395            format: "khive-kg".to_string(),
1396            version: "0.1".to_string(),
1397            namespace: "local".to_string(),
1398            exported_at: Utc::now(),
1399            entities: vec![
1400                ExportedEntity {
1401                    id: a.id,
1402                    kind: "concept".to_string(),
1403                    entity_type: None,
1404                    name: "A".to_string(),
1405                    description: None,
1406                    properties: None,
1407                    tags: vec![],
1408                    created_at: Utc::now(),
1409                    updated_at: Utc::now(),
1410                },
1411                ExportedEntity {
1412                    id: b.id,
1413                    kind: "concept".to_string(),
1414                    entity_type: None,
1415                    name: "B".to_string(),
1416                    description: None,
1417                    properties: None,
1418                    tags: vec![],
1419                    created_at: Utc::now(),
1420                    updated_at: Utc::now(),
1421                },
1422                ExportedEntity {
1423                    id: c.id,
1424                    kind: "concept".to_string(),
1425                    entity_type: None,
1426                    name: "C".to_string(),
1427                    description: None,
1428                    properties: None,
1429                    tags: vec![],
1430                    created_at: Utc::now(),
1431                    updated_at: Utc::now(),
1432                },
1433            ],
1434            edges: vec![
1435                ExportedEdge {
1436                    edge_id: Uuid::new_v4(),
1437                    source: a.id,
1438                    target: b.id,
1439                    relation: EdgeRelation::Extends,
1440                    weight: 1.0,
1441                    properties: None,
1442                    created_at: Utc::now(),
1443                    updated_at: Utc::now(),
1444                },
1445                ExportedEdge {
1446                    edge_id: Uuid::new_v4(),
1447                    source: b.id,
1448                    target: c.id,
1449                    // #1235: concept->concept is not in depends_on's endpoint
1450                    // contract; use variant_of (concept->concept is valid) so
1451                    // this fixture exercises "one dangling edge skipped" only,
1452                    // not an endpoint-contract violation this test isn't about.
1453                    relation: EdgeRelation::VariantOf,
1454                    weight: 0.9,
1455                    properties: None,
1456                    created_at: Utc::now(),
1457                    updated_at: Utc::now(),
1458                },
1459                ExportedEdge {
1460                    edge_id: Uuid::new_v4(),
1461                    source: a.id,
1462                    target: phantom,
1463                    relation: EdgeRelation::Enables,
1464                    weight: 0.5,
1465                    properties: None,
1466                    created_at: Utc::now(),
1467                    updated_at: Utc::now(),
1468                },
1469            ],
1470        };
1471
1472        let dst = make_rt().await;
1473        let summary = dst.import_kg(&archive, &tok).await.unwrap();
1474        assert_eq!(summary.entities_imported, 3);
1475        assert_eq!(
1476            summary.edges_imported, 2,
1477            "only valid edges must be imported"
1478        );
1479        assert_eq!(
1480            summary.edges_skipped, 1,
1481            "one dangling edge must be reported"
1482        );
1483    }
1484
1485    /// 9. All-valid edges produce edges_skipped=0 (no regression on the happy path).
1486    #[tokio::test]
1487    async fn import_all_valid_edges_reports_zero_skipped() {
1488        let src = make_rt().await;
1489        let tok = NamespaceToken::local();
1490        let e1 = src
1491            .create_entity(&tok, "concept", None, "E1", None, None, vec![])
1492            .await
1493            .unwrap();
1494        let e2 = src
1495            .create_entity(&tok, "concept", None, "E2", None, None, vec![])
1496            .await
1497            .unwrap();
1498        src.link(&tok, e1.id, e2.id, EdgeRelation::VariantOf, 0.7, None)
1499            .await
1500            .unwrap();
1501
1502        let archive = src.export_kg(&tok).await.unwrap();
1503        let dst = make_rt().await;
1504        let summary = dst.import_kg(&archive, &tok).await.unwrap();
1505        assert_eq!(summary.edges_imported, 1);
1506        assert_eq!(
1507            summary.edges_skipped, 0,
1508            "no edges should be skipped when all endpoints exist"
1509        );
1510    }
1511
1512    /// #1235: an edge whose (source kind, relation, target kind) triple violates the
1513    /// endpoint contract (here: `precedes` between two `concept` entities — `precedes`
1514    /// is restricted to document/dataset/artifact/service/project pairs) must be
1515    /// skipped by import the same way a dangling endpoint is, not written straight
1516    /// through with `graph.upsert_edge`.
1517    #[tokio::test]
1518    async fn import_edge_violating_endpoint_contract_is_skipped() {
1519        let src = make_rt().await;
1520        let tok = NamespaceToken::local();
1521        let e1 = src
1522            .create_entity(&tok, "concept", None, "E1", None, None, vec![])
1523            .await
1524            .unwrap();
1525        let e2 = src
1526            .create_entity(&tok, "concept", None, "E2", None, None, vec![])
1527            .await
1528            .unwrap();
1529
1530        let archive = KgArchive {
1531            format: "khive-kg".to_string(),
1532            version: "0.1".to_string(),
1533            namespace: "local".to_string(),
1534            exported_at: Utc::now(),
1535            entities: vec![
1536                ExportedEntity {
1537                    id: e1.id,
1538                    kind: "concept".to_string(),
1539                    entity_type: None,
1540                    name: "E1".to_string(),
1541                    description: None,
1542                    properties: None,
1543                    tags: vec![],
1544                    created_at: Utc::now(),
1545                    updated_at: Utc::now(),
1546                },
1547                ExportedEntity {
1548                    id: e2.id,
1549                    kind: "concept".to_string(),
1550                    entity_type: None,
1551                    name: "E2".to_string(),
1552                    description: None,
1553                    properties: None,
1554                    tags: vec![],
1555                    created_at: Utc::now(),
1556                    updated_at: Utc::now(),
1557                },
1558            ],
1559            edges: vec![ExportedEdge {
1560                edge_id: Uuid::new_v4(),
1561                source: e1.id,
1562                target: e2.id,
1563                relation: EdgeRelation::Precedes,
1564                weight: 1.0,
1565                properties: None,
1566                created_at: Utc::now(),
1567                updated_at: Utc::now(),
1568            }],
1569        };
1570
1571        let dst = make_rt().await;
1572        let summary = dst.import_kg(&archive, &tok).await.unwrap();
1573        assert_eq!(summary.entities_imported, 2);
1574        assert_eq!(
1575            summary.edges_imported, 0,
1576            "an endpoint-contract-violating edge must not be imported"
1577        );
1578        assert_eq!(
1579            summary.edges_skipped, 1,
1580            "an endpoint-contract-violating edge must be counted as skipped"
1581        );
1582        assert!(
1583            dst.neighbors(
1584                &tok,
1585                e1.id,
1586                khive_storage::types::Direction::Out,
1587                None,
1588                None
1589            )
1590            .await
1591            .unwrap()
1592            .is_empty(),
1593            "the contract-violating edge must not exist in the destination graph"
1594        );
1595    }
1596
1597    // ── edge_id contract tests ────────────────────────────────────────────────
1598
1599    /// 10. export_kg sets edge_id in the archive to the LinkId returned by link.
1600    #[tokio::test]
1601    async fn export_kg_preserves_edge_id() {
1602        let rt = make_rt().await;
1603        let tok = NamespaceToken::local();
1604        let a = rt
1605            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
1606            .await
1607            .unwrap();
1608        let b = rt
1609            .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
1610            .await
1611            .unwrap();
1612        let stored_edge = rt
1613            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1614            .await
1615            .unwrap();
1616        let stored_id: Uuid = stored_edge.id.into();
1617
1618        let archive = rt.export_kg(&tok).await.unwrap();
1619        assert_eq!(archive.edges.len(), 1);
1620        assert_eq!(
1621            archive.edges[0].edge_id, stored_id,
1622            "exported edge_id must equal the LinkId returned by link"
1623        );
1624    }
1625
1626    /// 11. import_kg writes the archive edge identity and timestamps exactly.
1627    #[tokio::test]
1628    async fn import_kg_persists_edge_id_and_timestamps() {
1629        let src = make_rt().await;
1630        let tok = NamespaceToken::local();
1631        let a = src
1632            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
1633            .await
1634            .unwrap();
1635        let b = src
1636            .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
1637            .await
1638            .unwrap();
1639        let stored_edge = src
1640            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1641            .await
1642            .unwrap();
1643        let original_id: Uuid = stored_edge.id.into();
1644
1645        let expected_created = chrono::DateTime::parse_from_rfc3339("2026-03-03T00:00:00Z")
1646            .unwrap()
1647            .with_timezone(&Utc);
1648        let expected_updated = chrono::DateTime::parse_from_rfc3339("2026-04-04T00:00:00Z")
1649            .unwrap()
1650            .with_timezone(&Utc);
1651        let mut archive = src.export_kg(&tok).await.unwrap();
1652        archive.edges[0].created_at = expected_created;
1653        archive.edges[0].updated_at = expected_updated;
1654        archive.edges[0].properties = Some(serde_json::json!({"confidence": 0.95}));
1655        let dst = make_rt().await;
1656        dst.import_kg(&archive, &tok).await.unwrap();
1657
1658        let imported_edge = dst.get_edge(&tok, original_id).await.unwrap();
1659        assert!(
1660            imported_edge.is_some(),
1661            "imported edge must be retrievable by the original edge_id"
1662        );
1663        let imported_edge = imported_edge.unwrap();
1664        assert_eq!(
1665            Uuid::from(imported_edge.id),
1666            original_id,
1667            "stored edge id must equal the archive edge_id"
1668        );
1669        assert_eq!(
1670            imported_edge.created_at, expected_created,
1671            "present edge created_at must not be replaced with import time"
1672        );
1673        assert_eq!(
1674            imported_edge.updated_at, expected_updated,
1675            "present edge updated_at must not be replaced with created_at or import time"
1676        );
1677        assert_eq!(
1678            imported_edge.metadata,
1679            Some(serde_json::json!({"confidence": 0.95})),
1680            "edge properties must persist as storage metadata"
1681        );
1682
1683        let reexported = dst.export_kg(&tok).await.unwrap();
1684        assert_eq!(
1685            reexported.edges[0].properties,
1686            Some(serde_json::json!({"confidence": 0.95})),
1687            "edge metadata must re-export as portable properties"
1688        );
1689    }
1690
1691    /// 12. Old archive (no edge_id field) deserializes, imports, and re-exports with the
1692    ///     same generated UUID — proving the generated ID survives the full round trip.
1693    ///
1694    ///     The fixture includes two entities so the edge is not skipped during import.
1695    #[tokio::test]
1696    async fn old_archive_missing_edge_id_round_trips() {
1697        let src_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
1698        let tgt_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
1699
1700        // Simulate a pre-0.2 archive JSON where the edge lacks an edge_id field.
1701        let json = format!(
1702            r#"{{
1703                "format": "khive-kg",
1704                "version": "0.1",
1705                "namespace": "local",
1706                "exported_at": "2026-01-01T00:00:00Z",
1707                "entities": [
1708                    {{"id":"{src_id}","kind":"concept","name":"SrcNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}},
1709                    {{"id":"{tgt_id}","kind":"concept","name":"TgtNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}
1710                ],
1711                "edges": [
1712                    {{
1713                        "source": "{src_id}",
1714                        "target": "{tgt_id}",
1715                        "relation": "extends",
1716                        "weight": 0.9
1717                    }}
1718                ]
1719            }}"#
1720        );
1721
1722        // serde(default) must assign a fresh non-nil UUID when edge_id is absent.
1723        let archive: KgArchive = serde_json::from_str(&json)
1724            .expect("old archive without edge_id must deserialize successfully");
1725        assert_eq!(archive.edges.len(), 1);
1726        let generated_id = archive.edges[0].edge_id;
1727        assert_ne!(
1728            generated_id,
1729            Uuid::nil(),
1730            "missing edge_id in old archive must get a fresh non-nil UUID"
1731        );
1732
1733        let rt = make_rt().await;
1734        let tok = NamespaceToken::local();
1735        let summary = rt.import_kg(&archive, &tok).await.unwrap();
1736        assert_eq!(summary.entities_imported, 2);
1737        assert_eq!(
1738            summary.edges_imported, 1,
1739            "edge must be imported when both endpoints exist"
1740        );
1741
1742        let stored = rt.get_edge(&tok, generated_id).await.unwrap();
1743        assert!(
1744            stored.is_some(),
1745            "imported edge must be retrievable by the generated edge_id"
1746        );
1747        assert_eq!(
1748            Uuid::from(stored.unwrap().id),
1749            generated_id,
1750            "stored edge id must equal the generated edge_id"
1751        );
1752
1753        let re_archive = rt.export_kg(&tok).await.unwrap();
1754        assert_eq!(re_archive.edges.len(), 1);
1755        assert_eq!(
1756            re_archive.edges[0].edge_id, generated_id,
1757            "re-exported edge_id must equal the ID generated on first import"
1758        );
1759    }
1760
1761    /// 13. Explicit export → import → export equality: the edge_id is unchanged across
1762    ///     a full round trip when the source archive already contains an edge_id.
1763    ///
1764    ///     Verifies by (source, target, relation) key that re-export emits the original ID.
1765    #[tokio::test]
1766    async fn export_import_export_edge_id_equality() {
1767        let src = make_rt().await;
1768        let tok = NamespaceToken::local();
1769        let a = src
1770            .create_entity(&tok, "concept", None, "NodeA", None, None, vec![])
1771            .await
1772            .unwrap();
1773        let b = src
1774            .create_entity(&tok, "concept", None, "NodeB", None, None, vec![])
1775            .await
1776            .unwrap();
1777        let stored = src
1778            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1779            .await
1780            .unwrap();
1781        let original_edge_id: Uuid = stored.id.into();
1782
1783        let archive1 = src.export_kg(&tok).await.unwrap();
1784        assert_eq!(archive1.edges.len(), 1);
1785        assert_eq!(
1786            archive1.edges[0].edge_id, original_edge_id,
1787            "first export must carry the stored edge_id"
1788        );
1789
1790        let dst = make_rt().await;
1791        dst.import_kg(&archive1, &tok).await.unwrap();
1792
1793        let archive2 = dst.export_kg(&tok).await.unwrap();
1794        assert_eq!(archive2.edges.len(), 1);
1795
1796        let re_edge = archive2
1797            .edges
1798            .iter()
1799            .find(|e| e.source == a.id && e.target == b.id && e.relation == EdgeRelation::Extends)
1800            .expect(
1801                "re-exported archive must contain the original edge by (source,target,relation)",
1802            );
1803        assert_eq!(
1804            re_edge.edge_id, original_edge_id,
1805            "edge_id must be identical across export → import → export"
1806        );
1807    }
1808
1809    // Skipped-edge details retain archive identity, order and the original refusal.
1810    fn skip_report_entity(id: Uuid, name: &str) -> ExportedEntity {
1811        let timestamp = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1812            .unwrap()
1813            .with_timezone(&Utc);
1814        ExportedEntity {
1815            id,
1816            kind: "document".into(),
1817            entity_type: None,
1818            name: name.into(),
1819            description: None,
1820            properties: None,
1821            tags: vec![],
1822            created_at: timestamp,
1823            updated_at: timestamp,
1824        }
1825    }
1826
1827    fn skip_report_edge(
1828        id: u128,
1829        source: Uuid,
1830        target: Uuid,
1831        relation: EdgeRelation,
1832    ) -> ExportedEdge {
1833        let timestamp = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1834            .unwrap()
1835            .with_timezone(&Utc);
1836        ExportedEdge {
1837            edge_id: Uuid::from_u128(id),
1838            source,
1839            target,
1840            relation,
1841            weight: 0.7,
1842            properties: None,
1843            created_at: timestamp,
1844            updated_at: timestamp,
1845        }
1846    }
1847
1848    fn skip_report_archive(entities: Vec<ExportedEntity>, edges: Vec<ExportedEdge>) -> KgArchive {
1849        KgArchive {
1850            format: "khive-kg".into(),
1851            version: "0.1".into(),
1852            namespace: "local".into(),
1853            exported_at: Utc::now(),
1854            entities,
1855            edges,
1856        }
1857    }
1858
1859    #[tokio::test]
1860    async fn skipped_edge_details_cover_three_causes_and_retain_the_valid_edge() {
1861        let runtime = make_rt().await;
1862        let token = NamespaceToken::local();
1863        let source = Uuid::from_u128(1);
1864        let target = Uuid::from_u128(2);
1865        let absent = Uuid::from_u128(3);
1866        let mut valid = skip_report_edge(101, source, target, EdgeRelation::LinksTo);
1867        valid.properties = Some(serde_json::json!({"retained": true}));
1868        let archive = skip_report_archive(
1869            vec![
1870                skip_report_entity(source, "Source"),
1871                skip_report_entity(target, "Target"),
1872            ],
1873            vec![
1874                skip_report_edge(100, absent, source, EdgeRelation::LinksTo),
1875                valid.clone(),
1876                skip_report_edge(102, source, absent, EdgeRelation::DependsOn),
1877                skip_report_edge(103, source, target, EdgeRelation::Annotates),
1878            ],
1879        );
1880        let summary = runtime.import_kg(&archive, &token).await.unwrap();
1881        let expected = vec![
1882            ImportSkippedEdge {
1883                edge_id: Uuid::from_u128(100),
1884                source: absent,
1885                target: source,
1886                relation: EdgeRelation::LinksTo,
1887                cause: ImportEdgeSkipCause::MissingSource,
1888                error: None,
1889            },
1890            ImportSkippedEdge {
1891                edge_id: Uuid::from_u128(102),
1892                source,
1893                target: absent,
1894                relation: EdgeRelation::DependsOn,
1895                cause: ImportEdgeSkipCause::MissingTarget,
1896                error: None,
1897            },
1898            ImportSkippedEdge {
1899                edge_id: Uuid::from_u128(103),
1900                source,
1901                target,
1902                relation: EdgeRelation::Annotates,
1903                cause: ImportEdgeSkipCause::EndpointContract,
1904                error: Some(format!(
1905                    "invalid input: annotates source {source} must be a note"
1906                )),
1907            },
1908        ];
1909        assert_eq!(summary.entities_imported, 2);
1910        assert_eq!(summary.edges_imported, 1);
1911        assert_eq!(summary.edges_skipped, 3);
1912        assert_eq!(summary.skipped_edges, expected);
1913        let wire = serde_json::to_value(&summary).unwrap();
1914        assert_eq!(
1915            wire["skipped_edges"],
1916            serde_json::json!([
1917                {"edge_id":Uuid::from_u128(100),"source":absent,"target":source,
1918                 "relation":"links_to","cause":"missing_source"},
1919                {"edge_id":Uuid::from_u128(102),"source":source,"target":absent,
1920                 "relation":"depends_on","cause":"missing_target"},
1921                {"edge_id":Uuid::from_u128(103),"source":source,"target":target,
1922                 "relation":"annotates","cause":"endpoint_contract",
1923                 "error":format!("invalid input: annotates source {source} must be a note")}
1924            ])
1925        );
1926        let decoded: ImportSummary = serde_json::from_value(wire).unwrap();
1927        assert_eq!(decoded.skipped_edges, expected);
1928        assert_eq!(decoded.edges_skipped, 3);
1929        for id in [100, 102, 103] {
1930            assert!(runtime
1931                .get_edge(&token, Uuid::from_u128(id))
1932                .await
1933                .unwrap()
1934                .is_none());
1935        }
1936        let stored = runtime
1937            .get_edge(&token, valid.edge_id)
1938            .await
1939            .unwrap()
1940            .unwrap();
1941        assert_eq!(Uuid::from(stored.id), valid.edge_id);
1942        assert_eq!(stored.source_id, source);
1943        assert_eq!(stored.target_id, target);
1944        assert_eq!(stored.relation, EdgeRelation::LinksTo);
1945        assert_eq!(stored.metadata, valid.properties);
1946        assert_eq!(stored.created_at, valid.created_at);
1947        assert_eq!(stored.updated_at, valid.updated_at);
1948        let exported = runtime.export_kg(&token).await.unwrap();
1949        assert_eq!(exported.edges.len(), 1);
1950        assert_eq!(exported.edges[0].edge_id, valid.edge_id);
1951    }
1952
1953    #[tokio::test]
1954    async fn skipped_edge_details_preserve_duplicates_order_and_both_missing_precedence() {
1955        let runtime = make_rt().await;
1956        let token = NamespaceToken::local();
1957        let present = Uuid::from_u128(1);
1958        let absent = Uuid::from_u128(3);
1959        let other_absent = Uuid::from_u128(4);
1960        let repeated = skip_report_edge(111, absent, present, EdgeRelation::LinksTo);
1961        let archive = skip_report_archive(
1962            vec![skip_report_entity(present, "Present")],
1963            vec![
1964                skip_report_edge(110, present, absent, EdgeRelation::DependsOn),
1965                repeated.clone(),
1966                skip_report_edge(112, absent, present, EdgeRelation::LinksTo),
1967                repeated,
1968                skip_report_edge(113, absent, other_absent, EdgeRelation::Extends),
1969            ],
1970        );
1971        let summary = runtime.import_kg(&archive, &token).await.unwrap();
1972        assert_eq!(summary.edges_imported, 0);
1973        assert_eq!(summary.edges_skipped, 5);
1974        assert_eq!(
1975            summary.skipped_edges,
1976            vec![
1977                ImportSkippedEdge {
1978                    edge_id: Uuid::from_u128(110),
1979                    source: present,
1980                    target: absent,
1981                    relation: EdgeRelation::DependsOn,
1982                    cause: ImportEdgeSkipCause::MissingTarget,
1983                    error: None,
1984                },
1985                ImportSkippedEdge {
1986                    edge_id: Uuid::from_u128(111),
1987                    source: absent,
1988                    target: present,
1989                    relation: EdgeRelation::LinksTo,
1990                    cause: ImportEdgeSkipCause::MissingSource,
1991                    error: None,
1992                },
1993                ImportSkippedEdge {
1994                    edge_id: Uuid::from_u128(112),
1995                    source: absent,
1996                    target: present,
1997                    relation: EdgeRelation::LinksTo,
1998                    cause: ImportEdgeSkipCause::MissingSource,
1999                    error: None,
2000                },
2001                ImportSkippedEdge {
2002                    edge_id: Uuid::from_u128(111),
2003                    source: absent,
2004                    target: present,
2005                    relation: EdgeRelation::LinksTo,
2006                    cause: ImportEdgeSkipCause::MissingSource,
2007                    error: None,
2008                },
2009                ImportSkippedEdge {
2010                    edge_id: Uuid::from_u128(113),
2011                    source: absent,
2012                    target: other_absent,
2013                    relation: EdgeRelation::Extends,
2014                    cause: ImportEdgeSkipCause::MissingSource,
2015                    error: None,
2016                },
2017            ]
2018        );
2019        assert!(runtime.export_kg(&token).await.unwrap().edges.is_empty());
2020    }
2021
2022    #[tokio::test]
2023    async fn successful_imports_without_skips_keep_the_previous_json_shape() {
2024        let token = NamespaceToken::local();
2025        let a = Uuid::from_u128(1);
2026        let b = Uuid::from_u128(2);
2027        for (archive, entities, edges) in [
2028            (skip_report_archive(vec![], vec![]), 0, 0),
2029            (
2030                skip_report_archive(
2031                    vec![skip_report_entity(a, "A"), skip_report_entity(b, "B")],
2032                    vec![skip_report_edge(101, a, b, EdgeRelation::LinksTo)],
2033                ),
2034                2,
2035                1,
2036            ),
2037        ] {
2038            let runtime = make_rt().await;
2039            let summary = runtime.import_kg(&archive, &token).await.unwrap();
2040            assert!(summary.skipped_edges.is_empty());
2041            assert_eq!(summary.edges_skipped, 0);
2042            assert_eq!(
2043                serde_json::to_value(&summary).unwrap(),
2044                serde_json::json!({
2045                    "entities_imported":entities,"edges_imported":edges,"edges_skipped":0,
2046                    "embedding_truncation":{"truncated":0,"discarded_bytes":0}
2047                })
2048            );
2049        }
2050    }
2051
2052    #[test]
2053    fn historical_summary_counts_do_not_invent_skipped_edge_details() {
2054        for count in [0, 2] {
2055            let old = serde_json::json!({
2056                "entities_imported":1,"edges_imported":0,"edges_skipped":count
2057            });
2058            let summary: ImportSummary = serde_json::from_value(old).unwrap();
2059            assert_eq!(summary.edges_skipped, count);
2060            assert!(summary.skipped_edges.is_empty());
2061            assert_eq!(
2062                serde_json::to_value(summary).unwrap(),
2063                serde_json::json!({
2064                    "entities_imported":1,"edges_imported":0,"edges_skipped":count,
2065                    "embedding_truncation":{"truncated":0,"discarded_bytes":0}
2066                })
2067            );
2068        }
2069    }
2070
2071    #[test]
2072    fn skipped_edge_cause_spellings_are_closed_and_error_is_preserved_verbatim() {
2073        let wire = serde_json::json!({
2074            "edge_id":"00000000-0000-0000-0000-000000000103",
2075            "source":"00000000-0000-0000-0000-000000000001",
2076            "target":"00000000-0000-0000-0000-000000000002",
2077            "relation":"annotates","cause":"endpoint_contract",
2078            "error":"not found: link target changed during endpoint validation"
2079        });
2080        let detail: ImportSkippedEdge = serde_json::from_value(wire.clone()).unwrap();
2081        assert_eq!(detail.cause, ImportEdgeSkipCause::EndpointContract);
2082        assert_eq!(
2083            detail.error.as_deref(),
2084            Some("not found: link target changed during endpoint validation")
2085        );
2086        assert_eq!(serde_json::to_value(detail).unwrap(), wire);
2087        let mut invalid = wire;
2088        invalid["cause"] = serde_json::json!("storage_failure");
2089        assert!(serde_json::from_value::<ImportSkippedEdge>(invalid).is_err());
2090    }
2091}