1use std::collections::HashMap;
7use std::str::FromStr;
8
9use chrono::Utc;
10use serde::Serialize;
11use uuid::Uuid;
12
13#[cfg(test)]
14use khive_score::DeterministicScore;
15use khive_storage::entity::EntityTypeCounts;
16use khive_storage::graph::{CommitAnnotationGuard, CommitAnnotationInsertOutcome};
17#[cfg(test)]
18use khive_storage::graph::{SymmetricEdgeUpdateOutcome, SymmetricEdgeUpdateRequest};
19use khive_storage::note::Note;
20#[cfg(test)]
21use khive_storage::types::DeleteMode;
22use khive_storage::types::{
23 DirectedNeighborHit, Direction, EdgeUpsertDisposition, EdgeUpsertRefusal, EdgeUpsertRequest,
24 EdgeUpsertResult, GraphPath, GuardedEdgeUpsertOutcome, LinkId, NeighborCursor, NeighborHit,
25 NeighborQuery, PageRequest, SqlStatement, SqlValue, TextFilter, TextQueryMode,
26 TextSearchRequest, TraversalRequest,
27};
28use khive_storage::{
29 Attachment, AttachmentSubstrate, Edge, EdgeRelation, Entity, EntityFilter, Event, EventFilter,
30 NewAttachment,
31};
32use khive_types::{EdgeEndpointRule, EndpointKind, EventKind, KhiveError, SubstrateKind};
33
34use khive_db::stores::entity::entity_upsert_statement;
35
36use khive_db::stores::graph::{
37 compose_graph_mutation_events, GraphMutationOutcome, GraphMutationPreconditions,
38 GraphMutationRequest,
39};
40
41use khive_db::stores::text::insert_document_statements;
42
43#[cfg(test)]
44mod batch_edge_tests;
45
46struct EdgeReadWindow {
47 outcomes: Vec<Option<RuntimeResult<Option<Edge>>>>,
48 groups: Vec<(khive_types::Namespace, Vec<usize>)>,
49}
50
51fn restore_reindex_failed(kind: &str, id: Uuid, error: RuntimeError) -> RuntimeError {
55 RuntimeError::Internal(format!(
56 "{kind} {id} is restored and text-indexed, but its embedding rebuild failed \
57 and will be retried by the next reindex: {error}"
58 ))
59}
60
61fn merge_tombstone_restore_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
62 KhiveError::conflict(format!(
63 "merge_tombstone: {id} was merged into {kept_id}; a merge tombstone is not restorable, query the kept id"
64 ))
65 .with_details(khive_types::Details::new_owned([
66 ("reason", "merge_tombstone".into()),
67 ("merged_into", kept_id.to_string()),
68 ]))
69 .into()
70}
71
72#[derive(Debug, PartialEq, Eq)]
74pub struct EntityStatsCounts {
75 pub entities: u64,
76 pub entities_by_type: Option<EntityTypeCounts>,
77}
78
79#[derive(Debug, Default, PartialEq, Eq)]
81pub struct EdgeStatsCounts {
82 pub edges: u64,
83 pub edges_by_relation: HashMap<String, u64>,
84 pub edges_by_endpoint_base: khive_storage::types::EdgeEndpointBaseCounts,
85}
86
87pub async fn entity_stats_counts(
90 store: &dyn khive_storage::EntityStore,
91 token: &NamespaceToken,
92) -> RuntimeResult<EntityStatsCounts> {
93 let namespaces: Vec<String> = token
94 .visible_namespaces()
95 .iter()
96 .map(|namespace| namespace.as_str().to_owned())
97 .collect();
98 match store.count_entities_by_type(&namespaces).await? {
99 Some(groups) => {
100 let entities = groups.iter().try_fold(0_u64, |total, (_, count)| {
101 total.checked_add(*count).ok_or_else(|| {
102 RuntimeError::Internal(
103 "entity type counts exceed the scalar count range".into(),
104 )
105 })
106 })?;
107 Ok(EntityStatsCounts {
108 entities,
109 entities_by_type: Some(groups),
110 })
111 }
112 None => {
113 let entities = store
114 .count_entities(
115 token.namespace().as_str(),
116 EntityFilter {
117 namespaces,
118 ..EntityFilter::default()
119 },
120 )
121 .await?;
122 Ok(EntityStatsCounts {
123 entities,
124 entities_by_type: None,
125 })
126 }
127 }
128}
129
130pub struct EntityClaimSpec {
133 pub id: Uuid,
134 pub kind: String,
135 pub entity_type: Option<String>,
136 pub name: String,
137 pub description: Option<String>,
138 pub properties: Option<serde_json::Value>,
139 pub tags: Vec<String>,
140 pub identity_tag: String,
141}
142
143fn live_merged_entity_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
144 KhiveError::conflict(format!(
145 "live_merged_entity: {id} is live but still carries merged_into {kept_id}; a row an \
146 earlier restore left live over its merge is not restorable, re-tombstone it or query the \
147 kept id"
148 ))
149 .with_details(khive_types::Details::new_owned([
150 ("reason", "live_merged_entity".into()),
151 ("merged_into", kept_id.to_string()),
152 ]))
153 .into()
154}
155
156fn restore_key_conflict(key: &str, holder: &Note) -> RuntimeError {
157 KhiveError::conflict(format!(
158 "restore_key_conflict: key {key:?} is already held by live note {}",
159 holder.id
160 ))
161 .with_details(khive_types::Details::new_owned([
162 ("reason", "restore_key_conflict".into()),
163 ("key", key.to_owned()),
164 ("existing_id", holder.id.to_string()),
165 ]))
166 .into()
167}
168
169use crate::atomic_plan::{
170 AddEntityPlan, AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan,
171};
172use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
173use crate::curation::{entity_fts_document, note_embedding_text_ref, note_fts_document};
174use crate::error::{GuardedWriteFailure, RuntimeError, RuntimeResult};
175use crate::runtime::{KhiveRuntime, NamespaceToken};
176
177#[derive(Clone, Debug, Serialize)]
180pub struct PostCommitDegradation {
181 pub stage: &'static str,
182 pub error: String,
183}
184
185impl PostCommitDegradation {
186 fn new(stage: &'static str, error: impl ToString) -> Self {
187 Self {
188 stage,
189 error: error.to_string(),
190 }
191 }
192}
193
194macro_rules! conditional_insert_stages {
196 ($($stage:ident => $label:literal),+ $(,)?) => {
197 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
203 pub enum ConditionalInsertStage {
204 $($stage,)+
205 }
206
207 impl ConditionalInsertStage {
208 pub const ALL: &'static [Self] = &[$(Self::$stage,)+];
210
211 pub const fn label(self) -> &'static str {
212 match self {
213 $(Self::$stage => $label,)+
214 }
215 }
216 }
217 };
218}
219
220conditional_insert_stages! {
221 FtsAcquisition => "fts_acquisition",
222 FtsUpsert => "fts_upsert",
223 Embedding => "embedding",
224 VectorAcquisition => "vector_acquisition",
225 VectorInsert => "vector_insert",
226}
227
228impl ConditionalInsertStage {
229 pub fn from_label(label: &str) -> Option<Self> {
230 Self::ALL
231 .iter()
232 .copied()
233 .find(|stage| stage.label() == label)
234 }
235}
236
237fn record_conditional_insert_degradation(
238 degradations: &mut Vec<PostCommitDegradation>,
239 id: Uuid,
240 stage: ConditionalInsertStage,
241 error: impl ToString,
242) {
243 record_post_commit_degradation(degradations, "try_create_note", id, stage.label(), error);
244}
245
246fn record_post_commit_degradation(
247 degradations: &mut Vec<PostCommitDegradation>,
248 operation: &'static str,
249 id: Uuid,
250 stage: &'static str,
251 error: impl ToString,
252) {
253 let degradation = PostCommitDegradation::new(stage, error);
254 tracing::warn!(%operation, %id, stage, error = %degradation.error,
255 "substrate mutation committed with post-commit degradation");
256 degradations.push(degradation);
257}
258
259pub(crate) fn legacy_post_commit_result<T>(
263 operation: &'static str,
264 id: Uuid,
265 value: T,
266 degradations: Vec<PostCommitDegradation>,
267) -> RuntimeResult<T> {
268 if degradations.is_empty() {
269 return Ok(value);
270 }
271 let failures = serde_json::Value::Array(
272 degradations
273 .iter()
274 .map(|failure| {
275 serde_json::json!({"stage": failure.stage, "error": failure.error.as_str()})
276 })
277 .collect(),
278 );
279 Err(KhiveError::internal(format!(
280 "{operation} committed record {id}, but post-commit work failed; do not retry the mutation; reconcile by record_id"
281 ))
282 .with_details(khive_types::Details::new_owned([
283 ("reason", "post_commit_degraded".to_string()),
284 ("operation", operation.to_string()),
285 ("record_id", id.to_string()),
286 ("committed", "true".to_string()),
287 ("retryable", "false".to_string()),
288 ("post_commit_degradations", failures.to_string()),
289 ]))
290 .into())
291}
292
293pub(crate) fn legacy_post_commit_result_with_embedding<T>(
294 operation: &'static str,
295 id: Uuid,
296 value: T,
297 embedding: crate::retrieval::EmbeddingTruncationReport,
298 degradations: Vec<PostCommitDegradation>,
299) -> RuntimeResult<T> {
300 if !embedding.any_truncated() {
301 return legacy_post_commit_result(operation, id, value, degradations);
302 }
303 let failures = serde_json::Value::Array(
304 degradations
305 .iter()
306 .map(|failure| {
307 serde_json::json!({"stage": failure.stage, "error": failure.error.as_str()})
308 })
309 .collect(),
310 );
311 Err(KhiveError::internal(format!(
312 "{operation} committed record {id}, but embedding input was truncated; do not retry the mutation; reconcile by record_id"
313 ))
314 .with_details(khive_types::Details::new_owned([
315 ("reason", "embedding_input_truncated".to_string()),
316 ("operation", operation.to_string()),
317 ("record_id", id.to_string()),
318 ("committed", "true".to_string()),
319 ("retryable", "false".to_string()),
320 (
321 "embedding_truncation_report",
322 serde_json::json!(embedding).to_string(),
323 ),
324 ("post_commit_degradations", failures.to_string()),
325 ]))
326 .into())
327}
328
329mod entity_bulk;
330mod entity_create;
331mod entity_indexes;
332#[cfg(any(test, feature = "fault-injection"))]
333mod fault_injection;
334mod resolve_prefix_in;
335mod resolve_uuid_or_prefix;
336
337mod delete_restore;
338mod edge_query;
339mod graph_rules;
340mod identity_reads;
341mod link_ops;
342mod read_helpers;
343mod record_ops;
344mod traversal;
345
346use read_helpers::{
347 direction_sort_rank, drain_embed_join_set, normalize_symmetric_direction, note_snippet,
348 note_title, resolve_prefix_statement, salience_weighted_rank,
349};
350pub use read_helpers::{
351 hex_prefix_to_uuid_pattern, uuid_prefix_bounds, NoteSearchHit, NoteSearchOutcome, Resolved,
352};
353pub use record_ops::NoteCreateSpec;
354
355pub use edge_query::QueryResult;
356use graph_rules::{
357 accepted_entity_relations_description, pack_rule_allows,
358 static_impossible_edge_pattern_warnings,
359};
360pub use graph_rules::{
361 accepted_entity_relations_for_entities, accepted_pack_relations_for_entities,
362 merge_entry_metadata,
363};
364pub(crate) use graph_rules::{
365 canonical_edge_endpoint_kinds, canonical_edge_endpoints, merge_dependency_kind,
366 validate_edge_metadata, validate_edge_weight,
367};
368#[cfg(test)]
369use link_ops::guarded_link_batch_failure;
370#[cfg(test)]
371use traversal::merge_traversal_paths_by_root;
372
373#[cfg(any(test, feature = "fault-injection"))]
374pub use fault_injection::{
375 arm_entity_compensation_fail_scoped, arm_fts_fail_many_partial_scoped,
376 arm_fts_fail_many_scoped, arm_fts_fail_scoped, arm_fts_search_fail,
377 arm_prefix_resolve_fail_scoped, arm_rollback_cleanup_fail, arm_vector_fail_after,
378 arm_vector_fail_scoped, FaultInjectionArm,
379};
380#[cfg(test)]
381use fault_injection::{arm_fault, FaultArmSet, LINK_FAIL_AFTER};
382#[cfg(any(test, feature = "fault-injection"))]
383use fault_injection::{
384 consume_fault, ENTITY_COMPENSATION_FAIL_NS, FTS_FAIL_MANY_NS, FTS_FAIL_MANY_PARTIAL_NS,
385 FTS_FAIL_NS, FTS_SEARCH_FAIL_NS, PREFIX_RESOLVE_FAIL_NS, ROLLBACK_CLEANUP_FAIL_NS,
386 VECTOR_FAIL_AFTER, VECTOR_FAIL_NS,
387};
388#[cfg(any(test, feature = "fault-injection"))]
389pub(crate) use fault_injection::{consume_fts_fail_fault, consume_vector_fail_fault};
390pub use resolve_prefix_in::PrefixScope;
391
392#[derive(Clone, Copy, Debug, Eq, PartialEq)]
399pub enum EdgeEndpointKind {
400 Entity,
401 Note,
402 Event,
403 Edge,
404}
405
406impl EdgeEndpointKind {
407 pub const fn name(self) -> &'static str {
409 match self {
410 Self::Entity => "entity",
411 Self::Note => "note",
412 Self::Event => "event",
413 Self::Edge => "edge",
414 }
415 }
416}
417
418fn resolved_pair(r: Option<&Resolved>) -> Option<(&'static str, &str, Option<&str>)> {
424 match r? {
425 Resolved::Entity(e) => Some(("entity", e.kind.as_str(), e.entity_type.as_deref())),
426 Resolved::Note(n) => Some(("note", n.kind.as_str(), None)),
427 Resolved::Event(_) => None,
428 Resolved::PackRecord { .. } => None,
429 }
430}
431
432pub use khive_types::{
433 base_entity_endpoint_rules, base_entity_rule_allows, endpoint_matches,
434 BASE_ENTITY_ENDPOINT_RULES,
435};
436
437impl KhiveRuntime {
438 #[allow(clippy::too_many_arguments)]
456 pub async fn create_note(
457 &self,
458 token: &NamespaceToken,
459 kind: &str,
460 name: Option<&str>,
461 content: &str,
462 salience: Option<f64>,
463 properties: Option<serde_json::Value>,
464 annotates: Vec<Uuid>,
465 ) -> RuntimeResult<Note> {
466 let (note, embedding, degradations, _) = self
467 .create_note_inner(
468 token, kind, name, content, None, salience, None, properties, annotates, None,
469 false, false,
470 )
471 .await?;
472 legacy_post_commit_result_with_embedding(
473 "create_note",
474 note.id,
475 note,
476 embedding,
477 degradations,
478 )
479 }
480
481 pub async fn create_web_receipt_note(
486 &self,
487 token: &NamespaceToken,
488 summary: &str,
489 request: serde_json::Value,
490 annotates: Vec<Uuid>,
491 ) -> RuntimeResult<Note> {
492 let properties = serde_json::json!({
493 "tags": ["web.receipt"],
494 "request": request,
495 });
496 let (note, _, degradations, _) = self
497 .create_note_inner(
498 token,
499 "observation",
500 None,
501 summary,
502 None,
503 None,
504 None,
505 Some(properties),
506 annotates,
507 None,
508 false,
509 true,
510 )
511 .await?;
512 legacy_post_commit_result("create_web_receipt_note", note.id, note, degradations)
513 }
514
515 #[allow(clippy::too_many_arguments)]
526 pub async fn create_note_with_embedding_content(
527 &self,
528 token: &NamespaceToken,
529 kind: &str,
530 name: Option<&str>,
531 content: &str,
532 embedding_content: Option<&str>,
533 salience: Option<f64>,
534 properties: Option<serde_json::Value>,
535 annotates: Vec<Uuid>,
536 ) -> RuntimeResult<Note> {
537 let (note, embedding, degradations, _) = self
538 .create_note_inner(
539 token,
540 kind,
541 name,
542 content,
543 embedding_content,
544 salience,
545 None,
546 properties,
547 annotates,
548 None,
549 false,
550 false,
551 )
552 .await?;
553 legacy_post_commit_result_with_embedding(
554 "create_note_with_embedding_content",
555 note.id,
556 note,
557 embedding,
558 degradations,
559 )
560 }
561
562 #[allow(clippy::too_many_arguments)]
563 pub async fn create_note_with_embedding_content_and_report(
564 &self,
565 token: &NamespaceToken,
566 kind: &str,
567 name: Option<&str>,
568 content: &str,
569 embedding_content: Option<&str>,
570 salience: Option<f64>,
571 properties: Option<serde_json::Value>,
572 annotates: Vec<Uuid>,
573 ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
574 let (note, embedding, degradations, _) = self
575 .create_note_inner(
576 token,
577 kind,
578 name,
579 content,
580 embedding_content,
581 salience,
582 None,
583 properties,
584 annotates,
585 None,
586 false,
587 false,
588 )
589 .await?;
590 legacy_post_commit_result(
591 "create_note_with_embedding_content_and_report",
592 note.id,
593 (note, embedding),
594 degradations,
595 )
596 }
597
598 #[allow(clippy::too_many_arguments)]
600 pub async fn create_note_with_embedding_content_and_post_commit_report(
601 &self,
602 token: &NamespaceToken,
603 kind: &str,
604 name: Option<&str>,
605 content: &str,
606 embedding_content: Option<&str>,
607 salience: Option<f64>,
608 properties: Option<serde_json::Value>,
609 annotates: Vec<Uuid>,
610 ) -> RuntimeResult<(
611 Note,
612 crate::retrieval::EmbeddingTruncationReport,
613 Vec<PostCommitDegradation>,
614 )> {
615 let (note, embedding, degradations, _) = self
616 .create_note_inner(
617 token,
618 kind,
619 name,
620 content,
621 embedding_content,
622 salience,
623 None,
624 properties,
625 annotates,
626 None,
627 false,
628 false,
629 )
630 .await?;
631 Ok((note, embedding, degradations))
632 }
633
634 #[allow(clippy::too_many_arguments)]
638 pub async fn create_note_with_decay(
639 &self,
640 token: &NamespaceToken,
641 kind: &str,
642 name: Option<&str>,
643 content: &str,
644 salience: Option<f64>,
645 decay_factor: f64,
646 properties: Option<serde_json::Value>,
647 annotates: Vec<Uuid>,
648 ) -> RuntimeResult<Note> {
649 self.create_note_with_decay_for_embedding_model(
650 token,
651 kind,
652 name,
653 content,
654 salience,
655 decay_factor,
656 properties,
657 annotates,
658 None,
659 )
660 .await
661 }
662
663 #[allow(clippy::too_many_arguments)]
665 pub async fn create_note_with_decay_and_report(
666 &self,
667 token: &NamespaceToken,
668 kind: &str,
669 name: Option<&str>,
670 content: &str,
671 salience: Option<f64>,
672 decay_factor: f64,
673 properties: Option<serde_json::Value>,
674 annotates: Vec<Uuid>,
675 ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
676 self.create_note_with_decay_for_embedding_model_and_report(
677 token,
678 kind,
679 name,
680 content,
681 salience,
682 decay_factor,
683 properties,
684 annotates,
685 None,
686 )
687 .await
688 }
689
690 #[allow(clippy::too_many_arguments)]
695 pub async fn create_note_with_decay_for_embedding_model(
696 &self,
697 token: &NamespaceToken,
698 kind: &str,
699 name: Option<&str>,
700 content: &str,
701 salience: Option<f64>,
702 decay_factor: f64,
703 properties: Option<serde_json::Value>,
704 annotates: Vec<Uuid>,
705 embedding_model: Option<&str>,
706 ) -> RuntimeResult<Note> {
707 let (note, embedding, degradations, _) = self
708 .create_note_inner(
709 token,
710 kind,
711 name,
712 content,
713 None,
714 salience,
715 Some(decay_factor),
716 properties,
717 annotates,
718 embedding_model,
719 false,
720 false,
721 )
722 .await?;
723 legacy_post_commit_result_with_embedding(
724 "create_note_with_decay_for_embedding_model",
725 note.id,
726 note,
727 embedding,
728 degradations,
729 )
730 }
731
732 #[allow(clippy::too_many_arguments)]
734 pub async fn create_note_with_decay_for_embedding_model_and_report(
735 &self,
736 token: &NamespaceToken,
737 kind: &str,
738 name: Option<&str>,
739 content: &str,
740 salience: Option<f64>,
741 decay_factor: f64,
742 properties: Option<serde_json::Value>,
743 annotates: Vec<Uuid>,
744 embedding_model: Option<&str>,
745 ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
746 let (note, embedding, degradations, _) = self
747 .create_note_inner(
748 token,
749 kind,
750 name,
751 content,
752 None,
753 salience,
754 Some(decay_factor),
755 properties,
756 annotates,
757 embedding_model,
758 false,
759 false,
760 )
761 .await?;
762 legacy_post_commit_result(
763 "create_note_with_decay_for_embedding_model_and_report",
764 note.id,
765 (note, embedding),
766 degradations,
767 )
768 }
769
770 #[allow(clippy::too_many_arguments)]
774 pub async fn create_note_with_decay_for_embedding_model_with_visibility(
775 &self,
776 token: &NamespaceToken,
777 kind: &str,
778 name: Option<&str>,
779 content: &str,
780 salience: Option<f64>,
781 decay_factor: f64,
782 properties: Option<serde_json::Value>,
783 annotates: Vec<Uuid>,
784 embedding_model: Option<&str>,
785 ) -> RuntimeResult<(Note, Vec<(String, u64)>)> {
786 let (note, _, degradations, fences) = self
787 .create_note_inner(
788 token,
789 kind,
790 name,
791 content,
792 None,
793 salience,
794 Some(decay_factor),
795 properties,
796 annotates,
797 embedding_model,
798 true,
799 false,
800 )
801 .await?;
802 legacy_post_commit_result(
803 "create_note_with_decay_for_embedding_model_with_visibility",
804 note.id,
805 (note, fences),
806 degradations,
807 )
808 }
809
810 #[allow(clippy::too_many_arguments)]
814 pub async fn create_note_with_decay_for_embedding_model_with_visibility_and_report(
815 &self,
816 token: &NamespaceToken,
817 kind: &str,
818 name: Option<&str>,
819 content: &str,
820 salience: Option<f64>,
821 decay_factor: f64,
822 properties: Option<serde_json::Value>,
823 annotates: Vec<Uuid>,
824 embedding_model: Option<&str>,
825 ) -> RuntimeResult<(
826 Note,
827 Vec<(String, u64)>,
828 crate::retrieval::EmbeddingTruncationReport,
829 )> {
830 let (note, embedding, degradations, fences) = self
831 .create_note_inner(
832 token,
833 kind,
834 name,
835 content,
836 None,
837 salience,
838 Some(decay_factor),
839 properties,
840 annotates,
841 embedding_model,
842 true,
843 false,
844 )
845 .await?;
846 legacy_post_commit_result(
847 "create_note_with_decay_for_embedding_model_with_visibility_and_report",
848 note.id,
849 (note, fences, embedding),
850 degradations,
851 )
852 }
853
854 pub async fn try_create_note(
877 &self,
878 token: &NamespaceToken,
879 kind: &str,
880 name: Option<&str>,
881 content: &str,
882 properties: Option<serde_json::Value>,
883 ) -> RuntimeResult<Option<Note>> {
884 self.try_create_note_impl(token, kind, name, content, properties, false, None, None)
885 .await
886 }
887
888 #[allow(clippy::too_many_arguments)]
903 pub async fn try_create_note_as_trusted_ingest(
904 &self,
905 _capability: &crate::pack::ChannelIngestCapability,
906 token: &NamespaceToken,
907 kind: &str,
908 name: Option<&str>,
909 content: &str,
910 properties: Option<serde_json::Value>,
911 expires_after: Option<std::time::Duration>,
912 ) -> RuntimeResult<Option<Note>> {
913 self.try_create_note_impl(
914 token,
915 kind,
916 name,
917 content,
918 properties,
919 true,
920 None,
921 expires_after,
922 )
923 .await
924 }
925
926 #[allow(clippy::too_many_arguments)]
930 pub async fn try_create_note_as_trusted_ingest_with_attachment(
931 &self,
932 _capability: &crate::pack::ChannelIngestCapability,
933 token: &NamespaceToken,
934 kind: &str,
935 name: Option<&str>,
936 content: &str,
937 properties: Option<serde_json::Value>,
938 attachment: NewAttachment,
939 expires_after: Option<std::time::Duration>,
940 ) -> RuntimeResult<Option<Note>> {
941 self.try_create_note_impl(
942 token,
943 kind,
944 name,
945 content,
946 properties,
947 true,
948 Some(attachment),
949 expires_after,
950 )
951 .await
952 }
953
954 #[allow(clippy::too_many_arguments)]
955 async fn try_create_note_impl(
956 &self,
957 token: &NamespaceToken,
958 kind: &str,
959 name: Option<&str>,
960 content: &str,
961 properties: Option<serde_json::Value>,
962 allow_transport_owned_message_properties: bool,
963 attachment: Option<NewAttachment>,
964 expires_after: Option<std::time::Duration>,
965 ) -> RuntimeResult<Option<Note>> {
966 self.validate_note_kind(kind)?;
967 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
968 crate::secret_gate::check_note_fields_at(content, name, properties.as_ref(), "note")?;
969 if let Some(ref attachment) = attachment {
970 drop(self.attachments()?);
973 attachment.validate()?;
974 let blob_store = self.blob_store().ok_or_else(|| {
975 RuntimeError::Unconfigured(
976 "trusted ingest attachment requires an installed BlobStore".to_string(),
977 )
978 })?;
979 if !blob_store.exists(&attachment.content_ref).await? {
980 return Err(RuntimeError::InvalidInput(format!(
981 "trusted ingest attachment refers to an unpublished blob: {}",
982 attachment.content_ref
983 )));
984 }
985 }
986 if !allow_transport_owned_message_properties && kind == "message" {
987 if let Some(key) = properties
988 .as_ref()
989 .and_then(serde_json::Value::as_object)
990 .and_then(|supplied| {
991 crate::curation::kind_owned_properties("message")
992 .iter()
993 .copied()
994 .find(|key| supplied.contains_key(*key))
995 })
996 {
997 return Err(RuntimeError::InvalidInput(format!(
998 "`{key}` is transport-owned on a `message` note and cannot be supplied \
999 through `try_create_note`; only the trusted channel-ingest path may \
1000 establish quarantine disposition and channel provenance"
1001 )));
1002 }
1003 }
1004
1005 let ns = token.namespace().as_str();
1006 let mut note = Note::new(ns, kind, content);
1007 if let Some(retention) = expires_after {
1008 let duration_us = i64::try_from(retention.as_micros()).map_err(|_| {
1009 RuntimeError::InvalidInput(
1010 "trusted ingest retention exceeds i64 microseconds".into(),
1011 )
1012 })?;
1013 note.expires_at = Some(note.created_at.checked_add(duration_us).ok_or_else(|| {
1014 RuntimeError::InvalidInput("trusted ingest expiry exceeds i64 microseconds".into())
1015 })?);
1016 }
1017 if let Some(n) = name {
1018 note = note.with_name(n);
1019 }
1020 if let Some(p) = properties {
1021 note = note.with_properties(p);
1022 }
1023
1024 let inserted = if let Some(attachment) = attachment {
1031 self.raw_notes(token)?
1032 .try_insert_note_with_attachments(
1033 note.clone(),
1034 vec![Attachment::from_new(
1035 note.id,
1036 AttachmentSubstrate::Note,
1037 attachment,
1038 note.created_at,
1039 )],
1040 )
1041 .await?
1042 } else {
1043 self.raw_notes(token)?.try_insert_note(note.clone()).await?
1044 };
1045 if !inserted {
1046 return Ok(None);
1047 }
1048
1049 let mut degradations = Vec::new();
1050 match self.text_for_notes(token) {
1051 Ok(fts) => {
1052 if let Err(error) = fts.upsert_document(note_fts_document(¬e)).await {
1053 record_conditional_insert_degradation(
1054 &mut degradations,
1055 note.id,
1056 ConditionalInsertStage::FtsUpsert,
1057 error,
1058 );
1059 }
1060 }
1061 Err(error) => record_conditional_insert_degradation(
1062 &mut degradations,
1063 note.id,
1064 ConditionalInsertStage::FtsAcquisition,
1065 error,
1066 ),
1067 }
1068
1069 let embed_model_names = self.embedding_models_for_note_kind(kind);
1070 for model_name in &embed_model_names {
1071 match self
1072 .embed_document_with_model_outcome_for_token(
1073 token,
1074 model_name,
1075 note_embedding_text_ref(¬e),
1076 )
1077 .await
1078 {
1079 Ok(outcome) => {
1080 if outcome.truncated {
1081 tracing::warn!(
1082 note_id = %note.id,
1083 model = %outcome.model_name,
1084 source_bytes = outcome.source_bytes,
1085 embedded_bytes = outcome.embedded_bytes,
1086 "try_create_note: embedding input truncated; full content stored unchanged"
1087 );
1088 }
1089 match self.vectors_for_model(token, model_name) {
1090 Ok(vs) => {
1091 if let Err(error) = vs
1092 .insert(
1093 note.id,
1094 SubstrateKind::Note,
1095 ns,
1096 "note.content",
1097 vec![outcome.vector],
1098 )
1099 .await
1100 {
1101 record_conditional_insert_degradation(
1102 &mut degradations,
1103 note.id,
1104 ConditionalInsertStage::VectorInsert,
1105 format!("model {model_name}: {error}"),
1106 );
1107 }
1108 }
1109 Err(error) => record_conditional_insert_degradation(
1110 &mut degradations,
1111 note.id,
1112 ConditionalInsertStage::VectorAcquisition,
1113 format!("model {model_name}: {error}"),
1114 ),
1115 }
1116 }
1117 Err(error) => record_conditional_insert_degradation(
1118 &mut degradations,
1119 note.id,
1120 ConditionalInsertStage::Embedding,
1121 format!("model {model_name}: {error}"),
1122 ),
1123 }
1124 }
1125
1126 legacy_post_commit_result("try_create_note", note.id, Some(note), degradations)
1127 }
1128
1129 #[allow(clippy::too_many_arguments)]
1133 async fn create_note_inner(
1134 &self,
1135 token: &NamespaceToken,
1136 kind: &str,
1137 name: Option<&str>,
1138 content: &str,
1139 embedding_content: Option<&str>,
1140 salience: Option<f64>,
1141 decay_factor: Option<f64>,
1142 properties: Option<serde_json::Value>,
1143 annotates: Vec<Uuid>,
1144 embedding_model: Option<&str>,
1145 capture_visibility: bool,
1146 web_receipt: bool,
1147 ) -> RuntimeResult<(
1148 Note,
1149 crate::retrieval::EmbeddingTruncationReport,
1150 Vec<PostCommitDegradation>,
1151 Vec<(String, u64)>,
1152 )> {
1153 self.validate_note_kind(kind)?;
1154 let mut properties = self.derive_note_write_properties(kind, token, properties)?;
1160 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1161 if web_receipt {
1162 let map = properties
1163 .as_mut()
1164 .and_then(serde_json::Value::as_object_mut)
1165 .expect("web receipt properties are constructed as an object");
1166 map.insert(
1167 crate::secret_gate::RESERVED_WEB_RECEIPT_KEY.to_string(),
1168 serde_json::Value::String(
1169 crate::secret_gate::WEB_RECEIPT_PROVENANCE_VALUE.to_string(),
1170 ),
1171 );
1172 }
1173 crate::secret_gate::check_note_fields_at(content, name, properties.as_ref(), "note")?;
1175 if let Some(ec) = embedding_content {
1181 if ec.is_empty() {
1182 return Err(RuntimeError::InvalidInput(
1183 "embedding_content must not be empty".into(),
1184 ));
1185 }
1186 if ec.len() >= content.len() || !content.starts_with(ec) {
1187 return Err(RuntimeError::InvalidInput(
1188 "embedding_content must be a proper prefix of content".into(),
1189 ));
1190 }
1191 crate::secret_gate::check_at(ec, "note", "embedding_content")?;
1192 }
1193 let ns = token.namespace().as_str();
1194
1195 for &target_id in &annotates {
1198 if !self.substrate_exists_by_id(token, target_id).await? {
1199 return Err(RuntimeError::NotFound(format!(
1200 "create_note annotates target {target_id} not found"
1201 )));
1202 }
1203 }
1204
1205 if let Some(s) = salience {
1208 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
1209 return Err(RuntimeError::InvalidInput(format!(
1210 "salience must be a finite value in [0.0, 1.0]; got {s}"
1211 )));
1212 }
1213 }
1214 if let Some(d) = decay_factor {
1215 if !d.is_finite() || d < 0.0 {
1216 return Err(RuntimeError::InvalidInput(format!(
1217 "decay_factor must be a finite value >= 0.0; got {d}"
1218 )));
1219 }
1220 }
1221
1222 if let Some(model_name) = embedding_model {
1226 self.resolve_embedding_model(Some(model_name))?;
1227 }
1228
1229 let mut note = Note::new(ns, kind, content);
1230 if let Some(s) = salience {
1231 note = note.with_salience(s);
1232 }
1233 if let Some(df) = decay_factor {
1234 note = note.with_decay(df);
1235 }
1236 if let Some(n) = name {
1237 note = note.with_name(n);
1238 }
1239 if let Some(p) = properties {
1240 note = note.with_properties(p);
1241 }
1242 let notes = if web_receipt {
1243 self.raw_notes(token)?
1244 } else {
1245 self.notes(token)?
1246 };
1247 notes.upsert_note(note.clone()).await?;
1248
1249 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
1255 vec![m.to_string()]
1256 } else {
1257 self.embedding_models_for_note_kind(kind)
1258 };
1259
1260 {
1262 #[cfg(any(test, feature = "fault-injection"))]
1268 let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
1269 #[cfg(not(any(test, feature = "fault-injection")))]
1270 let fts_inject = false;
1271 let fts_result: RuntimeResult<()> = if fts_inject {
1272 Err(RuntimeError::Internal("injected FTS failure".to_string()))
1273 } else {
1274 let statements =
1275 khive_db::stores::text::delete_document_statements("fts_notes", ns, note.id)
1276 .into_iter()
1277 .chain(khive_db::stores::text::insert_document_statements(
1278 "fts_notes",
1279 ¬e_fts_document(¬e),
1280 ))
1281 .collect();
1282 self.apply_note_index_revision(¬e, statements)
1283 .await
1284 .map(|_| ())
1285 };
1286
1287 if let Err(e) = fts_result {
1288 self.compensate_note_creation(¬e).await;
1289 return Err(e);
1290 }
1291 }
1292
1293 let canonical_embed_text = note_embedding_text_ref(¬e);
1303 let embed_text = embedding_content.unwrap_or(canonical_embed_text);
1304
1305 let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
1306 let mut vector_fences = Vec::with_capacity(embed_model_names.len());
1307 if embed_model_names.len() == 1 {
1308 let model_name = &embed_model_names[0];
1310 let vec_result = self
1311 .embed_document_with_model_outcome_for_token(token, model_name, embed_text)
1312 .await;
1313
1314 #[cfg(any(test, feature = "fault-injection"))]
1324 let vec_inject = {
1325 let ns_inject = consume_fault(&VECTOR_FAIL_NS, ns);
1326 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
1327 Some(0) => {
1328 cell.set(None);
1329 true
1330 }
1331 Some(n) => {
1332 cell.set(Some(n - 1));
1333 false
1334 }
1335 None => false,
1336 });
1337 ns_inject || count_inject
1338 };
1339 #[cfg(not(any(test, feature = "fault-injection")))]
1340 let vec_inject = false;
1341 let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
1342 if vec_inject {
1343 Err(RuntimeError::Internal(
1344 "injected vector failure".to_string(),
1345 ))
1346 } else {
1347 vec_result
1348 };
1349
1350 let single_model_result: RuntimeResult<()> = match vec_result {
1351 Ok(outcome) => {
1352 embedding_report.observe(&outcome);
1353 if capture_visibility {
1354 match self
1355 .publish_note_vector_revision_with_seq(
1356 token,
1357 ¬e,
1358 model_name,
1359 &outcome.vector,
1360 )
1361 .await
1362 {
1363 Ok(Some(seq)) => {
1364 vector_fences.push((model_name.clone(), seq));
1365 Ok(())
1366 }
1367 Ok(None) => Ok(()),
1368 Err(error) => Err(error),
1369 }
1370 } else {
1371 self.publish_note_vector_revision(token, ¬e, model_name, &outcome.vector)
1372 .await
1373 .map(|_| ())
1374 }
1375 }
1376 Err(e) => Err(e),
1377 };
1378 if let Err(e) = single_model_result {
1379 self.compensate_note_creation(¬e).await;
1380 return Err(e);
1381 }
1382 } else if !embed_model_names.is_empty() {
1383 let rt_clone = self.clone();
1386 let content_owned: std::sync::Arc<str> = std::sync::Arc::from(embed_text);
1389 let usage_ctx = crate::usage::current();
1390 let mut join_set = tokio::task::JoinSet::new();
1391 for (idx, model_name) in embed_model_names.iter().enumerate() {
1392 let rt = rt_clone.clone();
1393 let text = std::sync::Arc::clone(&content_owned);
1394 let name = model_name.clone();
1395 let ctx = usage_ctx.clone();
1396 let token = (*token).clone();
1397 join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
1398 let fut = rt.embed_document_with_model_outcome_for_token(
1399 &token,
1400 &name,
1401 text.as_ref(),
1402 );
1403 let result = match ctx {
1404 Some(ctx) => crate::usage::scope(ctx, fut).await,
1405 None => fut.await,
1406 };
1407 (idx, result)
1408 }));
1409 }
1410 let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
1414 Ok(outcomes) => outcomes,
1415 Err(e) => {
1416 self.compensate_note_creation(¬e).await;
1417 return Err(e);
1418 }
1419 };
1420 for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
1422 embedding_report.observe(&outcome);
1423 let insert_result = if capture_visibility {
1424 self.publish_note_vector_revision_with_seq(
1425 token,
1426 ¬e,
1427 model_name,
1428 &outcome.vector,
1429 )
1430 .await
1431 .map(|seq| {
1432 if let Some(seq) = seq {
1433 vector_fences.push((model_name.clone(), seq));
1434 }
1435 })
1436 } else {
1437 self.publish_note_vector_revision(token, ¬e, model_name, &outcome.vector)
1438 .await
1439 .map(|_| ())
1440 };
1441 if let Err(e) = insert_result {
1442 self.compensate_note_creation(¬e).await;
1443 return Err(e);
1444 }
1445 }
1446 }
1447
1448 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1453
1454 #[cfg(test)]
1457 let annotates_iter: Vec<(usize, Uuid)> = annotates
1458 .iter()
1459 .enumerate()
1460 .map(|(i, &id)| (i, id))
1461 .collect();
1462 #[cfg(test)]
1463 macro_rules! next_target {
1464 ($pair:expr) => {
1465 $pair.1
1466 };
1467 }
1468 #[cfg(not(test))]
1469 let annotates_iter: Vec<Uuid> = annotates.to_vec();
1470 #[cfg(not(test))]
1471 macro_rules! next_target {
1472 ($pair:expr) => {
1473 $pair
1474 };
1475 }
1476
1477 for pair in annotates_iter {
1478 let target_id = next_target!(pair);
1479
1480 #[cfg(test)]
1482 let injected_err: Option<RuntimeError> = {
1483 let call_idx = pair.0;
1484 LINK_FAIL_AFTER.with(|cell| {
1485 let n = cell.get();
1486 if n > 0 && call_idx + 1 == n {
1487 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
1489 } else {
1490 None
1491 }
1492 })
1493 };
1494 #[cfg(not(test))]
1495 let injected_err: Option<RuntimeError> = None;
1496
1497 let link_result = if let Some(e) = injected_err {
1498 Err(e)
1499 } else {
1500 self.link(
1501 token,
1502 note.id,
1503 target_id,
1504 EdgeRelation::Annotates,
1505 1.0,
1506 None,
1507 )
1508 .await
1509 };
1510
1511 match link_result {
1512 Ok(edge) => created_edges.push(edge.id.into()),
1513 Err(e) => {
1514 let edge_ids = created_edges
1519 .iter()
1520 .map(Uuid::to_string)
1521 .collect::<Vec<_>>()
1522 .join(", ");
1523 match self.compensate_note_creation_with_edges(¬e).await {
1524 Ok(true) => return Err(e),
1525 Ok(false) => {
1526 return Err(RuntimeError::Internal(format!(
1527 "create_note: annotates link failed: {e}; note {} changed before \
1528 compensation, retaining its incident edges [{edge_ids}]",
1529 note.id
1530 )));
1531 }
1532 Err(cleanup_error) => {
1533 return Err(RuntimeError::Internal(format!(
1534 "create_note: annotates link failed: {e}; compensation failed \
1535 for note {} and retained edges [{edge_ids}]: {cleanup_error}; \
1536 note and edges remain for reconciliation",
1537 note.id
1538 )));
1539 }
1540 }
1541 }
1542 }
1543 }
1544
1545 let created_event = khive_storage::event::Event::new(
1550 note.namespace.clone(),
1551 "create",
1552 EventKind::NoteCreated,
1553 SubstrateKind::Note,
1554 "",
1555 )
1556 .with_target(note.id)
1557 .with_payload(serde_json::json!({
1558 "id": note.id,
1559 "namespace": note.namespace,
1560 "kind": note.kind,
1561 "salience": note.salience,
1562 }));
1563 let event_result = match self.events(token) {
1564 Ok(store) => store
1565 .append_event(created_event)
1566 .await
1567 .map_err(RuntimeError::from),
1568 Err(error) => Err(error),
1569 };
1570 let mut degradations = Vec::new();
1571 if let Err(error) = event_result {
1572 record_post_commit_degradation(
1573 &mut degradations,
1574 "create_note",
1575 note.id,
1576 "event_append",
1577 error,
1578 );
1579 }
1580
1581 vector_fences.sort_by(|a, b| a.0.cmp(&b.0));
1582 Ok((note, embedding_report, degradations, vector_fences))
1583 }
1584
1585 #[allow(clippy::too_many_arguments)]
1605 pub async fn search_notes(
1606 &self,
1607 token: &NamespaceToken,
1608 query_text: &str,
1609 query_vector: Option<Vec<f32>>,
1610 limit: u32,
1611 note_kind: Option<&str>,
1612 include_superseded: bool,
1613 tags_any: &[String],
1614 properties_filter: Option<&serde_json::Value>,
1615 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1616 self.search_notes_with_text_mode(
1617 token,
1618 query_text,
1619 query_vector,
1620 limit,
1621 note_kind,
1622 include_superseded,
1623 tags_any,
1624 properties_filter,
1625 TextQueryMode::Plain,
1626 )
1627 .await
1628 }
1629
1630 #[allow(clippy::too_many_arguments)]
1632 pub async fn search_notes_with_text_mode(
1633 &self,
1634 token: &NamespaceToken,
1635 query_text: &str,
1636 query_vector: Option<Vec<f32>>,
1637 limit: u32,
1638 note_kind: Option<&str>,
1639 include_superseded: bool,
1640 tags_any: &[String],
1641 properties_filter: Option<&serde_json::Value>,
1642 text_mode: TextQueryMode,
1643 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1644 let (hits, _vector_error) = self
1645 .search_notes_inner(
1646 token,
1647 query_text,
1648 query_vector,
1649 limit,
1650 note_kind,
1651 include_superseded,
1652 tags_any,
1653 properties_filter,
1654 text_mode,
1655 false,
1656 )
1657 .await?;
1658 Ok(hits)
1659 }
1660
1661 #[allow(clippy::too_many_arguments)]
1668 pub async fn search_notes_outcome(
1669 &self,
1670 token: &NamespaceToken,
1671 query_text: &str,
1672 limit: u32,
1673 note_kind: Option<&str>,
1674 include_superseded: bool,
1675 tags_any: &[String],
1676 properties_filter: Option<&serde_json::Value>,
1677 ) -> RuntimeResult<NoteSearchOutcome> {
1678 self.search_notes_outcome_with_text_mode(
1679 token,
1680 query_text,
1681 limit,
1682 note_kind,
1683 include_superseded,
1684 tags_any,
1685 properties_filter,
1686 TextQueryMode::Plain,
1687 )
1688 .await
1689 }
1690
1691 #[allow(clippy::too_many_arguments)]
1693 pub async fn search_notes_outcome_with_text_mode(
1694 &self,
1695 token: &NamespaceToken,
1696 query_text: &str,
1697 limit: u32,
1698 note_kind: Option<&str>,
1699 include_superseded: bool,
1700 tags_any: &[String],
1701 properties_filter: Option<&serde_json::Value>,
1702 text_mode: TextQueryMode,
1703 ) -> RuntimeResult<NoteSearchOutcome> {
1704 let (hits, vector_error) = self
1705 .search_notes_inner(
1706 token,
1707 query_text,
1708 None,
1709 limit,
1710 note_kind,
1711 include_superseded,
1712 tags_any,
1713 properties_filter,
1714 text_mode,
1715 true,
1716 )
1717 .await?;
1718 Ok(NoteSearchOutcome { hits, vector_error })
1719 }
1720
1721 #[allow(clippy::too_many_arguments)]
1722 async fn search_notes_inner(
1723 &self,
1724 token: &NamespaceToken,
1725 query_text: &str,
1726 query_vector: Option<Vec<f32>>,
1727 limit: u32,
1728 note_kind: Option<&str>,
1729 include_superseded: bool,
1730 tags_any: &[String],
1731 properties_filter: Option<&serde_json::Value>,
1732 text_mode: TextQueryMode,
1733 tolerate_vector_error: bool,
1734 ) -> RuntimeResult<(Vec<NoteSearchHit>, Option<String>)> {
1735 const RRF_K: usize = 60;
1736 let candidates = limit.saturating_mul(4).max(limit);
1737 let visible_ns: Vec<String> = token
1738 .visible_namespaces()
1739 .iter()
1740 .map(|ns| ns.as_str().to_owned())
1741 .collect();
1742
1743 #[cfg(any(test, feature = "fault-injection"))]
1756 let fts_search_inject = {
1757 let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
1758 match g.as_deref() {
1759 Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
1760 *g = None;
1761 true
1762 }
1763 _ => false,
1764 }
1765 };
1766 #[cfg(not(any(test, feature = "fault-injection")))]
1767 let fts_search_inject = false;
1768
1769 let text_store = self.text_for_notes(token)?;
1770 let text_fut = async {
1771 if fts_search_inject {
1772 return Err(khive_storage::StorageError::Timeout {
1773 operation: "fts_search".into(),
1774 });
1775 }
1776 text_store
1777 .search(TextSearchRequest {
1778 query: query_text.to_string(),
1779 mode: text_mode,
1780 filter: Some(TextFilter {
1781 namespaces: visible_ns.clone(),
1782 record_kinds: note_kind
1789 .map(|kind| vec![kind.to_string()])
1790 .unwrap_or_default(),
1791 ..TextFilter::default()
1792 }),
1793 top_k: candidates,
1794 snippet_chars: 200,
1795 })
1796 .await
1797 };
1798 let text_fut = crate::stage_seam::text_stage(text_fut);
1799
1800 let vector_fut = async {
1802 if query_vector.is_some() || self.config().embedding_model.is_some() {
1803 self.note_search_vector_search(token, query_vector, query_text, candidates)
1804 .await
1805 } else {
1806 Ok(vec![])
1807 }
1808 };
1809 let (text_search_result, vector_result) = tokio::join!(text_fut, vector_fut);
1810
1811 let text_hits = crate::error::fts_text_leg_or_err(
1817 text_search_result.map_err(RuntimeError::from),
1818 "search_notes",
1819 query_text,
1820 )?;
1821
1822 let mut vector_error: Option<String> = None;
1823 let vector_hits = match vector_result {
1824 Ok(hits) => hits,
1825 Err(e) if tolerate_vector_error => {
1826 vector_error = Some(e.to_string());
1827 Vec::new()
1828 }
1829 Err(e) => return Err(e),
1830 };
1831
1832 let fuse_k = text_hits.len() + vector_hits.len();
1838 let fused = self
1839 .fuse_with_strategy(
1840 text_hits,
1841 vector_hits,
1842 &crate::fusion::FusionStrategy::Rrf { k: RRF_K },
1843 fuse_k,
1844 )
1845 .await?;
1846
1847 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
1848 if candidate_ids.is_empty() {
1849 return Ok((vec![], vector_error));
1850 }
1851
1852 let note_store = self.notes(token)?;
1859 let search_pool = self.backend().pool_arc();
1860 let mailbox_view = crate::MailboxView {
1861 actor_id: token.actor().id.clone(),
1862 delegated: false,
1863 };
1864 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
1865 for note in note_store.get_notes_batch(&candidate_ids).await? {
1866 search_pool.record_note_candidate_hydration_row();
1867 if note.deleted_at.is_some() {
1868 continue;
1869 }
1870 if !mailbox_view.permits_message_note(token, ¬e) {
1871 continue;
1872 }
1873 if let Some(want_kind) = note_kind {
1874 if note.kind != want_kind {
1875 continue;
1876 }
1877 }
1878 if !tags_any.is_empty() {
1883 let note_tags: Vec<String> = note
1884 .properties
1885 .as_ref()
1886 .and_then(|p| p.get("tags"))
1887 .and_then(serde_json::Value::as_array)
1888 .map(|arr| {
1889 arr.iter()
1890 .filter_map(serde_json::Value::as_str)
1891 .map(str::to_owned)
1892 .collect()
1893 })
1894 .unwrap_or_default();
1895 if !note_tags
1896 .iter()
1897 .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
1898 {
1899 continue;
1900 }
1901 }
1902 if let Some(pf) = properties_filter {
1904 if !crate::retrieval::properties_match(note.properties.as_ref(), pf) {
1905 continue;
1906 }
1907 }
1908 alive_notes.insert(note.id, note);
1909 }
1910
1911 if !include_superseded && !alive_notes.is_empty() {
1914 let graph = self.graph(token)?;
1915 let note_ids: Vec<Uuid> = alive_notes.keys().copied().collect();
1916 let superseded: std::collections::HashSet<Uuid> = graph
1917 .batch_neighbors(
1918 ¬e_ids,
1919 NeighborQuery {
1920 direction: Direction::In,
1921 relations: Some(vec![EdgeRelation::Supersedes]),
1922 limit: Some(1),
1923 min_weight: None,
1924 },
1925 )
1926 .await?
1927 .into_iter()
1928 .map(|(note_id, _)| note_id)
1929 .collect();
1930 alive_notes.retain(|id, _| !superseded.contains(id));
1931 }
1932
1933 let mut hits: Vec<NoteSearchHit> = fused
1935 .into_iter()
1936 .filter_map(|hit| {
1937 let note = alive_notes.get(&hit.entity_id)?;
1938 let weighted = salience_weighted_rank(hit.score, note.salience);
1939 Some(NoteSearchHit {
1940 note_id: hit.entity_id,
1941 score: weighted,
1942 rank_score_kind: hit.rank_score_kind,
1943 signals: hit.signals,
1944 source: hit.source,
1945 title: hit.title.or_else(|| note_title(note)),
1946 snippet: hit.snippet.or_else(|| note_snippet(note)),
1947 })
1948 })
1949 .collect();
1950
1951 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
1952 hits.truncate(limit as usize);
1953 Ok((hits, vector_error))
1954 }
1955
1956 pub async fn restore_note(
1962 &self,
1963 token: &NamespaceToken,
1964 id: Uuid,
1965 ) -> RuntimeResult<Option<(Note, bool)>> {
1966 let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? else {
1967 return Ok(None);
1968 };
1969 if note.namespace != token.namespace().as_str() {
1970 return Ok(None);
1971 }
1972 if note.deleted_at.is_none() {
1973 return Ok(Some((note, false)));
1974 }
1975 if let Some(key) = note.key.as_deref() {
1976 if let Some(holder) = self
1977 .notes(token)?
1978 .get_live_notes_by_key(¬e.namespace, key, Some(¬e.kind))
1979 .await?
1980 .into_iter()
1981 .find(|holder| holder.id != note.id)
1982 {
1983 return Err(restore_key_conflict(key, &holder));
1984 }
1985 }
1986 let updated_at =
1987 Utc::now()
1988 .timestamp_micros()
1989 .max(note.updated_at.checked_add(1).ok_or_else(|| {
1990 RuntimeError::Internal(format!(
1991 "note {id} updated_at is already at i64::MAX and cannot advance"
1992 ))
1993 })?);
1994 let mut params = vec![
1995 SqlValue::Text("active".into()),
1996 SqlValue::Integer(updated_at),
1997 SqlValue::Text(id.to_string()),
1998 SqlValue::Text(note.namespace.clone()),
1999 SqlValue::Text(note.kind.clone()),
2000 ];
2001 let key_clause = if let Some(key) = note.key.as_deref() {
2002 params.push(SqlValue::Text(key.to_owned()));
2003 format!(
2004 " AND (key IS NULL OR NOT EXISTS (SELECT 1 FROM notes live \
2005 WHERE live.namespace=?4 AND live.kind=?5 AND live.key=?{} \
2006 AND live.deleted_at IS NULL AND live.id != notes.id))",
2007 params.len()
2008 )
2009 } else {
2010 String::new()
2011 };
2012 let mut restored = note.clone();
2013 restored.status = "active".into();
2014 restored.deleted_at = None;
2015 restored.updated_at = updated_at;
2016 restored.version = restored
2017 .version
2018 .checked_add(1)
2019 .ok_or_else(|| RuntimeError::Internal(format!("note {id} version is exhausted")))?;
2020 let mut statements = vec![PlanStatement {
2021 statement: SqlStatement {
2022 sql: format!(
2023 "UPDATE notes SET status=?1, deleted_at=NULL, updated_at=?2 \
2024 WHERE id=?3 AND namespace=?4 AND kind=?5 AND deleted_at IS NOT NULL{key_clause}"
2025 ),
2026 params,
2027 label: Some("note-restore".into()),
2028 },
2029 guard: Some(AffectedRowGuard::exactly(1)),
2030 }];
2031 for statement in
2033 khive_db::stores::text::delete_document_statements("fts_notes", &restored.namespace, id)
2034 .into_iter()
2035 .chain(insert_document_statements(
2036 "fts_notes",
2037 ¬e_fts_document(&restored),
2038 ))
2039 {
2040 statements.push(PlanStatement {
2041 statement,
2042 guard: None,
2043 });
2044 }
2045 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
2046 graph_effects: Vec::new(),
2047 target_id: id,
2048 statements,
2049 post_commit: PostCommitEffect::None,
2050 edge_natural_key: None,
2051 idempotent_noop: false,
2052 entity_guard: None,
2053 note_guard: None,
2054 note_vector_purge: None,
2055 note_embedding_inheritance: None,
2056 }));
2057 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
2058 Ok(AtomicRunOutcome::Committed { .. }) => {
2059 #[cfg(any(test, feature = "fault-injection"))]
2060 if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
2061 return Err(restore_reindex_failed(
2062 "note",
2063 id,
2064 RuntimeError::Internal("injected FTS failure".to_string()),
2065 ));
2066 }
2067 let reindexed = self.reindex_note_with_report(token, &restored).await;
2068 let report = reindexed.map_err(|e| restore_reindex_failed("note", id, e))?;
2069 let degradations = report.post_commit_degradations();
2070 legacy_post_commit_result("restore_note", id, Some((restored, true)), degradations)
2071 }
2072 Ok(AtomicRunOutcome::RolledBack {
2073 failure: AtomicOpFailure::GuardFailed { .. },
2074 ..
2075 }) => {
2076 if let Some(key) = note.key.as_deref() {
2077 if let Some(holder) = self
2078 .notes(token)?
2079 .get_live_notes_by_key(¬e.namespace, key, Some(¬e.kind))
2080 .await?
2081 .into_iter()
2082 .find(|holder| holder.id != note.id)
2083 {
2084 return Err(restore_key_conflict(key, &holder));
2085 }
2086 }
2087 Err(RuntimeError::NotFound(format!(
2088 "note {id} is no longer a caller-owned tombstone"
2089 )))
2090 }
2091 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
2092 format!("note restore rolled back: {failure:?}"),
2093 )),
2094 Err(error) => Err(RuntimeError::Storage(error.0)),
2095 }
2096 }
2097}
2098
2099#[derive(Clone, Debug)]
2102pub struct LinkSpec {
2103 pub namespace: Option<String>,
2104 pub source_id: Uuid,
2105 pub target_id: Uuid,
2106 pub relation: EdgeRelation,
2107 pub weight: f64,
2108 pub metadata: Option<serde_json::Value>,
2109 pub resurrect: bool,
2110}
2111
2112#[derive(Clone, Debug)]
2120pub struct EntityCreateSpec {
2121 pub kind: String,
2122 pub entity_type: Option<String>,
2123 pub name: String,
2124 pub description: Option<String>,
2125 pub properties: Option<serde_json::Value>,
2126 pub tags: Vec<String>,
2127}
2128
2129#[cfg(test)]
2135#[path = "operations_tests.rs"]
2136mod tests;