1use std::any::Any;
12use std::collections::{HashMap, HashSet, VecDeque};
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use uuid::Uuid;
17
18use khive_db::{pool::RuntimeWriteOperation, SqliteError};
19use khive_storage::note::{FilterOp, Note, NoteFilter, PropertyFilter};
20use khive_storage::types::{EdgeFilter, PageRequest, SqlValue, TextDocument};
21use khive_storage::{AtomicUnitOp, EdgeRelation, Entity, SqlStatement, SubstrateKind};
22use khive_types::{Details, EdgeEndpointRule, EventKind, KhiveError};
23use rusqlite::OptionalExtension;
24
25use crate::error::{RuntimeError, RuntimeResult};
26use crate::event_store_guard::EventAttribution;
27use crate::operations::{base_entity_rule_allows, canonical_edge_endpoints, endpoint_matches};
28use crate::runtime::{KhiveRuntime, NamespaceToken};
29
30#[cfg(test)]
39pub(crate) mod race_seam {
40 use std::sync::Arc;
41 use tokio::sync::Barrier;
42
43 tokio::task_local! {
44 pub(crate) static AFTER_READ_BARRIER: Arc<Barrier>;
45 pub(crate) static BEFORE_ENTITY_INDEX_PUBLISH: Arc<(Barrier, Barrier)>;
46 pub(crate) static BEFORE_ENTITY_VECTOR_PUBLISH: Arc<(Barrier, Barrier)>;
47 }
48
49 pub(crate) async fn pause_after_read() {
50 if let Ok(barrier) = AFTER_READ_BARRIER.try_with(Arc::clone) {
51 barrier.wait().await;
52 }
53 }
54
55 pub(crate) async fn pause_before_entity_index_publish() {
56 if let Ok(barriers) = BEFORE_ENTITY_INDEX_PUBLISH.try_with(Arc::clone) {
57 barriers.0.wait().await;
58 barriers.1.wait().await;
59 }
60 }
61
62 pub(crate) async fn pause_before_entity_vector_publish() {
63 if let Ok(barriers) = BEFORE_ENTITY_VECTOR_PUBLISH.try_with(Arc::clone) {
64 barriers.0.wait().await;
65 barriers.1.wait().await;
66 }
67 }
68}
69
70pub(crate) fn stale_note_snapshot_error(id: Uuid) -> RuntimeError {
71 RuntimeError::Khive(KhiveError::conflict(format!(
72 "note {id} changed concurrently after it was read; retry with fresh state"
73 )))
74}
75
76pub(crate) fn stale_entity_snapshot_error(id: Uuid) -> RuntimeError {
77 RuntimeError::Khive(KhiveError::conflict(format!(
78 "entity {id} changed concurrently after it was read; retry with fresh state"
79 )))
80}
81
82pub(crate) fn stale_edge_snapshot_error(id: Uuid) -> RuntimeError {
83 RuntimeError::Khive(KhiveError::conflict(format!(
84 "edge {id} changed concurrently after it was read; retry with fresh state"
85 )))
86}
87
88#[derive(Clone, Debug, Default)]
94struct EmbeddingModelPlan {
95 model_names: Vec<String>,
96}
97
98impl EmbeddingModelPlan {
99 fn capture(runtime: &KhiveRuntime) -> Self {
100 Self {
101 model_names: runtime.registered_embedding_model_names(),
102 }
103 }
104
105 fn is_empty(&self) -> bool {
106 self.model_names.is_empty()
107 }
108
109 fn model_names(&self) -> &[String] {
110 &self.model_names
111 }
112
113 fn vector_tables(&self) -> Vec<String> {
114 self.model_names
115 .iter()
116 .map(|name| format!("vec_{}", crate::config::sanitize_key(name)))
117 .collect()
118 }
119}
120
121#[derive(Clone, Debug, Default)]
148pub struct EntityPatch {
149 pub name: Option<String>,
150 pub description: Option<Option<String>>,
151 pub properties: Option<Value>,
152 pub tags: Option<Vec<String>>,
153 pub entity_type: Option<Option<String>>,
154}
155
156#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(rename_all = "snake_case")]
159pub enum EntityDedupMergePolicy {
160 #[default]
163 PreferInto,
164 PreferFrom,
166 Union,
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub enum EntityMergeGuard {
173 EntityKind,
174 NameSimilarity,
175 ProjectCompatibility,
176}
177
178impl EntityMergeGuard {
179 pub fn as_str(self) -> &'static str {
180 match self {
181 Self::EntityKind => "entity_kind",
182 Self::NameSimilarity => "name_similarity",
183 Self::ProjectCompatibility => "project_compatibility",
184 }
185 }
186
187 pub fn compared(self) -> &'static str {
193 match self {
194 Self::EntityKind => "the records' entity kinds",
195 Self::NameSimilarity => "the records' names",
196 Self::ProjectCompatibility => "the project lists in the records' properties",
197 }
198 }
199}
200
201pub fn validate_entity_merge_floor(into: &Entity, from: &Entity) -> Result<(), EntityMergeGuard> {
203 if into.kind != from.kind {
204 return Err(EntityMergeGuard::EntityKind);
205 }
206 if !names_are_similar(&into.name, &from.name) {
207 return Err(EntityMergeGuard::NameSimilarity);
208 }
209 if projects_are_disjoint(into, from) {
210 return Err(EntityMergeGuard::ProjectCompatibility);
211 }
212 Ok(())
213}
214
215pub fn entity_merge_guard_refusal_message(guard: EntityMergeGuard) -> String {
223 format!(
224 "entity merge refused by the {} check, which compared {}; make the records agree on \
225 that check before merging, or ask an operator to authorize an override",
226 guard.as_str(),
227 guard.compared()
228 )
229}
230
231pub fn entity_merge_guard_compared_values(
237 guard: EntityMergeGuard,
238 into: &Entity,
239 from: &Entity,
240) -> (Value, Value) {
241 let projects = |entity: &Entity| {
242 entity
243 .properties
244 .as_ref()
245 .and_then(|properties| properties.get("projects"))
246 .cloned()
247 .unwrap_or(Value::Null)
248 };
249 match guard {
250 EntityMergeGuard::EntityKind => (
251 Value::String(into.kind.clone()),
252 Value::String(from.kind.clone()),
253 ),
254 EntityMergeGuard::NameSimilarity => (
255 Value::String(into.name.clone()),
256 Value::String(from.name.clone()),
257 ),
258 EntityMergeGuard::ProjectCompatibility => (projects(into), projects(from)),
259 }
260}
261
262pub fn entity_merge_guard_error(guard: EntityMergeGuard) -> RuntimeError {
264 RuntimeError::Khive(
265 KhiveError::conflict(entity_merge_guard_refusal_message(guard)).with_details(Details::new(
266 [("guard", guard.as_str()), ("compared", guard.compared())],
267 )),
268 )
269}
270
271fn names_are_similar(left: &str, right: &str) -> bool {
272 let left = normalize_name(left);
273 let right = normalize_name(right);
274 if left.is_empty() || right.is_empty() {
275 return false;
276 }
277 if left == right {
278 return true;
279 }
280
281 let shorter_len = left.chars().count().min(right.chars().count());
282 if shorter_len >= 3 && (left.starts_with(&right) || right.starts_with(&left)) {
283 return true;
284 }
285
286 let left_trigrams = trigrams(&left);
287 let right_trigrams = trigrams(&right);
288 if left_trigrams.is_empty() || right_trigrams.is_empty() {
289 return false;
290 }
291 let overlap = left_trigrams.intersection(&right_trigrams).count();
292 overlap.saturating_mul(4) >= left_trigrams.len().saturating_add(right_trigrams.len())
293}
294
295fn normalize_name(name: &str) -> String {
296 let mut normalized = String::with_capacity(name.len());
297 let mut pending_space = false;
298 for ch in name.chars().flat_map(char::to_lowercase) {
299 if ch.is_whitespace() {
300 pending_space = !normalized.is_empty();
301 } else {
302 if pending_space {
303 normalized.push(' ');
304 pending_space = false;
305 }
306 normalized.push(ch);
307 }
308 }
309 normalized
310}
311
312fn trigrams(value: &str) -> HashSet<[char; 3]> {
313 let chars: Vec<char> = value.chars().collect();
314 chars
315 .windows(3)
316 .map(|window| [window[0], window[1], window[2]])
317 .collect()
318}
319
320fn projects_are_disjoint(into: &Entity, from: &Entity) -> bool {
321 let Some(into_projects) = into
322 .properties
323 .as_ref()
324 .and_then(|properties| properties.get("projects"))
325 .and_then(Value::as_array)
326 else {
327 return false;
328 };
329 let Some(from_projects) = from
330 .properties
331 .as_ref()
332 .and_then(|properties| properties.get("projects"))
333 .and_then(Value::as_array)
334 else {
335 return false;
336 };
337 if into_projects.is_empty() || from_projects.is_empty() {
338 return false;
339 }
340
341 let (indexed, candidates) = if into_projects.len() <= from_projects.len() {
342 (into_projects, from_projects)
343 } else {
344 (from_projects, into_projects)
345 };
346 let mut indexed_strings = HashSet::new();
347 let mut indexed_values = HashSet::new();
348 for value in indexed {
349 if let Some(value) = value.as_str() {
350 indexed_strings.insert(normalize_project_string(value));
351 } else {
352 indexed_values.insert(value.clone());
353 }
354 }
355 !candidates.iter().any(|candidate| {
356 if let Some(candidate) = candidate.as_str() {
357 indexed_strings.contains(&normalize_project_string(candidate))
358 } else {
359 indexed_values.contains(candidate)
360 }
361 })
362}
363
364fn normalize_project_string(value: &str) -> String {
365 value.trim().to_ascii_lowercase()
366}
367
368#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
370#[serde(rename_all = "snake_case")]
371pub enum ContentMergeStrategy {
372 #[default]
373 Append,
374 PreferInto,
375 PreferFrom,
376}
377
378#[derive(Clone, Debug, Serialize, Deserialize)]
380pub struct MergeSummary {
381 pub kept_id: Uuid,
382 pub removed_id: Uuid,
383 pub edges_rewired: usize,
384 #[serde(default)]
393 pub edges_self_loop_dropped: usize,
394 #[serde(default)]
399 pub self_loop_edge_preimages: Vec<MergeEdgePreimage>,
400 #[serde(default)]
406 pub edges_contract_skipped: usize,
407 #[serde(default)]
411 pub edge_conflict_preimages: Vec<MergeEdgeConflictPreimage>,
412 pub properties_merged: usize,
413 pub tags_unioned: usize,
414 pub content_appended: bool,
415 pub dry_run: bool,
416 #[serde(default)]
420 pub tx_budget: MergeTxBudgetReport,
421 #[serde(skip)]
423 pub embedding_truncation: crate::retrieval::EmbeddingTruncationReport,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub post_commit_reindex_error: Option<String>,
428}
429
430#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
436pub struct MergeEdgePreimage {
437 pub id: Uuid,
438 pub namespace: String,
439 pub source_id: Uuid,
440 pub target_id: Uuid,
441 pub relation: String,
442 pub weight: f64,
443 pub created_at: i64,
444 pub updated_at: i64,
445 pub deleted_at: Option<i64>,
446 pub target_backend: Option<String>,
447 pub metadata: Option<Value>,
448}
449
450#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
452pub struct MergeEdgeConflictPreimage {
453 pub surviving_edge_id: Uuid,
454 pub dropped_edge: MergeEdgePreimage,
455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
459 pub incident_edge_preimages: Vec<MergeEdgePreimage>,
460}
461
462const MERGE_TX_MAX_ROWS: usize = 50_000;
469
470const MERGE_TX_MAX_BYTES: usize = 32 * 1024 * 1024;
474
475#[derive(Clone, Copy, Debug)]
485pub struct MergeTxLimits {
486 pub max_rows: usize,
487 pub max_bytes: usize,
488}
489
490impl Default for MergeTxLimits {
491 fn default() -> Self {
492 Self {
493 max_rows: MERGE_TX_MAX_ROWS,
494 max_bytes: MERGE_TX_MAX_BYTES,
495 }
496 }
497}
498
499#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
501pub struct MergeTxBudgetReport {
502 pub rows_charged: usize,
503 pub bytes_charged: usize,
504 pub max_rows: usize,
505 pub max_bytes: usize,
506}
507
508struct MergeTxBudget {
510 limits: MergeTxLimits,
511 rows: usize,
512 bytes: usize,
513}
514
515impl MergeTxBudget {
516 fn new(limits: MergeTxLimits) -> Self {
517 Self {
518 limits,
519 rows: 0,
520 bytes: 0,
521 }
522 }
523
524 fn charge(&mut self, rows: usize, bytes: usize, context: &str) -> Result<(), SqliteError> {
528 self.rows = self.rows.saturating_add(rows);
529 self.bytes = self.bytes.saturating_add(bytes);
530 if self.rows > self.limits.max_rows || self.bytes > self.limits.max_bytes {
531 return Err(SqliteError::InvalidData(format!(
532 "merge transaction budget exceeded while {context}: {} rows / {} bytes \
533 materialized (limits {} rows / {} bytes); the merge was rejected before \
534 materializing further state — curate the incident edges down or merge in \
535 smaller steps",
536 self.rows, self.bytes, self.limits.max_rows, self.limits.max_bytes
537 )));
538 }
539 Ok(())
540 }
541
542 fn report(&self) -> MergeTxBudgetReport {
543 MergeTxBudgetReport {
544 rows_charged: self.rows,
545 bytes_charged: self.bytes,
546 max_rows: self.limits.max_rows,
547 max_bytes: self.limits.max_bytes,
548 }
549 }
550}
551
552fn edge_row_budget_bytes(edge: &EdgeRow) -> usize {
555 96 + edge.namespace.len()
556 + edge.relation.len()
557 + edge.target_backend.as_deref().map_or(0, str::len)
558 + edge.metadata.as_deref().map_or(0, str::len)
559}
560
561#[derive(Clone, Debug, Default)]
566pub struct EdgePatch {
567 pub relation: Option<EdgeRelation>,
568 pub weight: Option<f64>,
569 pub properties: Option<Value>,
570}
571
572#[derive(Clone, Debug, Default)]
575pub struct NoteUpdatePolicy {
576 kind: Option<String>,
577 null_clearing_properties: &'static [&'static str],
578}
579
580impl NoteUpdatePolicy {
581 pub(crate) fn for_kind(kind: &str, null_clearing_properties: &'static [&'static str]) -> Self {
582 Self {
583 kind: Some(kind.to_owned()),
584 null_clearing_properties,
585 }
586 }
587}
588
589#[derive(Clone, Debug, Default)]
596pub struct NotePatch {
597 pub name: Option<Option<String>>,
598 pub content: Option<String>,
599 pub salience: Option<Option<f64>>,
600 pub decay_factor: Option<Option<f64>>,
601 pub properties: Option<Value>,
602 pub(crate) kind_status: Option<String>,
603 pub write_options: crate::note_write::NoteWriteOptions,
604 pub(crate) update_policy: NoteUpdatePolicy,
605}
606
607pub(crate) fn normalize_note_update_tags(args: &mut Value) -> RuntimeResult<()> {
611 let args = args
612 .as_object_mut()
613 .ok_or_else(|| RuntimeError::InvalidInput("update arguments must be an object".into()))?;
614 let Some(tags) = args.get("tags").filter(|value| !value.is_null()) else {
615 return Ok(());
616 };
617 let tags: Vec<String> = serde_json::from_value(tags.clone()).map_err(|error| {
618 RuntimeError::InvalidInput(format!("tags must be an array of strings: {error}"))
619 })?;
620 let mut properties = match args.get("properties") {
621 None | Some(Value::Null) => serde_json::Map::new(),
622 Some(Value::Object(properties)) => properties.clone(),
623 Some(_) => {
624 return Err(RuntimeError::InvalidInput(
625 "properties must be an object".into(),
626 ));
627 }
628 };
629 properties.insert("tags".into(), serde_json::json!(tags));
630 args.insert("properties".into(), Value::Object(properties));
631 args.remove("tags");
634 Ok(())
635}
636
637impl NotePatch {
638 pub fn new(
641 name: Option<Option<String>>,
642 content: Option<String>,
643 salience: Option<Option<f64>>,
644 decay_factor: Option<Option<f64>>,
645 properties: Option<Value>,
646 ) -> Self {
647 Self {
648 name,
649 content,
650 salience,
651 decay_factor,
652 properties,
653 kind_status: None,
654 write_options: Default::default(),
655 update_policy: Default::default(),
656 }
657 }
658
659 pub fn with_write_options(mut self, options: crate::note_write::NoteWriteOptions) -> Self {
660 self.write_options = options;
661 self
662 }
663
664 pub fn with_update_policy(mut self, policy: NoteUpdatePolicy) -> Self {
667 self.update_policy = policy;
668 self
669 }
670}
671
672#[derive(Clone, Debug, Default)]
674pub struct EdgeListFilter {
675 pub source_id: Option<Uuid>,
676 pub target_id: Option<Uuid>,
677 pub relations: Vec<EdgeRelation>,
679 pub min_weight: Option<f64>,
680 pub max_weight: Option<f64>,
681}
682
683impl From<EdgeListFilter> for EdgeFilter {
684 fn from(f: EdgeListFilter) -> Self {
685 EdgeFilter {
686 source_ids: f.source_id.into_iter().collect(),
687 target_ids: f.target_id.into_iter().collect(),
688 relations: f.relations,
689 min_weight: f.min_weight,
690 max_weight: f.max_weight,
691 ..Default::default()
692 }
693 }
694}
695
696#[derive(Clone, Copy, Debug, PartialEq, Eq)]
701enum EntityMergeValidation {
702 LegacyKind,
703 SafetyFloor,
704 Forced,
705}
706
707#[derive(Debug)]
708enum EntityMergeRefusal {
709 LegacyKind {
710 into_id: Uuid,
711 into_kind: String,
712 from_id: Uuid,
713 from_kind: String,
714 },
715 SafetyFloor(EntityMergeGuard),
716}
717
718impl EntityMergeRefusal {
719 fn into_runtime_error(self) -> RuntimeError {
720 match self {
721 Self::LegacyKind {
722 into_id,
723 into_kind,
724 from_id,
725 from_kind,
726 } => RuntimeError::InvalidInput(format!(
727 "cannot merge entities of different kinds: into={into_id} ({into_kind}), \
728 from={from_id} ({from_kind}); merge requires both entities to share the same kind"
729 )),
730 Self::SafetyFloor(guard) => entity_merge_guard_error(guard),
731 }
732 }
733}
734
735#[derive(Debug)]
736enum MergeSqlError {
737 Sqlite(SqliteError),
738 Refusal(RuntimeError),
739}
740
741impl std::fmt::Display for MergeSqlError {
742 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
743 match self {
744 Self::Sqlite(error) => std::fmt::Display::fmt(error, f),
745 Self::Refusal(_) => f.write_str("merge refused by transactional policy"),
746 }
747 }
748}
749
750impl std::error::Error for MergeSqlError {}
751
752impl From<SqliteError> for MergeSqlError {
753 fn from(error: SqliteError) -> Self {
754 Self::Sqlite(error)
755 }
756}
757
758impl From<rusqlite::Error> for MergeSqlError {
759 fn from(error: rusqlite::Error) -> Self {
760 Self::Sqlite(SqliteError::Rusqlite(error))
761 }
762}
763
764struct MergeEventContext {
768 attribution: EventAttribution,
769 reason: Option<String>,
770 force: bool,
771 strategy: EntityDedupMergePolicy,
772 content_strategy: ContentMergeStrategy,
773 kind: EventKind,
774 substrate: SubstrateKind,
775 event_id: Option<Uuid>,
776}
777
778fn append_merge_event_in_transaction(
779 conn: &rusqlite::Connection,
780 context: MergeEventContext,
781 summary: &MergeSummary,
782 namespace: &str,
783) -> Result<(), MergeSqlError> {
784 let policy = match context.strategy {
785 EntityDedupMergePolicy::PreferInto => "prefer_into",
786 EntityDedupMergePolicy::PreferFrom => "prefer_from",
787 EntityDedupMergePolicy::Union => "union",
788 };
789 let mut payload = serde_json::json!({
790 "into_id": summary.kept_id,
791 "from_id": summary.removed_id,
792 "policy": policy,
793 "content_strategy": format!("{:?}", context.content_strategy),
794 "edges_rewired": summary.edges_rewired,
795 "edges_self_loop_dropped": summary.edges_self_loop_dropped,
796 "self_loop_edge_preimages": &summary.self_loop_edge_preimages,
797 "edges_contract_skipped": summary.edges_contract_skipped,
798 "edge_conflict_preimages": &summary.edge_conflict_preimages,
799 });
800 if let Some(reason) = context.reason {
801 payload["reason"] = serde_json::Value::String(reason);
802 }
803 if context.force {
804 payload["force"] = serde_json::Value::Bool(true);
805 }
806 let mut event =
807 khive_storage::event::Event::new(namespace, "merge", context.kind, context.substrate, "")
808 .with_target(summary.kept_id)
809 .with_payload(payload);
810 if let Some(event_id) = context.event_id {
811 event.id = event_id;
812 }
813 let event = context.attribution.stamp(event);
814 khive_db::stores::event::append_event_in_transaction(conn, &event)?;
815 Ok(())
816}
817
818fn recover_rolled_back_merge_refusal(
821 error: khive_storage::StorageError,
822) -> Result<RuntimeError, khive_storage::StorageError> {
823 use khive_storage::{StorageError, WriterTaskRequestState};
824
825 let source = match error {
826 StorageError::WriterTaskRequestFailed {
827 request_state: WriterTaskRequestState::TransactionRolledBack,
828 source,
829 } => source,
830 error => return Err(error),
831 };
832 let source = match *source {
833 StorageError::Driver {
834 capability,
835 operation,
836 source,
837 } => match source.downcast::<MergeSqlError>() {
838 Ok(error) => match *error {
839 MergeSqlError::Refusal(error) => return Ok(error),
840 error => StorageError::driver(capability, operation, error),
841 },
842 Err(source) => StorageError::Driver {
843 capability,
844 operation,
845 source,
846 },
847 },
848 error => error,
849 };
850 Err(StorageError::WriterTaskRequestFailed {
851 request_state: WriterTaskRequestState::TransactionRolledBack,
852 source: Box::new(source),
853 })
854}
855
856fn map_merge_entity_storage_error(error: khive_storage::StorageError) -> RuntimeError {
857 let error = match recover_rolled_back_merge_refusal(error) {
858 Ok(refusal) => return refusal,
859 Err(error) => error,
860 };
861 match error {
862 khive_storage::StorageError::Driver {
863 capability,
864 operation,
865 source,
866 } => match source.downcast::<MergeSqlError>() {
867 Ok(error) => match *error {
868 MergeSqlError::Sqlite(error) => RuntimeError::Sqlite(error),
869 MergeSqlError::Refusal(error) => error,
870 },
871 Err(source) => RuntimeError::Storage(khive_storage::StorageError::Driver {
872 capability,
873 operation,
874 source,
875 }),
876 },
877 error => RuntimeError::Storage(error),
878 }
879}
880
881fn map_merge_note_storage_error(error: khive_storage::StorageError) -> RuntimeError {
882 let error = match recover_rolled_back_merge_refusal(error) {
883 Ok(refusal) => return refusal,
884 Err(error) => error,
885 };
886 match error {
887 khive_storage::StorageError::Driver {
888 capability,
889 operation,
890 source,
891 } => match source.downcast::<MergeSqlError>() {
892 Ok(error) => match *error {
893 MergeSqlError::Refusal(error) => error,
894 MergeSqlError::Sqlite(error) => RuntimeError::Storage(
896 khive_storage::StorageError::driver(capability, operation, error),
897 ),
898 },
899 Err(source) => RuntimeError::Storage(khive_storage::StorageError::Driver {
900 capability,
901 operation,
902 source,
903 }),
904 },
905 error => RuntimeError::Storage(error),
906 }
907}
908
909#[derive(Clone)]
913struct EdgeRow {
914 id: Uuid,
915 namespace: String,
921 source_id: Uuid,
922 target_id: Uuid,
923 relation: String,
924 weight: f64,
925 created_at: i64,
926 updated_at: i64,
927 deleted_at: Option<i64>,
928 target_backend: Option<String>,
929 metadata: Option<String>,
930}
931
932fn edge_row_preimage(edge: &EdgeRow) -> Result<MergeEdgePreimage, SqliteError> {
933 let metadata = edge
934 .metadata
935 .as_deref()
936 .map(serde_json::from_str)
937 .transpose()
938 .map_err(|error| SqliteError::InvalidData(error.to_string()))?;
939 Ok(MergeEdgePreimage {
940 id: edge.id,
941 namespace: edge.namespace.clone(),
942 source_id: edge.source_id,
943 target_id: edge.target_id,
944 relation: edge.relation.clone(),
945 weight: edge.weight,
946 created_at: edge.created_at,
947 updated_at: edge.updated_at,
948 deleted_at: edge.deleted_at,
949 target_backend: edge.target_backend.clone(),
950 metadata,
951 })
952}
953
954fn collect_conflict_incident_edge_preimages(
960 conn: &rusqlite::Connection,
961 root_edge_id: Uuid,
962 original_edges: &HashMap<Uuid, EdgeRow>,
963 budget: &mut MergeTxBudget,
964) -> Result<Vec<MergeEdgePreimage>, SqliteError> {
965 let parse_id =
966 |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
967 let mut queue = VecDeque::from([root_edge_id]);
968 let mut seen = HashSet::from([root_edge_id]);
969 let mut preimages = Vec::new();
970
971 while let Some(target_edge_id) = queue.pop_front() {
972 let mut stmt = conn.prepare(
973 "SELECT id, namespace, source_id, target_id, relation, weight, created_at, \
974 updated_at, deleted_at, target_backend, metadata \
975 FROM graph_edges WHERE source_id = ?1 OR target_id = ?1 ORDER BY id",
976 )?;
977 let mut rows = stmt.query(rusqlite::params![target_edge_id.to_string()])?;
978 while let Some(row) = rows.next()? {
979 let edge = EdgeRow {
980 id: parse_id(row.get(0)?)?,
981 namespace: row.get(1)?,
982 source_id: parse_id(row.get(2)?)?,
983 target_id: parse_id(row.get(3)?)?,
984 relation: row.get(4)?,
985 weight: row.get(5)?,
986 created_at: row.get(6)?,
987 updated_at: row.get(7)?,
988 deleted_at: row.get(8)?,
989 target_backend: row.get(9)?,
990 metadata: row.get(10)?,
991 };
992 budget.charge(
993 1,
994 edge_row_budget_bytes(&edge),
995 "collecting conflict cascade rows",
996 )?;
997 if !seen.insert(edge.id) {
998 continue;
999 }
1000 let preimage = match original_edges.get(&edge.id) {
1001 Some(original) => edge_row_preimage(original)?,
1002 None => edge_row_preimage(&edge)?,
1003 };
1004 queue.push_back(edge.id);
1005 preimages.push(preimage);
1006 }
1007 }
1008
1009 Ok(preimages)
1010}
1011
1012fn delete_conflict_incident_edges(
1013 conn: &rusqlite::Connection,
1014 preimages: &[MergeEdgePreimage],
1015) -> Result<(), SqliteError> {
1016 for edge in preimages.iter().rev() {
1017 conn.execute(
1018 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
1019 rusqlite::params![&edge.namespace, edge.id.to_string()],
1020 )?;
1021 }
1022 Ok(())
1023}
1024
1025type MergeEdgeEndpointInfo = (&'static str, String, Option<String>);
1034
1035fn resolve_merge_edge_endpoint(
1036 conn: &rusqlite::Connection,
1037 id: Uuid,
1038) -> Result<Option<MergeEdgeEndpointInfo>, SqliteError> {
1039 let id_str = id.to_string();
1040 if let Some((kind, entity_type)) = conn
1041 .query_row(
1042 "SELECT kind, entity_type FROM entities WHERE id = ?1",
1043 rusqlite::params![&id_str],
1044 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
1045 )
1046 .optional()
1047 .map_err(SqliteError::Rusqlite)?
1048 {
1049 return Ok(Some(("entity", kind, entity_type)));
1050 }
1051 if let Some(kind) = conn
1052 .query_row(
1053 "SELECT kind FROM notes WHERE id = ?1",
1054 rusqlite::params![&id_str],
1055 |row| row.get::<_, String>(0),
1056 )
1057 .optional()
1058 .map_err(SqliteError::Rusqlite)?
1059 {
1060 return Ok(Some(("note", kind, None)));
1061 }
1062 Ok(None)
1063}
1064
1065fn resolve_merge_edge_endpoint_budgeted(
1069 conn: &rusqlite::Connection,
1070 id: Uuid,
1071 budget: &mut MergeTxBudget,
1072) -> Result<Option<MergeEdgeEndpointInfo>, SqliteError> {
1073 let info = resolve_merge_edge_endpoint(conn, id)?;
1074 if let Some((_, kind, entity_type)) = &info {
1075 budget.charge(
1076 1,
1077 kind.len() + entity_type.as_deref().map_or(0, str::len),
1078 "resolving rewire endpoint contracts",
1079 )?;
1080 }
1081 Ok(info)
1082}
1083
1084#[allow(clippy::too_many_arguments)]
1102fn merge_rewire_endpoint_contract_allows(
1103 pack_rules: &[EdgeEndpointRule],
1104 relation: EdgeRelation,
1105 src_sub: &str,
1106 src_kind: &str,
1107 src_type: Option<&str>,
1108 tgt_sub: &str,
1109 tgt_kind: &str,
1110 tgt_type: Option<&str>,
1111) -> bool {
1112 if relation == EdgeRelation::Annotates {
1113 return true;
1114 }
1115 if src_sub == "note" && tgt_sub == "note" && crate::pack::is_special_relation(relation) {
1118 return true;
1119 }
1120 if src_sub == "entity"
1121 && tgt_sub == "entity"
1122 && base_entity_rule_allows(src_kind, relation, tgt_kind)
1123 {
1124 return true;
1125 }
1126 pack_rules.iter().any(|r| {
1127 r.relation == relation
1128 && endpoint_matches(&r.source, src_sub, src_kind, src_type)
1129 && endpoint_matches(&r.target, tgt_sub, tgt_kind, tgt_type)
1130 })
1131}
1132
1133impl KhiveRuntime {
1138 pub(crate) async fn prepare_update_entity(
1150 &self,
1151 token: &NamespaceToken,
1152 id: Uuid,
1153 patch: EntityPatch,
1154 ) -> RuntimeResult<(Entity, bool, Vec<&'static str>, i64, Option<i64>)> {
1155 self.prepare_guarded_entity_update(token, id, patch, None, &[])
1156 .await
1157 }
1158
1159 async fn prepare_guarded_entity_update(
1160 &self,
1161 token: &NamespaceToken,
1162 id: Uuid,
1163 patch: EntityPatch,
1164 expected: Option<&Entity>,
1165 remove_properties: &[&str],
1166 ) -> RuntimeResult<(Entity, bool, Vec<&'static str>, i64, Option<i64>)> {
1167 crate::secret_gate::reject_reserved_secret_gate_property(patch.properties.as_ref())?;
1168 if !remove_properties.is_empty() {
1169 let removals = Value::Object(
1170 remove_properties
1171 .iter()
1172 .map(|key| ((*key).to_string(), Value::Null))
1173 .collect(),
1174 );
1175 crate::secret_gate::reject_reserved_secret_gate_property(Some(&removals))?;
1176 }
1177 if let Some(ref name) = patch.name {
1178 crate::secret_gate::check_at(name, "entity", "name")?;
1179 }
1180 if let Some(Some(ref desc)) = patch.description {
1181 crate::secret_gate::check_at(desc, "entity", "description")?;
1182 }
1183 if let Some(ref props) = patch.properties {
1184 crate::secret_gate::check_json_at(props, "entity", "properties")?;
1185 }
1186 if let Some(ref tags) = patch.tags {
1187 crate::secret_gate::check_tags_at(tags, "entity", "tags")?;
1188 }
1189 let store = self.entities(token)?;
1190 let mut entity = store.get_entity(id).await?.ok_or_else(|| {
1191 if expected.is_some() {
1192 stale_entity_snapshot_error(id)
1193 } else {
1194 RuntimeError::NotFound(format!("entity {id}"))
1195 }
1196 })?;
1197 if let Some(expected) = expected {
1198 let actual = serde_json::to_value(&entity)
1199 .map_err(|error| RuntimeError::Internal(error.to_string()))?;
1200 let expected = serde_json::to_value(expected)
1201 .map_err(|error| RuntimeError::Internal(error.to_string()))?;
1202 if actual != expected {
1203 return Err(stale_entity_snapshot_error(id));
1204 }
1205 }
1206 let expected_updated_at = entity.updated_at;
1207 let expected_deleted_at = entity.deleted_at;
1208 #[cfg(test)]
1209 race_seam::pause_after_read().await;
1210
1211 let validated_entity_type = match &patch.entity_type {
1215 Some(None) => Some(None),
1216 Some(Some(raw)) => Some(Some(
1217 self.validate_entity_type_for_kind(&entity.kind, Some(raw))?
1218 .expect("set branch always yields a normalized value"),
1219 )),
1220 None => None,
1221 };
1222
1223 let mut reindex_required = false;
1224 let mut changed_fields: Vec<&'static str> = Vec::new();
1225
1226 if let Some(name) = patch.name {
1227 reindex_required |= entity.name != name;
1228 entity.name = name;
1229 changed_fields.push("name");
1230 }
1231 if let Some(desc_patch) = patch.description {
1232 reindex_required |= entity.description != desc_patch;
1233 entity.description = desc_patch;
1234 changed_fields.push("description");
1235 }
1236 if let Some(props) = patch.properties {
1237 let (merged, _) = merge_properties(
1238 &entity.properties,
1239 &Some(props),
1240 EntityDedupMergePolicy::PreferFrom,
1241 );
1242 entity.properties = merged;
1243 changed_fields.push("properties");
1244 }
1245 if let Some(Value::Object(properties)) = entity.properties.as_mut() {
1246 let mut removed = false;
1247 for key in remove_properties {
1248 removed |= properties.remove(*key).is_some();
1249 }
1250 if removed && !changed_fields.contains(&"properties") {
1251 changed_fields.push("properties");
1252 }
1253 }
1254 if let Some(tags) = patch.tags {
1255 entity.tags = tags;
1256 changed_fields.push("tags");
1257 }
1258 if let Some(entity_type) = validated_entity_type {
1259 reindex_required |= entity.entity_type != entity_type;
1260 entity.entity_type = entity_type;
1261 changed_fields.push("entity_type");
1262 }
1263
1264 if expected.is_some() && changed_fields.is_empty() {
1265 return Ok((
1266 entity,
1267 reindex_required,
1268 changed_fields,
1269 expected_updated_at,
1270 expected_deleted_at,
1271 ));
1272 }
1273
1274 if let Some(hook) = self.entity_kind_hook(&entity.kind) {
1283 hook.validate_entity_update(self, token, &entity, entity.properties.as_ref())
1284 .await?;
1285 }
1286
1287 let minimum_updated_at = expected_updated_at.checked_add(1).ok_or_else(|| {
1293 RuntimeError::Internal(format!(
1294 "entity {id} updated_at is already at i64::MAX and cannot advance"
1295 ))
1296 })?;
1297 entity.updated_at = chrono::Utc::now()
1298 .timestamp_micros()
1299 .max(minimum_updated_at);
1300 Ok((
1301 entity,
1302 reindex_required,
1303 changed_fields,
1304 expected_updated_at,
1305 expected_deleted_at,
1306 ))
1307 }
1308
1309 pub async fn update_entity(
1310 &self,
1311 token: &NamespaceToken,
1312 id: Uuid,
1313 patch: EntityPatch,
1314 ) -> RuntimeResult<Entity> {
1315 Ok(self
1316 .update_entity_with_embedding_report(token, id, patch)
1317 .await?
1318 .0)
1319 }
1320
1321 pub async fn update_entity_with_embedding_report(
1322 &self,
1323 token: &NamespaceToken,
1324 id: Uuid,
1325 patch: EntityPatch,
1326 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1327 self.update_entity_with_expected_version_and_embedding_report(token, id, patch, None)
1328 .await
1329 }
1330
1331 pub async fn update_entity_with_expected_version_and_embedding_report(
1333 &self,
1334 token: &NamespaceToken,
1335 id: Uuid,
1336 patch: EntityPatch,
1337 expected_version: Option<i64>,
1338 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1339 crate::entity_write::validate_expected_version(expected_version)?;
1340 let (entity, reindex_required, changed_fields, expected_updated_at, expected_deleted_at) =
1341 self.prepare_update_entity(token, id, patch).await?;
1342
1343 self.persist_prepared_entity_update(
1344 token,
1345 entity,
1346 reindex_required,
1347 changed_fields,
1348 expected_updated_at,
1349 expected_deleted_at,
1350 expected_version,
1351 )
1352 .await
1353 }
1354
1355 pub async fn update_entity_if_unchanged(
1360 &self,
1361 token: &NamespaceToken,
1362 expected: &Entity,
1363 patch: EntityPatch,
1364 remove_properties: &[&str],
1365 ) -> RuntimeResult<Entity> {
1366 let (entity, reindex_required, changed_fields, expected_updated_at, expected_deleted_at) =
1367 self.prepare_guarded_entity_update(
1368 token,
1369 expected.id,
1370 patch,
1371 Some(expected),
1372 remove_properties,
1373 )
1374 .await?;
1375 if changed_fields.is_empty() {
1376 return Ok(entity);
1377 }
1378 Ok(self
1379 .persist_prepared_entity_update(
1380 token,
1381 entity,
1382 reindex_required,
1383 changed_fields,
1384 expected_updated_at,
1385 expected_deleted_at,
1386 None,
1387 )
1388 .await?
1389 .0)
1390 }
1391
1392 #[allow(clippy::too_many_arguments)]
1393 async fn persist_prepared_entity_update(
1394 &self,
1395 token: &NamespaceToken,
1396 mut entity: Entity,
1397 reindex_required: bool,
1398 changed_fields: Vec<&'static str>,
1399 expected_updated_at: i64,
1400 expected_deleted_at: Option<i64>,
1401 expected_version: Option<i64>,
1402 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
1403 let id = entity.id;
1404 let _ = self.entities(token)?;
1405 let next_version = entity
1406 .version
1407 .checked_add(1)
1408 .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
1409 use crate::atomic_plan::{AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan};
1410 use crate::atomic_runner::{
1411 run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome,
1412 };
1413 let plan = UpdatePlan {
1414 target_id: id,
1415 statements: vec![PlanStatement {
1416 statement: khive_db::stores::entity::entity_replace_if_unchanged_statement(
1417 &entity,
1418 expected_updated_at,
1419 expected_deleted_at,
1420 ),
1421 guard: Some(AffectedRowGuard::exactly(1)),
1422 }],
1423 post_commit: PostCommitEffect::None,
1424 edge_natural_key: None,
1425 idempotent_noop: false,
1426 entity_guard: expected_version.map(|expected_version| {
1427 crate::entity_write::EntityWriteGuard {
1428 id,
1429 expected_version,
1430 }
1431 }),
1432 note_guard: None,
1433 note_vector_purge: None,
1434 note_embedding_inheritance: None,
1435 graph_effects: Vec::new(),
1436 };
1437 match run_atomic_unit(
1438 self.sql().as_ref(),
1439 vec![AtomicOpPlan::Update(Box::new(plan))],
1440 )
1441 .await
1442 {
1443 Ok(AtomicRunOutcome::Committed { .. }) => entity.version = next_version,
1444 Ok(AtomicRunOutcome::RolledBack {
1445 failure: AtomicOpFailure::EntityConflict(conflict),
1446 ..
1447 }) => return Err(conflict.into_error().into()),
1448 Ok(AtomicRunOutcome::RolledBack {
1449 failure: AtomicOpFailure::GuardFailed { .. },
1450 ..
1451 }) => return Err(stale_entity_snapshot_error(id)),
1452 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => {
1453 return Err(RuntimeError::Internal(format!(
1454 "entity update rolled back: {failure:?}"
1455 )))
1456 }
1457 Err(error) => return Err(RuntimeError::Storage(error.0)),
1458 }
1459
1460 let embedding_report = if reindex_required {
1461 self.reindex_entity(token, &entity).await?
1462 } else {
1463 crate::retrieval::EmbeddingTruncationReport::default()
1464 };
1465
1466 let event_token =
1467 token.with_namespace(crate::Namespace::parse(&entity.namespace).map_err(|error| {
1468 RuntimeError::Internal(format!("entity namespace invalid: {error}"))
1469 })?);
1470 let event_store = self.events(&event_token)?;
1471 let event = khive_storage::event::Event::new(
1472 entity.namespace.clone(),
1473 "update",
1474 EventKind::EntityUpdated,
1475 SubstrateKind::Entity,
1476 "",
1477 )
1478 .with_target(entity.id)
1479 .with_payload(serde_json::json!({
1480 "id": entity.id,
1481 "namespace": entity.namespace,
1482 "changed_fields": changed_fields,
1483 }));
1484 event_store.append_event(event).await.map_err(|e| {
1485 RuntimeError::Internal(format!("update_entity: event store write failed: {e}"))
1486 })?;
1487
1488 Ok((entity, embedding_report))
1489 }
1490
1491 pub async fn merge_entity(
1505 &self,
1506 token: &NamespaceToken,
1507 into_id: Uuid,
1508 from_id: Uuid,
1509 strategy: EntityDedupMergePolicy,
1510 content_strategy: ContentMergeStrategy,
1511 dry_run: bool,
1512 ) -> RuntimeResult<MergeSummary> {
1513 self.merge_entity_with_reason(
1514 token,
1515 into_id,
1516 from_id,
1517 strategy,
1518 content_strategy,
1519 dry_run,
1520 None,
1521 )
1522 .await
1523 }
1524
1525 #[allow(clippy::too_many_arguments)]
1529 pub async fn merge_entity_with_reason(
1530 &self,
1531 token: &NamespaceToken,
1532 into_id: Uuid,
1533 from_id: Uuid,
1534 strategy: EntityDedupMergePolicy,
1535 content_strategy: ContentMergeStrategy,
1536 dry_run: bool,
1537 reason: Option<String>,
1538 ) -> RuntimeResult<MergeSummary> {
1539 self.merge_entity_with_validation(
1540 token,
1541 into_id,
1542 from_id,
1543 strategy,
1544 content_strategy,
1545 dry_run,
1546 reason,
1547 EntityMergeValidation::LegacyKind,
1548 )
1549 .await
1550 }
1551
1552 #[allow(clippy::too_many_arguments)]
1561 pub async fn merge_entity_with_reason_and_force(
1562 &self,
1563 token: &NamespaceToken,
1564 into_id: Uuid,
1565 from_id: Uuid,
1566 strategy: EntityDedupMergePolicy,
1567 content_strategy: ContentMergeStrategy,
1568 dry_run: bool,
1569 reason: Option<String>,
1570 force: bool,
1571 ) -> RuntimeResult<MergeSummary> {
1572 let validation = if force {
1573 EntityMergeValidation::Forced
1574 } else {
1575 EntityMergeValidation::SafetyFloor
1576 };
1577 self.merge_entity_with_validation(
1578 token,
1579 into_id,
1580 from_id,
1581 strategy,
1582 content_strategy,
1583 dry_run,
1584 reason,
1585 validation,
1586 )
1587 .await
1588 }
1589
1590 #[allow(clippy::too_many_arguments)]
1591 async fn merge_entity_with_validation(
1592 &self,
1593 token: &NamespaceToken,
1594 into_id: Uuid,
1595 from_id: Uuid,
1596 strategy: EntityDedupMergePolicy,
1597 content_strategy: ContentMergeStrategy,
1598 dry_run: bool,
1599 reason: Option<String>,
1600 validation: EntityMergeValidation,
1601 ) -> RuntimeResult<MergeSummary> {
1602 if let Some(reason) = reason.as_deref() {
1603 crate::secret_gate::check_at(reason, "merge", "reason")?;
1604 }
1605 if into_id == from_id {
1606 return Err(RuntimeError::InvalidInput(
1607 "cannot merge an entity into itself".into(),
1608 ));
1609 }
1610 let ns = token.namespace().as_str().to_owned();
1611 let fts_table = "fts_entities".to_string();
1612 let embedding_plan = EmbeddingModelPlan::capture(self);
1616 let vec_tables = embedding_plan.vector_tables();
1617 let pack_rules = self.pack_edge_rules();
1620
1621 let _ = self.entities(token)?;
1623 let _ = self.graph(token)?;
1624 let _ = self.text(token)?;
1625 let _ = self.events(token)?;
1626 for model_name in embedding_plan.model_names() {
1629 let _ = self.vectors_for_model(token, model_name)?;
1630 }
1631
1632 let pool = self.backend().pool_arc();
1633 let writer_task = pool
1634 .writer_task_for_runtime_write(RuntimeWriteOperation::MergeEntity)
1635 .map_err(RuntimeError::Storage)?;
1636 let merge_event_id = Uuid::new_v4();
1639 let event_context = MergeEventContext {
1640 attribution: EventAttribution::from_token(token),
1641 reason,
1642 force: validation == EntityMergeValidation::Forced,
1643 strategy,
1644 content_strategy,
1645 kind: EventKind::EntityMerged,
1646 substrate: SubstrateKind::Entity,
1647 event_id: Some(merge_event_id),
1648 };
1649
1650 let (mut summary, updated_entity) = if let Some(writer_task) = writer_task {
1651 writer_task
1652 .send(move |conn| {
1653 merge_entity_sql(
1654 conn,
1655 ns,
1656 fts_table,
1657 vec_tables,
1658 into_id,
1659 from_id,
1660 strategy,
1661 content_strategy,
1662 dry_run,
1663 pack_rules,
1664 validation,
1665 MergeTxLimits::default(),
1666 merge_event_id,
1667 Some(event_context),
1668 )
1669 .map_err(|e| {
1670 khive_storage::StorageError::driver(
1671 khive_storage::StorageCapability::Entities,
1672 "merge_entity",
1673 e,
1674 )
1675 })
1676 })
1677 .await
1678 .map_err(map_merge_entity_storage_error)?
1679 } else {
1680 tokio::task::spawn_blocking(move || {
1681 let guard = pool.writer()?;
1682 let mut refusal = None;
1683 let result = guard.transaction(|conn| {
1684 merge_entity_sql(
1685 conn,
1686 ns,
1687 fts_table,
1688 vec_tables,
1689 into_id,
1690 from_id,
1691 strategy,
1692 content_strategy,
1693 dry_run,
1694 pack_rules,
1695 validation,
1696 MergeTxLimits::default(),
1697 merge_event_id,
1698 Some(event_context),
1699 )
1700 .map_err(|error| match error {
1701 MergeSqlError::Sqlite(error) => error,
1702 MergeSqlError::Refusal(error) => {
1703 refusal = Some(error);
1704 SqliteError::InvalidData(
1705 "entity merge refused by transactional policy".to_string(),
1706 )
1707 }
1708 })
1709 });
1710 match refusal {
1711 Some(error) => Err(error),
1712 None => result.map_err(RuntimeError::from),
1713 }
1714 })
1715 .await
1716 .map_err(|e| RuntimeError::Internal(e.to_string()))??
1717 };
1718
1719 if !dry_run {
1721 khive_storage::usage::count(khive_storage::usage::UsageUnit::EventRows, 1);
1722 tracing::info!(
1723 into_id = %summary.kept_id,
1724 from_id = %summary.removed_id,
1725 budget_rows = summary.tx_budget.rows_charged,
1726 budget_bytes = summary.tx_budget.bytes_charged,
1727 budget_max_rows = summary.tx_budget.max_rows,
1728 budget_max_bytes = summary.tx_budget.max_bytes,
1729 "merge_entity: transaction materialization budget"
1730 );
1731 }
1732
1733 if !dry_run && !embedding_plan.is_empty() {
1736 summary.embedding_truncation = self
1737 .reindex_entity_with_plan(token, &updated_entity, &embedding_plan)
1738 .await?;
1739 }
1740
1741 Ok(summary)
1742 }
1743
1744 async fn apply_entity_index_revision(
1747 &self,
1748 entity: &Entity,
1749 statements: Vec<SqlStatement>,
1750 ) -> RuntimeResult<bool> {
1751 let namespace = entity.namespace.clone();
1752 let id = entity.id.to_string();
1753 let version = entity.version;
1754 let op: AtomicUnitOp = Box::new(move |writer| {
1755 Box::pin(async move {
1756 let current = writer
1757 .query_scalar(SqlStatement {
1758 sql: "SELECT version FROM entities \
1759 WHERE namespace=?1 AND id=?2 AND deleted_at IS NULL"
1760 .into(),
1761 params: vec![SqlValue::Text(namespace), SqlValue::Text(id)],
1762 label: Some("entity-index-revision".into()),
1763 })
1764 .await?;
1765 if !matches!(current, Some(SqlValue::Integer(current)) if current == version) {
1766 return Ok(Box::new(false) as Box<dyn Any + Send>);
1767 }
1768 for statement in statements {
1769 writer.execute(statement).await?;
1770 }
1771 Ok(Box::new(true) as Box<dyn Any + Send>)
1772 })
1773 });
1774 self.sql()
1775 .atomic_unit(op)
1776 .await?
1777 .downcast::<bool>()
1778 .map(|result| *result)
1779 .map_err(|_| RuntimeError::Internal("invalid entity index outcome".into()))
1780 }
1781
1782 fn entity_vector_insert_statements(
1783 table: &str,
1784 entity: &Entity,
1785 model_name: &str,
1786 vector: &[f32],
1787 ) -> Vec<SqlStatement> {
1788 let subject = entity.id.to_string();
1789 let kind = SubstrateKind::Entity.to_string();
1790 let field = "entity.body";
1791 let blob = vector
1792 .iter()
1793 .flat_map(|value| value.to_ne_bytes())
1794 .collect();
1795 vec![
1796 SqlStatement {
1797 sql: format!(
1798 "INSERT INTO ann_write_log \
1799 (namespace, embedding_model, kind, field, subject_id, op) \
1800 SELECT namespace, embedding_model, kind, field, subject_id, 'delete' \
1801 FROM {table} WHERE subject_id=?1 AND NOT \
1802 (namespace=?2 AND embedding_model=?3 AND kind=?4 AND field=?5)"
1803 ),
1804 params: vec![
1805 SqlValue::Text(subject.clone()),
1806 SqlValue::Text(entity.namespace.clone()),
1807 SqlValue::Text(model_name.to_string()),
1808 SqlValue::Text(kind.clone()),
1809 SqlValue::Text(field.into()),
1810 ],
1811 label: Some("entity-reindex-log-delete".into()),
1812 },
1813 SqlStatement {
1814 sql: format!("DELETE FROM {table} WHERE subject_id=?1"),
1815 params: vec![SqlValue::Text(subject.clone())],
1816 label: Some("entity-reindex-vector-delete".into()),
1817 },
1818 SqlStatement {
1819 sql: format!(
1820 "INSERT INTO {table} \
1821 (subject_id, namespace, kind, field, embedding_model, embedding) \
1822 VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
1823 ),
1824 params: vec![
1825 SqlValue::Text(subject.clone()),
1826 SqlValue::Text(entity.namespace.clone()),
1827 SqlValue::Text(kind.clone()),
1828 SqlValue::Text(field.into()),
1829 SqlValue::Text(model_name.to_string()),
1830 SqlValue::Blob(blob),
1831 ],
1832 label: Some("entity-reindex-vector-insert".into()),
1833 },
1834 SqlStatement {
1835 sql: "INSERT INTO ann_write_log \
1836 (namespace, embedding_model, kind, field, subject_id, op) \
1837 VALUES (?1, ?2, ?3, ?4, ?5, 'upsert')"
1838 .into(),
1839 params: vec![
1840 SqlValue::Text(entity.namespace.clone()),
1841 SqlValue::Text(model_name.to_string()),
1842 SqlValue::Text(kind),
1843 SqlValue::Text(field.into()),
1844 SqlValue::Text(subject),
1845 ],
1846 label: Some("entity-reindex-log-upsert".into()),
1847 },
1848 ]
1849 }
1850
1851 pub(crate) async fn reindex_entity(
1864 &self,
1865 token: &NamespaceToken,
1866 entity: &Entity,
1867 ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1868 let embedding_plan = EmbeddingModelPlan::capture(self);
1869 self.reindex_entity_with_plan(token, entity, &embedding_plan)
1870 .await
1871 }
1872
1873 async fn reindex_entity_with_plan(
1874 &self,
1875 token: &NamespaceToken,
1876 entity: &Entity,
1877 embedding_plan: &EmbeddingModelPlan,
1878 ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1879 let doc = entity_fts_document(entity);
1881 let embed_body = doc.body.clone();
1882 let _ = self.text(token)?;
1883 #[cfg(test)]
1884 race_seam::pause_before_entity_index_publish().await;
1885 let statements = khive_db::stores::text::delete_document_statements(
1886 "fts_entities",
1887 &entity.namespace,
1888 entity.id,
1889 )
1890 .into_iter()
1891 .chain(khive_db::stores::text::insert_document_statements(
1892 "fts_entities",
1893 &doc,
1894 ))
1895 .collect();
1896 if !self.apply_entity_index_revision(entity, statements).await? {
1897 return Ok(crate::retrieval::EmbeddingTruncationReport::default());
1898 }
1899
1900 let mut report = crate::retrieval::EmbeddingTruncationReport::default();
1901 for model_name in embedding_plan.model_names() {
1902 match self
1903 .embed_document_with_model_outcome_for_token(token, model_name, &embed_body)
1904 .await
1905 {
1906 Ok(outcome) => {
1907 report.observe(&outcome);
1908 match self.vectors_for_model(token, model_name) {
1909 Ok(_) => {
1910 if let Some(index) =
1911 outcome.vector.iter().position(|value| !value.is_finite())
1912 {
1913 tracing::warn!(
1914 model = model_name,
1915 id = %entity.id,
1916 index,
1917 "reindex_entity: non-finite vector, skipping model"
1918 );
1919 continue;
1920 }
1921 let (storage_model, dimensions) = match self
1922 .vector_model_metadata(model_name)
1923 {
1924 Ok(metadata) => metadata,
1925 Err(e) => {
1926 tracing::warn!(
1927 model = model_name,
1928 id = %entity.id,
1929 "reindex_entity: could not resolve vector model, skipping: {e}"
1930 );
1931 continue;
1932 }
1933 };
1934 if outcome.vector.len() != dimensions {
1935 tracing::warn!(
1936 model = model_name,
1937 id = %entity.id,
1938 "reindex_entity: vector dimensions do not match model, skipping"
1939 );
1940 continue;
1941 }
1942 let table =
1943 format!("vec_{}", crate::config::sanitize_key(&storage_model));
1944 let statements = Self::entity_vector_insert_statements(
1945 &table,
1946 entity,
1947 &storage_model,
1948 &outcome.vector,
1949 );
1950 #[cfg(test)]
1951 race_seam::pause_before_entity_vector_publish().await;
1952 match self.apply_entity_index_revision(entity, statements).await {
1953 Ok(true) => {}
1954 Ok(false) => break,
1955 Err(e) => {
1956 tracing::warn!(
1957 model = model_name,
1958 id = %entity.id,
1959 "reindex_entity: vector insert failed, skipping model: {e}"
1960 );
1961 }
1962 }
1963 }
1964 Err(e) => {
1965 tracing::warn!(
1966 model = model_name,
1967 id = %entity.id,
1968 "reindex_entity: could not access vector store for model, skipping: {e}"
1969 );
1970 }
1971 }
1972 }
1973 Err(e) => {
1974 tracing::warn!(
1975 model = model_name,
1976 id = %entity.id,
1977 "reindex_entity: embed failed for model, skipping: {e}"
1978 );
1979 }
1980 }
1981 }
1982
1983 Ok(report)
1984 }
1985
1986 pub(crate) async fn remove_from_indexes(
1988 &self,
1989 token: &NamespaceToken,
1990 id: Uuid,
1991 ) -> RuntimeResult<()> {
1992 let ns = token.namespace().as_str().to_owned();
1993 self.text(token)?.delete_document(&ns, id).await?;
1994 for model_name in self.registered_embedding_model_names() {
1995 self.vectors_for_model(token, &model_name)?
1996 .delete(id)
1997 .await?;
1998 }
1999 Ok(())
2000 }
2001
2002 pub(crate) async fn reindex_note(
2006 &self,
2007 token: &NamespaceToken,
2008 note: &khive_storage::note::Note,
2009 ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
2010 let embedding_plan = EmbeddingModelPlan::capture(self);
2011 self.reindex_note_with_plan(token, note, &embedding_plan)
2012 .await
2013 }
2014
2015 async fn reindex_note_with_plan(
2016 &self,
2017 token: &NamespaceToken,
2018 note: &khive_storage::note::Note,
2019 embedding_plan: &EmbeddingModelPlan,
2020 ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
2021 let statements = khive_db::stores::text::delete_document_statements(
2022 "fts_notes",
2023 ¬e.namespace,
2024 note.id,
2025 )
2026 .into_iter()
2027 .chain(khive_db::stores::text::insert_document_statements(
2028 "fts_notes",
2029 ¬e_fts_document(note),
2030 ))
2031 .collect();
2032 if !self.apply_note_index_revision(note, statements).await? {
2033 return Ok(crate::retrieval::EmbeddingTruncationReport::default());
2034 }
2035 let mut report = crate::retrieval::EmbeddingTruncationReport::default();
2036 for model_name in embedding_plan.model_names() {
2037 match self
2038 .embed_document_with_model_outcome_for_token(
2039 token,
2040 model_name,
2041 note_embedding_text_ref(note),
2042 )
2043 .await
2044 {
2045 Ok(outcome) => {
2046 report.observe(&outcome);
2047 match self.vectors_for_model(token, model_name) {
2048 Ok(_) => {
2049 if outcome.vector.iter().any(|value| !value.is_finite()) {
2050 tracing::warn!(model = model_name, id = %note.id, "reindex_note: non-finite vector, skipping model");
2051 continue;
2052 }
2053 let table = format!("vec_{}", crate::config::sanitize_key(model_name));
2054 let statements = crate::atomic_message::vector_insert_statements(
2055 &table,
2056 ¬e.namespace,
2057 note.id,
2058 "note.content",
2059 model_name,
2060 &outcome.vector,
2061 "note-reindex",
2062 )
2063 .into_iter()
2064 .map(|planned| planned.statement)
2065 .collect();
2066 if let Err(e) = self.apply_note_index_revision(note, statements).await {
2067 tracing::warn!(
2068 model = model_name,
2069 id = %note.id,
2070 "reindex_note: vector insert failed, skipping model: {e}"
2071 );
2072 }
2073 }
2074 Err(e) => {
2075 tracing::warn!(
2076 model = model_name,
2077 id = %note.id,
2078 "reindex_note: could not access vector store for model, skipping: {e}"
2079 );
2080 }
2081 }
2082 }
2083 Err(e) => {
2084 tracing::warn!(
2085 model = model_name,
2086 id = %note.id,
2087 "reindex_note: embed failed for model, skipping: {e}"
2088 );
2089 }
2090 }
2091 }
2092 Ok(report)
2093 }
2094
2095 pub(crate) async fn prepare_update_note_from_snapshot(
2100 &self,
2101 _token: &NamespaceToken,
2102 mut note: khive_storage::note::Note,
2103 patch: NotePatch,
2104 ) -> RuntimeResult<(khive_storage::note::Note, bool, bool)> {
2105 let stored = note.clone();
2109 let original_name = note.name.clone();
2110 let original_content = note.content.clone();
2111 let original_salience = note.salience;
2112 let original_decay_factor = note.decay_factor;
2113 let original_properties = note.properties.clone();
2114 let original_status = note.status.clone();
2115 if patch
2116 .update_policy
2117 .kind
2118 .as_deref()
2119 .is_some_and(|kind| kind != note.kind)
2120 {
2121 return Err(RuntimeError::InvalidInput(
2122 "note update policy does not match the stored note kind".into(),
2123 ));
2124 }
2125 if patch.content.is_some() || patch.properties.is_some() {
2126 if let Some(error) = self.stream_member_error(¬e).await? {
2127 return Err(error);
2128 }
2129 }
2130 crate::secret_gate::reject_reserved_secret_gate_property(patch.properties.as_ref())?;
2131 if let Some(ref content) = patch.content {
2132 crate::secret_gate::check_at(content, "note", "content")?;
2133 }
2134 if let Some(Some(ref name)) = patch.name {
2135 crate::secret_gate::check_at(name, "note", "name")?;
2136 }
2137 if let Some(ref props) = patch.properties {
2138 crate::secret_gate::check_json_at(props, "note", "properties")?;
2139 }
2140
2141 reject_pack_managed_schedule_mutation(¬e, "update")?;
2142
2143 let mut text_changed = false;
2144
2145 if let Some(name_patch) = patch.name {
2146 text_changed |= note.name != name_patch;
2147 note.name = name_patch;
2148 }
2149 if let Some(content) = patch.content {
2150 text_changed |= note.content != content;
2151 note.content = content;
2152 }
2153 if let Some(salience_patch) = patch.salience {
2154 if let Some(s) = salience_patch {
2156 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
2157 return Err(crate::RuntimeError::InvalidInput(format!(
2158 "salience must be a finite value in [0.0, 1.0]; got {s}"
2159 )));
2160 }
2161 }
2162 note.salience = salience_patch;
2163 }
2164 if let Some(decay_patch) = patch.decay_factor {
2165 if let Some(d) = decay_patch {
2167 if !d.is_finite() || d < 0.0 {
2168 return Err(crate::RuntimeError::InvalidInput(format!(
2169 "decay_factor must be a finite value >= 0.0; got {d}"
2170 )));
2171 }
2172 }
2173 note.decay_factor = decay_patch;
2174 }
2175 if let Some(props) = patch.properties {
2176 let owned_keys = kind_owned_properties(¬e.kind);
2180 if !owned_keys.is_empty() {
2181 let object = props.as_object().ok_or_else(|| {
2182 if note.kind == "message" {
2183 RuntimeError::InvalidInput(
2184 "properties on a `message` note must be patched with an object: a \
2185 non-object patch would replace the transport-owned quarantine and \
2186 channel provenance established by `comm.ingest`"
2187 .into(),
2188 )
2189 } else {
2190 RuntimeError::InvalidInput(format!(
2191 "properties on a `{}` note must be patched with an object; \
2192 a non-object patch would erase its owner-established identity",
2193 note.kind
2194 ))
2195 }
2196 })?;
2197 if let Some(named) = owned_keys.iter().find(|key| object.contains_key(**key)) {
2198 if note.kind == "message" {
2199 return Err(RuntimeError::InvalidInput(format!(
2200 "`{named}` is transport-owned on a `message` note and cannot be patched; \
2201 only `comm.ingest` may establish quarantine disposition and channel \
2202 provenance"
2203 )));
2204 }
2205 return Err(RuntimeError::InvalidInput(format!(
2206 "`{named}` is not patchable on a `{}` note; \
2207 use `comm.heartbeat` to report health without changing the row's identity",
2208 note.kind
2209 )));
2210 }
2211 }
2212 if self.is_pack_owned_note_kind(¬e.kind) {
2225 if !props.is_object() {
2233 return Err(RuntimeError::InvalidInput(format!(
2234 "properties on a `{}` note must be patched with an object: a non-object \
2235 patch names no key, so it would replace the whole property object rather \
2236 than merging into it. Pass an object containing the keys you intend to \
2237 set.",
2238 note.kind
2239 )));
2240 }
2241 if let Some(named) = owner_established_property_named_in(&props) {
2242 return Err(RuntimeError::InvalidInput(format!(
2243 "`{named}` is not patchable on a `{}` note: the pack that owns this \
2244 kind establishes it and reads it back — to decide how the record is \
2245 attributed and grouped, or to reproduce it verbatim when the record \
2246 is re-emitted — so it is written by the owner and immutable to a \
2247 caller patch. Patch any other property key here, or omit \
2248 `{named}` from this patch.",
2249 note.kind
2250 )));
2251 }
2252 }
2253 let incoming_properties = Some(props);
2254 let (mut merged, _) = merge_properties(
2255 ¬e.properties,
2256 &incoming_properties,
2257 EntityDedupMergePolicy::PreferFrom,
2258 );
2259 if let Some(properties) = merged.as_mut().and_then(Value::as_object_mut) {
2260 for key in patch.update_policy.null_clearing_properties {
2261 if incoming_properties
2262 .as_ref()
2263 .and_then(|incoming| incoming.get(*key))
2264 .is_some_and(Value::is_null)
2265 {
2266 properties.remove(*key);
2267 }
2268 }
2269 }
2270 note.properties = merged;
2271 }
2272 if let Some(status) = patch.kind_status {
2273 note.status = status;
2274 }
2275
2276 let changed = original_name != note.name
2281 || original_content != note.content
2282 || original_salience != note.salience
2283 || original_decay_factor != note.decay_factor
2284 || !note_update_values_equal(&original_properties, ¬e.properties)
2285 || original_status != note.status;
2286 if !changed {
2287 return Ok((stored, text_changed, false));
2288 }
2289
2290 let minimum_updated_at = note.updated_at.checked_add(1).ok_or_else(|| {
2296 RuntimeError::Internal(format!(
2297 "note {} updated_at is already at i64::MAX and cannot advance",
2298 note.id
2299 ))
2300 })?;
2301 note.updated_at = chrono::Utc::now()
2302 .timestamp_micros()
2303 .max(minimum_updated_at);
2304 Ok((note, text_changed, true))
2305 }
2306
2307 pub async fn update_note(
2309 &self,
2310 token: &NamespaceToken,
2311 id: Uuid,
2312 patch: NotePatch,
2313 ) -> RuntimeResult<khive_storage::note::Note> {
2314 Ok(self
2315 .update_note_with_embedding_report(token, id, patch)
2316 .await?
2317 .0)
2318 }
2319
2320 pub async fn update_note_with_embedding_report(
2321 &self,
2322 token: &NamespaceToken,
2323 id: Uuid,
2324 patch: NotePatch,
2325 ) -> RuntimeResult<(
2326 khive_storage::note::Note,
2327 crate::retrieval::EmbeddingTruncationReport,
2328 )> {
2329 let snapshot = self
2330 .notes(token)?
2331 .get_note(id)
2332 .await?
2333 .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
2334 self.update_note_from_snapshot_with_embedding_report(token, snapshot, patch)
2335 .await
2336 }
2337
2338 pub async fn update_note_from_snapshot_with_embedding_report(
2346 &self,
2347 token: &NamespaceToken,
2348 snapshot: khive_storage::note::Note,
2349 patch: NotePatch,
2350 ) -> RuntimeResult<(
2351 khive_storage::note::Note,
2352 crate::retrieval::EmbeddingTruncationReport,
2353 )> {
2354 let (note, plan) = self
2355 .prepare_versioned_note_update(token, snapshot, patch)
2356 .await?;
2357 self.commit_prepared_note_update(token, note, crate::AtomicOpPlan::Update(Box::new(plan)))
2358 .await
2359 }
2360
2361 pub async fn update_note_from_snapshot_with_kind_effects(
2367 &self,
2368 token: &NamespaceToken,
2369 snapshot: khive_storage::Note,
2370 args: &Value,
2371 policy: crate::NoteUpdatePolicy,
2372 registry: &crate::VerbRegistry,
2373 ) -> RuntimeResult<(
2374 khive_storage::Note,
2375 crate::retrieval::EmbeddingTruncationReport,
2376 )> {
2377 let (note, plan) = crate::atomic_prepare::prepare_update_from_note_snapshot(
2378 self, token, args, None, snapshot, policy, registry,
2379 )
2380 .await?;
2381 self.commit_prepared_note_update(token, note, plan).await
2382 }
2383
2384 async fn commit_prepared_note_update(
2385 &self,
2386 token: &NamespaceToken,
2387 note: khive_storage::Note,
2388 plan: crate::AtomicOpPlan,
2389 ) -> RuntimeResult<(
2390 khive_storage::Note,
2391 crate::retrieval::EmbeddingTruncationReport,
2392 )> {
2393 let id = note.id;
2394 use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicRunOutcome};
2395 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
2396 Ok(AtomicRunOutcome::Committed { post_commit }) => {
2397 let outcomes = crate::atomic_prepare::apply_post_commit_effects_with_report(
2398 self,
2399 token,
2400 post_commit,
2401 )
2402 .await?;
2403 let report = outcomes
2404 .into_iter()
2405 .next()
2406 .map(|outcome| outcome.truncation)
2407 .unwrap_or_default();
2408 Ok((note, report))
2409 }
2410 Ok(AtomicRunOutcome::RolledBack {
2411 failure: AtomicOpFailure::NoteConflict(conflict),
2412 ..
2413 }) => Err(conflict.into_error().into()),
2414 Ok(AtomicRunOutcome::RolledBack {
2415 failure: AtomicOpFailure::GuardFailed { .. },
2416 ..
2417 }) => Err(stale_note_snapshot_error(id)),
2418 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
2419 format!("note update rolled back: {failure:?}"),
2420 )),
2421 Err(error) => Err(RuntimeError::Storage(error.0)),
2422 }
2423 }
2424
2425 pub async fn claim_outbound_message_external_id(
2439 &self,
2440 token: &NamespaceToken,
2441 id: Uuid,
2442 external_id: String,
2443 ) -> RuntimeResult<khive_storage::note::Note> {
2444 let store = self.notes(token)?;
2445 let note = store
2446 .get_note(id)
2447 .await?
2448 .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
2449 if note.kind != "message" {
2450 return Err(RuntimeError::InvalidInput(format!(
2451 "external_id can only be claimed on a `message` note; note {id} is a `{}`",
2452 note.kind
2453 )));
2454 }
2455 let props = note.properties.as_ref().and_then(|v| v.as_object());
2456 let direction = props
2457 .and_then(|p| p.get("direction"))
2458 .and_then(|v| v.as_str());
2459 if direction != Some("outbound") {
2460 return Err(RuntimeError::InvalidInput(format!(
2461 "external_id can only be claimed on an outbound message note; note {id} has \
2462 direction {:?}",
2463 direction
2464 )));
2465 }
2466 if note.deleted_at.is_some()
2467 || Self::outbound_delivery_is_terminal(props)
2468 || props
2469 .and_then(|p| p.get("delivered_at"))
2470 .is_some_and(|value| !value.is_null())
2471 {
2472 return Err(RuntimeError::InvalidInput(format!(
2473 "note {id} is not pending outbound delivery"
2474 )));
2475 }
2476 let existing = props
2477 .and_then(|p| p.get("external_id"))
2478 .and_then(|v| v.as_str());
2479 if existing.is_some_and(|v| !v.is_empty()) {
2480 return Err(RuntimeError::InvalidInput(format!(
2481 "note {id} already has an external_id claimed"
2482 )));
2483 }
2484 let mut properties = props
2485 .cloned()
2486 .expect("outbound direction requires an object");
2487 properties.insert("external_id".to_string(), Value::String(external_id));
2488 self.replace_outbound_message_properties_as_owner(token, note, properties)
2489 .await
2490 }
2491
2492 pub async fn list_undelivered_outbound_messages(
2520 &self,
2521 token: &NamespaceToken,
2522 to_prefix: Option<&str>,
2523 limit: u32,
2524 ) -> RuntimeResult<Vec<khive_storage::note::Note>> {
2525 const MAX_SCAN_TOTAL: u32 = 10_000;
2526 if limit == 0 {
2527 return Ok(Vec::new());
2528 }
2529 let now_micros = chrono::Utc::now().timestamp_micros();
2530 let mut property_filters = vec![
2531 PropertyFilter {
2532 json_path: "$.direction".to_string(),
2533 op: FilterOp::Eq,
2534 value: SqlValue::Text("outbound".to_string()),
2535 },
2536 PropertyFilter {
2537 json_path: "$.delivered_at".to_string(),
2538 op: FilterOp::JsonTypeMissingOrNullIndexed,
2539 value: SqlValue::Null,
2540 },
2541 PropertyFilter {
2542 json_path: "$.delivery".to_string(),
2543 op: FilterOp::NotInOrMissing(vec![
2544 SqlValue::Text("delivered".to_string()),
2545 SqlValue::Text("failed".to_string()),
2546 ]),
2547 value: SqlValue::Null,
2548 },
2549 ];
2550 if let Some(prefix) = to_prefix {
2551 property_filters.push(PropertyFilter {
2552 json_path: "$.to_actor".to_string(),
2553 op: FilterOp::TextStartsWithIndexed,
2554 value: SqlValue::Text(prefix.to_string()),
2555 });
2556 }
2557 let filter = NoteFilter {
2558 kind: Some("message".to_string()),
2559 property_filters,
2560 ..Default::default()
2561 };
2562 let candidates = self
2563 .notes(token)?
2564 .query_notes_filtered_count_free(
2565 token.namespace().as_str(),
2566 &filter,
2567 PageRequest {
2568 limit: MAX_SCAN_TOTAL,
2569 offset: 0,
2570 },
2571 )
2572 .await?
2573 .items;
2574
2575 let mut collected: Vec<khive_storage::note::Note> = Vec::new();
2576 for note in candidates {
2577 let props = note.properties.as_ref().and_then(|v| v.as_object());
2578 let retry_deferred = props
2579 .and_then(|p| p.get("next_attempt_at"))
2580 .and_then(|v| v.as_str())
2581 .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
2582 .is_some_and(|deadline| deadline.timestamp_micros() > now_micros);
2583 if retry_deferred {
2584 continue;
2585 }
2586 collected.push(note);
2587 if collected.len() >= limit as usize {
2588 return Ok(collected);
2589 }
2590 }
2591 Ok(collected)
2592 }
2593
2594 async fn outbound_message(
2599 &self,
2600 token: &NamespaceToken,
2601 id: Uuid,
2602 ) -> RuntimeResult<khive_storage::note::Note> {
2603 let note = self
2604 .notes(token)?
2605 .get_note(id)
2606 .await?
2607 .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))?;
2608 if note.kind != "message" || note.deleted_at.is_some() {
2609 return Err(RuntimeError::InvalidInput(format!(
2610 "note {id} is not a live message note (kind {})",
2611 note.kind
2612 )));
2613 }
2614 let outbound = note
2615 .properties
2616 .as_ref()
2617 .and_then(|v| v.as_object())
2618 .and_then(|p| p.get("direction"))
2619 .and_then(|v| v.as_str())
2620 == Some("outbound");
2621 if !outbound {
2622 return Err(RuntimeError::InvalidInput(format!(
2623 "note {id} is not an outbound message"
2624 )));
2625 }
2626 Ok(note)
2627 }
2628
2629 async fn replace_outbound_message_properties(
2630 &self,
2631 token: &NamespaceToken,
2632 mut snapshot: khive_storage::note::Note,
2633 properties: serde_json::Map<String, Value>,
2634 ) -> RuntimeResult<khive_storage::note::Note> {
2635 let expected_updated_at = snapshot.updated_at;
2636 let expected_deleted_at = snapshot.deleted_at;
2637 let id = snapshot.id;
2638 snapshot.properties = Some(Value::Object(properties));
2639 snapshot.updated_at = chrono::Utc::now().timestamp_micros().max(
2640 expected_updated_at.checked_add(1).ok_or_else(|| {
2641 RuntimeError::Internal(format!(
2642 "note {id} updated_at is already at i64::MAX and cannot advance"
2643 ))
2644 })?,
2645 );
2646
2647 let store = self.raw_notes(token)?;
2649 let persisted = store
2650 .replace_note_if_unchanged(snapshot, expected_updated_at, expected_deleted_at)
2651 .await?;
2652 if !persisted {
2653 return Err(stale_note_snapshot_error(id));
2654 }
2655 store
2658 .get_note(id)
2659 .await?
2660 .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))
2661 }
2662
2663 async fn replace_outbound_message_properties_as_owner(
2664 &self,
2665 token: &NamespaceToken,
2666 mut snapshot: khive_storage::note::Note,
2667 properties: serde_json::Map<String, Value>,
2668 ) -> RuntimeResult<khive_storage::note::Note> {
2669 let expected_updated_at = snapshot.updated_at;
2670 let expected_deleted_at = snapshot.deleted_at;
2671 let id = snapshot.id;
2672 snapshot.properties = Some(Value::Object(properties));
2673 snapshot.updated_at = chrono::Utc::now().timestamp_micros().max(
2674 expected_updated_at.checked_add(1).ok_or_else(|| {
2675 RuntimeError::Internal(format!("note {id} updated_at cannot advance"))
2676 })?,
2677 );
2678 let store = self.raw_notes(token)?;
2681 if !store
2682 .replace_note_if_unchanged(snapshot, expected_updated_at, expected_deleted_at)
2683 .await?
2684 {
2685 return Err(stale_note_snapshot_error(id));
2686 }
2687 store
2689 .get_note(id)
2690 .await?
2691 .ok_or_else(|| RuntimeError::NotFound(format!("note {id}")))
2692 }
2693
2694 fn outbound_delivery_is_terminal(props: Option<&serde_json::Map<String, Value>>) -> bool {
2704 props
2705 .and_then(|properties| properties.get("delivery"))
2706 .and_then(Value::as_str)
2707 .is_some_and(|state| state == "delivered" || state == "failed")
2708 }
2709
2710 pub async fn mark_outbound_message_transient_failure(
2715 &self,
2716 token: &NamespaceToken,
2717 id: Uuid,
2718 attempted_at: chrono::DateTime<chrono::Utc>,
2719 last_error: String,
2720 base_delay: std::time::Duration,
2721 max_delay: std::time::Duration,
2722 ) -> RuntimeResult<khive_storage::note::Note> {
2723 if base_delay.is_zero() || max_delay < base_delay {
2724 return Err(RuntimeError::InvalidInput(
2725 "outbound retry delays require a non-zero base no greater than the ceiling"
2726 .to_string(),
2727 ));
2728 }
2729
2730 crate::secret_gate::check_json_at(
2731 &serde_json::json!({
2732 "last_error": &last_error,
2733 }),
2734 "message",
2735 "last_error",
2736 )?;
2737
2738 let snapshot = self.outbound_message(token, id).await?;
2739 let props = snapshot.properties.as_ref().and_then(Value::as_object);
2740 if Self::outbound_delivery_is_terminal(props) {
2741 return Err(RuntimeError::InvalidInput(format!(
2742 "outbound message {id} already has a terminal delivery outcome"
2743 )));
2744 }
2745
2746 let properties = Self::outbound_retry_properties(
2747 props,
2748 attempted_at,
2749 last_error,
2750 base_delay,
2751 max_delay,
2752 )?;
2753 self.replace_outbound_message_properties(token, snapshot, properties)
2754 .await
2755 }
2756
2757 fn outbound_retry_properties(
2758 props: Option<&serde_json::Map<String, Value>>,
2759 attempted_at: chrono::DateTime<chrono::Utc>,
2760 last_error: String,
2761 base_delay: std::time::Duration,
2762 max_delay: std::time::Duration,
2763 ) -> RuntimeResult<serde_json::Map<String, Value>> {
2764 let attempts = props
2765 .and_then(|properties| properties.get("delivery_attempts"))
2766 .and_then(Value::as_u64)
2767 .unwrap_or(0)
2768 .saturating_add(1);
2769 let exponent = attempts.saturating_sub(1).min(127) as u32;
2770 let delay_nanos = base_delay
2771 .as_nanos()
2772 .checked_shl(exponent)
2773 .unwrap_or(u128::MAX)
2774 .min(max_delay.as_nanos());
2775 let delay = std::time::Duration::new(
2776 (delay_nanos / 1_000_000_000) as u64,
2777 (delay_nanos % 1_000_000_000) as u32,
2778 );
2779 let chrono_delay = chrono::TimeDelta::from_std(delay).map_err(|_| {
2780 RuntimeError::InvalidInput("outbound retry ceiling exceeds RFC 3339 range".to_string())
2781 })?;
2782 let next_attempt_at = attempted_at
2783 .checked_add_signed(chrono_delay)
2784 .ok_or_else(|| {
2785 RuntimeError::InvalidInput(
2786 "outbound retry deadline exceeds RFC 3339 range".to_string(),
2787 )
2788 })?;
2789
2790 let mut properties = props.cloned().unwrap_or_default();
2791 properties.insert("delivery_attempts".to_string(), Value::from(attempts));
2792 properties.insert(
2793 "next_attempt_at".to_string(),
2794 Value::String(next_attempt_at.to_rfc3339()),
2795 );
2796 properties.insert("last_error".to_string(), Value::String(last_error));
2797 Ok(properties)
2798 }
2799
2800 pub async fn mark_outbound_message_claim_transient_failure(
2803 &self,
2804 token: &NamespaceToken,
2805 id: Uuid,
2806 attempted_at: chrono::DateTime<chrono::Utc>,
2807 last_error: String,
2808 base_delay: std::time::Duration,
2809 max_delay: std::time::Duration,
2810 ) -> RuntimeResult<khive_storage::note::Note> {
2811 let snapshot = self.outbound_message(token, id).await?;
2812 let props = snapshot.properties.as_ref().and_then(Value::as_object);
2813 let has_claim = props
2814 .and_then(|properties| properties.get("external_id"))
2815 .and_then(Value::as_str)
2816 .is_some_and(|value| !value.is_empty());
2817 let has_delivery = props
2818 .and_then(|properties| properties.get("delivered_at"))
2819 .is_some_and(|value| !value.is_null());
2820 if has_claim || has_delivery || Self::outbound_delivery_is_terminal(props) {
2821 return Ok(snapshot);
2822 }
2823 if base_delay.is_zero() || max_delay < base_delay {
2824 return Err(RuntimeError::InvalidInput(
2825 "outbound retry delays require a non-zero base no greater than the ceiling"
2826 .to_string(),
2827 ));
2828 }
2829 crate::secret_gate::check_json_at(
2830 &serde_json::json!({"last_error": &last_error}),
2831 "message",
2832 "last_error",
2833 )?;
2834 let properties = Self::outbound_retry_properties(
2835 props,
2836 attempted_at,
2837 last_error,
2838 base_delay,
2839 max_delay,
2840 )?;
2841 self.replace_outbound_message_properties_as_owner(token, snapshot, properties)
2842 .await
2843 }
2844
2845 pub async fn mark_outbound_message_delivered(
2857 &self,
2858 token: &NamespaceToken,
2859 id: Uuid,
2860 delivered_at: String,
2861 transport_message_id: Option<String>,
2862 ) -> RuntimeResult<khive_storage::note::Note> {
2863 crate::secret_gate::check_json_at(
2864 &serde_json::json!({
2865 "delivered_at": &delivered_at,
2866 "transport_message_id": &transport_message_id,
2867 }),
2868 "message",
2869 "delivered",
2870 )?;
2871 let snapshot = self.outbound_message(token, id).await?;
2872 if Self::outbound_delivery_is_terminal(
2873 snapshot.properties.as_ref().and_then(Value::as_object),
2874 ) {
2875 return Err(RuntimeError::InvalidInput(format!(
2876 "outbound message {id} already has a terminal delivery outcome"
2877 )));
2878 }
2879 let mut props = snapshot
2880 .properties
2881 .as_ref()
2882 .and_then(Value::as_object)
2883 .cloned()
2884 .unwrap_or_default();
2885 props.remove("delivery_attempts");
2886 props.remove("next_attempt_at");
2887 props.insert("delivery".into(), Value::String("delivered".into()));
2888 props.insert("delivered_at".into(), Value::String(delivered_at));
2889 if let Some(transport_message_id) = transport_message_id {
2890 props.insert(
2891 "transport_message_id".into(),
2892 Value::String(transport_message_id),
2893 );
2894 }
2895 self.replace_outbound_message_properties(token, snapshot, props)
2896 .await
2897 }
2898
2899 pub async fn mark_outbound_message_failed(
2906 &self,
2907 token: &NamespaceToken,
2908 id: Uuid,
2909 failed_at: String,
2910 last_error: String,
2911 ) -> RuntimeResult<khive_storage::note::Note> {
2912 crate::secret_gate::check_json_at(
2913 &serde_json::json!({
2914 "failed_at": &failed_at,
2915 "last_error": &last_error,
2916 }),
2917 "message",
2918 "failed",
2919 )?;
2920 let snapshot = self.outbound_message(token, id).await?;
2921 if Self::outbound_delivery_is_terminal(
2922 snapshot.properties.as_ref().and_then(Value::as_object),
2923 ) {
2924 return Err(RuntimeError::InvalidInput(format!(
2925 "outbound message {id} already has a terminal delivery outcome"
2926 )));
2927 }
2928 let mut props = snapshot
2929 .properties
2930 .as_ref()
2931 .and_then(Value::as_object)
2932 .cloned()
2933 .unwrap_or_default();
2934 props.remove("delivery_attempts");
2935 props.remove("next_attempt_at");
2936 props.insert("delivery".into(), Value::String("failed".into()));
2937 props.insert("failed_at".into(), Value::String(failed_at));
2938 props.insert("last_error".into(), Value::String(last_error));
2939 self.replace_outbound_message_properties(token, snapshot, props)
2940 .await
2941 }
2942
2943 pub async fn mark_outbound_message_claim_failed(
2949 &self,
2950 token: &NamespaceToken,
2951 id: Uuid,
2952 failed_at: String,
2953 last_error: String,
2954 ) -> RuntimeResult<khive_storage::note::Note> {
2955 let snapshot = self.outbound_message(token, id).await?;
2956 self.mark_outbound_message_claim_failed_from_snapshot(
2957 token, snapshot, failed_at, last_error,
2958 )
2959 .await
2960 }
2961
2962 async fn mark_outbound_message_claim_failed_from_snapshot(
2963 &self,
2964 token: &NamespaceToken,
2965 snapshot: khive_storage::note::Note,
2966 failed_at: String,
2967 last_error: String,
2968 ) -> RuntimeResult<khive_storage::note::Note> {
2969 let props = snapshot.properties.as_ref().and_then(Value::as_object);
2970 let has_claim = props
2971 .and_then(|properties| properties.get("external_id"))
2972 .and_then(Value::as_str)
2973 .is_some_and(|value| !value.is_empty());
2974 let has_delivery = props
2975 .and_then(|properties| properties.get("delivered_at"))
2976 .is_some_and(|value| !value.is_null());
2977 if has_claim || has_delivery || Self::outbound_delivery_is_terminal(props) {
2978 return Ok(snapshot);
2979 }
2980 crate::secret_gate::check_json_at(
2981 &serde_json::json!({ "failed_at": &failed_at, "last_error": &last_error }),
2982 "message",
2983 "failed",
2984 )?;
2985 let mut properties = props.cloned().unwrap_or_default();
2986 properties.remove("delivery_attempts");
2987 properties.remove("next_attempt_at");
2988 properties.insert("delivery".into(), Value::String("failed".into()));
2989 properties.insert("failed_at".into(), Value::String(failed_at));
2990 properties.insert("last_error".into(), Value::String(last_error));
2991 self.replace_outbound_message_properties_as_owner(token, snapshot, properties)
2992 .await
2993 }
2994
2995 pub async fn merge_note(
3006 &self,
3007 token: &NamespaceToken,
3008 into_id: Uuid,
3009 from_id: Uuid,
3010 strategy: EntityDedupMergePolicy,
3011 content_strategy: ContentMergeStrategy,
3012 dry_run: bool,
3013 ) -> RuntimeResult<MergeSummary> {
3014 self.merge_note_with_reason(
3015 token,
3016 into_id,
3017 from_id,
3018 strategy,
3019 content_strategy,
3020 dry_run,
3021 None,
3022 )
3023 .await
3024 }
3025
3026 #[allow(clippy::too_many_arguments)]
3030 pub async fn merge_note_with_reason(
3031 &self,
3032 token: &NamespaceToken,
3033 into_id: Uuid,
3034 from_id: Uuid,
3035 strategy: EntityDedupMergePolicy,
3036 content_strategy: ContentMergeStrategy,
3037 dry_run: bool,
3038 reason: Option<String>,
3039 ) -> RuntimeResult<MergeSummary> {
3040 if let Some(reason) = reason.as_deref() {
3041 crate::secret_gate::check_at(reason, "merge", "reason")?;
3042 }
3043 if into_id == from_id {
3044 return Err(RuntimeError::InvalidInput(
3045 "cannot merge a note into itself".into(),
3046 ));
3047 }
3048 let ns = token.namespace().as_str().to_string();
3049 let fts_table = "fts_notes".to_string();
3050 let embedding_plan = EmbeddingModelPlan::capture(self);
3053 let vec_tables = embedding_plan.vector_tables();
3054 let pack_rules = self.pack_edge_rules();
3055
3056 let note_store = self.notes(token)?;
3057 let into_note = note_store
3058 .get_note(into_id)
3059 .await?
3060 .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
3061 Self::ensure_namespace(&into_note.namespace, &ns)?;
3062
3063 let from_note = note_store
3064 .get_note(from_id)
3065 .await?
3066 .ok_or_else(|| RuntimeError::NotFound("not found in this namespace".into()))?;
3067 Self::ensure_namespace(&from_note.namespace, &ns)?;
3068
3069 if !dry_run {
3070 for note in [&into_note, &from_note] {
3071 if let Some(error) = self.stream_member_error(note).await? {
3072 return Err(error);
3073 }
3074 }
3075 }
3076 reject_pack_managed_schedule_mutation(&into_note, "merge")?;
3077 reject_pack_managed_schedule_mutation(&from_note, "merge")?;
3078
3079 let _ = self.graph(token)?;
3080 let _ = self.text_for_notes(token)?;
3081 let _ = self.events(token)?;
3082 for model_name in embedding_plan.model_names() {
3083 let _ = self.vectors_for_model(token, model_name)?;
3084 }
3085
3086 let preserve_owner_established = self.is_pack_owned_note_kind(&into_note.kind);
3091
3092 let pool = self.backend().pool_arc();
3093 let writer_task = pool
3094 .writer_task_for_runtime_write(RuntimeWriteOperation::MergeNote)
3095 .map_err(RuntimeError::Storage)?;
3096 let event_context = MergeEventContext {
3097 attribution: EventAttribution::from_token(token),
3098 reason,
3099 force: false,
3100 strategy,
3101 content_strategy,
3102 kind: EventKind::NoteMerged,
3103 substrate: SubstrateKind::Note,
3104 event_id: None,
3105 };
3106
3107 let (mut summary, updated_note) = if let Some(writer_task) = writer_task {
3108 writer_task
3109 .send(move |conn| {
3110 merge_note_sql(
3111 conn,
3112 ns,
3113 fts_table,
3114 vec_tables,
3115 into_id,
3116 from_id,
3117 strategy,
3118 content_strategy,
3119 dry_run,
3120 pack_rules,
3121 preserve_owner_established,
3122 MergeTxLimits::default(),
3123 Some(event_context),
3124 )
3125 .map_err(|e| {
3126 khive_storage::StorageError::driver(
3127 khive_storage::StorageCapability::Notes,
3128 "merge_note",
3129 e,
3130 )
3131 })
3132 })
3133 .await
3134 .map_err(map_merge_note_storage_error)?
3135 } else {
3136 tokio::task::spawn_blocking(move || {
3137 let guard = pool.writer()?;
3138 let mut refusal = None;
3139 let result = guard.transaction(|conn| {
3140 merge_note_sql(
3141 conn,
3142 ns,
3143 fts_table,
3144 vec_tables,
3145 into_id,
3146 from_id,
3147 strategy,
3148 content_strategy,
3149 dry_run,
3150 pack_rules,
3151 preserve_owner_established,
3152 MergeTxLimits::default(),
3153 Some(event_context),
3154 )
3155 .map_err(|error| match error {
3156 MergeSqlError::Sqlite(error) => error,
3157 MergeSqlError::Refusal(error) => {
3158 refusal = Some(error);
3159 SqliteError::InvalidData(
3160 "note merge refused by transactional policy".to_string(),
3161 )
3162 }
3163 })
3164 });
3165 match refusal {
3166 Some(error) => Err(error),
3167 None => result.map_err(RuntimeError::from),
3168 }
3169 })
3170 .await
3171 .map_err(|e| RuntimeError::Internal(e.to_string()))??
3172 };
3173
3174 if !dry_run {
3176 khive_storage::usage::count(khive_storage::usage::UsageUnit::EventRows, 1);
3177 tracing::info!(
3178 into_id = %summary.kept_id,
3179 from_id = %summary.removed_id,
3180 budget_rows = summary.tx_budget.rows_charged,
3181 budget_bytes = summary.tx_budget.bytes_charged,
3182 budget_max_rows = summary.tx_budget.max_rows,
3183 budget_max_bytes = summary.tx_budget.max_bytes,
3184 "merge_note: transaction materialization budget"
3185 );
3186 }
3187
3188 if !dry_run {
3189 if !embedding_plan.is_empty() {
3190 #[cfg(any(test, feature = "fault-injection"))]
3191 let reindex_result =
3192 if crate::operations::consume_fts_fail_fault(&updated_note.namespace) {
3193 Err(RuntimeError::Internal("injected FTS failure".to_string()))
3194 } else {
3195 self.reindex_note_with_plan(token, &updated_note, &embedding_plan)
3196 .await
3197 };
3198 #[cfg(not(any(test, feature = "fault-injection")))]
3199 let reindex_result = self
3200 .reindex_note_with_plan(token, &updated_note, &embedding_plan)
3201 .await;
3202
3203 match reindex_result {
3204 Ok(report) => summary.embedding_truncation = report,
3205 Err(error) => {
3206 tracing::warn!(
3207 into_id = %summary.kept_id,
3208 from_id = %summary.removed_id,
3209 error = %error,
3210 "merge_note: committed merge but survivor reindex failed"
3211 );
3212 summary.post_commit_reindex_error = Some(error.to_string());
3213 }
3214 }
3215 }
3216 self.fire_note_mutation_hook(&updated_note.kind, updated_note.id)
3219 .await;
3220 }
3221
3222 Ok(summary)
3223 }
3224}
3225
3226fn reject_pack_managed_schedule_mutation(
3234 note: &khive_storage::note::Note,
3235 operation: &str,
3236) -> RuntimeResult<()> {
3237 if note.kind == "scheduled_event" {
3238 return Err(RuntimeError::InvalidInput(format!(
3239 "cannot {operation} a schedule-managed `scheduled_event` note through generic KG \
3240 mutation; use schedule.cancel or create a replacement schedule"
3241 )));
3242 }
3243 Ok(())
3244}
3245
3246pub fn entity_embedding_text(entity: &Entity) -> String {
3253 match &entity.description {
3254 Some(description) if !description.is_empty() => {
3255 format!("{} {description}", entity.name)
3256 }
3257 _ => entity.name.clone(),
3258 }
3259}
3260
3261pub fn note_embedding_text(note: &Note) -> String {
3264 note_embedding_text_ref(note).to_owned()
3265}
3266
3267pub(crate) fn note_embedding_text_ref(note: &Note) -> &str {
3270 ¬e.content
3271}
3272
3273pub fn entity_fts_document(entity: &Entity) -> TextDocument {
3286 let updated_at =
3287 chrono::DateTime::from_timestamp_micros(entity.updated_at).unwrap_or_else(chrono::Utc::now);
3288 TextDocument {
3289 subject_id: entity.id,
3290 kind: SubstrateKind::Entity,
3291 record_kind: Some(entity.kind.clone()),
3292 title: Some(entity.name.clone()),
3293 body: entity_embedding_text(entity),
3294 tags: entity.tags.clone(),
3295 namespace: entity.namespace.clone(),
3296 metadata: entity.properties.clone(),
3297 updated_at,
3298 }
3299}
3300
3301pub fn note_fts_document(note: &Note) -> TextDocument {
3314 let body = match ¬e.name {
3315 Some(n) => format!("{n} {}", note.content),
3316 None => note.content.clone(),
3317 };
3318 let updated_at =
3319 chrono::DateTime::from_timestamp_micros(note.updated_at).unwrap_or_else(chrono::Utc::now);
3320 TextDocument {
3321 subject_id: note.id,
3322 kind: SubstrateKind::Note,
3323 record_kind: Some(note.kind.clone()),
3324 title: note.name.clone(),
3325 body,
3326 tags: vec![],
3327 namespace: note.namespace.clone(),
3328 metadata: note.properties.clone(),
3329 updated_at,
3330 }
3331}
3332
3333pub(crate) struct NoteFtsScalars {
3339 pub record_kind: String,
3341 pub title: String,
3344 pub body: String,
3345 pub tags: String,
3347 pub metadata: Option<String>,
3349 pub updated_at_micros: i64,
3351}
3352
3353pub(crate) fn note_fts_scalars(note: &Note) -> NoteFtsScalars {
3358 let doc = note_fts_document(note);
3359 NoteFtsScalars {
3360 record_kind: doc.record_kind.unwrap_or_default(),
3361 title: doc.title.unwrap_or_default(),
3362 body: doc.body,
3363 tags: "[]".to_string(),
3364 metadata: doc
3365 .metadata
3366 .as_ref()
3367 .map(|v| serde_json::to_string(v).unwrap_or_default()),
3368 updated_at_micros: doc.updated_at.timestamp_micros(),
3369 }
3370}
3371
3372fn probe_merge_entity_bytes(conn: &rusqlite::Connection, id: Uuid) -> Result<usize, SqliteError> {
3391 let id_str = id.to_string();
3392 let len: Option<i64> = conn
3393 .query_row(
3394 "SELECT LENGTH(CAST(name AS BLOB)) \
3395 + COALESCE(LENGTH(CAST(description AS BLOB)), 0) \
3396 + COALESCE(LENGTH(CAST(properties AS BLOB)), 0) \
3397 + LENGTH(CAST(tags AS BLOB)) \
3398 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
3399 rusqlite::params![id_str],
3400 |row| row.get(0),
3401 )
3402 .optional()
3403 .map_err(SqliteError::Rusqlite)?;
3404 Ok(128_usize.saturating_add(len.unwrap_or(0).max(0) as usize))
3405}
3406
3407fn read_merge_entity(
3409 conn: &rusqlite::Connection,
3410 id: Uuid,
3411 namespace: &str,
3412) -> Result<Entity, SqliteError> {
3413 let id_str = id.to_string();
3414 let mut stmt = conn.prepare(
3415 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
3416 created_at, updated_at, deleted_at, merged_into, merge_event_id, \
3417 (SELECT a.content_ref FROM attachments AS a \
3418 WHERE a.record_uuid = entities.id AND a.substrate = 'entity' \
3419 AND a.role = 'content') AS content_ref, entities.version \
3420 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
3421 )?;
3422 let mut rows = stmt.query(rusqlite::params![id_str])?;
3423 let row = rows
3424 .next()?
3425 .ok_or_else(|| SqliteError::InvalidData(format!("entity {id} not found")))?;
3426
3427 let id_s: String = row.get(0)?;
3428 let ns: String = row.get(1)?;
3429 let kind: String = row.get(2)?;
3430 let entity_type: Option<String> = row.get(3)?;
3431 let name: String = row.get(4)?;
3432 let description: Option<String> = row.get(5)?;
3433 let properties_str: Option<String> = row.get(6)?;
3434 let tags_str: String = row.get(7)?;
3435 let created_at: i64 = row.get(8)?;
3436 let updated_at: i64 = row.get(9)?;
3437 let deleted_at: Option<i64> = row.get(10)?;
3438 let merged_into_str: Option<String> = row.get(11)?;
3439 let merge_event_id_str: Option<String> = row.get(12)?;
3440 let content_ref: Option<String> = row.get(13)?;
3441 let version: i64 = row.get(14)?;
3442
3443 if ns != namespace {
3444 return Err(SqliteError::InvalidData(format!(
3445 "entity {id} belongs to namespace '{ns}', not '{namespace}'"
3446 )));
3447 }
3448
3449 let entity_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
3450 let properties: Option<Value> = properties_str
3451 .map(|s| {
3452 serde_json::from_str::<Value>(&s).map_err(|e| SqliteError::InvalidData(e.to_string()))
3453 })
3454 .transpose()?;
3455 let tags: Vec<String> =
3456 serde_json::from_str(&tags_str).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
3457 let merged_into = merged_into_str
3458 .as_deref()
3459 .map(Uuid::parse_str)
3460 .transpose()
3461 .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
3462 let merge_event_id = merge_event_id_str
3463 .as_deref()
3464 .map(Uuid::parse_str)
3465 .transpose()
3466 .map_err(|e| SqliteError::InvalidData(e.to_string()))?;
3467
3468 Ok(Entity {
3469 id: entity_id,
3470 namespace: ns,
3471 kind,
3472 entity_type,
3473 name,
3474 description,
3475 properties,
3476 tags,
3477 created_at,
3478 updated_at,
3479 version,
3480 deleted_at,
3481 merged_into,
3482 merge_event_id,
3483 content_ref,
3484 })
3485}
3486
3487#[allow(clippy::too_many_arguments)]
3498fn merge_entity_sql(
3499 conn: &rusqlite::Connection,
3500 namespace: String,
3501 fts_table: String,
3502 vec_tables: Vec<String>,
3503 into_id: Uuid,
3504 from_id: Uuid,
3505 strategy: EntityDedupMergePolicy,
3506 content_strategy: ContentMergeStrategy,
3507 dry_run: bool,
3508 pack_rules: Vec<EdgeEndpointRule>,
3509 validation: EntityMergeValidation,
3510 limits: MergeTxLimits,
3511 merge_event_id: Uuid,
3512 event_context: Option<MergeEventContext>,
3513) -> Result<(MergeSummary, Entity), MergeSqlError> {
3514 let mut budget = MergeTxBudget::new(limits);
3515 budget.charge(
3519 0,
3520 vec_tables.iter().map(String::len).sum::<usize>()
3521 + pack_rules.len() * std::mem::size_of::<EdgeEndpointRule>(),
3522 "preparing pack and vector fanout",
3523 )?;
3524
3525 budget.charge(
3526 1,
3527 probe_merge_entity_bytes(conn, into_id)?,
3528 "reading merge records",
3529 )?;
3530 let into_entity = read_merge_entity(conn, into_id, &namespace)?;
3531 budget.charge(
3532 1,
3533 probe_merge_entity_bytes(conn, from_id)?,
3534 "reading merge records",
3535 )?;
3536 let from_entity = read_merge_entity(conn, from_id, &namespace)?;
3537
3538 for properties in [&into_entity.properties, &from_entity.properties] {
3541 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())
3542 .map_err(MergeSqlError::Refusal)?;
3543 }
3544
3545 match validation {
3546 EntityMergeValidation::LegacyKind if into_entity.kind != from_entity.kind => {
3547 return Err(MergeSqlError::Refusal(
3548 EntityMergeRefusal::LegacyKind {
3549 into_id,
3550 into_kind: into_entity.kind,
3551 from_id,
3552 from_kind: from_entity.kind,
3553 }
3554 .into_runtime_error(),
3555 ));
3556 }
3557 EntityMergeValidation::SafetyFloor => {
3558 validate_entity_merge_floor(&into_entity, &from_entity).map_err(|guard| {
3559 MergeSqlError::Refusal(EntityMergeRefusal::SafetyFloor(guard).into_runtime_error())
3560 })?;
3561 }
3562 EntityMergeValidation::LegacyKind | EntityMergeValidation::Forced => {}
3563 }
3564
3565 let parse_id =
3567 |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
3568
3569 let from_str = from_id.to_string();
3570
3571 let mut outbound: Vec<EdgeRow> = Vec::new();
3578 {
3579 let mut stmt = conn.prepare(
3580 "SELECT id, namespace, source_id, target_id, relation, weight, created_at, \
3581 updated_at, deleted_at, target_backend, metadata \
3582 FROM graph_edges WHERE source_id = ?1",
3583 )?;
3584 let mut rows = stmt.query(rusqlite::params![&from_str])?;
3585 while let Some(row) = rows.next()? {
3586 let edge = EdgeRow {
3587 id: parse_id(row.get(0)?)?,
3588 namespace: row.get(1)?,
3589 source_id: parse_id(row.get(2)?)?,
3590 target_id: parse_id(row.get(3)?)?,
3591 relation: row.get(4)?,
3592 weight: row.get(5)?,
3593 created_at: row.get(6)?,
3594 updated_at: row.get(7)?,
3595 deleted_at: row.get(8)?,
3596 target_backend: row.get(9)?,
3597 metadata: row.get(10)?,
3598 };
3599 budget.charge(1, edge_row_budget_bytes(&edge), "collecting incident edges")?;
3600 outbound.push(edge);
3601 }
3602 }
3603
3604 let mut inbound: Vec<EdgeRow> = Vec::new();
3605 {
3606 let mut stmt = conn.prepare(
3607 "SELECT id, namespace, source_id, target_id, relation, weight, created_at, \
3608 updated_at, deleted_at, target_backend, metadata \
3609 FROM graph_edges WHERE target_id = ?1",
3610 )?;
3611 let mut rows = stmt.query(rusqlite::params![&from_str])?;
3612 while let Some(row) = rows.next()? {
3613 let edge = EdgeRow {
3614 id: parse_id(row.get(0)?)?,
3615 namespace: row.get(1)?,
3616 source_id: parse_id(row.get(2)?)?,
3617 target_id: parse_id(row.get(3)?)?,
3618 relation: row.get(4)?,
3619 weight: row.get(5)?,
3620 created_at: row.get(6)?,
3621 updated_at: row.get(7)?,
3622 deleted_at: row.get(8)?,
3623 target_backend: row.get(9)?,
3624 metadata: row.get(10)?,
3625 };
3626 budget.charge(1, edge_row_budget_bytes(&edge), "collecting incident edges")?;
3627 inbound.push(edge);
3628 }
3629 }
3630
3631 let mut seen: HashSet<Uuid> = HashSet::new();
3633 let mut all_edges: Vec<EdgeRow> = Vec::new();
3634 for edge in outbound.into_iter().chain(inbound) {
3635 if seen.insert(edge.id) {
3636 all_edges.push(edge);
3637 }
3638 }
3639 let original_edges: HashMap<Uuid, EdgeRow> = all_edges
3640 .iter()
3641 .map(|edge| (edge.id, edge.clone()))
3642 .collect();
3643
3644 let (merged_props, properties_merged) =
3646 merge_properties(&into_entity.properties, &from_entity.properties, strategy);
3647 crate::secret_gate::reject_reserved_secret_gate_property(merged_props.as_ref())
3648 .map_err(MergeSqlError::Refusal)?;
3649 let merged_name = merge_string_field(&into_entity.name, &from_entity.name, strategy);
3650 let (merged_description, content_appended) = match content_strategy {
3651 ContentMergeStrategy::Append => {
3652 let into_desc = into_entity.description.as_deref().unwrap_or("");
3653 let from_desc = from_entity.description.as_deref().unwrap_or("");
3654 if from_desc.is_empty() {
3655 (into_entity.description.clone(), false)
3656 } else if into_desc.is_empty() {
3657 (from_entity.description.clone(), true)
3658 } else {
3659 (Some(format!("{}\n\n---\n\n{}", into_desc, from_desc)), true)
3660 }
3661 }
3662 ContentMergeStrategy::PreferInto => (into_entity.description.clone(), false),
3666 ContentMergeStrategy::PreferFrom => (from_entity.description.clone(), false),
3667 };
3668 let (merged_tags, tags_unioned) = union_tags(&into_entity.tags, &from_entity.tags);
3669
3670 let now = chrono::Utc::now().timestamp_micros();
3671 let into_str = into_id.to_string();
3672 let props_str = merged_props
3673 .as_ref()
3674 .map(|v| serde_json::to_string(v).unwrap_or_default());
3675 let tags_json = serde_json::to_string(&merged_tags).unwrap_or_else(|_| "[]".to_string());
3676
3677 let mut rewired_edge_ids = HashSet::new();
3680 let mut edges_contract_skipped = 0usize;
3681 let mut edge_conflict_preimages = Vec::new();
3682 let mut edges_self_loop_dropped = 0usize;
3683 let mut self_loop_edge_preimages = Vec::new();
3684 let mut conflict_deleted_edge_ids = HashSet::new();
3685 for edge in all_edges {
3686 if conflict_deleted_edge_ids.contains(&edge.id) {
3687 continue;
3688 }
3689 let raw_src = if edge.source_id == from_id {
3690 into_id
3691 } else {
3692 edge.source_id
3693 };
3694 let raw_tgt = if edge.target_id == from_id {
3695 into_id
3696 } else {
3697 edge.target_id
3698 };
3699 let relation_typed = edge.relation.parse::<EdgeRelation>().ok();
3700 let (new_src, new_tgt) = match relation_typed {
3703 Some(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
3704 None => (raw_src, raw_tgt),
3705 };
3706
3707 if new_src == new_tgt {
3708 self_loop_edge_preimages.push(edge_row_preimage(&edge)?);
3712 edges_self_loop_dropped += 1;
3713 if !dry_run {
3714 conn.execute(
3715 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
3716 rusqlite::params![&edge.namespace, edge.id.to_string()],
3717 )?;
3718 }
3719 continue;
3720 }
3721
3722 let contract_ok = match relation_typed {
3730 Some(EdgeRelation::Annotates) => true,
3735 Some(rel) => {
3736 let src_info = if new_src == into_id {
3737 Some((
3738 "entity",
3739 into_entity.kind.clone(),
3740 into_entity.entity_type.clone(),
3741 ))
3742 } else {
3743 resolve_merge_edge_endpoint_budgeted(conn, new_src, &mut budget)?
3744 };
3745 let tgt_info = if new_tgt == into_id {
3746 Some((
3747 "entity",
3748 into_entity.kind.clone(),
3749 into_entity.entity_type.clone(),
3750 ))
3751 } else {
3752 resolve_merge_edge_endpoint_budgeted(conn, new_tgt, &mut budget)?
3753 };
3754 match (src_info, tgt_info) {
3755 (Some((src_sub, src_kind, src_type)), Some((tgt_sub, tgt_kind, tgt_type))) => {
3756 merge_rewire_endpoint_contract_allows(
3757 &pack_rules,
3758 rel,
3759 src_sub,
3760 &src_kind,
3761 src_type.as_deref(),
3762 tgt_sub,
3763 &tgt_kind,
3764 tgt_type.as_deref(),
3765 )
3766 }
3767 _ => false,
3770 }
3771 }
3772 None => true,
3775 };
3776 if !contract_ok {
3777 if !dry_run {
3778 conn.execute(
3779 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
3780 rusqlite::params![&edge.namespace, edge.id.to_string()],
3781 )?;
3782 }
3783 tracing::warn!(
3784 edge_id = %edge.id,
3785 source = %new_src,
3786 target = %new_tgt,
3787 relation = %edge.relation,
3788 "merge_entity: dropping rewired edge — endpoint contract violation post-merge"
3789 );
3790 edges_contract_skipped += 1;
3791 continue;
3792 }
3793
3794 let now_ts = chrono::Utc::now().timestamp_micros();
3795 let conflict_id: Option<String> = {
3802 let conflict_src = new_src.to_string();
3803 let conflict_tgt = new_tgt.to_string();
3804 conn.query_row(
3805 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
3806 rusqlite::params![
3807 &edge.namespace,
3808 &conflict_src,
3809 &conflict_tgt,
3810 &edge.relation,
3811 edge.id.to_string(),
3812 ],
3813 |row| row.get(0),
3814 )
3815 .optional()
3816 .map_err(SqliteError::Rusqlite)?
3817 };
3818
3819 if let Some(conflict_id) = conflict_id {
3820 let surviving_edge_id = Uuid::parse_str(&conflict_id)
3826 .map_err(|error| SqliteError::InvalidData(error.to_string()))?;
3827 let incident_edge_preimages = collect_conflict_incident_edge_preimages(
3828 conn,
3829 edge.id,
3830 &original_edges,
3831 &mut budget,
3832 )?;
3833 for incident in &incident_edge_preimages {
3834 conflict_deleted_edge_ids.insert(incident.id);
3835 rewired_edge_ids.remove(&incident.id);
3836 }
3837 conflict_deleted_edge_ids.insert(edge.id);
3838 rewired_edge_ids.insert(edge.id);
3839
3840 if !dry_run {
3841 delete_conflict_incident_edges(conn, &incident_edge_preimages)?;
3842 conn.execute(
3843 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
3844 rusqlite::params![&edge.namespace, edge.id.to_string()],
3845 )?;
3846 }
3847 edge_conflict_preimages.push(MergeEdgeConflictPreimage {
3848 surviving_edge_id,
3849 dropped_edge: edge_row_preimage(&edge)?,
3850 incident_edge_preimages,
3851 });
3852 } else {
3853 if dry_run {
3854 rewired_edge_ids.insert(edge.id);
3855 continue;
3856 }
3857 let changed = conn.execute(
3858 "UPDATE graph_edges SET \
3859 source_id = ?1, target_id = ?2, updated_at = ?3 \
3860 WHERE namespace = ?4 AND id = ?5",
3861 rusqlite::params![
3862 new_src.to_string(),
3863 new_tgt.to_string(),
3864 now_ts,
3865 &edge.namespace,
3866 edge.id.to_string(),
3867 ],
3868 )?;
3869 if changed > 0 {
3870 rewired_edge_ids.insert(edge.id);
3871 }
3872 }
3873 }
3874 let edges_rewired = rewired_edge_ids.len();
3875
3876 if !dry_run {
3877 conn.execute(
3882 "UPDATE entities SET version = version + 1, \
3883 name = ?1, description = ?2, properties = ?3, tags = ?4, \
3884 updated_at = ?5, merged_into = NULL, merge_event_id = NULL \
3885 WHERE namespace = ?6 AND id = ?7",
3886 rusqlite::params![
3887 &merged_name,
3888 &merged_description,
3889 &props_str,
3890 &tags_json,
3891 now,
3892 &namespace,
3893 &into_str,
3894 ],
3895 )?;
3896
3897 let fts_body = match &merged_description {
3901 Some(d) if !d.is_empty() => format!("{} {}", merged_name, d),
3902 _ => merged_name.clone(),
3903 };
3904 let kind_str = SubstrateKind::Entity.to_string();
3905 let fts_map = khive_db::stores::text::rowid_map_table(&fts_table);
3906
3907 conn.execute(
3913 &format!(
3914 "DELETE FROM {fts_table} WHERE rowid IN \
3915 (SELECT rowid FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2) \
3916 AND namespace = ?1 AND subject_id = ?2"
3917 ),
3918 rusqlite::params![&namespace, &into_str],
3919 )?;
3920 conn.execute(
3921 &format!(
3922 "INSERT INTO {} \
3923 (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
3924 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
3925 fts_table
3926 ),
3927 rusqlite::params![
3928 &into_str,
3929 &kind_str,
3930 &merged_name,
3931 &fts_body,
3932 &tags_json,
3933 &namespace,
3934 &props_str,
3935 now,
3936 &into_entity.kind,
3937 ],
3938 )?;
3939 conn.execute(
3940 &format!(
3941 "INSERT OR REPLACE INTO {fts_map} (namespace, subject_id, rowid) \
3942 VALUES (?1, ?2, last_insert_rowid())"
3943 ),
3944 rusqlite::params![&namespace, &into_str],
3945 )?;
3946
3947 conn.execute(
3951 &format!(
3952 "DELETE FROM {fts_table} WHERE rowid IN \
3953 (SELECT rowid FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2) \
3954 AND namespace = ?1 AND subject_id = ?2"
3955 ),
3956 rusqlite::params![&namespace, &from_str],
3957 )?;
3958 conn.execute(
3959 &format!("DELETE FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2"),
3960 rusqlite::params![&namespace, &from_str],
3961 )?;
3962
3963 khive_db::stores::vectors::delete_subject_from_vector_tables(
3964 conn,
3965 &vec_tables,
3966 from_id,
3967 &namespace,
3968 )?;
3969
3970 conn.execute(
3971 "UPDATE entities \
3972 SET deleted_at = ?1, merged_into = ?2, merge_event_id = ?3, updated_at = ?1, version = version + 1 \
3973 WHERE namespace = ?4 AND id = ?5 AND deleted_at IS NULL",
3974 rusqlite::params![
3975 now,
3976 into_str,
3977 merge_event_id.to_string(),
3978 &namespace,
3979 &from_str,
3980 ],
3981 )?;
3982 }
3983
3984 let updated_entity = Entity {
3985 id: into_id,
3986 namespace,
3987 kind: into_entity.kind,
3988 entity_type: into_entity.entity_type,
3989 name: merged_name,
3990 description: merged_description,
3991 properties: merged_props,
3992 tags: merged_tags,
3993 created_at: into_entity.created_at,
3994 updated_at: now,
3995 deleted_at: into_entity.deleted_at,
3996 merged_into: None,
3997 merge_event_id: None,
3998 version: if dry_run {
3999 into_entity.version
4000 } else {
4001 into_entity
4002 .version
4003 .checked_add(1)
4004 .ok_or_else(|| SqliteError::InvalidData("entity version overflow".into()))?
4005 },
4006 content_ref: into_entity.content_ref,
4007 };
4008
4009 let summary = MergeSummary {
4010 kept_id: into_id,
4011 removed_id: from_id,
4012 edges_rewired,
4013 edges_self_loop_dropped,
4014 self_loop_edge_preimages,
4015 edges_contract_skipped,
4016 edge_conflict_preimages,
4017 properties_merged,
4018 tags_unioned,
4019 content_appended,
4020 dry_run,
4021 tx_budget: budget.report(),
4022 embedding_truncation: Default::default(),
4023 post_commit_reindex_error: None,
4024 };
4025 if !dry_run {
4028 if let Some(context) = event_context {
4029 append_merge_event_in_transaction(conn, context, &summary, &updated_entity.namespace)?;
4030 }
4031 }
4032 Ok((summary, updated_entity))
4033}
4034
4035fn probe_merge_note_bytes(conn: &rusqlite::Connection, id: Uuid) -> Result<usize, SqliteError> {
4044 let id_str = id.to_string();
4045 let len: Option<i64> = conn
4046 .query_row(
4047 "SELECT COALESCE(LENGTH(CAST(name AS BLOB)), 0) \
4048 + LENGTH(CAST(content AS BLOB)) \
4049 + COALESCE(LENGTH(CAST(properties AS BLOB)), 0) \
4050 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
4051 rusqlite::params![id_str],
4052 |row| row.get(0),
4053 )
4054 .optional()
4055 .map_err(SqliteError::Rusqlite)?;
4056 Ok(128_usize.saturating_add(len.unwrap_or(0).max(0) as usize))
4057}
4058
4059fn read_merge_note(
4061 conn: &rusqlite::Connection,
4062 id: Uuid,
4063 namespace: &str,
4064) -> Result<khive_storage::note::Note, SqliteError> {
4065 use khive_storage::note::Note;
4066 let id_str = id.to_string();
4067 let mut stmt = conn.prepare(
4068 "SELECT id, namespace, kind, status, name, content, salience, decay_factor, \
4069 expires_at, properties, created_at, updated_at, deleted_at, key, version \
4070 FROM notes WHERE id = ?1 AND deleted_at IS NULL",
4071 )?;
4072 let mut rows = stmt.query(rusqlite::params![id_str])?;
4073 let row = rows
4074 .next()?
4075 .ok_or_else(|| SqliteError::InvalidData(format!("note {id} not found")))?;
4076
4077 let id_s: String = row.get(0)?;
4078 let ns: String = row.get(1)?;
4079 let kind: String = row.get(2)?;
4080 let status: String = row.get(3)?;
4081 let name: Option<String> = row.get(4)?;
4082 let content: String = row.get(5)?;
4083 let salience: Option<f64> = row.get(6)?;
4084 let decay_factor: Option<f64> = row.get(7)?;
4085 let expires_at: Option<i64> = row.get(8)?;
4086 let properties_str: Option<String> = row.get(9)?;
4087 let created_at: i64 = row.get(10)?;
4088 let updated_at: i64 = row.get(11)?;
4089 let deleted_at: Option<i64> = row.get(12)?;
4090 let key: Option<String> = row.get(13)?;
4091 let version: i64 = row.get(14)?;
4092
4093 if ns != namespace {
4094 return Err(SqliteError::InvalidData(format!(
4095 "note {id} belongs to namespace '{ns}', not '{namespace}'"
4096 )));
4097 }
4098
4099 let note_id = Uuid::parse_str(&id_s).map_err(|e| SqliteError::InvalidData(e.to_string()))?;
4100 let properties: Option<serde_json::Value> = properties_str
4101 .map(|s| serde_json::from_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string())))
4102 .transpose()?;
4103
4104 Ok(Note {
4105 id: note_id,
4106 namespace: ns,
4107 kind,
4108 status,
4109 name,
4110 content,
4111 salience,
4112 decay_factor,
4113 expires_at,
4114 properties,
4115 created_at,
4116 updated_at,
4117 deleted_at,
4118 key,
4119 version,
4120 })
4121}
4122
4123fn max_option_f64(a: Option<f64>, b: Option<f64>) -> Option<f64> {
4124 match (a, b) {
4125 (Some(x), Some(y)) => Some(x.max(y)),
4126 (Some(x), None) => Some(x),
4127 (None, Some(y)) => Some(y),
4128 (None, None) => None,
4129 }
4130}
4131
4132fn append_merge_history(props: Option<Value>, entry: Value) -> Result<Option<Value>, SqliteError> {
4133 use serde_json::{json, Map};
4134 let mut obj: Map<String, Value> = match props {
4135 Some(Value::Object(m)) => m,
4136 Some(other) => {
4137 let mut m = Map::new();
4138 m.insert("_value".into(), other);
4139 m
4140 }
4141 None => Map::new(),
4142 };
4143 let history = obj
4144 .entry("_merge_history".to_string())
4145 .or_insert_with(|| json!([]));
4146 if let Value::Array(arr) = history {
4147 arr.push(entry);
4148 }
4149 Ok(Some(Value::Object(obj)))
4150}
4151
4152#[allow(clippy::too_many_arguments)]
4162fn merge_note_sql(
4163 conn: &rusqlite::Connection,
4164 namespace: String,
4165 fts_table: String,
4166 vec_tables: Vec<String>,
4167 into_id: Uuid,
4168 from_id: Uuid,
4169 strategy: EntityDedupMergePolicy,
4170 content_strategy: ContentMergeStrategy,
4171 dry_run: bool,
4172 pack_rules: Vec<EdgeEndpointRule>,
4173 preserve_owner_established: bool,
4174 limits: MergeTxLimits,
4175 event_context: Option<MergeEventContext>,
4176) -> Result<(MergeSummary, khive_storage::note::Note), MergeSqlError> {
4177 let mut budget = MergeTxBudget::new(limits);
4178 budget.charge(
4180 0,
4181 vec_tables.iter().map(String::len).sum::<usize>()
4182 + pack_rules.len() * std::mem::size_of::<EdgeEndpointRule>(),
4183 "preparing pack and vector fanout",
4184 )?;
4185
4186 budget.charge(
4187 1,
4188 probe_merge_note_bytes(conn, into_id)?,
4189 "reading merge records",
4190 )?;
4191 let into_note = read_merge_note(conn, into_id, &namespace)?;
4192 budget.charge(
4193 1,
4194 probe_merge_note_bytes(conn, from_id)?,
4195 "reading merge records",
4196 )?;
4197 let from_note = read_merge_note(conn, from_id, &namespace)?;
4198
4199 for properties in [&into_note.properties, &from_note.properties] {
4202 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())
4203 .map_err(MergeSqlError::Refusal)?;
4204 }
4205
4206 if into_note.kind != from_note.kind {
4207 return Err(SqliteError::InvalidData(format!(
4208 "cannot merge notes of different kinds: {} vs {}",
4209 into_note.kind, from_note.kind
4210 ))
4211 .into());
4212 }
4213
4214 if into_note.kind == "message"
4220 && (message_is_quarantined(&into_note) || message_is_quarantined(&from_note))
4221 {
4222 return Err(SqliteError::InvalidData(
4223 "cannot merge a quarantined message: quarantine disposition is transport-owned and must be released by the channel-ingest path before the content can be folded into another record"
4224 .to_string(),
4225 ).into());
4226 }
4227
4228 let now = chrono::Utc::now().timestamp_micros();
4229 let into_str = into_id.to_string();
4230 let from_str = from_id.to_string();
4231
4232 let parse_id =
4234 |s: String| Uuid::parse_str(&s).map_err(|e| SqliteError::InvalidData(e.to_string()));
4235
4236 let mut outbound: Vec<EdgeRow> = Vec::new();
4239 {
4240 let mut stmt = conn.prepare(
4241 "SELECT id, namespace, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
4242 FROM graph_edges WHERE source_id = ?1",
4243 )?;
4244 let mut rows = stmt.query(rusqlite::params![&from_str])?;
4245 while let Some(row) = rows.next()? {
4246 let edge = EdgeRow {
4247 id: parse_id(row.get(0)?)?,
4248 namespace: row.get(1)?,
4249 source_id: parse_id(row.get(2)?)?,
4250 target_id: parse_id(row.get(3)?)?,
4251 relation: row.get(4)?,
4252 weight: row.get(5)?,
4253 created_at: row.get(6)?,
4254 updated_at: row.get(7)?,
4255 deleted_at: row.get(8)?,
4256 target_backend: row.get(9)?,
4257 metadata: row.get(10)?,
4258 };
4259 budget.charge(1, edge_row_budget_bytes(&edge), "collecting incident edges")?;
4260 outbound.push(edge);
4261 }
4262 }
4263 let mut inbound: Vec<EdgeRow> = Vec::new();
4264 {
4265 let mut stmt = conn.prepare(
4266 "SELECT id, namespace, source_id, target_id, relation, weight, created_at, updated_at, deleted_at, target_backend, metadata \
4267 FROM graph_edges WHERE target_id = ?1",
4268 )?;
4269 let mut rows = stmt.query(rusqlite::params![&from_str])?;
4270 while let Some(row) = rows.next()? {
4271 let edge = EdgeRow {
4272 id: parse_id(row.get(0)?)?,
4273 namespace: row.get(1)?,
4274 source_id: parse_id(row.get(2)?)?,
4275 target_id: parse_id(row.get(3)?)?,
4276 relation: row.get(4)?,
4277 weight: row.get(5)?,
4278 created_at: row.get(6)?,
4279 updated_at: row.get(7)?,
4280 deleted_at: row.get(8)?,
4281 target_backend: row.get(9)?,
4282 metadata: row.get(10)?,
4283 };
4284 budget.charge(1, edge_row_budget_bytes(&edge), "collecting incident edges")?;
4285 inbound.push(edge);
4286 }
4287 }
4288 let mut seen: HashSet<Uuid> = HashSet::new();
4289 let mut all_edges: Vec<EdgeRow> = Vec::new();
4290 for edge in outbound.into_iter().chain(inbound) {
4291 if seen.insert(edge.id) {
4292 all_edges.push(edge);
4293 }
4294 }
4295 let original_edges: HashMap<Uuid, EdgeRow> = all_edges
4296 .iter()
4297 .map(|edge| (edge.id, edge.clone()))
4298 .collect();
4299
4300 let (merged_content, content_appended) = match content_strategy {
4302 ContentMergeStrategy::Append => {
4303 if from_note.content.is_empty() {
4304 (into_note.content.clone(), false)
4305 } else {
4306 (
4307 format!("{}\n\n---\n\n{}", into_note.content, from_note.content),
4308 true,
4309 )
4310 }
4311 }
4312 ContentMergeStrategy::PreferInto => (into_note.content.clone(), false),
4313 ContentMergeStrategy::PreferFrom => (from_note.content.clone(), false),
4314 };
4315
4316 let merged_name = match strategy {
4317 EntityDedupMergePolicy::PreferFrom => from_note.name.clone().or(into_note.name.clone()),
4318 _ => into_note.name.clone().or(from_note.name.clone()),
4319 };
4320
4321 let (mut merged_props, _) =
4322 merge_properties(&into_note.properties, &from_note.properties, strategy);
4323
4324 if preserve_owner_established {
4329 preserve_owner_established_properties(&into_note.properties, &mut merged_props);
4330 }
4331 preserve_property_keys(
4332 kind_owned_properties(&into_note.kind),
4333 &into_note.properties,
4334 &mut merged_props,
4335 );
4336
4337 let properties_merged = count_new_property_keys(
4347 into_note.properties.as_ref(),
4348 merged_props.as_ref(),
4349 strategy,
4350 );
4351
4352 let merge_history_entry = serde_json::json!({
4353 "merged_from": from_id.to_string(),
4354 "merged_at": now,
4355 "strategy": format!("{:?}", strategy),
4356 "content_strategy": format!("{:?}", content_strategy),
4357 });
4358 let merged_props = append_merge_history(merged_props, merge_history_entry)?;
4359 crate::secret_gate::reject_reserved_secret_gate_property(merged_props.as_ref())
4360 .map_err(MergeSqlError::Refusal)?;
4361
4362 let merged_salience = max_option_f64(into_note.salience, from_note.salience);
4363 let merged_expires_at = match (into_note.expires_at, from_note.expires_at) {
4364 (Some(a), Some(b)) => Some(a.max(b)),
4365 (Some(a), None) => Some(a),
4366 (None, Some(b)) => Some(b),
4367 (None, None) => None,
4368 };
4369
4370 let props_str = merged_props
4371 .as_ref()
4372 .map(|v| serde_json::to_string(v).unwrap_or_default());
4373
4374 let mut rewired_edge_ids = HashSet::new();
4377 let mut edges_contract_skipped = 0usize;
4378 let mut edge_conflict_preimages = Vec::new();
4379 let mut edges_self_loop_dropped = 0usize;
4380 let mut self_loop_edge_preimages = Vec::new();
4381 let mut conflict_deleted_edge_ids = HashSet::new();
4382 {
4383 for edge in all_edges {
4384 if conflict_deleted_edge_ids.contains(&edge.id) {
4385 continue;
4386 }
4387 let raw_src = if edge.source_id == from_id {
4388 into_id
4389 } else {
4390 edge.source_id
4391 };
4392 let raw_tgt = if edge.target_id == from_id {
4393 into_id
4394 } else {
4395 edge.target_id
4396 };
4397 let relation_typed = edge.relation.parse::<EdgeRelation>().ok();
4398 let (new_src, new_tgt) = match relation_typed {
4400 Some(rel) => canonical_edge_endpoints(rel, raw_src, raw_tgt),
4401 None => (raw_src, raw_tgt),
4402 };
4403 if new_src == new_tgt {
4404 self_loop_edge_preimages.push(edge_row_preimage(&edge)?);
4409 edges_self_loop_dropped += 1;
4410 if !dry_run {
4411 conn.execute(
4412 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
4413 rusqlite::params![&edge.namespace, edge.id.to_string()],
4414 )?;
4415 }
4416 continue;
4417 }
4418
4419 let contract_ok = match relation_typed {
4424 Some(EdgeRelation::Annotates) => true,
4428 Some(rel) => {
4429 let src_info = if new_src == into_id {
4430 Some(("note", into_note.kind.clone(), None))
4431 } else {
4432 resolve_merge_edge_endpoint_budgeted(conn, new_src, &mut budget)?
4433 };
4434 let tgt_info = if new_tgt == into_id {
4435 Some(("note", into_note.kind.clone(), None))
4436 } else {
4437 resolve_merge_edge_endpoint_budgeted(conn, new_tgt, &mut budget)?
4438 };
4439 match (src_info, tgt_info) {
4440 (
4441 Some((src_sub, src_kind, src_type)),
4442 Some((tgt_sub, tgt_kind, tgt_type)),
4443 ) => merge_rewire_endpoint_contract_allows(
4444 &pack_rules,
4445 rel,
4446 src_sub,
4447 &src_kind,
4448 src_type.as_deref(),
4449 tgt_sub,
4450 &tgt_kind,
4451 tgt_type.as_deref(),
4452 ),
4453 _ => false,
4454 }
4455 }
4456 None => true,
4457 };
4458 if !contract_ok {
4459 if !dry_run {
4460 conn.execute(
4461 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2",
4462 rusqlite::params![&edge.namespace, edge.id.to_string()],
4463 )?;
4464 }
4465 tracing::warn!(
4466 edge_id = %edge.id,
4467 source = %new_src,
4468 target = %new_tgt,
4469 relation = %edge.relation,
4470 "merge_note: dropping rewired edge — endpoint contract violation post-merge"
4471 );
4472 edges_contract_skipped += 1;
4473 continue;
4474 }
4475
4476 let now_ts = chrono::Utc::now().timestamp_micros();
4477 let conflict_id: Option<String> = {
4478 let conflict_src = new_src.to_string();
4479 let conflict_tgt = new_tgt.to_string();
4480 conn.query_row(
4481 khive_db::stores::graph::EDGE_SYMMETRIC_CONFLICT_PROBE_SQL,
4482 rusqlite::params![
4483 &edge.namespace,
4484 &conflict_src,
4485 &conflict_tgt,
4486 &edge.relation,
4487 edge.id.to_string(),
4488 ],
4489 |row| row.get(0),
4490 )
4491 .optional()
4492 .map_err(SqliteError::Rusqlite)?
4493 };
4494
4495 if let Some(conflict_id) = conflict_id {
4496 let surviving_edge_id = Uuid::parse_str(&conflict_id)
4502 .map_err(|error| SqliteError::InvalidData(error.to_string()))?;
4503 let incident_edge_preimages = collect_conflict_incident_edge_preimages(
4504 conn,
4505 edge.id,
4506 &original_edges,
4507 &mut budget,
4508 )?;
4509 for incident in &incident_edge_preimages {
4510 conflict_deleted_edge_ids.insert(incident.id);
4511 rewired_edge_ids.remove(&incident.id);
4512 }
4513 conflict_deleted_edge_ids.insert(edge.id);
4514 rewired_edge_ids.insert(edge.id);
4515
4516 if !dry_run {
4517 delete_conflict_incident_edges(conn, &incident_edge_preimages)?;
4518 conn.execute(
4519 khive_db::stores::graph::EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL,
4520 rusqlite::params![&edge.namespace, edge.id.to_string()],
4521 )?;
4522 }
4523 edge_conflict_preimages.push(MergeEdgeConflictPreimage {
4524 surviving_edge_id,
4525 dropped_edge: edge_row_preimage(&edge)?,
4526 incident_edge_preimages,
4527 });
4528 } else {
4529 if dry_run {
4530 rewired_edge_ids.insert(edge.id);
4531 continue;
4532 }
4533 let changed = conn.execute(
4534 "UPDATE graph_edges SET \
4535 source_id = ?1, target_id = ?2, updated_at = ?3 \
4536 WHERE namespace = ?4 AND id = ?5",
4537 rusqlite::params![
4538 new_src.to_string(),
4539 new_tgt.to_string(),
4540 now_ts,
4541 &edge.namespace,
4542 edge.id.to_string(),
4543 ],
4544 )?;
4545 if changed > 0 {
4546 rewired_edge_ids.insert(edge.id);
4547 }
4548 }
4549 }
4550 }
4551 let edges_rewired = rewired_edge_ids.len();
4552
4553 if !dry_run {
4554 conn.prepare_cached(khive_db::stores::note::NOTE_UPSERT_SQL)?
4555 .execute(rusqlite::params![
4556 &into_str,
4557 &namespace,
4558 &into_note.kind,
4559 &into_note.status,
4560 &merged_name,
4561 &merged_content,
4562 merged_salience,
4563 into_note.decay_factor,
4564 merged_expires_at,
4565 &props_str,
4566 into_note.created_at,
4567 now,
4568 into_note.deleted_at,
4569 &into_note.key,
4570 ])?;
4571
4572 let fts_map = khive_db::stores::text::rowid_map_table(&fts_table);
4573
4574 conn.execute(
4578 &format!(
4579 "DELETE FROM {fts_table} WHERE rowid IN \
4580 (SELECT rowid FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2) \
4581 AND namespace = ?1 AND subject_id = ?2"
4582 ),
4583 rusqlite::params![&namespace, &into_str],
4584 )?;
4585 let fts_merged = {
4590 let mut merged_note = Note::new(&namespace, &*into_note.kind, &*merged_content);
4591 merged_note.id = into_id;
4592 merged_note.name = merged_name.clone();
4593 merged_note.properties = merged_props.clone();
4594 merged_note.updated_at = now;
4595 note_fts_scalars(&merged_note)
4596 };
4597 conn.execute(
4598 &format!(
4599 "INSERT INTO {} \
4600 (subject_id, kind, title, body, tags, namespace, metadata, updated_at, record_kind) \
4601 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
4602 fts_table
4603 ),
4604 rusqlite::params![
4605 &into_str,
4606 SubstrateKind::Note.to_string(),
4607 &fts_merged.title,
4608 &fts_merged.body,
4609 &fts_merged.tags,
4610 &namespace,
4611 &fts_merged.metadata,
4612 fts_merged.updated_at_micros,
4613 &fts_merged.record_kind,
4614 ],
4615 )?;
4616 conn.execute(
4617 &format!(
4618 "INSERT OR REPLACE INTO {fts_map} (namespace, subject_id, rowid) \
4619 VALUES (?1, ?2, last_insert_rowid())"
4620 ),
4621 rusqlite::params![&namespace, &into_str],
4622 )?;
4623
4624 conn.execute(
4626 &format!(
4627 "DELETE FROM {fts_table} WHERE rowid IN \
4628 (SELECT rowid FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2) \
4629 AND namespace = ?1 AND subject_id = ?2"
4630 ),
4631 rusqlite::params![&namespace, &from_str],
4632 )?;
4633 conn.execute(
4634 &format!("DELETE FROM {fts_map} WHERE namespace = ?1 AND subject_id = ?2"),
4635 rusqlite::params![&namespace, &from_str],
4636 )?;
4637
4638 khive_db::stores::vectors::delete_subject_from_vector_tables(
4639 conn,
4640 &vec_tables,
4641 from_id,
4642 &namespace,
4643 )?;
4644
4645 conn.execute(
4646 "UPDATE notes SET status = 'deleted', deleted_at = ?1, updated_at = ?1 \
4647 WHERE namespace = ?2 AND id = ?3 AND deleted_at IS NULL",
4648 rusqlite::params![now, &namespace, &from_str],
4649 )?;
4650 }
4651
4652 let updated_note = khive_storage::note::Note {
4653 id: into_id,
4654 namespace: namespace.clone(),
4655 kind: into_note.kind.clone(),
4656 status: into_note.status.clone(),
4657 name: merged_name,
4658 content: merged_content,
4659 salience: merged_salience,
4660 decay_factor: into_note.decay_factor,
4661 expires_at: merged_expires_at,
4662 properties: merged_props,
4663 created_at: into_note.created_at,
4664 updated_at: now,
4665 deleted_at: into_note.deleted_at,
4666 key: into_note.key.clone(),
4667 version: conn.query_row(
4668 "SELECT version FROM notes WHERE id = ?1",
4669 [&into_str],
4670 |row| row.get(0),
4671 )?,
4672 };
4673
4674 let summary = MergeSummary {
4675 kept_id: into_id,
4676 removed_id: from_id,
4677 edges_rewired,
4678 edges_self_loop_dropped,
4679 self_loop_edge_preimages,
4680 edges_contract_skipped,
4681 edge_conflict_preimages,
4682 properties_merged,
4683 tags_unioned: 0,
4684 content_appended,
4685 dry_run,
4686 tx_budget: budget.report(),
4687 embedding_truncation: Default::default(),
4688 post_commit_reindex_error: None,
4689 };
4690 if !dry_run {
4691 if let Some(context) = event_context {
4692 append_merge_event_in_transaction(conn, context, &summary, &updated_note.namespace)?;
4693 }
4694 }
4695 Ok((summary, updated_note))
4696}
4697
4698pub(crate) fn merge_string_field(
4705 into: &str,
4706 from: &str,
4707 strategy: EntityDedupMergePolicy,
4708) -> String {
4709 match strategy {
4710 EntityDedupMergePolicy::PreferInto | EntityDedupMergePolicy::Union => into.to_string(),
4711 EntityDedupMergePolicy::PreferFrom => from.to_string(),
4712 }
4713}
4714
4715pub(crate) const OWNER_ESTABLISHED_PROPERTIES: &[&str] = &[
4744 "from_actor",
4745 "to_actor",
4746 "direction",
4747 "sent_at",
4748 "outbound_ref",
4749 "thread_id",
4750 "subject",
4751 "wire_message_id",
4752 "external_id",
4753];
4754
4755const KIND_OWNED_PROPERTIES: &[(&str, &[&str])] = &[
4761 ("message", &["quarantined", "channel_kind", "channel_slug"]),
4762 ("channel_health", &["channel_kind", "channel_slug"]),
4763];
4764
4765pub(crate) fn kind_owned_properties(kind: &str) -> &'static [&'static str] {
4766 KIND_OWNED_PROPERTIES
4767 .iter()
4768 .find_map(|(owned_kind, keys)| (*owned_kind == kind).then_some(*keys))
4769 .unwrap_or(&[])
4770}
4771
4772fn message_is_quarantined(note: &khive_storage::note::Note) -> bool {
4780 let Some(Value::Object(map)) = note.properties.as_ref() else {
4781 return false;
4782 };
4783 match map.get("quarantined") {
4784 None => false,
4785 Some(Value::Bool(value)) => *value,
4786 Some(Value::String(value)) => value != "false",
4787 Some(_) => true,
4788 }
4789}
4790
4791pub(crate) fn owner_established_property_named_in(patch: &Value) -> Option<&'static str> {
4798 let Value::Object(map) = patch else {
4799 return None;
4800 };
4801 OWNER_ESTABLISHED_PROPERTIES
4802 .iter()
4803 .copied()
4804 .find(|key| map.contains_key(*key))
4805}
4806
4807pub(crate) fn preserve_owner_established_properties(
4833 into: &Option<Value>,
4834 merged: &mut Option<Value>,
4835) {
4836 preserve_property_keys(OWNER_ESTABLISHED_PROPERTIES, into, merged);
4837}
4838
4839fn preserve_property_keys(keys: &[&str], into: &Option<Value>, merged: &mut Option<Value>) {
4840 if !matches!(merged, Some(Value::Object(_))) {
4841 let Some(Value::Object(into_map)) = into else {
4842 return;
4843 };
4844 let owned_on_into = keys.iter().any(|key| into_map.contains_key(*key));
4845 if owned_on_into {
4846 *merged = into.clone();
4847 }
4848 return;
4849 }
4850 let Some(Value::Object(merged_map)) = merged.as_mut() else {
4851 return;
4852 };
4853 let into_map = match into {
4854 Some(Value::Object(m)) => Some(m),
4855 _ => None,
4856 };
4857 for key in keys {
4858 match into_map.and_then(|m| m.get(*key)) {
4859 Some(value) => {
4860 merged_map.insert((*key).to_string(), value.clone());
4862 }
4863 None => {
4864 merged_map.remove(*key);
4867 }
4868 }
4869 }
4870}
4871
4872pub(crate) fn count_new_property_keys(
4884 original: Option<&Value>,
4885 final_value: Option<&Value>,
4886 strategy: EntityDedupMergePolicy,
4887) -> usize {
4888 match (original, final_value) {
4889 (_, None) => 0,
4890 (None, Some(Value::Object(map))) => map.len(),
4891 (None, Some(_)) => 1,
4892 (Some(Value::Object(orig_map)), Some(Value::Object(final_map))) => {
4893 count_new_keys_within_object(orig_map, final_map, strategy)
4894 }
4895 (Some(_), Some(Value::Object(final_map))) => usize::from(!final_map.is_empty()),
4906 (Some(orig), Some(final_val)) => usize::from(orig != final_val),
4916 }
4917}
4918
4919fn count_new_keys_within_object(
4936 orig_map: &serde_json::Map<String, Value>,
4937 final_map: &serde_json::Map<String, Value>,
4938 strategy: EntityDedupMergePolicy,
4939) -> usize {
4940 final_map
4941 .iter()
4942 .map(|(key, value)| match orig_map.get(key) {
4943 None => 1,
4944 Some(Value::Object(nested_orig))
4945 if matches!(strategy, EntityDedupMergePolicy::Union) =>
4946 {
4947 match value {
4948 Value::Object(nested_final) => {
4949 count_new_keys_within_object(nested_orig, nested_final, strategy)
4950 }
4951 _ => 0,
4952 }
4953 }
4954 Some(_) => 0,
4955 })
4956 .sum()
4957}
4958
4959pub(crate) fn merge_properties(
4964 into: &Option<Value>,
4965 from: &Option<Value>,
4966 strategy: EntityDedupMergePolicy,
4967) -> (Option<Value>, usize) {
4968 match (into, from) {
4969 (None, None) => (None, 0),
4970 (Some(a), None) => (Some(a.clone()), 0),
4971 (None, Some(b)) => {
4972 let count = if let Value::Object(m) = b { m.len() } else { 1 };
4973 (Some(b.clone()), count)
4974 }
4975 (Some(into_val), Some(from_val)) => {
4976 let (merged, added) = merge_json(into_val, from_val, strategy);
4977 (Some(merged), added)
4978 }
4979 }
4980}
4981
4982fn note_update_values_equal(left: &Option<Value>, right: &Option<Value>) -> bool {
4988 fn equal(left: &Value, right: &Value, is_tags_field: bool, is_properties_object: bool) -> bool {
4989 match (left, right) {
4990 (Value::Object(a), Value::Object(b)) => {
4991 a.len() == b.len()
4992 && a.iter().all(|(key, value)| {
4993 b.get(key).is_some_and(|other| {
4994 equal(value, other, is_properties_object && key == "tags", false)
4995 })
4996 })
4997 }
4998 (Value::Array(a), Value::Array(b)) if is_tags_field => {
4999 if a.len() != b.len() {
5000 return false;
5001 }
5002 let mut left = a.iter().map(Value::to_string).collect::<Vec<_>>();
5003 let mut right = b.iter().map(Value::to_string).collect::<Vec<_>>();
5004 left.sort_unstable();
5005 right.sort_unstable();
5006 left == right
5007 }
5008 (Value::Array(a), Value::Array(b)) => {
5009 a.len() == b.len()
5010 && a.iter()
5011 .zip(b)
5012 .all(|(left, right)| equal(left, right, false, false))
5013 }
5014 _ => left == right,
5015 }
5016 }
5017
5018 match (left, right) {
5019 (None, None) => true,
5020 (Some(left), Some(right)) => equal(left, right, false, true),
5021 _ => false,
5022 }
5023}
5024
5025fn merge_json(into: &Value, from: &Value, strategy: EntityDedupMergePolicy) -> (Value, usize) {
5027 match (into, from, strategy) {
5028 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::Union) => {
5029 let mut result = a.clone();
5030 let mut added = 0usize;
5031 for (k, v_from) in b {
5032 if let Some(v_into) = a.get(k) {
5033 let (merged, sub_added) =
5034 merge_json(v_into, v_from, EntityDedupMergePolicy::Union);
5035 result.insert(k.clone(), merged);
5036 added += sub_added;
5037 } else {
5038 result.insert(k.clone(), v_from.clone());
5039 added += 1;
5040 }
5041 }
5042 (Value::Object(result), added)
5043 }
5044 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferInto) => {
5045 let mut result = a.clone();
5046 let mut added = 0usize;
5047 for (k, v) in b {
5048 if !a.contains_key(k) {
5049 result.insert(k.clone(), v.clone());
5050 added += 1;
5051 }
5052 }
5053 (Value::Object(result), added)
5054 }
5055 (Value::Object(a), Value::Object(b), EntityDedupMergePolicy::PreferFrom) => {
5056 let mut result = a.clone();
5057 let mut added = 0usize;
5058 for (k, v) in b {
5059 result.insert(k.clone(), v.clone());
5060 if !a.contains_key(k) {
5061 added += 1;
5062 }
5063 }
5064 (Value::Object(result), added)
5065 }
5066 (_into_val, from_val, EntityDedupMergePolicy::PreferFrom) => (from_val.clone(), 1),
5068 _ => (into.clone(), 0),
5069 }
5070}
5071
5072pub(crate) fn union_tags(into: &[String], from: &[String]) -> (Vec<String>, usize) {
5075 let mut seen: HashSet<&str> = into.iter().map(|s| s.as_str()).collect();
5076 let mut result: Vec<String> = into.to_vec();
5077 let mut added = 0usize;
5078 for tag in from {
5079 if seen.insert(tag.as_str()) {
5080 result.push(tag.clone());
5081 added += 1;
5082 }
5083 }
5084 (result, added)
5085}
5086
5087#[cfg(test)]
5096mod merge_reservation_tests;
5097
5098#[cfg(test)]
5099mod tests {
5100 use std::sync::Arc;
5101 use std::sync::Mutex;
5102
5103 use super::*;
5104 use crate::runtime::{KhiveRuntime, NamespaceToken};
5105 use khive_storage::types::{Direction, TextFilter, TextQueryMode, TextSearchRequest};
5106 use khive_types::EndpointKind;
5107
5108 fn rt() -> KhiveRuntime {
5109 KhiveRuntime::memory().unwrap()
5110 }
5111
5112 fn set_merge_event_refusal(rt: &KhiveRuntime, kind: &str, refuse: bool) {
5113 let pool = rt.backend().pool_arc();
5114 let guard = pool.writer().expect("acquire fixture writer");
5115 let sql = if refuse {
5116 format!(
5117 "CREATE TRIGGER reject_merge_event BEFORE INSERT ON events \
5118 WHEN NEW.kind = '{kind}' BEGIN SELECT RAISE(ABORT, 'blocked merge event'); END"
5119 )
5120 } else {
5121 "DROP TRIGGER reject_merge_event".to_string()
5122 };
5123 guard.conn().execute_batch(&sql).expect("set event fault");
5124 }
5125
5126 async fn seed_health_identity_note(runtime: &KhiveRuntime, slug: &str) -> Note {
5127 let mut note = Note::new("local", "channel_health", "health state");
5128 note.properties = Some(serde_json::json!({
5129 "channel_kind": "email", "channel_slug": slug, "operator_note": "original"
5130 }));
5131 runtime
5132 .notes(&NamespaceToken::local())
5133 .unwrap()
5134 .upsert_note(note.clone())
5135 .await
5136 .unwrap();
5137 note
5138 }
5139
5140 #[tokio::test]
5143 async fn channel_health_identity_plain_update_refuses_coordinate_changes() {
5144 let runtime = rt();
5145 let token = NamespaceToken::local();
5146 let note = seed_health_identity_note(&runtime, "original@example.com").await;
5147 let before = serde_json::to_value(¬e).unwrap();
5148 for properties in [
5149 serde_json::json!({"channel_kind": "telegram", "operator_note": "changed"}),
5150 serde_json::json!({"channel_slug": "other@example.com"}),
5151 serde_json::json!({"channel_kind": null}),
5152 serde_json::json!({"channel_slug": null}),
5153 serde_json::json!({"channel_kind": "email"}),
5154 serde_json::json!({"channel_slug": "original@example.com"}),
5155 serde_json::json!(null),
5156 serde_json::json!([]),
5157 ] {
5158 let error = runtime
5159 .update_note(
5160 &token,
5161 note.id,
5162 NotePatch::new(None, None, None, None, Some(properties)),
5163 )
5164 .await
5165 .expect_err("plain runtime update must preserve heartbeat coordinates");
5166 assert!(error.to_string().contains("channel_health"), "{error}");
5167 let stored = runtime
5168 .notes(&token)
5169 .unwrap()
5170 .get_note(note.id)
5171 .await
5172 .unwrap()
5173 .unwrap();
5174 assert_eq!(serde_json::to_value(stored).unwrap(), before);
5175 }
5176 let changed = runtime
5177 .update_note(
5178 &token,
5179 note.id,
5180 NotePatch::new(
5181 None,
5182 None,
5183 None,
5184 None,
5185 Some(serde_json::json!({"operator_note": "changed"})),
5186 ),
5187 )
5188 .await
5189 .unwrap();
5190 let properties = changed.properties.unwrap();
5191 assert_eq!(properties["channel_kind"], "email");
5192 assert_eq!(properties["channel_slug"], "original@example.com");
5193 assert_eq!(properties["operator_note"], "changed");
5194 }
5195
5196 #[tokio::test]
5197 async fn channel_health_identity_atomic_prepare_refuses_coordinates() {
5198 let runtime = rt();
5199 let token = NamespaceToken::local();
5200 let note = seed_health_identity_note(&runtime, "original@example.com").await;
5201 let error = crate::atomic_prepare::prepare_update(
5202 &runtime,
5203 &token,
5204 &serde_json::json!({
5205 "id": note.id.to_string(),
5206 "properties": {"channel_slug": "other@example.com"}
5207 }),
5208 None,
5209 )
5210 .await
5211 .expect_err("atomic/proposal preparation must use the runtime identity guard");
5212 assert!(error.to_string().contains("channel_slug"), "{error}");
5213 let stored = runtime
5214 .notes(&token)
5215 .unwrap()
5216 .get_note(note.id)
5217 .await
5218 .unwrap()
5219 .unwrap();
5220 assert_eq!(
5221 serde_json::to_value(stored).unwrap(),
5222 serde_json::to_value(note).unwrap()
5223 );
5224 }
5225
5226 #[tokio::test]
5229 async fn channel_health_identity_merge_retains_survivor_coordinates() {
5230 for strategy in [
5231 EntityDedupMergePolicy::PreferFrom,
5232 EntityDedupMergePolicy::Union,
5233 EntityDedupMergePolicy::PreferInto,
5234 ] {
5235 let runtime = rt();
5236 let token = NamespaceToken::local();
5237 let into = seed_health_identity_note(&runtime, "survivor@example.com").await;
5238 let mut from = seed_health_identity_note(&runtime, "absorbed@example.com").await;
5239 from.properties = Some(serde_json::json!({
5240 "channel_kind": "telegram", "channel_slug": "absorbed@example.com", "new_metadata": true
5241 }));
5242 runtime
5245 .raw_notes(&token)
5246 .unwrap()
5247 .upsert_note(from.clone())
5248 .await
5249 .unwrap();
5250 runtime
5251 .merge_note(
5252 &token,
5253 into.id,
5254 from.id,
5255 strategy,
5256 ContentMergeStrategy::PreferInto,
5257 false,
5258 )
5259 .await
5260 .unwrap();
5261 let stored = runtime
5262 .notes(&token)
5263 .unwrap()
5264 .get_note(into.id)
5265 .await
5266 .unwrap()
5267 .unwrap();
5268 assert_eq!(stored.id, into.id);
5269 assert_eq!(stored.created_at, into.created_at);
5270 let properties = stored.properties.unwrap();
5271 assert_eq!(properties["channel_kind"], "email");
5272 assert_eq!(properties["channel_slug"], "survivor@example.com");
5273 assert_eq!(properties["new_metadata"], true);
5274 assert!(runtime
5275 .notes(&token)
5276 .unwrap()
5277 .get_note(from.id)
5278 .await
5279 .unwrap()
5280 .is_none());
5281 }
5282 }
5283
5284 async fn entity_update_events(
5285 runtime: &KhiveRuntime,
5286 token: &NamespaceToken,
5287 ) -> Vec<khive_storage::event::Event> {
5288 runtime
5289 .events(token)
5290 .unwrap()
5291 .query_events(
5292 khive_storage::event::EventFilter {
5293 kinds: vec![EventKind::EntityUpdated],
5294 ..Default::default()
5295 },
5296 khive_storage::types::PageRequest {
5297 limit: 100,
5298 offset: 0,
5299 },
5300 )
5301 .await
5302 .unwrap()
5303 .items
5304 }
5305
5306 fn outbound_message_note() -> Note {
5307 let mut note = Note::new("local", "message", "hello");
5308 note.properties = Some(serde_json::json!({"direction": "outbound"}));
5309 note
5310 }
5311
5312 #[tokio::test]
5318 async fn list_undelivered_outbound_messages_predicate_and_order() {
5319 let rt = rt();
5320 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5321 let tok = NamespaceToken::local();
5322 let store = rt.notes(&tok).expect("note store");
5323
5324 let mut undelivered_old = outbound_message_note();
5325 undelivered_old.created_at -= 10;
5326 let mut undelivered_null = outbound_message_note();
5327 undelivered_null.properties =
5328 Some(serde_json::json!({"direction": "outbound", "delivered_at": null}));
5329 let mut delivered = outbound_message_note();
5330 delivered.properties = Some(
5331 serde_json::json!({"direction": "outbound", "delivered_at": "2026-08-28T00:00:00Z"}),
5332 );
5333 let mut terminal_failed = outbound_message_note();
5335 terminal_failed.properties = Some(
5336 serde_json::json!({"direction": "outbound", "delivery": "failed", "last_error": "x"}),
5337 );
5338 let mut inbound = Note::new("local", "message", "inbound row");
5339 inbound.properties = Some(serde_json::json!({"direction": "inbound"}));
5340 let mut soft_deleted = outbound_message_note();
5341 soft_deleted.deleted_at = Some(chrono::Utc::now().timestamp_micros());
5342
5343 let old_id = undelivered_old.id;
5344 let null_id = undelivered_null.id;
5345 for note in [
5346 undelivered_old,
5347 undelivered_null,
5348 delivered,
5349 terminal_failed,
5350 inbound,
5351 soft_deleted,
5352 ] {
5353 store.upsert_note(note).await.expect("seed note");
5354 }
5355
5356 let hits = rt
5357 .list_undelivered_outbound_messages(&tok, None, 200)
5358 .await
5359 .expect("scan succeeds");
5360 let ids: Vec<_> = hits.iter().map(|n| n.id).collect();
5361 assert_eq!(
5362 ids,
5363 vec![null_id, old_id],
5364 "only the two undelivered outbound rows, newest-first"
5365 );
5366
5367 let capped = rt
5368 .list_undelivered_outbound_messages(&tok, None, 1)
5369 .await
5370 .expect("capped scan succeeds");
5371 assert_eq!(
5372 capped.iter().map(|n| n.id).collect::<Vec<_>>(),
5373 vec![null_id],
5374 "limit truncates after the newest undelivered row"
5375 );
5376
5377 let zero = rt
5378 .list_undelivered_outbound_messages(&tok, None, 0)
5379 .await
5380 .expect("zero-limit scan succeeds");
5381 assert!(zero.is_empty(), "limit=0 returns no rows, not one");
5382 }
5383
5384 #[tokio::test]
5394 async fn list_undelivered_outbound_messages_email_row_past_the_scan_window() {
5395 const FILLERS: usize = 10_050;
5396 let rt = rt();
5397 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5398 let tok = NamespaceToken::local();
5399 let store = rt.notes(&tok).expect("note store");
5400
5401 let mut email = outbound_message_note();
5402 email.created_at -= 1_000_000;
5403 email.updated_at = email.created_at;
5404 email.properties = Some(
5405 serde_json::json!({"direction": "outbound", "to_actor": "email:ocean@example.test"}),
5406 );
5407 let email_id = email.id;
5408
5409 let mut fillers = Vec::with_capacity(FILLERS);
5410 for i in 0..FILLERS {
5411 let mut filler = outbound_message_note();
5412 filler.created_at += i as i64;
5413 filler.updated_at = filler.created_at;
5414 filler.properties =
5415 Some(serde_json::json!({"direction": "outbound", "to_actor": "daemon:filler"}));
5416 fillers.push(filler);
5417 }
5418 let summary = store.upsert_notes(fillers).await.expect("seed fillers");
5420 assert_eq!(
5421 summary.affected as usize, FILLERS,
5422 "every filler row seeded"
5423 );
5424 store.upsert_note(email).await.expect("seed email row");
5425
5426 let hits = rt
5427 .list_undelivered_outbound_messages(&tok, Some("email:"), 200)
5428 .await
5429 .expect("scan succeeds");
5430 assert_eq!(
5431 hits.iter().map(|n| n.id).collect::<Vec<_>>(),
5432 vec![email_id],
5433 "the email row is found behind {FILLERS} pending actor-to-actor rows"
5434 );
5435
5436 let daemon_hits = rt
5437 .list_undelivered_outbound_messages(&tok, Some("daemon:"), 3)
5438 .await
5439 .expect("scan succeeds");
5440 assert_eq!(
5441 daemon_hits.len(),
5442 3,
5443 "the other prefix still sees its own rows"
5444 );
5445 }
5446
5447 fn legacy_outbox_pending(note: &Note, to_prefix: Option<&str>, now_micros: i64) -> bool {
5448 if note.deleted_at.is_some() {
5449 return false;
5450 }
5451 let props = note.properties.as_ref().and_then(|value| value.as_object());
5452 if props
5453 .and_then(|properties| properties.get("direction"))
5454 .and_then(Value::as_str)
5455 != Some("outbound")
5456 {
5457 return false;
5458 }
5459 if let Some(prefix) = to_prefix {
5460 let matches = props
5461 .and_then(|properties| properties.get("to_actor"))
5462 .and_then(Value::as_str)
5463 .is_some_and(|actor| actor.starts_with(prefix));
5464 if !matches {
5465 return false;
5466 }
5467 }
5468 if props
5469 .and_then(|properties| properties.get("delivered_at"))
5470 .is_some_and(|value| !value.is_null())
5471 {
5472 return false;
5473 }
5474 if props
5475 .and_then(|properties| properties.get("delivery"))
5476 .and_then(Value::as_str)
5477 .is_some_and(|state| state == "delivered" || state == "failed")
5478 {
5479 return false;
5480 }
5481 let retry_deferred = props
5482 .and_then(|properties| properties.get("next_attempt_at"))
5483 .and_then(Value::as_str)
5484 .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
5485 .is_some_and(|deadline| deadline.timestamp_micros() > now_micros);
5486 !retry_deferred
5487 }
5488
5489 #[tokio::test]
5492 async fn list_undelivered_outbound_messages_matches_legacy_predicate() {
5493 let rt = rt();
5494 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5495 let tok = NamespaceToken::local();
5496 let store = rt.notes(&tok).expect("note store");
5497
5498 let make_note = |created_at, properties, deleted_at| {
5499 let mut note = Note::new("local", "message", "outbox fixture");
5500 note.created_at = created_at;
5501 note.updated_at = created_at;
5502 note.properties = Some(properties);
5503 note.deleted_at = deleted_at;
5504 note
5505 };
5506 let notes = vec![
5507 make_note(
5508 110,
5509 serde_json::json!({"direction": "outbound", "to_actor": "email:absent"}),
5510 None,
5511 ),
5512 make_note(
5513 109,
5514 serde_json::json!({
5515 "direction": "outbound",
5516 "to_actor": "email:null",
5517 "delivered_at": null
5518 }),
5519 None,
5520 ),
5521 make_note(
5522 108,
5523 serde_json::json!({
5524 "direction": "outbound",
5525 "to_actor": "email:terminal-failed",
5526 "delivery": "failed"
5527 }),
5528 None,
5529 ),
5530 make_note(
5531 107,
5532 serde_json::json!({
5533 "direction": "outbound",
5534 "to_actor": "email:terminal-delivered",
5535 "delivery": "delivered"
5536 }),
5537 None,
5538 ),
5539 make_note(
5540 106,
5541 serde_json::json!({
5542 "direction": "outbound",
5543 "to_actor": "email:malformed",
5544 "next_attempt_at": "not-a-timestamp"
5545 }),
5546 None,
5547 ),
5548 make_note(
5549 105,
5550 serde_json::json!({
5551 "direction": "outbound",
5552 "to_actor": "email:future",
5553 "next_attempt_at": "2999-01-01T00:00:00Z"
5554 }),
5555 None,
5556 ),
5557 make_note(
5558 104,
5559 serde_json::json!({
5560 "direction": "outbound",
5561 "to_actor": "email:due",
5562 "next_attempt_at": "2000-01-01T00:00:00Z"
5563 }),
5564 None,
5565 ),
5566 make_note(
5567 103,
5568 serde_json::json!({"direction": "inbound", "to_actor": "email:inbound"}),
5569 None,
5570 ),
5571 make_note(
5572 102,
5573 serde_json::json!({"direction": "outbound", "to_actor": "telegram:other"}),
5574 None,
5575 ),
5576 make_note(
5577 101,
5578 serde_json::json!({
5579 "direction": "outbound",
5580 "to_actor": "email:already-delivered",
5581 "delivered_at": "2026-08-28T00:00:00Z"
5582 }),
5583 None,
5584 ),
5585 make_note(
5586 100,
5587 serde_json::json!({"direction": "outbound", "to_actor": "email:deleted"}),
5588 Some(100),
5589 ),
5590 ];
5591 for note in ¬es {
5592 store.upsert_note(note.clone()).await.expect("seed note");
5593 }
5594
5595 let now_micros = chrono::Utc::now().timestamp_micros();
5596 let all_rows = rt
5597 .list_notes(&tok, Some("message"), 200, 0)
5598 .await
5599 .expect("legacy scan fixture loads");
5600 let expected_ids: Vec<_> = all_rows
5601 .iter()
5602 .filter(|note| legacy_outbox_pending(note, Some("email:"), now_micros))
5603 .map(|note| note.id)
5604 .collect();
5605 let actual_ids: Vec<_> = rt
5606 .list_undelivered_outbound_messages(&tok, Some("email:"), 200)
5607 .await
5608 .expect("filtered scan succeeds")
5609 .into_iter()
5610 .map(|note| note.id)
5611 .collect();
5612
5613 assert_eq!(
5614 expected_ids.len(),
5615 4,
5616 "fixture must exercise all exclusions"
5617 );
5618 assert_eq!(
5619 actual_ids, expected_ids,
5620 "filtered scan changed answer or order"
5621 );
5622 }
5623
5624 #[tokio::test]
5625 async fn list_undelivered_outbound_messages_skips_only_future_retry_deadlines() {
5626 let rt = rt();
5627 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5628 let tok = NamespaceToken::local();
5629 let store = rt.notes(&tok).expect("note store");
5630
5631 let mut future = outbound_message_note();
5632 future.properties = Some(serde_json::json!({
5633 "direction": "outbound",
5634 "next_attempt_at": "2999-01-01T00:00:00Z",
5635 }));
5636 let future_id = future.id;
5637
5638 let mut overdue = outbound_message_note();
5639 overdue.properties = Some(serde_json::json!({
5640 "direction": "outbound",
5641 "next_attempt_at": "2000-01-01T00:00:00Z",
5642 }));
5643 let overdue_id = overdue.id;
5644
5645 let mut malformed = outbound_message_note();
5646 malformed.properties = Some(serde_json::json!({
5647 "direction": "outbound",
5648 "next_attempt_at": "not-a-timestamp",
5649 }));
5650 let malformed_id = malformed.id;
5651
5652 for note in [future, overdue, malformed] {
5653 store.upsert_note(note).await.expect("seed note");
5654 }
5655
5656 let ids: HashSet<_> = rt
5657 .list_undelivered_outbound_messages(&tok, None, 200)
5658 .await
5659 .expect("scan succeeds")
5660 .into_iter()
5661 .map(|note| note.id)
5662 .collect();
5663
5664 assert!(!ids.contains(&future_id), "future retry must stay parked");
5665 assert!(ids.contains(&overdue_id), "overdue retry must be eligible");
5666 assert!(
5667 ids.contains(&malformed_id),
5668 "malformed legacy retry state must fail open instead of stranding the note"
5669 );
5670 }
5671
5672 #[tokio::test]
5676 async fn list_undelivered_outbound_messages_prefix_filters_before_limit() {
5677 let rt = rt();
5678 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5679 let tok = NamespaceToken::local();
5680 let store = rt.notes(&tok).expect("note store");
5681
5682 let mut email = outbound_message_note();
5684 email.created_at -= 100;
5685 email.properties =
5686 Some(serde_json::json!({"direction": "outbound", "to_actor": "email:a@b.c"}));
5687 let email_id = email.id;
5688 store.upsert_note(email).await.expect("seed email");
5689 for _ in 0..3 {
5690 let mut tg = outbound_message_note();
5691 tg.properties =
5692 Some(serde_json::json!({"direction": "outbound", "to_actor": "telegram:42"}));
5693 store.upsert_note(tg).await.expect("seed telegram");
5694 }
5695
5696 let hits = rt
5699 .list_undelivered_outbound_messages(&tok, Some("email:"), 1)
5700 .await
5701 .expect("scan succeeds");
5702 assert_eq!(
5703 hits.iter().map(|n| n.id).collect::<Vec<_>>(),
5704 vec![email_id],
5705 "prefix predicate applies before the limit"
5706 );
5707
5708 let telegram_hits = rt
5709 .list_undelivered_outbound_messages(&tok, Some("telegram:"), 200)
5710 .await
5711 .expect("scan succeeds");
5712 assert_eq!(telegram_hits.len(), 3, "telegram prefix sees its own rows");
5713 }
5714
5715 #[tokio::test]
5720 async fn outbound_delivery_markers_stamp_adr122_properties_and_validate_target() {
5721 let rt = rt();
5722 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5723 let tok = NamespaceToken::local();
5724 let store = rt.notes(&tok).expect("note store");
5725
5726 let delivered_note = outbound_message_note();
5727 let delivered_id = delivered_note.id;
5728 let delivered_version = delivered_note.version;
5729 let failed_note = outbound_message_note();
5730 let failed_id = failed_note.id;
5731 let failed_version = failed_note.version;
5732 let mut inbound = Note::new("local", "message", "inbound row");
5733 inbound.properties = Some(serde_json::json!({"direction": "inbound"}));
5734 let inbound_id = inbound.id;
5735 let wrong_kind = Note::new("local", "observation", "not a message");
5736 let wrong_kind_id = wrong_kind.id;
5737 for note in [delivered_note, failed_note] {
5738 store.upsert_note(note).await.expect("seed note");
5739 }
5740 store.upsert_note(inbound).await.expect("seed inbound");
5741 store
5742 .upsert_note(wrong_kind)
5743 .await
5744 .expect("seed non-message");
5745
5746 let marked = rt
5747 .mark_outbound_message_delivered(
5748 &tok,
5749 delivered_id,
5750 "2026-08-28T00:00:00Z".to_string(),
5751 Some("<mid@example>".to_string()),
5752 )
5753 .await
5754 .expect("mark delivered succeeds");
5755 assert_eq!(marked.version, delivered_version + 1);
5756 assert_eq!(marked, store.get_note(delivered_id).await.unwrap().unwrap());
5757 let props = marked
5758 .properties
5759 .as_ref()
5760 .and_then(|v| v.as_object())
5761 .unwrap();
5762 assert_eq!(
5763 props.get("delivery").and_then(|v| v.as_str()),
5764 Some("delivered")
5765 );
5766 assert_eq!(
5767 props.get("delivered_at").and_then(|v| v.as_str()),
5768 Some("2026-08-28T00:00:00Z")
5769 );
5770 assert_eq!(
5771 props.get("transport_message_id").and_then(|v| v.as_str()),
5772 Some("<mid@example>")
5773 );
5774
5775 let failed = rt
5776 .mark_outbound_message_failed(
5777 &tok,
5778 failed_id,
5779 "2026-08-28T00:00:01Z".to_string(),
5780 "recipient not in allowlist".to_string(),
5781 )
5782 .await
5783 .expect("mark failed succeeds");
5784 assert_eq!(failed.version, failed_version + 1);
5785 assert_eq!(failed, store.get_note(failed_id).await.unwrap().unwrap());
5786 let props = failed
5787 .properties
5788 .as_ref()
5789 .and_then(|v| v.as_object())
5790 .unwrap();
5791 assert_eq!(
5792 props.get("delivery").and_then(|v| v.as_str()),
5793 Some("failed")
5794 );
5795 assert_eq!(
5796 props.get("failed_at").and_then(|v| v.as_str()),
5797 Some("2026-08-28T00:00:01Z")
5798 );
5799 assert_eq!(
5800 props.get("last_error").and_then(|v| v.as_str()),
5801 Some("recipient not in allowlist")
5802 );
5803
5804 let pending = rt
5806 .list_undelivered_outbound_messages(&tok, None, 200)
5807 .await
5808 .expect("scan succeeds");
5809 assert!(
5810 pending.is_empty(),
5811 "terminal rows left in scan: {:?}",
5812 pending.iter().map(|n| n.id).collect::<Vec<_>>()
5813 );
5814
5815 for (id, label) in [(inbound_id, "inbound"), (wrong_kind_id, "non-message")] {
5817 let err = rt
5818 .mark_outbound_message_delivered(&tok, id, "t".to_string(), None)
5819 .await
5820 .expect_err(label);
5821 assert!(
5822 matches!(err, RuntimeError::InvalidInput(_)),
5823 "{label}: expected InvalidInput, got {err:?}"
5824 );
5825 let err = rt
5826 .mark_outbound_message_failed(&tok, id, "t".to_string(), "e".to_string())
5827 .await
5828 .expect_err(label);
5829 assert!(
5830 matches!(err, RuntimeError::InvalidInput(_)),
5831 "{label}: expected InvalidInput, got {err:?}"
5832 );
5833 }
5834 }
5835
5836 #[tokio::test]
5845 async fn outbound_delivery_marker_refuses_to_overwrite_existing_terminal_outcome() {
5846 let rt = rt();
5847 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5848 let tok = NamespaceToken::local();
5849 let store = rt.notes(&tok).expect("note store");
5850
5851 let delivered_first = outbound_message_note();
5852 let delivered_first_id = delivered_first.id;
5853 let failed_first = outbound_message_note();
5854 let failed_first_id = failed_first.id;
5855 for note in [delivered_first, failed_first] {
5856 store.upsert_note(note).await.expect("seed note");
5857 }
5858
5859 rt.mark_outbound_message_delivered(
5860 &tok,
5861 delivered_first_id,
5862 "2026-08-28T00:00:00Z".to_string(),
5863 None,
5864 )
5865 .await
5866 .expect("first delivery marker wins the race");
5867
5868 let err = rt
5869 .mark_outbound_message_failed(
5870 &tok,
5871 delivered_first_id,
5872 "2026-08-28T00:00:01Z".to_string(),
5873 "duplicate send rejected".to_string(),
5874 )
5875 .await
5876 .expect_err("a losing failure marker must not clobber the recorded success");
5877 assert!(matches!(err, RuntimeError::InvalidInput(_)));
5878
5879 let note = store
5880 .get_note(delivered_first_id)
5881 .await
5882 .expect("get note")
5883 .expect("note exists");
5884 let props = note
5885 .properties
5886 .as_ref()
5887 .and_then(|v| v.as_object())
5888 .unwrap();
5889 assert_eq!(
5890 props.get("delivery").and_then(|v| v.as_str()),
5891 Some("delivered"),
5892 "recorded success must survive the losing worker's overwrite attempt"
5893 );
5894
5895 rt.mark_outbound_message_failed(
5896 &tok,
5897 failed_first_id,
5898 "2026-08-28T00:00:00Z".to_string(),
5899 "recipient rejected".to_string(),
5900 )
5901 .await
5902 .expect("first failure marker wins the race");
5903
5904 let err = rt
5905 .mark_outbound_message_delivered(
5906 &tok,
5907 failed_first_id,
5908 "2026-08-28T00:00:01Z".to_string(),
5909 None,
5910 )
5911 .await
5912 .expect_err("a late success marker must not clobber a recorded failure");
5913 assert!(matches!(err, RuntimeError::InvalidInput(_)));
5914 }
5915
5916 #[tokio::test]
5917 async fn outbound_transient_failure_increments_attempts_and_arms_bounded_backoff() {
5918 let rt = rt();
5919 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5920 let tok = NamespaceToken::local();
5921 let store = rt.notes(&tok).expect("note store");
5922
5923 let mut note = outbound_message_note();
5924 note.properties = Some(serde_json::json!({
5925 "direction": "outbound",
5926 "delivery_attempts": 3,
5927 "unrelated": "preserved",
5928 }));
5929 let id = note.id;
5930 let original_version = note.version;
5931 store.upsert_note(note).await.expect("seed note");
5932
5933 let attempted_at = chrono::DateTime::parse_from_rfc3339("2026-08-30T00:00:00Z")
5934 .unwrap()
5935 .with_timezone(&chrono::Utc);
5936 let marked = rt
5937 .mark_outbound_message_transient_failure(
5938 &tok,
5939 id,
5940 attempted_at,
5941 "mailbox temporarily unavailable".to_string(),
5942 std::time::Duration::from_secs(5),
5943 std::time::Duration::from_secs(20),
5944 )
5945 .await
5946 .expect("transient marker succeeds");
5947 assert_eq!(marked.version, original_version + 1);
5948 assert_eq!(marked, store.get_note(id).await.unwrap().unwrap());
5949 let props = marked.properties.unwrap();
5950
5951 assert_eq!(props["delivery_attempts"].as_u64(), Some(4));
5952 assert_eq!(
5953 props["next_attempt_at"].as_str(),
5954 Some("2026-08-30T00:00:20+00:00"),
5955 "5 * 2^(4-1) is capped at the configured 20-second ceiling"
5956 );
5957 assert_eq!(
5958 props["last_error"].as_str(),
5959 Some("mailbox temporarily unavailable")
5960 );
5961 assert_eq!(props["unrelated"].as_str(), Some("preserved"));
5962 assert!(
5963 props.get("delivery").is_none(),
5964 "a transient failure must remain pending"
5965 );
5966 }
5967
5968 #[tokio::test]
5969 async fn outbound_terminal_markers_clear_retry_schedule() {
5970 let rt = rt();
5971 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
5972 let tok = NamespaceToken::local();
5973 let store = rt.notes(&tok).expect("note store");
5974
5975 let mut delivered = outbound_message_note();
5976 delivered.properties = Some(serde_json::json!({
5977 "direction": "outbound",
5978 "delivery_attempts": 2,
5979 "next_attempt_at": "2999-01-01T00:00:00Z",
5980 "last_error": "temporary",
5981 }));
5982 let delivered_id = delivered.id;
5983
5984 let mut failed = outbound_message_note();
5985 failed.properties = Some(serde_json::json!({
5986 "direction": "outbound",
5987 "delivery_attempts": 7,
5988 "next_attempt_at": "2999-01-01T00:00:00Z",
5989 }));
5990 let failed_id = failed.id;
5991
5992 for note in [delivered, failed] {
5993 store.upsert_note(note).await.expect("seed note");
5994 }
5995
5996 let delivered = rt
5997 .mark_outbound_message_delivered(
5998 &tok,
5999 delivered_id,
6000 "2026-08-30T00:00:00Z".to_string(),
6001 None,
6002 )
6003 .await
6004 .expect("delivery marker succeeds");
6005 let delivered_props = delivered.properties.unwrap();
6006 assert!(delivered_props.get("delivery_attempts").is_none());
6007 assert!(delivered_props.get("next_attempt_at").is_none());
6008 assert_eq!(delivered_props["last_error"].as_str(), Some("temporary"));
6009
6010 let failed = rt
6011 .mark_outbound_message_failed(
6012 &tok,
6013 failed_id,
6014 "2026-08-30T00:00:01Z".to_string(),
6015 "recipient rejected".to_string(),
6016 )
6017 .await
6018 .expect("permanent marker succeeds");
6019 let failed_props = failed.properties.unwrap();
6020 assert!(failed_props.get("delivery_attempts").is_none());
6021 assert!(failed_props.get("next_attempt_at").is_none());
6022 assert_eq!(failed_props["delivery"].as_str(), Some("failed"));
6023 }
6024
6025 #[tokio::test]
6026 async fn claim_outbound_message_external_id_sets_value_and_survives_readback() {
6027 let rt = rt();
6028 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6029 let tok = NamespaceToken::local();
6030 let note = outbound_message_note();
6031 let note_id = note.id;
6032 rt.notes(&tok)
6033 .expect("note store")
6034 .upsert_note(note)
6035 .await
6036 .expect("seed note");
6037
6038 let claimed = rt
6039 .claim_outbound_message_external_id(&tok, note_id, "<abc@example.com>".to_string())
6040 .await
6041 .expect("claim succeeds on a fresh outbound message note");
6042 assert_eq!(
6043 claimed
6044 .properties
6045 .as_ref()
6046 .and_then(|p| p.get("external_id"))
6047 .and_then(|v| v.as_str()),
6048 Some("<abc@example.com>")
6049 );
6050
6051 let reread = rt
6059 .notes(&tok)
6060 .expect("note store")
6061 .get_note(note_id)
6062 .await
6063 .expect("read note")
6064 .expect("note still exists");
6065 assert_eq!(
6066 reread
6067 .properties
6068 .as_ref()
6069 .and_then(|p| p.get("external_id"))
6070 .and_then(|v| v.as_str()),
6071 Some("<abc@example.com>")
6072 );
6073 }
6074
6075 #[tokio::test]
6076 async fn generic_update_still_refuses_external_id_on_message_note() {
6077 let rt = rt();
6078 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6079 let tok = NamespaceToken::local();
6080 let note = outbound_message_note();
6081 let note_id = note.id;
6082 rt.notes(&tok)
6083 .expect("note store")
6084 .upsert_note(note)
6085 .await
6086 .expect("seed note");
6087
6088 let err = rt
6089 .update_note(
6090 &tok,
6091 note_id,
6092 NotePatch {
6093 properties: Some(serde_json::json!({"external_id": "<forged@example.com>"})),
6094 ..Default::default()
6095 },
6096 )
6097 .await
6098 .expect_err("caller-facing update must keep refusing external_id on a message note");
6099 assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6100 assert!(err.to_string().contains("is not patchable"), "error: {err}");
6101
6102 let claimed = rt
6104 .claim_outbound_message_external_id(&tok, note_id, "<claimed@example.com>".to_string())
6105 .await
6106 .expect("owner-bookkeeping path still claims after a refused caller patch");
6107 assert_eq!(
6108 claimed
6109 .properties
6110 .as_ref()
6111 .and_then(|p| p.get("external_id"))
6112 .and_then(|v| v.as_str()),
6113 Some("<claimed@example.com>")
6114 );
6115 }
6116
6117 #[tokio::test]
6118 async fn claim_failure_snapshot_cannot_park_a_concurrent_successful_claim() {
6119 let rt = rt();
6120 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6121 let token = NamespaceToken::local();
6122 let mut before = outbound_message_note();
6123 before
6125 .properties
6126 .as_mut()
6127 .unwrap()
6128 .as_object_mut()
6129 .unwrap()
6130 .insert("channel_kind".into(), serde_json::json!("email"));
6131 rt.raw_notes(&token)
6132 .unwrap()
6133 .upsert_note(before.clone())
6134 .await
6135 .unwrap();
6136 let claimed = rt
6137 .claim_outbound_message_external_id(&token, before.id, "<winner@example.com>".into())
6138 .await
6139 .unwrap();
6140 assert!(claimed.updated_at > before.updated_at);
6141 assert_eq!(
6142 claimed.properties.as_ref().unwrap()["channel_kind"],
6143 "email"
6144 );
6145
6146 rt.mark_outbound_message_claim_failed_from_snapshot(
6147 &token,
6148 before,
6149 "2026-09-22T12:00:00Z".into(),
6150 "claim refused".into(),
6151 )
6152 .await
6153 .expect_err("the stale pre-claim snapshot cannot overwrite the winner");
6154 let returned = rt
6155 .mark_outbound_message_claim_failed(
6156 &token,
6157 claimed.id,
6158 "2026-09-22T12:00:00Z".into(),
6159 "already claimed".into(),
6160 )
6161 .await
6162 .unwrap();
6163 let stored = rt
6164 .notes(&token)
6165 .unwrap()
6166 .get_note(claimed.id)
6167 .await
6168 .unwrap()
6169 .unwrap();
6170 assert_eq!(
6171 serde_json::to_value(returned).unwrap(),
6172 serde_json::to_value(&claimed).unwrap()
6173 );
6174 assert_eq!(
6175 serde_json::to_value(stored).unwrap(),
6176 serde_json::to_value(claimed).unwrap()
6177 );
6178 }
6179
6180 #[tokio::test]
6181 async fn claim_failure_parks_only_unclaimed_pending_messages() {
6182 let rt = rt();
6183 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6184 let token = NamespaceToken::local();
6185 for outcome in [None, Some("delivered"), Some("failed")] {
6186 let mut note = outbound_message_note();
6187 let props = note.properties.as_mut().unwrap().as_object_mut().unwrap();
6188 props.insert("channel_kind".into(), serde_json::json!("email"));
6189 if let Some(outcome) = outcome {
6190 props.insert("delivery".into(), serde_json::json!(outcome));
6191 }
6192 rt.raw_notes(&token)
6193 .unwrap()
6194 .upsert_note(note.clone())
6195 .await
6196 .unwrap();
6197 let result = rt
6198 .mark_outbound_message_claim_failed(
6199 &token,
6200 note.id,
6201 "2026-09-22T12:00:00Z".into(),
6202 "claim refused".into(),
6203 )
6204 .await
6205 .unwrap();
6206 if outcome.is_some() {
6207 assert_eq!(
6208 serde_json::to_value(&result).unwrap(),
6209 serde_json::to_value(note).unwrap()
6210 );
6211 } else {
6212 assert_eq!(result.properties.as_ref().unwrap()["delivery"], "failed");
6213 assert_eq!(result.properties.as_ref().unwrap()["channel_kind"], "email");
6214 assert!(result.updated_at > note.updated_at);
6215 assert_eq!(result.version, note.version + 1, "one parking write");
6216 }
6217 assert_eq!(
6218 serde_json::to_value(
6219 rt.notes(&token)
6220 .unwrap()
6221 .get_note(result.id)
6222 .await
6223 .unwrap()
6224 .unwrap()
6225 )
6226 .unwrap(),
6227 serde_json::to_value(result).unwrap()
6228 );
6229 }
6230 }
6231
6232 #[tokio::test]
6233 async fn claim_refuses_non_message_note() {
6234 let rt = rt();
6235 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6236 let tok = NamespaceToken::local();
6237 let mut note = Note::new("local", "observation", "not a message");
6238 note.properties = Some(serde_json::json!({"direction": "outbound"}));
6239 let note_id = note.id;
6240 rt.notes(&tok)
6241 .expect("note store")
6242 .upsert_note(note)
6243 .await
6244 .expect("seed note");
6245
6246 let err = rt
6247 .claim_outbound_message_external_id(&tok, note_id, "<x@example.com>".to_string())
6248 .await
6249 .expect_err("a non-message note must never accept the claim");
6250 assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6251 }
6252
6253 #[tokio::test]
6254 async fn claim_refuses_inbound_message() {
6255 let rt = rt();
6256 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6257 let tok = NamespaceToken::local();
6258 let mut note = Note::new("local", "message", "inbound content");
6259 note.properties = Some(serde_json::json!({"direction": "inbound"}));
6260 let note_id = note.id;
6261 rt.notes(&tok)
6262 .expect("note store")
6263 .upsert_note(note)
6264 .await
6265 .expect("seed note");
6266
6267 let err = rt
6268 .claim_outbound_message_external_id(&tok, note_id, "<x@example.com>".to_string())
6269 .await
6270 .expect_err("an inbound message note must never accept the claim");
6271 assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6272 }
6273
6274 #[tokio::test]
6275 async fn claim_refuses_when_external_id_already_set() {
6276 let rt = rt();
6277 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6278 let tok = NamespaceToken::local();
6279 let mut note = outbound_message_note();
6280 note.properties = Some(
6281 serde_json::json!({"direction": "outbound", "external_id": "<already@example.com>"}),
6282 );
6283 let note_id = note.id;
6284 rt.notes(&tok)
6285 .expect("note store")
6286 .upsert_note(note)
6287 .await
6288 .expect("seed note");
6289
6290 let err = rt
6291 .claim_outbound_message_external_id(&tok, note_id, "<new@example.com>".to_string())
6292 .await
6293 .expect_err("a note that already carries external_id must refuse re-claim");
6294 assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6295 }
6296
6297 #[tokio::test]
6298 async fn generic_update_can_still_patch_delivered_at_on_message_note() {
6299 let rt = rt();
6300 rt.install_pack_owned_note_kinds(vec!["message".to_string()]);
6301 let tok = NamespaceToken::local();
6302 let note = outbound_message_note();
6303 let note_id = note.id;
6304 rt.notes(&tok)
6305 .expect("note store")
6306 .upsert_note(note)
6307 .await
6308 .expect("seed note");
6309
6310 let updated = rt
6311 .update_note(
6312 &tok,
6313 note_id,
6314 NotePatch {
6315 properties: Some(serde_json::json!({"delivered_at": "2026-08-09T00:00:00Z"})),
6316 ..Default::default()
6317 },
6318 )
6319 .await
6320 .expect("delivered_at is not owner-established and must remain patchable");
6321 assert_eq!(
6322 updated
6323 .properties
6324 .as_ref()
6325 .and_then(|p| p.get("delivered_at"))
6326 .and_then(|v| v.as_str()),
6327 Some("2026-08-09T00:00:00Z")
6328 );
6329 }
6330
6331 fn secret_shaped_reason() -> String {
6332 const ALPHANUMERIC: &[u8] =
6333 b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
6334 let candidate: String = (0..48)
6335 .map(|index| char::from(ALPHANUMERIC[(index * 17 + 11) % ALPHANUMERIC.len()]))
6336 .collect();
6337 format!("secret value: {candidate}")
6338 }
6339
6340 #[test]
6341 fn note_embedding_text_ref_borrows_stored_content() {
6342 let note = Note::new("embedding-borrow", "observation", "borrow this content");
6343 let text = note_embedding_text_ref(¬e);
6344
6345 assert_eq!(text, note.content.as_str());
6346 assert!(
6347 std::ptr::eq(text, note.content.as_str()),
6348 "internal canonical note text must borrow instead of cloning"
6349 );
6350 let owned: String = note_embedding_text(¬e);
6351 assert_eq!(owned.as_str(), note.content.as_str());
6352 }
6353
6354 #[tokio::test]
6355 async fn generic_note_update_errors_when_revision_is_already_i64_max() {
6356 let rt = rt();
6357 let tok = NamespaceToken::local();
6358 let mut note = Note::new("local", "observation", "saturated revision");
6359 note.updated_at = i64::MAX;
6360 let note_id = note.id;
6361 rt.notes(&tok)
6362 .expect("note store")
6363 .upsert_note(note.clone())
6364 .await
6365 .expect("seed saturated note");
6366
6367 let error = rt
6368 .update_note(
6369 &tok,
6370 note_id,
6371 NotePatch::new(None, Some("must not land".to_string()), None, None, None),
6372 )
6373 .await
6374 .expect_err("i64::MAX cannot yield a strictly newer CAS revision");
6375 assert!(
6376 matches!(&error, RuntimeError::Internal(_)),
6377 "revision exhaustion is an internal persisted-state error: {error}"
6378 );
6379 assert!(error.to_string().contains("i64::MAX"), "error: {error}");
6380
6381 let persisted = rt
6382 .notes(&tok)
6383 .expect("note store")
6384 .get_note(note_id)
6385 .await
6386 .expect("read note")
6387 .expect("note remains live");
6388 assert_eq!(persisted, note, "failed revision advance must not mutate");
6389 }
6390
6391 async fn restore_edge_preimage(
6392 rt: &KhiveRuntime,
6393 token: &NamespaceToken,
6394 preimage: &MergeEdgePreimage,
6395 ) {
6396 let edge = khive_storage::types::Edge {
6397 id: preimage.id.into(),
6398 namespace: preimage.namespace.clone(),
6399 source_id: preimage.source_id,
6400 target_id: preimage.target_id,
6401 relation: preimage.relation.parse().expect("stored relation"),
6402 weight: preimage.weight,
6403 created_at: chrono::DateTime::from_timestamp_micros(preimage.created_at)
6404 .expect("stored created_at"),
6405 updated_at: chrono::DateTime::from_timestamp_micros(preimage.updated_at)
6406 .expect("stored updated_at"),
6407 deleted_at: preimage.deleted_at.map(|value| {
6408 chrono::DateTime::from_timestamp_micros(value).expect("stored deleted_at")
6409 }),
6410 metadata: preimage.metadata.clone(),
6411 target_backend: preimage.target_backend.clone(),
6412 };
6413 rt.graph(token)
6414 .expect("graph store")
6415 .upsert_edge(edge)
6416 .await
6417 .expect("restore edge preimage");
6418 }
6419
6420 fn assert_edge_matches_preimage(
6421 edge: &khive_storage::types::Edge,
6422 preimage: &MergeEdgePreimage,
6423 ) {
6424 assert_eq!(Uuid::from(edge.id), preimage.id);
6425 assert_eq!(edge.namespace, preimage.namespace);
6426 assert_eq!(edge.source_id, preimage.source_id);
6427 assert_eq!(edge.target_id, preimage.target_id);
6428 assert_eq!(edge.relation.to_string(), preimage.relation);
6429 assert_eq!(edge.weight, preimage.weight);
6430 assert_eq!(edge.created_at.timestamp_micros(), preimage.created_at);
6431 assert_eq!(edge.updated_at.timestamp_micros(), preimage.updated_at);
6432 assert_eq!(
6433 edge.deleted_at.map(|value| value.timestamp_micros()),
6434 preimage.deleted_at
6435 );
6436 assert_eq!(edge.metadata, preimage.metadata);
6437 assert_eq!(edge.target_backend, preimage.target_backend);
6438 }
6439
6440 async fn fts_hit(rt: &KhiveRuntime, token: &NamespaceToken, query: &str) -> Vec<Uuid> {
6442 let ns = token.namespace().as_str().to_string();
6443 rt.text(token)
6444 .unwrap()
6445 .search(TextSearchRequest {
6446 query: query.to_string(),
6447 mode: TextQueryMode::Plain,
6448 filter: Some(TextFilter {
6449 namespaces: vec![ns],
6450 ..Default::default()
6451 }),
6452 top_k: 50,
6453 snippet_chars: 100,
6454 })
6455 .await
6456 .unwrap()
6457 .into_iter()
6458 .map(|h| h.subject_id)
6459 .collect()
6460 }
6461
6462 #[tokio::test]
6463 async fn update_entity_patch_changes_only_specified_fields() {
6464 let rt = rt();
6465 let tok = NamespaceToken::local();
6466 let entity = rt
6467 .create_entity(
6468 &tok,
6469 "concept",
6470 None,
6471 "OriginalName",
6472 Some("orig desc"),
6473 Some(serde_json::json!({"k":"v"})),
6474 vec![],
6475 )
6476 .await
6477 .unwrap();
6478
6479 let updated = rt
6480 .update_entity(
6481 &tok,
6482 entity.id,
6483 EntityPatch {
6484 description: Some(Some("new desc".to_string())),
6485 ..Default::default()
6486 },
6487 )
6488 .await
6489 .unwrap();
6490
6491 assert_eq!(updated.name, "OriginalName");
6492 assert_eq!(updated.description.as_deref(), Some("new desc"));
6493 assert_eq!(updated.properties, Some(serde_json::json!({"k":"v"})));
6494 }
6495
6496 #[tokio::test]
6497 async fn update_entity_if_unchanged_removes_properties_after_merge() {
6498 let rt = rt();
6499 let tok = NamespaceToken::local();
6500 let entity = rt
6501 .create_entity(
6502 &tok,
6503 "concept",
6504 None,
6505 "LegacyEcho",
6506 Some("keep description"),
6507 Some(serde_json::json!({
6508 "type": " Concept ",
6509 "nested": {"items": [1, {"value": null}], "keep": true},
6510 "label": "verbatim"
6511 })),
6512 vec!["keep-tag".to_string()],
6513 )
6514 .await
6515 .unwrap();
6516
6517 let updated = rt
6518 .update_entity_if_unchanged(
6519 &tok,
6520 &entity,
6521 EntityPatch {
6522 properties: Some(serde_json::json!({"type": "also remove", "added": 7})),
6523 ..Default::default()
6524 },
6525 &["type", "absent", "type"],
6526 )
6527 .await
6528 .expect("remove only the requested key after merging");
6529
6530 let mut expected = entity.clone();
6531 expected.properties = Some(serde_json::json!({
6532 "nested": {"items": [1, {"value": null}], "keep": true},
6533 "label": "verbatim",
6534 "added": 7
6535 }));
6536 assert!(updated.updated_at > entity.updated_at);
6537 expected.updated_at = updated.updated_at;
6538 expected.version = entity.version + 1;
6539 assert_eq!(serde_json::json!(updated), serde_json::json!(expected));
6540 assert_eq!(
6541 serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6542 serde_json::json!(expected)
6543 );
6544 let events = entity_update_events(&rt, &tok).await;
6545 assert_eq!(events.len(), 1);
6546 assert_eq!(
6547 events[0].payload["changed_fields"],
6548 serde_json::json!(["properties"])
6549 );
6550 }
6551
6552 #[tokio::test]
6553 async fn update_entity_if_unchanged_missing_removals_are_no_op() {
6554 let rt = rt();
6555 let tok = NamespaceToken::local();
6556 for properties in [
6557 None,
6558 Some(serde_json::json!({"keep": [true, null]})),
6559 Some(serde_json::json!(["not an object"])),
6560 ] {
6561 let entity = rt
6562 .create_entity(&tok, "concept", None, "NoRemoval", None, properties, vec![])
6563 .await
6564 .unwrap();
6565 let unchanged = rt
6566 .update_entity_if_unchanged(&tok, &entity, EntityPatch::default(), &["absent"])
6567 .await
6568 .unwrap();
6569 assert_eq!(serde_json::json!(unchanged), serde_json::json!(entity));
6570 assert_eq!(
6571 serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6572 serde_json::json!(entity)
6573 );
6574 }
6575 assert!(entity_update_events(&rt, &tok).await.is_empty());
6576 }
6577
6578 #[tokio::test]
6579 async fn update_entity_if_unchanged_refuses_stale_full_snapshot() {
6580 let rt = rt();
6581 let tok = NamespaceToken::local();
6582 let entity = rt
6583 .create_entity(
6584 &tok,
6585 "concept",
6586 None,
6587 "StaleBackfill",
6588 None,
6589 Some(serde_json::json!({"type": "concept", "keep": true})),
6590 vec![],
6591 )
6592 .await
6593 .unwrap();
6594 for field in ["properties", "entity_type", "deleted_at", "updated_at"] {
6595 let mut stale = entity.clone();
6596 match field {
6597 "properties" => stale.properties = Some(serde_json::json!({"type": "algorithm"})),
6598 "entity_type" => stale.entity_type = Some("algorithm".to_string()),
6599 "deleted_at" => stale.deleted_at = Some(entity.updated_at),
6600 "updated_at" => stale.updated_at -= 1,
6601 _ => unreachable!(),
6602 }
6603 let error = rt
6604 .update_entity_if_unchanged(
6605 &tok,
6606 &stale,
6607 EntityPatch {
6608 entity_type: Some(Some("algorithm".to_string())),
6609 ..Default::default()
6610 },
6611 &["type"],
6612 )
6613 .await
6614 .expect_err("every stale snapshot field must refuse before edits");
6615 assert!(
6616 matches!(error, RuntimeError::Khive(ref error) if error.kind() == khive_types::ErrorKind::Conflict),
6617 "{field}: {error}"
6618 );
6619 assert_eq!(
6620 serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6621 serde_json::json!(entity),
6622 "{field}"
6623 );
6624 }
6625
6626 let store = rt.entities(&tok).unwrap();
6627 store
6628 .delete_entity(entity.id, khive_storage::types::DeleteMode::Soft)
6629 .await
6630 .unwrap();
6631 let tombstone = store
6632 .get_entity_including_deleted(entity.id)
6633 .await
6634 .unwrap()
6635 .unwrap();
6636 let error = rt
6637 .update_entity_if_unchanged(&tok, &entity, EntityPatch::default(), &["type"])
6638 .await
6639 .expect_err("a deleted candidate must not be resurrected");
6640 assert!(
6641 matches!(error, RuntimeError::Khive(ref error) if error.kind() == khive_types::ErrorKind::Conflict),
6642 "{error}"
6643 );
6644 assert_eq!(
6645 serde_json::json!(store
6646 .get_entity_including_deleted(entity.id)
6647 .await
6648 .unwrap()
6649 .unwrap()),
6650 serde_json::json!(tombstone)
6651 );
6652 assert!(entity_update_events(&rt, &tok).await.is_empty());
6653 }
6654
6655 #[tokio::test]
6656 async fn update_entity_if_unchanged_refuses_concurrent_writer_after_snapshot_check() {
6657 let rt = rt();
6658 let tok = NamespaceToken::local();
6659 let entity = rt
6660 .create_entity(
6661 &tok,
6662 "concept",
6663 None,
6664 "RacingBackfill",
6665 None,
6666 Some(serde_json::json!({"type": "concept", "keep": true})),
6667 vec![],
6668 )
6669 .await
6670 .unwrap();
6671 let barrier = Arc::new(tokio::sync::Barrier::new(2));
6672 let (guarded, normal) = tokio::join!(
6673 race_seam::AFTER_READ_BARRIER.scope(
6674 Arc::clone(&barrier),
6675 rt.update_entity_if_unchanged(&tok, &entity, EntityPatch::default(), &["type"]),
6676 ),
6677 race_seam::AFTER_READ_BARRIER.scope(
6678 barrier,
6679 rt.update_entity(
6680 &tok,
6681 entity.id,
6682 EntityPatch {
6683 properties: Some(serde_json::json!({"normal_writer": true})),
6684 ..Default::default()
6685 },
6686 ),
6687 ),
6688 );
6689 assert_eq!(
6690 usize::from(guarded.is_ok()) + usize::from(normal.is_ok()),
6691 1
6692 );
6693 let (winner, refused) = match (guarded, normal) {
6694 (Ok(winner), Err(refused)) | (Err(refused), Ok(winner)) => (winner, refused),
6695 results => panic!("exactly one writer must win: {results:?}"),
6696 };
6697 assert!(
6698 matches!(refused, RuntimeError::Khive(ref error) if error.kind() == khive_types::ErrorKind::Conflict),
6699 "{refused}"
6700 );
6701 assert_eq!(
6702 serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6703 serde_json::json!(winner)
6704 );
6705 assert_eq!(entity_update_events(&rt, &tok).await.len(), 1);
6706 }
6707
6708 #[tokio::test]
6709 async fn update_entity_if_unchanged_normalizes_type_with_installed_validator() {
6710 let rt = rt();
6711 let composed = khive_types::EntityTypeRegistry::with_extra([khive_types::EntityTypeDef {
6712 kind: khive_types::EntityKind::Document,
6713 type_name: "backfill_test_report",
6714 aliases: &["field_report"],
6715 }]);
6716 rt.install_entity_type_validator(Arc::new(move |kind, raw| {
6717 let kind = kind
6718 .parse::<khive_types::EntityKind>()
6719 .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?;
6720 composed
6721 .resolve(kind, raw)
6722 .map(|resolved| resolved.entity_type)
6723 .map_err(RuntimeError::from)
6724 }));
6725 let tok = NamespaceToken::local();
6726 let entity = rt
6727 .create_entity(
6728 &tok,
6729 "document",
6730 None,
6731 "LegacySubtype",
6732 None,
6733 Some(serde_json::json!({"type": " Field-Report ", "keep": [1, 2]})),
6734 vec![],
6735 )
6736 .await
6737 .unwrap();
6738 let updated = rt
6739 .update_entity_if_unchanged(
6740 &tok,
6741 &entity,
6742 EntityPatch {
6743 entity_type: Some(Some(" Field-Report ".to_string())),
6744 ..Default::default()
6745 },
6746 &[],
6747 )
6748 .await
6749 .expect("normal write validation resolves pack-supplied aliases");
6750 assert_eq!(updated.entity_type.as_deref(), Some("backfill_test_report"));
6751 assert_eq!(updated.properties, entity.properties);
6752 assert_eq!(
6753 rt.get_entity(&tok, entity.id).await.unwrap().entity_type,
6754 updated.entity_type
6755 );
6756 let error = rt
6757 .update_entity_if_unchanged(
6758 &tok,
6759 &updated,
6760 EntityPatch {
6761 entity_type: Some(Some("not_registered".to_string())),
6762 ..Default::default()
6763 },
6764 &["type"],
6765 )
6766 .await
6767 .expect_err("invalid subtype refuses the entire patch and removal");
6768 assert!(matches!(error, RuntimeError::InvalidInput(_)), "{error}");
6769 assert_eq!(
6770 serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6771 serde_json::json!(updated)
6772 );
6773 let events = entity_update_events(&rt, &tok).await;
6774 assert_eq!(events.len(), 1);
6775 assert_eq!(
6776 events[0].payload["changed_fields"],
6777 serde_json::json!(["entity_type"])
6778 );
6779 }
6780
6781 #[tokio::test]
6782 async fn update_entity_if_unchanged_refuses_reserved_property_removal() {
6783 let rt = rt();
6784 let tok = NamespaceToken::local();
6785 let entity = rt
6786 .create_entity(&tok, "concept", None, "ReservedRemoval", None, None, vec![])
6787 .await
6788 .unwrap();
6789 let error = rt
6790 .update_entity_if_unchanged(
6791 &tok,
6792 &entity,
6793 EntityPatch::default(),
6794 &[crate::secret_gate::RESERVED_SECRET_GATE_KEY],
6795 )
6796 .await
6797 .expect_err("removal must share the reserved-property write validator");
6798 assert!(matches!(error, RuntimeError::InvalidInput(_)), "{error}");
6799 assert_eq!(
6800 serde_json::json!(rt.get_entity(&tok, entity.id).await.unwrap()),
6801 serde_json::json!(entity)
6802 );
6803 assert!(entity_update_events(&rt, &tok).await.is_empty());
6804 }
6805
6806 #[tokio::test]
6807 async fn update_entity_type_patch_validates_preserves_fields_and_requires_reindex() {
6808 let rt = rt();
6809 rt.install_entity_type_validator(std::sync::Arc::new(|kind, entity_type| {
6810 let Some(raw) = entity_type else {
6811 return Ok(None);
6812 };
6813 let normalized = raw.trim().to_ascii_lowercase();
6814 if kind == "concept" && normalized == "algorithm" {
6815 Ok(Some(normalized))
6816 } else {
6817 Err(RuntimeError::InvalidInput(format!(
6818 "unknown entity_type {raw:?} for {kind:?}; valid: algorithm"
6819 )))
6820 }
6821 }));
6822 let tok = NamespaceToken::local();
6823 let entity = rt
6824 .create_entity(
6825 &tok,
6826 "concept",
6827 None,
6828 "HistoricalAlgorithm",
6829 Some("keep description"),
6830 Some(serde_json::json!({"type": "algorithm", "keep": true})),
6831 vec!["keep-tag".to_string()],
6832 )
6833 .await
6834 .unwrap();
6835
6836 let (
6837 prepared,
6838 reindex_required,
6839 changed_fields,
6840 _expected_updated_at,
6841 _expected_deleted_at,
6842 ) = rt
6843 .prepare_update_entity(
6844 &tok,
6845 entity.id,
6846 EntityPatch {
6847 entity_type: Some(Some(" Algorithm ".to_string())),
6848 ..Default::default()
6849 },
6850 )
6851 .await
6852 .expect("registered entity_type must validate");
6853
6854 assert_eq!(prepared.entity_type.as_deref(), Some("algorithm"));
6855 assert_eq!(prepared.name, "HistoricalAlgorithm");
6856 assert_eq!(prepared.description.as_deref(), Some("keep description"));
6857 assert_eq!(
6858 prepared.properties,
6859 Some(serde_json::json!({"type": "algorithm", "keep": true}))
6860 );
6861 assert_eq!(prepared.tags, vec!["keep-tag"]);
6862 assert!(
6863 reindex_required,
6864 "changing entity_type must request the normal entity reindex path"
6865 );
6866 assert_eq!(changed_fields, vec!["entity_type"]);
6867
6868 let err = rt
6869 .prepare_update_entity(
6870 &tok,
6871 entity.id,
6872 EntityPatch {
6873 entity_type: Some(Some("not_registered".to_string())),
6874 ..Default::default()
6875 },
6876 )
6877 .await
6878 .expect_err("unregistered entity_type must be rejected");
6879 assert!(matches!(err, RuntimeError::InvalidInput(_)), "error: {err}");
6880 }
6881
6882 #[tokio::test]
6925 async fn concurrent_entity_property_patches_from_one_revision_only_one_survives() {
6926 let rt = Arc::new(rt());
6927 let tok = NamespaceToken::local();
6928 let entity = rt
6929 .create_entity(
6930 &tok,
6931 "concept",
6932 None,
6933 "RaceTarget",
6934 None,
6935 Some(serde_json::json!({"a": 0, "b": 0})),
6936 vec![],
6937 )
6938 .await
6939 .expect("seed entity");
6940 let id = entity.id;
6941
6942 let barrier = Arc::new(tokio::sync::Barrier::new(2));
6943
6944 let reader_a = {
6945 let rt = Arc::clone(&rt);
6946 let tok = tok.clone();
6947 let barrier = Arc::clone(&barrier);
6948 tokio::spawn(async move {
6949 barrier.wait().await;
6950 rt.prepare_update_entity(
6951 &tok,
6952 id,
6953 EntityPatch {
6954 properties: Some(serde_json::json!({"a": 1})),
6955 ..Default::default()
6956 },
6957 )
6958 .await
6959 })
6960 };
6961 let reader_b = {
6962 let rt = Arc::clone(&rt);
6963 let tok = tok.clone();
6964 let barrier = Arc::clone(&barrier);
6965 tokio::spawn(async move {
6966 barrier.wait().await;
6967 rt.prepare_update_entity(
6968 &tok,
6969 id,
6970 EntityPatch {
6971 properties: Some(serde_json::json!({"b": 1})),
6972 ..Default::default()
6973 },
6974 )
6975 .await
6976 })
6977 };
6978
6979 let (entity_a, _, _, expected_updated_at_a, expected_deleted_at_a) =
6980 reader_a.await.unwrap().expect("reader A prepares");
6981 let (entity_b, _, _, expected_updated_at_b, expected_deleted_at_b) =
6982 reader_b.await.unwrap().expect("reader B prepares");
6983 assert_eq!(
6984 expected_updated_at_a, expected_updated_at_b,
6985 "both readers must observe the same pre-write revision for this to be a real race"
6986 );
6987
6988 let store = rt.entities(&tok).expect("entity store");
6989 assert!(
6990 store
6991 .replace_entity_if_unchanged(entity_a, expected_updated_at_a, expected_deleted_at_a)
6992 .await
6993 .expect("writer A CAS query"),
6994 "the first committer from a shared revision must win"
6995 );
6996 assert!(
6997 !store
6998 .replace_entity_if_unchanged(entity_b, expected_updated_at_b, expected_deleted_at_b)
6999 .await
7000 .expect("writer B CAS query"),
7001 "the second committer from the SAME stale revision must be refused, not merged"
7002 );
7003
7004 let final_entity = rt.get_entity(&tok, id).await.expect("read final entity");
7005 assert_eq!(
7006 final_entity.properties,
7007 Some(serde_json::json!({"a": 1, "b": 0})),
7008 "writer B's field must not be silently merged into the persisted row: {:?}",
7009 final_entity.properties
7010 );
7011 }
7012
7013 #[tokio::test]
7024 async fn production_update_entity_refuses_concurrent_stale_writer() {
7025 let rt = Arc::new(rt());
7026 let tok = NamespaceToken::local();
7027 let entity = rt
7028 .create_entity(
7029 &tok,
7030 "concept",
7031 None,
7032 "ProductionRaceTarget",
7033 None,
7034 Some(serde_json::json!({"a": 0, "b": 0})),
7035 vec![],
7036 )
7037 .await
7038 .expect("seed entity");
7039 let id = entity.id;
7040
7041 let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
7042
7043 let writer_a = {
7044 let rt = Arc::clone(&rt);
7045 let tok = tok.clone();
7046 let barrier = std::sync::Arc::clone(&barrier);
7047 tokio::spawn(race_seam::AFTER_READ_BARRIER.scope(barrier, async move {
7048 rt.update_entity_with_embedding_report(
7049 &tok,
7050 id,
7051 EntityPatch {
7052 properties: Some(serde_json::json!({"a": 1})),
7053 ..Default::default()
7054 },
7055 )
7056 .await
7057 }))
7058 };
7059 let writer_b = {
7060 let rt = Arc::clone(&rt);
7061 let tok = tok.clone();
7062 let barrier = std::sync::Arc::clone(&barrier);
7063 tokio::spawn(race_seam::AFTER_READ_BARRIER.scope(barrier, async move {
7064 rt.update_entity_with_embedding_report(
7065 &tok,
7066 id,
7067 EntityPatch {
7068 properties: Some(serde_json::json!({"b": 1})),
7069 ..Default::default()
7070 },
7071 )
7072 .await
7073 }))
7074 };
7075
7076 let result_a = writer_a.await.expect("writer A task");
7077 let result_b = writer_b.await.expect("writer B task");
7078 let successes = [result_a.is_ok(), result_b.is_ok()]
7079 .into_iter()
7080 .filter(|ok| *ok)
7081 .count();
7082 assert_eq!(
7083 successes, 1,
7084 "exactly one production caller must win the race; the other must be refused: \
7085 a={result_a:?} b={result_b:?}"
7086 );
7087 let refused = if result_a.is_err() {
7088 result_a
7089 } else {
7090 result_b
7091 };
7092 match &refused {
7093 Err(RuntimeError::Khive(khive_error)) => {
7094 assert_eq!(
7095 khive_error.kind(),
7096 khive_types::ErrorKind::Conflict,
7097 "the losing production caller must surface a typed conflict, not \
7098 silently overwrite: {refused:?}"
7099 );
7100 }
7101 other => panic!("expected a typed conflict error, got {other:?}"),
7102 }
7103
7104 let final_entity = rt.get_entity(&tok, id).await.expect("read final entity");
7105 assert_ne!(
7106 final_entity.properties,
7107 Some(serde_json::json!({"a": 1, "b": 1})),
7108 "both racers' fields must never both land: that would mean the loser's stale \
7109 write silently succeeded"
7110 );
7111 }
7112
7113 #[tokio::test]
7135 async fn entity_cas_refuses_a_replacement_revision_that_does_not_advance() {
7136 let rt = rt();
7137 let tok = NamespaceToken::local();
7138 let entity = rt
7139 .create_entity(
7140 &tok,
7141 "concept",
7142 None,
7143 "NonAdvancing",
7144 None,
7145 Some(serde_json::json!({"a": 0})),
7146 vec![],
7147 )
7148 .await
7149 .expect("seed entity");
7150 let id = entity.id;
7151
7152 let (mut replacement, _, _, expected_updated_at, expected_deleted_at) = rt
7153 .prepare_update_entity(
7154 &tok,
7155 id,
7156 EntityPatch {
7157 properties: Some(serde_json::json!({"a": 1})),
7158 ..Default::default()
7159 },
7160 )
7161 .await
7162 .expect("prepare update");
7163
7164 replacement.updated_at = expected_updated_at;
7169
7170 let store = rt.entities(&tok).expect("entity store");
7171 let stored = store
7172 .get_entity_including_deleted(id)
7173 .await
7174 .expect("read stored row")
7175 .expect("row present before CAS");
7176 assert_eq!(
7177 stored.updated_at, expected_updated_at,
7178 "fixture premise: nothing moved the stored revision between prepare and CAS, \
7179 otherwise `?13` would refuse and this stops being an isolating fixture"
7180 );
7181 assert_eq!(
7182 stored.deleted_at, expected_deleted_at,
7183 "fixture premise: the stored deletion marker must still equal the snapshot's, \
7184 otherwise `deleted_at IS ?14` would refuse and this stops being an isolating \
7185 fixture"
7186 );
7187 assert_eq!(
7188 replacement.updated_at, stored.updated_at,
7189 "fixture premise: the replacement revision must NOT advance past the stored one, \
7190 which is the single condition under test"
7191 );
7192
7193 let committed = store
7194 .replace_entity_if_unchanged(replacement, expected_updated_at, expected_deleted_at)
7195 .await
7196 .expect("CAS query");
7197 assert!(
7198 !committed,
7199 "a replacement whose revision does not strictly advance past the stored one must \
7200 be refused: without `?8 > updated_at` the CAS would accept a write that leaves \
7201 `updated_at` unmoved, so a later writer holding the same snapshot would still \
7202 see its expected revision match and overwrite this one"
7203 );
7204
7205 let stored = rt.get_entity(&tok, id).await.expect("read back");
7206 assert_eq!(
7207 stored.properties,
7208 Some(serde_json::json!({"a": 0})),
7209 "the refused write must not have landed"
7210 );
7211 }
7212
7213 #[tokio::test]
7219 async fn entity_cas_refuses_a_stale_replacement_that_would_resurrect_a_tombstone() {
7220 let rt = rt();
7221 let tok = NamespaceToken::local();
7222 let entity = rt
7223 .create_entity(
7224 &tok,
7225 "concept",
7226 None,
7227 "Tombstoned",
7228 None,
7229 Some(serde_json::json!({"a": 0})),
7230 vec![],
7231 )
7232 .await
7233 .expect("seed entity");
7234 let id = entity.id;
7235
7236 let (mut replacement, _, _, expected_updated_at, expected_deleted_at) = rt
7238 .prepare_update_entity(
7239 &tok,
7240 id,
7241 EntityPatch {
7242 properties: Some(serde_json::json!({"a": 1})),
7243 ..Default::default()
7244 },
7245 )
7246 .await
7247 .expect("prepare update");
7248 assert_eq!(
7249 expected_deleted_at, None,
7250 "fixture premise: the snapshot must be of a LIVE row"
7251 );
7252
7253 rt.delete_entity(&tok, id, false)
7254 .await
7255 .expect("soft delete");
7256
7257 let store = rt.entities(&tok).expect("entity store");
7258 let tombstoned = store
7259 .get_entity_including_deleted(id)
7260 .await
7261 .expect("read tombstone")
7262 .expect("row still present after soft delete");
7263
7264 assert_eq!(tombstoned.version, replacement.version + 1);
7268 replacement.version = tombstoned.version;
7269
7270 assert_eq!(
7275 tombstoned.updated_at, expected_updated_at,
7276 "fixture premise: soft delete must NOT move `updated_at`, otherwise \
7277 `?13` would refuse and this stops being an isolating fixture"
7278 );
7279 assert!(
7280 replacement.updated_at > tombstoned.updated_at,
7281 "fixture premise: the replacement revision must still advance, \
7282 otherwise `?8 > updated_at` would refuse and this stops being an \
7283 isolating fixture"
7284 );
7285 assert!(
7286 tombstoned.deleted_at.is_some(),
7287 "fixture premise: the row must actually be tombstoned"
7288 );
7289
7290 let committed = store
7291 .replace_entity_if_unchanged(replacement, expected_updated_at, expected_deleted_at)
7292 .await
7293 .expect("CAS query");
7294 assert!(
7295 !committed,
7296 "a replacement carrying a pre-delete snapshot must be refused after the row is \
7297 soft-deleted: without `deleted_at IS ?14` it would write `deleted_at = NULL` over \
7298 the tombstone and silently resurrect a deleted entity"
7299 );
7300
7301 let after = store
7302 .get_entity_including_deleted(id)
7303 .await
7304 .expect("read back")
7305 .expect("row present");
7306 assert!(
7307 after.deleted_at.is_some(),
7308 "the tombstone must survive the refused write"
7309 );
7310 assert_eq!(
7311 after.properties,
7312 Some(serde_json::json!({"a": 0})),
7313 "the refused write must not have landed"
7314 );
7315 }
7316
7317 #[tokio::test]
7338 async fn update_entity_succeeds_when_stored_revision_is_ahead_of_wall_clock() {
7339 let rt = rt();
7340 let tok = NamespaceToken::local();
7341 let entity = rt
7342 .create_entity(
7343 &tok,
7344 "concept",
7345 None,
7346 "FutureRevisionTarget",
7347 None,
7348 None,
7349 vec![],
7350 )
7351 .await
7352 .expect("seed entity");
7353 let id = entity.id;
7354
7355 let future_micros = chrono::Utc::now().timestamp_micros() + 1_000_000;
7356 let pool = rt.backend().pool_arc();
7357 let id_str = id.to_string();
7358 tokio::task::spawn_blocking(move || {
7359 let guard = pool.writer().expect("writer connection");
7360 guard
7361 .execute(
7362 "UPDATE entities SET version = version + 1, updated_at = ?1 WHERE id = ?2",
7363 rusqlite::params![future_micros, id_str],
7364 )
7365 .expect("force future revision")
7366 })
7367 .await
7368 .expect("join");
7369
7370 let updated = rt
7371 .update_entity(
7372 &tok,
7373 id,
7374 EntityPatch {
7375 description: Some(Some("patched after a forced future revision".to_string())),
7376 ..Default::default()
7377 },
7378 )
7379 .await
7380 .expect(
7381 "update must succeed and advance past the stored revision, not report a \
7382 spurious conflict when nothing else wrote to this row",
7383 );
7384 assert!(updated.updated_at > future_micros);
7385 }
7386
7387 #[tokio::test]
7388 async fn update_entity_clear_description_with_some_none() {
7389 let rt = rt();
7390 let tok = NamespaceToken::local();
7391 let entity = rt
7392 .create_entity(
7393 &tok,
7394 "concept",
7395 None,
7396 "ClearDesc",
7397 Some("has description"),
7398 None,
7399 vec![],
7400 )
7401 .await
7402 .unwrap();
7403
7404 let updated = rt
7405 .update_entity(
7406 &tok,
7407 entity.id,
7408 EntityPatch {
7409 description: Some(None),
7410 ..Default::default()
7411 },
7412 )
7413 .await
7414 .unwrap();
7415
7416 assert!(
7417 updated.description.is_none(),
7418 "description should be cleared"
7419 );
7420 }
7421
7422 #[tokio::test]
7423 async fn update_entity_reindexes_when_name_changes() {
7424 let rt = rt();
7425 let tok = NamespaceToken::local();
7426 let entity = rt
7427 .create_entity(&tok, "concept", None, "OldName", None, None, vec![])
7428 .await
7429 .unwrap();
7430
7431 let hits_before = fts_hit(&rt, &tok, "OldName").await;
7432 assert!(
7433 hits_before.contains(&entity.id),
7434 "entity should be findable by old name"
7435 );
7436
7437 rt.update_entity(
7438 &tok,
7439 entity.id,
7440 EntityPatch {
7441 name: Some("NewName".to_string()),
7442 ..Default::default()
7443 },
7444 )
7445 .await
7446 .unwrap();
7447
7448 let hits_old = fts_hit(&rt, &tok, "OldName").await;
7449 let hits_new = fts_hit(&rt, &tok, "NewName").await;
7450
7451 assert!(
7452 !hits_old.contains(&entity.id),
7453 "old name should no longer match after rename"
7454 );
7455 assert!(
7456 hits_new.contains(&entity.id),
7457 "new name should be findable after rename"
7458 );
7459 }
7460
7461 #[tokio::test]
7462 async fn update_entity_properties_merges_preserving_existing_keys() {
7463 let rt = rt();
7464 let tok = NamespaceToken::local();
7465 let entity = rt
7466 .create_entity(
7467 &tok,
7468 "concept",
7469 None,
7470 "MergeProps",
7471 None,
7472 Some(serde_json::json!({
7473 "domain": "inference",
7474 "repo": "lattice",
7475 "status": "researched",
7476 })),
7477 vec![],
7478 )
7479 .await
7480 .unwrap();
7481
7482 let updated = rt
7483 .update_entity(
7484 &tok,
7485 entity.id,
7486 EntityPatch {
7487 properties: Some(serde_json::json!({"status": "implemented"})),
7488 ..Default::default()
7489 },
7490 )
7491 .await
7492 .unwrap();
7493
7494 let props = updated.properties.expect("properties should remain set");
7495 assert_eq!(props["domain"], "inference", "domain key must be preserved");
7496 assert_eq!(props["repo"], "lattice", "repo key must be preserved");
7497 assert_eq!(
7498 props["status"], "implemented",
7499 "status key must be updated by patch"
7500 );
7501 }
7502
7503 #[tokio::test]
7504 async fn update_entity_skips_reindex_when_only_properties_change() {
7505 let rt = rt();
7506 let tok = NamespaceToken::local();
7507 let entity = rt
7508 .create_entity(&tok, "concept", None, "StableIndexed", None, None, vec![])
7509 .await
7510 .unwrap();
7511
7512 let hits_before = fts_hit(&rt, &tok, "StableIndexed").await;
7513 assert!(hits_before.contains(&entity.id));
7514
7515 rt.update_entity(
7516 &tok,
7517 entity.id,
7518 EntityPatch {
7519 properties: Some(serde_json::json!({"new": "prop"})),
7520 ..Default::default()
7521 },
7522 )
7523 .await
7524 .unwrap();
7525
7526 let hits_after = fts_hit(&rt, &tok, "StableIndexed").await;
7527 assert!(
7528 hits_after.contains(&entity.id),
7529 "still findable after props-only patch"
7530 );
7531 }
7532
7533 #[tokio::test]
7534 async fn merge_entity_rewires_edges() {
7535 let rt = rt();
7536 let tok = NamespaceToken::local();
7537 let a = rt
7538 .create_entity(&tok, "concept", None, "A", None, None, vec![])
7539 .await
7540 .unwrap();
7541 let b = rt
7542 .create_entity(&tok, "concept", None, "B", None, None, vec![])
7543 .await
7544 .unwrap();
7545 let c = rt
7546 .create_entity(&tok, "concept", None, "C", None, None, vec![])
7547 .await
7548 .unwrap();
7549 let d = rt
7550 .create_entity(&tok, "concept", None, "D", None, None, vec![])
7551 .await
7552 .unwrap();
7553
7554 rt.link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
7556 .await
7557 .unwrap();
7558 rt.link(&tok, c.id, b.id, EdgeRelation::Extends, 1.0, None)
7559 .await
7560 .unwrap();
7561
7562 let summary = rt
7563 .merge_entity_with_reason(
7564 &tok,
7565 d.id,
7566 b.id,
7567 EntityDedupMergePolicy::PreferInto,
7568 ContentMergeStrategy::Append,
7569 false,
7570 None,
7571 )
7572 .await
7573 .unwrap();
7574
7575 assert_eq!(summary.kept_id, d.id);
7576 assert_eq!(summary.removed_id, b.id);
7577 assert_eq!(summary.edges_rewired, 2);
7578
7579 let a_neighbors = rt
7580 .neighbors(&tok, a.id, Direction::Out, None, None)
7581 .await
7582 .unwrap();
7583 assert_eq!(a_neighbors.len(), 1);
7584 assert_eq!(a_neighbors[0].node_id, d.id);
7585
7586 let c_neighbors = rt
7587 .neighbors(&tok, c.id, Direction::Out, None, None)
7588 .await
7589 .unwrap();
7590 assert_eq!(c_neighbors.len(), 1);
7591 assert_eq!(c_neighbors[0].node_id, d.id);
7592 }
7593
7594 #[tokio::test]
7599 async fn merge_entity_rewires_edges_from_other_namespaces() {
7600 use crate::Namespace;
7601
7602 let rt = rt();
7603 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
7604 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
7605
7606 let into_a = rt
7607 .create_entity(&ns_a, "concept", None, "Into A", None, None, vec![])
7608 .await
7609 .unwrap();
7610 let from_a = rt
7611 .create_entity(&ns_a, "concept", None, "From A", None, None, vec![])
7612 .await
7613 .unwrap();
7614 let foreign_b = rt
7615 .create_entity(&ns_b, "concept", None, "Foreign B", None, None, vec![])
7616 .await
7617 .unwrap();
7618
7619 rt.link(
7622 &ns_b,
7623 foreign_b.id,
7624 from_a.id,
7625 EdgeRelation::Extends,
7626 1.0,
7627 None,
7628 )
7629 .await
7630 .unwrap();
7631
7632 let summary = rt
7633 .merge_entity_with_reason(
7634 &ns_a,
7635 into_a.id,
7636 from_a.id,
7637 EntityDedupMergePolicy::PreferInto,
7638 ContentMergeStrategy::Append,
7639 false,
7640 None,
7641 )
7642 .await
7643 .unwrap();
7644
7645 assert_eq!(
7646 summary.edges_rewired, 1,
7647 "the ns_b-stamped edge incident to from_id must be discovered and rewired, not missed"
7648 );
7649
7650 let foreign_neighbors = rt
7651 .neighbors(&ns_b, foreign_b.id, Direction::Out, None, None)
7652 .await
7653 .unwrap();
7654 assert_eq!(
7655 foreign_neighbors.len(),
7656 1,
7657 "cross-namespace edge must survive the merge, rewired to point at into_id"
7658 );
7659 assert_eq!(foreign_neighbors[0].node_id, into_a.id);
7660 }
7661
7662 #[tokio::test]
7669 async fn merge_entity_drops_edge_violating_endpoint_contract_after_rewire() {
7670 let rt = rt();
7671 let tok = NamespaceToken::local();
7672
7673 rt.install_edge_rules(vec![EdgeEndpointRule {
7676 relation: EdgeRelation::DependsOn,
7677 source: EndpointKind::EntityOfType {
7678 kind: "concept",
7679 entity_type: "theorem",
7680 },
7681 target: EndpointKind::EntityOfType {
7682 kind: "concept",
7683 entity_type: "definition",
7684 },
7685 }]);
7686
7687 let def_entity = rt
7688 .create_entity(
7689 &tok,
7690 "concept",
7691 Some("definition"),
7692 "Def",
7693 None,
7694 None,
7695 vec![],
7696 )
7697 .await
7698 .unwrap();
7699 let from_theorem = rt
7700 .create_entity(
7701 &tok,
7702 "concept",
7703 Some("theorem"),
7704 "FromTheorem",
7705 None,
7706 None,
7707 vec![],
7708 )
7709 .await
7710 .unwrap();
7711 let into_lemma = rt
7715 .create_entity(
7716 &tok,
7717 "concept",
7718 Some("lemma"),
7719 "IntoLemma",
7720 None,
7721 None,
7722 vec![],
7723 )
7724 .await
7725 .unwrap();
7726
7727 rt.link(
7728 &tok,
7729 from_theorem.id,
7730 def_entity.id,
7731 EdgeRelation::DependsOn,
7732 1.0,
7733 None,
7734 )
7735 .await
7736 .unwrap();
7737
7738 let summary = rt
7739 .merge_entity_with_reason(
7740 &tok,
7741 into_lemma.id,
7742 from_theorem.id,
7743 EntityDedupMergePolicy::PreferInto,
7744 ContentMergeStrategy::Append,
7745 false,
7746 None,
7747 )
7748 .await
7749 .unwrap();
7750
7751 assert_eq!(
7752 summary.edges_rewired, 0,
7753 "the contract-violating rewire must not be counted as rewired"
7754 );
7755 assert_eq!(
7756 summary.edges_contract_skipped, 1,
7757 "the depends_on edge must be dropped, not silently rewritten past the endpoint contract"
7758 );
7759
7760 let def_neighbors = rt
7761 .neighbors(&tok, def_entity.id, Direction::In, None, None)
7762 .await
7763 .unwrap();
7764 assert!(
7765 def_neighbors.is_empty(),
7766 "no contract-violating depends_on edge should survive onto into_lemma; got {def_neighbors:?}"
7767 );
7768 }
7769
7770 #[tokio::test]
7773 async fn merge_entity_dry_run_predicts_contract_skip_without_writing() {
7774 let rt = rt();
7775 let tok = NamespaceToken::local();
7776
7777 rt.install_edge_rules(vec![EdgeEndpointRule {
7778 relation: EdgeRelation::DependsOn,
7779 source: EndpointKind::EntityOfType {
7780 kind: "concept",
7781 entity_type: "theorem",
7782 },
7783 target: EndpointKind::EntityOfType {
7784 kind: "concept",
7785 entity_type: "definition",
7786 },
7787 }]);
7788
7789 let def_entity = rt
7790 .create_entity(
7791 &tok,
7792 "concept",
7793 Some("definition"),
7794 "Def",
7795 None,
7796 None,
7797 vec![],
7798 )
7799 .await
7800 .unwrap();
7801 let from_theorem = rt
7802 .create_entity(
7803 &tok,
7804 "concept",
7805 Some("theorem"),
7806 "FromTheorem",
7807 None,
7808 None,
7809 vec![],
7810 )
7811 .await
7812 .unwrap();
7813 let into_lemma = rt
7814 .create_entity(
7815 &tok,
7816 "concept",
7817 Some("lemma"),
7818 "IntoLemma",
7819 None,
7820 None,
7821 vec![],
7822 )
7823 .await
7824 .unwrap();
7825
7826 rt.link(
7827 &tok,
7828 from_theorem.id,
7829 def_entity.id,
7830 EdgeRelation::DependsOn,
7831 1.0,
7832 None,
7833 )
7834 .await
7835 .unwrap();
7836
7837 let summary = rt
7838 .merge_entity_with_reason(
7839 &tok,
7840 into_lemma.id,
7841 from_theorem.id,
7842 EntityDedupMergePolicy::PreferInto,
7843 ContentMergeStrategy::Append,
7844 true, None,
7846 )
7847 .await
7848 .unwrap();
7849
7850 assert_eq!(summary.edges_rewired, 0);
7851 assert_eq!(summary.edges_contract_skipped, 1);
7852
7853 let def_neighbors = rt
7855 .neighbors(&tok, def_entity.id, Direction::In, None, None)
7856 .await
7857 .unwrap();
7858 assert_eq!(def_neighbors.len(), 1);
7859 assert_eq!(def_neighbors[0].node_id, from_theorem.id);
7860 }
7861
7862 #[tokio::test]
7865 async fn merge_entity_conflict_keeps_survivor_edge_attributes() {
7866 let rt = rt();
7867 let tok = NamespaceToken::local();
7868 let into = rt
7869 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
7870 .await
7871 .unwrap();
7872 let from = rt
7873 .create_entity(&tok, "concept", None, "From", None, None, vec![])
7874 .await
7875 .unwrap();
7876 let shared = rt
7877 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
7878 .await
7879 .unwrap();
7880
7881 let survivor = rt
7882 .link(&tok, into.id, shared.id, EdgeRelation::Extends, 0.9, None)
7883 .await
7884 .unwrap();
7885 rt.link(&tok, from.id, shared.id, EdgeRelation::Extends, 0.2, None)
7886 .await
7887 .unwrap();
7888
7889 rt.merge_entity(
7890 &tok,
7891 into.id,
7892 from.id,
7893 EntityDedupMergePolicy::PreferInto,
7894 ContentMergeStrategy::Append,
7895 false,
7896 )
7897 .await
7898 .unwrap();
7899
7900 let edges = rt
7901 .list_edges(
7902 &tok,
7903 crate::EdgeListFilter {
7904 source_id: Some(into.id),
7905 target_id: Some(shared.id),
7906 relations: vec![EdgeRelation::Extends],
7907 ..Default::default()
7908 },
7909 10,
7910 0,
7911 )
7912 .await
7913 .unwrap();
7914 assert_eq!(edges.len(), 1);
7915 assert_eq!(edges[0].id, survivor.id);
7916 assert!(
7917 (edges[0].weight - 0.9).abs() < f64::EPSILON,
7918 "survivor weight must not be overwritten by the merged-from edge; got {}",
7919 edges[0].weight
7920 );
7921 }
7922
7923 #[tokio::test]
7924 async fn merge_entity_conflict_records_restorable_edge_and_annotation_preimages() {
7925 let rt = rt();
7926 let tok = NamespaceToken::local();
7927 let into = rt
7928 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
7929 .await
7930 .unwrap();
7931 let from = rt
7932 .create_entity(&tok, "concept", None, "From", None, None, vec![])
7933 .await
7934 .unwrap();
7935 let shared = rt
7936 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
7937 .await
7938 .unwrap();
7939 let annotator = rt
7940 .create_note(
7941 &tok,
7942 "observation",
7943 None,
7944 "edge judgment",
7945 None,
7946 None,
7947 vec![],
7948 )
7949 .await
7950 .unwrap();
7951 let nested_annotator = rt
7952 .create_note(
7953 &tok,
7954 "observation",
7955 None,
7956 "judgment review",
7957 None,
7958 None,
7959 vec![],
7960 )
7961 .await
7962 .unwrap();
7963
7964 let survivor = rt
7965 .link(
7966 &tok,
7967 into.id,
7968 shared.id,
7969 EdgeRelation::Extends,
7970 0.9,
7971 Some(serde_json::json!({"source": "survivor"})),
7972 )
7973 .await
7974 .unwrap();
7975 let dropped = rt
7976 .link(
7977 &tok,
7978 from.id,
7979 shared.id,
7980 EdgeRelation::Extends,
7981 0.2,
7982 Some(serde_json::json!({"source": "dropped"})),
7983 )
7984 .await
7985 .unwrap();
7986 let annotation = rt
7987 .link(
7988 &tok,
7989 annotator.id,
7990 dropped.id.into(),
7991 EdgeRelation::Annotates,
7992 0.7,
7993 Some(serde_json::json!({"basis": "manual"})),
7994 )
7995 .await
7996 .unwrap();
7997 let nested_annotation = rt
7998 .link(
7999 &tok,
8000 nested_annotator.id,
8001 annotation.id.into(),
8002 EdgeRelation::Annotates,
8003 0.6,
8004 Some(serde_json::json!({"review": "confirmed"})),
8005 )
8006 .await
8007 .unwrap();
8008 rt.delete_edge(&tok, annotation.id.into(), false)
8009 .await
8010 .unwrap();
8011
8012 let summary = rt
8013 .merge_entity(
8014 &tok,
8015 into.id,
8016 from.id,
8017 EntityDedupMergePolicy::PreferInto,
8018 ContentMergeStrategy::Append,
8019 false,
8020 )
8021 .await
8022 .unwrap();
8023
8024 let [conflict] = summary.edge_conflict_preimages.as_slice() else {
8025 panic!(
8026 "expected one edge-conflict preimage, got {:?}",
8027 summary.edge_conflict_preimages
8028 );
8029 };
8030 assert_eq!(conflict.surviving_edge_id, Uuid::from(survivor.id));
8031 assert_eq!(conflict.dropped_edge.id, Uuid::from(dropped.id));
8032 assert_eq!(conflict.dropped_edge.source_id, from.id);
8033 assert_eq!(conflict.dropped_edge.target_id, shared.id);
8034 assert_eq!(conflict.dropped_edge.relation, "extends");
8035 assert_eq!(conflict.dropped_edge.weight, 0.2);
8036 assert_eq!(
8037 conflict.dropped_edge.metadata,
8038 Some(serde_json::json!({"source": "dropped"}))
8039 );
8040 assert_eq!(conflict.incident_edge_preimages.len(), 2);
8041 assert_eq!(
8042 conflict.incident_edge_preimages[0].id,
8043 Uuid::from(annotation.id)
8044 );
8045 assert_eq!(
8046 conflict.incident_edge_preimages[1].id,
8047 Uuid::from(nested_annotation.id)
8048 );
8049
8050 for id in [dropped.id, annotation.id, nested_annotation.id] {
8051 assert!(
8052 rt.get_edge_including_deleted(&tok, id.into())
8053 .await
8054 .unwrap()
8055 .is_none(),
8056 "merge conflict cascade must leave no dangling edge row for {id}"
8057 );
8058 }
8059
8060 let events = rt
8061 .events(&tok)
8062 .unwrap()
8063 .query_events(
8064 khive_storage::EventFilter {
8065 kinds: vec![EventKind::EntityMerged],
8066 ..Default::default()
8067 },
8068 khive_storage::types::PageRequest {
8069 offset: 0,
8070 limit: 10,
8071 },
8072 )
8073 .await
8074 .unwrap();
8075 assert_eq!(events.items.len(), 1);
8076 assert_eq!(
8077 events.items[0].payload["edge_conflict_preimages"],
8078 serde_json::to_value(&summary.edge_conflict_preimages).unwrap()
8079 );
8080
8081 restore_edge_preimage(&rt, &tok, &conflict.dropped_edge).await;
8082 for preimage in &conflict.incident_edge_preimages {
8083 restore_edge_preimage(&rt, &tok, preimage).await;
8084 }
8085 for preimage in
8086 std::iter::once(&conflict.dropped_edge).chain(conflict.incident_edge_preimages.iter())
8087 {
8088 let restored = rt
8089 .get_edge_including_deleted(&tok, preimage.id)
8090 .await
8091 .unwrap()
8092 .expect("restored edge");
8093 assert_edge_matches_preimage(&restored, preimage);
8094 }
8095 }
8096
8097 #[tokio::test]
8106 async fn merge_entity_dry_run_conflict_returns_preimages_without_mutating() {
8107 let rt = rt();
8108 let tok = NamespaceToken::local();
8109 let into = rt
8110 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8111 .await
8112 .unwrap();
8113 let from = rt
8114 .create_entity(&tok, "concept", None, "From", None, None, vec![])
8115 .await
8116 .unwrap();
8117 let shared = rt
8118 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
8119 .await
8120 .unwrap();
8121 let annotator = rt
8122 .create_note(
8123 &tok,
8124 "observation",
8125 None,
8126 "edge judgment",
8127 None,
8128 None,
8129 vec![],
8130 )
8131 .await
8132 .unwrap();
8133 let nested_annotator = rt
8134 .create_note(
8135 &tok,
8136 "observation",
8137 None,
8138 "judgment review",
8139 None,
8140 None,
8141 vec![],
8142 )
8143 .await
8144 .unwrap();
8145
8146 let survivor = rt
8147 .link(
8148 &tok,
8149 into.id,
8150 shared.id,
8151 EdgeRelation::Extends,
8152 0.9,
8153 Some(serde_json::json!({"source": "survivor"})),
8154 )
8155 .await
8156 .unwrap();
8157 let dropped = rt
8158 .link(
8159 &tok,
8160 from.id,
8161 shared.id,
8162 EdgeRelation::Extends,
8163 0.2,
8164 Some(serde_json::json!({"source": "dropped"})),
8165 )
8166 .await
8167 .unwrap();
8168 let annotation = rt
8169 .link(
8170 &tok,
8171 annotator.id,
8172 dropped.id.into(),
8173 EdgeRelation::Annotates,
8174 0.7,
8175 Some(serde_json::json!({"basis": "manual"})),
8176 )
8177 .await
8178 .unwrap();
8179 let nested_annotation = rt
8180 .link(
8181 &tok,
8182 nested_annotator.id,
8183 annotation.id.into(),
8184 EdgeRelation::Annotates,
8185 0.6,
8186 Some(serde_json::json!({"basis": "nested"})),
8187 )
8188 .await
8189 .unwrap();
8190 rt.delete_edge(&tok, nested_annotation.id.into(), false)
8191 .await
8192 .unwrap();
8193
8194 let survivor_before = rt
8195 .get_edge_including_deleted(&tok, survivor.id.into())
8196 .await
8197 .unwrap()
8198 .expect("survivor edge exists");
8199 let dropped_before = rt
8200 .get_edge_including_deleted(&tok, dropped.id.into())
8201 .await
8202 .unwrap()
8203 .expect("dropped edge exists");
8204 let annotation_before = rt
8205 .get_edge_including_deleted(&tok, annotation.id.into())
8206 .await
8207 .unwrap()
8208 .expect("annotation edge exists");
8209 let nested_annotation_before = rt
8210 .get_edge_including_deleted(&tok, nested_annotation.id.into())
8211 .await
8212 .unwrap()
8213 .expect("nested annotation edge exists");
8214 let into_before = rt
8215 .get_entity(&tok, into.id)
8216 .await
8217 .expect("into entity exists");
8218 let from_before = rt
8219 .get_entity(&tok, from.id)
8220 .await
8221 .expect("from entity exists");
8222
8223 let summary = rt
8224 .merge_entity(
8225 &tok,
8226 into.id,
8227 from.id,
8228 EntityDedupMergePolicy::PreferInto,
8229 ContentMergeStrategy::Append,
8230 true,
8231 )
8232 .await
8233 .unwrap();
8234
8235 let [conflict] = summary.edge_conflict_preimages.as_slice() else {
8236 panic!(
8237 "expected one edge-conflict preimage from the dry run, got {:?}",
8238 summary.edge_conflict_preimages
8239 );
8240 };
8241 assert_eq!(conflict.surviving_edge_id, Uuid::from(survivor.id));
8242 assert_eq!(conflict.dropped_edge.id, Uuid::from(dropped.id));
8243 assert_eq!(conflict.dropped_edge.source_id, from.id);
8244 assert_eq!(conflict.dropped_edge.target_id, shared.id);
8245 assert_eq!(conflict.dropped_edge.weight, 0.2);
8246 assert_eq!(conflict.incident_edge_preimages.len(), 2);
8249 assert_eq!(
8250 conflict.incident_edge_preimages[0].id,
8251 Uuid::from(annotation.id)
8252 );
8253 assert!(
8254 conflict.incident_edge_preimages[0].deleted_at.is_none(),
8255 "the direct annotation was never soft-deleted"
8256 );
8257 assert_eq!(
8258 conflict.incident_edge_preimages[1].id,
8259 Uuid::from(nested_annotation.id)
8260 );
8261 assert!(
8262 conflict.incident_edge_preimages[1].deleted_at.is_some(),
8263 "dry-run preimage must retain the nested annotation's tombstone state"
8264 );
8265
8266 let survivor_after = rt
8267 .get_edge_including_deleted(&tok, survivor.id.into())
8268 .await
8269 .unwrap()
8270 .expect("dry run must not delete the survivor edge");
8271 let dropped_after = rt
8272 .get_edge_including_deleted(&tok, dropped.id.into())
8273 .await
8274 .unwrap()
8275 .expect("dry run must not delete the dropped edge");
8276 let annotation_after = rt
8277 .get_edge_including_deleted(&tok, annotation.id.into())
8278 .await
8279 .unwrap()
8280 .expect("dry run must not delete the cascaded annotation");
8281 let nested_annotation_after = rt
8282 .get_edge_including_deleted(&tok, nested_annotation.id.into())
8283 .await
8284 .unwrap()
8285 .expect("dry run must not delete the nested cascaded annotation");
8286 assert_eq!(
8287 serde_json::to_value(&survivor_before).unwrap(),
8288 serde_json::to_value(&survivor_after).unwrap(),
8289 "dry run must not mutate the surviving edge's row at all"
8290 );
8291 assert_eq!(
8292 serde_json::to_value(&dropped_before).unwrap(),
8293 serde_json::to_value(&dropped_after).unwrap(),
8294 "dry run must not mutate the would-be-dropped edge's row at all"
8295 );
8296 assert_eq!(
8297 serde_json::to_value(&annotation_before).unwrap(),
8298 serde_json::to_value(&annotation_after).unwrap(),
8299 "dry run must not mutate the incident annotation's row at all"
8300 );
8301 assert_eq!(
8302 serde_json::to_value(&nested_annotation_before).unwrap(),
8303 serde_json::to_value(&nested_annotation_after).unwrap(),
8304 "dry run must not mutate the nested incident annotation's row at all"
8305 );
8306
8307 let into_after = rt
8308 .get_entity(&tok, into.id)
8309 .await
8310 .expect("into entity must remain unmerged after a dry run");
8311 let from_after = rt
8312 .get_entity(&tok, from.id)
8313 .await
8314 .expect("from entity must not be merged away by a dry run");
8315 assert_eq!(
8316 serde_json::to_value(&into_before).unwrap(),
8317 serde_json::to_value(&into_after).unwrap(),
8318 "dry run must not mutate the into entity's row at all"
8319 );
8320 assert_eq!(
8321 serde_json::to_value(&from_before).unwrap(),
8322 serde_json::to_value(&from_after).unwrap(),
8323 "dry run must not mutate the from entity's row at all"
8324 );
8325 assert_eq!(from_after.deleted_at, None);
8326 assert_eq!(from_after.merged_into, None);
8327 assert_eq!(from_after.merge_event_id, None);
8328
8329 let events = rt
8330 .events(&tok)
8331 .unwrap()
8332 .query_events(
8333 khive_storage::EventFilter {
8334 kinds: vec![EventKind::EntityMerged],
8335 ..Default::default()
8336 },
8337 khive_storage::types::PageRequest {
8338 offset: 0,
8339 limit: 10,
8340 },
8341 )
8342 .await
8343 .unwrap();
8344 assert!(
8345 events.items.is_empty(),
8346 "a dry run must not record a merge audit event"
8347 );
8348 }
8349
8350 #[tokio::test]
8353 async fn merge_entity_conflict_does_not_resurrect_tombstoned_edge() {
8354 let rt = rt();
8355 let tok = NamespaceToken::local();
8356 let into = rt
8357 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8358 .await
8359 .unwrap();
8360 let from = rt
8361 .create_entity(&tok, "concept", None, "From", None, None, vec![])
8362 .await
8363 .unwrap();
8364 let shared = rt
8365 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
8366 .await
8367 .unwrap();
8368
8369 let survivor = rt
8370 .link(&tok, into.id, shared.id, EdgeRelation::Extends, 0.9, None)
8371 .await
8372 .unwrap();
8373 rt.delete_edge(&tok, survivor.id.into(), false)
8374 .await
8375 .unwrap();
8376 rt.link(&tok, from.id, shared.id, EdgeRelation::Extends, 0.2, None)
8377 .await
8378 .unwrap();
8379
8380 rt.merge_entity(
8381 &tok,
8382 into.id,
8383 from.id,
8384 EntityDedupMergePolicy::PreferInto,
8385 ContentMergeStrategy::Append,
8386 false,
8387 )
8388 .await
8389 .unwrap();
8390
8391 for (src, label) in [(into.id, "into"), (from.id, "from")] {
8392 let edges = rt
8393 .list_edges(
8394 &tok,
8395 crate::EdgeListFilter {
8396 source_id: Some(src),
8397 target_id: Some(shared.id),
8398 relations: vec![EdgeRelation::Extends],
8399 ..Default::default()
8400 },
8401 10,
8402 0,
8403 )
8404 .await
8405 .unwrap();
8406 assert!(
8407 edges.is_empty(),
8408 "no live {label}→shared edge may exist after merging over a tombstone; got: {edges:?}"
8409 );
8410 }
8411 }
8412
8413 #[tokio::test]
8417 async fn merge_entity_preserves_survivor_entity_type() {
8418 let rt = rt();
8419 let tok = NamespaceToken::local();
8420 let into = rt
8421 .create_entity(&tok, "resource", Some("skill"), "Into", None, None, vec![])
8422 .await
8423 .unwrap();
8424 assert_eq!(into.entity_type.as_deref(), Some("skill"));
8425 let from = rt
8426 .create_entity(&tok, "resource", None, "From", None, None, vec![])
8427 .await
8428 .unwrap();
8429
8430 rt.merge_entity(
8431 &tok,
8432 into.id,
8433 from.id,
8434 EntityDedupMergePolicy::PreferInto,
8435 ContentMergeStrategy::Append,
8436 false,
8437 )
8438 .await
8439 .unwrap();
8440
8441 let got = rt.get_entity(&tok, into.id).await.unwrap();
8442 assert_eq!(
8443 got.entity_type.as_deref(),
8444 Some("skill"),
8445 "merge must not null the survivor's entity_type"
8446 );
8447 }
8448
8449 #[tokio::test]
8450 async fn merge_entity_preserves_survivor_content_ref() {
8451 let rt = rt();
8452 let tok = NamespaceToken::local();
8453 let into = rt
8454 .create_entity(&tok, "document", None, "Into", None, None, vec![])
8455 .await
8456 .unwrap();
8457 let from = rt
8458 .create_entity(&tok, "document", None, "From", None, None, vec![])
8459 .await
8460 .unwrap();
8461
8462 let content_ref = khive_storage::ContentRef::from_hex("0".repeat(64)).unwrap();
8463 let store = rt.entities(&tok).unwrap();
8464 rt.attachments()
8465 .unwrap()
8466 .upsert_attachment(khive_storage::Attachment::from_new(
8467 into.id,
8468 khive_storage::AttachmentSubstrate::Entity,
8469 khive_storage::NewAttachment {
8470 role: "content".to_string(),
8471 content_ref: content_ref.clone(),
8472 media_type: None,
8473 size_bytes: None,
8474 },
8475 into.created_at,
8476 ))
8477 .await
8478 .unwrap();
8479
8480 rt.merge_entity(
8481 &tok,
8482 into.id,
8483 from.id,
8484 EntityDedupMergePolicy::PreferInto,
8485 ContentMergeStrategy::Append,
8486 false,
8487 )
8488 .await
8489 .unwrap();
8490
8491 let got = store.get_entity(into.id).await.unwrap().unwrap();
8492 assert_eq!(
8493 got.content_ref.as_deref(),
8494 Some(content_ref.as_str()),
8495 "merge must not null the survivor's content_ref"
8496 );
8497 }
8498
8499 #[tokio::test]
8500 async fn merge_entity_self_merge_rejected() {
8501 let rt = rt();
8502 let tok = NamespaceToken::local();
8503 let a = rt
8504 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8505 .await
8506 .unwrap();
8507 let err = rt
8508 .merge_entity_with_reason(
8509 &tok,
8510 a.id,
8511 a.id,
8512 EntityDedupMergePolicy::PreferInto,
8513 ContentMergeStrategy::Append,
8514 false,
8515 None,
8516 )
8517 .await
8518 .unwrap_err();
8519 assert!(
8520 format!("{err:?}").contains("cannot merge an entity into itself"),
8521 "expected self-merge rejection, got: {err:?}"
8522 );
8523 }
8524
8525 #[tokio::test]
8526 async fn merge_entity_prefer_into_strategy() {
8527 let rt = rt();
8528 let tok = NamespaceToken::local();
8529 let into = rt
8530 .create_entity(
8531 &tok,
8532 "concept",
8533 None,
8534 "Into",
8535 None,
8536 Some(serde_json::json!({"a": 1})),
8537 vec![],
8538 )
8539 .await
8540 .unwrap();
8541 let from = rt
8542 .create_entity(
8543 &tok,
8544 "concept",
8545 None,
8546 "From",
8547 None,
8548 Some(serde_json::json!({"a": 2, "b": 3})),
8549 vec![],
8550 )
8551 .await
8552 .unwrap();
8553
8554 rt.merge_entity_with_reason(
8555 &tok,
8556 into.id,
8557 from.id,
8558 EntityDedupMergePolicy::PreferInto,
8559 ContentMergeStrategy::Append,
8560 false,
8561 None,
8562 )
8563 .await
8564 .unwrap();
8565
8566 let kept = rt.get_entity(&tok, into.id).await.unwrap();
8567 let props = kept.properties.unwrap();
8568 assert_eq!(props["a"], 1);
8570 assert_eq!(props["b"], 3);
8571 }
8572
8573 #[tokio::test]
8574 async fn merge_entity_prefer_from_strategy() {
8575 let rt = rt();
8576 let tok = NamespaceToken::local();
8577 let into = rt
8578 .create_entity(
8579 &tok,
8580 "concept",
8581 None,
8582 "Into",
8583 None,
8584 Some(serde_json::json!({"a": 1})),
8585 vec![],
8586 )
8587 .await
8588 .unwrap();
8589 let from = rt
8590 .create_entity(
8591 &tok,
8592 "concept",
8593 None,
8594 "From",
8595 None,
8596 Some(serde_json::json!({"a": 2, "b": 3})),
8597 vec![],
8598 )
8599 .await
8600 .unwrap();
8601
8602 rt.merge_entity_with_reason(
8603 &tok,
8604 into.id,
8605 from.id,
8606 EntityDedupMergePolicy::PreferFrom,
8607 ContentMergeStrategy::Append,
8608 false,
8609 None,
8610 )
8611 .await
8612 .unwrap();
8613
8614 let kept = rt.get_entity(&tok, into.id).await.unwrap();
8615 let props = kept.properties.unwrap();
8616 assert_eq!(props["a"], 2);
8618 assert_eq!(props["b"], 3);
8619 }
8620
8621 #[tokio::test]
8622 async fn merge_entity_union_strategy() {
8623 let rt = rt();
8624 let tok = NamespaceToken::local();
8625 let into = rt
8626 .create_entity(
8627 &tok,
8628 "concept",
8629 None,
8630 "Into",
8631 None,
8632 Some(serde_json::json!({"a": 1})),
8633 vec![],
8634 )
8635 .await
8636 .unwrap();
8637 let from = rt
8638 .create_entity(
8639 &tok,
8640 "concept",
8641 None,
8642 "From",
8643 None,
8644 Some(serde_json::json!({"a": 2, "b": 3})),
8645 vec![],
8646 )
8647 .await
8648 .unwrap();
8649
8650 rt.merge_entity_with_reason(
8651 &tok,
8652 into.id,
8653 from.id,
8654 EntityDedupMergePolicy::Union,
8655 ContentMergeStrategy::Append,
8656 false,
8657 None,
8658 )
8659 .await
8660 .unwrap();
8661
8662 let kept = rt.get_entity(&tok, into.id).await.unwrap();
8663 let props = kept.properties.unwrap();
8664 assert_eq!(props["a"], 1);
8666 assert_eq!(props["b"], 3);
8667 }
8668
8669 #[tokio::test]
8670 async fn merge_entity_unions_tags() {
8671 let rt = rt();
8672 let tok = NamespaceToken::local();
8673 let into = rt
8674 .create_entity(
8675 &tok,
8676 "concept",
8677 None,
8678 "Into",
8679 None,
8680 None,
8681 vec!["x".to_string(), "y".to_string()],
8682 )
8683 .await
8684 .unwrap();
8685 let from = rt
8686 .create_entity(
8687 &tok,
8688 "concept",
8689 None,
8690 "From",
8691 None,
8692 None,
8693 vec!["y".to_string(), "z".to_string()],
8694 )
8695 .await
8696 .unwrap();
8697
8698 rt.merge_entity_with_reason(
8699 &tok,
8700 into.id,
8701 from.id,
8702 EntityDedupMergePolicy::PreferInto,
8703 ContentMergeStrategy::Append,
8704 false,
8705 None,
8706 )
8707 .await
8708 .unwrap();
8709
8710 let kept = rt.get_entity(&tok, into.id).await.unwrap();
8711 let mut tags = kept.tags.clone();
8712 tags.sort();
8713 assert_eq!(tags, vec!["x", "y", "z"]);
8714 }
8715
8716 #[tokio::test]
8720 async fn entity_merge_event_insert_failure_rolls_back_destructive_merge() {
8721 let rt = rt();
8722 let tok = NamespaceToken::local();
8723 let into = rt
8724 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8725 .await
8726 .unwrap();
8727 let from = rt
8728 .create_entity(&tok, "concept", None, "From", None, None, vec![])
8729 .await
8730 .unwrap();
8731 let edge = rt
8732 .link(&tok, into.id, from.id, EdgeRelation::Extends, 1.0, None)
8733 .await
8734 .unwrap();
8735 let event_store = rt.events(&tok).unwrap();
8736 set_merge_event_refusal(&rt, "entity_merged", true);
8737
8738 let failed = rt
8739 .merge_entity(
8740 &tok,
8741 into.id,
8742 from.id,
8743 EntityDedupMergePolicy::PreferInto,
8744 ContentMergeStrategy::Append,
8745 false,
8746 )
8747 .await;
8748 assert!(failed.is_err(), "event insert must abort the merge");
8749 let source = rt.get_entity(&tok, from.id).await.unwrap();
8750 assert_eq!(source.merge_event_id, None);
8751 assert!(source.deleted_at.is_none());
8752 assert!(
8753 rt.get_edge_including_deleted(&tok, edge.id.into())
8754 .await
8755 .unwrap()
8756 .is_some(),
8757 "the deleted self-loop must roll back"
8758 );
8759 let filter = khive_storage::EventFilter {
8760 kinds: vec![EventKind::EntityMerged],
8761 ..Default::default()
8762 };
8763 let page = khive_storage::types::PageRequest {
8764 offset: 0,
8765 limit: 10,
8766 };
8767 assert!(event_store
8768 .query_events(filter.clone(), page.clone())
8769 .await
8770 .unwrap()
8771 .items
8772 .is_empty());
8773
8774 set_merge_event_refusal(&rt, "entity_merged", false);
8775 let summary = rt
8776 .merge_entity(
8777 &tok,
8778 into.id,
8779 from.id,
8780 EntityDedupMergePolicy::PreferInto,
8781 ContentMergeStrategy::Append,
8782 false,
8783 )
8784 .await
8785 .unwrap();
8786 let tombstone = rt
8787 .entities(&tok)
8788 .unwrap()
8789 .get_entity_including_deleted(from.id)
8790 .await
8791 .unwrap()
8792 .unwrap();
8793 let events = event_store.query_events(filter, page).await.unwrap();
8794 assert_eq!(events.items.len(), 1);
8795 assert_eq!(tombstone.merge_event_id, Some(events.items[0].id));
8796 assert_eq!(
8797 events.items[0].payload["self_loop_edge_preimages"],
8798 serde_json::to_value(&summary.self_loop_edge_preimages).unwrap()
8799 );
8800 }
8801
8802 #[tokio::test]
8803 async fn merge_entity_drops_self_loops() {
8804 let rt = rt();
8805 let tok = NamespaceToken::local();
8806 let a = rt
8807 .create_entity(&tok, "concept", None, "A", None, None, vec![])
8808 .await
8809 .unwrap();
8810 let b = rt
8811 .create_entity(&tok, "concept", None, "B", None, None, vec![])
8812 .await
8813 .unwrap();
8814
8815 let edge = rt
8817 .link(
8818 &tok,
8819 a.id,
8820 b.id,
8821 EdgeRelation::Extends,
8822 0.6,
8823 Some(serde_json::json!({"basis": "shared lineage"})),
8824 )
8825 .await
8826 .unwrap();
8827
8828 let summary = rt
8829 .merge_entity_with_reason(
8830 &tok,
8831 a.id,
8832 b.id,
8833 EntityDedupMergePolicy::PreferInto,
8834 ContentMergeStrategy::Append,
8835 false,
8836 None,
8837 )
8838 .await
8839 .unwrap();
8840
8841 assert_eq!(
8842 summary.edges_rewired, 0,
8843 "self-loop should be dropped, not rewired"
8844 );
8845
8846 assert_eq!(summary.edges_self_loop_dropped, 1);
8850 let [preimage] = summary.self_loop_edge_preimages.as_slice() else {
8851 panic!(
8852 "expected exactly one self-loop preimage, got {:?}",
8853 summary.self_loop_edge_preimages
8854 );
8855 };
8856 assert_eq!(preimage.id, Uuid::from(edge.id));
8857 assert_eq!(preimage.source_id, a.id);
8858 assert_eq!(preimage.target_id, b.id);
8859 assert_eq!(preimage.relation, "extends");
8860 assert_eq!(preimage.weight, 0.6);
8861 assert_eq!(
8862 preimage.metadata,
8863 Some(serde_json::json!({"basis": "shared lineage"}))
8864 );
8865
8866 let a_out = rt
8867 .neighbors(&tok, a.id, Direction::Out, None, None)
8868 .await
8869 .unwrap();
8870 assert!(a_out.is_empty(), "no self-loop should remain");
8871
8872 let events = rt
8873 .events(&tok)
8874 .unwrap()
8875 .query_events(
8876 khive_storage::EventFilter {
8877 kinds: vec![EventKind::EntityMerged],
8878 ..Default::default()
8879 },
8880 khive_storage::types::PageRequest {
8881 offset: 0,
8882 limit: 10,
8883 },
8884 )
8885 .await
8886 .unwrap();
8887 assert_eq!(events.items.len(), 1);
8888 assert_eq!(
8889 events.items[0].payload["edges_self_loop_dropped"],
8890 serde_json::json!(1)
8891 );
8892 assert_eq!(
8893 events.items[0].payload["self_loop_edge_preimages"],
8894 serde_json::to_value(&summary.self_loop_edge_preimages).unwrap()
8895 );
8896 }
8897
8898 #[tokio::test]
8903 async fn merge_entity_self_loop_dry_run_matches_real_run() {
8904 let rt = rt();
8905 let tok = NamespaceToken::local();
8906 let into = rt
8907 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8908 .await
8909 .unwrap();
8910 let from = rt
8911 .create_entity(&tok, "concept", None, "From", None, None, vec![])
8912 .await
8913 .unwrap();
8914 let edge = rt
8915 .link(
8916 &tok,
8917 into.id,
8918 from.id,
8919 EdgeRelation::Extends,
8920 0.5,
8921 Some(serde_json::json!({"basis": "dry-run parity"})),
8922 )
8923 .await
8924 .unwrap();
8925
8926 let dry_summary = rt
8927 .merge_entity(
8928 &tok,
8929 into.id,
8930 from.id,
8931 EntityDedupMergePolicy::PreferInto,
8932 ContentMergeStrategy::Append,
8933 true,
8934 )
8935 .await
8936 .unwrap();
8937
8938 assert!(
8939 rt.get_edge_including_deleted(&tok, edge.id.into())
8940 .await
8941 .unwrap()
8942 .is_some(),
8943 "dry run must not delete the self-loop edge"
8944 );
8945
8946 let real_summary = rt
8947 .merge_entity(
8948 &tok,
8949 into.id,
8950 from.id,
8951 EntityDedupMergePolicy::PreferInto,
8952 ContentMergeStrategy::Append,
8953 false,
8954 )
8955 .await
8956 .unwrap();
8957
8958 assert_eq!(dry_summary.edges_self_loop_dropped, 1);
8959 let [dry_preimage] = dry_summary.self_loop_edge_preimages.as_slice() else {
8960 panic!(
8961 "expected exactly one predicted self-loop preimage, got {:?}",
8962 dry_summary.self_loop_edge_preimages
8963 );
8964 };
8965 assert_eq!(dry_preimage.id, Uuid::from(edge.id));
8966 assert_eq!(dry_preimage.source_id, into.id);
8967 assert_eq!(dry_preimage.target_id, from.id);
8968 assert_eq!(dry_preimage.relation, "extends");
8969 assert_eq!(dry_preimage.weight, 0.5);
8970 assert_eq!(
8971 dry_summary.edges_self_loop_dropped, real_summary.edges_self_loop_dropped,
8972 "a dry run must predict the same self-loop-drop count the committed merge produces"
8973 );
8974 assert_eq!(
8975 dry_summary.self_loop_edge_preimages, real_summary.self_loop_edge_preimages,
8976 "a dry run must predict the exact preimage the committed merge produces"
8977 );
8978
8979 assert!(
8980 rt.get_edge_including_deleted(&tok, edge.id.into())
8981 .await
8982 .unwrap()
8983 .is_none(),
8984 "the committed merge must actually delete the self-loop edge"
8985 );
8986 }
8987
8988 #[tokio::test]
8992 async fn merge_entity_no_self_loop_between_operands_reports_zero() {
8993 let rt = rt();
8994 let tok = NamespaceToken::local();
8995 let into = rt
8996 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
8997 .await
8998 .unwrap();
8999 let from = rt
9000 .create_entity(&tok, "concept", None, "From", None, None, vec![])
9001 .await
9002 .unwrap();
9003 let other = rt
9004 .create_entity(&tok, "concept", None, "Other", None, None, vec![])
9005 .await
9006 .unwrap();
9007
9008 rt.link(&tok, from.id, other.id, EdgeRelation::Extends, 1.0, None)
9009 .await
9010 .unwrap();
9011
9012 let summary = rt
9013 .merge_entity(
9014 &tok,
9015 into.id,
9016 from.id,
9017 EntityDedupMergePolicy::PreferInto,
9018 ContentMergeStrategy::Append,
9019 false,
9020 )
9021 .await
9022 .unwrap();
9023
9024 assert_eq!(
9025 summary.edges_rewired, 1,
9026 "the non-self-loop edge must still rewire"
9027 );
9028 assert_eq!(
9029 summary.edges_self_loop_dropped, 0,
9030 "no self-loop exists between the merge operands"
9031 );
9032 assert!(summary.self_loop_edge_preimages.is_empty());
9033 }
9034
9035 #[tokio::test]
9038 async fn merge_entity_append_strategy_concatenates_descriptions() {
9039 let rt = rt();
9040 let tok = NamespaceToken::local();
9041 let into = rt
9042 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9043 .await
9044 .unwrap();
9045 let from = rt
9046 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9047 .await
9048 .unwrap();
9049
9050 let summary = rt
9051 .merge_entity_with_reason(
9052 &tok,
9053 into.id,
9054 from.id,
9055 EntityDedupMergePolicy::PreferInto,
9056 ContentMergeStrategy::Append,
9057 false,
9058 None,
9059 )
9060 .await
9061 .unwrap();
9062
9063 assert!(
9064 summary.content_appended,
9065 "append strategy with two non-empty descriptions must report content_appended=true"
9066 );
9067 let kept = rt.get_entity(&tok, into.id).await.unwrap();
9068 assert_eq!(kept.description.as_deref(), Some("desc A\n\n---\n\ndesc B"));
9069 }
9070
9071 #[tokio::test]
9072 async fn merge_entity_append_strategy_from_empty_is_noop() {
9073 let rt = rt();
9074 let tok = NamespaceToken::local();
9075 let into = rt
9076 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9077 .await
9078 .unwrap();
9079 let from = rt
9080 .create_entity(&tok, "concept", None, "From", None, None, vec![])
9081 .await
9082 .unwrap();
9083
9084 let summary = rt
9085 .merge_entity_with_reason(
9086 &tok,
9087 into.id,
9088 from.id,
9089 EntityDedupMergePolicy::PreferInto,
9090 ContentMergeStrategy::Append,
9091 false,
9092 None,
9093 )
9094 .await
9095 .unwrap();
9096
9097 assert!(
9098 !summary.content_appended,
9099 "from's empty description means nothing was appended"
9100 );
9101 let kept = rt.get_entity(&tok, into.id).await.unwrap();
9102 assert_eq!(kept.description.as_deref(), Some("desc A"));
9103 }
9104
9105 #[tokio::test]
9106 async fn merge_entity_append_strategy_into_empty_takes_from() {
9107 let rt = rt();
9108 let tok = NamespaceToken::local();
9109 let into = rt
9110 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
9111 .await
9112 .unwrap();
9113 let from = rt
9114 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9115 .await
9116 .unwrap();
9117
9118 let summary = rt
9119 .merge_entity_with_reason(
9120 &tok,
9121 into.id,
9122 from.id,
9123 EntityDedupMergePolicy::PreferInto,
9124 ContentMergeStrategy::Append,
9125 false,
9126 None,
9127 )
9128 .await
9129 .unwrap();
9130
9131 assert!(
9132 summary.content_appended,
9133 "taking from's description into an empty into is real content preservation"
9134 );
9135 let kept = rt.get_entity(&tok, into.id).await.unwrap();
9136 assert_eq!(kept.description.as_deref(), Some("desc B"));
9137 }
9138
9139 #[tokio::test]
9140 async fn merge_entity_prefer_into_strategy_still_discards_explicitly() {
9141 let rt = rt();
9142 let tok = NamespaceToken::local();
9143 let into = rt
9144 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9145 .await
9146 .unwrap();
9147 let from = rt
9148 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9149 .await
9150 .unwrap();
9151
9152 let summary = rt
9153 .merge_entity_with_reason(
9154 &tok,
9155 into.id,
9156 from.id,
9157 EntityDedupMergePolicy::PreferInto,
9158 ContentMergeStrategy::PreferInto,
9159 false,
9160 None,
9161 )
9162 .await
9163 .unwrap();
9164
9165 assert!(
9166 !summary.content_appended,
9167 "explicit PreferInto opt-out must not report an append"
9168 );
9169 let kept = rt.get_entity(&tok, into.id).await.unwrap();
9170 assert_eq!(
9171 kept.description.as_deref(),
9172 Some("desc A"),
9173 "explicit PreferInto opt-out keeps the old discard behavior"
9174 );
9175 }
9176
9177 #[tokio::test]
9182 async fn merge_entity_prefer_from_content_strategy_wins_over_default_entity_policy() {
9183 let rt = rt();
9184 let tok = NamespaceToken::local();
9185 let into = rt
9186 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9187 .await
9188 .unwrap();
9189 let from = rt
9190 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9191 .await
9192 .unwrap();
9193
9194 let summary = rt
9195 .merge_entity_with_reason(
9196 &tok,
9197 into.id,
9198 from.id,
9199 EntityDedupMergePolicy::PreferInto,
9200 ContentMergeStrategy::PreferFrom,
9201 false,
9202 None,
9203 )
9204 .await
9205 .unwrap();
9206
9207 assert!(
9208 !summary.content_appended,
9209 "explicit PreferFrom is not an append"
9210 );
9211 let kept = rt.get_entity(&tok, into.id).await.unwrap();
9212 assert_eq!(
9213 kept.description.as_deref(),
9214 Some("desc B"),
9215 "content_strategy=prefer_from must win over the default prefer_into entity policy"
9216 );
9217 }
9218
9219 #[tokio::test]
9220 async fn merge_entity_dry_run_previews_append() {
9221 let rt = rt();
9222 let tok = NamespaceToken::local();
9223 let into = rt
9224 .create_entity(&tok, "concept", None, "Into", Some("desc A"), None, vec![])
9225 .await
9226 .unwrap();
9227 let from = rt
9228 .create_entity(&tok, "concept", None, "From", Some("desc B"), None, vec![])
9229 .await
9230 .unwrap();
9231
9232 let summary = rt
9233 .merge_entity_with_reason(
9234 &tok,
9235 into.id,
9236 from.id,
9237 EntityDedupMergePolicy::PreferInto,
9238 ContentMergeStrategy::Append,
9239 true,
9240 None,
9241 )
9242 .await
9243 .unwrap();
9244
9245 assert!(summary.dry_run);
9246 assert!(
9247 summary.content_appended,
9248 "dry-run must preview the append outcome without writing"
9249 );
9250 let kept = rt.get_entity(&tok, into.id).await.unwrap();
9251 assert_eq!(
9252 kept.description.as_deref(),
9253 Some("desc A"),
9254 "dry_run=true must not mutate the into entity's description"
9255 );
9256 }
9257
9258 #[tokio::test]
9261 async fn merge_entity_dry_run_predicts_edges_rewired_without_writing() {
9262 use khive_storage::EdgeRelation;
9263
9264 let rt = rt();
9265 let tok = NamespaceToken::local();
9266 let a = rt
9267 .create_entity(&tok, "concept", None, "A", None, None, vec![])
9268 .await
9269 .unwrap();
9270 let into = rt
9271 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
9272 .await
9273 .unwrap();
9274 let from = rt
9275 .create_entity(&tok, "concept", None, "From", None, None, vec![])
9276 .await
9277 .unwrap();
9278
9279 rt.link(&tok, a.id, from.id, EdgeRelation::Extends, 1.0, None)
9280 .await
9281 .unwrap();
9282
9283 let summary = rt
9284 .merge_entity_with_reason(
9285 &tok,
9286 into.id,
9287 from.id,
9288 EntityDedupMergePolicy::PreferInto,
9289 ContentMergeStrategy::Append,
9290 true,
9291 None,
9292 )
9293 .await
9294 .unwrap();
9295
9296 assert!(summary.dry_run);
9297 assert_eq!(
9298 summary.edges_rewired, 1,
9299 "dry-run must predict the edge that would be rewired, not report zero"
9300 );
9301
9302 let a_neighbors = rt
9303 .neighbors(&tok, a.id, Direction::Out, None, None)
9304 .await
9305 .unwrap();
9306 assert_eq!(a_neighbors.len(), 1);
9307 assert_eq!(
9308 a_neighbors[0].node_id, from.id,
9309 "dry_run=true must not rewire any edges"
9310 );
9311
9312 let events = rt
9313 .events(&tok)
9314 .unwrap()
9315 .query_events(
9316 khive_storage::EventFilter {
9317 kinds: vec![EventKind::EntityMerged],
9318 ..Default::default()
9319 },
9320 khive_storage::types::PageRequest {
9321 offset: 0,
9322 limit: 10,
9323 },
9324 )
9325 .await
9326 .unwrap();
9327 assert!(
9328 events.items.is_empty(),
9329 "dry_run=true must not append an EntityMerged event"
9330 );
9331 }
9332
9333 #[tokio::test]
9337 async fn merge_entity_event_reason_present_when_supplied_absent_when_not() {
9338 let rt = rt();
9339 let tok = NamespaceToken::local();
9340
9341 let into_a = rt
9342 .create_entity(&tok, "concept", None, "IntoA", None, None, vec![])
9343 .await
9344 .unwrap();
9345 let from_a = rt
9346 .create_entity(&tok, "concept", None, "FromA", None, None, vec![])
9347 .await
9348 .unwrap();
9349 rt.merge_entity_with_reason(
9350 &tok,
9351 into_a.id,
9352 from_a.id,
9353 EntityDedupMergePolicy::PreferInto,
9354 ContentMergeStrategy::Append,
9355 false,
9356 Some("duplicate".to_string()),
9357 )
9358 .await
9359 .unwrap();
9360
9361 let into_b = rt
9362 .create_entity(&tok, "concept", None, "IntoB", None, None, vec![])
9363 .await
9364 .unwrap();
9365 let from_b = rt
9366 .create_entity(&tok, "concept", None, "FromB", None, None, vec![])
9367 .await
9368 .unwrap();
9369 rt.merge_entity_with_reason(
9370 &tok,
9371 into_b.id,
9372 from_b.id,
9373 EntityDedupMergePolicy::PreferInto,
9374 ContentMergeStrategy::Append,
9375 false,
9376 None,
9377 )
9378 .await
9379 .unwrap();
9380
9381 let events = rt
9382 .events(&tok)
9383 .unwrap()
9384 .query_events(
9385 khive_storage::EventFilter {
9386 kinds: vec![EventKind::EntityMerged],
9387 ..Default::default()
9388 },
9389 khive_storage::types::PageRequest {
9390 offset: 0,
9391 limit: 10,
9392 },
9393 )
9394 .await
9395 .unwrap();
9396 assert_eq!(events.items.len(), 2);
9397
9398 let with_reason = events
9399 .items
9400 .iter()
9401 .find(|e| {
9402 e.payload.get("from_id").and_then(|v| v.as_str())
9403 == Some(from_a.id.to_string()).as_deref()
9404 })
9405 .expect("event for the reasoned merge must exist");
9406 assert_eq!(
9407 with_reason.payload.get("reason").and_then(|v| v.as_str()),
9408 Some("duplicate"),
9409 "reason must be threaded verbatim into the payload when supplied"
9410 );
9411
9412 let without_reason = events
9413 .items
9414 .iter()
9415 .find(|e| {
9416 e.payload.get("from_id").and_then(|v| v.as_str())
9417 == Some(from_b.id.to_string()).as_deref()
9418 })
9419 .expect("event for the reasonless merge must exist");
9420 assert!(
9421 without_reason.payload.get("reason").is_none(),
9422 "reason key must be absent (never null) when the caller omits it, got: {:?}",
9423 without_reason.payload
9424 );
9425 }
9426
9427 #[tokio::test]
9434 async fn a_forced_merge_event_names_the_acting_actor_not_an_empty_string() {
9435 async fn forced_merge_event_actor(actor_id: Option<&str>) -> (String, serde_json::Value) {
9436 let rt = KhiveRuntime::new(crate::RuntimeConfig {
9437 db_path: None,
9438 packs: vec!["kg".to_string()],
9439 brain_profile: None,
9440 actor_id: actor_id.map(str::to_string),
9441 ..crate::RuntimeConfig::no_embeddings()
9442 })
9443 .expect("runtime");
9444 let tok = rt.authorize(crate::Namespace::local()).expect("authorize");
9445 let into = rt
9446 .create_entity(&tok, "concept", None, "Flash Attention", None, None, vec![])
9447 .await
9448 .unwrap();
9449 let from = rt
9450 .create_entity(&tok, "concept", None, "Paged KV Cache", None, None, vec![])
9451 .await
9452 .unwrap();
9453 rt.merge_entity_with_reason_and_force(
9454 &tok,
9455 into.id,
9456 from.id,
9457 EntityDedupMergePolicy::PreferInto,
9458 ContentMergeStrategy::Append,
9459 false,
9460 None,
9461 true,
9462 )
9463 .await
9464 .expect("the floor refuses this pair, so only force lands it");
9465 let events = rt
9466 .events(&tok)
9467 .unwrap()
9468 .query_events(
9469 khive_storage::EventFilter {
9470 kinds: vec![EventKind::EntityMerged],
9471 ..Default::default()
9472 },
9473 khive_storage::types::PageRequest {
9474 offset: 0,
9475 limit: 10,
9476 },
9477 )
9478 .await
9479 .unwrap();
9480 assert_eq!(events.items.len(), 1, "one forced merge, one event");
9481 let event = &events.items[0];
9482 (event.actor.clone(), event.payload.clone())
9483 }
9484
9485 let (actor, payload) = forced_merge_event_actor(Some("merge-forcer")).await;
9486 assert_eq!(
9487 actor, "actor:merge-forcer",
9488 "the persisted event must name the actor the token carries"
9489 );
9490 assert_eq!(
9491 payload.get("force"),
9492 Some(&serde_json::Value::Bool(true)),
9493 "the force marker rides the same event: {payload}"
9494 );
9495
9496 let (anonymous, _) = forced_merge_event_actor(None).await;
9497 assert_eq!(
9498 anonymous, "anonymous:local",
9499 "an unconfigured runtime stamps the anonymous fallback, so the field \
9500 tracks the token rather than a constant"
9501 );
9502 }
9503
9504 #[tokio::test]
9505 async fn merge_entity_with_reason_preserves_an_explicit_empty_reason() {
9506 let rt = rt();
9507 let tok = NamespaceToken::local();
9508 let into = rt
9509 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
9510 .await
9511 .unwrap();
9512 let from = rt
9513 .create_entity(&tok, "concept", None, "From", None, None, vec![])
9514 .await
9515 .unwrap();
9516
9517 rt.merge_entity_with_reason(
9518 &tok,
9519 into.id,
9520 from.id,
9521 EntityDedupMergePolicy::PreferInto,
9522 ContentMergeStrategy::Append,
9523 false,
9524 Some(String::new()),
9525 )
9526 .await
9527 .unwrap();
9528
9529 let events = rt
9530 .events(&tok)
9531 .unwrap()
9532 .query_events(
9533 khive_storage::EventFilter {
9534 kinds: vec![EventKind::EntityMerged],
9535 ..Default::default()
9536 },
9537 khive_storage::types::PageRequest {
9538 offset: 0,
9539 limit: 10,
9540 },
9541 )
9542 .await
9543 .unwrap();
9544 assert_eq!(events.items.len(), 1);
9545 assert_eq!(
9546 events.items[0].payload.get("reason"),
9547 Some(&Value::String(String::new()))
9548 );
9549 }
9550
9551 #[tokio::test]
9552 async fn merge_entity_with_reason_rejects_secrets_before_reads_or_writes() {
9553 let rt = rt();
9554 let tok = NamespaceToken::local();
9555 let into = rt
9556 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
9557 .await
9558 .unwrap();
9559 let from = rt
9560 .create_entity(&tok, "concept", None, "From", None, None, vec![])
9561 .await
9562 .unwrap();
9563 let secret = secret_shaped_reason();
9564
9565 let error = rt
9566 .merge_entity_with_reason(
9567 &tok,
9568 into.id,
9569 from.id,
9570 EntityDedupMergePolicy::PreferInto,
9571 ContentMergeStrategy::Append,
9572 false,
9573 Some(secret),
9574 )
9575 .await
9576 .unwrap_err();
9577
9578 assert!(matches!(error, RuntimeError::SecretDetected(_)));
9579 assert_eq!(rt.get_entity(&tok, into.id).await.unwrap().id, into.id);
9580 assert_eq!(rt.get_entity(&tok, from.id).await.unwrap().id, from.id);
9581 let event_count = rt
9582 .events(&tok)
9583 .unwrap()
9584 .count_events(khive_storage::EventFilter {
9585 kinds: vec![EventKind::EntityMerged],
9586 ..Default::default()
9587 })
9588 .await
9589 .unwrap();
9590 assert_eq!(event_count, 0);
9591 }
9592
9593 #[tokio::test]
9596 async fn merge_note_emits_exactly_one_note_merged_event_with_kept_and_absorbed_ids() {
9597 let rt = rt();
9598 let tok = NamespaceToken::local();
9599 let into = rt
9600 .create_note(&tok, "observation", None, "into note", None, None, vec![])
9601 .await
9602 .unwrap();
9603 let from = rt
9604 .create_note(&tok, "observation", None, "from note", None, None, vec![])
9605 .await
9606 .unwrap();
9607
9608 let summary = rt
9609 .merge_note_with_reason(
9610 &tok,
9611 into.id,
9612 from.id,
9613 EntityDedupMergePolicy::PreferInto,
9614 ContentMergeStrategy::Append,
9615 false,
9616 Some("duplicate".to_string()),
9617 )
9618 .await
9619 .unwrap();
9620
9621 let events = rt
9622 .events(&tok)
9623 .unwrap()
9624 .query_events(
9625 khive_storage::EventFilter {
9626 kinds: vec![EventKind::NoteMerged],
9627 ..Default::default()
9628 },
9629 khive_storage::types::PageRequest {
9630 offset: 0,
9631 limit: 10,
9632 },
9633 )
9634 .await
9635 .unwrap();
9636 assert_eq!(
9637 events.items.len(),
9638 1,
9639 "merge_note must emit exactly one NoteMerged event"
9640 );
9641
9642 let payload = &events.items[0].payload;
9643 assert_eq!(
9644 payload.get("into_id").and_then(|v| v.as_str()),
9645 Some(summary.kept_id.to_string()).as_deref()
9646 );
9647 assert_eq!(
9648 payload.get("from_id").and_then(|v| v.as_str()),
9649 Some(summary.removed_id.to_string()).as_deref()
9650 );
9651 assert_eq!(
9652 payload.get("reason").and_then(|v| v.as_str()),
9653 Some("duplicate")
9654 );
9655 }
9656
9657 #[tokio::test]
9658 async fn merge_note_returns_committed_summary_when_post_commit_reindex_fails() {
9659 use crate::operations::arm_fts_fail_scoped;
9660
9661 const DIMS: usize = 4;
9662 let rt = rt();
9663 rt.register_embedder(MergeTestVecProvider::new(
9664 "merge-note-reindex-failure",
9665 DIMS,
9666 ));
9667 let namespace = format!("merge-reindex-failure-{}", Uuid::new_v4().as_simple());
9668 let tok = NamespaceToken::for_namespace(crate::Namespace::parse(&namespace).unwrap());
9669 let into = rt
9670 .create_note(
9671 &tok,
9672 "observation",
9673 None,
9674 "survivor content",
9675 None,
9676 Some(serde_json::json!({"survivor": "retained"})),
9677 vec![],
9678 )
9679 .await
9680 .expect("create survivor note");
9681 let from = rt
9682 .create_note(
9683 &tok,
9684 "observation",
9685 None,
9686 "source content",
9687 None,
9688 Some(serde_json::json!({"merged": "source"})),
9689 vec![],
9690 )
9691 .await
9692 .expect("create source note");
9693
9694 let observed_by_hook = Arc::new(Mutex::new(Vec::new()));
9695 let hook_observations = Arc::clone(&observed_by_hook);
9696 let event_store = rt.events(&tok).expect("event store");
9697 rt.install_note_mutation_hook(Arc::new(move |kind, id| {
9698 let hook_observations = Arc::clone(&hook_observations);
9699 let event_store = Arc::clone(&event_store);
9700 Box::pin(async move {
9701 let events = event_store
9702 .query_events(
9703 khive_storage::EventFilter {
9704 kinds: vec![EventKind::NoteMerged],
9705 ..Default::default()
9706 },
9707 khive_storage::types::PageRequest {
9708 offset: 0,
9709 limit: 10,
9710 },
9711 )
9712 .await
9713 .expect("read merge event from mutation hook");
9714 hook_observations
9715 .lock()
9716 .unwrap()
9717 .push((kind, id, !events.items.is_empty()));
9718 })
9719 }));
9720
9721 let _arm = arm_fts_fail_scoped(&namespace);
9722 let outcome = rt
9723 .merge_note(
9724 &tok,
9725 into.id,
9726 from.id,
9727 EntityDedupMergePolicy::Union,
9728 ContentMergeStrategy::Append,
9729 false,
9730 )
9731 .await;
9732 assert!(
9733 outcome.is_ok(),
9734 "a committed merge must return its summary after reindexing fails: {outcome:?}"
9735 );
9736 let summary = outcome.expect("the committed merge summary must be returned");
9737 assert_eq!(summary.kept_id, into.id);
9738 assert_eq!(summary.removed_id, from.id);
9739 assert!(
9740 summary
9741 .post_commit_reindex_error
9742 .as_deref()
9743 .is_some_and(|error| error.contains("injected FTS failure")),
9744 "the summary must report the post-commit reindex failure: {:?}",
9745 summary.post_commit_reindex_error
9746 );
9747
9748 assert_eq!(
9749 observed_by_hook.lock().unwrap().as_slice(),
9750 &[("observation".to_string(), into.id, true)],
9751 "the note mutation hook must observe the committed merge event"
9752 );
9753
9754 let note_store = rt.notes(&tok).expect("note store");
9755 let survivor = note_store
9756 .get_note(into.id)
9757 .await
9758 .expect("read survivor")
9759 .expect("survivor remains live");
9760 assert_eq!(
9761 survivor.content,
9762 "survivor content\n\n---\n\nsource content"
9763 );
9764 let properties = survivor.properties.expect("merged properties");
9765 assert_eq!(properties["survivor"], "retained");
9766 assert_eq!(properties["merged"], "source");
9767
9768 let removed = note_store
9769 .get_note_including_deleted(from.id)
9770 .await
9771 .expect("read merge tombstone")
9772 .expect("source row is retained as a tombstone");
9773 assert_eq!(removed.status, "deleted");
9774 assert!(removed.deleted_at.is_some());
9775
9776 let events = rt
9777 .events(&tok)
9778 .expect("event store")
9779 .query_events(
9780 khive_storage::EventFilter {
9781 kinds: vec![EventKind::NoteMerged],
9782 ..Default::default()
9783 },
9784 khive_storage::types::PageRequest {
9785 offset: 0,
9786 limit: 10,
9787 },
9788 )
9789 .await
9790 .expect("read merge event");
9791 assert_eq!(
9792 events.items.len(),
9793 1,
9794 "the merge event must be recorded when reindexing fails"
9795 );
9796 assert_eq!(
9797 events.items[0]
9798 .payload
9799 .get("into_id")
9800 .and_then(|v| v.as_str()),
9801 Some(summary.kept_id.to_string()).as_deref()
9802 );
9803 assert_eq!(
9804 events.items[0]
9805 .payload
9806 .get("from_id")
9807 .and_then(|v| v.as_str()),
9808 Some(summary.removed_id.to_string()).as_deref()
9809 );
9810 }
9811
9812 #[tokio::test]
9813 async fn merge_note_fires_mutation_hook_without_embedding_models() {
9814 let rt = rt();
9815 let tok = NamespaceToken::local();
9816 let into = rt
9817 .create_note(&tok, "observation", None, "survivor", None, None, vec![])
9818 .await
9819 .expect("create survivor note");
9820 let from = rt
9821 .create_note(&tok, "observation", None, "source", None, None, vec![])
9822 .await
9823 .expect("create source note");
9824 let hook_calls = Arc::new(Mutex::new(Vec::new()));
9825 let observed_calls = Arc::clone(&hook_calls);
9826 rt.install_note_mutation_hook(Arc::new(move |kind, id| {
9827 let observed_calls = Arc::clone(&observed_calls);
9828 Box::pin(async move { observed_calls.lock().unwrap().push((kind, id)) })
9829 }));
9830
9831 rt.merge_note(
9832 &tok,
9833 into.id,
9834 from.id,
9835 EntityDedupMergePolicy::PreferInto,
9836 ContentMergeStrategy::Append,
9837 false,
9838 )
9839 .await
9840 .expect("merge note without registered embedding models");
9841
9842 assert_eq!(
9843 hook_calls.lock().unwrap().as_slice(),
9844 &[("observation".to_string(), into.id)],
9845 "every committed note merge must notify mutation hooks without embedding models"
9846 );
9847 }
9848
9849 #[tokio::test]
9850 async fn merge_note_with_reason_preserves_an_explicit_empty_reason() {
9851 let rt = rt();
9852 let tok = NamespaceToken::local();
9853 let into = rt
9854 .create_note(&tok, "observation", None, "into note", None, None, vec![])
9855 .await
9856 .unwrap();
9857 let from = rt
9858 .create_note(&tok, "observation", None, "from note", None, None, vec![])
9859 .await
9860 .unwrap();
9861
9862 rt.merge_note_with_reason(
9863 &tok,
9864 into.id,
9865 from.id,
9866 EntityDedupMergePolicy::PreferInto,
9867 ContentMergeStrategy::Append,
9868 false,
9869 Some(String::new()),
9870 )
9871 .await
9872 .unwrap();
9873
9874 let events = rt
9875 .events(&tok)
9876 .unwrap()
9877 .query_events(
9878 khive_storage::EventFilter {
9879 kinds: vec![EventKind::NoteMerged],
9880 ..Default::default()
9881 },
9882 khive_storage::types::PageRequest {
9883 offset: 0,
9884 limit: 10,
9885 },
9886 )
9887 .await
9888 .unwrap();
9889 assert_eq!(events.items.len(), 1);
9890 assert_eq!(
9891 events.items[0].payload.get("reason"),
9892 Some(&Value::String(String::new()))
9893 );
9894 }
9895
9896 #[tokio::test]
9897 async fn merge_note_with_reason_rejects_secrets_before_reads_or_writes() {
9898 let rt = rt();
9899 let tok = NamespaceToken::local();
9900 let into = rt
9901 .create_note(&tok, "observation", None, "into note", None, None, vec![])
9902 .await
9903 .unwrap();
9904 let from = rt
9905 .create_note(&tok, "observation", None, "from note", None, None, vec![])
9906 .await
9907 .unwrap();
9908 let secret = secret_shaped_reason();
9909
9910 let error = rt
9911 .merge_note_with_reason(
9912 &tok,
9913 into.id,
9914 from.id,
9915 EntityDedupMergePolicy::PreferInto,
9916 ContentMergeStrategy::Append,
9917 false,
9918 Some(secret),
9919 )
9920 .await
9921 .unwrap_err();
9922
9923 assert!(matches!(error, RuntimeError::SecretDetected(_)));
9924 let note_store = rt.notes(&tok).unwrap();
9925 assert_eq!(
9926 note_store.get_note(into.id).await.unwrap().unwrap().id,
9927 into.id
9928 );
9929 assert_eq!(
9930 note_store.get_note(from.id).await.unwrap().unwrap().id,
9931 from.id
9932 );
9933 let event_count = rt
9934 .events(&tok)
9935 .unwrap()
9936 .count_events(khive_storage::EventFilter {
9937 kinds: vec![EventKind::NoteMerged],
9938 ..Default::default()
9939 })
9940 .await
9941 .unwrap();
9942 assert_eq!(event_count, 0);
9943 }
9944
9945 #[tokio::test]
9946 async fn legacy_merge_methods_remain_source_compatible() {
9947 let rt = rt();
9948 let tok = NamespaceToken::local();
9949 let into_entity = rt
9950 .create_entity(&tok, "concept", None, "Entity A", None, None, vec![])
9951 .await
9952 .unwrap();
9953 let from_entity = rt
9954 .create_entity(&tok, "concept", None, "Entity B", None, None, vec![])
9955 .await
9956 .unwrap();
9957 let into_note = rt
9958 .create_note(&tok, "observation", None, "note A", None, None, vec![])
9959 .await
9960 .unwrap();
9961 let from_note = rt
9962 .create_note(&tok, "observation", None, "note B", None, None, vec![])
9963 .await
9964 .unwrap();
9965
9966 rt.merge_entity(
9967 &tok,
9968 into_entity.id,
9969 from_entity.id,
9970 EntityDedupMergePolicy::PreferInto,
9971 ContentMergeStrategy::Append,
9972 false,
9973 )
9974 .await
9975 .unwrap();
9976 rt.merge_note(
9977 &tok,
9978 into_note.id,
9979 from_note.id,
9980 EntityDedupMergePolicy::PreferInto,
9981 ContentMergeStrategy::Append,
9982 false,
9983 )
9984 .await
9985 .unwrap();
9986 }
9987
9988 #[tokio::test]
9991 async fn get_entity_after_merge_discloses_kept_id() {
9992 let rt = rt();
9993 let tok = NamespaceToken::local();
9994 let into = rt
9995 .create_entity(&tok, "concept", None, "Kept", None, None, vec![])
9996 .await
9997 .unwrap();
9998 let from = rt
9999 .create_entity(&tok, "concept", None, "Absorbed", None, None, vec![])
10000 .await
10001 .unwrap();
10002
10003 rt.merge_entity(
10004 &tok,
10005 into.id,
10006 from.id,
10007 EntityDedupMergePolicy::PreferInto,
10008 ContentMergeStrategy::Append,
10009 false,
10010 )
10011 .await
10012 .unwrap();
10013
10014 let err = rt.get_entity(&tok, from.id).await.unwrap_err();
10015 let msg = err.to_string();
10016 assert!(
10017 msg.contains("was merged into") && msg.contains(&into.id.to_string()),
10018 "expected a merged_into disclosure naming {}, got {msg:?}",
10019 into.id
10020 );
10021 }
10022
10023 #[tokio::test]
10027 async fn restore_names_a_live_row_that_still_carries_merged_into() {
10028 let rt = rt();
10029 let tok = NamespaceToken::local();
10030 let into = rt
10031 .create_entity(&tok, "concept", None, "Kept", None, None, vec![])
10032 .await
10033 .unwrap();
10034 let from = rt
10035 .create_entity(&tok, "concept", None, "Absorbed", None, None, vec![])
10036 .await
10037 .unwrap();
10038 rt.merge_entity(
10039 &tok,
10040 into.id,
10041 from.id,
10042 EntityDedupMergePolicy::PreferInto,
10043 ContentMergeStrategy::Append,
10044 false,
10045 )
10046 .await
10047 .unwrap();
10048 let mut writer = rt.sql().writer().await.expect("sql writer");
10051 let cleared = writer
10052 .execute(khive_storage::SqlStatement {
10053 sql: "UPDATE entities SET version = version + 1, deleted_at = NULL \
10054 WHERE id = ?1 AND merged_into IS NOT NULL"
10055 .to_string(),
10056 params: vec![SqlValue::Text(from.id.to_string())],
10057 label: None,
10058 })
10059 .await
10060 .expect("seed the pre-guard state");
10061 assert_eq!(
10062 cleared, 1,
10063 "control: the seed must have found the merge tombstone"
10064 );
10065 drop(writer);
10066
10067 let err = rt.restore_entity(&tok, from.id).await.unwrap_err();
10068 let msg = err.to_string();
10069 assert!(
10070 msg.contains("live_merged_entity") && msg.contains(&into.id.to_string()),
10071 "restore of a live merged row must be named, not reported already live, got {msg:?}"
10072 );
10073
10074 let row = rt
10076 .get_entity_including_deleted(&tok, from.id)
10077 .await
10078 .unwrap()
10079 .expect("row exists");
10080 assert!(row.deleted_at.is_none());
10081 assert_eq!(row.merged_into, Some(into.id));
10082 }
10083
10084 #[tokio::test]
10085 async fn restore_refuses_a_merge_tombstone_and_keeps_the_disclosure() {
10086 let rt = rt();
10087 let tok = NamespaceToken::local();
10088 let into = rt
10089 .create_entity(&tok, "concept", None, "Kept", None, None, vec![])
10090 .await
10091 .unwrap();
10092 let from = rt
10093 .create_entity(&tok, "concept", None, "Absorbed", None, None, vec![])
10094 .await
10095 .unwrap();
10096 rt.merge_entity(
10097 &tok,
10098 into.id,
10099 from.id,
10100 EntityDedupMergePolicy::PreferInto,
10101 ContentMergeStrategy::Append,
10102 false,
10103 )
10104 .await
10105 .unwrap();
10106
10107 let err = rt.restore_entity(&tok, from.id).await.unwrap_err();
10108 let msg = err.to_string();
10109 assert!(
10110 msg.contains("merge_tombstone") && msg.contains(&into.id.to_string()),
10111 "restore of a merge tombstone must be refused naming the kept id, got {msg:?}"
10112 );
10113
10114 let err = rt.get_entity(&tok, from.id).await.unwrap_err();
10116 let msg = err.to_string();
10117 assert!(
10118 msg.contains("was merged into") && msg.contains(&into.id.to_string()),
10119 "after a refused restore the merged_into disclosure must survive, got {msg:?}"
10120 );
10121 let tombstone = rt
10122 .get_entity_including_deleted(&tok, from.id)
10123 .await
10124 .unwrap()
10125 .expect("tombstone row still present");
10126 assert!(tombstone.deleted_at.is_some());
10127 assert_eq!(tombstone.merged_into, Some(into.id));
10128 }
10129
10130 #[tokio::test]
10131 async fn merge_tombstone_carries_the_id_of_its_merge_event() {
10132 let rt = rt();
10133 let tok = NamespaceToken::local();
10134 let into = rt
10135 .create_entity(&tok, "concept", None, "Kept", None, None, vec![])
10136 .await
10137 .unwrap();
10138 let from = rt
10139 .create_entity(&tok, "concept", None, "Absorbed", None, None, vec![])
10140 .await
10141 .unwrap();
10142 rt.merge_entity(
10143 &tok,
10144 into.id,
10145 from.id,
10146 EntityDedupMergePolicy::PreferInto,
10147 ContentMergeStrategy::Append,
10148 false,
10149 )
10150 .await
10151 .unwrap();
10152
10153 let events = rt
10154 .events(&tok)
10155 .unwrap()
10156 .query_events(
10157 khive_storage::EventFilter {
10158 kinds: vec![EventKind::EntityMerged],
10159 ..Default::default()
10160 },
10161 khive_storage::types::PageRequest {
10162 offset: 0,
10163 limit: 10,
10164 },
10165 )
10166 .await
10167 .unwrap();
10168 let [event] = events.items.as_slice() else {
10169 panic!(
10170 "expected one EntityMerged event, got {}",
10171 events.items.len()
10172 );
10173 };
10174 assert_eq!(event.payload["from_id"], serde_json::json!(from.id));
10175
10176 let tombstone = rt
10177 .get_entity_including_deleted(&tok, from.id)
10178 .await
10179 .unwrap()
10180 .expect("tombstone row still present");
10181 assert_eq!(
10182 tombstone.merge_event_id,
10183 Some(event.id),
10184 "the tombstone must name the event that recorded its merge"
10185 );
10186 let kept = rt.get_entity(&tok, into.id).await.unwrap();
10187 assert_eq!(kept.merge_event_id, None);
10188 }
10189
10190 #[tokio::test]
10191 async fn get_entity_on_plain_soft_delete_stays_bare_not_found() {
10192 let rt = rt();
10193 let tok = NamespaceToken::local();
10194 let entity = rt
10195 .create_entity(&tok, "concept", None, "Deleted", None, None, vec![])
10196 .await
10197 .unwrap();
10198 assert!(rt.delete_entity(&tok, entity.id, false).await.unwrap());
10199
10200 let err = rt.get_entity(&tok, entity.id).await.unwrap_err();
10201 let msg = err.to_string();
10202 assert!(
10203 !msg.contains("merged into"),
10204 "plain soft-delete must not gain a merge hint, got {msg:?}"
10205 );
10206 assert_eq!(msg, format!("not found: entity {}", entity.id));
10207 }
10208
10209 #[tokio::test]
10210 async fn get_entity_on_absent_id_stays_bare_not_found() {
10211 let rt = rt();
10212 let tok = NamespaceToken::local();
10213 let absent = Uuid::new_v4();
10214
10215 let err = rt.get_entity(&tok, absent).await.unwrap_err();
10216 let msg = err.to_string();
10217 assert!(
10218 !msg.contains("merged into"),
10219 "a never-existed id must not gain a merge hint, got {msg:?}"
10220 );
10221 assert_eq!(msg, format!("not found: entity {absent}"));
10222 }
10223
10224 #[test]
10227 fn union_tags_deduplicates() {
10228 let (tags, added) = union_tags(
10229 &["x".to_string(), "y".to_string()],
10230 &["y".to_string(), "z".to_string()],
10231 );
10232 let mut sorted = tags.clone();
10233 sorted.sort();
10234 assert_eq!(sorted, vec!["x", "y", "z"]);
10235 assert_eq!(added, 1);
10236 }
10237
10238 #[test]
10239 fn merge_properties_prefer_into_fills_missing_keys() {
10240 let a = serde_json::json!({"a": 1});
10241 let b = serde_json::json!({"a": 99, "b": 2});
10242 let (merged, added) =
10243 merge_properties(&Some(a), &Some(b), EntityDedupMergePolicy::PreferInto);
10244 let m = merged.unwrap();
10245 assert_eq!(m["a"], 1);
10246 assert_eq!(m["b"], 2);
10247 assert_eq!(added, 1);
10248 }
10249
10250 #[tokio::test]
10253 async fn merge_entity_tombstones_source_with_provenance() {
10254 let rt = rt();
10255 let tok = NamespaceToken::local();
10256 let into = rt
10257 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
10258 .await
10259 .unwrap();
10260 let from = rt
10261 .create_entity(&tok, "concept", None, "From", None, None, vec![])
10262 .await
10263 .unwrap();
10264 let from_id = from.id;
10265
10266 rt.merge_entity_with_reason(
10267 &tok,
10268 into.id,
10269 from_id,
10270 EntityDedupMergePolicy::PreferInto,
10271 ContentMergeStrategy::Append,
10272 false,
10273 None,
10274 )
10275 .await
10276 .unwrap();
10277
10278 assert!(
10279 rt.get_entity(&tok, from_id).await.is_err(),
10280 "tombstoned source should not be returned by get_entity"
10281 );
10282
10283 let pool = rt.backend().pool_arc();
10284 let (deleted_at, merged_into): (Option<i64>, Option<String>) =
10285 tokio::task::spawn_blocking(move || {
10286 let guard = pool.writer().unwrap();
10287 guard
10288 .conn()
10289 .query_row(
10290 "SELECT deleted_at, merged_into FROM entities WHERE id = ?1",
10291 [from_id.to_string()],
10292 |row| Ok((row.get(0)?, row.get(1)?)),
10293 )
10294 .unwrap()
10295 })
10296 .await
10297 .unwrap();
10298 assert!(
10299 deleted_at.is_some(),
10300 "tombstoned entity must have deleted_at set"
10301 );
10302 assert_eq!(
10303 merged_into.as_deref(),
10304 Some(into.id.to_string().as_str()),
10305 "merged_into must point to into_id"
10306 );
10307 }
10308
10309 #[tokio::test]
10310 async fn generic_update_and_merge_reject_schedule_managed_notes() {
10311 let rt = rt();
10312 let tok = NamespaceToken::local();
10313 let schedule_a = rt
10314 .create_note(
10315 &tok,
10316 "scheduled_event",
10317 None,
10318 "stats()",
10319 None,
10320 Some(serde_json::json!({
10321 "event_type": "schedule",
10322 "payload": "stats()",
10323 "status": "pending",
10324 "trigger_at": "2099-01-01T00:00:00Z"
10325 })),
10326 vec![],
10327 )
10328 .await
10329 .unwrap();
10330 let schedule_b = rt
10331 .create_note(
10332 &tok,
10333 "scheduled_event",
10334 None,
10335 "stats()",
10336 None,
10337 Some(serde_json::json!({
10338 "event_type": "schedule",
10339 "payload": "stats()",
10340 "status": "pending",
10341 "trigger_at": "2099-01-02T00:00:00Z"
10342 })),
10343 vec![],
10344 )
10345 .await
10346 .unwrap();
10347
10348 let update_error = rt
10349 .update_note(
10350 &tok,
10351 schedule_a.id,
10352 NotePatch::new(
10353 None,
10354 None,
10355 None,
10356 None,
10357 Some(serde_json::json!({ "payload": "delete(id=\"victim\")" })),
10358 ),
10359 )
10360 .await
10361 .expect_err("schedule-managed note update must fail");
10362 assert!(
10363 update_error.to_string().contains("schedule-managed"),
10364 "{update_error}"
10365 );
10366
10367 for (into_id, from_id) in [
10368 (schedule_a.id, schedule_b.id),
10369 (schedule_b.id, schedule_a.id),
10370 ] {
10371 let merge_error = rt
10372 .merge_note(
10373 &tok,
10374 into_id,
10375 from_id,
10376 EntityDedupMergePolicy::PreferFrom,
10377 ContentMergeStrategy::PreferFrom,
10378 false,
10379 )
10380 .await
10381 .expect_err("either schedule-managed merge operand must fail");
10382 assert!(
10383 merge_error.to_string().contains("schedule-managed"),
10384 "{merge_error}"
10385 );
10386 }
10387
10388 let store = rt.notes(&tok).unwrap();
10389 for (id, trigger_at) in [
10390 (schedule_a.id, "2099-01-01T00:00:00Z"),
10391 (schedule_b.id, "2099-01-02T00:00:00Z"),
10392 ] {
10393 let note = store
10394 .get_note(id)
10395 .await
10396 .unwrap()
10397 .expect("rejected generic mutation leaves the schedule intact");
10398 assert_eq!(note.properties.as_ref().unwrap()["trigger_at"], trigger_at);
10399 }
10400 }
10401
10402 #[tokio::test]
10403 async fn merge_note_refuses_quarantined_message_in_either_role() {
10404 let rt = rt();
10405 let tok = NamespaceToken::local();
10406 let capability = crate::pack::ChannelIngestCapability { _sealed: () };
10407 let ordinary = rt
10408 .create_note(
10409 &tok,
10410 "message",
10411 None,
10412 "ordinary message",
10413 None,
10414 None,
10415 vec![],
10416 )
10417 .await
10418 .unwrap();
10419 let quarantined = rt
10420 .try_create_note_as_trusted_ingest(
10421 &capability,
10422 &tok,
10423 "message",
10424 None,
10425 "quarantined transport content",
10426 Some(serde_json::json!({"quarantined": true})),
10427 )
10428 .await
10429 .unwrap()
10430 .expect("quarantined insert");
10431
10432 for (into_id, from_id) in [(ordinary.id, quarantined.id), (quarantined.id, ordinary.id)] {
10433 let error = rt
10434 .merge_note(
10435 &tok,
10436 into_id,
10437 from_id,
10438 EntityDedupMergePolicy::PreferFrom,
10439 ContentMergeStrategy::Append,
10440 false,
10441 )
10442 .await
10443 .expect_err("a quarantined message must not merge in either role");
10444 assert!(error.to_string().contains("quarantined"), "{error}");
10445 }
10446
10447 let store = rt.notes(&tok).unwrap();
10449 let kept = store
10450 .get_note(quarantined.id)
10451 .await
10452 .unwrap()
10453 .expect("quarantined note intact");
10454 assert_eq!(
10455 kept.properties.as_ref().unwrap()["quarantined"],
10456 serde_json::json!(true)
10457 );
10458 assert_eq!(kept.content, "quarantined transport content");
10459 }
10460
10461 #[tokio::test]
10462 async fn merge_note_refuses_string_encoded_quarantine_marker() {
10463 let rt = rt();
10464 let tok = NamespaceToken::local();
10465 let capability = crate::pack::ChannelIngestCapability { _sealed: () };
10466 let ordinary = rt
10467 .create_note(
10468 &tok,
10469 "message",
10470 None,
10471 "ordinary message",
10472 None,
10473 None,
10474 vec![],
10475 )
10476 .await
10477 .unwrap();
10478 let quarantined = rt
10480 .try_create_note_as_trusted_ingest(
10481 &capability,
10482 &tok,
10483 "message",
10484 None,
10485 "string-marked quarantined content",
10486 Some(serde_json::json!({"quarantined": "true"})),
10487 )
10488 .await
10489 .unwrap()
10490 .expect("quarantined insert");
10491
10492 let error = rt
10493 .merge_note(
10494 &tok,
10495 ordinary.id,
10496 quarantined.id,
10497 EntityDedupMergePolicy::PreferFrom,
10498 ContentMergeStrategy::Append,
10499 false,
10500 )
10501 .await
10502 .expect_err("string-encoded quarantine marker must also refuse the merge");
10503 assert!(error.to_string().contains("quarantined"), "{error}");
10504 }
10505
10506 #[tokio::test]
10507 async fn merge_note_still_merges_unquarantined_messages() {
10508 let rt = rt();
10509 let tok = NamespaceToken::local();
10510 let into = rt
10511 .create_note(&tok, "message", None, "into message", None, None, vec![])
10512 .await
10513 .unwrap();
10514 let from = rt
10515 .create_note(&tok, "message", None, "from message", None, None, vec![])
10516 .await
10517 .unwrap();
10518 rt.merge_note(
10519 &tok,
10520 into.id,
10521 from.id,
10522 EntityDedupMergePolicy::PreferInto,
10523 ContentMergeStrategy::Append,
10524 false,
10525 )
10526 .await
10527 .expect("ordinary message merge must still work");
10528 }
10529
10530 #[tokio::test]
10531 async fn merge_note_same_kind_appends_content() {
10532 let rt = rt();
10533 let tok = NamespaceToken::local();
10534 let into = rt
10535 .create_note(
10536 &tok,
10537 "observation",
10538 None,
10539 "Into content",
10540 None,
10541 None,
10542 vec![],
10543 )
10544 .await
10545 .unwrap();
10546 let from = rt
10547 .create_note(
10548 &tok,
10549 "observation",
10550 None,
10551 "From content",
10552 None,
10553 None,
10554 vec![],
10555 )
10556 .await
10557 .unwrap();
10558 let from_id = from.id;
10559
10560 let summary = rt
10561 .merge_note_with_reason(
10562 &tok,
10563 into.id,
10564 from_id,
10565 EntityDedupMergePolicy::PreferInto,
10566 ContentMergeStrategy::Append,
10567 false,
10568 None,
10569 )
10570 .await
10571 .unwrap();
10572
10573 assert_eq!(summary.kept_id, into.id);
10574 assert_eq!(summary.removed_id, from_id);
10575 assert!(summary.content_appended);
10576 assert!(!summary.dry_run);
10577
10578 let from_store = rt.notes(&tok).unwrap();
10579 assert!(
10580 from_store.get_note(from_id).await.unwrap().is_none(),
10581 "merged-from note should be soft-deleted"
10582 );
10583 }
10584
10585 #[tokio::test]
10586 async fn merge_note_preserves_the_kept_memory_key() {
10587 use crate::keyed_memory::{create_keyed_memory, KeyedMemorySpec};
10588
10589 let rt = rt();
10590 let tok = NamespaceToken::local();
10591 let (into, _, _) = create_keyed_memory(
10592 &rt,
10593 &tok,
10594 KeyedMemorySpec {
10595 content: "Into keyed memory",
10596 key: "kept-memory-key",
10597 salience: 0.7,
10598 decay_factor: 0.0,
10599 properties: serde_json::json!({}),
10600 source_id: None,
10601 embedding_model: None,
10602 },
10603 )
10604 .await
10605 .unwrap();
10606 let from = rt
10607 .create_note(&tok, "memory", None, "From memory", None, None, vec![])
10608 .await
10609 .unwrap();
10610
10611 let summary = rt
10612 .merge_note(
10613 &tok,
10614 into.id,
10615 from.id,
10616 EntityDedupMergePolicy::PreferInto,
10617 ContentMergeStrategy::Append,
10618 false,
10619 )
10620 .await
10621 .expect("merge binds every stored note field");
10622 assert_eq!(summary.kept_id, into.id);
10623 let stored = rt
10624 .notes(&tok)
10625 .unwrap()
10626 .get_note(into.id)
10627 .await
10628 .unwrap()
10629 .unwrap();
10630 assert_eq!(stored.key.as_deref(), Some("kept-memory-key"));
10631 assert!(stored.content.contains("Into keyed memory"));
10632 assert!(stored.content.contains("From memory"));
10633 assert!(rt
10634 .notes(&tok)
10635 .unwrap()
10636 .get_note(from.id)
10637 .await
10638 .unwrap()
10639 .is_none());
10640 }
10641
10642 #[tokio::test]
10645 async fn merge_note_survives_shared_edge_to_third_party() {
10646 use khive_storage::EdgeRelation;
10647 let rt = rt();
10648 let tok = NamespaceToken::local();
10649
10650 let into = rt
10651 .create_note(&tok, "observation", None, "Into", None, None, vec![])
10652 .await
10653 .unwrap();
10654 let from = rt
10655 .create_note(&tok, "observation", None, "From", None, None, vec![])
10656 .await
10657 .unwrap();
10658 let shared = rt
10659 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
10660 .await
10661 .unwrap();
10662
10663 rt.link(&tok, into.id, shared.id, EdgeRelation::Annotates, 1.0, None)
10667 .await
10668 .unwrap();
10669 rt.link(&tok, from.id, shared.id, EdgeRelation::Annotates, 1.0, None)
10670 .await
10671 .unwrap();
10672
10673 let summary = rt
10674 .merge_note_with_reason(
10675 &tok,
10676 into.id,
10677 from.id,
10678 EntityDedupMergePolicy::PreferInto,
10679 ContentMergeStrategy::Append,
10680 false,
10681 None,
10682 )
10683 .await
10684 .expect("merge must succeed even when both notes annotate the same entity");
10685
10686 assert_eq!(summary.kept_id, into.id);
10687 assert_eq!(summary.removed_id, from.id);
10688
10689 let into_edges = rt
10690 .list_edges(
10691 &tok,
10692 crate::EdgeListFilter {
10693 source_id: Some(into.id),
10694 target_id: Some(shared.id),
10695 relations: vec![EdgeRelation::Annotates],
10696 ..Default::default()
10697 },
10698 10,
10699 0,
10700 )
10701 .await
10702 .unwrap();
10703 assert_eq!(
10704 into_edges.len(),
10705 1,
10706 "exactly one live into→shared annotates edge must exist after merge; got: {into_edges:?}"
10707 );
10708 }
10709
10710 #[tokio::test]
10711 async fn merge_note_conflict_records_dropped_edge_and_cascades_annotation() {
10712 let rt = rt();
10713 let tok = NamespaceToken::local();
10714 let into = rt
10715 .create_note(&tok, "observation", None, "Into", None, None, vec![])
10716 .await
10717 .unwrap();
10718 let from = rt
10719 .create_note(&tok, "observation", None, "From", None, None, vec![])
10720 .await
10721 .unwrap();
10722 let annotator = rt
10723 .create_note(
10724 &tok,
10725 "observation",
10726 None,
10727 "edge annotation",
10728 None,
10729 None,
10730 vec![],
10731 )
10732 .await
10733 .unwrap();
10734 let shared = rt
10735 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
10736 .await
10737 .unwrap();
10738
10739 let survivor = rt
10740 .link(
10741 &tok,
10742 into.id,
10743 shared.id,
10744 EdgeRelation::Annotates,
10745 1.0,
10746 Some(serde_json::json!({"source": "survivor"})),
10747 )
10748 .await
10749 .unwrap();
10750 let dropped = rt
10751 .link(
10752 &tok,
10753 from.id,
10754 shared.id,
10755 EdgeRelation::Annotates,
10756 0.4,
10757 Some(serde_json::json!({"source": "dropped"})),
10758 )
10759 .await
10760 .unwrap();
10761 let annotation = rt
10762 .link(
10763 &tok,
10764 annotator.id,
10765 dropped.id.into(),
10766 EdgeRelation::Annotates,
10767 0.8,
10768 Some(serde_json::json!({"why": "duplicate claim"})),
10769 )
10770 .await
10771 .unwrap();
10772 rt.delete_edge(&tok, annotation.id.into(), false)
10773 .await
10774 .unwrap();
10775
10776 let summary = rt
10777 .merge_note(
10778 &tok,
10779 into.id,
10780 from.id,
10781 EntityDedupMergePolicy::PreferInto,
10782 ContentMergeStrategy::Append,
10783 false,
10784 )
10785 .await
10786 .unwrap();
10787
10788 let [conflict] = summary.edge_conflict_preimages.as_slice() else {
10789 panic!(
10790 "expected one note-merge edge conflict, got {:?}",
10791 summary.edge_conflict_preimages
10792 );
10793 };
10794 assert_eq!(conflict.surviving_edge_id, Uuid::from(survivor.id));
10795 assert_eq!(conflict.dropped_edge.id, Uuid::from(dropped.id));
10796 assert_eq!(conflict.dropped_edge.source_id, from.id);
10797 assert_eq!(conflict.dropped_edge.weight, 0.4);
10798 assert_eq!(
10799 conflict.dropped_edge.metadata,
10800 Some(serde_json::json!({"source": "dropped"}))
10801 );
10802 assert_eq!(conflict.incident_edge_preimages.len(), 1);
10803 assert_eq!(
10804 conflict.incident_edge_preimages[0].id,
10805 Uuid::from(annotation.id)
10806 );
10807 assert_eq!(
10808 conflict.incident_edge_preimages[0].metadata,
10809 Some(serde_json::json!({"why": "duplicate claim"}))
10810 );
10811 assert!(
10812 conflict.incident_edge_preimages[0].deleted_at.is_some(),
10813 "the cascade preimage must retain an annotation's tombstone state"
10814 );
10815 assert!(rt
10816 .get_edge_including_deleted(&tok, dropped.id.into())
10817 .await
10818 .unwrap()
10819 .is_none());
10820 assert!(
10821 rt.get_edge_including_deleted(&tok, annotation.id.into())
10822 .await
10823 .unwrap()
10824 .is_none(),
10825 "annotation targeting the dropped edge must be cascaded, not left dangling"
10826 );
10827
10828 let events = rt
10829 .events(&tok)
10830 .unwrap()
10831 .query_events(
10832 khive_storage::EventFilter {
10833 kinds: vec![EventKind::NoteMerged],
10834 ..Default::default()
10835 },
10836 khive_storage::types::PageRequest {
10837 offset: 0,
10838 limit: 10,
10839 },
10840 )
10841 .await
10842 .unwrap();
10843 assert_eq!(events.items.len(), 1);
10844 assert_eq!(
10845 events.items[0].payload["edge_conflict_preimages"],
10846 serde_json::to_value(&summary.edge_conflict_preimages).unwrap()
10847 );
10848 }
10849
10850 #[tokio::test]
10859 async fn merge_note_dry_run_conflict_returns_preimages_without_mutating() {
10860 let rt = rt();
10861 let tok = NamespaceToken::local();
10862 let into = rt
10863 .create_note(&tok, "observation", None, "Into", None, None, vec![])
10864 .await
10865 .unwrap();
10866 let from = rt
10867 .create_note(&tok, "observation", None, "From", None, None, vec![])
10868 .await
10869 .unwrap();
10870 let annotator = rt
10871 .create_note(
10872 &tok,
10873 "observation",
10874 None,
10875 "edge annotation",
10876 None,
10877 None,
10878 vec![],
10879 )
10880 .await
10881 .unwrap();
10882 let nested_annotator = rt
10883 .create_note(
10884 &tok,
10885 "observation",
10886 None,
10887 "nested edge annotation",
10888 None,
10889 None,
10890 vec![],
10891 )
10892 .await
10893 .unwrap();
10894 let shared = rt
10895 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
10896 .await
10897 .unwrap();
10898
10899 let survivor = rt
10900 .link(
10901 &tok,
10902 into.id,
10903 shared.id,
10904 EdgeRelation::Annotates,
10905 1.0,
10906 Some(serde_json::json!({"source": "survivor"})),
10907 )
10908 .await
10909 .unwrap();
10910 let dropped = rt
10911 .link(
10912 &tok,
10913 from.id,
10914 shared.id,
10915 EdgeRelation::Annotates,
10916 0.4,
10917 Some(serde_json::json!({"source": "dropped"})),
10918 )
10919 .await
10920 .unwrap();
10921 let annotation = rt
10922 .link(
10923 &tok,
10924 annotator.id,
10925 dropped.id.into(),
10926 EdgeRelation::Annotates,
10927 0.8,
10928 Some(serde_json::json!({"why": "duplicate claim"})),
10929 )
10930 .await
10931 .unwrap();
10932 let nested_annotation = rt
10933 .link(
10934 &tok,
10935 nested_annotator.id,
10936 annotation.id.into(),
10937 EdgeRelation::Annotates,
10938 0.6,
10939 Some(serde_json::json!({"why": "nested duplicate claim"})),
10940 )
10941 .await
10942 .unwrap();
10943 rt.delete_edge(&tok, nested_annotation.id.into(), false)
10944 .await
10945 .unwrap();
10946
10947 let survivor_before = rt
10948 .get_edge_including_deleted(&tok, survivor.id.into())
10949 .await
10950 .unwrap()
10951 .expect("survivor edge exists");
10952 let dropped_before = rt
10953 .get_edge_including_deleted(&tok, dropped.id.into())
10954 .await
10955 .unwrap()
10956 .expect("dropped edge exists");
10957 let annotation_before = rt
10958 .get_edge_including_deleted(&tok, annotation.id.into())
10959 .await
10960 .unwrap()
10961 .expect("annotation edge exists");
10962 let nested_annotation_before = rt
10963 .get_edge_including_deleted(&tok, nested_annotation.id.into())
10964 .await
10965 .unwrap()
10966 .expect("nested annotation edge exists");
10967 let into_before = rt
10968 .get_note_including_deleted(&tok, into.id)
10969 .await
10970 .unwrap()
10971 .expect("into note exists");
10972 let from_before = rt
10973 .get_note_including_deleted(&tok, from.id)
10974 .await
10975 .unwrap()
10976 .expect("from note exists");
10977
10978 let summary = rt
10979 .merge_note(
10980 &tok,
10981 into.id,
10982 from.id,
10983 EntityDedupMergePolicy::PreferInto,
10984 ContentMergeStrategy::Append,
10985 true,
10986 )
10987 .await
10988 .unwrap();
10989
10990 let [conflict] = summary.edge_conflict_preimages.as_slice() else {
10991 panic!(
10992 "expected one note-merge edge conflict from the dry run, got {:?}",
10993 summary.edge_conflict_preimages
10994 );
10995 };
10996 assert_eq!(conflict.surviving_edge_id, Uuid::from(survivor.id));
10997 assert_eq!(conflict.dropped_edge.id, Uuid::from(dropped.id));
10998 assert_eq!(conflict.dropped_edge.source_id, from.id);
10999 assert_eq!(conflict.dropped_edge.weight, 0.4);
11000 assert_eq!(conflict.incident_edge_preimages.len(), 2);
11003 assert_eq!(
11004 conflict.incident_edge_preimages[0].id,
11005 Uuid::from(annotation.id)
11006 );
11007 assert!(
11008 conflict.incident_edge_preimages[0].deleted_at.is_none(),
11009 "the direct annotation was never soft-deleted"
11010 );
11011 assert_eq!(
11012 conflict.incident_edge_preimages[1].id,
11013 Uuid::from(nested_annotation.id)
11014 );
11015 assert!(
11016 conflict.incident_edge_preimages[1].deleted_at.is_some(),
11017 "dry-run preimage must retain the nested annotation's tombstone state"
11018 );
11019
11020 let survivor_after = rt
11021 .get_edge_including_deleted(&tok, survivor.id.into())
11022 .await
11023 .unwrap()
11024 .expect("dry run must not delete the survivor edge");
11025 let dropped_after = rt
11026 .get_edge_including_deleted(&tok, dropped.id.into())
11027 .await
11028 .unwrap()
11029 .expect("dry run must not delete the dropped edge");
11030 let annotation_after = rt
11031 .get_edge_including_deleted(&tok, annotation.id.into())
11032 .await
11033 .unwrap()
11034 .expect("dry run must not delete the cascaded annotation");
11035 let nested_annotation_after = rt
11036 .get_edge_including_deleted(&tok, nested_annotation.id.into())
11037 .await
11038 .unwrap()
11039 .expect("dry run must not delete the nested cascaded annotation");
11040 assert_eq!(
11041 serde_json::to_value(&survivor_before).unwrap(),
11042 serde_json::to_value(&survivor_after).unwrap(),
11043 "dry run must not mutate the surviving edge's row at all"
11044 );
11045 assert_eq!(
11046 serde_json::to_value(&dropped_before).unwrap(),
11047 serde_json::to_value(&dropped_after).unwrap(),
11048 "dry run must not mutate the would-be-dropped edge's row at all"
11049 );
11050 assert_eq!(
11051 serde_json::to_value(&annotation_before).unwrap(),
11052 serde_json::to_value(&annotation_after).unwrap(),
11053 "dry run must not mutate the incident annotation's row at all"
11054 );
11055 assert_eq!(
11056 serde_json::to_value(&nested_annotation_before).unwrap(),
11057 serde_json::to_value(&nested_annotation_after).unwrap(),
11058 "dry run must not mutate the nested incident annotation's row at all"
11059 );
11060
11061 let into_after = rt
11062 .get_note_including_deleted(&tok, into.id)
11063 .await
11064 .unwrap()
11065 .expect("into note must remain unmerged after a dry run");
11066 let from_after = rt
11067 .get_note_including_deleted(&tok, from.id)
11068 .await
11069 .unwrap()
11070 .expect("from note must not be deleted by a dry run");
11071 assert_eq!(
11072 serde_json::to_value(&into_before).unwrap(),
11073 serde_json::to_value(&into_after).unwrap(),
11074 "dry run must not mutate the into note's row at all"
11075 );
11076 assert_eq!(
11077 serde_json::to_value(&from_before).unwrap(),
11078 serde_json::to_value(&from_after).unwrap(),
11079 "dry run must not mutate the from note's row at all"
11080 );
11081 assert_eq!(from_after.status, from_before.status);
11082 assert_eq!(from_after.deleted_at, None);
11083
11084 let events = rt
11085 .events(&tok)
11086 .unwrap()
11087 .query_events(
11088 khive_storage::EventFilter {
11089 kinds: vec![EventKind::NoteMerged],
11090 ..Default::default()
11091 },
11092 khive_storage::types::PageRequest {
11093 offset: 0,
11094 limit: 10,
11095 },
11096 )
11097 .await
11098 .unwrap();
11099 assert!(
11100 events.items.is_empty(),
11101 "a dry run must not record a merge audit event"
11102 );
11103 }
11104
11105 #[tokio::test]
11110 async fn merge_note_preserves_note_to_note_epistemic_and_supersession_edges() {
11111 use khive_storage::EdgeRelation;
11112 let rt = rt();
11113 let tok = NamespaceToken::local();
11114
11115 let into = rt
11116 .create_note(&tok, "observation", None, "Into", None, None, vec![])
11117 .await
11118 .unwrap();
11119 let from = rt
11120 .create_note(&tok, "observation", None, "From", None, None, vec![])
11121 .await
11122 .unwrap();
11123 let superseded = rt
11124 .create_note(&tok, "observation", None, "Old", None, None, vec![])
11125 .await
11126 .unwrap();
11127 let claim = rt
11128 .create_note(&tok, "insight", None, "Claim", None, None, vec![])
11129 .await
11130 .unwrap();
11131 let counter = rt
11132 .create_note(&tok, "observation", None, "Counter", None, None, vec![])
11133 .await
11134 .unwrap();
11135
11136 rt.link(
11139 &tok,
11140 from.id,
11141 superseded.id,
11142 EdgeRelation::Supersedes,
11143 1.0,
11144 None,
11145 )
11146 .await
11147 .unwrap();
11148 rt.link(&tok, from.id, claim.id, EdgeRelation::Supports, 1.0, None)
11149 .await
11150 .unwrap();
11151 rt.link(&tok, counter.id, from.id, EdgeRelation::Refutes, 1.0, None)
11152 .await
11153 .unwrap();
11154
11155 let summary = rt
11156 .merge_note_with_reason(
11157 &tok,
11158 into.id,
11159 from.id,
11160 EntityDedupMergePolicy::PreferInto,
11161 ContentMergeStrategy::Append,
11162 false,
11163 None,
11164 )
11165 .await
11166 .unwrap();
11167
11168 assert_eq!(
11169 summary.edges_rewired, 3,
11170 "all three note→note edges must be rewired, not contract-dropped"
11171 );
11172 assert_eq!(
11173 summary.edges_contract_skipped, 0,
11174 "no valid note→note supersedes/supports/refutes edge may be dropped"
11175 );
11176
11177 for (src, tgt, rel) in [
11178 (into.id, superseded.id, EdgeRelation::Supersedes),
11179 (into.id, claim.id, EdgeRelation::Supports),
11180 (counter.id, into.id, EdgeRelation::Refutes),
11181 ] {
11182 let edges = rt
11183 .list_edges(
11184 &tok,
11185 crate::EdgeListFilter {
11186 source_id: Some(src),
11187 target_id: Some(tgt),
11188 relations: vec![rel],
11189 ..Default::default()
11190 },
11191 10,
11192 0,
11193 )
11194 .await
11195 .unwrap();
11196 assert_eq!(
11197 edges.len(),
11198 1,
11199 "rewired {rel:?} edge {src}→{tgt} must survive the merge; got {edges:?}"
11200 );
11201 }
11202 }
11203
11204 #[tokio::test]
11207 async fn note_merge_event_insert_failure_rolls_back_destructive_merge() {
11208 let rt = rt();
11209 let tok = NamespaceToken::local();
11210 let into = rt
11211 .create_note(&tok, "observation", None, "Into", None, None, vec![])
11212 .await
11213 .unwrap();
11214 let from = rt
11215 .create_note(&tok, "observation", None, "From", None, None, vec![])
11216 .await
11217 .unwrap();
11218 let edge = rt
11219 .link(&tok, into.id, from.id, EdgeRelation::Refutes, 1.0, None)
11220 .await
11221 .unwrap();
11222 let event_store = rt.events(&tok).unwrap();
11223 set_merge_event_refusal(&rt, "note_merged", true);
11224
11225 let failed = rt
11226 .merge_note(
11227 &tok,
11228 into.id,
11229 from.id,
11230 EntityDedupMergePolicy::PreferInto,
11231 ContentMergeStrategy::Append,
11232 false,
11233 )
11234 .await;
11235 assert!(failed.is_err(), "event insert must abort the note merge");
11236 let source = rt
11237 .notes(&tok)
11238 .unwrap()
11239 .get_note(from.id)
11240 .await
11241 .unwrap()
11242 .unwrap();
11243 assert!(source.deleted_at.is_none());
11244 assert!(
11245 rt.get_edge_including_deleted(&tok, edge.id.into())
11246 .await
11247 .unwrap()
11248 .is_some(),
11249 "the deleted self-loop must roll back"
11250 );
11251 let filter = khive_storage::EventFilter {
11252 kinds: vec![EventKind::NoteMerged],
11253 ..Default::default()
11254 };
11255 let page = khive_storage::types::PageRequest {
11256 offset: 0,
11257 limit: 10,
11258 };
11259 assert!(event_store
11260 .query_events(filter.clone(), page.clone())
11261 .await
11262 .unwrap()
11263 .items
11264 .is_empty());
11265
11266 set_merge_event_refusal(&rt, "note_merged", false);
11267 let summary = rt
11268 .merge_note(
11269 &tok,
11270 into.id,
11271 from.id,
11272 EntityDedupMergePolicy::PreferInto,
11273 ContentMergeStrategy::Append,
11274 false,
11275 )
11276 .await
11277 .unwrap();
11278 let tombstone = rt
11279 .notes(&tok)
11280 .unwrap()
11281 .get_note_including_deleted(from.id)
11282 .await
11283 .unwrap()
11284 .unwrap();
11285 assert!(tombstone.deleted_at.is_some());
11286 let events = event_store.query_events(filter, page).await.unwrap();
11287 assert_eq!(events.items.len(), 1);
11288 assert_eq!(
11289 events.items[0].payload["self_loop_edge_preimages"],
11290 serde_json::to_value(&summary.self_loop_edge_preimages).unwrap()
11291 );
11292 }
11293
11294 #[tokio::test]
11301 async fn merge_note_drops_self_loop_edge_records_preimage() {
11302 let rt = rt();
11303 let tok = NamespaceToken::local();
11304 let into = rt
11305 .create_note(&tok, "observation", None, "Into", None, None, vec![])
11306 .await
11307 .unwrap();
11308 let from = rt
11309 .create_note(&tok, "observation", None, "From", None, None, vec![])
11310 .await
11311 .unwrap();
11312
11313 let edge = rt
11314 .link(
11315 &tok,
11316 into.id,
11317 from.id,
11318 EdgeRelation::Refutes,
11319 0.85,
11320 Some(serde_json::json!({"basis": "direct contradiction"})),
11321 )
11322 .await
11323 .unwrap();
11324
11325 let summary = rt
11326 .merge_note(
11327 &tok,
11328 into.id,
11329 from.id,
11330 EntityDedupMergePolicy::PreferInto,
11331 ContentMergeStrategy::Append,
11332 false,
11333 )
11334 .await
11335 .unwrap();
11336
11337 assert_eq!(
11338 summary.edges_self_loop_dropped, 1,
11339 "the into-refutes-from edge becomes a self-loop and must be counted"
11340 );
11341 let [preimage] = summary.self_loop_edge_preimages.as_slice() else {
11342 panic!(
11343 "expected exactly one self-loop preimage, got {:?}",
11344 summary.self_loop_edge_preimages
11345 );
11346 };
11347 assert_eq!(preimage.id, Uuid::from(edge.id));
11348 assert_eq!(preimage.source_id, into.id);
11349 assert_eq!(preimage.target_id, from.id);
11350 assert_eq!(preimage.relation, "refutes");
11351 assert_eq!(preimage.weight, 0.85);
11352 assert_eq!(
11353 preimage.metadata,
11354 Some(serde_json::json!({"basis": "direct contradiction"}))
11355 );
11356
11357 assert!(
11358 rt.get_edge_including_deleted(&tok, edge.id.into())
11359 .await
11360 .unwrap()
11361 .is_none(),
11362 "the self-loop edge must actually be removed"
11363 );
11364
11365 let events = rt
11366 .events(&tok)
11367 .unwrap()
11368 .query_events(
11369 khive_storage::EventFilter {
11370 kinds: vec![EventKind::NoteMerged],
11371 ..Default::default()
11372 },
11373 khive_storage::types::PageRequest {
11374 offset: 0,
11375 limit: 10,
11376 },
11377 )
11378 .await
11379 .unwrap();
11380 assert_eq!(events.items.len(), 1);
11381 assert_eq!(
11382 events.items[0].payload["edges_self_loop_dropped"],
11383 serde_json::json!(1)
11384 );
11385 assert_eq!(
11386 events.items[0].payload["self_loop_edge_preimages"],
11387 serde_json::to_value(&summary.self_loop_edge_preimages).unwrap()
11388 );
11389 }
11390
11391 #[tokio::test]
11393 async fn merge_note_self_loop_dry_run_matches_real_run() {
11394 let rt = rt();
11395 let tok = NamespaceToken::local();
11396 let into = rt
11397 .create_note(&tok, "observation", None, "Into", None, None, vec![])
11398 .await
11399 .unwrap();
11400 let from = rt
11401 .create_note(&tok, "observation", None, "From", None, None, vec![])
11402 .await
11403 .unwrap();
11404 let edge = rt
11405 .link(
11406 &tok,
11407 from.id,
11408 into.id,
11409 EdgeRelation::Supports,
11410 0.5,
11411 Some(serde_json::json!({"basis": "dry-run parity"})),
11412 )
11413 .await
11414 .unwrap();
11415
11416 let dry_summary = rt
11417 .merge_note(
11418 &tok,
11419 into.id,
11420 from.id,
11421 EntityDedupMergePolicy::PreferInto,
11422 ContentMergeStrategy::Append,
11423 true,
11424 )
11425 .await
11426 .unwrap();
11427
11428 assert!(
11429 rt.get_edge_including_deleted(&tok, edge.id.into())
11430 .await
11431 .unwrap()
11432 .is_some(),
11433 "dry run must not delete the self-loop edge"
11434 );
11435
11436 let real_summary = rt
11437 .merge_note(
11438 &tok,
11439 into.id,
11440 from.id,
11441 EntityDedupMergePolicy::PreferInto,
11442 ContentMergeStrategy::Append,
11443 false,
11444 )
11445 .await
11446 .unwrap();
11447
11448 assert_eq!(dry_summary.edges_self_loop_dropped, 1);
11449 let [dry_preimage] = dry_summary.self_loop_edge_preimages.as_slice() else {
11450 panic!(
11451 "expected exactly one predicted self-loop preimage, got {:?}",
11452 dry_summary.self_loop_edge_preimages
11453 );
11454 };
11455 assert_eq!(dry_preimage.id, Uuid::from(edge.id));
11456 assert_eq!(dry_preimage.source_id, from.id);
11457 assert_eq!(dry_preimage.target_id, into.id);
11458 assert_eq!(dry_preimage.relation, "supports");
11459 assert_eq!(dry_preimage.weight, 0.5);
11460 assert_eq!(
11461 dry_summary.edges_self_loop_dropped, real_summary.edges_self_loop_dropped,
11462 "a dry run must predict the same self-loop-drop count the committed merge produces"
11463 );
11464 assert_eq!(
11465 dry_summary.self_loop_edge_preimages, real_summary.self_loop_edge_preimages,
11466 "a dry run must predict the exact preimage the committed merge produces"
11467 );
11468
11469 assert!(
11470 rt.get_edge_including_deleted(&tok, edge.id.into())
11471 .await
11472 .unwrap()
11473 .is_none(),
11474 "the committed merge must actually delete the self-loop edge"
11475 );
11476 }
11477
11478 #[tokio::test]
11481 async fn merge_note_no_self_loop_between_operands_reports_zero() {
11482 let rt = rt();
11483 let tok = NamespaceToken::local();
11484 let into = rt
11485 .create_note(&tok, "observation", None, "Into", None, None, vec![])
11486 .await
11487 .unwrap();
11488 let from = rt
11489 .create_note(&tok, "observation", None, "From", None, None, vec![])
11490 .await
11491 .unwrap();
11492 let other = rt
11493 .create_note(&tok, "observation", None, "Other", None, None, vec![])
11494 .await
11495 .unwrap();
11496
11497 rt.link(&tok, from.id, other.id, EdgeRelation::Supersedes, 1.0, None)
11498 .await
11499 .unwrap();
11500
11501 let summary = rt
11502 .merge_note(
11503 &tok,
11504 into.id,
11505 from.id,
11506 EntityDedupMergePolicy::PreferInto,
11507 ContentMergeStrategy::Append,
11508 false,
11509 )
11510 .await
11511 .unwrap();
11512
11513 assert_eq!(
11514 summary.edges_rewired, 1,
11515 "the non-self-loop edge must still rewire"
11516 );
11517 assert_eq!(
11518 summary.edges_self_loop_dropped, 0,
11519 "no self-loop exists between the merge operands"
11520 );
11521 assert!(summary.self_loop_edge_preimages.is_empty());
11522 }
11523
11524 #[tokio::test]
11529 async fn merge_note_preserves_annotates_edges_targeting_edges_and_events() {
11530 use khive_storage::EdgeRelation;
11531 let rt = rt();
11532 let tok = NamespaceToken::local();
11533
11534 let a = rt
11536 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11537 .await
11538 .unwrap();
11539 let b = rt
11540 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11541 .await
11542 .unwrap();
11543 let annotated_edge = rt
11544 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
11545 .await
11546 .unwrap();
11547
11548 let scrap_into = rt
11551 .create_note(&tok, "observation", None, "ScrapInto", None, None, vec![])
11552 .await
11553 .unwrap();
11554 let scrap_from = rt
11555 .create_note(&tok, "observation", None, "ScrapFrom", None, None, vec![])
11556 .await
11557 .unwrap();
11558 rt.merge_note_with_reason(
11559 &tok,
11560 scrap_into.id,
11561 scrap_from.id,
11562 EntityDedupMergePolicy::PreferInto,
11563 ContentMergeStrategy::Append,
11564 false,
11565 None,
11566 )
11567 .await
11568 .unwrap();
11569 let events = rt
11570 .events(&tok)
11571 .unwrap()
11572 .query_events(
11573 khive_storage::EventFilter {
11574 kinds: vec![EventKind::NoteMerged],
11575 ..Default::default()
11576 },
11577 khive_storage::types::PageRequest {
11578 offset: 0,
11579 limit: 1,
11580 },
11581 )
11582 .await
11583 .unwrap();
11584 let annotated_event_id = events.items[0].id;
11585
11586 let into = rt
11587 .create_note(&tok, "observation", None, "Into", None, None, vec![])
11588 .await
11589 .unwrap();
11590 let from = rt
11591 .create_note(&tok, "observation", None, "From", None, None, vec![])
11592 .await
11593 .unwrap();
11594
11595 rt.link(
11596 &tok,
11597 from.id,
11598 annotated_edge.id.0,
11599 EdgeRelation::Annotates,
11600 1.0,
11601 None,
11602 )
11603 .await
11604 .unwrap();
11605 rt.link(
11606 &tok,
11607 from.id,
11608 annotated_event_id,
11609 EdgeRelation::Annotates,
11610 1.0,
11611 None,
11612 )
11613 .await
11614 .unwrap();
11615
11616 let summary = rt
11617 .merge_note_with_reason(
11618 &tok,
11619 into.id,
11620 from.id,
11621 EntityDedupMergePolicy::PreferInto,
11622 ContentMergeStrategy::Append,
11623 false,
11624 None,
11625 )
11626 .await
11627 .unwrap();
11628
11629 assert_eq!(
11630 summary.edges_rewired, 2,
11631 "annotates edges targeting an edge and an event must be rewired"
11632 );
11633 assert_eq!(
11634 summary.edges_contract_skipped, 0,
11635 "no valid annotates edge may be dropped as contract-violating"
11636 );
11637
11638 for tgt in [annotated_edge.id.0, annotated_event_id] {
11639 let edges = rt
11640 .list_edges(
11641 &tok,
11642 crate::EdgeListFilter {
11643 source_id: Some(into.id),
11644 target_id: Some(tgt),
11645 relations: vec![EdgeRelation::Annotates],
11646 ..Default::default()
11647 },
11648 10,
11649 0,
11650 )
11651 .await
11652 .unwrap();
11653 assert_eq!(
11654 edges.len(),
11655 1,
11656 "rewired annotates edge onto target {tgt} must survive the merge; got {edges:?}"
11657 );
11658 }
11659 }
11660
11661 #[tokio::test]
11664 async fn merge_note_dry_run_predicts_edges_rewired() {
11665 use khive_storage::EdgeRelation;
11666 let rt = rt();
11667 let tok = NamespaceToken::local();
11668 let into = rt
11669 .create_note(&tok, "observation", None, "Into", None, None, vec![])
11670 .await
11671 .unwrap();
11672 let from = rt
11673 .create_note(&tok, "observation", None, "From", None, None, vec![])
11674 .await
11675 .unwrap();
11676 let shared = rt
11677 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
11678 .await
11679 .unwrap();
11680 rt.link(&tok, from.id, shared.id, EdgeRelation::Annotates, 1.0, None)
11681 .await
11682 .unwrap();
11683
11684 let summary = rt
11685 .merge_note_with_reason(
11686 &tok,
11687 into.id,
11688 from.id,
11689 EntityDedupMergePolicy::PreferInto,
11690 ContentMergeStrategy::Append,
11691 true,
11692 None,
11693 )
11694 .await
11695 .unwrap();
11696 assert!(summary.dry_run);
11697 assert_eq!(
11698 summary.edges_rewired, 1,
11699 "note dry-run must predict the rewire count"
11700 );
11701
11702 let from_edges = rt
11703 .list_edges(
11704 &tok,
11705 crate::EdgeListFilter {
11706 source_id: Some(from.id),
11707 target_id: Some(shared.id),
11708 relations: vec![EdgeRelation::Annotates],
11709 ..Default::default()
11710 },
11711 10,
11712 0,
11713 )
11714 .await
11715 .unwrap();
11716 assert_eq!(from_edges.len(), 1, "dry-run must leave topology untouched");
11717 }
11718
11719 #[tokio::test]
11720 async fn merge_note_different_kinds_rejected() {
11721 let rt = rt();
11722 let tok = NamespaceToken::local();
11723 let into = rt
11724 .create_note(&tok, "observation", None, "Into", None, None, vec![])
11725 .await
11726 .unwrap();
11727 let from = rt
11728 .create_note(&tok, "decision", None, "From", None, None, vec![])
11729 .await
11730 .unwrap();
11731
11732 let result = rt
11733 .merge_note_with_reason(
11734 &tok,
11735 into.id,
11736 from.id,
11737 EntityDedupMergePolicy::PreferInto,
11738 ContentMergeStrategy::Append,
11739 false,
11740 None,
11741 )
11742 .await;
11743 assert!(result.is_err(), "merging different note kinds must fail");
11744 }
11745
11746 #[tokio::test]
11747 async fn merge_note_dry_run_leaves_notes_unchanged() {
11748 let rt = rt();
11749 let tok = NamespaceToken::local();
11750 let into = rt
11751 .create_note(
11752 &tok,
11753 "observation",
11754 None,
11755 "Into content",
11756 None,
11757 None,
11758 vec![],
11759 )
11760 .await
11761 .unwrap();
11762 let from = rt
11763 .create_note(
11764 &tok,
11765 "observation",
11766 None,
11767 "From content",
11768 None,
11769 None,
11770 vec![],
11771 )
11772 .await
11773 .unwrap();
11774 let into_id = into.id;
11775 let from_id = from.id;
11776
11777 let summary = rt
11778 .merge_note_with_reason(
11779 &tok,
11780 into_id,
11781 from_id,
11782 EntityDedupMergePolicy::PreferInto,
11783 ContentMergeStrategy::Append,
11784 true,
11785 None,
11786 )
11787 .await
11788 .unwrap();
11789
11790 assert!(summary.dry_run);
11791
11792 let store = rt.notes(&tok).unwrap();
11793 let into_after = store.get_note(into_id).await.unwrap().unwrap();
11794 let from_after = store.get_note(from_id).await.unwrap().unwrap();
11795 assert_eq!(
11796 into_after.content, "Into content",
11797 "dry_run must not mutate into-note"
11798 );
11799 assert_eq!(
11800 from_after.content, "From content",
11801 "dry_run must not mutate from-note"
11802 );
11803
11804 let events = rt
11805 .events(&tok)
11806 .unwrap()
11807 .query_events(
11808 khive_storage::EventFilter {
11809 kinds: vec![EventKind::NoteMerged],
11810 ..Default::default()
11811 },
11812 khive_storage::types::PageRequest {
11813 offset: 0,
11814 limit: 10,
11815 },
11816 )
11817 .await
11818 .unwrap();
11819 assert!(
11820 events.items.is_empty(),
11821 "dry_run=true must not append a NoteMerged event"
11822 );
11823 }
11824
11825 #[tokio::test]
11830 async fn merge_nameless_notes_fts_document_is_parity_correct() {
11831 use khive_storage::types::TextSearchRequest;
11832
11833 let rt = rt(); let tok = NamespaceToken::local();
11835
11836 let into = rt
11837 .create_note(
11838 &tok,
11839 "observation",
11840 None,
11841 "intosentinelzxq body",
11842 None,
11843 Some(serde_json::json!({"src": "into"})),
11844 vec![],
11845 )
11846 .await
11847 .expect("create into-note");
11848 let from = rt
11849 .create_note(
11850 &tok,
11851 "observation",
11852 None,
11853 "fromsentinelzxq body",
11854 None,
11855 None,
11856 vec![],
11857 )
11858 .await
11859 .expect("create from-note");
11860
11861 let into_id = into.id;
11862 let from_id = from.id;
11863
11864 rt.merge_note_with_reason(
11865 &tok,
11866 into_id,
11867 from_id,
11868 EntityDedupMergePolicy::PreferInto,
11869 ContentMergeStrategy::Append,
11870 false,
11871 None,
11872 )
11873 .await
11874 .expect("merge_note must succeed");
11875
11876 let note_store = rt.notes(&tok).expect("note store");
11877 let merged_note = note_store
11878 .get_note(into_id)
11879 .await
11880 .expect("get_note")
11881 .expect("merged note must exist");
11882
11883 let expected = note_fts_document(&merged_note);
11884
11885 let fts = rt.text_for_notes(&tok).expect("FTS store");
11886 let stored = fts
11887 .get_document("local", into_id)
11888 .await
11889 .expect("get_document must not error")
11890 .expect("FTS document must exist after merge");
11891
11892 assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
11893 assert_eq!(
11894 stored.title, expected.title,
11895 "title (None for nameless note)"
11896 );
11897 assert_eq!(stored.body, expected.body, "body");
11898 assert_eq!(stored.namespace, expected.namespace, "namespace");
11899 assert_eq!(stored.kind, expected.kind, "kind");
11900
11901 assert!(
11902 stored.title.is_none(),
11903 "nameless merged note must have title=None in FTS (was NULL before fix)"
11904 );
11905
11906 let hits = fts
11908 .search(TextSearchRequest {
11909 query: "intosentinelzxq".to_string(),
11910 mode: khive_storage::types::TextQueryMode::Plain,
11911 filter: None,
11912 top_k: 10,
11913 snippet_chars: 0,
11914 })
11915 .await
11916 .expect("search");
11917 assert!(
11918 hits.iter().any(|h| h.subject_id == into_id),
11919 "merged note must be searchable by into-note content"
11920 );
11921 }
11922
11923 #[tokio::test]
11924 async fn update_edge_updates_properties() {
11925 use khive_storage::EdgeRelation;
11926 let rt = rt();
11927 let tok = NamespaceToken::local();
11928 let a = rt
11929 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11930 .await
11931 .unwrap();
11932 let b = rt
11933 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11934 .await
11935 .unwrap();
11936 let edge = rt
11937 .link(&tok, a.id, b.id, EdgeRelation::Extends, 0.5, None)
11938 .await
11939 .unwrap();
11940 let edge_id: Uuid = edge.id.into();
11941
11942 let updated = rt
11943 .update_edge(
11944 &tok,
11945 edge_id,
11946 EdgePatch {
11947 properties: Some(serde_json::json!({"source": "manual"})),
11948 ..Default::default()
11949 },
11950 )
11951 .await
11952 .unwrap();
11953
11954 assert_eq!(updated.metadata.as_ref().unwrap()["source"], "manual");
11955 assert!((updated.weight - 0.5).abs() < 0.001, "weight unchanged");
11956 }
11957
11958 #[tokio::test]
11962 async fn merge_entity_survives_shared_edge_to_third_party() {
11963 use khive_storage::EdgeRelation;
11964 let rt = rt();
11965 let tok = NamespaceToken::local();
11966
11967 let a = rt
11970 .create_entity(&tok, "concept", None, "A", None, None, vec![])
11971 .await
11972 .unwrap();
11973 let b = rt
11974 .create_entity(&tok, "concept", None, "B", None, None, vec![])
11975 .await
11976 .unwrap();
11977 let shared = rt
11978 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
11979 .await
11980 .unwrap();
11981
11982 rt.link(&tok, a.id, shared.id, EdgeRelation::Extends, 1.0, None)
11985 .await
11986 .unwrap();
11987 rt.link(&tok, b.id, shared.id, EdgeRelation::Extends, 1.0, None)
11988 .await
11989 .unwrap();
11990
11991 let summary = rt
11992 .merge_entity_with_reason(
11993 &tok,
11994 a.id,
11995 b.id,
11996 crate::EntityDedupMergePolicy::PreferInto,
11997 ContentMergeStrategy::Append,
11998 false,
11999 None,
12000 )
12001 .await
12002 .expect(
12003 "C1: merge must succeed even when both entities share an edge to a third party",
12004 );
12005
12006 assert_eq!(summary.kept_id, a.id);
12007 assert_eq!(summary.removed_id, b.id);
12008 let a_edges = rt
12014 .list_edges(
12015 &tok,
12016 crate::EdgeListFilter {
12017 source_id: Some(a.id),
12018 target_id: Some(shared.id),
12019 relations: vec![EdgeRelation::Extends],
12020 ..Default::default()
12021 },
12022 10,
12023 0,
12024 )
12025 .await
12026 .unwrap();
12027 assert_eq!(
12028 a_edges.len(),
12029 1,
12030 "C1: exactly one live A→shared Extends edge must exist after merge; got: {a_edges:?}"
12031 );
12032
12033 let b_after = rt.entities(&tok).unwrap().get_entity(b.id).await.unwrap();
12035 assert!(
12036 b_after.is_none(),
12037 "C3: from_entity must be tombstoned (get_entity returns None for deleted) after merge; got: {b_after:?}"
12038 );
12039 }
12040
12041 #[tokio::test]
12045 async fn merge_entity_symmetric_conflict_preserves_survivor_fields() {
12046 use khive_storage::EdgeRelation;
12047 let rt = rt();
12048 let tok = NamespaceToken::local();
12049
12050 let a = rt
12051 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12052 .await
12053 .unwrap();
12054 let b = rt
12055 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12056 .await
12057 .unwrap();
12058 let shared = rt
12059 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
12060 .await
12061 .unwrap();
12062
12063 let survivor_edge = rt
12064 .link(
12065 &tok,
12066 a.id,
12067 shared.id,
12068 EdgeRelation::Extends,
12069 1.0,
12070 Some(serde_json::json!({"source": "survivor"})),
12071 )
12072 .await
12073 .unwrap();
12074 rt.link(
12075 &tok,
12076 b.id,
12077 shared.id,
12078 EdgeRelation::Extends,
12079 0.3,
12080 Some(serde_json::json!({"source": "loser"})),
12081 )
12082 .await
12083 .unwrap();
12084
12085 rt.merge_entity_with_reason(
12086 &tok,
12087 a.id,
12088 b.id,
12089 crate::EntityDedupMergePolicy::PreferInto,
12090 ContentMergeStrategy::Append,
12091 false,
12092 None,
12093 )
12094 .await
12095 .expect("merge must succeed across the symmetric-edge collision");
12096
12097 let after = rt
12098 .get_edge(&tok, survivor_edge.id.into())
12099 .await
12100 .unwrap()
12101 .expect("survivor edge must still exist after merge");
12102 assert!(
12103 (after.weight - 1.0).abs() < 0.001,
12104 "survivor weight must be untouched by the dropped duplicate; got {}",
12105 after.weight
12106 );
12107 assert_eq!(
12108 after.metadata.as_ref().unwrap()["source"],
12109 "survivor",
12110 "survivor metadata must be untouched by the dropped duplicate; got {:?}",
12111 after.metadata
12112 );
12113 }
12114
12115 #[tokio::test]
12118 async fn merge_entity_symmetric_conflict_does_not_resurrect_soft_deleted_survivor() {
12119 use khive_storage::EdgeRelation;
12120 let rt = rt();
12121 let tok = NamespaceToken::local();
12122
12123 let a = rt
12124 .create_entity(&tok, "concept", None, "A", None, None, vec![])
12125 .await
12126 .unwrap();
12127 let b = rt
12128 .create_entity(&tok, "concept", None, "B", None, None, vec![])
12129 .await
12130 .unwrap();
12131 let shared = rt
12132 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
12133 .await
12134 .unwrap();
12135
12136 let survivor_edge = rt
12137 .link(&tok, a.id, shared.id, EdgeRelation::Extends, 1.0, None)
12138 .await
12139 .unwrap();
12140 rt.delete_edge(&tok, survivor_edge.id.into(), false)
12141 .await
12142 .unwrap();
12143 rt.link(&tok, b.id, shared.id, EdgeRelation::Extends, 0.5, None)
12144 .await
12145 .unwrap();
12146
12147 rt.merge_entity_with_reason(
12148 &tok,
12149 a.id,
12150 b.id,
12151 crate::EntityDedupMergePolicy::PreferInto,
12152 ContentMergeStrategy::Append,
12153 false,
12154 None,
12155 )
12156 .await
12157 .expect("merge must succeed even when the surviving edge is soft-deleted");
12158
12159 let after = rt
12160 .get_edge_including_deleted(&tok, survivor_edge.id.into())
12161 .await
12162 .unwrap()
12163 .expect("survivor edge row must still exist after merge");
12164 assert!(
12165 after.deleted_at.is_some(),
12166 "soft-deleted survivor must stay soft-deleted after merge collision; got: {after:?}"
12167 );
12168 }
12169
12170 #[tokio::test]
12174 async fn merge_entity_cross_kind_rejected_at_runtime() {
12175 let rt = rt();
12176 let tok = NamespaceToken::local();
12177
12178 let concept = rt
12179 .create_entity(&tok, "concept", None, "H2Concept", None, None, vec![])
12180 .await
12181 .unwrap();
12182 let project = rt
12183 .create_entity(&tok, "project", None, "H2Project", None, None, vec![])
12184 .await
12185 .unwrap();
12186
12187 let err = rt
12188 .merge_entity_with_reason(
12189 &tok,
12190 concept.id,
12191 project.id,
12192 crate::EntityDedupMergePolicy::PreferInto,
12193 ContentMergeStrategy::Append,
12194 false,
12195 None,
12196 )
12197 .await
12198 .expect_err("H2: cross-kind merge must be rejected by runtime");
12199 assert!(
12200 matches!(err, crate::RuntimeError::InvalidInput(_)),
12201 "H2: expected InvalidInput, got: {err:?}"
12202 );
12203
12204 let concept_after = rt.get_entity(&tok, concept.id).await;
12205 let project_after = rt.get_entity(&tok, project.id).await;
12206 assert!(
12207 concept_after.is_ok(),
12208 "H2: concept must remain live after rejected merge; got: {concept_after:?}"
12209 );
12210 assert!(
12211 project_after.is_ok(),
12212 "H2: project must remain live after rejected merge; got: {project_after:?}"
12213 );
12214 }
12215
12216 #[tokio::test]
12218 async fn merge_entity_same_kind_succeeds() {
12219 let rt = rt();
12220 let tok = NamespaceToken::local();
12221
12222 let c1 = rt
12223 .create_entity(&tok, "concept", None, "Concept1", None, None, vec![])
12224 .await
12225 .unwrap();
12226 let c2 = rt
12227 .create_entity(&tok, "concept", None, "Concept2", None, None, vec![])
12228 .await
12229 .unwrap();
12230
12231 let summary = rt
12232 .merge_entity_with_reason(
12233 &tok,
12234 c1.id,
12235 c2.id,
12236 crate::EntityDedupMergePolicy::PreferInto,
12237 ContentMergeStrategy::Append,
12238 false,
12239 None,
12240 )
12241 .await
12242 .expect("same-kind merge must succeed");
12243 assert_eq!(summary.kept_id, c1.id);
12244 assert_eq!(summary.removed_id, c2.id);
12245
12246 let c2_after = rt.entities(&tok).unwrap().get_entity(c2.id).await.unwrap();
12247 assert!(c2_after.is_none(), "from_entity must be tombstoned");
12248 }
12249
12250 #[tokio::test]
12251 async fn merge_entity_explicit_policy_rereads_names_before_commit() {
12252 let rt = rt();
12253 let tok = NamespaceToken::local();
12254
12255 let into = rt
12256 .create_entity(
12257 &tok,
12258 "concept",
12259 None,
12260 "Transactional Guard",
12261 None,
12262 None,
12263 vec![],
12264 )
12265 .await
12266 .unwrap();
12267 let from = rt
12268 .create_entity(
12269 &tok,
12270 "concept",
12271 None,
12272 "Transactional Guard",
12273 None,
12274 None,
12275 vec![],
12276 )
12277 .await
12278 .unwrap();
12279
12280 validate_entity_merge_floor(&into, &from)
12281 .expect("the handler's fast-path validation would initially pass");
12282 let renamed_into = rt
12283 .update_entity(
12284 &tok,
12285 into.id,
12286 EntityPatch {
12287 name: Some("Unrelated Renamed Target".to_string()),
12288 ..Default::default()
12289 },
12290 )
12291 .await
12292 .unwrap();
12293 let expected = validate_entity_merge_floor(&renamed_into, &from)
12294 .expect_err("the renamed transactional state must violate the name guard");
12295 let RuntimeError::Khive(expected) = entity_merge_guard_error(expected) else {
12296 unreachable!("merge guard errors are structured Khive errors")
12297 };
12298
12299 let err = rt
12300 .merge_entity_with_reason_and_force(
12301 &tok,
12302 into.id,
12303 from.id,
12304 EntityDedupMergePolicy::PreferInto,
12305 ContentMergeStrategy::Append,
12306 false,
12307 None,
12308 false,
12309 )
12310 .await
12311 .expect_err("the explicit non-forced path must validate its transactional reread");
12312 let RuntimeError::Khive(err) = err else {
12313 panic!("expected a structured merge-guard conflict, got {err:?}");
12314 };
12315 assert_eq!(err.kind(), expected.kind());
12316 assert_eq!(err.message(), expected.message());
12317 assert_eq!(err.code(), expected.code());
12318 assert_eq!(err.details(), expected.details());
12319 assert!(
12320 rt.get_entity(&tok, from.id).await.is_ok(),
12321 "a refused merge must leave the source entity live"
12322 );
12323 }
12324
12325 #[tokio::test]
12328 async fn merge_note_cross_namespace_either_id_returns_not_found() {
12329 use crate::error::RuntimeError;
12330 use crate::Namespace;
12331
12332 let rt = rt();
12333 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
12334 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
12335
12336 let into_a = rt
12337 .create_note(&ns_a, "observation", None, "Into A", None, None, vec![])
12338 .await
12339 .unwrap();
12340 let from_a = rt
12341 .create_note(&ns_a, "observation", None, "From A", None, None, vec![])
12342 .await
12343 .unwrap();
12344 let note_b = rt
12345 .create_note(&ns_b, "observation", None, "Note B", None, None, vec![])
12346 .await
12347 .unwrap();
12348
12349 let foreign_into = rt
12351 .merge_note_with_reason(
12352 &ns_a,
12353 note_b.id,
12354 from_a.id,
12355 EntityDedupMergePolicy::PreferInto,
12356 ContentMergeStrategy::Append,
12357 false,
12358 None,
12359 )
12360 .await;
12361 assert!(
12362 matches!(foreign_into, Err(RuntimeError::NotFound(_))),
12363 "foreign into_id must be denied before merge, got {foreign_into:?}"
12364 );
12365
12366 let foreign_from = rt
12368 .merge_note_with_reason(
12369 &ns_a,
12370 into_a.id,
12371 note_b.id,
12372 EntityDedupMergePolicy::PreferInto,
12373 ContentMergeStrategy::Append,
12374 false,
12375 None,
12376 )
12377 .await;
12378 assert!(
12379 matches!(foreign_from, Err(RuntimeError::NotFound(_))),
12380 "foreign from_id must be denied before merge, got {foreign_from:?}"
12381 );
12382 }
12383
12384 #[tokio::test]
12387 async fn update_entity_cross_namespace_succeeds() {
12388 use crate::Namespace;
12389
12390 let rt = rt();
12391 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
12392 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
12393
12394 let entity = rt
12395 .create_entity(
12396 &ns_a,
12397 "concept",
12398 None,
12399 "Alpha",
12400 Some("original"),
12401 None,
12402 vec![],
12403 )
12404 .await
12405 .unwrap();
12406
12407 let result = rt
12408 .update_entity(
12409 &ns_b,
12410 entity.id,
12411 EntityPatch {
12412 name: Some("Updated".into()),
12413 ..Default::default()
12414 },
12415 )
12416 .await;
12417
12418 assert!(
12419 result.is_ok(),
12420 "cross-namespace update must succeed in shared-brain OSS; got {result:?}"
12421 );
12422 assert_eq!(result.unwrap().name, "Updated");
12423 }
12424
12425 #[tokio::test]
12429 async fn merge_entity_cross_namespace_ids_fail_at_sql_layer() {
12430 use crate::Namespace;
12431
12432 let rt = rt();
12433 let ns_a = NamespaceToken::for_namespace(Namespace::parse("ns-a").unwrap());
12434 let ns_b = NamespaceToken::for_namespace(Namespace::parse("ns-b").unwrap());
12435
12436 let into_a = rt
12437 .create_entity(&ns_a, "concept", None, "Into A", None, None, vec![])
12438 .await
12439 .unwrap();
12440 let from_a = rt
12441 .create_entity(&ns_a, "concept", None, "From A", None, None, vec![])
12442 .await
12443 .unwrap();
12444 let foreign_b = rt
12445 .create_entity(&ns_b, "concept", None, "Foreign B", None, None, vec![])
12446 .await
12447 .unwrap();
12448
12449 let foreign_into = rt
12451 .merge_entity_with_reason(
12452 &ns_a,
12453 foreign_b.id,
12454 from_a.id,
12455 EntityDedupMergePolicy::PreferInto,
12456 ContentMergeStrategy::Append,
12457 false,
12458 None,
12459 )
12460 .await;
12461 assert!(
12462 foreign_into.is_err(),
12463 "cross-namespace into_id must still fail at SQL layer; got {foreign_into:?}"
12464 );
12465
12466 let foreign_from = rt
12468 .merge_entity_with_reason(
12469 &ns_a,
12470 into_a.id,
12471 foreign_b.id,
12472 EntityDedupMergePolicy::PreferInto,
12473 ContentMergeStrategy::Append,
12474 false,
12475 None,
12476 )
12477 .await;
12478 assert!(
12479 foreign_from.is_err(),
12480 "cross-namespace from_id must still fail at SQL layer; got {foreign_from:?}"
12481 );
12482
12483 assert!(rt.get_entity(&ns_a, into_a.id).await.is_ok());
12485 assert!(rt.get_entity(&ns_a, from_a.id).await.is_ok());
12486 assert!(rt.get_entity(&ns_b, foreign_b.id).await.is_ok());
12487 }
12488
12489 #[test]
12492 fn entity_fts_document_with_description() {
12493 let mut entity = Entity::new("local", "concept", "MyEntity");
12494 entity = entity.with_description("some description text");
12495 let doc = entity_fts_document(&entity);
12496 assert_eq!(doc.subject_id, entity.id);
12497 assert_eq!(doc.namespace, "local");
12498 assert_eq!(doc.title.as_deref(), Some("MyEntity"));
12499 assert_eq!(doc.body, "MyEntity some description text");
12500 assert_eq!(doc.kind, khive_types::SubstrateKind::Entity);
12501 }
12502
12503 #[test]
12504 fn entity_fts_document_without_description() {
12505 let entity = Entity::new("local", "concept", "NameOnly");
12506 let doc = entity_fts_document(&entity);
12507 assert_eq!(doc.title.as_deref(), Some("NameOnly"));
12508 assert_eq!(doc.body, "NameOnly");
12509 }
12510
12511 #[test]
12512 fn entity_fts_document_empty_description_uses_name_only() {
12513 let mut entity = Entity::new("local", "concept", "TitleOnly");
12514 entity = entity.with_description("");
12515 let doc = entity_fts_document(&entity);
12516 assert_eq!(
12517 doc.body, "TitleOnly",
12518 "empty description must not be appended"
12519 );
12520 }
12521
12522 #[tokio::test]
12526 async fn entity_fts_document_matches_runtime_create_path() {
12527 let rt = rt();
12528 let tok = NamespaceToken::local();
12529
12530 let entity = rt
12531 .create_entity(
12532 &tok,
12533 "concept",
12534 None,
12535 "CrossPathTitle",
12536 Some("cross path description body"),
12537 Some(serde_json::json!({"key": "val"})),
12538 vec!["tag1".to_string()],
12539 )
12540 .await
12541 .expect("create_entity");
12542
12543 let fts = rt.text(&tok).expect("FTS store");
12544 let stored = fts
12545 .get_document("local", entity.id)
12546 .await
12547 .expect("get_document")
12548 .expect("document must exist after create_entity");
12549
12550 let expected = entity_fts_document(&entity);
12551
12552 assert_eq!(stored.subject_id, expected.subject_id, "subject_id");
12553 assert_eq!(stored.kind, expected.kind, "kind");
12554 assert_eq!(stored.title, expected.title, "title");
12555 assert_eq!(stored.body, expected.body, "body");
12556 assert_eq!(stored.namespace, expected.namespace, "namespace");
12557 }
12558
12559 #[tokio::test]
12562 async fn entity_fts_document_matches_runtime_update_path() {
12563 let rt = rt();
12564 let tok = NamespaceToken::local();
12565
12566 let entity = rt
12567 .create_entity(
12568 &tok,
12569 "concept",
12570 None,
12571 "OldName",
12572 Some("old desc"),
12573 None,
12574 vec![],
12575 )
12576 .await
12577 .expect("create_entity");
12578
12579 let updated = rt
12580 .update_entity(
12581 &tok,
12582 entity.id,
12583 EntityPatch {
12584 name: Some("NewName".to_string()),
12585 description: Some(Some("new desc".to_string())),
12586 ..Default::default()
12587 },
12588 )
12589 .await
12590 .expect("update_entity");
12591
12592 let fts = rt.text(&tok).expect("FTS store");
12593 let stored = fts
12594 .get_document("local", updated.id)
12595 .await
12596 .expect("get_document")
12597 .expect("document must exist after update_entity");
12598
12599 let expected = entity_fts_document(&updated);
12600
12601 assert_eq!(stored.title, expected.title, "title after update");
12602 assert_eq!(stored.body, expected.body, "body after update");
12603 }
12604
12605 struct MergeTestVecService {
12611 dims: usize,
12612 }
12613
12614 #[async_trait::async_trait]
12615 impl lattice_embed::EmbeddingService for MergeTestVecService {
12616 async fn embed(
12617 &self,
12618 texts: &[String],
12619 _model: lattice_embed::EmbeddingModel,
12620 ) -> std::result::Result<Vec<Vec<f32>>, lattice_embed::EmbedError> {
12621 Ok(texts.iter().map(|_| vec![1.0_f32; self.dims]).collect())
12622 }
12623
12624 fn supports_model(&self, _model: lattice_embed::EmbeddingModel) -> bool {
12625 true
12626 }
12627
12628 fn name(&self) -> &'static str {
12629 "merge-test-const-vec"
12630 }
12631 }
12632
12633 struct MergeTestVecProvider {
12634 provider_name: String,
12635 dims: usize,
12636 }
12637
12638 impl MergeTestVecProvider {
12639 fn new(name: &str, dims: usize) -> Self {
12640 Self {
12641 provider_name: name.to_owned(),
12642 dims,
12643 }
12644 }
12645 }
12646
12647 #[async_trait::async_trait]
12648 impl crate::embedder_registry::EmbedderProvider for MergeTestVecProvider {
12649 fn name(&self) -> &str {
12650 &self.provider_name
12651 }
12652
12653 fn dimensions(&self) -> usize {
12654 self.dims
12655 }
12656
12657 async fn build(
12658 &self,
12659 ) -> crate::error::RuntimeResult<std::sync::Arc<dyn lattice_embed::EmbeddingService>>
12660 {
12661 Ok(std::sync::Arc::new(MergeTestVecService { dims: self.dims }))
12662 }
12663 }
12664
12665 async fn assert_delete_during_entity_reindex_does_not_restore_indexes(pause_vector: bool) {
12666 const MODEL: &str = "entity-reindex-delete-race";
12667 let rt = Arc::new(KhiveRuntime::memory().unwrap());
12668 let tok = NamespaceToken::local();
12669 rt.register_embedder(MergeTestVecProvider::new(MODEL, 4));
12670 let entity = rt
12671 .create_entity(
12672 &tok,
12673 "concept",
12674 None,
12675 "DeletedDuringReindex",
12676 Some("the stale document must not return"),
12677 None,
12678 vec![],
12679 )
12680 .await
12681 .unwrap();
12682 let id = entity.id;
12683 let barriers = Arc::new((tokio::sync::Barrier::new(2), tokio::sync::Barrier::new(2)));
12684 let reindex_rt = Arc::clone(&rt);
12685 let reindex_tok = tok.clone();
12686 let reindex = async move { reindex_rt.reindex_entity(&reindex_tok, &entity).await };
12687 let reindex = if pause_vector {
12688 tokio::spawn(
12689 race_seam::BEFORE_ENTITY_VECTOR_PUBLISH.scope(Arc::clone(&barriers), reindex),
12690 )
12691 } else {
12692 tokio::spawn(
12693 race_seam::BEFORE_ENTITY_INDEX_PUBLISH.scope(Arc::clone(&barriers), reindex),
12694 )
12695 };
12696
12697 tokio::time::timeout(std::time::Duration::from_secs(10), barriers.0.wait())
12698 .await
12699 .expect("reindex reached publication boundary");
12700 assert!(rt.delete_entity(&tok, id, false).await.unwrap());
12701 barriers.1.wait().await;
12702 reindex.await.unwrap().unwrap();
12703
12704 assert!(rt
12705 .text(&tok)
12706 .unwrap()
12707 .get_document("local", id)
12708 .await
12709 .unwrap()
12710 .is_none());
12711 assert_eq!(
12712 rt.vectors_for_model(&tok, MODEL)
12713 .unwrap()
12714 .count()
12715 .await
12716 .unwrap(),
12717 0
12718 );
12719 }
12720
12721 #[tokio::test]
12722 async fn deleted_entity_cannot_restore_fts_at_pending_reindex() {
12723 assert_delete_during_entity_reindex_does_not_restore_indexes(false).await;
12724 }
12725
12726 #[tokio::test]
12727 async fn deleted_entity_cannot_restore_vector_after_embedding() {
12728 assert_delete_during_entity_reindex_does_not_restore_indexes(true).await;
12729 }
12730
12731 #[tokio::test]
12732 async fn entity_reindex_with_captured_merge_plan_excludes_late_model() {
12733 const DIMS: usize = 4;
12734 const PLANNED: &str = "merge-entity-plan-existing";
12735 const LATE: &str = "merge-entity-plan-late";
12736 let rt = KhiveRuntime::memory().unwrap();
12737 let ns = crate::Namespace::parse("merge-entity-plan-snapshot").unwrap();
12738 let tok = NamespaceToken::for_namespace(ns);
12739 let entity = rt
12740 .create_entity(
12741 &tok,
12742 "concept",
12743 None,
12744 "CapturedEntityPlan",
12745 Some("full source remains indexed"),
12746 None,
12747 vec![],
12748 )
12749 .await
12750 .expect("create entity before registering embedders");
12751
12752 rt.register_embedder(MergeTestVecProvider::new(PLANNED, DIMS));
12753 let embedding_plan = EmbeddingModelPlan::capture(&rt);
12754 rt.register_embedder(MergeTestVecProvider::new(LATE, DIMS));
12755
12756 rt.reindex_entity_with_plan(&tok, &entity, &embedding_plan)
12757 .await
12758 .expect("reindex entity with captured merge plan");
12759
12760 assert_eq!(embedding_plan.model_names().len(), 1);
12761 assert_eq!(embedding_plan.model_names()[0].as_str(), PLANNED);
12762 assert_eq!(
12763 rt.vectors_for_model(&tok, PLANNED)
12764 .unwrap()
12765 .count()
12766 .await
12767 .unwrap(),
12768 1
12769 );
12770 assert_eq!(
12771 rt.vectors_for_model(&tok, LATE)
12772 .unwrap()
12773 .count()
12774 .await
12775 .unwrap(),
12776 0,
12777 "a provider registered after plan capture must not join survivor reindex"
12778 );
12779 }
12780
12781 #[tokio::test]
12782 async fn note_reindex_with_captured_merge_plan_excludes_late_model() {
12783 const DIMS: usize = 4;
12784 const PLANNED: &str = "merge-note-plan-existing";
12785 const LATE: &str = "merge-note-plan-late";
12786 let rt = KhiveRuntime::memory().unwrap();
12787 let ns = crate::Namespace::parse("merge-note-plan-snapshot").unwrap();
12788 let tok = NamespaceToken::for_namespace(ns);
12789 let note = rt
12790 .create_note(
12791 &tok,
12792 "observation",
12793 None,
12794 "full note source remains indexed",
12795 None,
12796 None,
12797 vec![],
12798 )
12799 .await
12800 .expect("create note before registering embedders");
12801
12802 rt.register_embedder(MergeTestVecProvider::new(PLANNED, DIMS));
12803 let embedding_plan = EmbeddingModelPlan::capture(&rt);
12804 rt.register_embedder(MergeTestVecProvider::new(LATE, DIMS));
12805
12806 rt.reindex_note_with_plan(&tok, ¬e, &embedding_plan)
12807 .await
12808 .expect("reindex note with captured merge plan");
12809
12810 assert_eq!(embedding_plan.model_names().len(), 1);
12811 assert_eq!(embedding_plan.model_names()[0].as_str(), PLANNED);
12812 assert_eq!(
12813 rt.vectors_for_model(&tok, PLANNED)
12814 .unwrap()
12815 .count()
12816 .await
12817 .unwrap(),
12818 1
12819 );
12820 assert_eq!(
12821 rt.vectors_for_model(&tok, LATE)
12822 .unwrap()
12823 .count()
12824 .await
12825 .unwrap(),
12826 0,
12827 "a provider registered after plan capture must not join survivor reindex"
12828 );
12829 }
12830
12831 #[tokio::test]
12837 async fn merge_entity_clears_vectors_from_all_registered_models() {
12838 const DIMS: usize = 4;
12839 let rt = KhiveRuntime::memory().unwrap();
12840 rt.register_embedder(MergeTestVecProvider::new("merge-vec-a", DIMS));
12841 rt.register_embedder(MergeTestVecProvider::new("merge-vec-b", DIMS));
12842
12843 let ns_str = "merge-entity-vec-cleanup";
12844 let ns = crate::Namespace::parse(ns_str).unwrap();
12845 let tok = NamespaceToken::for_namespace(ns);
12846
12847 let into_e = rt
12848 .create_entity(
12849 &tok,
12850 "concept",
12851 None,
12852 "IntoVecEntity",
12853 Some("desc a"),
12854 None,
12855 vec![],
12856 )
12857 .await
12858 .expect("create into");
12859 let from_e = rt
12860 .create_entity(
12861 &tok,
12862 "concept",
12863 None,
12864 "FromVecEntity",
12865 Some("desc b"),
12866 None,
12867 vec![],
12868 )
12869 .await
12870 .expect("create from");
12871
12872 let vs_a = rt.vectors_for_model(&tok, "merge-vec-a").unwrap();
12874 let vs_b = rt.vectors_for_model(&tok, "merge-vec-b").unwrap();
12875 use khive_storage::types::VectorSearchRequest;
12876 let query = vec![1.0_f32; DIMS];
12877 let pre_a = vs_a
12878 .search(VectorSearchRequest {
12879 query_vectors: vec![query.clone()],
12880 top_k: 100,
12881 namespace: Some(ns_str.to_string()),
12882 kind: Some(khive_types::SubstrateKind::Entity),
12883 embedding_model: Some("merge-vec-a".to_string()),
12884 filter: None,
12885 backend_hints: None,
12886 })
12887 .await
12888 .unwrap();
12889 assert!(
12890 pre_a.iter().any(|h| h.subject_id == into_e.id)
12891 && pre_a.iter().any(|h| h.subject_id == from_e.id),
12892 "both entities must be in model-a before merge; got {pre_a:?}"
12893 );
12894
12895 let pre_b = vs_b
12898 .search(VectorSearchRequest {
12899 query_vectors: vec![query.clone()],
12900 top_k: 100,
12901 namespace: Some(ns_str.to_string()),
12902 kind: Some(khive_types::SubstrateKind::Entity),
12903 embedding_model: Some("merge-vec-b".to_string()),
12904 filter: None,
12905 backend_hints: None,
12906 })
12907 .await
12908 .unwrap();
12909 assert!(
12910 pre_b.iter().any(|h| h.subject_id == into_e.id)
12911 && pre_b.iter().any(|h| h.subject_id == from_e.id),
12912 "both entities must be in model-b before merge; got {pre_b:?}"
12913 );
12914
12915 rt.merge_entity_with_reason(
12916 &tok,
12917 into_e.id,
12918 from_e.id,
12919 EntityDedupMergePolicy::PreferInto,
12920 ContentMergeStrategy::Append,
12921 false,
12922 None,
12923 )
12924 .await
12925 .expect("merge_entity");
12926
12927 let post_a = vs_a
12928 .search(VectorSearchRequest {
12929 query_vectors: vec![query.clone()],
12930 top_k: 100,
12931 namespace: Some(ns_str.to_string()),
12932 kind: Some(khive_types::SubstrateKind::Entity),
12933 embedding_model: Some("merge-vec-a".to_string()),
12934 filter: None,
12935 backend_hints: None,
12936 })
12937 .await
12938 .unwrap();
12939 let from_ids_a: Vec<_> = post_a
12940 .iter()
12941 .filter(|h| h.subject_id == from_e.id)
12942 .collect();
12943 assert!(
12944 from_ids_a.is_empty(),
12945 "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
12946 );
12947
12948 let post_b = vs_b
12949 .search(VectorSearchRequest {
12950 query_vectors: vec![query],
12951 top_k: 100,
12952 namespace: Some(ns_str.to_string()),
12953 kind: Some(khive_types::SubstrateKind::Entity),
12954 embedding_model: Some("merge-vec-b".to_string()),
12955 filter: None,
12956 backend_hints: None,
12957 })
12958 .await
12959 .unwrap();
12960 let from_ids_b: Vec<_> = post_b
12961 .iter()
12962 .filter(|h| h.subject_id == from_e.id)
12963 .collect();
12964 assert!(
12965 from_ids_b.is_empty(),
12966 "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
12967 );
12968 }
12969
12970 #[tokio::test]
12976 async fn merge_note_clears_vectors_from_all_registered_models() {
12977 const DIMS: usize = 4;
12978 let rt = KhiveRuntime::memory().unwrap();
12979 rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-a", DIMS));
12980 rt.register_embedder(MergeTestVecProvider::new("merge-note-vec-b", DIMS));
12981
12982 let ns_str = "merge-note-vec-cleanup";
12983 let ns = crate::Namespace::parse(ns_str).unwrap();
12984 let tok = NamespaceToken::for_namespace(ns);
12985
12986 let into_n = rt
12987 .create_note(
12988 &tok,
12989 "observation",
12990 None,
12991 "IntoVecNote content",
12992 None,
12993 None,
12994 vec![],
12995 )
12996 .await
12997 .expect("create into note");
12998 let from_n = rt
12999 .create_note(
13000 &tok,
13001 "observation",
13002 None,
13003 "FromVecNote content",
13004 None,
13005 None,
13006 vec![],
13007 )
13008 .await
13009 .expect("create from note");
13010
13011 let vs_a = rt.vectors_for_model(&tok, "merge-note-vec-a").unwrap();
13012 let vs_b = rt.vectors_for_model(&tok, "merge-note-vec-b").unwrap();
13013 use khive_storage::types::VectorSearchRequest;
13014 let query = vec![1.0_f32; DIMS];
13015
13016 let pre_a = vs_a
13017 .search(VectorSearchRequest {
13018 query_vectors: vec![query.clone()],
13019 top_k: 100,
13020 namespace: Some(ns_str.to_string()),
13021 kind: Some(khive_types::SubstrateKind::Note),
13022 embedding_model: Some("merge-note-vec-a".to_string()),
13023 filter: None,
13024 backend_hints: None,
13025 })
13026 .await
13027 .unwrap();
13028 assert!(
13029 pre_a.iter().any(|h| h.subject_id == into_n.id)
13030 && pre_a.iter().any(|h| h.subject_id == from_n.id),
13031 "both notes must be in model-a before merge; got {pre_a:?}"
13032 );
13033
13034 let pre_b = vs_b
13037 .search(VectorSearchRequest {
13038 query_vectors: vec![query.clone()],
13039 top_k: 100,
13040 namespace: Some(ns_str.to_string()),
13041 kind: Some(khive_types::SubstrateKind::Note),
13042 embedding_model: Some("merge-note-vec-b".to_string()),
13043 filter: None,
13044 backend_hints: None,
13045 })
13046 .await
13047 .unwrap();
13048 assert!(
13049 pre_b.iter().any(|h| h.subject_id == into_n.id)
13050 && pre_b.iter().any(|h| h.subject_id == from_n.id),
13051 "both notes must be in model-b before merge; got {pre_b:?}"
13052 );
13053
13054 rt.merge_note_with_reason(
13055 &tok,
13056 into_n.id,
13057 from_n.id,
13058 EntityDedupMergePolicy::PreferInto,
13059 ContentMergeStrategy::PreferInto,
13060 false,
13061 None,
13062 )
13063 .await
13064 .expect("merge_note");
13065
13066 let post_a = vs_a
13067 .search(VectorSearchRequest {
13068 query_vectors: vec![query.clone()],
13069 top_k: 100,
13070 namespace: Some(ns_str.to_string()),
13071 kind: Some(khive_types::SubstrateKind::Note),
13072 embedding_model: Some("merge-note-vec-a".to_string()),
13073 filter: None,
13074 backend_hints: None,
13075 })
13076 .await
13077 .unwrap();
13078 let from_ids_a: Vec<_> = post_a
13079 .iter()
13080 .filter(|h| h.subject_id == from_n.id)
13081 .collect();
13082 assert!(
13083 from_ids_a.is_empty(),
13084 "from_id must have no vectors in model-a after merge; got {from_ids_a:?}"
13085 );
13086
13087 let post_b = vs_b
13088 .search(VectorSearchRequest {
13089 query_vectors: vec![query],
13090 top_k: 100,
13091 namespace: Some(ns_str.to_string()),
13092 kind: Some(khive_types::SubstrateKind::Note),
13093 embedding_model: Some("merge-note-vec-b".to_string()),
13094 filter: None,
13095 backend_hints: None,
13096 })
13097 .await
13098 .unwrap();
13099 let from_ids_b: Vec<_> = post_b
13100 .iter()
13101 .filter(|h| h.subject_id == from_n.id)
13102 .collect();
13103 assert!(
13104 from_ids_b.is_empty(),
13105 "from_id must have no vectors in model-b after merge; got {from_ids_b:?}"
13106 );
13107 }
13108
13109 #[tokio::test]
13112 async fn entity_fts_document_matches_runtime_merge_path() {
13113 let rt = rt();
13114 let tok = NamespaceToken::local();
13115
13116 let into_e = rt
13117 .create_entity(
13118 &tok,
13119 "concept",
13120 None,
13121 "IntoEntity",
13122 Some("into desc"),
13123 None,
13124 vec![],
13125 )
13126 .await
13127 .expect("create into");
13128 let from_e = rt
13129 .create_entity(
13130 &tok,
13131 "concept",
13132 None,
13133 "FromEntity",
13134 Some("from desc"),
13135 None,
13136 vec![],
13137 )
13138 .await
13139 .expect("create from");
13140
13141 let summary = rt
13142 .merge_entity_with_reason(
13143 &tok,
13144 into_e.id,
13145 from_e.id,
13146 EntityDedupMergePolicy::PreferInto,
13147 ContentMergeStrategy::Append,
13148 false,
13149 None,
13150 )
13151 .await
13152 .expect("merge_entity");
13153
13154 let kept = rt
13155 .get_entity(&tok, summary.kept_id)
13156 .await
13157 .expect("get kept");
13158
13159 let fts = rt.text(&tok).expect("FTS store");
13160 let stored = fts
13161 .get_document("local", kept.id)
13162 .await
13163 .expect("get_document")
13164 .expect("FTS document must exist for kept entity after merge");
13165
13166 let expected = entity_fts_document(&kept);
13167
13168 assert_eq!(stored.title, expected.title, "title after merge");
13169 assert_eq!(stored.body, expected.body, "body after merge");
13170 }
13171
13172 #[test]
13186 fn recomputed_count_agrees_with_fold_on_whole_value_replacement() {
13187 use serde_json::json;
13188
13189 for (into, from, label) in [
13190 (json!({"a": 1}), json!(5), "object replaced by scalar"),
13191 (
13192 json!(7),
13193 json!({"b": 2}),
13194 "scalar replaced by single-key object",
13195 ),
13196 (
13202 json!(7),
13203 json!({"b": 2, "c": 3}),
13204 "scalar replaced by multi-key object",
13205 ),
13206 ] {
13207 let (merged, fold_count) = merge_json(&into, &from, EntityDedupMergePolicy::PreferFrom);
13208 let recomputed = count_new_property_keys(
13209 Some(&into),
13210 Some(&merged),
13211 EntityDedupMergePolicy::PreferFrom,
13212 );
13213 assert_eq!(
13214 recomputed, fold_count,
13215 "{label}: recomputed count must match the fold's own count",
13216 );
13217 assert_eq!(recomputed, 1, "{label}: one value was contributed");
13218 }
13219
13220 let (merged, fold_count) = merge_json(
13224 &json!({"a": 1}),
13225 &json!({"a": 2}),
13226 EntityDedupMergePolicy::PreferFrom,
13227 );
13228 let recomputed = count_new_property_keys(
13229 Some(&json!({"a": 1})),
13230 Some(&merged),
13231 EntityDedupMergePolicy::PreferFrom,
13232 );
13233 assert_eq!(fold_count, 0, "control: overwrite is never a fold addition");
13234 assert_eq!(recomputed, 0, "control: overwrite is never a new key");
13235
13236 assert_eq!(
13239 count_new_property_keys(
13240 Some(&json!({"a": 1})),
13241 Some(&json!({"a": 1})),
13242 EntityDedupMergePolicy::PreferFrom,
13243 ),
13244 0,
13245 "control: an unchanged properties object contributes nothing",
13246 );
13247 }
13248
13249 #[test]
13260 fn recomputed_count_recurses_into_nested_objects_only_under_union() {
13261 use serde_json::json;
13262
13263 let into = json!({"meta": {"old": 1}});
13264 let from = json!({"meta": {"new": 2}});
13265
13266 for (strategy, expected, label) in [
13267 (
13268 EntityDedupMergePolicy::PreferFrom,
13269 0,
13270 "prefer_from replaces the whole key",
13271 ),
13272 (
13273 EntityDedupMergePolicy::PreferInto,
13274 0,
13275 "prefer_into keeps the whole key",
13276 ),
13277 (
13278 EntityDedupMergePolicy::Union,
13279 1,
13280 "union merges beneath the key",
13281 ),
13282 ] {
13283 let (merged, fold_count) = merge_json(&into, &from, strategy);
13284 let recomputed = count_new_property_keys(Some(&into), Some(&merged), strategy);
13285 assert_eq!(
13286 recomputed, fold_count,
13287 "{label}: recomputed count must match the fold's own count",
13288 );
13289 assert_eq!(recomputed, expected, "{label}");
13290 }
13291 }
13292
13293 #[test]
13301 fn recomputed_count_is_zero_when_restoration_empties_the_object() {
13302 use serde_json::json;
13303
13304 assert_eq!(
13305 count_new_property_keys(
13306 Some(&json!("scalar-properties")),
13307 Some(&json!({})),
13308 EntityDedupMergePolicy::PreferFrom,
13309 ),
13310 0,
13311 "an emptied object retains nothing from the absorbed record",
13312 );
13313
13314 assert_eq!(
13317 count_new_property_keys(
13318 Some(&json!("scalar-properties")),
13319 Some(&json!({"kept": 1})),
13320 EntityDedupMergePolicy::PreferFrom,
13321 ),
13322 1,
13323 "a surviving key is still counted",
13324 );
13325 }
13326
13327 async fn run_entity_merge_with_limits(
13334 rt: &KhiveRuntime,
13335 into_id: Uuid,
13336 from_id: Uuid,
13337 limits: MergeTxLimits,
13338 ) -> Result<(MergeSummary, Entity), SqliteError> {
13339 let pack_rules = rt.pack_edge_rules();
13340 let pool = rt.backend().pool_arc();
13341 tokio::task::spawn_blocking(move || {
13342 let guard = pool.writer().unwrap();
13343 guard.transaction(|conn| {
13344 merge_entity_sql(
13345 conn,
13346 "local".to_string(),
13347 "fts_entities".to_string(),
13348 Vec::new(),
13349 into_id,
13350 from_id,
13351 EntityDedupMergePolicy::PreferInto,
13352 ContentMergeStrategy::Append,
13353 false,
13354 pack_rules,
13355 EntityMergeValidation::LegacyKind,
13356 limits,
13357 Uuid::new_v4(),
13358 None,
13359 )
13360 .map_err(|error| match error {
13361 MergeSqlError::Sqlite(error) => error,
13362 MergeSqlError::Refusal(_) => SqliteError::InvalidData(
13363 "unexpected transactional policy refusal".to_string(),
13364 ),
13365 })
13366 })
13367 })
13368 .await
13369 .unwrap()
13370 }
13371
13372 async fn preview_entity_merge_bytes(rt: &KhiveRuntime, into_id: Uuid, from_id: Uuid) -> usize {
13379 let pack_rules = rt.pack_edge_rules();
13380 let pool = rt.backend().pool_arc();
13381 let (summary, _) = tokio::task::spawn_blocking(move || {
13382 let guard = pool.writer().unwrap();
13383 guard.transaction(|conn| {
13384 merge_entity_sql(
13385 conn,
13386 "local".to_string(),
13387 "fts_entities".to_string(),
13388 Vec::new(),
13389 into_id,
13390 from_id,
13391 EntityDedupMergePolicy::PreferInto,
13392 ContentMergeStrategy::Append,
13393 true,
13394 pack_rules,
13395 EntityMergeValidation::LegacyKind,
13396 MergeTxLimits {
13397 max_rows: usize::MAX,
13398 max_bytes: usize::MAX,
13399 },
13400 Uuid::new_v4(),
13401 None,
13402 )
13403 .map_err(|error| match error {
13404 MergeSqlError::Sqlite(error) => error,
13405 MergeSqlError::Refusal(_) => SqliteError::InvalidData(
13406 "unexpected transactional policy refusal".to_string(),
13407 ),
13408 })
13409 })
13410 })
13411 .await
13412 .unwrap()
13413 .unwrap();
13414 summary.tx_budget.bytes_charged
13415 }
13416
13417 #[tokio::test]
13425 async fn merge_entity_byte_budget_rejects_multibyte_properties_char_count_would_pass() {
13426 let rt = rt();
13427 let tok = NamespaceToken::local();
13428
13429 let ascii_payload = "x".repeat(200);
13430 let multibyte_payload = "\u{4e2d}".repeat(200);
13431 assert_eq!(
13432 ascii_payload.chars().count(),
13433 multibyte_payload.chars().count(),
13434 "control and payload must share one character count"
13435 );
13436 assert!(
13437 multibyte_payload.len() > ascii_payload.len(),
13438 "multibyte payload must have more UTF-8 bytes than the ASCII control"
13439 );
13440
13441 let into_ascii = rt
13442 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13443 .await
13444 .unwrap();
13445 let from_ascii = rt
13446 .create_entity(
13447 &tok,
13448 "concept",
13449 None,
13450 "From",
13451 None,
13452 Some(serde_json::json!({ "note": ascii_payload })),
13453 vec![],
13454 )
13455 .await
13456 .unwrap();
13457 let into_multi = rt
13458 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13459 .await
13460 .unwrap();
13461 let from_multi = rt
13462 .create_entity(
13463 &tok,
13464 "concept",
13465 None,
13466 "From",
13467 None,
13468 Some(serde_json::json!({ "note": multibyte_payload })),
13469 vec![],
13470 )
13471 .await
13472 .unwrap();
13473
13474 let ascii_total_bytes = preview_entity_merge_bytes(&rt, into_ascii.id, from_ascii.id).await;
13475 let multi_total_bytes = preview_entity_merge_bytes(&rt, into_multi.id, from_multi.id).await;
13476 assert!(
13477 multi_total_bytes > ascii_total_bytes,
13478 "byte-accurate probe must charge more for the multibyte record: \
13479 ascii={ascii_total_bytes} multi={multi_total_bytes}"
13480 );
13481
13482 let limits = MergeTxLimits {
13486 max_rows: usize::MAX,
13487 max_bytes: ascii_total_bytes,
13488 };
13489
13490 run_entity_merge_with_limits(&rt, into_ascii.id, from_ascii.id, limits)
13491 .await
13492 .expect("ASCII control's true byte cost must fit its own threshold");
13493
13494 let error = run_entity_merge_with_limits(&rt, into_multi.id, from_multi.id, limits)
13495 .await
13496 .unwrap_err();
13497 let msg = error.to_string();
13498 assert!(
13499 msg.contains("merge transaction budget exceeded"),
13500 "multibyte properties must be rejected by the byte-accurate probe; got: {msg}"
13501 );
13502 assert!(msg.contains("reading merge records"), "got: {msg}");
13503 assert!(
13504 rt.get_entity(&tok, from_multi.id).await.is_ok(),
13505 "from-entity must survive a budget-rejected merge"
13506 );
13507 }
13508
13509 async fn run_note_merge_with_limits(
13510 rt: &KhiveRuntime,
13511 into_id: Uuid,
13512 from_id: Uuid,
13513 pack_rules: Vec<khive_types::EdgeEndpointRule>,
13514 limits: MergeTxLimits,
13515 ) -> Result<(MergeSummary, Note), SqliteError> {
13516 let pool = rt.backend().pool_arc();
13517 tokio::task::spawn_blocking(move || {
13518 let guard = pool.writer().unwrap();
13519 guard.transaction(|conn| {
13520 merge_note_sql(
13521 conn,
13522 "local".to_string(),
13523 "fts_notes".to_string(),
13524 Vec::new(),
13525 into_id,
13526 from_id,
13527 EntityDedupMergePolicy::PreferInto,
13528 ContentMergeStrategy::Append,
13529 false,
13530 pack_rules,
13531 false,
13532 limits,
13533 None,
13534 )
13535 .map_err(|error| match error {
13536 MergeSqlError::Sqlite(error) => error,
13537 MergeSqlError::Refusal(_) => SqliteError::InvalidData(
13538 "unexpected transactional policy refusal".to_string(),
13539 ),
13540 })
13541 })
13542 })
13543 .await
13544 .unwrap()
13545 }
13546
13547 #[tokio::test]
13548 async fn merge_entity_rejects_row_budget_while_collecting_incident_edges() {
13549 use khive_storage::EdgeRelation;
13550 let rt = rt();
13551 let tok = NamespaceToken::local();
13552 let into = rt
13553 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13554 .await
13555 .unwrap();
13556 let from = rt
13557 .create_entity(&tok, "concept", None, "From", None, None, vec![])
13558 .await
13559 .unwrap();
13560 for name in ["T1", "T2", "T3"] {
13561 let target = rt
13562 .create_entity(&tok, "concept", None, name, None, None, vec![])
13563 .await
13564 .unwrap();
13565 rt.link(&tok, from.id, target.id, EdgeRelation::Extends, 1.0, None)
13566 .await
13567 .unwrap();
13568 }
13569
13570 let error = run_entity_merge_with_limits(
13573 &rt,
13574 into.id,
13575 from.id,
13576 MergeTxLimits {
13577 max_rows: 4,
13578 max_bytes: usize::MAX,
13579 },
13580 )
13581 .await
13582 .unwrap_err();
13583 let msg = error.to_string();
13584 assert!(
13585 msg.contains("merge transaction budget exceeded"),
13586 "got: {msg}"
13587 );
13588 assert!(msg.contains("collecting incident edges"), "got: {msg}");
13589
13590 assert!(
13592 rt.get_entity(&tok, from.id).await.is_ok(),
13593 "from-entity must survive a budget-rejected merge"
13594 );
13595 let edges = rt
13596 .list_edges(
13597 &tok,
13598 EdgeListFilter {
13599 source_id: Some(from.id),
13600 ..Default::default()
13601 },
13602 10,
13603 0,
13604 )
13605 .await
13606 .unwrap();
13607 assert_eq!(
13608 edges.len(),
13609 3,
13610 "every incident edge must survive a budget-rejected merge"
13611 );
13612 }
13613
13614 #[tokio::test]
13615 async fn merge_entity_rejects_row_budget_while_collecting_conflict_cascade_rows() {
13616 use khive_storage::EdgeRelation;
13617 let rt = rt();
13618 let tok = NamespaceToken::local();
13619 let into = rt
13620 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13621 .await
13622 .unwrap();
13623 let from = rt
13624 .create_entity(&tok, "concept", None, "From", None, None, vec![])
13625 .await
13626 .unwrap();
13627 let shared = rt
13628 .create_entity(&tok, "concept", None, "Shared", None, None, vec![])
13629 .await
13630 .unwrap();
13631 let annotator = rt
13632 .create_note(
13633 &tok,
13634 "observation",
13635 None,
13636 "annotator note",
13637 None,
13638 None,
13639 vec![],
13640 )
13641 .await
13642 .unwrap();
13643 let nested_annotator = rt
13644 .create_note(&tok, "observation", None, "nested note", None, None, vec![])
13645 .await
13646 .unwrap();
13647 rt.link(&tok, into.id, shared.id, EdgeRelation::Extends, 0.9, None)
13648 .await
13649 .unwrap();
13650 let dropped = rt
13651 .link(&tok, from.id, shared.id, EdgeRelation::Extends, 0.2, None)
13652 .await
13653 .unwrap();
13654 let annotation = rt
13655 .link(
13656 &tok,
13657 annotator.id,
13658 dropped.id.into(),
13659 EdgeRelation::Annotates,
13660 0.7,
13661 None,
13662 )
13663 .await
13664 .unwrap();
13665 let nested_annotation = rt
13666 .link(
13667 &tok,
13668 nested_annotator.id,
13669 annotation.id.into(),
13670 EdgeRelation::Annotates,
13671 0.6,
13672 None,
13673 )
13674 .await
13675 .unwrap();
13676
13677 let error = run_entity_merge_with_limits(
13682 &rt,
13683 into.id,
13684 from.id,
13685 MergeTxLimits {
13686 max_rows: 5,
13687 max_bytes: usize::MAX,
13688 },
13689 )
13690 .await
13691 .unwrap_err();
13692 let msg = error.to_string();
13693 assert!(
13694 msg.contains("merge transaction budget exceeded"),
13695 "got: {msg}"
13696 );
13697 assert!(
13698 msg.contains("collecting conflict cascade rows"),
13699 "got: {msg}"
13700 );
13701
13702 for id in [dropped.id, annotation.id, nested_annotation.id] {
13704 assert!(
13705 rt.get_edge_including_deleted(&tok, id.into())
13706 .await
13707 .unwrap()
13708 .is_some(),
13709 "edge {id} must survive a budget-rejected merge"
13710 );
13711 }
13712 assert!(
13713 rt.get_entity(&tok, from.id).await.is_ok(),
13714 "from-entity must survive a budget-rejected merge"
13715 );
13716 }
13717
13718 #[tokio::test]
13719 async fn merge_note_rejects_byte_budget_while_reading_merge_records() {
13720 let rt = rt();
13721 let tok = NamespaceToken::local();
13722 let into = rt
13723 .create_note(
13724 &tok,
13725 "observation",
13726 None,
13727 "into content",
13728 None,
13729 None,
13730 vec![],
13731 )
13732 .await
13733 .unwrap();
13734 let fat = "x".repeat(8192);
13735 let from = rt
13736 .create_note(&tok, "observation", None, &fat, None, None, vec![])
13737 .await
13738 .unwrap();
13739
13740 let error = run_note_merge_with_limits(
13741 &rt,
13742 into.id,
13743 from.id,
13744 Vec::new(),
13745 MergeTxLimits {
13746 max_rows: usize::MAX,
13747 max_bytes: 4096,
13748 },
13749 )
13750 .await
13751 .unwrap_err();
13752 let msg = error.to_string();
13753 assert!(
13754 msg.contains("merge transaction budget exceeded"),
13755 "got: {msg}"
13756 );
13757 assert!(msg.contains("reading merge records"), "got: {msg}");
13758
13759 assert!(
13760 rt.notes(&tok)
13761 .unwrap()
13762 .get_note(from.id)
13763 .await
13764 .unwrap()
13765 .is_some(),
13766 "from-note must survive a budget-rejected merge"
13767 );
13768 }
13769
13770 #[tokio::test]
13782 async fn merge_entity_rejects_byte_budget_before_parsing_oversized_malformed_properties() {
13783 let rt = rt();
13784 let tok = NamespaceToken::local();
13785 let into = rt
13786 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13787 .await
13788 .unwrap();
13789 let from = rt
13790 .create_entity(&tok, "concept", None, "From", None, None, vec![])
13791 .await
13792 .unwrap();
13793
13794 let huge_malformed_properties = format!("{{not valid json: {}", "x".repeat(8192));
13795 let pool = rt.backend().pool_arc();
13796 let from_id = from.id;
13797 tokio::task::spawn_blocking(move || {
13798 let guard = pool.writer().unwrap();
13799 guard.transaction(|conn| {
13800 conn.execute(
13801 "UPDATE entities SET version = version + 1, properties = ?1 WHERE id = ?2",
13802 rusqlite::params![huge_malformed_properties, from_id.to_string()],
13803 )?;
13804 Ok(())
13805 })
13806 })
13807 .await
13808 .unwrap()
13809 .unwrap();
13810
13811 let error = run_entity_merge_with_limits(
13812 &rt,
13813 into.id,
13814 from.id,
13815 MergeTxLimits {
13816 max_rows: usize::MAX,
13817 max_bytes: 4096,
13818 },
13819 )
13820 .await
13821 .unwrap_err();
13822 let msg = error.to_string();
13823 assert!(
13824 msg.contains("merge transaction budget exceeded"),
13825 "expected an early budget rejection, not a JSON parse failure; got: {msg}"
13826 );
13827 assert!(msg.contains("reading merge records"), "got: {msg}");
13828
13829 let pool = rt.backend().pool_arc();
13833 let still_present: i64 = tokio::task::spawn_blocking(move || {
13834 let guard = pool.writer().unwrap();
13835 guard.transaction(|conn| {
13836 conn.query_row(
13837 "SELECT COUNT(*) FROM entities WHERE id = ?1 AND deleted_at IS NULL",
13838 rusqlite::params![from_id.to_string()],
13839 |row| row.get(0),
13840 )
13841 .map_err(SqliteError::Rusqlite)
13842 })
13843 })
13844 .await
13845 .unwrap()
13846 .unwrap();
13847 assert_eq!(
13848 still_present, 1,
13849 "from-entity must survive a budget-rejected merge"
13850 );
13851 }
13852
13853 #[tokio::test]
13854 async fn merge_note_rejects_row_budget_while_collecting_incident_edges() {
13855 use khive_storage::EdgeRelation;
13856 let rt = rt();
13857 let tok = NamespaceToken::local();
13858 let into = rt
13859 .create_note(&tok, "observation", None, "Into", None, None, vec![])
13860 .await
13861 .unwrap();
13862 let from = rt
13863 .create_note(&tok, "observation", None, "From", None, None, vec![])
13864 .await
13865 .unwrap();
13866 for name in ["T1", "T2", "T3"] {
13867 let target = rt
13868 .create_entity(&tok, "concept", None, name, None, None, vec![])
13869 .await
13870 .unwrap();
13871 rt.link(&tok, from.id, target.id, EdgeRelation::Annotates, 1.0, None)
13872 .await
13873 .unwrap();
13874 }
13875
13876 let error = run_note_merge_with_limits(
13877 &rt,
13878 into.id,
13879 from.id,
13880 rt.pack_edge_rules(),
13881 MergeTxLimits {
13882 max_rows: 4,
13883 max_bytes: usize::MAX,
13884 },
13885 )
13886 .await
13887 .unwrap_err();
13888 let msg = error.to_string();
13889 assert!(
13890 msg.contains("merge transaction budget exceeded"),
13891 "got: {msg}"
13892 );
13893 assert!(msg.contains("collecting incident edges"), "got: {msg}");
13894
13895 let edges = rt
13896 .list_edges(
13897 &tok,
13898 EdgeListFilter {
13899 source_id: Some(from.id),
13900 ..Default::default()
13901 },
13902 10,
13903 0,
13904 )
13905 .await
13906 .unwrap();
13907 assert_eq!(
13908 edges.len(),
13909 3,
13910 "every incident edge must survive a budget-rejected merge"
13911 );
13912 }
13913
13914 use crate::pack::tests::budget_log_events;
13921
13922 #[tokio::test]
13923 async fn merge_entity_reports_and_logs_tx_budget_after_commit() {
13924 use khive_storage::EdgeRelation;
13925 let events = budget_log_events();
13926
13927 let rt = rt();
13928 let tok = NamespaceToken::local();
13929 let into = rt
13930 .create_entity(&tok, "concept", None, "Into", None, None, vec![])
13931 .await
13932 .unwrap();
13933 let from = rt
13934 .create_entity(&tok, "concept", None, "From", None, None, vec![])
13935 .await
13936 .unwrap();
13937 let target = rt
13938 .create_entity(&tok, "concept", None, "Target", None, None, vec![])
13939 .await
13940 .unwrap();
13941 rt.link(&tok, from.id, target.id, EdgeRelation::Extends, 1.0, None)
13942 .await
13943 .unwrap();
13944
13945 let preview = rt
13948 .merge_entity(
13949 &tok,
13950 into.id,
13951 from.id,
13952 EntityDedupMergePolicy::PreferInto,
13953 ContentMergeStrategy::Append,
13954 true,
13955 )
13956 .await
13957 .unwrap();
13958 assert!(preview.tx_budget.rows_charged >= 2);
13959 assert_eq!(preview.tx_budget.max_rows, MERGE_TX_MAX_ROWS);
13960 assert_eq!(preview.tx_budget.max_bytes, MERGE_TX_MAX_BYTES);
13961 assert!(
13962 events
13963 .lock()
13964 .unwrap()
13965 .iter()
13966 .all(|e| e.into_id != into.id.to_string()),
13967 "a dry-run preview must not emit the post-commit budget log"
13968 );
13969
13970 let summary = rt
13971 .merge_entity(
13972 &tok,
13973 into.id,
13974 from.id,
13975 EntityDedupMergePolicy::PreferInto,
13976 ContentMergeStrategy::Append,
13977 false,
13978 )
13979 .await
13980 .unwrap();
13981 assert!(summary.tx_budget.rows_charged >= 2);
13982 assert!(summary.tx_budget.bytes_charged > 0);
13983
13984 let captured = events.lock().unwrap();
13985 let row = captured
13986 .iter()
13987 .find(|e| {
13988 e.into_id == summary.kept_id.to_string()
13989 && e.message == "merge_entity: transaction materialization budget"
13990 })
13991 .expect("committing entity merge must emit the post-commit budget log");
13992 assert_eq!(
13993 row.budget_rows as usize, summary.tx_budget.rows_charged,
13994 "the log must carry the same observed row count the summary reports"
13995 );
13996 }
13997
13998 #[tokio::test]
13999 async fn merge_note_reports_and_logs_tx_budget_after_commit() {
14000 let events = budget_log_events();
14001
14002 let rt = rt();
14003 let tok = NamespaceToken::local();
14004 let into = rt
14005 .create_note(&tok, "observation", None, "Into", None, None, vec![])
14006 .await
14007 .unwrap();
14008 let from = rt
14009 .create_note(&tok, "observation", None, "From", None, None, vec![])
14010 .await
14011 .unwrap();
14012
14013 let summary = rt
14014 .merge_note(
14015 &tok,
14016 into.id,
14017 from.id,
14018 EntityDedupMergePolicy::PreferInto,
14019 ContentMergeStrategy::Append,
14020 false,
14021 )
14022 .await
14023 .unwrap();
14024 assert!(summary.tx_budget.rows_charged >= 2);
14025 assert!(summary.tx_budget.bytes_charged > 0);
14026
14027 let captured = events.lock().unwrap();
14028 let row = captured
14029 .iter()
14030 .find(|e| {
14031 e.into_id == summary.kept_id.to_string()
14032 && e.message == "merge_note: transaction materialization budget"
14033 })
14034 .expect("committing note merge must emit the post-commit budget log");
14035 assert_eq!(
14036 row.budget_rows as usize, summary.tx_budget.rows_charged,
14037 "the log must carry the same observed row count the summary reports"
14038 );
14039 }
14040
14041 fn reserved_key_props() -> serde_json::Value {
14044 serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"})
14045 }
14046
14047 #[tokio::test]
14048 async fn update_entity_rejects_reserved_secret_gate_key() {
14049 let rt = rt();
14050 let tok = NamespaceToken::local();
14051 let entity = rt
14052 .create_entity(
14053 &tok,
14054 "concept",
14055 None,
14056 "reservation-target-entity",
14057 None,
14058 Some(serde_json::json!({"k": "v"})),
14059 vec![],
14060 )
14061 .await
14062 .unwrap();
14063
14064 let err = rt
14065 .update_entity(
14066 &tok,
14067 entity.id,
14068 EntityPatch {
14069 properties: Some(reserved_key_props()),
14070 ..Default::default()
14071 },
14072 )
14073 .await
14074 .expect_err("caller-supplied reserved key must be rejected on patch update");
14075 assert!(
14076 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
14077 "unexpected error: {err:?}"
14078 );
14079
14080 let unchanged = rt
14082 .entities(&tok)
14083 .unwrap()
14084 .get_entity(entity.id)
14085 .await
14086 .unwrap()
14087 .unwrap();
14088 assert_eq!(unchanged.properties, Some(serde_json::json!({"k": "v"})));
14089 }
14090
14091 #[tokio::test]
14092 async fn update_note_rejects_reserved_secret_gate_key() {
14093 let rt = rt();
14094 let tok = NamespaceToken::local();
14095 let note = rt
14096 .create_note(
14097 &tok,
14098 "observation",
14099 None,
14100 "reservation target note",
14101 None,
14102 Some(serde_json::json!({"k": "v"})),
14103 vec![],
14104 )
14105 .await
14106 .unwrap();
14107
14108 let err = rt
14109 .update_note(
14110 &tok,
14111 note.id,
14112 NotePatch::new(None, None, None, None, Some(reserved_key_props())),
14113 )
14114 .await
14115 .expect_err("caller-supplied reserved key must be rejected on patch update");
14116 assert!(
14117 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
14118 "unexpected error: {err:?}"
14119 );
14120
14121 let unchanged = rt
14122 .notes(&tok)
14123 .unwrap()
14124 .get_note(note.id)
14125 .await
14126 .unwrap()
14127 .unwrap();
14128 assert_eq!(unchanged.properties, Some(serde_json::json!({"k": "v"})));
14129 }
14130
14131 #[derive(Debug, Default)]
14148 struct RefusingKindHook;
14149
14150 #[async_trait::async_trait]
14151 impl crate::pack::KindHook for RefusingKindHook {
14152 async fn prepare_create(
14153 &self,
14154 _runtime: &KhiveRuntime,
14155 _args: &mut Value,
14156 ) -> Result<(), RuntimeError> {
14157 Ok(())
14158 }
14159
14160 async fn after_create(
14161 &self,
14162 _runtime: &KhiveRuntime,
14163 _id: Uuid,
14164 _args: &Value,
14165 ) -> Result<(), RuntimeError> {
14166 Ok(())
14167 }
14168
14169 async fn validate_entity_update(
14170 &self,
14171 _runtime: &KhiveRuntime,
14172 _token: &NamespaceToken,
14173 _entity: &Entity,
14174 properties: Option<&Value>,
14175 ) -> Result<(), RuntimeError> {
14176 let ok = properties
14177 .and_then(Value::as_object)
14178 .and_then(|p| p.get("ok"))
14179 .and_then(Value::as_bool)
14180 .unwrap_or(false);
14181 if ok {
14182 Ok(())
14183 } else {
14184 Err(RuntimeError::InvalidInput(
14185 "widget update requires properties.ok == true".into(),
14186 ))
14187 }
14188 }
14189 }
14190
14191 #[derive(Debug, Default)]
14203 struct SilentKindHook;
14204
14205 #[async_trait::async_trait]
14206 impl crate::pack::KindHook for SilentKindHook {
14207 async fn prepare_create(
14208 &self,
14209 _runtime: &KhiveRuntime,
14210 _args: &mut Value,
14211 ) -> Result<(), RuntimeError> {
14212 Ok(())
14213 }
14214
14215 async fn after_create(
14216 &self,
14217 _runtime: &KhiveRuntime,
14218 _id: Uuid,
14219 _args: &Value,
14220 ) -> Result<(), RuntimeError> {
14221 Ok(())
14222 }
14223 }
14224
14225 #[tokio::test]
14226 async fn entity_update_dispatches_installed_kind_hook_refusal() {
14227 let rt = rt();
14228 rt.install_entity_kind_hooks(vec![(
14229 "widget".to_string(),
14230 Arc::new(RefusingKindHook) as Arc<dyn crate::pack::KindHook>,
14231 )]);
14232 let tok = NamespaceToken::local();
14233 let entity = rt
14234 .create_entity(
14235 &tok,
14236 "widget",
14237 None,
14238 "Gadget",
14239 None,
14240 Some(serde_json::json!({"ok": true})),
14241 vec![],
14242 )
14243 .await
14244 .unwrap();
14245
14246 let error = rt
14247 .update_entity(
14248 &tok,
14249 entity.id,
14250 EntityPatch {
14251 properties: Some(serde_json::json!({"ok": false})),
14252 ..Default::default()
14253 },
14254 )
14255 .await
14256 .expect_err("installed hook must refuse the merged properties");
14257 assert!(
14258 matches!(error, RuntimeError::InvalidInput(ref msg) if msg.contains("requires properties.ok")),
14259 "unexpected error: {error:?}"
14260 );
14261 let unchanged = rt.get_entity(&tok, entity.id).await.unwrap();
14262 assert_eq!(
14263 unchanged.properties, entity.properties,
14264 "a refused update must not mutate storage"
14265 );
14266 }
14267
14268 #[tokio::test]
14269 async fn entity_update_dispatches_installed_kind_hook_acceptance() {
14270 let rt = rt();
14271 rt.install_entity_kind_hooks(vec![(
14272 "widget".to_string(),
14273 Arc::new(RefusingKindHook) as Arc<dyn crate::pack::KindHook>,
14274 )]);
14275 let tok = NamespaceToken::local();
14276 let entity = rt
14277 .create_entity(
14278 &tok,
14279 "widget",
14280 None,
14281 "Gadget",
14282 None,
14283 Some(serde_json::json!({"ok": false})),
14284 vec![],
14285 )
14286 .await
14287 .unwrap();
14288
14289 let updated = rt
14290 .update_entity(
14291 &tok,
14292 entity.id,
14293 EntityPatch {
14294 properties: Some(serde_json::json!({"ok": true})),
14295 ..Default::default()
14296 },
14297 )
14298 .await
14299 .expect("installed hook accepts a merged properties value satisfying its check");
14300 assert_eq!(updated.properties, Some(serde_json::json!({"ok": true})));
14301 }
14302
14303 #[tokio::test]
14304 async fn entity_update_with_hook_missing_the_default_method_still_succeeds() {
14305 let rt = rt();
14306 rt.install_entity_kind_hooks(vec![(
14307 "widget".to_string(),
14308 Arc::new(SilentKindHook) as Arc<dyn crate::pack::KindHook>,
14309 )]);
14310 let tok = NamespaceToken::local();
14311 let entity = rt
14312 .create_entity(&tok, "widget", None, "Gadget", None, None, vec![])
14313 .await
14314 .unwrap();
14315
14316 let updated = rt
14317 .update_entity(
14318 &tok,
14319 entity.id,
14320 EntityPatch {
14321 name: Some("Renamed Gadget".to_string()),
14322 ..Default::default()
14323 },
14324 )
14325 .await
14326 .expect(
14327 "a hook that does not override validate_entity_update must not block the update",
14328 );
14329 assert_eq!(updated.name, "Renamed Gadget");
14330 }
14331
14332 #[tokio::test]
14336 async fn entity_update_with_no_installed_hook_for_kind_succeeds() {
14337 let rt = rt();
14338 let tok = NamespaceToken::local();
14339 let entity = rt
14340 .create_entity(&tok, "concept", None, "Plain", None, None, vec![])
14341 .await
14342 .unwrap();
14343
14344 let updated = rt
14345 .update_entity(
14346 &tok,
14347 entity.id,
14348 EntityPatch {
14349 name: Some("Plain Renamed".to_string()),
14350 ..Default::default()
14351 },
14352 )
14353 .await
14354 .expect("no hook installed for this kind must not block the update");
14355 assert_eq!(updated.name, "Plain Renamed");
14356 }
14357}