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 \
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
926    if ns != namespace {
927        return Err(SqliteError::InvalidData(format!(
928            "entity {id} belongs to namespace '{ns}', not '{namespace}'"
929        )));
930    }
931
932    let entity_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
933    let properties: Option<Value> = properties_str
934        .map(|s| {
935            serde_json::from_str::<Value>(&s).map_err(|e| SqliteError::InvalidData(e.to_string()))
936        })
937        .transpose()?;
938    let tags: Vec<String> =
939        serde_json::from_str(&tags_str).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
940    let merged_into = merged_into_str
941        .as_deref()
942        .map(Uuid::parse_str)
943        .transpose()
944        .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
945    let merge_event_id = merge_event_id_str
946        .as_deref()
947        .map(Uuid::parse_str)
948        .transpose()
949        .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
950
951    Ok(Entity {
952        id: entity_id,
953        namespace: ns,
954        kind,
955        entity_type,
956        name,
957        description,
958        properties,
959        tags,
960        created_at,
961        updated_at,
962        deleted_at,
963        merged_into,
964        merge_event_id,
965    })
966}
967
968/// All merge SQL on one connection inside an already-open `BEGIN IMMEDIATE` transaction.
969///
970/// Reads both entities, rewires/drops incident edges, merges entity fields, updates FTS,
971/// deletes the `from` vec entry (if `vec_table` is Some), and tombstones `from` with merge
972/// provenance.  Returns the updated `into` entity so the caller can do the async vec re-insert.
973///
974/// When `dry_run` is true, all reads and computations are performed but no writes are issued.
975// REASON: merge requires both entity IDs, the namespace, FTS and vec table names, merge
976// policy, and dry-run flag — all are load-bearing; reducing to a struct would obscure
977// the sync/async boundary split that keeps this function off the async runtime.
978#[allow(clippy::too_many_arguments)]
979fn merge_entity_sql(
980    conn: &rusqlite::Connection,
981    namespace: String,
982    fts_table: String,
983    vec_tables: Vec<String>,
984    into_id: Uuid,
985    from_id: Uuid,
986    strategy: EntityDedupMergePolicy,
987    content_strategy: ContentMergeStrategy,
988    dry_run: bool,
989) -> Result<(MergeSummary, Entity), SqliteError> {
990    let into_entity = read_merge_entity(conn, into_id, &namespace)?;
991    let from_entity = read_merge_entity(conn, from_id, &namespace)?;
992
993    // --- Collect edges incident to from_id ---
994    let parse_id =
995        |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
996
997    let from_str = from_id.to_string();
998
999    let mut outbound: Vec<EdgeRow> = Vec::new();
1000    {
1001        let mut stmt = conn.prepare(
1002            "SELECT id, source_id, target_id, relation, weight, created_at, \
1003                    updated_at, deleted_at, target_backend, metadata \
1004             FROM graph_edges WHERE namespace = ?1 AND source_id = ?2",
1005        )?;
1006        let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1007        while let Some(row) = rows.next()? {
1008            outbound.push(EdgeRow {
1009                id: parse_id(row.get(0)?)?,
1010                source_id: parse_id(row.get(1)?)?,
1011                target_id: parse_id(row.get(2)?)?,
1012                relation: row.get(3)?,
1013                weight: row.get(4)?,
1014                created_at: row.get(5)?,
1015                updated_at: row.get(6)?,
1016                deleted_at: row.get(7)?,
1017                target_backend: row.get(8)?,
1018                metadata: row.get(9)?,
1019            });
1020        }
1021    }
1022
1023    let mut inbound: Vec<EdgeRow> = Vec::new();
1024    {
1025        let mut stmt = conn.prepare(
1026            "SELECT id, source_id, target_id, relation, weight, created_at, \
1027                    updated_at, deleted_at, target_backend, metadata \
1028             FROM graph_edges WHERE namespace = ?1 AND target_id = ?2",
1029        )?;
1030        let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1031        while let Some(row) = rows.next()? {
1032            inbound.push(EdgeRow {
1033                id: parse_id(row.get(0)?)?,
1034                source_id: parse_id(row.get(1)?)?,
1035                target_id: parse_id(row.get(2)?)?,
1036                relation: row.get(3)?,
1037                weight: row.get(4)?,
1038                created_at: row.get(5)?,
1039                updated_at: row.get(6)?,
1040                deleted_at: row.get(7)?,
1041                target_backend: row.get(8)?,
1042                metadata: row.get(9)?,
1043            });
1044        }
1045    }
1046
1047    // Deduplicate by edge ID (a self-edge from_id→from_id appears in both lists).
1048    let mut seen: HashSet<Uuid> = HashSet::new();
1049    let mut all_edges: Vec<EdgeRow> = Vec::new();
1050    for edge in outbound.into_iter().chain(inbound) {
1051        if seen.insert(edge.id) {
1052            all_edges.push(edge);
1053        }
1054    }
1055
1056    // --- Merge entity fields ---
1057    let (merged_props, properties_merged) =
1058        merge_properties(&into_entity.properties, &from_entity.properties, strategy);
1059    let merged_name = merge_string_field(&into_entity.name, &from_entity.name, strategy);
1060    let (merged_description, content_appended) = match content_strategy {
1061        ContentMergeStrategy::Append => {
1062            let into_desc = into_entity.description.as_deref().unwrap_or("");
1063            let from_desc = from_entity.description.as_deref().unwrap_or("");
1064            if from_desc.is_empty() {
1065                (into_entity.description.clone(), false)
1066            } else if into_desc.is_empty() {
1067                (from_entity.description.clone(), true)
1068            } else {
1069                (Some(format!("{}\n\n---\n\n{}", into_desc, from_desc)), true)
1070            }
1071        }
1072        // Description selection follows `content_strategy` directly — it is a
1073        // deliberate, independently-settable choice, not derived from the
1074        // entity-field `strategy` (properties/name/tags merge policy).
1075        ContentMergeStrategy::PreferInto => (into_entity.description.clone(), false),
1076        ContentMergeStrategy::PreferFrom => (from_entity.description.clone(), false),
1077    };
1078    let (merged_tags, tags_unioned) = union_tags(&into_entity.tags, &from_entity.tags);
1079
1080    let now = chrono::Utc::now().timestamp_micros();
1081    let into_str = into_id.to_string();
1082    let props_str = merged_props
1083        .as_ref()
1084        .map(|v| serde_json::to_string(v).unwrap_or_default());
1085    let tags_json = serde_json::to_string(&merged_tags).unwrap_or_else(|_| "[]".to_string());
1086
1087    // Writes are gated on `!dry_run` below, but the loop itself always runs so a
1088    // dry-run response reports a predictive `edges_rewired` count instead of zero.
1089    let mut edges_rewired = 0usize;
1090    for edge in all_edges {
1091        let raw_src = if edge.source_id == from_id {
1092            into_id
1093        } else {
1094            edge.source_id
1095        };
1096        let raw_tgt = if edge.target_id == from_id {
1097            into_id
1098        } else {
1099            edge.target_id
1100        };
1101        // Symmetric relations must be stored with source_uuid < target_uuid.
1102        // Apply canonicalization so the conflict check and UPDATE both use the canonical form.
1103        let (new_src, new_tgt) = match edge.relation.parse::<EdgeRelation>() {
1104            Ok(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
1105            Err(_) => (raw_src, raw_tgt),
1106        };
1107
1108        if new_src == new_tgt {
1109            if !dry_run {
1110                conn.execute(
1111                    "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1112                    rusqlite::params![&namespace, edge.id.to_string()],
1113                )?;
1114            }
1115            continue;
1116        }
1117
1118        if dry_run {
1119            // Predictive count only — no write in a dry-run.
1120            edges_rewired += 1;
1121            continue;
1122        }
1123
1124        let now_ts = chrono::Utc::now().timestamp();
1125        // Preserve the original edge ID where possible so callers can still get()
1126        // it by the ID returned from link(): update in-place when there's no
1127        // conflict; when into_id already owns this (source,target,relation), drop
1128        // the from-edge instead and refresh the existing into-edge.
1129        // Check for a conflict: does into_id already have this natural key?
1130        let conflict_id: Option<String> = {
1131            let conflict_src = new_src.to_string();
1132            let conflict_tgt = new_tgt.to_string();
1133            conn.query_row(
1134                khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
1135                rusqlite::params![
1136                    &namespace,
1137                    &conflict_src,
1138                    &conflict_tgt,
1139                    &edge.relation,
1140                    edge.id.to_string(),
1141                ],
1142                |row| row.get(0),
1143            )
1144            .optional()
1145            .map_err(SqliteError::Rusqlite)?
1146        };
1147
1148        let changed = if let Some(existing_id) = conflict_id {
1149            // A live or soft-deleted row already owns this natural key.
1150            conn.execute(
1151                khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1152                rusqlite::params![&namespace, edge.id.to_string()],
1153            )?;
1154            conn.execute(
1155                khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
1156                rusqlite::params![
1157                    edge.weight,
1158                    now_ts,
1159                    edge.target_backend,
1160                    edge.metadata,
1161                    &namespace,
1162                    &existing_id,
1163                ],
1164            )?
1165        } else {
1166            conn.execute(
1167                "UPDATE graph_edges SET \
1168                     source_id = ?1, target_id = ?2, updated_at = ?3 \
1169                     WHERE namespace = ?4 AND id = ?5",
1170                rusqlite::params![
1171                    new_src.to_string(),
1172                    new_tgt.to_string(),
1173                    now_ts,
1174                    &namespace,
1175                    edge.id.to_string(),
1176                ],
1177            )?
1178        };
1179        if changed > 0 {
1180            edges_rewired += 1;
1181        }
1182    }
1183
1184    if !dry_run {
1185        conn.execute(
1186            "INSERT OR REPLACE INTO entities \
1187             (id, namespace, kind, name, description, properties, tags, \
1188              created_at, updated_at, deleted_at, merged_into, merge_event_id) \
1189             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1190            rusqlite::params![
1191                &into_str,
1192                &namespace,
1193                &into_entity.kind,
1194                &merged_name,
1195                &merged_description,
1196                &props_str,
1197                &tags_json,
1198                into_entity.created_at,
1199                now,
1200                into_entity.deleted_at,
1201                Option::<String>::None,
1202                Option::<String>::None,
1203            ],
1204        )?;
1205
1206        // Body formula mirrors entity_fts_document (the canonical constructor):
1207        // this path is sync/spawn_blocking so it can't call it directly, but
1208        // must stay field-identical.
1209        let fts_body = match &merged_description {
1210            Some(d) if !d.is_empty() => format!("{} {}", merged_name, d),
1211            _ => merged_name.clone(),
1212        };
1213        let kind_str = SubstrateKind::Entity.to_string();
1214
1215        conn.execute(
1216            &format!(
1217                "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1218                fts_table
1219            ),
1220            rusqlite::params![&namespace, &into_str],
1221        )?;
1222        conn.execute(
1223            &format!(
1224                "INSERT INTO {} \
1225                 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1226                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1227                fts_table
1228            ),
1229            rusqlite::params![
1230                &into_str,
1231                &kind_str,
1232                &merged_name,
1233                &fts_body,
1234                &tags_json,
1235                &namespace,
1236                &props_str,
1237                now,
1238            ],
1239        )?;
1240
1241        conn.execute(
1242            &format!(
1243                "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1244                fts_table
1245            ),
1246            rusqlite::params![&namespace, &from_str],
1247        )?;
1248
1249        khive_db::stores::vectors::delete_subject_from_vector_tables(
1250            conn,
1251            &vec_tables,
1252            from_id,
1253            &namespace,
1254        )?;
1255
1256        let merge_event_id = Uuid::new_v4();
1257        conn.execute(
1258            "UPDATE entities \
1259             SET deleted_at = ?1, merged_into = ?2, merge_event_id = ?3, updated_at = ?1 \
1260             WHERE namespace = ?4 AND id = ?5 AND deleted_at IS NULL",
1261            rusqlite::params![
1262                now,
1263                into_str,
1264                merge_event_id.to_string(),
1265                &namespace,
1266                &from_str,
1267            ],
1268        )?;
1269    }
1270
1271    let updated_entity = Entity {
1272        id: into_id,
1273        namespace,
1274        kind: into_entity.kind,
1275        entity_type: into_entity.entity_type,
1276        name: merged_name,
1277        description: merged_description,
1278        properties: merged_props,
1279        tags: merged_tags,
1280        created_at: into_entity.created_at,
1281        updated_at: now,
1282        deleted_at: into_entity.deleted_at,
1283        merged_into: None,
1284        merge_event_id: None,
1285    };
1286
1287    Ok((
1288        MergeSummary {
1289            kept_id: into_id,
1290            removed_id: from_id,
1291            edges_rewired,
1292            properties_merged,
1293            tags_unioned,
1294            content_appended,
1295            dry_run,
1296        },
1297        updated_entity,
1298    ))
1299}
1300
1301// ---------------------------------------------------------------------------
1302// Note merge SQL helpers
1303// ---------------------------------------------------------------------------
1304
1305/// Read one note row by ID within a namespace, returning `SqliteError` on missing/wrong-ns.
1306fn read_merge_note(
1307    conn: &rusqlite::Connection,
1308    id: Uuid,
1309    namespace: &str,
1310) -> Result<khive_storage::note::Note, SqliteError> {
1311    use khive_storage::note::Note;
1312    let id_str = id.to_string();
1313    let mut stmt = conn.prepare(
1314        "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
1315         expires_at, properties, created_at, updated_at, deleted_at \
1316         FROM notes WHERE id = ?1 AND deleted_at IS NULL",
1317    )?;
1318    let mut rows = stmt.query(rusqlite::params![id_str])?;
1319    let row = rows
1320        .next()?
1321        .ok_or_else(|| SqliteError::InvalidData(format!("note {id} not found")))?;
1322
1323    let id_s: String = row.get(0)?;
1324    let ns: String = row.get(1)?;
1325    let kind: String = row.get(2)?;
1326    let status: String = row.get(3)?;
1327    let name: Option<String> = row.get(4)?;
1328    let content: String = row.get(5)?;
1329    let salience: Option<f64> = row.get(6)?;
1330    let decay_factor: Option<f64> = row.get(7)?;
1331    let expires_at: Option<i64> = row.get(8)?;
1332    let properties_str: Option<String> = row.get(9)?;
1333    let created_at: i64 = row.get(10)?;
1334    let updated_at: i64 = row.get(11)?;
1335    let deleted_at: Option<i64> = row.get(12)?;
1336
1337    if ns != namespace {
1338        return Err(SqliteError::InvalidData(format!(
1339            "note {id} belongs to namespace '{ns}', not '{namespace}'"
1340        )));
1341    }
1342
1343    let note_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
1344    let properties: Option<serde_json::Value> = properties_str
1345        .map(|s| serde_json::from_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string())))
1346        .transpose()?;
1347
1348    Ok(Note {
1349        id: note_id,
1350        namespace: ns,
1351        kind,
1352        status,
1353        name,
1354        content,
1355        salience,
1356        decay_factor,
1357        expires_at,
1358        properties,
1359        created_at,
1360        updated_at,
1361        deleted_at,
1362    })
1363}
1364
1365fn max_option_f64(a: Option<f64>, b: Option<f64>) -> Option<f64> {
1366    match (a, b) {
1367        (Some(x), Some(y)) => Some(x.max(y)),
1368        (Some(x), None) => Some(x),
1369        (None, Some(y)) => Some(y),
1370        (None, None) => None,
1371    }
1372}
1373
1374fn append_merge_history(props: Option<Value>, entry: Value) -> Result<Option<Value>, SqliteError> {
1375    use serde_json::{json, Map};
1376    let mut obj: Map<String, Value> = match props {
1377        Some(Value::Object(m)) => m,
1378        Some(other) => {
1379            let mut m = Map::new();
1380            m.insert("_value".into(), other);
1381            m
1382        }
1383        None => Map::new(),
1384    };
1385    let history = obj
1386        .entry("_merge_history".to_string())
1387        .or_insert_with(|| json!([]));
1388    if let Value::Array(arr) = history {
1389        arr.push(entry);
1390    }
1391    Ok(Some(Value::Object(obj)))
1392}
1393
1394/// All note merge SQL on one connection inside a `BEGIN IMMEDIATE` transaction.
1395///
1396/// Reads both notes (must have same `kind`), rewires/drops incident edges, merges content
1397/// per `content_strategy`, tombstones `from`. Returns the updated `into` note for async
1398/// re-embedding.
1399///
1400/// When `dry_run` is true, all reads and computations are performed but no writes are issued.
1401// REASON: note merge additionally requires a content_strategy parameter versus entity merge;
1402// same sync/async boundary rationale as merge_entity_sql applies here.
1403#[allow(clippy::too_many_arguments)]
1404fn merge_note_sql(
1405    conn: &rusqlite::Connection,
1406    namespace: String,
1407    fts_table: String,
1408    vec_tables: Vec<String>,
1409    into_id: Uuid,
1410    from_id: Uuid,
1411    strategy: EntityDedupMergePolicy,
1412    content_strategy: ContentMergeStrategy,
1413    dry_run: bool,
1414) -> Result<(MergeSummary, khive_storage::note::Note), SqliteError> {
1415    let into_note = read_merge_note(conn, into_id, &namespace)?;
1416    let from_note = read_merge_note(conn, from_id, &namespace)?;
1417
1418    if into_note.kind != from_note.kind {
1419        return Err(SqliteError::InvalidData(format!(
1420            "cannot merge notes of different kinds: {} vs {}",
1421            into_note.kind, from_note.kind
1422        )));
1423    }
1424
1425    let now = chrono::Utc::now().timestamp_micros();
1426    let into_str = into_id.to_string();
1427    let from_str = from_id.to_string();
1428
1429    // Collect edges incident to from_id.
1430    let parse_id =
1431        |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
1432
1433    let mut outbound: Vec<EdgeRow> = Vec::new();
1434    {
1435        let mut stmt = conn.prepare(
1436            "SELECT id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
1437             FROM graph_edges WHERE namespace = ?1 AND source_id = ?2",
1438        )?;
1439        let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1440        while let Some(row) = rows.next()? {
1441            outbound.push(EdgeRow {
1442                id: parse_id(row.get(0)?)?,
1443                source_id: parse_id(row.get(1)?)?,
1444                target_id: parse_id(row.get(2)?)?,
1445                relation: row.get(3)?,
1446                weight: row.get(4)?,
1447                created_at: row.get(5)?,
1448                updated_at: row.get(6)?,
1449                deleted_at: row.get(7)?,
1450                target_backend: row.get(8)?,
1451                metadata: row.get(9)?,
1452            });
1453        }
1454    }
1455    let mut inbound: Vec<EdgeRow> = Vec::new();
1456    {
1457        let mut stmt = conn.prepare(
1458            "SELECT id, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
1459             FROM graph_edges WHERE namespace = ?1 AND target_id = ?2",
1460        )?;
1461        let mut rows = stmt.query(rusqlite::params![&namespace, &from_str])?;
1462        while let Some(row) = rows.next()? {
1463            inbound.push(EdgeRow {
1464                id: parse_id(row.get(0)?)?,
1465                source_id: parse_id(row.get(1)?)?,
1466                target_id: parse_id(row.get(2)?)?,
1467                relation: row.get(3)?,
1468                weight: row.get(4)?,
1469                created_at: row.get(5)?,
1470                updated_at: row.get(6)?,
1471                deleted_at: row.get(7)?,
1472                target_backend: row.get(8)?,
1473                metadata: row.get(9)?,
1474            });
1475        }
1476    }
1477    let mut seen: HashSet<Uuid> = HashSet::new();
1478    let mut all_edges: Vec<EdgeRow> = Vec::new();
1479    for edge in outbound.into_iter().chain(inbound) {
1480        if seen.insert(edge.id) {
1481            all_edges.push(edge);
1482        }
1483    }
1484
1485    // Merge note fields.
1486    let (merged_content, content_appended) = match content_strategy {
1487        ContentMergeStrategy::Append => {
1488            if from_note.content.is_empty() {
1489                (into_note.content.clone(), false)
1490            } else {
1491                (
1492                    format!("{}\n\n---\n\n{}", into_note.content, from_note.content),
1493                    true,
1494                )
1495            }
1496        }
1497        ContentMergeStrategy::PreferInto => (into_note.content.clone(), false),
1498        ContentMergeStrategy::PreferFrom => (from_note.content.clone(), false),
1499    };
1500
1501    let merged_name = match strategy {
1502        EntityDedupMergePolicy::PreferFrom => from_note.name.clone().or(into_note.name.clone()),
1503        _ => into_note.name.clone().or(from_note.name.clone()),
1504    };
1505
1506    let (merged_props, properties_merged) =
1507        merge_properties(&into_note.properties, &from_note.properties, strategy);
1508
1509    let merge_history_entry = serde_json::json!({
1510        "merged_from": from_id.to_string(),
1511        "merged_at": now,
1512        "strategy": format!("{:?}", strategy),
1513        "content_strategy": format!("{:?}", content_strategy),
1514    });
1515    let merged_props = append_merge_history(merged_props, merge_history_entry)?;
1516
1517    let merged_salience = max_option_f64(into_note.salience, from_note.salience);
1518    let merged_expires_at = match (into_note.expires_at, from_note.expires_at) {
1519        (Some(a), Some(b)) => Some(a.max(b)),
1520        (Some(a), None) => Some(a),
1521        (None, Some(b)) => Some(b),
1522        (None, None) => None,
1523    };
1524
1525    let props_str = merged_props
1526        .as_ref()
1527        .map(|v| serde_json::to_string(v).unwrap_or_default());
1528
1529    let mut edges_rewired = 0usize;
1530    if !dry_run {
1531        for edge in all_edges {
1532            let raw_src = if edge.source_id == from_id {
1533                into_id
1534            } else {
1535                edge.source_id
1536            };
1537            let raw_tgt = if edge.target_id == from_id {
1538                into_id
1539            } else {
1540                edge.target_id
1541            };
1542            // Canonicalize symmetric relations before conflict check + UPDATE.
1543            let (new_src, new_tgt) = match edge.relation.parse::<EdgeRelation>() {
1544                Ok(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
1545                Err(_) => (raw_src, raw_tgt),
1546            };
1547            if new_src == new_tgt {
1548                conn.execute(
1549                    "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
1550                    rusqlite::params![&namespace, edge.id.to_string()],
1551                )?;
1552                continue;
1553            }
1554            let now_ts = chrono::Utc::now().timestamp();
1555            let conflict_id: Option<String> = {
1556                let conflict_src = new_src.to_string();
1557                let conflict_tgt = new_tgt.to_string();
1558                conn.query_row(
1559                    khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
1560                    rusqlite::params![
1561                        &namespace,
1562                        &conflict_src,
1563                        &conflict_tgt,
1564                        &edge.relation,
1565                        edge.id.to_string(),
1566                    ],
1567                    |row| row.get(0),
1568                )
1569                .optional()
1570                .map_err(SqliteError::Rusqlite)?
1571            };
1572
1573            let changed = if let Some(existing_id) = conflict_id {
1574                conn.execute(
1575                    khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1576                    rusqlite::params![&namespace, edge.id.to_string()],
1577                )?;
1578                conn.execute(
1579                    khive_db::stores::graph::EDGE_SYMMETRIC_REFRESH_CANONICAL_SQL,
1580                    rusqlite::params![
1581                        edge.weight,
1582                        now_ts,
1583                        edge.target_backend,
1584                        edge.metadata,
1585                        &namespace,
1586                        &existing_id,
1587                    ],
1588                )?
1589            } else {
1590                conn.execute(
1591                    "UPDATE graph_edges SET \
1592                     source_id = ?1, target_id = ?2, updated_at = ?3 \
1593                     WHERE namespace = ?4 AND id = ?5",
1594                    rusqlite::params![
1595                        new_src.to_string(),
1596                        new_tgt.to_string(),
1597                        now_ts,
1598                        &namespace,
1599                        edge.id.to_string(),
1600                    ],
1601                )?
1602            };
1603            if changed > 0 {
1604                edges_rewired += 1;
1605            }
1606        }
1607
1608        conn.execute(
1609            "INSERT OR REPLACE INTO notes \
1610             (id, namespace, kind, status, name, content, salience, decay_factor, \
1611              expires_at, properties, created_at, updated_at, deleted_at) \
1612             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
1613            rusqlite::params![
1614                &into_str,
1615                &namespace,
1616                &into_note.kind,
1617                &into_note.status,
1618                &merged_name,
1619                &merged_content,
1620                merged_salience,
1621                into_note.decay_factor,
1622                merged_expires_at,
1623                &props_str,
1624                into_note.created_at,
1625                now,
1626                into_note.deleted_at,
1627            ],
1628        )?;
1629
1630        conn.execute(
1631            &format!(
1632                "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1633                fts_table
1634            ),
1635            rusqlite::params![&namespace, &into_str],
1636        )?;
1637        // Derive FTS scalars through the shared constructor so this raw SQL path
1638        // is field-identical to TextSearch::upsert_document: critically, `title`
1639        // is an empty string (not SQL NULL) for nameless notes, so get_document
1640        // round-trips None <-> "" correctly.
1641        let fts_merged = {
1642            let mut merged_note = Note::new(&namespace, &*into_note.kind, &*merged_content);
1643            merged_note.id = into_id;
1644            merged_note.name = merged_name.clone();
1645            merged_note.properties = merged_props.clone();
1646            merged_note.updated_at = now;
1647            note_fts_scalars(&merged_note)
1648        };
1649        conn.execute(
1650            &format!(
1651                "INSERT INTO {} \
1652                 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1653                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1654                fts_table
1655            ),
1656            rusqlite::params![
1657                &into_str,
1658                SubstrateKind::Note.to_string(),
1659                &fts_merged.title,
1660                &fts_merged.body,
1661                &fts_merged.tags,
1662                &namespace,
1663                &fts_merged.metadata,
1664                fts_merged.updated_at_micros,
1665            ],
1666        )?;
1667
1668        conn.execute(
1669            &format!(
1670                "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
1671                fts_table
1672            ),
1673            rusqlite::params![&namespace, &from_str],
1674        )?;
1675
1676        khive_db::stores::vectors::delete_subject_from_vector_tables(
1677            conn,
1678            &vec_tables,
1679            from_id,
1680            &namespace,
1681        )?;
1682
1683        conn.execute(
1684            "UPDATE notes SET status = 'deleted', deleted_at = ?1, updated_at = ?1 \
1685             WHERE namespace = ?2 AND id = ?3 AND deleted_at IS NULL",
1686            rusqlite::params![now, &namespace, &from_str],
1687        )?;
1688    }
1689
1690    let updated_note = khive_storage::note::Note {
1691        id: into_id,
1692        namespace: namespace.clone(),
1693        kind: into_note.kind.clone(),
1694        status: into_note.status.clone(),
1695        name: merged_name,
1696        content: merged_content,
1697        salience: merged_salience,
1698        decay_factor: into_note.decay_factor,
1699        expires_at: merged_expires_at,
1700        properties: merged_props,
1701        created_at: into_note.created_at,
1702        updated_at: now,
1703        deleted_at: into_note.deleted_at,
1704    };
1705
1706    Ok((
1707        MergeSummary {
1708            kept_id: into_id,
1709            removed_id: from_id,
1710            edges_rewired,
1711            properties_merged,
1712            tags_unioned: 0,
1713            content_appended,
1714            dry_run,
1715        },
1716        updated_note,
1717    ))
1718}
1719
1720// ---------------------------------------------------------------------------
1721// Merge helpers (pure functions — easier to unit test)
1722// ---------------------------------------------------------------------------
1723
1724/// `pub(crate)` so `crate::atomic_prepare::prepare_merge` can reuse this exact
1725/// field-fold semantics for atomic/non-atomic parity.
1726pub(crate) fn merge_string_field(
1727    into: &str,
1728    from: &str,
1729    strategy: EntityDedupMergePolicy,
1730) -> String {
1731    match strategy {
1732        EntityDedupMergePolicy::PreferInto | EntityDedupMergePolicy::Union => into.to_string(),
1733        EntityDedupMergePolicy::PreferFrom => from.to_string(),
1734    }
1735}
1736
1737/// Merge two property objects. Returns (merged, count_of_fields_from_from_that_were_added).
1738/// `pub(crate)` so `crate::atomic_prepare` can reuse this exact properties-merge
1739/// semantics when building an `update` write plan's row statement, matching
1740/// `update_entity`/`update_note`'s own patch behavior byte-for-byte.
1741pub(crate) fn merge_properties(
1742    into: &Option<Value>,
1743    from: &Option<Value>,
1744    strategy: EntityDedupMergePolicy,
1745) -> (Option<Value>, usize) {
1746    match (into, from) {
1747        (None, None) => (None, 0),
1748        (Some(a), None) => (Some(a.clone()), 0),
1749        (None, Some(b)) => {
1750            let count = if let Value::Object(m) = b { m.len() } else { 1 };
1751            (Some(b.clone()), count)
1752        }
1753        (Some(into_val), Some(from_val)) => {
1754            let (merged, added) = merge_json(into_val, from_val, strategy);
1755            (Some(merged), added)
1756        }
1757    }
1758}
1759
1760/// Deep-merge two JSON values per strategy. Returns (merged, keys_contributed_by_from).
1761fn merge_json(into: &Value, from: &Value, strategy: EntityDedupMergePolicy) -> (Value, usize) {
1762    match (into, from, strategy) {
1763        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::Union) => {
1764            let mut result = a.clone();
1765            let mut added = 0usize;
1766            for (k, v_from) in b {
1767                if let Some(v_into) = a.get(k) {
1768                    let (merged, sub_added) =
1769                        merge_json(v_into, v_from, EntityDedupMergePolicy::Union);
1770                    result.insert(k.clone(), merged);
1771                    added += sub_added;
1772                } else {
1773                    result.insert(k.clone(), v_from.clone());
1774                    added += 1;
1775                }
1776            }
1777            (Value::Object(result), added)
1778        }
1779        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferInto) => {
1780            let mut result = a.clone();
1781            let mut added = 0usize;
1782            for (k, v) in b {
1783                if !a.contains_key(k) {
1784                    result.insert(k.clone(), v.clone());
1785                    added += 1;
1786                }
1787            }
1788            (Value::Object(result), added)
1789        }
1790        (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferFrom) => {
1791            let mut result = a.clone();
1792            let mut added = 0usize;
1793            for (k, v) in b {
1794                result.insert(k.clone(), v.clone());
1795                if !a.contains_key(k) {
1796                    added += 1;
1797                }
1798            }
1799            (Value::Object(result), added)
1800        }
1801        // Non-object scalars: apply strategy directly.
1802        (_into_val, from_val, EntityDedupMergePolicy::PreferFrom) => (from_val.clone(), 1),
1803        _ => (into.clone(), 0),
1804    }
1805}
1806
1807/// `pub(crate)` so `crate::atomic_prepare::prepare_merge` can reuse this for
1808/// atomic/non-atomic parity.
1809pub(crate) fn union_tags(into: &[String], from: &[String]) -> (Vec<String>, usize) {
1810    let mut seen: HashSet<&str> = into.iter().map(|s| s.as_str()).collect();
1811    let mut result: Vec<String> = into.to_vec();
1812    let mut added = 0usize;
1813    for tag in from {
1814        if seen.insert(tag.as_str()) {
1815            result.push(tag.clone());
1816            added += 1;
1817        }
1818    }
1819    (result, added)
1820}
1821
1822// ---------------------------------------------------------------------------
1823// INLINE TEST JUSTIFICATION: tests here exercise patch/merge helpers and the
1824// update_note/update_entity paths that share private merge_properties logic.
1825// Moving them to tests/ would require pub-exporting merge_properties, which is
1826// an internal invariant not suitable for the public API surface. Broad
1827// behavioral curation tests live in tests/integration.rs.
1828// ---------------------------------------------------------------------------
1829
1830#[cfg(test)]
1831mod tests {
1832    use super::*;
1833    use crate::runtime::{KhiveRuntime, NamespaceToken};
1834    use khive_storage::types::{Direction, TextFilter, TextQueryMode, TextSearchRequest};
1835
1836    fn rt() -> KhiveRuntime {
1837        KhiveRuntime::memory().unwrap()
1838    }
1839
1840    // Helper: search FTS5 for `query` in a runtime namespace.
1841    async fn fts_hit(rt: &KhiveRuntime, token: &NamespaceToken, query: &str) -> Vec<Uuid> {
1842        let ns = token.namespace().as_str().to_string();
1843        rt.text(token)
1844            .unwrap()
1845            .search(TextSearchRequest {
1846                query: query.to_string(),
1847                mode: TextQueryMode::Plain,
1848                filter: Some(TextFilter {
1849                    namespaces: vec![ns],
1850                    ..Default::default()
1851                }),
1852                top_k: 50,
1853                snippet_chars: 100,
1854            })
1855            .await
1856            .unwrap()
1857            .into_iter()
1858            .map(|h| h.subject_id)
1859            .collect()
1860    }
1861
1862    #[tokio::test]
1863    async fn update_entity_patch_changes_only_specified_fields() {
1864        let rt = rt();
1865        let tok = NamespaceToken::local();
1866        let entity = rt
1867            .create_entity(
1868                &tok,
1869                "concept",
1870                None,
1871                "OriginalName",
1872                Some("orig desc"),
1873                Some(serde_json::json!({"k":"v"})),
1874                vec![],
1875            )
1876            .await
1877            .unwrap();
1878
1879        let updated = rt
1880            .update_entity(
1881                &tok,
1882                entity.id,
1883                EntityPatch {
1884                    description: Some(Some("new desc".to_string())),
1885                    ..Default::default()
1886                },
1887            )
1888            .await
1889            .unwrap();
1890
1891        assert_eq!(updated.name, "OriginalName");
1892        assert_eq!(updated.description.as_deref(), Some("new desc"));
1893        assert_eq!(updated.properties, Some(serde_json::json!({"k":"v"})));
1894    }
1895
1896    #[tokio::test]
1897    async fn update_entity_clear_description_with_some_none() {
1898        let rt = rt();
1899        let tok = NamespaceToken::local();
1900        let entity = rt
1901            .create_entity(
1902                &tok,
1903                "concept",
1904                None,
1905                "ClearDesc",
1906                Some("has description"),
1907                None,
1908                vec![],
1909            )
1910            .await
1911            .unwrap();
1912
1913        let updated = rt
1914            .update_entity(
1915                &tok,
1916                entity.id,
1917                EntityPatch {
1918                    description: Some(None),
1919                    ..Default::default()
1920                },
1921            )
1922            .await
1923            .unwrap();
1924
1925        assert!(
1926            updated.description.is_none(),
1927            "description should be cleared"
1928        );
1929    }
1930
1931    #[tokio::test]
1932    async fn update_entity_reindexes_when_name_changes() {
1933        let rt = rt();
1934        let tok = NamespaceToken::local();
1935        let entity = rt
1936            .create_entity(&tok, "concept", None, "OldName", None, None, vec![])
1937            .await
1938            .unwrap();
1939
1940        let hits_before = fts_hit(&rt, &tok, "OldName").await;
1941        assert!(
1942            hits_before.contains(&entity.id),
1943            "entity should be findable by old name"
1944        );
1945
1946        rt.update_entity(
1947            &tok,
1948            entity.id,
1949            EntityPatch {
1950                name: Some("NewName".to_string()),
1951                ..Default::default()
1952            },
1953        )
1954        .await
1955        .unwrap();
1956
1957        let hits_old = fts_hit(&rt, &tok, "OldName").await;
1958        let hits_new = fts_hit(&rt, &tok, "NewName").await;
1959
1960        assert!(
1961            !hits_old.contains(&entity.id),
1962            "old name should no longer match after rename"
1963        );
1964        assert!(
1965            hits_new.contains(&entity.id),
1966            "new name should be findable after rename"
1967        );
1968    }
1969
1970    #[tokio::test]
1971    async fn update_entity_properties_merges_preserving_existing_keys() {
1972        let rt = rt();
1973        let tok = NamespaceToken::local();
1974        let entity = rt
1975            .create_entity(
1976                &tok,
1977                "concept",
1978                None,
1979                "MergeProps",
1980                None,
1981                Some(serde_json::json!({
1982                    "domain": "inference",
1983                    "repo": "lattice",
1984                    "status": "researched",
1985                })),
1986                vec![],
1987            )
1988            .await
1989            .unwrap();
1990
1991        let updated = rt
1992            .update_entity(
1993                &tok,
1994                entity.id,
1995                EntityPatch {
1996                    properties: Some(serde_json::json!({"status": "implemented"})),
1997                    ..Default::default()
1998                },
1999            )
2000            .await
2001            .unwrap();
2002
2003        let props = updated.properties.expect("properties should remain set");
2004        assert_eq!(props["domain"], "inference", "domain key must be preserved");
2005        assert_eq!(props["repo"], "lattice", "repo key must be preserved");
2006        assert_eq!(
2007            props["status"], "implemented",
2008            "status key must be updated by patch"
2009        );
2010    }
2011
2012    #[tokio::test]
2013    async fn update_entity_skips_reindex_when_only_properties_change() {
2014        let rt = rt();
2015        let tok = NamespaceToken::local();
2016        let entity = rt
2017            .create_entity(&tok, "concept", None, "StableIndexed", None, None, vec![])
2018            .await
2019            .unwrap();
2020
2021        let hits_before = fts_hit(&rt, &tok, "StableIndexed").await;
2022        assert!(hits_before.contains(&entity.id));
2023
2024        rt.update_entity(
2025            &tok,
2026            entity.id,
2027            EntityPatch {
2028                properties: Some(serde_json::json!({"new": "prop"})),
2029                ..Default::default()
2030            },
2031        )
2032        .await
2033        .unwrap();
2034
2035        let hits_after = fts_hit(&rt, &tok, "StableIndexed").await;
2036        assert!(
2037            hits_after.contains(&entity.id),
2038            "still findable after props-only patch"
2039        );
2040    }
2041
2042    #[tokio::test]
2043    async fn merge_entity_rewires_edges() {
2044        let rt = rt();
2045        let tok = NamespaceToken::local();
2046        let a = rt
2047            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2048            .await
2049            .unwrap();
2050        let b = rt
2051            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2052            .await
2053            .unwrap();
2054        let c = rt
2055            .create_entity(&tok, "concept", None, "C", None, None, vec![])
2056            .await
2057            .unwrap();
2058        let d = rt
2059            .create_entity(&tok, "concept", None, "D", None, None, vec![])
2060            .await
2061            .unwrap();
2062
2063        // A→B and C→B; merge B into D → should become A→D and C→D.
2064        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2065            .await
2066            .unwrap();
2067        rt.link(&tok, c.id, b.id, EdgeRelation::Extends, 1.0, None)
2068            .await
2069            .unwrap();
2070
2071        let summary = rt
2072            .merge_entity(
2073                &tok,
2074                d.id,
2075                b.id,
2076                EntityDedupMergePolicy::PreferInto,
2077                ContentMergeStrategy::Append,
2078                false,
2079            )
2080            .await
2081            .unwrap();
2082
2083        assert_eq!(summary.kept_id, d.id);
2084        assert_eq!(summary.removed_id, b.id);
2085        assert_eq!(summary.edges_rewired, 2);
2086
2087        let a_neighbors = rt
2088            .neighbors(&tok, a.id, Direction::Out, None, None)
2089            .await
2090            .unwrap();
2091        assert_eq!(a_neighbors.len(), 1);
2092        assert_eq!(a_neighbors[0].node_id, d.id);
2093
2094        let c_neighbors = rt
2095            .neighbors(&tok, c.id, Direction::Out, None, None)
2096            .await
2097            .unwrap();
2098        assert_eq!(c_neighbors.len(), 1);
2099        assert_eq!(c_neighbors[0].node_id, d.id);
2100    }
2101
2102    #[tokio::test]
2103    async fn merge_entity_self_merge_rejected() {
2104        let rt = rt();
2105        let tok = NamespaceToken::local();
2106        let a = rt
2107            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2108            .await
2109            .unwrap();
2110        let err = rt
2111            .merge_entity(
2112                &tok,
2113                a.id,
2114                a.id,
2115                EntityDedupMergePolicy::PreferInto,
2116                ContentMergeStrategy::Append,
2117                false,
2118            )
2119            .await
2120            .unwrap_err();
2121        assert!(
2122            format!("{err:?}").contains("cannot merge an entity into itself"),
2123            "expected self-merge rejection, got: {err:?}"
2124        );
2125    }
2126
2127    #[tokio::test]
2128    async fn merge_entity_prefer_into_strategy() {
2129        let rt = rt();
2130        let tok = NamespaceToken::local();
2131        let into = rt
2132            .create_entity(
2133                &tok,
2134                "concept",
2135                None,
2136                "Into",
2137                None,
2138                Some(serde_json::json!({"a": 1})),
2139                vec![],
2140            )
2141            .await
2142            .unwrap();
2143        let from = rt
2144            .create_entity(
2145                &tok,
2146                "concept",
2147                None,
2148                "From",
2149                None,
2150                Some(serde_json::json!({"a": 2, "b": 3})),
2151                vec![],
2152            )
2153            .await
2154            .unwrap();
2155
2156        rt.merge_entity(
2157            &tok,
2158            into.id,
2159            from.id,
2160            EntityDedupMergePolicy::PreferInto,
2161            ContentMergeStrategy::Append,
2162            false,
2163        )
2164        .await
2165        .unwrap();
2166
2167        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2168        let props = kept.properties.unwrap();
2169        // a stays as 1 (into wins), b is added from from.
2170        assert_eq!(props["a"], 1);
2171        assert_eq!(props["b"], 3);
2172    }
2173
2174    #[tokio::test]
2175    async fn merge_entity_prefer_from_strategy() {
2176        let rt = rt();
2177        let tok = NamespaceToken::local();
2178        let into = rt
2179            .create_entity(
2180                &tok,
2181                "concept",
2182                None,
2183                "Into",
2184                None,
2185                Some(serde_json::json!({"a": 1})),
2186                vec![],
2187            )
2188            .await
2189            .unwrap();
2190        let from = rt
2191            .create_entity(
2192                &tok,
2193                "concept",
2194                None,
2195                "From",
2196                None,
2197                Some(serde_json::json!({"a": 2, "b": 3})),
2198                vec![],
2199            )
2200            .await
2201            .unwrap();
2202
2203        rt.merge_entity(
2204            &tok,
2205            into.id,
2206            from.id,
2207            EntityDedupMergePolicy::PreferFrom,
2208            ContentMergeStrategy::Append,
2209            false,
2210        )
2211        .await
2212        .unwrap();
2213
2214        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2215        let props = kept.properties.unwrap();
2216        // from wins on a, b also from from.
2217        assert_eq!(props["a"], 2);
2218        assert_eq!(props["b"], 3);
2219    }
2220
2221    #[tokio::test]
2222    async fn merge_entity_union_strategy() {
2223        let rt = rt();
2224        let tok = NamespaceToken::local();
2225        let into = rt
2226            .create_entity(
2227                &tok,
2228                "concept",
2229                None,
2230                "Into",
2231                None,
2232                Some(serde_json::json!({"a": 1})),
2233                vec![],
2234            )
2235            .await
2236            .unwrap();
2237        let from = rt
2238            .create_entity(
2239                &tok,
2240                "concept",
2241                None,
2242                "From",
2243                None,
2244                Some(serde_json::json!({"a": 2, "b": 3})),
2245                vec![],
2246            )
2247            .await
2248            .unwrap();
2249
2250        rt.merge_entity(
2251            &tok,
2252            into.id,
2253            from.id,
2254            EntityDedupMergePolicy::Union,
2255            ContentMergeStrategy::Append,
2256            false,
2257        )
2258        .await
2259        .unwrap();
2260
2261        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2262        let props = kept.properties.unwrap();
2263        // Scalar conflict: into wins → a=1. b added from from.
2264        assert_eq!(props["a"], 1);
2265        assert_eq!(props["b"], 3);
2266    }
2267
2268    #[tokio::test]
2269    async fn merge_entity_unions_tags() {
2270        let rt = rt();
2271        let tok = NamespaceToken::local();
2272        let into = rt
2273            .create_entity(
2274                &tok,
2275                "concept",
2276                None,
2277                "Into",
2278                None,
2279                None,
2280                vec!["x".to_string(), "y".to_string()],
2281            )
2282            .await
2283            .unwrap();
2284        let from = rt
2285            .create_entity(
2286                &tok,
2287                "concept",
2288                None,
2289                "From",
2290                None,
2291                None,
2292                vec!["y".to_string(), "z".to_string()],
2293            )
2294            .await
2295            .unwrap();
2296
2297        rt.merge_entity(
2298            &tok,
2299            into.id,
2300            from.id,
2301            EntityDedupMergePolicy::PreferInto,
2302            ContentMergeStrategy::Append,
2303            false,
2304        )
2305        .await
2306        .unwrap();
2307
2308        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2309        let mut tags = kept.tags.clone();
2310        tags.sort();
2311        assert_eq!(tags, vec!["x", "y", "z"]);
2312    }
2313
2314    #[tokio::test]
2315    async fn merge_entity_drops_self_loops() {
2316        let rt = rt();
2317        let tok = NamespaceToken::local();
2318        let a = rt
2319            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2320            .await
2321            .unwrap();
2322        let b = rt
2323            .create_entity(&tok, "concept", None, "B", None, None, vec![])
2324            .await
2325            .unwrap();
2326
2327        // A `extends` B — merging B into A would produce A `extends` A → drop it.
2328        rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
2329            .await
2330            .unwrap();
2331
2332        let summary = rt
2333            .merge_entity(
2334                &tok,
2335                a.id,
2336                b.id,
2337                EntityDedupMergePolicy::PreferInto,
2338                ContentMergeStrategy::Append,
2339                false,
2340            )
2341            .await
2342            .unwrap();
2343
2344        assert_eq!(
2345            summary.edges_rewired, 0,
2346            "self-loop should be dropped, not rewired"
2347        );
2348
2349        let a_out = rt
2350            .neighbors(&tok, a.id, Direction::Out, None, None)
2351            .await
2352            .unwrap();
2353        assert!(a_out.is_empty(), "no self-loop should remain");
2354    }
2355
2356    // ---- content_strategy for entity merge ----
2357
2358    #[tokio::test]
2359    async fn merge_entity_append_strategy_concatenates_descriptions() {
2360        let rt = rt();
2361        let tok = NamespaceToken::local();
2362        let into = rt
2363            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2364            .await
2365            .unwrap();
2366        let from = rt
2367            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2368            .await
2369            .unwrap();
2370
2371        let summary = rt
2372            .merge_entity(
2373                &tok,
2374                into.id,
2375                from.id,
2376                EntityDedupMergePolicy::PreferInto,
2377                ContentMergeStrategy::Append,
2378                false,
2379            )
2380            .await
2381            .unwrap();
2382
2383        assert!(
2384            summary.content_appended,
2385            "append strategy with two non-empty descriptions must report content_appended=true"
2386        );
2387        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2388        assert_eq!(kept.description.as_deref(), Some("desc A\n\n---\n\ndesc B"));
2389    }
2390
2391    #[tokio::test]
2392    async fn merge_entity_append_strategy_from_empty_is_noop() {
2393        let rt = rt();
2394        let tok = NamespaceToken::local();
2395        let into = rt
2396            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2397            .await
2398            .unwrap();
2399        let from = rt
2400            .create_entity(&tok, "concept", None, "From", None, None, vec![])
2401            .await
2402            .unwrap();
2403
2404        let summary = rt
2405            .merge_entity(
2406                &tok,
2407                into.id,
2408                from.id,
2409                EntityDedupMergePolicy::PreferInto,
2410                ContentMergeStrategy::Append,
2411                false,
2412            )
2413            .await
2414            .unwrap();
2415
2416        assert!(
2417            !summary.content_appended,
2418            "from's empty description means nothing was appended"
2419        );
2420        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2421        assert_eq!(kept.description.as_deref(), Some("desc A"));
2422    }
2423
2424    #[tokio::test]
2425    async fn merge_entity_append_strategy_into_empty_takes_from() {
2426        let rt = rt();
2427        let tok = NamespaceToken::local();
2428        let into = rt
2429            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2430            .await
2431            .unwrap();
2432        let from = rt
2433            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2434            .await
2435            .unwrap();
2436
2437        let summary = rt
2438            .merge_entity(
2439                &tok,
2440                into.id,
2441                from.id,
2442                EntityDedupMergePolicy::PreferInto,
2443                ContentMergeStrategy::Append,
2444                false,
2445            )
2446            .await
2447            .unwrap();
2448
2449        assert!(
2450            summary.content_appended,
2451            "taking from's description into an empty into is real content preservation"
2452        );
2453        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2454        assert_eq!(kept.description.as_deref(), Some("desc B"));
2455    }
2456
2457    #[tokio::test]
2458    async fn merge_entity_prefer_into_strategy_still_discards_explicitly() {
2459        let rt = rt();
2460        let tok = NamespaceToken::local();
2461        let into = rt
2462            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2463            .await
2464            .unwrap();
2465        let from = rt
2466            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2467            .await
2468            .unwrap();
2469
2470        let summary = rt
2471            .merge_entity(
2472                &tok,
2473                into.id,
2474                from.id,
2475                EntityDedupMergePolicy::PreferInto,
2476                ContentMergeStrategy::PreferInto,
2477                false,
2478            )
2479            .await
2480            .unwrap();
2481
2482        assert!(
2483            !summary.content_appended,
2484            "explicit PreferInto opt-out must not report an append"
2485        );
2486        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2487        assert_eq!(
2488            kept.description.as_deref(),
2489            Some("desc A"),
2490            "explicit PreferInto opt-out keeps the old discard behavior"
2491        );
2492    }
2493
2494    /// `content_strategy` must be followed directly, independent of the
2495    /// entity-field `strategy`: with the default entity policy `prefer_into`,
2496    /// an explicit `content_strategy=prefer_from` must still keep the
2497    /// from-description.
2498    #[tokio::test]
2499    async fn merge_entity_prefer_from_content_strategy_wins_over_default_entity_policy() {
2500        let rt = rt();
2501        let tok = NamespaceToken::local();
2502        let into = rt
2503            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2504            .await
2505            .unwrap();
2506        let from = rt
2507            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2508            .await
2509            .unwrap();
2510
2511        let summary = rt
2512            .merge_entity(
2513                &tok,
2514                into.id,
2515                from.id,
2516                EntityDedupMergePolicy::PreferInto,
2517                ContentMergeStrategy::PreferFrom,
2518                false,
2519            )
2520            .await
2521            .unwrap();
2522
2523        assert!(
2524            !summary.content_appended,
2525            "explicit PreferFrom is not an append"
2526        );
2527        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2528        assert_eq!(
2529            kept.description.as_deref(),
2530            Some("desc B"),
2531            "content_strategy=prefer_from must win over the default prefer_into entity policy"
2532        );
2533    }
2534
2535    #[tokio::test]
2536    async fn merge_entity_dry_run_previews_append() {
2537        let rt = rt();
2538        let tok = NamespaceToken::local();
2539        let into = rt
2540            .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
2541            .await
2542            .unwrap();
2543        let from = rt
2544            .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
2545            .await
2546            .unwrap();
2547
2548        let summary = rt
2549            .merge_entity(
2550                &tok,
2551                into.id,
2552                from.id,
2553                EntityDedupMergePolicy::PreferInto,
2554                ContentMergeStrategy::Append,
2555                true,
2556            )
2557            .await
2558            .unwrap();
2559
2560        assert!(summary.dry_run);
2561        assert!(
2562            summary.content_appended,
2563            "dry-run must preview the append outcome without writing"
2564        );
2565        let kept = rt.get_entity(&tok, into.id).await.unwrap();
2566        assert_eq!(
2567            kept.description.as_deref(),
2568            Some("desc A"),
2569            "dry_run=true must not mutate the into entity's description"
2570        );
2571    }
2572
2573    /// Dry-run must be a read-only, accurate preview: it must predict
2574    /// `edges_rewired` without writing, and must not append an `EntityMerged` event.
2575    #[tokio::test]
2576    async fn merge_entity_dry_run_predicts_edges_rewired_without_writing() {
2577        use khive_storage::EdgeRelation;
2578
2579        let rt = rt();
2580        let tok = NamespaceToken::local();
2581        let a = rt
2582            .create_entity(&tok, "concept", None, "A", None, None, vec![])
2583            .await
2584            .unwrap();
2585        let into = rt
2586            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2587            .await
2588            .unwrap();
2589        let from = rt
2590            .create_entity(&tok, "concept", None, "From", None, None, vec![])
2591            .await
2592            .unwrap();
2593
2594        rt.link(&tok, a.id, from.id, EdgeRelation::Extends, 1.0, None)
2595            .await
2596            .unwrap();
2597
2598        let summary = rt
2599            .merge_entity(
2600                &tok,
2601                into.id,
2602                from.id,
2603                EntityDedupMergePolicy::PreferInto,
2604                ContentMergeStrategy::Append,
2605                true,
2606            )
2607            .await
2608            .unwrap();
2609
2610        assert!(summary.dry_run);
2611        assert_eq!(
2612            summary.edges_rewired, 1,
2613            "dry-run must predict the edge that would be rewired, not report zero"
2614        );
2615
2616        let a_neighbors = rt
2617            .neighbors(&tok, a.id, Direction::Out, None, None)
2618            .await
2619            .unwrap();
2620        assert_eq!(a_neighbors.len(), 1);
2621        assert_eq!(
2622            a_neighbors[0].node_id, from.id,
2623            "dry_run=true must not rewire any edges"
2624        );
2625
2626        let events = rt
2627            .events(&tok)
2628            .unwrap()
2629            .query_events(
2630                khive_storage::EventFilter {
2631                    kinds: vec![EventKind::EntityMerged],
2632                    ..Default::default()
2633                },
2634                khive_storage::types::PageRequest {
2635                    offset: 0,
2636                    limit: 10,
2637                },
2638            )
2639            .await
2640            .unwrap();
2641        assert!(
2642            events.items.is_empty(),
2643            "dry_run=true must not append an EntityMerged event"
2644        );
2645    }
2646
2647    // ---- merge helper unit tests ----
2648
2649    #[test]
2650    fn union_tags_deduplicates() {
2651        let (tags, added) = union_tags(
2652            &["x".to_string(), "y".to_string()],
2653            &["y".to_string(), "z".to_string()],
2654        );
2655        let mut sorted = tags.clone();
2656        sorted.sort();
2657        assert_eq!(sorted, vec!["x", "y", "z"]);
2658        assert_eq!(added, 1);
2659    }
2660
2661    #[test]
2662    fn merge_properties_prefer_into_fills_missing_keys() {
2663        let a = serde_json::json!({"a": 1});
2664        let b = serde_json::json!({"a": 99, "b": 2});
2665        let (merged, added) =
2666            merge_properties(&Some(a), &Some(b), EntityDedupMergePolicy::PreferInto);
2667        let m = merged.unwrap();
2668        assert_eq!(m["a"], 1);
2669        assert_eq!(m["b"], 2);
2670        assert_eq!(added, 1);
2671    }
2672
2673    // ---- tombstone and note merge tests ----
2674
2675    #[tokio::test]
2676    async fn merge_entity_tombstones_source_with_provenance() {
2677        let rt = rt();
2678        let tok = NamespaceToken::local();
2679        let into = rt
2680            .create_entity(&tok, "concept", None, "Into", None, None, vec![])
2681            .await
2682            .unwrap();
2683        let from = rt
2684            .create_entity(&tok, "concept", None, "From", None, None, vec![])
2685            .await
2686            .unwrap();
2687        let from_id = from.id;
2688
2689        rt.merge_entity(
2690            &tok,
2691            into.id,
2692            from_id,
2693            EntityDedupMergePolicy::PreferInto,
2694            ContentMergeStrategy::Append,
2695            false,
2696        )
2697        .await
2698        .unwrap();
2699
2700        assert!(
2701            rt.get_entity(&tok, from_id).await.is_err(),
2702            "tombstoned source should not be returned by get_entity"
2703        );
2704
2705        let pool = rt.backend().pool_arc();
2706        let (deleted_at, merged_into): (Option<i64>, Option<String>) =
2707            tokio::task::spawn_blocking(move || {
2708                let guard = pool.writer().unwrap();
2709                guard
2710                    .conn()
2711                    .query_row(
2712                        "SELECT deleted_at, merged_into FROM entities WHERE id = ?1",
2713                        [from_id.to_string()],
2714                        |row| Ok((row.get(0)?, row.get(1)?)),
2715                    )
2716                    .unwrap()
2717            })
2718            .await
2719            .unwrap();
2720        assert!(
2721            deleted_at.is_some(),
2722            "tombstoned entity must have deleted_at set"
2723        );
2724        assert_eq!(
2725            merged_into.as_deref(),
2726            Some(into.id.to_string().as_str()),
2727            "merged_into must point to into_id"
2728        );
2729    }
2730
2731    #[tokio::test]
2732    async fn merge_note_same_kind_appends_content() {
2733        let rt = rt();
2734        let tok = NamespaceToken::local();
2735        let into = rt
2736            .create_note(
2737                &tok,
2738                "observation",
2739                None,
2740                "Into content",
2741                None,
2742                None,
2743                vec![],
2744            )
2745            .await
2746            .unwrap();
2747        let from = rt
2748            .create_note(
2749                &tok,
2750                "observation",
2751                None,
2752                "From content",
2753                None,
2754                None,
2755                vec![],
2756            )
2757            .await
2758            .unwrap();
2759        let from_id = from.id;
2760
2761        let summary = rt
2762            .merge_note(
2763                &tok,
2764                into.id,
2765                from_id,
2766                EntityDedupMergePolicy::PreferInto,
2767                ContentMergeStrategy::Append,
2768                false,
2769            )
2770            .await
2771            .unwrap();
2772
2773        assert_eq!(summary.kept_id, into.id);
2774        assert_eq!(summary.removed_id, from_id);
2775        assert!(summary.content_appended);
2776        assert!(!summary.dry_run);
2777
2778        let from_store = rt.notes(&tok).unwrap();
2779        assert!(
2780            from_store.get_note(from_id).await.unwrap().is_none(),
2781            "merged-from note should be soft-deleted"
2782        );
2783    }
2784
2785    // Note merge must absorb a conflicting edge natural key exactly like entity
2786    // merge does, since both route through the shared EDGE_SYMMETRIC_*_SQL arms.
2787    #[tokio::test]
2788    async fn merge_note_survives_shared_edge_to_third_party() {
2789        use khive_storage::EdgeRelation;
2790        let rt = rt();
2791        let tok = NamespaceToken::local();
2792
2793        let into = rt
2794            .create_note(&tok, "observation", None, "Into", None, None, vec![])
2795            .await
2796            .unwrap();
2797        let from = rt
2798            .create_note(&tok, "observation", None, "From", None, None, vec![])
2799            .await
2800            .unwrap();
2801        let shared = rt
2802            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
2803            .await
2804            .unwrap();
2805
2806        // Both into and from annotate the same shared entity — rewiring from's
2807        // edge onto into during merge produces a duplicate (into, shared,
2808        // annotates) triple, exercising the conflict-probe/delete/refresh arms.
2809        rt.link(&tok, into.id, shared.id, EdgeRelation::Annotates, 1.0, None)
2810            .await
2811            .unwrap();
2812        rt.link(&tok, from.id, shared.id, EdgeRelation::Annotates, 1.0, None)
2813            .await
2814            .unwrap();
2815
2816        let summary = rt
2817            .merge_note(
2818                &tok,
2819                into.id,
2820                from.id,
2821                EntityDedupMergePolicy::PreferInto,
2822                ContentMergeStrategy::Append,
2823                false,
2824            )
2825            .await
2826            .expect("merge must succeed even when both notes annotate the same entity");
2827
2828        assert_eq!(summary.kept_id, into.id);
2829        assert_eq!(summary.removed_id, from.id);
2830
2831        let into_edges = rt
2832            .list_edges(
2833                &tok,
2834                crate::EdgeListFilter {
2835                    source_id: Some(into.id),
2836                    target_id: Some(shared.id),
2837                    relations: vec![EdgeRelation::Annotates],
2838                    ..Default::default()
2839                },
2840                10,
2841                0,
2842            )
2843            .await
2844            .unwrap();
2845        assert_eq!(
2846            into_edges.len(),
2847            1,
2848            "exactly one live into→shared annotates edge must exist after merge; got: {into_edges:?}"
2849        );
2850    }
2851
2852    #[tokio::test]
2853    async fn merge_note_different_kinds_rejected() {
2854        let rt = rt();
2855        let tok = NamespaceToken::local();
2856        let into = rt
2857            .create_note(&tok, "observation", None, "Into", None, None, vec![])
2858            .await
2859            .unwrap();
2860        let from = rt
2861            .create_note(&tok, "decision", None, "From", None, None, vec![])
2862            .await
2863            .unwrap();
2864
2865        let result = rt
2866            .merge_note(
2867                &tok,
2868                into.id,
2869                from.id,
2870                EntityDedupMergePolicy::PreferInto,
2871                ContentMergeStrategy::Append,
2872                false,
2873            )
2874            .await;
2875        assert!(result.is_err(), "merging different note kinds must fail");
2876    }
2877
2878    #[tokio::test]
2879    async fn merge_note_dry_run_leaves_notes_unchanged() {
2880        let rt = rt();
2881        let tok = NamespaceToken::local();
2882        let into = rt
2883            .create_note(
2884                &tok,
2885                "observation",
2886                None,
2887                "Into content",
2888                None,
2889                None,
2890                vec![],
2891            )
2892            .await
2893            .unwrap();
2894        let from = rt
2895            .create_note(
2896                &tok,
2897                "observation",
2898                None,
2899                "From content",
2900                None,
2901                None,
2902                vec![],
2903            )
2904            .await
2905            .unwrap();
2906        let into_id = into.id;
2907        let from_id = from.id;
2908
2909        let summary = rt
2910            .merge_note(
2911                &tok,
2912                into_id,
2913                from_id,
2914                EntityDedupMergePolicy::PreferInto,
2915                ContentMergeStrategy::Append,
2916                true,
2917            )
2918            .await
2919            .unwrap();
2920
2921        assert!(summary.dry_run);
2922
2923        let store = rt.notes(&tok).unwrap();
2924        let into_after = store.get_note(into_id).await.unwrap().unwrap();
2925        let from_after = store.get_note(from_id).await.unwrap().unwrap();
2926        assert_eq!(
2927            into_after.content, "Into content",
2928            "dry_run must not mutate into-note"
2929        );
2930        assert_eq!(
2931            from_after.content, "From content",
2932            "dry_run must not mutate from-note"
2933        );
2934    }
2935
2936    // Merging two nameless notes with no embedding model configured: a raw SQL FTS
2937    // INSERT binding &merged_name directly would store SQL NULL for a nameless
2938    // note, while Fts5TextSearch::upsert_document stores an empty string:
2939    // note_fts_scalars must keep the round-trip field-identical.
2940    #[tokio::test]
2941    async fn merge_nameless_notes_fts_document_is_parity_correct() {
2942        use khive_storage::types::TextSearchRequest;
2943
2944        let rt = rt(); // in-memory runtime — no embedding model configured
2945        let tok = NamespaceToken::local();
2946
2947        let into = rt
2948            .create_note(
2949                &tok,
2950                "observation",
2951                None,
2952                "intosentinelzxq body",
2953                None,
2954                Some(serde_json::json!({"src": "into"})),
2955                vec![],
2956            )
2957            .await
2958            .expect("create into-note");
2959        let from = rt
2960            .create_note(
2961                &tok,
2962                "observation",
2963                None,
2964                "fromsentinelzxq body",
2965                None,
2966                None,
2967                vec![],
2968            )
2969            .await
2970            .expect("create from-note");
2971
2972        let into_id = into.id;
2973        let from_id = from.id;
2974
2975        rt.merge_note(
2976            &tok,
2977            into_id,
2978            from_id,
2979            EntityDedupMergePolicy::PreferInto,
2980            ContentMergeStrategy::Append,
2981            false,
2982        )
2983        .await
2984        .expect("merge_note must succeed");
2985
2986        let note_store = rt.notes(&tok).expect("note store");
2987        let merged_note = note_store
2988            .get_note(into_id)
2989            .await
2990            .expect("get_note")
2991            .expect("merged note must exist");
2992
2993        let expected = note_fts_document(&merged_note);
2994
2995        let fts = rt.text_for_notes(&tok).expect("FTS store");
2996        let stored = fts
2997            .get_document("local", into_id)
2998            .await
2999            .expect("get_document must not error")
3000            .expect("FTS document must exist after merge");
3001
3002        assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
3003        assert_eq!(
3004            stored.title, expected.title,
3005            "title (None for nameless note)"
3006        );
3007        assert_eq!(stored.body, expected.body, "body");
3008        assert_eq!(stored.namespace, expected.namespace, "namespace");
3009        assert_eq!(stored.kind, expected.kind, "kind");
3010
3011        assert!(
3012            stored.title.is_none(),
3013            "nameless merged note must have title=None in FTS (was NULL before fix)"
3014        );
3015
3016        // The merged note must be searchable by a unique token from the into-note body.
3017        let hits = fts
3018            .search(TextSearchRequest {
3019                query: "intosentinelzxq".to_string(),
3020                mode: khive_storage::types::TextQueryMode::Plain,
3021                filter: None,
3022                top_k: 10,
3023                snippet_chars: 0,
3024            })
3025            .await
3026            .expect("search");
3027        assert!(
3028            hits.iter().any(|h| h.subject_id == into_id),
3029            "merged note must be searchable by into-note content"
3030        );
3031    }
3032
3033    #[tokio::test]
3034    async fn update_edge_updates_properties() {
3035        use khive_storage::EdgeRelation;
3036        let rt = rt();
3037        let tok = NamespaceToken::local();
3038        let a = rt
3039            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3040            .await
3041            .unwrap();
3042        let b = rt
3043            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3044            .await
3045            .unwrap();
3046        let edge = rt
3047            .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
3048            .await
3049            .unwrap();
3050        let edge_id: Uuid = edge.id.into();
3051
3052        let updated = rt
3053            .update_edge(
3054                &tok,
3055                edge_id,
3056                EdgePatch {
3057                    properties: Some(serde_json::json!({"source": "manual"})),
3058                    ..Default::default()
3059                },
3060            )
3061            .await
3062            .unwrap();
3063
3064        assert_eq!(updated.metadata.as_ref().unwrap()["source"], "manual");
3065        assert!((updated.weight - 0.5).abs() < 0.001, "weight unchanged");
3066    }
3067
3068    // Merge must not crash when both entities share a common third-party edge
3069    // (duplicate triple after rewire): a double-ON-CONFLICT INSERT would
3070    // otherwise raise a UNIQUE constraint error and abort mid-transaction.
3071    #[tokio::test]
3072    async fn merge_entity_survives_shared_edge_to_third_party() {
3073        use khive_storage::EdgeRelation;
3074        let rt = rt();
3075        let tok = NamespaceToken::local();
3076
3077        // A and B will be merged; shared is the common target. `extends` is used
3078        // since concept→concept is a valid endpoint combination.
3079        let a = rt
3080            .create_entity(&tok, "concept", None, "A", None, None, vec![])
3081            .await
3082            .unwrap();
3083        let b = rt
3084            .create_entity(&tok, "concept", None, "B", None, None, vec![])
3085            .await
3086            .unwrap();
3087        let shared = rt
3088            .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
3089            .await
3090            .unwrap();
3091
3092        // Both A and B extend the same shared concept — this creates a duplicate
3093        // triple (A/B → shared, extends) that triggers the crash on rewire.
3094        rt.link(&tok, a.id, shared.id, EdgeRelation::Extends, 1.0, None)
3095            .await
3096            .unwrap();
3097        rt.link(&tok, b.id, shared.id, EdgeRelation::Extends, 1.0, None)
3098            .await
3099            .unwrap();
3100
3101        let summary = rt
3102            .merge_entity(
3103                &tok,
3104                a.id,
3105                b.id,
3106                crate::EntityDedupMergePolicy::PreferInto,
3107                ContentMergeStrategy::Append,
3108                false,
3109            )
3110            .await
3111            .expect(
3112                "C1: merge must succeed even when both entities share an edge to a third party",
3113            );
3114
3115        assert_eq!(summary.kept_id, a.id);
3116        assert_eq!(summary.removed_id, b.id);
3117        // A already had the Extends edge to shared; rewiring B->shared onto it
3118        // refreshes the existing row via ON CONFLICT DO UPDATE rather than
3119        // erroring. The invariant checked below is that exactly one live edge
3120        // A->shared remains.
3121        let a_edges = rt
3122            .list_edges(
3123                &tok,
3124                crate::EdgeListFilter {
3125                    source_id: Some(a.id),
3126                    target_id: Some(shared.id),
3127                    relations: vec![EdgeRelation::Extends],
3128                    ..Default::default()
3129                },
3130                10,
3131                0,
3132            )
3133            .await
3134            .unwrap();
3135        assert_eq!(
3136            a_edges.len(),
3137            1,
3138            "C1: exactly one live A→shared Extends edge must exist after merge; got: {a_edges:?}"
3139        );
3140
3141        // get_entity filters deleted_at IS NULL, so a tombstoned entity returns None.
3142        let b_after = rt.entities(&tok).unwrap().get_entity(b.id).await.unwrap();
3143        assert!(
3144            b_after.is_none(),
3145            "C3: from_entity must be tombstoned (get_entity returns None for deleted) after merge; got: {b_after:?}"
3146        );
3147    }
3148
3149    // merge_entity at the runtime level must reject cross-kind merges: without this
3150    // guard, a direct runtime caller could merge concept+project, silently
3151    // tombstoning the source entity, even though the pack handler also checks it.
3152    #[tokio::test]
3153    async fn merge_entity_cross_kind_rejected_at_runtime() {
3154        let rt = rt();
3155        let tok = NamespaceToken::local();
3156
3157        let concept = rt
3158            .create_entity(&tok, "concept", None, "H2Concept", None, None, vec![])
3159            .await
3160            .unwrap();
3161        let project = rt
3162            .create_entity(&tok, "project", None, "H2Project", None, None, vec![])
3163            .await
3164            .unwrap();
3165
3166        let err = rt
3167            .merge_entity(
3168                &tok,
3169                concept.id,
3170                project.id,
3171                crate::EntityDedupMergePolicy::PreferInto,
3172                ContentMergeStrategy::Append,
3173                false,
3174            )
3175            .await
3176            .expect_err("H2: cross-kind merge must be rejected by runtime");
3177        assert!(
3178            matches!(err, crate::RuntimeError::InvalidInput(_)),
3179            "H2: expected InvalidInput, got: {err:?}"
3180        );
3181
3182        let concept_after = rt.get_entity(&tok, concept.id).await;
3183        let project_after = rt.get_entity(&tok, project.id).await;
3184        assert!(
3185            concept_after.is_ok(),
3186            "H2: concept must remain live after rejected merge; got: {concept_after:?}"
3187        );
3188        assert!(
3189            project_after.is_ok(),
3190            "H2: project must remain live after rejected merge; got: {project_after:?}"
3191        );
3192    }
3193
3194    // Same-kind merge must succeed.
3195    #[tokio::test]
3196    async fn merge_entity_same_kind_succeeds() {
3197        let rt = rt();
3198        let tok = NamespaceToken::local();
3199
3200        let c1 = rt
3201            .create_entity(&tok, "concept", None, "Concept1", None, None, vec![])
3202            .await
3203            .unwrap();
3204        let c2 = rt
3205            .create_entity(&tok, "concept", None, "Concept2", None, None, vec![])
3206            .await
3207            .unwrap();
3208
3209        let summary = rt
3210            .merge_entity(
3211                &tok,
3212                c1.id,
3213                c2.id,
3214                crate::EntityDedupMergePolicy::PreferInto,
3215                ContentMergeStrategy::Append,
3216                false,
3217            )
3218            .await
3219            .expect("same-kind merge must succeed");
3220        assert_eq!(summary.kept_id, c1.id);
3221        assert_eq!(summary.removed_id, c2.id);
3222
3223        let c2_after = rt.entities(&tok).unwrap().get_entity(c2.id).await.unwrap();
3224        assert!(c2_after.is_none(), "from_entity must be tombstoned");
3225    }
3226
3227    // Cross-namespace merge_note must be denied on either ID.
3228
3229    #[tokio::test]
3230    async fn merge_note_cross_namespace_either_id_returns_not_found() {
3231        use crate::error::RuntimeError;
3232        use crate::Namespace;
3233
3234        let rt = rt();
3235        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3236        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3237
3238        let into_a = rt
3239            .create_note(&ns_a, "observation", None, "Into A", None, None, vec![])
3240            .await
3241            .unwrap();
3242        let from_a = rt
3243            .create_note(&ns_a, "observation", None, "From A", None, None, vec![])
3244            .await
3245            .unwrap();
3246        let note_b = rt
3247            .create_note(&ns_b, "observation", None, "Note B", None, None, vec![])
3248            .await
3249            .unwrap();
3250
3251        // foreign into_id: note_b belongs to ns_b, caller token is ns_a
3252        let foreign_into = rt
3253            .merge_note(
3254                &ns_a,
3255                note_b.id,
3256                from_a.id,
3257                EntityDedupMergePolicy::PreferInto,
3258                ContentMergeStrategy::Append,
3259                false,
3260            )
3261            .await;
3262        assert!(
3263            matches!(foreign_into, Err(RuntimeError::NotFound(_))),
3264            "foreign into_id must be denied before merge, got {foreign_into:?}"
3265        );
3266
3267        // foreign from_id: note_b belongs to ns_b, caller token is ns_a
3268        let foreign_from = rt
3269            .merge_note(
3270                &ns_a,
3271                into_a.id,
3272                note_b.id,
3273                EntityDedupMergePolicy::PreferInto,
3274                ContentMergeStrategy::Append,
3275                false,
3276            )
3277            .await;
3278        assert!(
3279            matches!(foreign_from, Err(RuntimeError::NotFound(_))),
3280            "foreign from_id must be denied before merge, got {foreign_from:?}"
3281        );
3282    }
3283
3284    // Cross-namespace update now succeeds (shared-brain model).
3285
3286    #[tokio::test]
3287    async fn update_entity_cross_namespace_succeeds() {
3288        use crate::Namespace;
3289
3290        let rt = rt();
3291        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3292        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3293
3294        let entity = rt
3295            .create_entity(
3296                &ns_a,
3297                "concept",
3298                None,
3299                "Alpha",
3300                Some("original"),
3301                None,
3302                vec![],
3303            )
3304            .await
3305            .unwrap();
3306
3307        let result = rt
3308            .update_entity(
3309                &ns_b,
3310                entity.id,
3311                EntityPatch {
3312                    name: Some("Updated".into()),
3313                    ..Default::default()
3314                },
3315            )
3316            .await;
3317
3318        assert!(
3319            result.is_ok(),
3320            "cross-namespace update must succeed in shared-brain OSS; got {result:?}"
3321        );
3322        assert_eq!(result.unwrap().name, "Updated");
3323    }
3324
3325    // merge_entity still requires both entities to be in the same namespace as
3326    // the token's write namespace (enforced at the SQL transaction layer, not the
3327    // runtime layer).  This is a merge-semantic constraint, not tenant isolation.
3328    #[tokio::test]
3329    async fn merge_entity_cross_namespace_ids_fail_at_sql_layer() {
3330        use crate::Namespace;
3331
3332        let rt = rt();
3333        let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
3334        let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
3335
3336        let into_a = rt
3337            .create_entity(&ns_a, "concept", None, "Into A", None, None, vec![])
3338            .await
3339            .unwrap();
3340        let from_a = rt
3341            .create_entity(&ns_a, "concept", None, "From A", None, None, vec![])
3342            .await
3343            .unwrap();
3344        let foreign_b = rt
3345            .create_entity(&ns_b, "concept", None, "Foreign B", None, None, vec![])
3346            .await
3347            .unwrap();
3348
3349        // foreign into_id: SQL read_merge_entity checks ns matches token namespace.
3350        let foreign_into = rt
3351            .merge_entity(
3352                &ns_a,
3353                foreign_b.id,
3354                from_a.id,
3355                EntityDedupMergePolicy::PreferInto,
3356                ContentMergeStrategy::Append,
3357                false,
3358            )
3359            .await;
3360        assert!(
3361            foreign_into.is_err(),
3362            "cross-namespace into_id must still fail at SQL layer; got {foreign_into:?}"
3363        );
3364
3365        // foreign from_id: same SQL constraint.
3366        let foreign_from = rt
3367            .merge_entity(
3368                &ns_a,
3369                into_a.id,
3370                foreign_b.id,
3371                EntityDedupMergePolicy::PreferInto,
3372                ContentMergeStrategy::Append,
3373                false,
3374            )
3375            .await;
3376        assert!(
3377            foreign_from.is_err(),
3378            "cross-namespace from_id must still fail at SQL layer; got {foreign_from:?}"
3379        );
3380
3381        // All three entities survive the failed merges.
3382        assert!(rt.get_entity(&ns_a, into_a.id).await.is_ok());
3383        assert!(rt.get_entity(&ns_a, from_a.id).await.is_ok());
3384        assert!(rt.get_entity(&ns_b, foreign_b.id).await.is_ok());
3385    }
3386
3387    // Parity: entity_fts_document must produce the same body/title as the
3388    // create_entity and update_entity FTS write paths.
3389    #[test]
3390    fn entity_fts_document_with_description() {
3391        let mut entity = Entity::new("local", "concept", "MyEntity");
3392        entity = entity.with_description("some description text");
3393        let doc = entity_fts_document(&entity);
3394        assert_eq!(doc.subject_id, entity.id);
3395        assert_eq!(doc.namespace, "local");
3396        assert_eq!(doc.title.as_deref(), Some("MyEntity"));
3397        assert_eq!(doc.body, "MyEntity some description text");
3398        assert_eq!(doc.kind, khive_types::SubstrateKind::Entity);
3399    }
3400
3401    #[test]
3402    fn entity_fts_document_without_description() {
3403        let entity = Entity::new("local", "concept", "NameOnly");
3404        let doc = entity_fts_document(&entity);
3405        assert_eq!(doc.title.as_deref(), Some("NameOnly"));
3406        assert_eq!(doc.body, "NameOnly");
3407    }
3408
3409    #[test]
3410    fn entity_fts_document_empty_description_uses_name_only() {
3411        let mut entity = Entity::new("local", "concept", "TitleOnly");
3412        entity = entity.with_description("");
3413        let doc = entity_fts_document(&entity);
3414        assert_eq!(
3415            doc.body, "TitleOnly",
3416            "empty description must not be appended"
3417        );
3418    }
3419
3420    // Cross-path equality: an entity created through the runtime (operations.rs
3421    // create_entity path) must produce a stored FTS document field-identical to
3422    // entity_fts_document() called on the same Entity.
3423    #[tokio::test]
3424    async fn entity_fts_document_matches_runtime_create_path() {
3425        let rt = rt();
3426        let tok = NamespaceToken::local();
3427
3428        let entity = rt
3429            .create_entity(
3430                &tok,
3431                "concept",
3432                None,
3433                "CrossPathTitle",
3434                Some("cross path description body"),
3435                Some(serde_json::json!({"key": "val"})),
3436                vec!["tag1".to_string()],
3437            )
3438            .await
3439            .expect("create_entity");
3440
3441        let fts = rt.text(&tok).expect("FTS store");
3442        let stored = fts
3443            .get_document("local", entity.id)
3444            .await
3445            .expect("get_document")
3446            .expect("document must exist after create_entity");
3447
3448        let expected = entity_fts_document(&entity);
3449
3450        assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
3451        assert_eq!(stored.kind, expected.kind, "kind");
3452        assert_eq!(stored.title, expected.title, "title");
3453        assert_eq!(stored.body, expected.body, "body");
3454        assert_eq!(stored.namespace, expected.namespace, "namespace");
3455    }
3456
3457    // Cross-path equality: update_entity must produce a stored FTS document
3458    // field-identical to entity_fts_document() on the updated Entity.
3459    #[tokio::test]
3460    async fn entity_fts_document_matches_runtime_update_path() {
3461        let rt = rt();
3462        let tok = NamespaceToken::local();
3463
3464        let entity = rt
3465            .create_entity(
3466                &tok,
3467                "concept",
3468                None,
3469                "OldName",
3470                Some("old desc"),
3471                None,
3472                vec![],
3473            )
3474            .await
3475            .expect("create_entity");
3476
3477        let updated = rt
3478            .update_entity(
3479                &tok,
3480                entity.id,
3481                EntityPatch {
3482                    name: Some("NewName".to_string()),
3483                    description: Some(Some("new desc".to_string())),
3484                    ..Default::default()
3485                },
3486            )
3487            .await
3488            .expect("update_entity");
3489
3490        let fts = rt.text(&tok).expect("FTS store");
3491        let stored = fts
3492            .get_document("local", updated.id)
3493            .await
3494            .expect("get_document")
3495            .expect("document must exist after update_entity");
3496
3497        let expected = entity_fts_document(&updated);
3498
3499        assert_eq!(stored.title, expected.title, "title after update");
3500        assert_eq!(stored.body, expected.body, "body after update");
3501    }
3502
3503    // Verify that merge_entity / merge_note delete from_id vectors from ALL
3504    // registered model vec tables, not just the default-model table. Uses the
3505    // same ConstVecProvider/ConstVecService pattern as operations.rs so no
3506    // real model files are required.
3507
3508    struct MergeTestVecService {
3509        dims: usize,
3510    }
3511
3512    #[async_trait::async_trait]
3513    impl lattice_embed::EmbeddingService for MergeTestVecService {
3514        async fn embed(
3515            &self,
3516            texts: &[String],
3517            _model: lattice_embed::EmbeddingModel,
3518        ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
3519            Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
3520        }
3521
3522        fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
3523            true
3524        }
3525
3526        fn name(&self) -> &'static str {
3527            "merge-test-const-vec"
3528        }
3529    }
3530
3531    struct MergeTestVecProvider {
3532        provider_name: String,
3533        dims: usize,
3534    }
3535
3536    impl MergeTestVecProvider {
3537        fn new(name: &str, dims: usize) -> Self {
3538            Self {
3539                provider_name: name.to_owned(),
3540                dims,
3541            }
3542        }
3543    }
3544
3545    #[async_trait::async_trait]
3546    impl crate::embedder_registry::EmbedderProvider for MergeTestVecProvider {
3547        fn name(&self) -> &str {
3548            &self.provider_name
3549        }
3550
3551        fn dimensions(&self) -> usize {
3552            self.dims
3553        }
3554
3555        async fn build(
3556            &self,
3557        ) -> crate::error::RuntimeResult<std::sync::Arc<dyn lattice_embed::EmbeddingService>>
3558        {
3559            Ok(std::sync::Arc::new(MergeTestVecService { dims: self.dims }))
3560        }
3561    }
3562
3563    /// merge_entity must delete from_id vectors from ALL registered model tables.
3564    ///
3565    /// Two custom embedders ("merge-vec-a", "merge-vec-b") are registered.  Both
3566    /// entities are embedded so each has a row in both model tables.  After merge,
3567    /// from_id must have zero surviving rows in either table.
3568    #[tokio::test]
3569    async fn merge_entity_clears_vectors_from_all_registered_models() {
3570        const DIMS: usize = 4;
3571        let rt = KhiveRuntime::memory().unwrap();
3572        rt.register_embedder(MergeTestVecProvider::new("merge-vec-a", DIMS));
3573        rt.register_embedder(MergeTestVecProvider::new("merge-vec-b", DIMS));
3574
3575        let ns_str = "merge-entity-vec-cleanup";
3576        let ns = crate::Namespace::parse(ns_str).unwrap();
3577        let tok = NamespaceToken::for_namespace(ns);
3578
3579        let into_e = rt
3580            .create_entity(
3581                &tok,
3582                "concept",
3583                None,
3584                "IntoVecEntity",
3585                Some("desc a"),
3586                None,
3587                vec![],
3588            )
3589            .await
3590            .expect("create into");
3591        let from_e = rt
3592            .create_entity(
3593                &tok,
3594                "concept",
3595                None,
3596                "FromVecEntity",
3597                Some("desc b"),
3598                None,
3599                vec![],
3600            )
3601            .await
3602            .expect("create from");
3603
3604        // Confirm both entities have vectors in both model tables before merge.
3605        let vs_a = rt.vectors_for_model(&tok, "merge-vec-a").unwrap();
3606        let vs_b = rt.vectors_for_model(&tok, "merge-vec-b").unwrap();
3607        use khive_storage::types::VectorSearchRequest;
3608        let query = vec![1.0_f32; DIMS];
3609        let pre_a = vs_a
3610            .search(VectorSearchRequest {
3611                query_vectors: vec![query.clone()],
3612                top_k: 100,
3613                namespace: Some(ns_str.to_string()),
3614                kind: Some(khive_types::SubstrateKind::Entity),
3615                embedding_model: Some("merge-vec-a".to_string()),
3616                filter: None,
3617                backend_hints: None,
3618            })
3619            .await
3620            .unwrap();
3621        assert!(
3622            pre_a.iter().any(|h| h.subject_id == into_e.id)
3623                && pre_a.iter().any(|h| h.subject_id == from_e.id),
3624            "both entities must be in model-a before merge; got {pre_a:?}"
3625        );
3626
3627        // model-b must ALSO hold both entities pre-merge, else the post-merge
3628        // model-b emptiness check below is vacuous (nothing to delete).
3629        let pre_b = vs_b
3630            .search(VectorSearchRequest {
3631                query_vectors: vec![query.clone()],
3632                top_k: 100,
3633                namespace: Some(ns_str.to_string()),
3634                kind: Some(khive_types::SubstrateKind::Entity),
3635                embedding_model: Some("merge-vec-b".to_string()),
3636                filter: None,
3637                backend_hints: None,
3638            })
3639            .await
3640            .unwrap();
3641        assert!(
3642            pre_b.iter().any(|h| h.subject_id == into_e.id)
3643                && pre_b.iter().any(|h| h.subject_id == from_e.id),
3644            "both entities must be in model-b before merge; got {pre_b:?}"
3645        );
3646
3647        rt.merge_entity(
3648            &tok,
3649            into_e.id,
3650            from_e.id,
3651            EntityDedupMergePolicy::PreferInto,
3652            ContentMergeStrategy::Append,
3653            false,
3654        )
3655        .await
3656        .expect("merge_entity");
3657
3658        let post_a = vs_a
3659            .search(VectorSearchRequest {
3660                query_vectors: vec![query.clone()],
3661                top_k: 100,
3662                namespace: Some(ns_str.to_string()),
3663                kind: Some(khive_types::SubstrateKind::Entity),
3664                embedding_model: Some("merge-vec-a".to_string()),
3665                filter: None,
3666                backend_hints: None,
3667            })
3668            .await
3669            .unwrap();
3670        let from_ids_a: Vec<_> = post_a
3671            .iter()
3672            .filter(|h| h.subject_id == from_e.id)
3673            .collect();
3674        assert!(
3675            from_ids_a.is_empty(),
3676            "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
3677        );
3678
3679        let post_b = vs_b
3680            .search(VectorSearchRequest {
3681                query_vectors: vec![query],
3682                top_k: 100,
3683                namespace: Some(ns_str.to_string()),
3684                kind: Some(khive_types::SubstrateKind::Entity),
3685                embedding_model: Some("merge-vec-b".to_string()),
3686                filter: None,
3687                backend_hints: None,
3688            })
3689            .await
3690            .unwrap();
3691        let from_ids_b: Vec<_> = post_b
3692            .iter()
3693            .filter(|h| h.subject_id == from_e.id)
3694            .collect();
3695        assert!(
3696            from_ids_b.is_empty(),
3697            "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
3698        );
3699    }
3700
3701    /// merge_note must delete from_id vectors from ALL registered model tables.
3702    ///
3703    /// Two custom embedders ("merge-note-vec-a", "merge-note-vec-b") are registered.
3704    /// Both notes are embedded so each has a row in both model tables.  After merge,
3705    /// from_id must have zero surviving rows in either table.
3706    #[tokio::test]
3707    async fn merge_note_clears_vectors_from_all_registered_models() {
3708        const DIMS: usize = 4;
3709        let rt = KhiveRuntime::memory().unwrap();
3710        rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-a", DIMS));
3711        rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-b", DIMS));
3712
3713        let ns_str = "merge-note-vec-cleanup";
3714        let ns = crate::Namespace::parse(ns_str).unwrap();
3715        let tok = NamespaceToken::for_namespace(ns);
3716
3717        let into_n = rt
3718            .create_note(
3719                &tok,
3720                "observation",
3721                None,
3722                "IntoVecNote content",
3723                None,
3724                None,
3725                vec![],
3726            )
3727            .await
3728            .expect("create into note");
3729        let from_n = rt
3730            .create_note(
3731                &tok,
3732                "observation",
3733                None,
3734                "FromVecNote content",
3735                None,
3736                None,
3737                vec![],
3738            )
3739            .await
3740            .expect("create from note");
3741
3742        let vs_a = rt.vectors_for_model(&tok, "merge-note-vec-a").unwrap();
3743        let vs_b = rt.vectors_for_model(&tok, "merge-note-vec-b").unwrap();
3744        use khive_storage::types::VectorSearchRequest;
3745        let query = vec![1.0_f32; DIMS];
3746
3747        let pre_a = vs_a
3748            .search(VectorSearchRequest {
3749                query_vectors: vec![query.clone()],
3750                top_k: 100,
3751                namespace: Some(ns_str.to_string()),
3752                kind: Some(khive_types::SubstrateKind::Note),
3753                embedding_model: Some("merge-note-vec-a".to_string()),
3754                filter: None,
3755                backend_hints: None,
3756            })
3757            .await
3758            .unwrap();
3759        assert!(
3760            pre_a.iter().any(|h| h.subject_id == into_n.id)
3761                && pre_a.iter().any(|h| h.subject_id == from_n.id),
3762            "both notes must be in model-a before merge; got {pre_a:?}"
3763        );
3764
3765        // model-b must ALSO hold both notes pre-merge, else the post-merge
3766        // model-b emptiness check below is vacuous (nothing to delete).
3767        let pre_b = vs_b
3768            .search(VectorSearchRequest {
3769                query_vectors: vec![query.clone()],
3770                top_k: 100,
3771                namespace: Some(ns_str.to_string()),
3772                kind: Some(khive_types::SubstrateKind::Note),
3773                embedding_model: Some("merge-note-vec-b".to_string()),
3774                filter: None,
3775                backend_hints: None,
3776            })
3777            .await
3778            .unwrap();
3779        assert!(
3780            pre_b.iter().any(|h| h.subject_id == into_n.id)
3781                && pre_b.iter().any(|h| h.subject_id == from_n.id),
3782            "both notes must be in model-b before merge; got {pre_b:?}"
3783        );
3784
3785        rt.merge_note(
3786            &tok,
3787            into_n.id,
3788            from_n.id,
3789            EntityDedupMergePolicy::PreferInto,
3790            ContentMergeStrategy::PreferInto,
3791            false,
3792        )
3793        .await
3794        .expect("merge_note");
3795
3796        let post_a = vs_a
3797            .search(VectorSearchRequest {
3798                query_vectors: vec![query.clone()],
3799                top_k: 100,
3800                namespace: Some(ns_str.to_string()),
3801                kind: Some(khive_types::SubstrateKind::Note),
3802                embedding_model: Some("merge-note-vec-a".to_string()),
3803                filter: None,
3804                backend_hints: None,
3805            })
3806            .await
3807            .unwrap();
3808        let from_ids_a: Vec<_> = post_a
3809            .iter()
3810            .filter(|h| h.subject_id == from_n.id)
3811            .collect();
3812        assert!(
3813            from_ids_a.is_empty(),
3814            "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
3815        );
3816
3817        let post_b = vs_b
3818            .search(VectorSearchRequest {
3819                query_vectors: vec![query],
3820                top_k: 100,
3821                namespace: Some(ns_str.to_string()),
3822                kind: Some(khive_types::SubstrateKind::Note),
3823                embedding_model: Some("merge-note-vec-b".to_string()),
3824                filter: None,
3825                backend_hints: None,
3826            })
3827            .await
3828            .unwrap();
3829        let from_ids_b: Vec<_> = post_b
3830            .iter()
3831            .filter(|h| h.subject_id == from_n.id)
3832            .collect();
3833        assert!(
3834            from_ids_b.is_empty(),
3835            "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
3836        );
3837    }
3838
3839    // Cross-path equality: merge_entity must produce a stored FTS document for
3840    // the kept entity that is field-identical to entity_fts_document().
3841    #[tokio::test]
3842    async fn entity_fts_document_matches_runtime_merge_path() {
3843        let rt = rt();
3844        let tok = NamespaceToken::local();
3845
3846        let into_e = rt
3847            .create_entity(
3848                &tok,
3849                "concept",
3850                None,
3851                "IntoEntity",
3852                Some("into desc"),
3853                None,
3854                vec![],
3855            )
3856            .await
3857            .expect("create into");
3858        let from_e = rt
3859            .create_entity(
3860                &tok,
3861                "concept",
3862                None,
3863                "FromEntity",
3864                Some("from desc"),
3865                None,
3866                vec![],
3867            )
3868            .await
3869            .expect("create from");
3870
3871        let summary = rt
3872            .merge_entity(
3873                &tok,
3874                into_e.id,
3875                from_e.id,
3876                EntityDedupMergePolicy::PreferInto,
3877                ContentMergeStrategy::Append,
3878                false,
3879            )
3880            .await
3881            .expect("merge_entity");
3882
3883        let kept = rt
3884            .get_entity(&tok, summary.kept_id)
3885            .await
3886            .expect("get kept");
3887
3888        let fts = rt.text(&tok).expect("FTS store");
3889        let stored = fts
3890            .get_document("local", kept.id)
3891            .await
3892            .expect("get_document")
3893            .expect("FTS document must exist for kept entity after merge");
3894
3895        let expected = entity_fts_document(&kept);
3896
3897        assert_eq!(stored.title, expected.title, "title after merge");
3898        assert_eq!(stored.body, expected.body, "body after merge");
3899    }
3900}