Skip to main content

khive_runtime/
curation.rs

1// Licensed under the Apache License, Version 2.0.
2
3// FILE SIZE JUSTIFICATION: curation.rs holds entity/note/edge patch types alongside
4// their update and merge implementations. The implementations share private helpers
5// (merge_properties, namespace checks, dedup policy) that need pub(crate) access to
6// runtime internals. Inline tests cover merge semantics that require direct access to
7// those helpers. Split plan: extract patch types into `curation/patch.rs` and merge
8// logic into `curation/merge.rs` once the dedup policy API stabilises.
9//! Curation operations: entity update/merge and edge-list filter type.
10
11use std::collections::HashSet;
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use uuid::Uuid;
16
17use khive_db::SqliteError;
18use khive_storage::note::Note;
19use khive_storage::types::{EdgeFilter, TextDocument};
20use khive_storage::{EdgeRelation, Entity, SubstrateKind};
21use khive_types::EventKind;
22use rusqlite::OptionalExtension;
23
24use crate::error::{RuntimeError, RuntimeResult};
25use crate::operations::canonical_edge_endpoints;
26use crate::runtime::{KhiveRuntime, NamespaceToken};
27
28// ---------------------------------------------------------------------------
29// Public types
30// ---------------------------------------------------------------------------
31
32/// Patch for `update_entity`. Only `Some(_)` fields are applied; `None` means "leave unchanged".
33///
34/// For `description`:
35/// - `None` (outer) — leave the current description as-is
36/// - `Some(None)` — clear the description (set to NULL)
37/// - `Some(Some(s))` — set the description to `s`
38///
39/// For `properties` (deep-merge semantics):
40/// - `None` — leave properties as-is
41/// - `Some(value)` — deep-merge `value` into existing properties. Keys present in
42///   the patch overwrite existing keys; keys absent from the patch are preserved.
43///   Removing a key requires explicit replacement of the parent object (or a future
44///   `unset`/`null-marker` extension).
45///
46/// For `tags` — replace semantics: `Some(vec)` sets tags to exactly `vec`. To add
47/// a tag without losing existing tags, read the entity first, push the new tag,
48/// and pass the full list back.
49#[derive(Clone, Debug, Default)]
50pub struct EntityPatch {
51    pub name: Option<String>,
52    pub description: Option<Option<String>>,
53    pub properties: Option<Value>,
54    pub tags: Option<Vec<String>>,
55}
56
57/// Policy used when deduplicating two entities.
58#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum EntityDedupMergePolicy {
61    /// `into` values win on conflict. Tags are unioned. Properties from `from` fill in
62    /// keys that `into` doesn't have. This is the default.
63    #[default]
64    PreferInto,
65    /// `from` values win on conflict.
66    PreferFrom,
67    /// Deep-merge: object properties merge recursively. Scalar conflicts go to `into`.
68    Union,
69}
70
71/// Strategy for merging note content when two notes are combined.
72#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "snake_case")]
74pub enum ContentMergeStrategy {
75    #[default]
76    Append,
77    PreferInto,
78    PreferFrom,
79}
80
81/// Result returned by `merge_entity` / `merge_note`.
82#[derive(Clone, Debug, Serialize, Deserialize)]
83pub struct MergeSummary {
84    pub kept_id: Uuid,
85    pub removed_id: Uuid,
86    pub edges_rewired: usize,
87    pub properties_merged: usize,
88    pub tags_unioned: usize,
89    pub content_appended: bool,
90    pub dry_run: bool,
91}
92
93/// Patch for `update_edge`. Only `Some(_)` fields are applied; `None` means "leave unchanged".
94///
95/// For `properties` — replacement semantics (not deep merge): `Some(value)` replaces
96/// the entire metadata object. `None` leaves metadata unchanged.
97#[derive(Clone, Debug, Default)]
98pub struct EdgePatch {
99    pub relation: Option<EdgeRelation>,
100    pub weight: Option<f64>,
101    pub properties: Option<Value>,
102}
103
104/// Patch for `update_note`. Only `Some(_)` fields are applied; `None` means "leave unchanged".
105///
106/// For `salience`/`decay_factor`:
107/// - `None` (outer) — leave unchanged
108/// - `Some(None)` — clear the value
109/// - `Some(Some(v))` — set to v
110#[derive(Clone, Debug, Default)]
111pub struct NotePatch {
112    pub name: Option<Option<String>>,
113    pub content: Option<String>,
114    pub salience: Option<Option<f64>>,
115    pub decay_factor: Option<Option<f64>>,
116    pub properties: Option<Value>,
117    pub(crate) kind_status: Option<String>,
118}
119
120impl NotePatch {
121    /// Construct a `NotePatch` from the public fields only.
122    /// Use this from external crates; `kind_status` is set to `None`.
123    pub fn new(
124        name: Option<Option<String>>,
125        content: Option<String>,
126        salience: Option<Option<f64>>,
127        decay_factor: Option<Option<f64>>,
128        properties: Option<Value>,
129    ) -> Self {
130        Self {
131            name,
132            content,
133            salience,
134            decay_factor,
135            properties,
136            kind_status: None,
137        }
138    }
139}
140
141/// Filter for `list_edges` / `count_edges`.
142#[derive(Clone, Debug, Default)]
143pub struct EdgeListFilter {
144    pub source_id: Option<Uuid>,
145    pub target_id: Option<Uuid>,
146    /// Empty = any relation.
147    pub relations: Vec<EdgeRelation>,
148    pub min_weight: Option<f64>,
149    pub max_weight: Option<f64>,
150}
151
152impl From<EdgeListFilter> for EdgeFilter {
153    fn from(f: EdgeListFilter) -> Self {
154        EdgeFilter {
155            source_ids: f.source_id.into_iter().collect(),
156            target_ids: f.target_id.into_iter().collect(),
157            relations: f.relations,
158            min_weight: f.min_weight,
159            max_weight: f.max_weight,
160            ..Default::default()
161        }
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Private types
167// ---------------------------------------------------------------------------
168
169// REASON: EdgeRow fields are populated via rusqlite row mapping. The struct is fully
170// constructed even when not all fields are read back after construction. The complete
171// field mapping guards against column-order bugs when the schema changes.
172#[allow(dead_code)]
173struct EdgeRow {
174    id: Uuid,
175    source_id: Uuid,
176    target_id: Uuid,
177    relation: String,
178    weight: f64,
179    created_at: i64,
180    updated_at: i64,
181    deleted_at: Option<i64>,
182    target_backend: Option<String>,
183    metadata: Option<String>,
184}
185
186// ---------------------------------------------------------------------------
187// Implementation
188// ---------------------------------------------------------------------------
189
190impl KhiveRuntime {
191    /// Patch-style entity update.
192    ///
193    /// Only fields set to `Some(_)` are changed. Re-indexes FTS5 (and vectors if configured)
194    /// when `name` or `description` changes; skips re-indexing for property/tag-only patches.
195    ///
196    /// Returns `RuntimeError::NotFound` if the entity does not exist or belongs to a different
197    /// namespace. Namespace isolation is enforced at the runtime layer.
198    /// Computes the patched `Entity`, `text_changed`, and `changed_fields` without
199    /// writing anything, so both the normal write path and the atomic-prepare path
200    /// share one source of truth for what a patched entity looks like.
201    pub(crate) async fn prepare_update_entity(
202        &self,
203        token: &NamespaceToken,
204        id: Uuid,
205        patch: EntityPatch,
206    ) -> RuntimeResult<(Entity, bool, Vec<&'static str>)> {
207        if let Some(ref name) = patch.name {
208            crate::secret_gate::check(name)?;
209        }
210        if let Some(Some(ref desc)) = patch.description {
211            crate::secret_gate::check(desc)?;
212        }
213        if let Some(ref props) = patch.properties {
214            crate::secret_gate::check_json(props)?;
215        }
216        if let Some(ref tags) = patch.tags {
217            crate::secret_gate::check_tags(tags)?;
218        }
219        let store = self.entities(token)?;
220        let mut entity = store
221            .get_entity(id)
222            .await?
223            .ok_or_else(|| RuntimeError::NotFound(format!("entity {id}")))?;
224
225        let mut text_changed = false;
226        let mut changed_fields: Vec<&'static str> = Vec::new();
227
228        if let Some(name) = patch.name {
229            text_changed |= entity.name != name;
230            entity.name = name;
231            changed_fields.push("name");
232        }
233        if let Some(desc_patch) = patch.description {
234            text_changed |= entity.description != desc_patch;
235            entity.description = desc_patch;
236            changed_fields.push("description");
237        }
238        if let Some(props) = patch.properties {
239            let (merged, _) = merge_properties(
240                &entity.properties,
241                &Some(props),
242                EntityDedupMergePolicy::PreferFrom,
243            );
244            entity.properties = merged;
245            changed_fields.push("properties");
246        }
247        if let Some(tags) = patch.tags {
248            entity.tags = tags;
249            changed_fields.push("tags");
250        }
251
252        entity.updated_at = chrono::Utc::now().timestamp_micros();
253        Ok((entity, text_changed, changed_fields))
254    }
255
256    pub async fn update_entity(
257        &self,
258        token: &NamespaceToken,
259        id: Uuid,
260        patch: EntityPatch,
261    ) -> RuntimeResult<Entity> {
262        let (entity, text_changed, changed_fields) =
263            self.prepare_update_entity(token, id, patch).await?;
264
265        let store = self.entities(token)?;
266        store.upsert_entity(entity.clone()).await?;
267
268        if text_changed {
269            self.reindex_entity(token, &entity).await?;
270        }
271
272        let event_store = self.events(token)?;
273        let event = khive_storage::event::Event::new(
274            entity.namespace.clone(),
275            "update",
276            EventKind::EntityUpdated,
277            SubstrateKind::Entity,
278            "",
279        )
280        .with_target(entity.id)
281        .with_payload(serde_json::json!({
282            "id": entity.id,
283            "namespace": entity.namespace,
284            "changed_fields": changed_fields,
285        }));
286        event_store.append_event(event).await.map_err(|e| {
287            RuntimeError::Internal(format!("update_entity: event store write failed: {e}"))
288        })?;
289
290        Ok(entity)
291    }
292
293    /// Merge `from_id` into `into_id`.
294    ///
295    /// All edges incident to `from_id` are rewired to `into_id`. Self-loops that would
296    /// result from the rewire are dropped. Properties and tags are merged per `strategy`.
297    /// `from_id` is tombstoned with merge provenance and removed from indexes. Returns a summary.
298    ///
299    /// If `dry_run` is true, computes and returns the planned summary without mutating any rows.
300    ///
301    /// Atomic: all SQL (entity reads/writes, edge rewires, FTS updates, vec-index delete)
302    /// runs on a single pool connection inside one `BEGIN IMMEDIATE` transaction via
303    /// `merge_entity_sql`. If embedding vectors are configured, the vector re-insert for
304    /// `into_id` is performed after the transaction (requires async embedding computation).
305    pub async fn merge_entity(
306        &self,
307        token: &NamespaceToken,
308        into_id: Uuid,
309        from_id: Uuid,
310        strategy: EntityDedupMergePolicy,
311        content_strategy: ContentMergeStrategy,
312        dry_run: bool,
313    ) -> RuntimeResult<MergeSummary> {
314        if into_id == from_id {
315            return Err(RuntimeError::InvalidInput(
316                "cannot merge an entity into itself".into(),
317            ));
318        }
319        // Enforce the same-kind constraint here too: any direct runtime caller
320        // (CLI, tests, future SDK) would bypass the handler-level guard otherwise.
321        {
322            let into_entity = self.get_entity(token, into_id).await?;
323            let from_entity = self.get_entity(token, from_id).await?;
324            if into_entity.kind != from_entity.kind {
325                return Err(RuntimeError::InvalidInput(format!(
326                    "cannot merge entities of different kinds: into={} ({}), from={} ({}); \
327                     merge requires both entities to share the same kind",
328                    into_id, into_entity.kind, from_id, from_entity.kind
329                )));
330            }
331        }
332        let ns = token.namespace().as_str().to_owned();
333        let fts_table = "fts_entities".to_string();
334        let vec_tables: Vec<String> = self
335            .registered_embedding_model_names()
336            .iter()
337            .map(|name| format!("vec_{}", crate::config::sanitize_key(name)))
338            .collect();
339
340        // Ensure all required tables exist (idempotent DDL) before the transaction.
341        let _ = self.entities(token)?;
342        let _ = self.graph(token)?;
343        let _ = self.text(token)?;
344        // vectors_for_model (not the default-model-only self.vectors()) so
345        // custom-only runtimes (no default embedding_model) still get DDL primed.
346        for model_name in &self.registered_embedding_model_names() {
347            let _ = self.vectors_for_model(token, model_name)?;
348        }
349
350        let pool = self.backend().pool_arc();
351        // When the write queue is enabled, route this multi-statement merge through
352        // the single-writer task instead of the pool's writer mutex. A lookup
353        // failure degrades to the legacy mutex path rather than failing the merge.
354        let writer_task = pool.writer_task_handle().ok().flatten();
355
356        let (summary, updated_entity) = if let Some(writer_task) = writer_task {
357            writer_task
358                .send(move |conn| {
359                    merge_entity_sql(
360                        conn,
361                        ns,
362                        fts_table,
363                        vec_tables,
364                        into_id,
365                        from_id,
366                        strategy,
367                        content_strategy,
368                        dry_run,
369                    )
370                    .map_err(|e| {
371                        khive_storage::StorageError::driver(
372                            khive_storage::StorageCapability::Entities,
373                            "merge_entity",
374                            e,
375                        )
376                    })
377                })
378                .await
379                .map_err(RuntimeError::Storage)?
380        } else {
381            tokio::task::spawn_blocking(move || {
382                let guard = pool.writer()?;
383                guard.transaction(|conn| {
384                    merge_entity_sql(
385                        conn,
386                        ns,
387                        fts_table,
388                        vec_tables,
389                        into_id,
390                        from_id,
391                        strategy,
392                        content_strategy,
393                        dry_run,
394                    )
395                })
396            })
397            .await
398            .map_err(|e| RuntimeError::Internal(e.to_string()))??
399        };
400
401        // FTS and vec-deletes already committed inside the transaction above;
402        // only the embedding re-insert needs an async step outside it.
403        if !dry_run && !self.registered_embedding_model_names().is_empty() {
404            self.reindex_entity(token, &updated_entity).await?;
405        }
406
407        // Dry-run is a read-only preview: it must not append a merge event.
408        if !dry_run {
409            let event_store = self.events(token)?;
410            // Mirror the wire-level strategy spelling from MergeParams so consumers
411            // can round-trip the policy string back into a request.
412            let policy_str = match strategy {
413                EntityDedupMergePolicy::PreferInto => "prefer_into",
414                EntityDedupMergePolicy::PreferFrom => "prefer_from",
415                EntityDedupMergePolicy::Union => "union",
416            };
417            let event = khive_storage::event::Event::new(
418                updated_entity.namespace.clone(),
419                "merge",
420                EventKind::EntityMerged,
421                SubstrateKind::Entity,
422                "",
423            )
424            .with_target(summary.kept_id)
425            .with_payload(serde_json::json!({
426                "into_id": summary.kept_id,
427                "from_id": summary.removed_id,
428                "policy": policy_str,
429                "content_strategy": format!("{:?}", content_strategy),
430                "edges_rewired": summary.edges_rewired,
431            }));
432            event_store.append_event(event).await.map_err(|e| {
433                RuntimeError::Internal(format!("merge_entity: event store write failed: {e}"))
434            })?;
435        }
436
437        Ok(summary)
438    }
439
440    // ---- Internal helpers ----
441
442    /// Re-upsert FTS5 document and vector(s) for the entity across all registered models.
443    ///
444    /// Uses `entity.namespace` — the authoritative namespace stored on the record — rather
445    /// than the caller-supplied `namespace` parameter. This prevents a cross-namespace
446    /// reindex from writing the search document into the wrong namespace's FTS index.
447    ///
448    /// Best-effort for vectors: if embedding or inserting for a particular model fails,
449    /// logs a warning and continues to the next model. The FTS step is fail-closed
450    /// (propagates error). Callers (update_entity, merge_entity) have already committed
451    /// the entity row, so a partial embed miss leaves a stale vector rather than
452    /// rolling back the update — acceptable because SqliteVecStore::insert is an upsert
453    /// (the prior vector stays intact on failure, keeping the record searchable).
454    pub(crate) async fn reindex_entity(
455        &self,
456        token: &NamespaceToken,
457        entity: &Entity,
458    ) -> RuntimeResult<()> {
459        // Use entity.namespace (authoritative) rather than token.namespace().as_str() (caller claim).
460        let ns = entity.namespace.clone();
461        let doc = entity_fts_document(entity);
462        let embed_body = doc.body.clone();
463        self.text(token)?.upsert_document(doc).await?;
464
465        let embed_model_names = self.registered_embedding_model_names();
466        for model_name in &embed_model_names {
467            match self
468                .embed_document_with_model(model_name, &embed_body)
469                .await
470            {
471                Ok(vector) => match self.vectors_for_model(token, model_name) {
472                    Ok(vs) => {
473                        if let Err(e) = vs
474                            .insert(
475                                entity.id,
476                                SubstrateKind::Entity,
477                                &ns,
478                                "entity.body",
479                                vec![vector],
480                            )
481                            .await
482                        {
483                            tracing::warn!(
484                                model = model_name,
485                                id = %entity.id,
486                                "reindex_entity: vector insert failed, skipping model: {e}"
487                            );
488                        }
489                    }
490                    Err(e) => {
491                        tracing::warn!(
492                            model = model_name,
493                            id = %entity.id,
494                            "reindex_entity: could not access vector store for model, skipping: {e}"
495                        );
496                    }
497                },
498                Err(e) => {
499                    tracing::warn!(
500                        model = model_name,
501                        id = %entity.id,
502                        "reindex_entity: embed failed for model, skipping: {e}"
503                    );
504                }
505            }
506        }
507
508        Ok(())
509    }
510
511    /// Remove an entity from FTS5 and vector indexes across all registered models.
512    pub(crate) async fn remove_from_indexes(
513        &self,
514        token: &NamespaceToken,
515        id: Uuid,
516    ) -> RuntimeResult<()> {
517        let ns = token.namespace().as_str().to_owned();
518        self.text(token)?.delete_document(&ns, id).await?;
519        for model_name in self.registered_embedding_model_names() {
520            self.vectors_for_model(token, &model_name)?
521                .delete(id)
522                .await?;
523        }
524        Ok(())
525    }
526
527    /// Re-upsert FTS5 document and vector(s) for the note across all registered models.
528    ///
529    /// Best-effort for vectors: mirrors reindex_entity's warn-and-continue policy.
530    pub(crate) async fn reindex_note(
531        &self,
532        token: &NamespaceToken,
533        note: &khive_storage::note::Note,
534    ) -> RuntimeResult<()> {
535        self.text_for_notes(token)?
536            .upsert_document(note_fts_document(note))
537            .await?;
538
539        let ns = note.namespace.clone();
540        let embed_model_names = self.registered_embedding_model_names();
541        for model_name in &embed_model_names {
542            match self
543                .embed_document_with_model(model_name, &note.content)
544                .await
545            {
546                Ok(vector) => match self.vectors_for_model(token, model_name) {
547                    Ok(vs) => {
548                        if let Err(e) = vs
549                            .insert(
550                                note.id,
551                                SubstrateKind::Note,
552                                &ns,
553                                "note.content",
554                                vec![vector],
555                            )
556                            .await
557                        {
558                            tracing::warn!(
559                                model = model_name,
560                                id = %note.id,
561                                "reindex_note: vector insert failed, skipping model: {e}"
562                            );
563                        }
564                    }
565                    Err(e) => {
566                        tracing::warn!(
567                            model = model_name,
568                            id = %note.id,
569                            "reindex_note: could not access vector store for model, skipping: {e}"
570                        );
571                    }
572                },
573                Err(e) => {
574                    tracing::warn!(
575                        model = model_name,
576                        id = %note.id,
577                        "reindex_note: embed failed for model, skipping: {e}"
578                    );
579                }
580            }
581        }
582        Ok(())
583    }
584
585    /// Computes the patched `Note` and `text_changed` without writing, mirroring
586    /// `prepare_update_entity` so both the normal write path and the
587    /// atomic-prepare path share one source of truth.
588    pub(crate) async fn prepare_update_note(
589        &self,
590        token: &NamespaceToken,
591        id: Uuid,
592        patch: NotePatch,
593    ) -> RuntimeResult<(khive_storage::note::Note, bool)> {
594        if let Some(ref content) = patch.content {
595            crate::secret_gate::check(content)?;
596        }
597        if let Some(Some(ref name)) = patch.name {
598            crate::secret_gate::check(name)?;
599        }
600        if let Some(ref props) = patch.properties {
601            crate::secret_gate::check_json(props)?;
602        }
603        let store = self.notes(token)?;
604        let mut note = store
605            .get_note(id)
606            .await?
607            .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
608
609        let mut text_changed = false;
610
611        if let Some(name_patch) = patch.name {
612            text_changed |= note.name != name_patch;
613            note.name = name_patch;
614        }
615        if let Some(content) = patch.content {
616            text_changed |= note.content != content;
617            note.content = content;
618        }
619        if let Some(salience_patch) = patch.salience {
620            // Reject invalid salience rather than silently clamping caller input.
621            if let Some(s) = salience_patch {
622                if !s.is_finite() || !(0.0..=1.0).contains(&s) {
623                    return Err(crate::RuntimeError::InvalidInput(format!(
624                        "salience must be a finite value in [0.0, 1.0]; got {s}"
625                    )));
626                }
627            }
628            note.salience = salience_patch;
629        }
630        if let Some(decay_patch) = patch.decay_factor {
631            // Reject invalid decay_factor rather than silently clamping caller input.
632            if let Some(d) = decay_patch {
633                if !d.is_finite() || d < 0.0 {
634                    return Err(crate::RuntimeError::InvalidInput(format!(
635                        "decay_factor must be a finite value >= 0.0; got {d}"
636                    )));
637                }
638            }
639            note.decay_factor = decay_patch;
640        }
641        if let Some(props) = patch.properties {
642            let (merged, _) = merge_properties(
643                &note.properties,
644                &Some(props),
645                EntityDedupMergePolicy::PreferFrom,
646            );
647            note.properties = merged;
648        }
649        if let Some(status) = patch.kind_status {
650            note.status = status;
651        }
652
653        note.updated_at = chrono::Utc::now().timestamp_micros();
654        Ok((note, text_changed))
655    }
656
657    /// Patch-style note update.
658    pub async fn update_note(
659        &self,
660        token: &NamespaceToken,
661        id: Uuid,
662        patch: NotePatch,
663    ) -> RuntimeResult<khive_storage::note::Note> {
664        let (note, text_changed) = self.prepare_update_note(token, id, patch).await?;
665
666        let store = self.notes(token)?;
667        store.upsert_note(note.clone()).await?;
668
669        if text_changed {
670            self.reindex_note(token, &note).await?;
671            // Notify any pack-owned vector cache (e.g. a warm ANN index) that this
672            // note's embedding changed, via a generic hook so khive-runtime/pack-kg
673            // never take a dependency on the consuming pack. No-op if unregistered.
674            self.fire_note_mutation_hook(&note.kind, note.id).await;
675        }
676
677        Ok(note)
678    }
679
680    /// Merge `from_id` note into `into_id` note.
681    ///
682    /// Both notes must exist in the namespace and have the same `kind`. Content is merged
683    /// per `content_strategy`. Properties are merged per `strategy`. `from_id` is
684    /// tombstoned (status='deleted', deleted_at set). Returns a summary.
685    ///
686    /// If `dry_run` is true, computes and returns the planned summary without mutating
687    /// any rows, edges, or indexes.
688    pub async fn merge_note(
689        &self,
690        token: &NamespaceToken,
691        into_id: Uuid,
692        from_id: Uuid,
693        strategy: EntityDedupMergePolicy,
694        content_strategy: ContentMergeStrategy,
695        dry_run: bool,
696    ) -> RuntimeResult<MergeSummary> {
697        if into_id == from_id {
698            return Err(RuntimeError::InvalidInput(
699                "cannot merge a note into itself".into(),
700            ));
701        }
702        let ns = token.namespace().as_str().to_string();
703        let fts_table = "fts_notes".to_string();
704        let vec_tables: Vec<String> = self
705            .registered_embedding_model_names()
706            .iter()
707            .map(|name| format!("vec_{}", crate::config::sanitize_key(name)))
708            .collect();
709
710        let note_store = self.notes(token)?;
711        let into_note = note_store
712            .get_note(into_id)
713            .await?
714            .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
715        Self::ensure_namespace(&into_note.namespace, &ns)?;
716
717        let from_note = note_store
718            .get_note(from_id)
719            .await?
720            .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
721        Self::ensure_namespace(&from_note.namespace, &ns)?;
722
723        let _ = self.graph(token)?;
724        let _ = self.text_for_notes(token)?;
725        for model_name in &self.registered_embedding_model_names() {
726            let _ = self.vectors_for_model(token, model_name)?;
727        }
728
729        let pool = self.backend().pool_arc();
730        let writer_task = pool.writer_task_handle().ok().flatten();
731
732        let (summary, updated_note) = if let Some(writer_task) = writer_task {
733            writer_task
734                .send(move |conn| {
735                    merge_note_sql(
736                        conn,
737                        ns,
738                        fts_table,
739                        vec_tables,
740                        into_id,
741                        from_id,
742                        strategy,
743                        content_strategy,
744                        dry_run,
745                    )
746                    .map_err(|e| {
747                        khive_storage::StorageError::driver(
748                            khive_storage::StorageCapability::Notes,
749                            "merge_note",
750                            e,
751                        )
752                    })
753                })
754                .await
755                .map_err(RuntimeError::Storage)?
756        } else {
757            tokio::task::spawn_blocking(move || {
758                let guard = pool.writer()?;
759                guard.transaction(|conn| {
760                    merge_note_sql(
761                        conn,
762                        ns,
763                        fts_table,
764                        vec_tables,
765                        into_id,
766                        from_id,
767                        strategy,
768                        content_strategy,
769                        dry_run,
770                    )
771                })
772            })
773            .await
774            .map_err(|e| RuntimeError::Internal(e.to_string()))??
775        };
776
777        if !dry_run && !self.registered_embedding_model_names().is_empty() {
778            self.reindex_note(token, &updated_note).await?;
779            // A merge changes the same ANN corpus as update_note's text_changed
780            // branch, so fire the same mutation hook regardless of which public
781            // write path reached the corpus change.
782            self.fire_note_mutation_hook(&updated_note.kind, updated_note.id)
783                .await;
784        }
785        Ok(summary)
786    }
787}
788
789// ---------------------------------------------------------------------------
790// FTS document construction
791// ---------------------------------------------------------------------------
792
793/// Build the `TextDocument` for an entity. This is the single source of truth for
794/// entity FTS document shape; all write paths (create, update, merge, reindex, backfill)
795/// must go through this function so search parity is guaranteed.
796///
797/// Body rule: when the entity has a non-empty description, prepend the name
798/// (`"<name> <description>"`). Otherwise the body is just the name. This
799/// matches the FTS index contract: `title` and `body` are the ranked columns;
800/// `tags`, `metadata`, and `namespace` are UNINDEXED.
801///
802/// `updated_at` is taken from the entity's own timestamp so that backfill and
803/// reindex runs record the entity's actual mutation time rather than the
804/// reindex execution time.
805pub fn entity_fts_document(entity: &Entity) -> TextDocument {
806    let body = match &entity.description {
807        Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
808        _ => entity.name.clone(),
809    };
810    let updated_at =
811        chrono::DateTime::from_timestamp_micros(entity.updated_at).unwrap_or_else(chrono::Utc::now);
812    TextDocument {
813        subject_id: entity.id,
814        kind: SubstrateKind::Entity,
815        title: Some(entity.name.clone()),
816        body,
817        tags: entity.tags.clone(),
818        namespace: entity.namespace.clone(),
819        metadata: entity.properties.clone(),
820        updated_at,
821    }
822}
823
824/// Build the `TextDocument` for a note. This is the single source of truth for
825/// note FTS document shape; all write paths (create, update, reindex) must go
826/// through this function so recall parity is guaranteed. Changes here apply to
827/// every caller automatically.
828///
829/// Body rule: when the note has a `name`, prepend it to the content
830/// (`"<name> <content>"`). This matches the FTS index contract: title and body
831/// both contribute to ranking, and the name is the most salient signal.
832///
833/// `updated_at` is taken from the note's own timestamp (not `Utc::now()`) so
834/// that backfill and reindex runs record the note's actual mutation time rather
835/// than the reindex execution time.
836pub fn note_fts_document(note: &Note) -> TextDocument {
837    let body = match &note.name {
838        Some(n) => format!("{n} {}", note.content),
839        None => note.content.clone(),
840    };
841    let updated_at =
842        chrono::DateTime::from_timestamp_micros(note.updated_at).unwrap_or_else(chrono::Utc::now);
843    TextDocument {
844        subject_id: note.id,
845        kind: SubstrateKind::Note,
846        title: note.name.clone(),
847        body,
848        tags: vec![],
849        namespace: note.namespace.clone(),
850        metadata: note.properties.clone(),
851        updated_at,
852    }
853}
854
855/// SQL-bind–ready scalars derived from [`note_fts_document`].
856///
857/// Used by `merge_note_sql` to guarantee that the raw SQL FTS INSERT stores
858/// exactly what [`Fts5TextSearch::upsert_document`] would write, preventing
859/// null/empty-string divergence on the `title` column for nameless notes.
860pub(crate) struct NoteFtsScalars {
861    /// Empty string when `note.name` is `None` — matches the `unwrap_or("")` in
862    /// `Fts5TextSearch::upsert_document`.
863    pub title: String,
864    pub body: String,
865    /// Always the JSON array `"[]"`.
866    pub tags: String,
867    /// Serialised `note.properties`, or `None` when properties are absent.
868    pub metadata: Option<String>,
869    /// `note.updated_at` converted to `DateTime<Utc>` timestamp_micros.
870    pub updated_at_micros: i64,
871}
872
873/// Derive [`NoteFtsScalars`] from a [`Note`].
874///
875/// All values match the encoding that [`Fts5TextSearch::upsert_document`]
876/// applies when given the output of [`note_fts_document`].
877pub(crate) fn note_fts_scalars(note: &Note) -> NoteFtsScalars {
878    let doc = note_fts_document(note);
879    NoteFtsScalars {
880        title: doc.title.unwrap_or_default(),
881        body: doc.body,
882        tags: "[]".to_string(),
883        metadata: doc
884            .metadata
885            .as_ref()
886            .map(|v| serde_json::to_string(v).unwrap_or_default()),
887        updated_at_micros: doc.updated_at.timestamp_micros(),
888    }
889}
890
891// ---------------------------------------------------------------------------
892// Transactional merge SQL helpers
893// ---------------------------------------------------------------------------
894
895/// Read one entity row by ID within a namespace, returning `SqliteError` on missing/wrong-ns.
896fn read_merge_entity(
897    conn: &rusqlite::Connection,
898    id: Uuid,
899    namespace: &str,
900) -> Result<Entity, SqliteError> {
901    let id_str = id.to_string();
902    let mut stmt = conn.prepare(
903        "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
904         created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref \
905         FROM entities WHERE id = ?1 AND deleted_at IS NULL",
906    )?;
907    let mut rows = stmt.query(rusqlite::params![id_str])?;
908    let row = rows
909        .next()?
910        .ok_or_else(|| SqliteError::InvalidData(format!("entity {id} not found")))?;
911
912    let id_s: String = row.get(0)?;
913    let ns: String = row.get(1)?;
914    let kind: String = row.get(2)?;
915    let entity_type: Option<String> = row.get(3)?;
916    let name: String = row.get(4)?;
917    let description: Option<String> = row.get(5)?;
918    let properties_str: Option<String> = row.get(6)?;
919    let tags_str: String = row.get(7)?;
920    let created_at: i64 = row.get(8)?;
921    let updated_at: i64 = row.get(9)?;
922    let deleted_at: Option<i64> = row.get(10)?;
923    let merged_into_str: Option<String> = row.get(11)?;
924    let merge_event_id_str: Option<String> = row.get(12)?;
925    let content_ref: Option<String> = row.get(13)?;
926
927    if ns != namespace {
928        return Err(SqliteError::InvalidData(format!(
929            "entity {id} belongs to namespace '{ns}', not '{namespace}'"
930        )));
931    }
932
933    let entity_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
934    let properties: Option<Value> = properties_str
935        .map(|s| {
936            serde_json::from_str::<Value>(&s).map_err(|e| SqliteError::InvalidData(e.to_string()))
937        })
938        .transpose()?;
939    let tags: Vec<String> =
940        serde_json::from_str(&tags_str).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
941    let merged_into = merged_into_str
942        .as_deref()
943        .map(Uuid::parse_str)
944        .transpose()
945        .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
946    let merge_event_id = merge_event_id_str
947        .as_deref()
948        .map(Uuid::parse_str)
949        .transpose()
950        .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
951
952    Ok(Entity {
953        id: entity_id,
954        namespace: ns,
955        kind,
956        entity_type,
957        name,
958        description,
959        properties,
960        tags,
961        created_at,
962        updated_at,
963        deleted_at,
964        merged_into,
965        merge_event_id,
966        content_ref,
967    })
968}
969
970/// All merge SQL on one connection inside an already-open `BEGIN IMMEDIATE` transaction.
971///
972/// Reads both entities, rewires/drops incident edges, merges entity fields, updates FTS,
973/// deletes the `from` vec entry (if `vec_table` is Some), and tombstones `from` with merge
974/// provenance.  Returns the updated `into` entity so the caller can do the async vec re-insert.
975///
976/// When `dry_run` is true, all reads and computations are performed but no writes are issued.
977// REASON: merge requires both entity IDs, the namespace, FTS and vec table names, merge
978// policy, and dry-run flag — all are load-bearing; reducing to a struct would obscure
979// the sync/async boundary split that keeps this function off the async runtime.
980#[allow(clippy::too_many_arguments)]
981fn merge_entity_sql(
982    conn: &rusqlite::Connection,
983    namespace: String,
984    fts_table: String,
985    vec_tables: Vec<String>,
986    into_id: Uuid,
987    from_id: Uuid,
988    strategy: EntityDedupMergePolicy,
989    content_strategy: ContentMergeStrategy,
990    dry_run: bool,
991) -> Result<(MergeSummary, Entity), SqliteError> {
992    let into_entity = read_merge_entity(conn, into_id, &namespace)?;
993    let from_entity = read_merge_entity(conn, from_id, &namespace)?;
994
995    // --- Collect edges incident to from_id ---
996    let parse_id =
997        |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
998
999    let from_str = from_id.to_string();
1000
1001    let mut outbound: Vec<EdgeRow> = Vec::new();
1002    {
1003        let mut stmt = conn.prepare(
1004            "SELECT id, source_id, target_id, relation, weight, created_at, \
1005                    updated_at, deleted_at, target_backend, metadata \
1006             FROM graph_edges WHERE namespace = ?1 AND source_id = ?2",
1007        )?;
1008        let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1009        while let Some(row) = rows.next()? {
1010            outbound.push(EdgeRow {
1011                id: parse_id(row.get(0)?)?,
1012                source_id: parse_id(row.get(1)?)?,
1013                target_id: parse_id(row.get(2)?)?,
1014                relation: row.get(3)?,
1015                weight: row.get(4)?,
1016                created_at: row.get(5)?,
1017                updated_at: row.get(6)?,
1018                deleted_at: row.get(7)?,
1019                target_backend: row.get(8)?,
1020                metadata: row.get(9)?,
1021            });
1022        }
1023    }
1024
1025    let mut inbound: Vec<EdgeRow> = Vec::new();
1026    {
1027        let mut stmt = conn.prepare(
1028            "SELECT id, source_id, target_id, relation, weight, created_at, \
1029                    updated_at, deleted_at, target_backend, metadata \
1030             FROM graph_edges WHERE namespace = ?1 AND target_id = ?2",
1031        )?;
1032        let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1033        while let Some(row) = rows.next()? {
1034            inbound.push(EdgeRow {
1035                id: parse_id(row.get(0)?)?,
1036                source_id: parse_id(row.get(1)?)?,
1037                target_id: parse_id(row.get(2)?)?,
1038                relation: row.get(3)?,
1039                weight: row.get(4)?,
1040                created_at: row.get(5)?,
1041                updated_at: row.get(6)?,
1042                deleted_at: row.get(7)?,
1043                target_backend: row.get(8)?,
1044                metadata: row.get(9)?,
1045            });
1046        }
1047    }
1048
1049    // Deduplicate by edge ID (a self-edge from_id→from_id appears in both lists).
1050    let mut seen: HashSet<Uuid> = HashSet::new();
1051    let mut all_edges: Vec<EdgeRow> = Vec::new();
1052    for edge in outbound.into_iter().chain(inbound) {
1053        if seen.insert(edge.id) {
1054            all_edges.push(edge);
1055        }
1056    }
1057
1058    // --- Merge entity fields ---
1059    let (merged_props, properties_merged) =
1060        merge_properties(&into_entity.properties, &from_entity.properties, strategy);
1061    let merged_name = merge_string_field(&into_entity.name, &from_entity.name, strategy);
1062    let (merged_description, content_appended) = match content_strategy {
1063        ContentMergeStrategy::Append => {
1064            let into_desc = into_entity.description.as_deref().unwrap_or("");
1065            let from_desc = from_entity.description.as_deref().unwrap_or("");
1066            if from_desc.is_empty() {
1067                (into_entity.description.clone(), false)
1068            } else if into_desc.is_empty() {
1069                (from_entity.description.clone(), true)
1070            } else {
1071                (Some(format!("{}\n\n---\n\n{}", into_desc, from_desc)), true)
1072            }
1073        }
1074        // Description selection follows `content_strategy` directly — it is a
1075        // deliberate, independently-settable choice, not derived from the
1076        // entity-field `strategy` (properties/name/tags merge policy).
1077        ContentMergeStrategy::PreferInto => (into_entity.description.clone(), false),
1078        ContentMergeStrategy::PreferFrom => (from_entity.description.clone(), false),
1079    };
1080    let (merged_tags, tags_unioned) = union_tags(&into_entity.tags, &from_entity.tags);
1081
1082    let now = chrono::Utc::now().timestamp_micros();
1083    let into_str = into_id.to_string();
1084    let props_str = merged_props
1085        .as_ref()
1086        .map(|v| serde_json::to_string(v).unwrap_or_default());
1087    let tags_json = serde_json::to_string(&merged_tags).unwrap_or_else(|_| "[]".to_string());
1088
1089    // Writes are gated on `!dry_run` below, but the loop itself always runs so a
1090    // dry-run response reports a predictive `edges_rewired` count instead of zero.
1091    let mut edges_rewired = 0usize;
1092    for edge in all_edges {
1093        let raw_src = if edge.source_id == from_id {
1094            into_id
1095        } else {
1096            edge.source_id
1097        };
1098        let raw_tgt = if edge.target_id == from_id {
1099            into_id
1100        } else {
1101            edge.target_id
1102        };
1103        // Symmetric relations must be stored with source_uuid < target_uuid.
1104        // Apply canonicalization so the conflict check and UPDATE both use the canonical form.
1105        let (new_src, new_tgt) = match edge.relation.parse::<EdgeRelation>() {
1106            Ok(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
1107            Err(_) => (raw_src, raw_tgt),
1108        };
1109
1110        if new_src == new_tgt {
1111            if !dry_run {
1112                conn.execute(
1113                    "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1114                    rusqlite::params![&namespace, edge.id.to_string()],
1115                )?;
1116            }
1117            continue;
1118        }
1119
1120        if dry_run {
1121            // Predictive count only — no write in a dry-run.
1122            edges_rewired += 1;
1123            continue;
1124        }
1125
1126        let now_ts = chrono::Utc::now().timestamp();
1127        // Preserve the original edge ID where possible so callers can still get()
1128        // it by the ID returned from link(): update in-place when there's no
1129        // conflict; when into_id already owns this (source,target,relation), drop
1130        // the from-edge instead and refresh the existing into-edge.
1131        // Check for a conflict: does into_id already have this natural key?
1132        let conflict_id: Option<String> = {
1133            let conflict_src = new_src.to_string();
1134            let conflict_tgt = new_tgt.to_string();
1135            conn.query_row(
1136                khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
1137                rusqlite::params![
1138                    &namespace,
1139                    &conflict_src,
1140                    &conflict_tgt,
1141                    &edge.relation,
1142                    edge.id.to_string(),
1143                ],
1144                |row| row.get(0),
1145            )
1146            .optional()
1147            .map_err(SqliteError::Rusqlite)?
1148        };
1149
1150        let changed = if let Some(existing_id) = conflict_id {
1151            // A live or soft-deleted row already owns this natural key.
1152            conn.execute(
1153                khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1154                rusqlite::params![&namespace, edge.id.to_string()],
1155            )?;
1156            conn.execute(
1157                khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
1158                rusqlite::params![
1159                    edge.weight,
1160                    now_ts,
1161                    edge.target_backend,
1162                    edge.metadata,
1163                    &namespace,
1164                    &existing_id,
1165                ],
1166            )?
1167        } else {
1168            conn.execute(
1169                "UPDATE graph_edges SET \
1170                     source_id = ?1, target_id = ?2, updated_at = ?3 \
1171                     WHERE namespace = ?4 AND id = ?5",
1172                rusqlite::params![
1173                    new_src.to_string(),
1174                    new_tgt.to_string(),
1175                    now_ts,
1176                    &namespace,
1177                    edge.id.to_string(),
1178                ],
1179            )?
1180        };
1181        if changed > 0 {
1182            edges_rewired += 1;
1183        }
1184    }
1185
1186    if !dry_run {
1187        conn.execute(
1188            "INSERT OR REPLACE INTO entities \
1189             (id, namespace, kind, name, description, properties, tags, \
1190              created_at, updated_at, deleted_at, merged_into, merge_event_id) \
1191             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1192            rusqlite::params![
1193                &into_str,
1194                &namespace,
1195                &into_entity.kind,
1196                &merged_name,
1197                &merged_description,
1198                &props_str,
1199                &tags_json,
1200                into_entity.created_at,
1201                now,
1202                into_entity.deleted_at,
1203                Option::<String>::None,
1204                Option::<String>::None,
1205            ],
1206        )?;
1207
1208        // Body formula mirrors entity_fts_document (the canonical constructor):
1209        // this path is sync/spawn_blocking so it can't call it directly, but
1210        // must stay field-identical.
1211        let fts_body = match &merged_description {
1212            Some(d) if !d.is_empty() => format!("{} {}", merged_name, d),
1213            _ => merged_name.clone(),
1214        };
1215        let kind_str = SubstrateKind::Entity.to_string();
1216
1217        conn.execute(
1218            &format!(
1219                "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1220                fts_table
1221            ),
1222            rusqlite::params![&namespace, &into_str],
1223        )?;
1224        conn.execute(
1225            &format!(
1226                "INSERT INTO {} \
1227                 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1228                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1229                fts_table
1230            ),
1231            rusqlite::params![
1232                &into_str,
1233                &kind_str,
1234                &merged_name,
1235                &fts_body,
1236                &tags_json,
1237                &namespace,
1238                &props_str,
1239                now,
1240            ],
1241        )?;
1242
1243        conn.execute(
1244            &format!(
1245                "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1246                fts_table
1247            ),
1248            rusqlite::params![&namespace, &from_str],
1249        )?;
1250
1251        khive_db::stores::vectors::delete_subject_from_vector_tables(
1252            conn,
1253            &vec_tables,
1254            from_id,
1255            &namespace,
1256        )?;
1257
1258        let merge_event_id = Uuid::new_v4();
1259        conn.execute(
1260            "UPDATE entities \
1261             SET deleted_at = ?1, merged_into = ?2, merge_event_id = ?3, updated_at = ?1 \
1262             WHERE namespace = ?4 AND id = ?5 AND deleted_at IS NULL",
1263            rusqlite::params![
1264                now,
1265                into_str,
1266                merge_event_id.to_string(),
1267                &namespace,
1268                &from_str,
1269            ],
1270        )?;
1271    }
1272
1273    let updated_entity = Entity {
1274        id: into_id,
1275        namespace,
1276        kind: into_entity.kind,
1277        entity_type: into_entity.entity_type,
1278        name: merged_name,
1279        description: merged_description,
1280        properties: merged_props,
1281        tags: merged_tags,
1282        created_at: into_entity.created_at,
1283        updated_at: now,
1284        deleted_at: into_entity.deleted_at,
1285        merged_into: None,
1286        merge_event_id: None,
1287        content_ref: into_entity.content_ref,
1288    };
1289
1290    Ok((
1291        MergeSummary {
1292            kept_id: into_id,
1293            removed_id: from_id,
1294            edges_rewired,
1295            properties_merged,
1296            tags_unioned,
1297            content_appended,
1298            dry_run,
1299        },
1300        updated_entity,
1301    ))
1302}
1303
1304// ---------------------------------------------------------------------------
1305// Note merge SQL helpers
1306// ---------------------------------------------------------------------------
1307
1308/// Read one note row by ID within a namespace, returning `SqliteError` on missing/wrong-ns.
1309fn read_merge_note(
1310    conn: &rusqlite::Connection,
1311    id: Uuid,
1312    namespace: &str,
1313) -> Result<khive_storage::note::Note, SqliteError> {
1314    use khive_storage::note::Note;
1315    let id_str = id.to_string();
1316    let mut stmt = conn.prepare(
1317        "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1318         expires_at, properties, created_at, updated_at, deleted_at \
1319         FROM notes WHERE id = ?1 AND deleted_at IS NULL",
1320    )?;
1321    let mut rows = stmt.query(rusqlite::params![id_str])?;
1322    let row = rows
1323        .next()?
1324        .ok_or_else(|| SqliteError::InvalidData(format!("note {id} not found")))?;
1325
1326    let id_s: String = row.get(0)?;
1327    let ns: String = row.get(1)?;
1328    let kind: String = row.get(2)?;
1329    let status: String = row.get(3)?;
1330    let name: Option<String> = row.get(4)?;
1331    let content: String = row.get(5)?;
1332    let salience: Option<f64> = row.get(6)?;
1333    let decay_factor: Option<f64> = row.get(7)?;
1334    let expires_at: Option<i64> = row.get(8)?;
1335    let properties_str: Option<String> = row.get(9)?;
1336    let created_at: i64 = row.get(10)?;
1337    let updated_at: i64 = row.get(11)?;
1338    let deleted_at: Option<i64> = row.get(12)?;
1339
1340    if ns != namespace {
1341        return Err(SqliteError::InvalidData(format!(
1342            "note {id} belongs to namespace '{ns}', not '{namespace}'"
1343        )));
1344    }
1345
1346    let note_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
1347    let properties: Option<serde_json::Value> = properties_str
1348        .map(|s| serde_json::from_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string())))
1349        .transpose()?;
1350
1351    Ok(Note {
1352        id: note_id,
1353        namespace: ns,
1354        kind,
1355        status,
1356        name,
1357        content,
1358        salience,
1359        decay_factor,
1360        expires_at,
1361        properties,
1362        created_at,
1363        updated_at,
1364        deleted_at,
1365    })
1366}
1367
1368fn max_option_f64(a: Option<f64>, b: Option<f64>) -> Option<f64> {
1369    match (a, b) {
1370        (Some(x), Some(y)) => Some(x.max(y)),
1371        (Some(x), None) => Some(x),
1372        (None, Some(y)) => Some(y),
1373        (None, None) => None,
1374    }
1375}
1376
1377fn append_merge_history(props: Option<Value>, entry: Value) -> Result<Option<Value>, SqliteError> {
1378    use serde_json::{json, Map};
1379    let mut obj: Map<String, Value> = match props {
1380        Some(Value::Object(m)) => m,
1381        Some(other) => {
1382            let mut m = Map::new();
1383            m.insert("_value".into(), other);
1384            m
1385        }
1386        None => Map::new(),
1387    };
1388    let history = obj
1389        .entry("_merge_history".to_string())
1390        .or_insert_with(|| json!([]));
1391    if let Value::Array(arr) = history {
1392        arr.push(entry);
1393    }
1394    Ok(Some(Value::Object(obj)))
1395}
1396
1397/// All note merge SQL on one connection inside a `BEGIN IMMEDIATE` transaction.
1398///
1399/// Reads both notes (must have same `kind`), rewires/drops incident edges, merges content
1400/// per `content_strategy`, tombstones `from`. Returns the updated `into` note for async
1401/// re-embedding.
1402///
1403/// When `dry_run` is true, all reads and computations are performed but no writes are issued.
1404// REASON: note merge additionally requires a content_strategy parameter versus entity merge;
1405// same sync/async boundary rationale as merge_entity_sql applies here.
1406#[allow(clippy::too_many_arguments)]
1407fn merge_note_sql(
1408    conn: &rusqlite::Connection,
1409    namespace: String,
1410    fts_table: String,
1411    vec_tables: Vec<String>,
1412    into_id: Uuid,
1413    from_id: Uuid,
1414    strategy: EntityDedupMergePolicy,
1415    content_strategy: ContentMergeStrategy,
1416    dry_run: bool,
1417) -> Result<(MergeSummary, khive_storage::note::Note), SqliteError> {
1418    let into_note = read_merge_note(conn, into_id, &namespace)?;
1419    let from_note = read_merge_note(conn, from_id, &namespace)?;
1420
1421    if into_note.kind != from_note.kind {
1422        return Err(SqliteError::InvalidData(format!(
1423            "cannot merge notes of different kinds: {} vs {}",
1424            into_note.kind, from_note.kind
1425        )));
1426    }
1427
1428    let now = chrono::Utc::now().timestamp_micros();
1429    let into_str = into_id.to_string();
1430    let from_str = from_id.to_string();
1431
1432    // Collect edges incident to from_id.
1433    let parse_id =
1434        |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
1435
1436    let mut outbound: Vec<EdgeRow> = Vec::new();
1437    {
1438        let mut stmt = conn.prepare(
1439            "SELECT id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
1440             FROM graph_edges WHERE namespace = ?1 AND source_id = ?2",
1441        )?;
1442        let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1443        while let Some(row) = rows.next()? {
1444            outbound.push(EdgeRow {
1445                id: parse_id(row.get(0)?)?,
1446                source_id: parse_id(row.get(1)?)?,
1447                target_id: parse_id(row.get(2)?)?,
1448                relation: row.get(3)?,
1449                weight: row.get(4)?,
1450                created_at: row.get(5)?,
1451                updated_at: row.get(6)?,
1452                deleted_at: row.get(7)?,
1453                target_backend: row.get(8)?,
1454                metadata: row.get(9)?,
1455            });
1456        }
1457    }
1458    let mut inbound: Vec<EdgeRow> = Vec::new();
1459    {
1460        let mut stmt = conn.prepare(
1461            "SELECT id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
1462             FROM graph_edges WHERE namespace = ?1 AND target_id = ?2",
1463        )?;
1464        let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1465        while let Some(row) = rows.next()? {
1466            inbound.push(EdgeRow {
1467                id: parse_id(row.get(0)?)?,
1468                source_id: parse_id(row.get(1)?)?,
1469                target_id: parse_id(row.get(2)?)?,
1470                relation: row.get(3)?,
1471                weight: row.get(4)?,
1472                created_at: row.get(5)?,
1473                updated_at: row.get(6)?,
1474                deleted_at: row.get(7)?,
1475                target_backend: row.get(8)?,
1476                metadata: row.get(9)?,
1477            });
1478        }
1479    }
1480    let mut seen: HashSet<Uuid> = HashSet::new();
1481    let mut all_edges: Vec<EdgeRow> = Vec::new();
1482    for edge in outbound.into_iter().chain(inbound) {
1483        if seen.insert(edge.id) {
1484            all_edges.push(edge);
1485        }
1486    }
1487
1488    // Merge note fields.
1489    let (merged_content, content_appended) = match content_strategy {
1490        ContentMergeStrategy::Append => {
1491            if from_note.content.is_empty() {
1492                (into_note.content.clone(), false)
1493            } else {
1494                (
1495                    format!("{}\n\n---\n\n{}", into_note.content, from_note.content),
1496                    true,
1497                )
1498            }
1499        }
1500        ContentMergeStrategy::PreferInto => (into_note.content.clone(), false),
1501        ContentMergeStrategy::PreferFrom => (from_note.content.clone(), false),
1502    };
1503
1504    let merged_name = match strategy {
1505        EntityDedupMergePolicy::PreferFrom => from_note.name.clone().or(into_note.name.clone()),
1506        _ => into_note.name.clone().or(from_note.name.clone()),
1507    };
1508
1509    let (merged_props, properties_merged) =
1510        merge_properties(&into_note.properties, &from_note.properties, strategy);
1511
1512    let merge_history_entry = serde_json::json!({
1513        "merged_from": from_id.to_string(),
1514        "merged_at": now,
1515        "strategy": format!("{:?}", strategy),
1516        "content_strategy": format!("{:?}", content_strategy),
1517    });
1518    let merged_props = append_merge_history(merged_props, merge_history_entry)?;
1519
1520    let merged_salience = max_option_f64(into_note.salience, from_note.salience);
1521    let merged_expires_at = match (into_note.expires_at, from_note.expires_at) {
1522        (Some(a), Some(b)) => Some(a.max(b)),
1523        (Some(a), None) => Some(a),
1524        (None, Some(b)) => Some(b),
1525        (None, None) => None,
1526    };
1527
1528    let props_str = merged_props
1529        .as_ref()
1530        .map(|v| serde_json::to_string(v).unwrap_or_default());
1531
1532    let mut edges_rewired = 0usize;
1533    if !dry_run {
1534        for edge in all_edges {
1535            let raw_src = if edge.source_id == from_id {
1536                into_id
1537            } else {
1538                edge.source_id
1539            };
1540            let raw_tgt = if edge.target_id == from_id {
1541                into_id
1542            } else {
1543                edge.target_id
1544            };
1545            // Canonicalize symmetric relations before conflict check + UPDATE.
1546            let (new_src, new_tgt) = match edge.relation.parse::<EdgeRelation>() {
1547                Ok(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
1548                Err(_) => (raw_src, raw_tgt),
1549            };
1550            if new_src == new_tgt {
1551                conn.execute(
1552                    "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1553                    rusqlite::params![&namespace, edge.id.to_string()],
1554                )?;
1555                continue;
1556            }
1557            let now_ts = chrono::Utc::now().timestamp();
1558            let conflict_id: Option<String> = {
1559                let conflict_src = new_src.to_string();
1560                let conflict_tgt = new_tgt.to_string();
1561                conn.query_row(
1562                    khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
1563                    rusqlite::params![
1564                        &namespace,
1565                        &conflict_src,
1566                        &conflict_tgt,
1567                        &edge.relation,
1568                        edge.id.to_string(),
1569                    ],
1570                    |row| row.get(0),
1571                )
1572                .optional()
1573                .map_err(SqliteError::Rusqlite)?
1574            };
1575
1576            let changed = if let Some(existing_id) = conflict_id {
1577                conn.execute(
1578                    khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1579                    rusqlite::params![&namespace, edge.id.to_string()],
1580                )?;
1581                conn.execute(
1582                    khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
1583                    rusqlite::params![
1584                        edge.weight,
1585                        now_ts,
1586                        edge.target_backend,
1587                        edge.metadata,
1588                        &namespace,
1589                        &existing_id,
1590                    ],
1591                )?
1592            } else {
1593                conn.execute(
1594                    "UPDATE graph_edges SET \
1595                     source_id = ?1, target_id = ?2, updated_at = ?3 \
1596                     WHERE namespace = ?4 AND id = ?5",
1597                    rusqlite::params![
1598                        new_src.to_string(),
1599                        new_tgt.to_string(),
1600                        now_ts,
1601                        &namespace,
1602                        edge.id.to_string(),
1603                    ],
1604                )?
1605            };
1606            if changed > 0 {
1607                edges_rewired += 1;
1608            }
1609        }
1610
1611        conn.execute(
1612            "INSERT OR REPLACE INTO notes \
1613             (id, namespace, kind, status, name, content, salience, decay_factor, \
1614              expires_at, properties, created_at, updated_at, deleted_at) \
1615             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1616            rusqlite::params![
1617                &into_str,
1618                &namespace,
1619                &into_note.kind,
1620                &into_note.status,
1621                &merged_name,
1622                &merged_content,
1623                merged_salience,
1624                into_note.decay_factor,
1625                merged_expires_at,
1626                &props_str,
1627                into_note.created_at,
1628                now,
1629                into_note.deleted_at,
1630            ],
1631        )?;
1632
1633        conn.execute(
1634            &format!(
1635                "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1636                fts_table
1637            ),
1638            rusqlite::params![&namespace, &into_str],
1639        )?;
1640        // Derive FTS scalars through the shared constructor so this raw SQL path
1641        // is field-identical to TextSearch::upsert_document: critically, `title`
1642        // is an empty string (not SQL NULL) for nameless notes, so get_document
1643        // round-trips None <-> "" correctly.
1644        let fts_merged = {
1645            let mut merged_note = Note::new(&namespace, &*into_note.kind, &*merged_content);
1646            merged_note.id = into_id;
1647            merged_note.name = merged_name.clone();
1648            merged_note.properties = merged_props.clone();
1649            merged_note.updated_at = now;
1650            note_fts_scalars(&merged_note)
1651        };
1652        conn.execute(
1653            &format!(
1654                "INSERT INTO {} \
1655                 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1656                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1657                fts_table
1658            ),
1659            rusqlite::params![
1660                &into_str,
1661                SubstrateKind::Note.to_string(),
1662                &fts_merged.title,
1663                &fts_merged.body,
1664                &fts_merged.tags,
1665                &namespace,
1666                &fts_merged.metadata,
1667                fts_merged.updated_at_micros,
1668            ],
1669        )?;
1670
1671        conn.execute(
1672            &format!(
1673                "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1674                fts_table
1675            ),
1676            rusqlite::params![&namespace, &from_str],
1677        )?;
1678
1679        khive_db::stores::vectors::delete_subject_from_vector_tables(
1680            conn,
1681            &vec_tables,
1682            from_id,
1683            &namespace,
1684        )?;
1685
1686        conn.execute(
1687            "UPDATE notes SET status = 'deleted', deleted_at = ?1, updated_at = ?1 \
1688             WHERE namespace = ?2 AND id = ?3 AND deleted_at IS NULL",
1689            rusqlite::params![now, &namespace, &from_str],
1690        )?;
1691    }
1692
1693    let updated_note = khive_storage::note::Note {
1694        id: into_id,
1695        namespace: namespace.clone(),
1696        kind: into_note.kind.clone(),
1697        status: into_note.status.clone(),
1698        name: merged_name,
1699        content: merged_content,
1700        salience: merged_salience,
1701        decay_factor: into_note.decay_factor,
1702        expires_at: merged_expires_at,
1703        properties: merged_props,
1704        created_at: into_note.created_at,
1705        updated_at: now,
1706        deleted_at: into_note.deleted_at,
1707    };
1708
1709    Ok((
1710        MergeSummary {
1711            kept_id: into_id,
1712            removed_id: from_id,
1713            edges_rewired,
1714            properties_merged,
1715            tags_unioned: 0,
1716            content_appended,
1717            dry_run,
1718        },
1719        updated_note,
1720    ))
1721}
1722
1723// ---------------------------------------------------------------------------
1724// Merge helpers (pure functions — easier to unit test)
1725// ---------------------------------------------------------------------------
1726
1727/// `pub(crate)` so `crate::atomic_prepare::prepare_merge` can reuse this exact
1728/// field-fold semantics for atomic/non-atomic parity.
1729pub(crate) fn merge_string_field(
1730    into: &str,
1731    from: &str,
1732    strategy: EntityDedupMergePolicy,
1733) -> String {
1734    match strategy {
1735        EntityDedupMergePolicy::PreferInto | EntityDedupMergePolicy::Union => into.to_string(),
1736        EntityDedupMergePolicy::PreferFrom => from.to_string(),
1737    }
1738}
1739
1740/// Merge two property objects. Returns (merged, count_of_fields_from_from_that_were_added).
1741/// `pub(crate)` so `crate::atomic_prepare` can reuse this exact properties-merge
1742/// semantics when building an `update` write plan's row statement, matching
1743/// `update_entity`/`update_note`'s own patch behavior byte-for-byte.
1744pub(crate) fn merge_properties(
1745    into: &Option<Value>,
1746    from: &Option<Value>,
1747    strategy: EntityDedupMergePolicy,
1748) -> (Option<Value>, usize) {
1749    match (into, from) {
1750        (None, None) => (None, 0),
1751        (Some(a), None) => (Some(a.clone()), 0),
1752        (None, Some(b)) => {
1753            let count = if let Value::Object(m) = b { m.len() } else { 1 };
1754            (Some(b.clone()), count)
1755        }
1756        (Some(into_val), Some(from_val)) => {
1757            let (merged, added) = merge_json(into_val, from_val, strategy);
1758            (Some(merged), added)
1759        }
1760    }
1761}
1762
1763/// Deep-merge two JSON values per strategy. Returns (merged, keys_contributed_by_from).
1764fn merge_json(into: &Value, from: &Value, strategy: EntityDedupMergePolicy) -> (Value, usize) {
1765    match (into, from, strategy) {
1766        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::Union) => {
1767            let mut result = a.clone();
1768            let mut added = 0usize;
1769            for (k, v_from) in b {
1770                if let Some(v_into) = a.get(k) {
1771                    let (merged, sub_added) =
1772                        merge_json(v_into, v_from, EntityDedupMergePolicy::Union);
1773                    result.insert(k.clone(), merged);
1774                    added += sub_added;
1775                } else {
1776                    result.insert(k.clone(), v_from.clone());
1777                    added += 1;
1778                }
1779            }
1780            (Value::Object(result), added)
1781        }
1782        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferInto) => {
1783            let mut result = a.clone();
1784            let mut added = 0usize;
1785            for (k, v) in b {
1786                if !a.contains_key(k) {
1787                    result.insert(k.clone(), v.clone());
1788                    added += 1;
1789                }
1790            }
1791            (Value::Object(result), added)
1792        }
1793        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferFrom) => {
1794            let mut result = a.clone();
1795            let mut added = 0usize;
1796            for (k, v) in b {
1797                result.insert(k.clone(), v.clone());
1798                if !a.contains_key(k) {
1799                    added += 1;
1800                }
1801            }
1802            (Value::Object(result), added)
1803        }
1804        // Non-object scalars: apply strategy directly.
1805        (_into_val, from_val, EntityDedupMergePolicy::PreferFrom) => (from_val.clone(), 1),
1806        _ => (into.clone(), 0),
1807    }
1808}
1809
1810/// `pub(crate)` so `crate::atomic_prepare::prepare_merge` can reuse this for
1811/// atomic/non-atomic parity.
1812pub(crate) fn union_tags(into: &[String], from: &[String]) -> (Vec<String>, usize) {
1813    let mut seen: HashSet<&str> = into.iter().map(|s| s.as_str()).collect();
1814    let mut result: Vec<String> = into.to_vec();
1815    let mut added = 0usize;
1816    for tag in from {
1817        if seen.insert(tag.as_str()) {
1818            result.push(tag.clone());
1819            added += 1;
1820        }
1821    }
1822    (result, added)
1823}
1824
1825// ---------------------------------------------------------------------------
1826// INLINE TEST JUSTIFICATION: tests here exercise patch/merge helpers and the
1827// update_note/update_entity paths that share private merge_properties logic.
1828// Moving them to tests/ would require pub-exporting merge_properties, which is
1829// an internal invariant not suitable for the public API surface. Broad
1830// behavioral curation tests live in tests/integration.rs.
1831// ---------------------------------------------------------------------------
1832
1833#[cfg(test)]
1834mod tests {
1835    use super::*;
1836    use crate::runtime::{KhiveRuntime, NamespaceToken};
1837    use khive_storage::types::{Direction, TextFilter, TextQueryMode, TextSearchRequest};
1838
1839    fn rt() -> KhiveRuntime {
1840        KhiveRuntime::memory().unwrap()
1841    }
1842
1843    // Helper: search FTS5 for `query` in a runtime namespace.
1844    async fn fts_hit(rt: &KhiveRuntime, token: &NamespaceToken, query: &str) -> Vec<Uuid> {
1845        let ns = token.namespace().as_str().to_string();
1846        rt.text(token)
1847            .unwrap()
1848            .search(TextSearchRequest {
1849                query: query.to_string(),
1850                mode: TextQueryMode::Plain,
1851                filter: Some(TextFilter {
1852                    namespaces: vec![ns],
1853                    ..Default::default()
1854                }),
1855                top_k: 50,
1856                snippet_chars: 100,
1857            })
1858            .await
1859            .unwrap()
1860            .into_iter()
1861            .map(|h| h.subject_id)
1862            .collect()
1863    }
1864
1865    #[tokio::test]
1866    async fn update_entity_patch_changes_only_specified_fields() {
1867        let rt = rt();
1868        let tok = NamespaceToken::local();
1869        let entity = rt
1870            .create_entity(
1871                &tok,
1872                "concept",
1873                None,
1874                "OriginalName",
1875                Some("orig desc"),
1876                Some(serde_json::json!({"k":"v"})),
1877                vec![],
1878            )
1879            .await
1880            .unwrap();
1881
1882        let updated = rt
1883            .update_entity(
1884                &tok,
1885                entity.id,
1886                EntityPatch {
1887                    description: Some(Some("new desc".to_string())),
1888                    ..Default::default()
1889                },
1890            )
1891            .await
1892            .unwrap();
1893
1894        assert_eq!(updated.name, "OriginalName");
1895        assert_eq!(updated.description.as_deref(), Some("new desc"));
1896        assert_eq!(updated.properties, Some(serde_json::json!({"k":"v"})));
1897    }
1898
1899    #[tokio::test]
1900    async fn update_entity_clear_description_with_some_none() {
1901        let rt = rt();
1902        let tok = NamespaceToken::local();
1903        let entity = rt
1904            .create_entity(
1905                &tok,
1906                "concept",
1907                None,
1908                "ClearDesc",
1909                Some("has description"),
1910                None,
1911                vec![],
1912            )
1913            .await
1914            .unwrap();
1915
1916        let updated = rt
1917            .update_entity(
1918                &tok,
1919                entity.id,
1920                EntityPatch {
1921                    description: Some(None),
1922                    ..Default::default()
1923                },
1924            )
1925            .await
1926            .unwrap();
1927
1928        assert!(
1929            updated.description.is_none(),
1930            "description should be cleared"
1931        );
1932    }
1933
1934    #[tokio::test]
1935    async fn update_entity_reindexes_when_name_changes() {
1936        let rt = rt();
1937        let tok = NamespaceToken::local();
1938        let entity = rt
1939            .create_entity(&tok, "concept", None, "OldName", None, None, vec![])
1940            .await
1941            .unwrap();
1942
1943        let hits_before = fts_hit(&rt, &tok, "OldName").await;
1944        assert!(
1945            hits_before.contains(&entity.id),
1946            "entity should be findable by old name"
1947        );
1948
1949        rt.update_entity(
1950            &tok,
1951            entity.id,
1952            EntityPatch {
1953                name: Some("NewName".to_string()),
1954                ..Default::default()
1955            },
1956        )
1957        .await
1958        .unwrap();
1959
1960        let hits_old = fts_hit(&rt, &tok, "OldName").await;
1961        let hits_new = fts_hit(&rt, &tok, "NewName").await;
1962
1963        assert!(
1964            !hits_old.contains(&entity.id),
1965            "old name should no longer match after rename"
1966        );
1967        assert!(
1968            hits_new.contains(&entity.id),
1969            "new name should be findable after rename"
1970        );
1971    }
1972
1973    #[tokio::test]
1974    async fn update_entity_properties_merges_preserving_existing_keys() {
1975        let rt = rt();
1976        let tok = NamespaceToken::local();
1977        let entity = rt
1978            .create_entity(
1979                &tok,
1980                "concept",
1981                None,
1982                "MergeProps",
1983                None,
1984                Some(serde_json::json!({
1985                    "domain": "inference",
1986                    "repo": "lattice",
1987                    "status": "researched",
1988                })),
1989                vec![],
1990            )
1991            .await
1992            .unwrap();
1993
1994        let updated = rt
1995            .update_entity(
1996                &tok,
1997                entity.id,
1998                EntityPatch {
1999                    properties: Some(serde_json::json!({"status": "implemented"})),
2000                    ..Default::default()
2001                },
2002            )
2003            .await
2004            .unwrap();
2005
2006        let props = updated.properties.expect("properties should remain set");
2007        assert_eq!(props["domain"], "inference", "domain key must be preserved");
2008        assert_eq!(props["repo"], "lattice", "repo key must be preserved");
2009        assert_eq!(
2010            props["status"], "implemented",
2011            "status key must be updated by patch"
2012        );
2013    }
2014
2015    #[tokio::test]
2016    async fn update_entity_skips_reindex_when_only_properties_change() {
2017        let rt = rt();
2018        let tok = NamespaceToken::local();
2019        let entity = rt
2020            .create_entity(&tok, "concept", None, "StableIndexed", None, None, vec![])
2021            .await
2022            .unwrap();
2023
2024        let hits_before = fts_hit(&rt, &tok, "StableIndexed").await;
2025        assert!(hits_before.contains(&entity.id));
2026
2027        rt.update_entity(
2028            &tok,
2029            entity.id,
2030            EntityPatch {
2031                properties: Some(serde_json::json!({"new": "prop"})),
2032                ..Default::default()
2033            },
2034        )
2035        .await
2036        .unwrap();
2037
2038        let hits_after = fts_hit(&rt, &tok, "StableIndexed").await;
2039        assert!(
2040            hits_after.contains(&entity.id),
2041            "still findable after props-only patch"
2042        );
2043    }
2044
2045    #[tokio::test]
2046    async fn merge_entity_rewires_edges() {
2047        let rt = rt();
2048        let tok = NamespaceToken::local();
2049        let a = rt
2050            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2051            .await
2052            .unwrap();
2053        let b = rt
2054            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2055            .await
2056            .unwrap();
2057        let c = rt
2058            .create_entity(&tok, "concept", None, "C", None, None, vec![])
2059            .await
2060            .unwrap();
2061        let d = rt
2062            .create_entity(&tok, "concept", None, "D", None, None, vec![])
2063            .await
2064            .unwrap();
2065
2066        // A→B and C→B; merge B into D → should become A→D and C→D.
2067        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2068            .await
2069            .unwrap();
2070        rt.link(&tok, c.id, b.id, EdgeRelation::Extends, 1.0, None)
2071            .await
2072            .unwrap();
2073
2074        let summary = rt
2075            .merge_entity(
2076                &tok,
2077                d.id,
2078                b.id,
2079                EntityDedupMergePolicy::PreferInto,
2080                ContentMergeStrategy::Append,
2081                false,
2082            )
2083            .await
2084            .unwrap();
2085
2086        assert_eq!(summary.kept_id, d.id);
2087        assert_eq!(summary.removed_id, b.id);
2088        assert_eq!(summary.edges_rewired, 2);
2089
2090        let a_neighbors = rt
2091            .neighbors(&tok, a.id, Direction::Out, None, None)
2092            .await
2093            .unwrap();
2094        assert_eq!(a_neighbors.len(), 1);
2095        assert_eq!(a_neighbors[0].node_id, d.id);
2096
2097        let c_neighbors = rt
2098            .neighbors(&tok, c.id, Direction::Out, None, None)
2099            .await
2100            .unwrap();
2101        assert_eq!(c_neighbors.len(), 1);
2102        assert_eq!(c_neighbors[0].node_id, d.id);
2103    }
2104
2105    #[tokio::test]
2106    async fn merge_entity_self_merge_rejected() {
2107        let rt = rt();
2108        let tok = NamespaceToken::local();
2109        let a = rt
2110            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2111            .await
2112            .unwrap();
2113        let err = rt
2114            .merge_entity(
2115                &tok,
2116                a.id,
2117                a.id,
2118                EntityDedupMergePolicy::PreferInto,
2119                ContentMergeStrategy::Append,
2120                false,
2121            )
2122            .await
2123            .unwrap_err();
2124        assert!(
2125            format!("{err:?}").contains("cannot merge an entity into itself"),
2126            "expected self-merge rejection, got: {err:?}"
2127        );
2128    }
2129
2130    #[tokio::test]
2131    async fn merge_entity_prefer_into_strategy() {
2132        let rt = rt();
2133        let tok = NamespaceToken::local();
2134        let into = rt
2135            .create_entity(
2136                &tok,
2137                "concept",
2138                None,
2139                "Into",
2140                None,
2141                Some(serde_json::json!({"a": 1})),
2142                vec![],
2143            )
2144            .await
2145            .unwrap();
2146        let from = rt
2147            .create_entity(
2148                &tok,
2149                "concept",
2150                None,
2151                "From",
2152                None,
2153                Some(serde_json::json!({"a": 2, "b": 3})),
2154                vec![],
2155            )
2156            .await
2157            .unwrap();
2158
2159        rt.merge_entity(
2160            &tok,
2161            into.id,
2162            from.id,
2163            EntityDedupMergePolicy::PreferInto,
2164            ContentMergeStrategy::Append,
2165            false,
2166        )
2167        .await
2168        .unwrap();
2169
2170        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2171        let props = kept.properties.unwrap();
2172        // a stays as 1 (into wins), b is added from from.
2173        assert_eq!(props["a"], 1);
2174        assert_eq!(props["b"], 3);
2175    }
2176
2177    #[tokio::test]
2178    async fn merge_entity_prefer_from_strategy() {
2179        let rt = rt();
2180        let tok = NamespaceToken::local();
2181        let into = rt
2182            .create_entity(
2183                &tok,
2184                "concept",
2185                None,
2186                "Into",
2187                None,
2188                Some(serde_json::json!({"a": 1})),
2189                vec![],
2190            )
2191            .await
2192            .unwrap();
2193        let from = rt
2194            .create_entity(
2195                &tok,
2196                "concept",
2197                None,
2198                "From",
2199                None,
2200                Some(serde_json::json!({"a": 2, "b": 3})),
2201                vec![],
2202            )
2203            .await
2204            .unwrap();
2205
2206        rt.merge_entity(
2207            &tok,
2208            into.id,
2209            from.id,
2210            EntityDedupMergePolicy::PreferFrom,
2211            ContentMergeStrategy::Append,
2212            false,
2213        )
2214        .await
2215        .unwrap();
2216
2217        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2218        let props = kept.properties.unwrap();
2219        // from wins on a, b also from from.
2220        assert_eq!(props["a"], 2);
2221        assert_eq!(props["b"], 3);
2222    }
2223
2224    #[tokio::test]
2225    async fn merge_entity_union_strategy() {
2226        let rt = rt();
2227        let tok = NamespaceToken::local();
2228        let into = rt
2229            .create_entity(
2230                &tok,
2231                "concept",
2232                None,
2233                "Into",
2234                None,
2235                Some(serde_json::json!({"a": 1})),
2236                vec![],
2237            )
2238            .await
2239            .unwrap();
2240        let from = rt
2241            .create_entity(
2242                &tok,
2243                "concept",
2244                None,
2245                "From",
2246                None,
2247                Some(serde_json::json!({"a": 2, "b": 3})),
2248                vec![],
2249            )
2250            .await
2251            .unwrap();
2252
2253        rt.merge_entity(
2254            &tok,
2255            into.id,
2256            from.id,
2257            EntityDedupMergePolicy::Union,
2258            ContentMergeStrategy::Append,
2259            false,
2260        )
2261        .await
2262        .unwrap();
2263
2264        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2265        let props = kept.properties.unwrap();
2266        // Scalar conflict: into wins → a=1. b added from from.
2267        assert_eq!(props["a"], 1);
2268        assert_eq!(props["b"], 3);
2269    }
2270
2271    #[tokio::test]
2272    async fn merge_entity_unions_tags() {
2273        let rt = rt();
2274        let tok = NamespaceToken::local();
2275        let into = rt
2276            .create_entity(
2277                &tok,
2278                "concept",
2279                None,
2280                "Into",
2281                None,
2282                None,
2283                vec!["x".to_string(), "y".to_string()],
2284            )
2285            .await
2286            .unwrap();
2287        let from = rt
2288            .create_entity(
2289                &tok,
2290                "concept",
2291                None,
2292                "From",
2293                None,
2294                None,
2295                vec!["y".to_string(), "z".to_string()],
2296            )
2297            .await
2298            .unwrap();
2299
2300        rt.merge_entity(
2301            &tok,
2302            into.id,
2303            from.id,
2304            EntityDedupMergePolicy::PreferInto,
2305            ContentMergeStrategy::Append,
2306            false,
2307        )
2308        .await
2309        .unwrap();
2310
2311        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2312        let mut tags = kept.tags.clone();
2313        tags.sort();
2314        assert_eq!(tags, vec!["x", "y", "z"]);
2315    }
2316
2317    #[tokio::test]
2318    async fn merge_entity_drops_self_loops() {
2319        let rt = rt();
2320        let tok = NamespaceToken::local();
2321        let a = rt
2322            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2323            .await
2324            .unwrap();
2325        let b = rt
2326            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2327            .await
2328            .unwrap();
2329
2330        // A `extends` B — merging B into A would produce A `extends` A → drop it.
2331        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2332            .await
2333            .unwrap();
2334
2335        let summary = rt
2336            .merge_entity(
2337                &tok,
2338                a.id,
2339                b.id,
2340                EntityDedupMergePolicy::PreferInto,
2341                ContentMergeStrategy::Append,
2342                false,
2343            )
2344            .await
2345            .unwrap();
2346
2347        assert_eq!(
2348            summary.edges_rewired, 0,
2349            "self-loop should be dropped, not rewired"
2350        );
2351
2352        let a_out = rt
2353            .neighbors(&tok, a.id, Direction::Out, None, None)
2354            .await
2355            .unwrap();
2356        assert!(a_out.is_empty(), "no self-loop should remain");
2357    }
2358
2359    // ---- content_strategy for entity merge ----
2360
2361    #[tokio::test]
2362    async fn merge_entity_append_strategy_concatenates_descriptions() {
2363        let rt = rt();
2364        let tok = NamespaceToken::local();
2365        let into = rt
2366            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2367            .await
2368            .unwrap();
2369        let from = rt
2370            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2371            .await
2372            .unwrap();
2373
2374        let summary = rt
2375            .merge_entity(
2376                &tok,
2377                into.id,
2378                from.id,
2379                EntityDedupMergePolicy::PreferInto,
2380                ContentMergeStrategy::Append,
2381                false,
2382            )
2383            .await
2384            .unwrap();
2385
2386        assert!(
2387            summary.content_appended,
2388            "append strategy with two non-empty descriptions must report content_appended=true"
2389        );
2390        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2391        assert_eq!(kept.description.as_deref(), Some("desc A\n\n---\n\ndesc B"));
2392    }
2393
2394    #[tokio::test]
2395    async fn merge_entity_append_strategy_from_empty_is_noop() {
2396        let rt = rt();
2397        let tok = NamespaceToken::local();
2398        let into = rt
2399            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2400            .await
2401            .unwrap();
2402        let from = rt
2403            .create_entity(&tok, "concept", None, "From", None, None, vec![])
2404            .await
2405            .unwrap();
2406
2407        let summary = rt
2408            .merge_entity(
2409                &tok,
2410                into.id,
2411                from.id,
2412                EntityDedupMergePolicy::PreferInto,
2413                ContentMergeStrategy::Append,
2414                false,
2415            )
2416            .await
2417            .unwrap();
2418
2419        assert!(
2420            !summary.content_appended,
2421            "from's empty description means nothing was appended"
2422        );
2423        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2424        assert_eq!(kept.description.as_deref(), Some("desc A"));
2425    }
2426
2427    #[tokio::test]
2428    async fn merge_entity_append_strategy_into_empty_takes_from() {
2429        let rt = rt();
2430        let tok = NamespaceToken::local();
2431        let into = rt
2432            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2433            .await
2434            .unwrap();
2435        let from = rt
2436            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2437            .await
2438            .unwrap();
2439
2440        let summary = rt
2441            .merge_entity(
2442                &tok,
2443                into.id,
2444                from.id,
2445                EntityDedupMergePolicy::PreferInto,
2446                ContentMergeStrategy::Append,
2447                false,
2448            )
2449            .await
2450            .unwrap();
2451
2452        assert!(
2453            summary.content_appended,
2454            "taking from's description into an empty into is real content preservation"
2455        );
2456        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2457        assert_eq!(kept.description.as_deref(), Some("desc B"));
2458    }
2459
2460    #[tokio::test]
2461    async fn merge_entity_prefer_into_strategy_still_discards_explicitly() {
2462        let rt = rt();
2463        let tok = NamespaceToken::local();
2464        let into = rt
2465            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2466            .await
2467            .unwrap();
2468        let from = rt
2469            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2470            .await
2471            .unwrap();
2472
2473        let summary = rt
2474            .merge_entity(
2475                &tok,
2476                into.id,
2477                from.id,
2478                EntityDedupMergePolicy::PreferInto,
2479                ContentMergeStrategy::PreferInto,
2480                false,
2481            )
2482            .await
2483            .unwrap();
2484
2485        assert!(
2486            !summary.content_appended,
2487            "explicit PreferInto opt-out must not report an append"
2488        );
2489        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2490        assert_eq!(
2491            kept.description.as_deref(),
2492            Some("desc A"),
2493            "explicit PreferInto opt-out keeps the old discard behavior"
2494        );
2495    }
2496
2497    /// `content_strategy` must be followed directly, independent of the
2498    /// entity-field `strategy`: with the default entity policy `prefer_into`,
2499    /// an explicit `content_strategy=prefer_from` must still keep the
2500    /// from-description.
2501    #[tokio::test]
2502    async fn merge_entity_prefer_from_content_strategy_wins_over_default_entity_policy() {
2503        let rt = rt();
2504        let tok = NamespaceToken::local();
2505        let into = rt
2506            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2507            .await
2508            .unwrap();
2509        let from = rt
2510            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2511            .await
2512            .unwrap();
2513
2514        let summary = rt
2515            .merge_entity(
2516                &tok,
2517                into.id,
2518                from.id,
2519                EntityDedupMergePolicy::PreferInto,
2520                ContentMergeStrategy::PreferFrom,
2521                false,
2522            )
2523            .await
2524            .unwrap();
2525
2526        assert!(
2527            !summary.content_appended,
2528            "explicit PreferFrom is not an append"
2529        );
2530        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2531        assert_eq!(
2532            kept.description.as_deref(),
2533            Some("desc B"),
2534            "content_strategy=prefer_from must win over the default prefer_into entity policy"
2535        );
2536    }
2537
2538    #[tokio::test]
2539    async fn merge_entity_dry_run_previews_append() {
2540        let rt = rt();
2541        let tok = NamespaceToken::local();
2542        let into = rt
2543            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2544            .await
2545            .unwrap();
2546        let from = rt
2547            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2548            .await
2549            .unwrap();
2550
2551        let summary = rt
2552            .merge_entity(
2553                &tok,
2554                into.id,
2555                from.id,
2556                EntityDedupMergePolicy::PreferInto,
2557                ContentMergeStrategy::Append,
2558                true,
2559            )
2560            .await
2561            .unwrap();
2562
2563        assert!(summary.dry_run);
2564        assert!(
2565            summary.content_appended,
2566            "dry-run must preview the append outcome without writing"
2567        );
2568        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2569        assert_eq!(
2570            kept.description.as_deref(),
2571            Some("desc A"),
2572            "dry_run=true must not mutate the into entity's description"
2573        );
2574    }
2575
2576    /// Dry-run must be a read-only, accurate preview: it must predict
2577    /// `edges_rewired` without writing, and must not append an `EntityMerged` event.
2578    #[tokio::test]
2579    async fn merge_entity_dry_run_predicts_edges_rewired_without_writing() {
2580        use khive_storage::EdgeRelation;
2581
2582        let rt = rt();
2583        let tok = NamespaceToken::local();
2584        let a = rt
2585            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2586            .await
2587            .unwrap();
2588        let into = rt
2589            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2590            .await
2591            .unwrap();
2592        let from = rt
2593            .create_entity(&tok, "concept", None, "From", None, None, vec![])
2594            .await
2595            .unwrap();
2596
2597        rt.link(&tok, a.id, from.id, EdgeRelation::Extends, 1.0, None)
2598            .await
2599            .unwrap();
2600
2601        let summary = rt
2602            .merge_entity(
2603                &tok,
2604                into.id,
2605                from.id,
2606                EntityDedupMergePolicy::PreferInto,
2607                ContentMergeStrategy::Append,
2608                true,
2609            )
2610            .await
2611            .unwrap();
2612
2613        assert!(summary.dry_run);
2614        assert_eq!(
2615            summary.edges_rewired, 1,
2616            "dry-run must predict the edge that would be rewired, not report zero"
2617        );
2618
2619        let a_neighbors = rt
2620            .neighbors(&tok, a.id, Direction::Out, None, None)
2621            .await
2622            .unwrap();
2623        assert_eq!(a_neighbors.len(), 1);
2624        assert_eq!(
2625            a_neighbors[0].node_id, from.id,
2626            "dry_run=true must not rewire any edges"
2627        );
2628
2629        let events = rt
2630            .events(&tok)
2631            .unwrap()
2632            .query_events(
2633                khive_storage::EventFilter {
2634                    kinds: vec![EventKind::EntityMerged],
2635                    ..Default::default()
2636                },
2637                khive_storage::types::PageRequest {
2638                    offset: 0,
2639                    limit: 10,
2640                },
2641            )
2642            .await
2643            .unwrap();
2644        assert!(
2645            events.items.is_empty(),
2646            "dry_run=true must not append an EntityMerged event"
2647        );
2648    }
2649
2650    // ---- merge helper unit tests ----
2651
2652    #[test]
2653    fn union_tags_deduplicates() {
2654        let (tags, added) = union_tags(
2655            &["x".to_string(), "y".to_string()],
2656            &["y".to_string(), "z".to_string()],
2657        );
2658        let mut sorted = tags.clone();
2659        sorted.sort();
2660        assert_eq!(sorted, vec!["x", "y", "z"]);
2661        assert_eq!(added, 1);
2662    }
2663
2664    #[test]
2665    fn merge_properties_prefer_into_fills_missing_keys() {
2666        let a = serde_json::json!({"a": 1});
2667        let b = serde_json::json!({"a": 99, "b": 2});
2668        let (merged, added) =
2669            merge_properties(&Some(a), &Some(b), EntityDedupMergePolicy::PreferInto);
2670        let m = merged.unwrap();
2671        assert_eq!(m["a"], 1);
2672        assert_eq!(m["b"], 2);
2673        assert_eq!(added, 1);
2674    }
2675
2676    // ---- tombstone and note merge tests ----
2677
2678    #[tokio::test]
2679    async fn merge_entity_tombstones_source_with_provenance() {
2680        let rt = rt();
2681        let tok = NamespaceToken::local();
2682        let into = rt
2683            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2684            .await
2685            .unwrap();
2686        let from = rt
2687            .create_entity(&tok, "concept", None, "From", None, None, vec![])
2688            .await
2689            .unwrap();
2690        let from_id = from.id;
2691
2692        rt.merge_entity(
2693            &tok,
2694            into.id,
2695            from_id,
2696            EntityDedupMergePolicy::PreferInto,
2697            ContentMergeStrategy::Append,
2698            false,
2699        )
2700        .await
2701        .unwrap();
2702
2703        assert!(
2704            rt.get_entity(&tok, from_id).await.is_err(),
2705            "tombstoned source should not be returned by get_entity"
2706        );
2707
2708        let pool = rt.backend().pool_arc();
2709        let (deleted_at, merged_into): (Option<i64>, Option<String>) =
2710            tokio::task::spawn_blocking(move || {
2711                let guard = pool.writer().unwrap();
2712                guard
2713                    .conn()
2714                    .query_row(
2715                        "SELECT deleted_at, merged_into FROM entities WHERE id = ?1",
2716                        [from_id.to_string()],
2717                        |row| Ok((row.get(0)?, row.get(1)?)),
2718                    )
2719                    .unwrap()
2720            })
2721            .await
2722            .unwrap();
2723        assert!(
2724            deleted_at.is_some(),
2725            "tombstoned entity must have deleted_at set"
2726        );
2727        assert_eq!(
2728            merged_into.as_deref(),
2729            Some(into.id.to_string().as_str()),
2730            "merged_into must point to into_id"
2731        );
2732    }
2733
2734    #[tokio::test]
2735    async fn merge_note_same_kind_appends_content() {
2736        let rt = rt();
2737        let tok = NamespaceToken::local();
2738        let into = rt
2739            .create_note(
2740                &tok,
2741                "observation",
2742                None,
2743                "Into content",
2744                None,
2745                None,
2746                vec![],
2747            )
2748            .await
2749            .unwrap();
2750        let from = rt
2751            .create_note(
2752                &tok,
2753                "observation",
2754                None,
2755                "From content",
2756                None,
2757                None,
2758                vec![],
2759            )
2760            .await
2761            .unwrap();
2762        let from_id = from.id;
2763
2764        let summary = rt
2765            .merge_note(
2766                &tok,
2767                into.id,
2768                from_id,
2769                EntityDedupMergePolicy::PreferInto,
2770                ContentMergeStrategy::Append,
2771                false,
2772            )
2773            .await
2774            .unwrap();
2775
2776        assert_eq!(summary.kept_id, into.id);
2777        assert_eq!(summary.removed_id, from_id);
2778        assert!(summary.content_appended);
2779        assert!(!summary.dry_run);
2780
2781        let from_store = rt.notes(&tok).unwrap();
2782        assert!(
2783            from_store.get_note(from_id).await.unwrap().is_none(),
2784            "merged-from note should be soft-deleted"
2785        );
2786    }
2787
2788    // Note merge must absorb a conflicting edge natural key exactly like entity
2789    // merge does, since both route through the shared EDGE_SYMMETRIC_*_SQL arms.
2790    #[tokio::test]
2791    async fn merge_note_survives_shared_edge_to_third_party() {
2792        use khive_storage::EdgeRelation;
2793        let rt = rt();
2794        let tok = NamespaceToken::local();
2795
2796        let into = rt
2797            .create_note(&tok, "observation", None, "Into", None, None, vec![])
2798            .await
2799            .unwrap();
2800        let from = rt
2801            .create_note(&tok, "observation", None, "From", None, None, vec![])
2802            .await
2803            .unwrap();
2804        let shared = rt
2805            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
2806            .await
2807            .unwrap();
2808
2809        // Both into and from annotate the same shared entity — rewiring from's
2810        // edge onto into during merge produces a duplicate (into, shared,
2811        // annotates) triple, exercising the conflict-probe/delete/refresh arms.
2812        rt.link(&tok, into.id, shared.id, EdgeRelation::Annotates, 1.0, None)
2813            .await
2814            .unwrap();
2815        rt.link(&tok, from.id, shared.id, EdgeRelation::Annotates, 1.0, None)
2816            .await
2817            .unwrap();
2818
2819        let summary = rt
2820            .merge_note(
2821                &tok,
2822                into.id,
2823                from.id,
2824                EntityDedupMergePolicy::PreferInto,
2825                ContentMergeStrategy::Append,
2826                false,
2827            )
2828            .await
2829            .expect("merge must succeed even when both notes annotate the same entity");
2830
2831        assert_eq!(summary.kept_id, into.id);
2832        assert_eq!(summary.removed_id, from.id);
2833
2834        let into_edges = rt
2835            .list_edges(
2836                &tok,
2837                crate::EdgeListFilter {
2838                    source_id: Some(into.id),
2839                    target_id: Some(shared.id),
2840                    relations: vec![EdgeRelation::Annotates],
2841                    ..Default::default()
2842                },
2843                10,
2844                0,
2845            )
2846            .await
2847            .unwrap();
2848        assert_eq!(
2849            into_edges.len(),
2850            1,
2851            "exactly one live into→shared annotates edge must exist after merge; got: {into_edges:?}"
2852        );
2853    }
2854
2855    #[tokio::test]
2856    async fn merge_note_different_kinds_rejected() {
2857        let rt = rt();
2858        let tok = NamespaceToken::local();
2859        let into = rt
2860            .create_note(&tok, "observation", None, "Into", None, None, vec![])
2861            .await
2862            .unwrap();
2863        let from = rt
2864            .create_note(&tok, "decision", None, "From", None, None, vec![])
2865            .await
2866            .unwrap();
2867
2868        let result = rt
2869            .merge_note(
2870                &tok,
2871                into.id,
2872                from.id,
2873                EntityDedupMergePolicy::PreferInto,
2874                ContentMergeStrategy::Append,
2875                false,
2876            )
2877            .await;
2878        assert!(result.is_err(), "merging different note kinds must fail");
2879    }
2880
2881    #[tokio::test]
2882    async fn merge_note_dry_run_leaves_notes_unchanged() {
2883        let rt = rt();
2884        let tok = NamespaceToken::local();
2885        let into = rt
2886            .create_note(
2887                &tok,
2888                "observation",
2889                None,
2890                "Into content",
2891                None,
2892                None,
2893                vec![],
2894            )
2895            .await
2896            .unwrap();
2897        let from = rt
2898            .create_note(
2899                &tok,
2900                "observation",
2901                None,
2902                "From content",
2903                None,
2904                None,
2905                vec![],
2906            )
2907            .await
2908            .unwrap();
2909        let into_id = into.id;
2910        let from_id = from.id;
2911
2912        let summary = rt
2913            .merge_note(
2914                &tok,
2915                into_id,
2916                from_id,
2917                EntityDedupMergePolicy::PreferInto,
2918                ContentMergeStrategy::Append,
2919                true,
2920            )
2921            .await
2922            .unwrap();
2923
2924        assert!(summary.dry_run);
2925
2926        let store = rt.notes(&tok).unwrap();
2927        let into_after = store.get_note(into_id).await.unwrap().unwrap();
2928        let from_after = store.get_note(from_id).await.unwrap().unwrap();
2929        assert_eq!(
2930            into_after.content, "Into content",
2931            "dry_run must not mutate into-note"
2932        );
2933        assert_eq!(
2934            from_after.content, "From content",
2935            "dry_run must not mutate from-note"
2936        );
2937    }
2938
2939    // Merging two nameless notes with no embedding model configured: a raw SQL FTS
2940    // INSERT binding &merged_name directly would store SQL NULL for a nameless
2941    // note, while Fts5TextSearch::upsert_document stores an empty string:
2942    // note_fts_scalars must keep the round-trip field-identical.
2943    #[tokio::test]
2944    async fn merge_nameless_notes_fts_document_is_parity_correct() {
2945        use khive_storage::types::TextSearchRequest;
2946
2947        let rt = rt(); // in-memory runtime — no embedding model configured
2948        let tok = NamespaceToken::local();
2949
2950        let into = rt
2951            .create_note(
2952                &tok,
2953                "observation",
2954                None,
2955                "intosentinelzxq body",
2956                None,
2957                Some(serde_json::json!({"src": "into"})),
2958                vec![],
2959            )
2960            .await
2961            .expect("create into-note");
2962        let from = rt
2963            .create_note(
2964                &tok,
2965                "observation",
2966                None,
2967                "fromsentinelzxq body",
2968                None,
2969                None,
2970                vec![],
2971            )
2972            .await
2973            .expect("create from-note");
2974
2975        let into_id = into.id;
2976        let from_id = from.id;
2977
2978        rt.merge_note(
2979            &tok,
2980            into_id,
2981            from_id,
2982            EntityDedupMergePolicy::PreferInto,
2983            ContentMergeStrategy::Append,
2984            false,
2985        )
2986        .await
2987        .expect("merge_note must succeed");
2988
2989        let note_store = rt.notes(&tok).expect("note store");
2990        let merged_note = note_store
2991            .get_note(into_id)
2992            .await
2993            .expect("get_note")
2994            .expect("merged note must exist");
2995
2996        let expected = note_fts_document(&merged_note);
2997
2998        let fts = rt.text_for_notes(&tok).expect("FTS store");
2999        let stored = fts
3000            .get_document("local", into_id)
3001            .await
3002            .expect("get_document must not error")
3003            .expect("FTS document must exist after merge");
3004
3005        assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
3006        assert_eq!(
3007            stored.title, expected.title,
3008            "title (None for nameless note)"
3009        );
3010        assert_eq!(stored.body, expected.body, "body");
3011        assert_eq!(stored.namespace, expected.namespace, "namespace");
3012        assert_eq!(stored.kind, expected.kind, "kind");
3013
3014        assert!(
3015            stored.title.is_none(),
3016            "nameless merged note must have title=None in FTS (was NULL before fix)"
3017        );
3018
3019        // The merged note must be searchable by a unique token from the into-note body.
3020        let hits = fts
3021            .search(TextSearchRequest {
3022                query: "intosentinelzxq".to_string(),
3023                mode: khive_storage::types::TextQueryMode::Plain,
3024                filter: None,
3025                top_k: 10,
3026                snippet_chars: 0,
3027            })
3028            .await
3029            .expect("search");
3030        assert!(
3031            hits.iter().any(|h| h.subject_id == into_id),
3032            "merged note must be searchable by into-note content"
3033        );
3034    }
3035
3036    #[tokio::test]
3037    async fn update_edge_updates_properties() {
3038        use khive_storage::EdgeRelation;
3039        let rt = rt();
3040        let tok = NamespaceToken::local();
3041        let a = rt
3042            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3043            .await
3044            .unwrap();
3045        let b = rt
3046            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3047            .await
3048            .unwrap();
3049        let edge = rt
3050            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
3051            .await
3052            .unwrap();
3053        let edge_id: Uuid = edge.id.into();
3054
3055        let updated = rt
3056            .update_edge(
3057                &tok,
3058                edge_id,
3059                EdgePatch {
3060                    properties: Some(serde_json::json!({"source": "manual"})),
3061                    ..Default::default()
3062                },
3063            )
3064            .await
3065            .unwrap();
3066
3067        assert_eq!(updated.metadata.as_ref().unwrap()["source"], "manual");
3068        assert!((updated.weight - 0.5).abs() < 0.001, "weight unchanged");
3069    }
3070
3071    // Merge must not crash when both entities share a common third-party edge
3072    // (duplicate triple after rewire): a double-ON-CONFLICT INSERT would
3073    // otherwise raise a UNIQUE constraint error and abort mid-transaction.
3074    #[tokio::test]
3075    async fn merge_entity_survives_shared_edge_to_third_party() {
3076        use khive_storage::EdgeRelation;
3077        let rt = rt();
3078        let tok = NamespaceToken::local();
3079
3080        // A and B will be merged; shared is the common target. `extends` is used
3081        // since concept→concept is a valid endpoint combination.
3082        let a = rt
3083            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3084            .await
3085            .unwrap();
3086        let b = rt
3087            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3088            .await
3089            .unwrap();
3090        let shared = rt
3091            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
3092            .await
3093            .unwrap();
3094
3095        // Both A and B extend the same shared concept — this creates a duplicate
3096        // triple (A/B → shared, extends) that triggers the crash on rewire.
3097        rt.link(&tok, a.id, shared.id, EdgeRelation::Extends, 1.0, None)
3098            .await
3099            .unwrap();
3100        rt.link(&tok, b.id, shared.id, EdgeRelation::Extends, 1.0, None)
3101            .await
3102            .unwrap();
3103
3104        let summary = rt
3105            .merge_entity(
3106                &tok,
3107                a.id,
3108                b.id,
3109                crate::EntityDedupMergePolicy::PreferInto,
3110                ContentMergeStrategy::Append,
3111                false,
3112            )
3113            .await
3114            .expect(
3115                "C1: merge must succeed even when both entities share an edge to a third party",
3116            );
3117
3118        assert_eq!(summary.kept_id, a.id);
3119        assert_eq!(summary.removed_id, b.id);
3120        // A already had the Extends edge to shared; rewiring B->shared onto it
3121        // refreshes the existing row via ON CONFLICT DO UPDATE rather than
3122        // erroring. The invariant checked below is that exactly one live edge
3123        // A->shared remains.
3124        let a_edges = rt
3125            .list_edges(
3126                &tok,
3127                crate::EdgeListFilter {
3128                    source_id: Some(a.id),
3129                    target_id: Some(shared.id),
3130                    relations: vec![EdgeRelation::Extends],
3131                    ..Default::default()
3132                },
3133                10,
3134                0,
3135            )
3136            .await
3137            .unwrap();
3138        assert_eq!(
3139            a_edges.len(),
3140            1,
3141            "C1: exactly one live A→shared Extends edge must exist after merge; got: {a_edges:?}"
3142        );
3143
3144        // get_entity filters deleted_at IS NULL, so a tombstoned entity returns None.
3145        let b_after = rt.entities(&tok).unwrap().get_entity(b.id).await.unwrap();
3146        assert!(
3147            b_after.is_none(),
3148            "C3: from_entity must be tombstoned (get_entity returns None for deleted) after merge; got: {b_after:?}"
3149        );
3150    }
3151
3152    // merge_entity at the runtime level must reject cross-kind merges: without this
3153    // guard, a direct runtime caller could merge concept+project, silently
3154    // tombstoning the source entity, even though the pack handler also checks it.
3155    #[tokio::test]
3156    async fn merge_entity_cross_kind_rejected_at_runtime() {
3157        let rt = rt();
3158        let tok = NamespaceToken::local();
3159
3160        let concept = rt
3161            .create_entity(&tok, "concept", None, "H2Concept", None, None, vec![])
3162            .await
3163            .unwrap();
3164        let project = rt
3165            .create_entity(&tok, "project", None, "H2Project", None, None, vec![])
3166            .await
3167            .unwrap();
3168
3169        let err = rt
3170            .merge_entity(
3171                &tok,
3172                concept.id,
3173                project.id,
3174                crate::EntityDedupMergePolicy::PreferInto,
3175                ContentMergeStrategy::Append,
3176                false,
3177            )
3178            .await
3179            .expect_err("H2: cross-kind merge must be rejected by runtime");
3180        assert!(
3181            matches!(err, crate::RuntimeError::InvalidInput(_)),
3182            "H2: expected InvalidInput, got: {err:?}"
3183        );
3184
3185        let concept_after = rt.get_entity(&tok, concept.id).await;
3186        let project_after = rt.get_entity(&tok, project.id).await;
3187        assert!(
3188            concept_after.is_ok(),
3189            "H2: concept must remain live after rejected merge; got: {concept_after:?}"
3190        );
3191        assert!(
3192            project_after.is_ok(),
3193            "H2: project must remain live after rejected merge; got: {project_after:?}"
3194        );
3195    }
3196
3197    // Same-kind merge must succeed.
3198    #[tokio::test]
3199    async fn merge_entity_same_kind_succeeds() {
3200        let rt = rt();
3201        let tok = NamespaceToken::local();
3202
3203        let c1 = rt
3204            .create_entity(&tok, "concept", None, "Concept1", None, None, vec![])
3205            .await
3206            .unwrap();
3207        let c2 = rt
3208            .create_entity(&tok, "concept", None, "Concept2", None, None, vec![])
3209            .await
3210            .unwrap();
3211
3212        let summary = rt
3213            .merge_entity(
3214                &tok,
3215                c1.id,
3216                c2.id,
3217                crate::EntityDedupMergePolicy::PreferInto,
3218                ContentMergeStrategy::Append,
3219                false,
3220            )
3221            .await
3222            .expect("same-kind merge must succeed");
3223        assert_eq!(summary.kept_id, c1.id);
3224        assert_eq!(summary.removed_id, c2.id);
3225
3226        let c2_after = rt.entities(&tok).unwrap().get_entity(c2.id).await.unwrap();
3227        assert!(c2_after.is_none(), "from_entity must be tombstoned");
3228    }
3229
3230    // Cross-namespace merge_note must be denied on either ID.
3231
3232    #[tokio::test]
3233    async fn merge_note_cross_namespace_either_id_returns_not_found() {
3234        use crate::error::RuntimeError;
3235        use crate::Namespace;
3236
3237        let rt = rt();
3238        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3239        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3240
3241        let into_a = rt
3242            .create_note(&ns_a, "observation", None, "Into A", None, None, vec![])
3243            .await
3244            .unwrap();
3245        let from_a = rt
3246            .create_note(&ns_a, "observation", None, "From A", None, None, vec![])
3247            .await
3248            .unwrap();
3249        let note_b = rt
3250            .create_note(&ns_b, "observation", None, "Note B", None, None, vec![])
3251            .await
3252            .unwrap();
3253
3254        // foreign into_id: note_b belongs to ns_b, caller token is ns_a
3255        let foreign_into = rt
3256            .merge_note(
3257                &ns_a,
3258                note_b.id,
3259                from_a.id,
3260                EntityDedupMergePolicy::PreferInto,
3261                ContentMergeStrategy::Append,
3262                false,
3263            )
3264            .await;
3265        assert!(
3266            matches!(foreign_into, Err(RuntimeError::NotFound(_))),
3267            "foreign into_id must be denied before merge, got {foreign_into:?}"
3268        );
3269
3270        // foreign from_id: note_b belongs to ns_b, caller token is ns_a
3271        let foreign_from = rt
3272            .merge_note(
3273                &ns_a,
3274                into_a.id,
3275                note_b.id,
3276                EntityDedupMergePolicy::PreferInto,
3277                ContentMergeStrategy::Append,
3278                false,
3279            )
3280            .await;
3281        assert!(
3282            matches!(foreign_from, Err(RuntimeError::NotFound(_))),
3283            "foreign from_id must be denied before merge, got {foreign_from:?}"
3284        );
3285    }
3286
3287    // Cross-namespace update now succeeds (shared-brain model).
3288
3289    #[tokio::test]
3290    async fn update_entity_cross_namespace_succeeds() {
3291        use crate::Namespace;
3292
3293        let rt = rt();
3294        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3295        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3296
3297        let entity = rt
3298            .create_entity(
3299                &ns_a,
3300                "concept",
3301                None,
3302                "Alpha",
3303                Some("original"),
3304                None,
3305                vec![],
3306            )
3307            .await
3308            .unwrap();
3309
3310        let result = rt
3311            .update_entity(
3312                &ns_b,
3313                entity.id,
3314                EntityPatch {
3315                    name: Some("Updated".into()),
3316                    ..Default::default()
3317                },
3318            )
3319            .await;
3320
3321        assert!(
3322            result.is_ok(),
3323            "cross-namespace update must succeed in shared-brain OSS; got {result:?}"
3324        );
3325        assert_eq!(result.unwrap().name, "Updated");
3326    }
3327
3328    // merge_entity still requires both entities to be in the same namespace as
3329    // the token's write namespace (enforced at the SQL transaction layer, not the
3330    // runtime layer).  This is a merge-semantic constraint, not tenant isolation.
3331    #[tokio::test]
3332    async fn merge_entity_cross_namespace_ids_fail_at_sql_layer() {
3333        use crate::Namespace;
3334
3335        let rt = rt();
3336        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3337        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3338
3339        let into_a = rt
3340            .create_entity(&ns_a, "concept", None, "Into A", None, None, vec![])
3341            .await
3342            .unwrap();
3343        let from_a = rt
3344            .create_entity(&ns_a, "concept", None, "From A", None, None, vec![])
3345            .await
3346            .unwrap();
3347        let foreign_b = rt
3348            .create_entity(&ns_b, "concept", None, "Foreign B", None, None, vec![])
3349            .await
3350            .unwrap();
3351
3352        // foreign into_id: SQL read_merge_entity checks ns matches token namespace.
3353        let foreign_into = rt
3354            .merge_entity(
3355                &ns_a,
3356                foreign_b.id,
3357                from_a.id,
3358                EntityDedupMergePolicy::PreferInto,
3359                ContentMergeStrategy::Append,
3360                false,
3361            )
3362            .await;
3363        assert!(
3364            foreign_into.is_err(),
3365            "cross-namespace into_id must still fail at SQL layer; got {foreign_into:?}"
3366        );
3367
3368        // foreign from_id: same SQL constraint.
3369        let foreign_from = rt
3370            .merge_entity(
3371                &ns_a,
3372                into_a.id,
3373                foreign_b.id,
3374                EntityDedupMergePolicy::PreferInto,
3375                ContentMergeStrategy::Append,
3376                false,
3377            )
3378            .await;
3379        assert!(
3380            foreign_from.is_err(),
3381            "cross-namespace from_id must still fail at SQL layer; got {foreign_from:?}"
3382        );
3383
3384        // All three entities survive the failed merges.
3385        assert!(rt.get_entity(&ns_a, into_a.id).await.is_ok());
3386        assert!(rt.get_entity(&ns_a, from_a.id).await.is_ok());
3387        assert!(rt.get_entity(&ns_b, foreign_b.id).await.is_ok());
3388    }
3389
3390    // Parity: entity_fts_document must produce the same body/title as the
3391    // create_entity and update_entity FTS write paths.
3392    #[test]
3393    fn entity_fts_document_with_description() {
3394        let mut entity = Entity::new("local", "concept", "MyEntity");
3395        entity = entity.with_description("some description text");
3396        let doc = entity_fts_document(&entity);
3397        assert_eq!(doc.subject_id, entity.id);
3398        assert_eq!(doc.namespace, "local");
3399        assert_eq!(doc.title.as_deref(), Some("MyEntity"));
3400        assert_eq!(doc.body, "MyEntity some description text");
3401        assert_eq!(doc.kind, khive_types::SubstrateKind::Entity);
3402    }
3403
3404    #[test]
3405    fn entity_fts_document_without_description() {
3406        let entity = Entity::new("local", "concept", "NameOnly");
3407        let doc = entity_fts_document(&entity);
3408        assert_eq!(doc.title.as_deref(), Some("NameOnly"));
3409        assert_eq!(doc.body, "NameOnly");
3410    }
3411
3412    #[test]
3413    fn entity_fts_document_empty_description_uses_name_only() {
3414        let mut entity = Entity::new("local", "concept", "TitleOnly");
3415        entity = entity.with_description("");
3416        let doc = entity_fts_document(&entity);
3417        assert_eq!(
3418            doc.body, "TitleOnly",
3419            "empty description must not be appended"
3420        );
3421    }
3422
3423    // Cross-path equality: an entity created through the runtime (operations.rs
3424    // create_entity path) must produce a stored FTS document field-identical to
3425    // entity_fts_document() called on the same Entity.
3426    #[tokio::test]
3427    async fn entity_fts_document_matches_runtime_create_path() {
3428        let rt = rt();
3429        let tok = NamespaceToken::local();
3430
3431        let entity = rt
3432            .create_entity(
3433                &tok,
3434                "concept",
3435                None,
3436                "CrossPathTitle",
3437                Some("cross path description body"),
3438                Some(serde_json::json!({"key": "val"})),
3439                vec!["tag1".to_string()],
3440            )
3441            .await
3442            .expect("create_entity");
3443
3444        let fts = rt.text(&tok).expect("FTS store");
3445        let stored = fts
3446            .get_document("local", entity.id)
3447            .await
3448            .expect("get_document")
3449            .expect("document must exist after create_entity");
3450
3451        let expected = entity_fts_document(&entity);
3452
3453        assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
3454        assert_eq!(stored.kind, expected.kind, "kind");
3455        assert_eq!(stored.title, expected.title, "title");
3456        assert_eq!(stored.body, expected.body, "body");
3457        assert_eq!(stored.namespace, expected.namespace, "namespace");
3458    }
3459
3460    // Cross-path equality: update_entity must produce a stored FTS document
3461    // field-identical to entity_fts_document() on the updated Entity.
3462    #[tokio::test]
3463    async fn entity_fts_document_matches_runtime_update_path() {
3464        let rt = rt();
3465        let tok = NamespaceToken::local();
3466
3467        let entity = rt
3468            .create_entity(
3469                &tok,
3470                "concept",
3471                None,
3472                "OldName",
3473                Some("old desc"),
3474                None,
3475                vec![],
3476            )
3477            .await
3478            .expect("create_entity");
3479
3480        let updated = rt
3481            .update_entity(
3482                &tok,
3483                entity.id,
3484                EntityPatch {
3485                    name: Some("NewName".to_string()),
3486                    description: Some(Some("new desc".to_string())),
3487                    ..Default::default()
3488                },
3489            )
3490            .await
3491            .expect("update_entity");
3492
3493        let fts = rt.text(&tok).expect("FTS store");
3494        let stored = fts
3495            .get_document("local", updated.id)
3496            .await
3497            .expect("get_document")
3498            .expect("document must exist after update_entity");
3499
3500        let expected = entity_fts_document(&updated);
3501
3502        assert_eq!(stored.title, expected.title, "title after update");
3503        assert_eq!(stored.body, expected.body, "body after update");
3504    }
3505
3506    // Verify that merge_entity / merge_note delete from_id vectors from ALL
3507    // registered model vec tables, not just the default-model table. Uses the
3508    // same ConstVecProvider/ConstVecService pattern as operations.rs so no
3509    // real model files are required.
3510
3511    struct MergeTestVecService {
3512        dims: usize,
3513    }
3514
3515    #[async_trait::async_trait]
3516    impl lattice_embed::EmbeddingService for MergeTestVecService {
3517        async fn embed(
3518            &self,
3519            texts: &[String],
3520            _model: lattice_embed::EmbeddingModel,
3521        ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
3522            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
3523        }
3524
3525        fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
3526            true
3527        }
3528
3529        fn name(&self) -> &'static str {
3530            "merge-test-const-vec"
3531        }
3532    }
3533
3534    struct MergeTestVecProvider {
3535        provider_name: String,
3536        dims: usize,
3537    }
3538
3539    impl MergeTestVecProvider {
3540        fn new(name: &str, dims: usize) -> Self {
3541            Self {
3542                provider_name: name.to_owned(),
3543                dims,
3544            }
3545        }
3546    }
3547
3548    #[async_trait::async_trait]
3549    impl crate::embedder_registry::EmbedderProvider for MergeTestVecProvider {
3550        fn name(&self) -> &str {
3551            &self.provider_name
3552        }
3553
3554        fn dimensions(&self) -> usize {
3555            self.dims
3556        }
3557
3558        async fn build(
3559            &self,
3560        ) -> crate::error::RuntimeResult<std::sync::Arc<dyn lattice_embed::EmbeddingService>>
3561        {
3562            Ok(std::sync::Arc::new(MergeTestVecService { dims: self.dims }))
3563        }
3564    }
3565
3566    /// merge_entity must delete from_id vectors from ALL registered model tables.
3567    ///
3568    /// Two custom embedders ("merge-vec-a", "merge-vec-b") are registered.  Both
3569    /// entities are embedded so each has a row in both model tables.  After merge,
3570    /// from_id must have zero surviving rows in either table.
3571    #[tokio::test]
3572    async fn merge_entity_clears_vectors_from_all_registered_models() {
3573        const DIMS: usize = 4;
3574        let rt = KhiveRuntime::memory().unwrap();
3575        rt.register_embedder(MergeTestVecProvider::new("merge-vec-a", DIMS));
3576        rt.register_embedder(MergeTestVecProvider::new("merge-vec-b", DIMS));
3577
3578        let ns_str = "merge-entity-vec-cleanup";
3579        let ns = crate::Namespace::parse(ns_str).unwrap();
3580        let tok = NamespaceToken::for_namespace(ns);
3581
3582        let into_e = rt
3583            .create_entity(
3584                &tok,
3585                "concept",
3586                None,
3587                "IntoVecEntity",
3588                Some("desc a"),
3589                None,
3590                vec![],
3591            )
3592            .await
3593            .expect("create into");
3594        let from_e = rt
3595            .create_entity(
3596                &tok,
3597                "concept",
3598                None,
3599                "FromVecEntity",
3600                Some("desc b"),
3601                None,
3602                vec![],
3603            )
3604            .await
3605            .expect("create from");
3606
3607        // Confirm both entities have vectors in both model tables before merge.
3608        let vs_a = rt.vectors_for_model(&tok, "merge-vec-a").unwrap();
3609        let vs_b = rt.vectors_for_model(&tok, "merge-vec-b").unwrap();
3610        use khive_storage::types::VectorSearchRequest;
3611        let query = vec![1.0_f32; DIMS];
3612        let pre_a = vs_a
3613            .search(VectorSearchRequest {
3614                query_vectors: vec![query.clone()],
3615                top_k: 100,
3616                namespace: Some(ns_str.to_string()),
3617                kind: Some(khive_types::SubstrateKind::Entity),
3618                embedding_model: Some("merge-vec-a".to_string()),
3619                filter: None,
3620                backend_hints: None,
3621            })
3622            .await
3623            .unwrap();
3624        assert!(
3625            pre_a.iter().any(|h| h.subject_id == into_e.id)
3626                && pre_a.iter().any(|h| h.subject_id == from_e.id),
3627            "both entities must be in model-a before merge; got {pre_a:?}"
3628        );
3629
3630        // model-b must ALSO hold both entities pre-merge, else the post-merge
3631        // model-b emptiness check below is vacuous (nothing to delete).
3632        let pre_b = vs_b
3633            .search(VectorSearchRequest {
3634                query_vectors: vec![query.clone()],
3635                top_k: 100,
3636                namespace: Some(ns_str.to_string()),
3637                kind: Some(khive_types::SubstrateKind::Entity),
3638                embedding_model: Some("merge-vec-b".to_string()),
3639                filter: None,
3640                backend_hints: None,
3641            })
3642            .await
3643            .unwrap();
3644        assert!(
3645            pre_b.iter().any(|h| h.subject_id == into_e.id)
3646                && pre_b.iter().any(|h| h.subject_id == from_e.id),
3647            "both entities must be in model-b before merge; got {pre_b:?}"
3648        );
3649
3650        rt.merge_entity(
3651            &tok,
3652            into_e.id,
3653            from_e.id,
3654            EntityDedupMergePolicy::PreferInto,
3655            ContentMergeStrategy::Append,
3656            false,
3657        )
3658        .await
3659        .expect("merge_entity");
3660
3661        let post_a = vs_a
3662            .search(VectorSearchRequest {
3663                query_vectors: vec![query.clone()],
3664                top_k: 100,
3665                namespace: Some(ns_str.to_string()),
3666                kind: Some(khive_types::SubstrateKind::Entity),
3667                embedding_model: Some("merge-vec-a".to_string()),
3668                filter: None,
3669                backend_hints: None,
3670            })
3671            .await
3672            .unwrap();
3673        let from_ids_a: Vec<_> = post_a
3674            .iter()
3675            .filter(|h| h.subject_id == from_e.id)
3676            .collect();
3677        assert!(
3678            from_ids_a.is_empty(),
3679            "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
3680        );
3681
3682        let post_b = vs_b
3683            .search(VectorSearchRequest {
3684                query_vectors: vec![query],
3685                top_k: 100,
3686                namespace: Some(ns_str.to_string()),
3687                kind: Some(khive_types::SubstrateKind::Entity),
3688                embedding_model: Some("merge-vec-b".to_string()),
3689                filter: None,
3690                backend_hints: None,
3691            })
3692            .await
3693            .unwrap();
3694        let from_ids_b: Vec<_> = post_b
3695            .iter()
3696            .filter(|h| h.subject_id == from_e.id)
3697            .collect();
3698        assert!(
3699            from_ids_b.is_empty(),
3700            "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
3701        );
3702    }
3703
3704    /// merge_note must delete from_id vectors from ALL registered model tables.
3705    ///
3706    /// Two custom embedders ("merge-note-vec-a", "merge-note-vec-b") are registered.
3707    /// Both notes are embedded so each has a row in both model tables.  After merge,
3708    /// from_id must have zero surviving rows in either table.
3709    #[tokio::test]
3710    async fn merge_note_clears_vectors_from_all_registered_models() {
3711        const DIMS: usize = 4;
3712        let rt = KhiveRuntime::memory().unwrap();
3713        rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-a", DIMS));
3714        rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-b", DIMS));
3715
3716        let ns_str = "merge-note-vec-cleanup";
3717        let ns = crate::Namespace::parse(ns_str).unwrap();
3718        let tok = NamespaceToken::for_namespace(ns);
3719
3720        let into_n = rt
3721            .create_note(
3722                &tok,
3723                "observation",
3724                None,
3725                "IntoVecNote content",
3726                None,
3727                None,
3728                vec![],
3729            )
3730            .await
3731            .expect("create into note");
3732        let from_n = rt
3733            .create_note(
3734                &tok,
3735                "observation",
3736                None,
3737                "FromVecNote content",
3738                None,
3739                None,
3740                vec![],
3741            )
3742            .await
3743            .expect("create from note");
3744
3745        let vs_a = rt.vectors_for_model(&tok, "merge-note-vec-a").unwrap();
3746        let vs_b = rt.vectors_for_model(&tok, "merge-note-vec-b").unwrap();
3747        use khive_storage::types::VectorSearchRequest;
3748        let query = vec![1.0_f32; DIMS];
3749
3750        let pre_a = vs_a
3751            .search(VectorSearchRequest {
3752                query_vectors: vec![query.clone()],
3753                top_k: 100,
3754                namespace: Some(ns_str.to_string()),
3755                kind: Some(khive_types::SubstrateKind::Note),
3756                embedding_model: Some("merge-note-vec-a".to_string()),
3757                filter: None,
3758                backend_hints: None,
3759            })
3760            .await
3761            .unwrap();
3762        assert!(
3763            pre_a.iter().any(|h| h.subject_id == into_n.id)
3764                && pre_a.iter().any(|h| h.subject_id == from_n.id),
3765            "both notes must be in model-a before merge; got {pre_a:?}"
3766        );
3767
3768        // model-b must ALSO hold both notes pre-merge, else the post-merge
3769        // model-b emptiness check below is vacuous (nothing to delete).
3770        let pre_b = vs_b
3771            .search(VectorSearchRequest {
3772                query_vectors: vec![query.clone()],
3773                top_k: 100,
3774                namespace: Some(ns_str.to_string()),
3775                kind: Some(khive_types::SubstrateKind::Note),
3776                embedding_model: Some("merge-note-vec-b".to_string()),
3777                filter: None,
3778                backend_hints: None,
3779            })
3780            .await
3781            .unwrap();
3782        assert!(
3783            pre_b.iter().any(|h| h.subject_id == into_n.id)
3784                && pre_b.iter().any(|h| h.subject_id == from_n.id),
3785            "both notes must be in model-b before merge; got {pre_b:?}"
3786        );
3787
3788        rt.merge_note(
3789            &tok,
3790            into_n.id,
3791            from_n.id,
3792            EntityDedupMergePolicy::PreferInto,
3793            ContentMergeStrategy::PreferInto,
3794            false,
3795        )
3796        .await
3797        .expect("merge_note");
3798
3799        let post_a = vs_a
3800            .search(VectorSearchRequest {
3801                query_vectors: vec![query.clone()],
3802                top_k: 100,
3803                namespace: Some(ns_str.to_string()),
3804                kind: Some(khive_types::SubstrateKind::Note),
3805                embedding_model: Some("merge-note-vec-a".to_string()),
3806                filter: None,
3807                backend_hints: None,
3808            })
3809            .await
3810            .unwrap();
3811        let from_ids_a: Vec<_> = post_a
3812            .iter()
3813            .filter(|h| h.subject_id == from_n.id)
3814            .collect();
3815        assert!(
3816            from_ids_a.is_empty(),
3817            "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
3818        );
3819
3820        let post_b = vs_b
3821            .search(VectorSearchRequest {
3822                query_vectors: vec![query],
3823                top_k: 100,
3824                namespace: Some(ns_str.to_string()),
3825                kind: Some(khive_types::SubstrateKind::Note),
3826                embedding_model: Some("merge-note-vec-b".to_string()),
3827                filter: None,
3828                backend_hints: None,
3829            })
3830            .await
3831            .unwrap();
3832        let from_ids_b: Vec<_> = post_b
3833            .iter()
3834            .filter(|h| h.subject_id == from_n.id)
3835            .collect();
3836        assert!(
3837            from_ids_b.is_empty(),
3838            "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
3839        );
3840    }
3841
3842    // Cross-path equality: merge_entity must produce a stored FTS document for
3843    // the kept entity that is field-identical to entity_fts_document().
3844    #[tokio::test]
3845    async fn entity_fts_document_matches_runtime_merge_path() {
3846        let rt = rt();
3847        let tok = NamespaceToken::local();
3848
3849        let into_e = rt
3850            .create_entity(
3851                &tok,
3852                "concept",
3853                None,
3854                "IntoEntity",
3855                Some("into desc"),
3856                None,
3857                vec![],
3858            )
3859            .await
3860            .expect("create into");
3861        let from_e = rt
3862            .create_entity(
3863                &tok,
3864                "concept",
3865                None,
3866                "FromEntity",
3867                Some("from desc"),
3868                None,
3869                vec![],
3870            )
3871            .await
3872            .expect("create from");
3873
3874        let summary = rt
3875            .merge_entity(
3876                &tok,
3877                into_e.id,
3878                from_e.id,
3879                EntityDedupMergePolicy::PreferInto,
3880                ContentMergeStrategy::Append,
3881                false,
3882            )
3883            .await
3884            .expect("merge_entity");
3885
3886        let kept = rt
3887            .get_entity(&tok, summary.kept_id)
3888            .await
3889            .expect("get kept");
3890
3891        let fts = rt.text(&tok).expect("FTS store");
3892        let stored = fts
3893            .get_document("local", kept.id)
3894            .await
3895            .expect("get_document")
3896            .expect("FTS document must exist for kept entity after merge");
3897
3898        let expected = entity_fts_document(&kept);
3899
3900        assert_eq!(stored.title, expected.title, "title after merge");
3901        assert_eq!(stored.body, expected.body, "body after merge");
3902    }
3903}