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
259fn 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 fn endpoint_matches(
440 spec: &EndpointKind,
441 substrate: &str,
442 kind: &str,
443 entity_type: Option<&str>,
444) -> bool {
445 match spec {
446 EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
447 EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
448 EndpointKind::EntityOfType {
449 kind: k,
450 entity_type: t,
451 } => substrate == "entity" && *k == kind && entity_type == Some(*t),
452 }
453}
454
455pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
465 ("concept", EdgeRelation::Contains, "concept"),
467 ("project", EdgeRelation::Contains, "project"),
468 ("project", EdgeRelation::Contains, "artifact"),
469 ("org", EdgeRelation::Contains, "project"),
470 ("org", EdgeRelation::Contains, "service"),
471 ("concept", EdgeRelation::PartOf, "concept"),
472 ("project", EdgeRelation::PartOf, "project"),
473 ("project", EdgeRelation::PartOf, "org"),
474 ("*", EdgeRelation::InstanceOf, "concept"),
475 ("service", EdgeRelation::InstanceOf, "project"),
476 ("document", EdgeRelation::LinksTo, "document"),
481 ("concept", EdgeRelation::LocatedIn, "concept"),
484 ("org", EdgeRelation::LocatedIn, "concept"),
485 ("person", EdgeRelation::Owns, "org"),
487 ("org", EdgeRelation::Owns, "org"),
488 ("concept", EdgeRelation::Extends, "concept"),
490 ("concept", EdgeRelation::VariantOf, "concept"),
491 ("artifact", EdgeRelation::VariantOf, "artifact"),
492 ("concept", EdgeRelation::IntroducedBy, "document"),
493 ("concept", EdgeRelation::IntroducedBy, "person"),
494 ("artifact", EdgeRelation::IntroducedBy, "document"),
495 ("project", EdgeRelation::IntroducedBy, "document"),
496 ("service", EdgeRelation::IntroducedBy, "document"),
499 ("document", EdgeRelation::IntroducedBy, "person"),
500 ("document", EdgeRelation::IntroducedBy, "org"),
501 ("concept", EdgeRelation::IntroducedBy, "org"),
502 ("artifact", EdgeRelation::DerivedFrom, "dataset"),
504 ("artifact", EdgeRelation::DerivedFrom, "document"),
505 ("artifact", EdgeRelation::DerivedFrom, "project"),
506 ("artifact", EdgeRelation::DerivedFrom, "artifact"),
507 ("document", EdgeRelation::DerivedFrom, "document"),
510 ("document", EdgeRelation::Precedes, "document"),
512 ("dataset", EdgeRelation::Precedes, "dataset"),
513 ("artifact", EdgeRelation::Precedes, "artifact"),
514 ("service", EdgeRelation::Precedes, "service"),
515 ("project", EdgeRelation::Precedes, "project"),
516 ("project", EdgeRelation::DependsOn, "project"),
518 ("service", EdgeRelation::DependsOn, "project"),
519 ("service", EdgeRelation::DependsOn, "service"),
520 ("service", EdgeRelation::DependsOn, "artifact"),
521 ("service", EdgeRelation::DependsOn, "dataset"),
522 ("artifact", EdgeRelation::DependsOn, "project"),
523 ("artifact", EdgeRelation::DependsOn, "service"),
524 ("document", EdgeRelation::DependsOn, "document"),
525 ("concept", EdgeRelation::Enables, "concept"),
526 ("service", EdgeRelation::Enables, "concept"),
527 ("dataset", EdgeRelation::Enables, "concept"),
528 ("project", EdgeRelation::Implements, "concept"),
530 ("service", EdgeRelation::Implements, "concept"),
531 ("concept", EdgeRelation::CompetesWith, "concept"),
533 ("project", EdgeRelation::CompetesWith, "project"),
534 ("service", EdgeRelation::CompetesWith, "service"),
535 ("org", EdgeRelation::CompetesWith, "org"),
536 ("concept", EdgeRelation::ComposedWith, "concept"),
537 ("project", EdgeRelation::ComposedWith, "project"),
538 ("concept", EdgeRelation::Supersedes, "concept"),
540 ("document", EdgeRelation::Supersedes, "document"),
541 ("artifact", EdgeRelation::Supersedes, "artifact"),
542 ("service", EdgeRelation::Supersedes, "service"),
543 ("dataset", EdgeRelation::Supersedes, "dataset"),
544 ("concept", EdgeRelation::Supports, "concept"),
546 ("document", EdgeRelation::Supports, "concept"),
547 ("dataset", EdgeRelation::Supports, "concept"),
548 ("artifact", EdgeRelation::Supports, "concept"),
549 ("concept", EdgeRelation::Refutes, "concept"),
550 ("document", EdgeRelation::Refutes, "concept"),
551 ("dataset", EdgeRelation::Refutes, "concept"),
552 ("artifact", EdgeRelation::Refutes, "concept"),
553];
554
555pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
561 BASE_ENTITY_ENDPOINT_RULES
562}
563
564pub fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
570 BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
571 *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
572 })
573}
574
575impl KhiveRuntime {
576 #[allow(clippy::too_many_arguments)]
594 pub async fn create_note(
595 &self,
596 token: &NamespaceToken,
597 kind: &str,
598 name: Option<&str>,
599 content: &str,
600 salience: Option<f64>,
601 properties: Option<serde_json::Value>,
602 annotates: Vec<Uuid>,
603 ) -> RuntimeResult<Note> {
604 let (note, embedding, degradations, _) = self
605 .create_note_inner(
606 token, kind, name, content, None, salience, None, properties, annotates, None,
607 false, false,
608 )
609 .await?;
610 legacy_post_commit_result_with_embedding(
611 "create_note",
612 note.id,
613 note,
614 embedding,
615 degradations,
616 )
617 }
618
619 pub async fn create_web_receipt_note(
624 &self,
625 token: &NamespaceToken,
626 summary: &str,
627 request: serde_json::Value,
628 annotates: Vec<Uuid>,
629 ) -> RuntimeResult<Note> {
630 let properties = serde_json::json!({
631 "tags": ["web.receipt"],
632 "request": request,
633 });
634 let (note, _, degradations, _) = self
635 .create_note_inner(
636 token,
637 "observation",
638 None,
639 summary,
640 None,
641 None,
642 None,
643 Some(properties),
644 annotates,
645 None,
646 false,
647 true,
648 )
649 .await?;
650 legacy_post_commit_result("create_web_receipt_note", note.id, note, degradations)
651 }
652
653 #[allow(clippy::too_many_arguments)]
664 pub async fn create_note_with_embedding_content(
665 &self,
666 token: &NamespaceToken,
667 kind: &str,
668 name: Option<&str>,
669 content: &str,
670 embedding_content: Option<&str>,
671 salience: Option<f64>,
672 properties: Option<serde_json::Value>,
673 annotates: Vec<Uuid>,
674 ) -> RuntimeResult<Note> {
675 let (note, embedding, degradations, _) = self
676 .create_note_inner(
677 token,
678 kind,
679 name,
680 content,
681 embedding_content,
682 salience,
683 None,
684 properties,
685 annotates,
686 None,
687 false,
688 false,
689 )
690 .await?;
691 legacy_post_commit_result_with_embedding(
692 "create_note_with_embedding_content",
693 note.id,
694 note,
695 embedding,
696 degradations,
697 )
698 }
699
700 #[allow(clippy::too_many_arguments)]
701 pub async fn create_note_with_embedding_content_and_report(
702 &self,
703 token: &NamespaceToken,
704 kind: &str,
705 name: Option<&str>,
706 content: &str,
707 embedding_content: Option<&str>,
708 salience: Option<f64>,
709 properties: Option<serde_json::Value>,
710 annotates: Vec<Uuid>,
711 ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
712 let (note, embedding, degradations, _) = self
713 .create_note_inner(
714 token,
715 kind,
716 name,
717 content,
718 embedding_content,
719 salience,
720 None,
721 properties,
722 annotates,
723 None,
724 false,
725 false,
726 )
727 .await?;
728 legacy_post_commit_result(
729 "create_note_with_embedding_content_and_report",
730 note.id,
731 (note, embedding),
732 degradations,
733 )
734 }
735
736 #[allow(clippy::too_many_arguments)]
738 pub async fn create_note_with_embedding_content_and_post_commit_report(
739 &self,
740 token: &NamespaceToken,
741 kind: &str,
742 name: Option<&str>,
743 content: &str,
744 embedding_content: Option<&str>,
745 salience: Option<f64>,
746 properties: Option<serde_json::Value>,
747 annotates: Vec<Uuid>,
748 ) -> RuntimeResult<(
749 Note,
750 crate::retrieval::EmbeddingTruncationReport,
751 Vec<PostCommitDegradation>,
752 )> {
753 let (note, embedding, degradations, _) = self
754 .create_note_inner(
755 token,
756 kind,
757 name,
758 content,
759 embedding_content,
760 salience,
761 None,
762 properties,
763 annotates,
764 None,
765 false,
766 false,
767 )
768 .await?;
769 Ok((note, embedding, degradations))
770 }
771
772 #[allow(clippy::too_many_arguments)]
776 pub async fn create_note_with_decay(
777 &self,
778 token: &NamespaceToken,
779 kind: &str,
780 name: Option<&str>,
781 content: &str,
782 salience: Option<f64>,
783 decay_factor: f64,
784 properties: Option<serde_json::Value>,
785 annotates: Vec<Uuid>,
786 ) -> RuntimeResult<Note> {
787 self.create_note_with_decay_for_embedding_model(
788 token,
789 kind,
790 name,
791 content,
792 salience,
793 decay_factor,
794 properties,
795 annotates,
796 None,
797 )
798 .await
799 }
800
801 #[allow(clippy::too_many_arguments)]
803 pub async fn create_note_with_decay_and_report(
804 &self,
805 token: &NamespaceToken,
806 kind: &str,
807 name: Option<&str>,
808 content: &str,
809 salience: Option<f64>,
810 decay_factor: f64,
811 properties: Option<serde_json::Value>,
812 annotates: Vec<Uuid>,
813 ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
814 self.create_note_with_decay_for_embedding_model_and_report(
815 token,
816 kind,
817 name,
818 content,
819 salience,
820 decay_factor,
821 properties,
822 annotates,
823 None,
824 )
825 .await
826 }
827
828 #[allow(clippy::too_many_arguments)]
833 pub async fn create_note_with_decay_for_embedding_model(
834 &self,
835 token: &NamespaceToken,
836 kind: &str,
837 name: Option<&str>,
838 content: &str,
839 salience: Option<f64>,
840 decay_factor: f64,
841 properties: Option<serde_json::Value>,
842 annotates: Vec<Uuid>,
843 embedding_model: Option<&str>,
844 ) -> RuntimeResult<Note> {
845 let (note, embedding, degradations, _) = self
846 .create_note_inner(
847 token,
848 kind,
849 name,
850 content,
851 None,
852 salience,
853 Some(decay_factor),
854 properties,
855 annotates,
856 embedding_model,
857 false,
858 false,
859 )
860 .await?;
861 legacy_post_commit_result_with_embedding(
862 "create_note_with_decay_for_embedding_model",
863 note.id,
864 note,
865 embedding,
866 degradations,
867 )
868 }
869
870 #[allow(clippy::too_many_arguments)]
872 pub async fn create_note_with_decay_for_embedding_model_and_report(
873 &self,
874 token: &NamespaceToken,
875 kind: &str,
876 name: Option<&str>,
877 content: &str,
878 salience: Option<f64>,
879 decay_factor: f64,
880 properties: Option<serde_json::Value>,
881 annotates: Vec<Uuid>,
882 embedding_model: Option<&str>,
883 ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
884 let (note, embedding, degradations, _) = self
885 .create_note_inner(
886 token,
887 kind,
888 name,
889 content,
890 None,
891 salience,
892 Some(decay_factor),
893 properties,
894 annotates,
895 embedding_model,
896 false,
897 false,
898 )
899 .await?;
900 legacy_post_commit_result(
901 "create_note_with_decay_for_embedding_model_and_report",
902 note.id,
903 (note, embedding),
904 degradations,
905 )
906 }
907
908 #[allow(clippy::too_many_arguments)]
912 pub async fn create_note_with_decay_for_embedding_model_with_visibility(
913 &self,
914 token: &NamespaceToken,
915 kind: &str,
916 name: Option<&str>,
917 content: &str,
918 salience: Option<f64>,
919 decay_factor: f64,
920 properties: Option<serde_json::Value>,
921 annotates: Vec<Uuid>,
922 embedding_model: Option<&str>,
923 ) -> RuntimeResult<(Note, Vec<(String, u64)>)> {
924 let (note, _, degradations, fences) = self
925 .create_note_inner(
926 token,
927 kind,
928 name,
929 content,
930 None,
931 salience,
932 Some(decay_factor),
933 properties,
934 annotates,
935 embedding_model,
936 true,
937 false,
938 )
939 .await?;
940 legacy_post_commit_result(
941 "create_note_with_decay_for_embedding_model_with_visibility",
942 note.id,
943 (note, fences),
944 degradations,
945 )
946 }
947
948 #[allow(clippy::too_many_arguments)]
952 pub async fn create_note_with_decay_for_embedding_model_with_visibility_and_report(
953 &self,
954 token: &NamespaceToken,
955 kind: &str,
956 name: Option<&str>,
957 content: &str,
958 salience: Option<f64>,
959 decay_factor: f64,
960 properties: Option<serde_json::Value>,
961 annotates: Vec<Uuid>,
962 embedding_model: Option<&str>,
963 ) -> RuntimeResult<(
964 Note,
965 Vec<(String, u64)>,
966 crate::retrieval::EmbeddingTruncationReport,
967 )> {
968 let (note, embedding, degradations, fences) = self
969 .create_note_inner(
970 token,
971 kind,
972 name,
973 content,
974 None,
975 salience,
976 Some(decay_factor),
977 properties,
978 annotates,
979 embedding_model,
980 true,
981 false,
982 )
983 .await?;
984 legacy_post_commit_result(
985 "create_note_with_decay_for_embedding_model_with_visibility_and_report",
986 note.id,
987 (note, fences, embedding),
988 degradations,
989 )
990 }
991
992 pub async fn try_create_note(
1015 &self,
1016 token: &NamespaceToken,
1017 kind: &str,
1018 name: Option<&str>,
1019 content: &str,
1020 properties: Option<serde_json::Value>,
1021 ) -> RuntimeResult<Option<Note>> {
1022 self.try_create_note_impl(token, kind, name, content, properties, false, None, None)
1023 .await
1024 }
1025
1026 #[allow(clippy::too_many_arguments)]
1041 pub async fn try_create_note_as_trusted_ingest(
1042 &self,
1043 _capability: &crate::pack::ChannelIngestCapability,
1044 token: &NamespaceToken,
1045 kind: &str,
1046 name: Option<&str>,
1047 content: &str,
1048 properties: Option<serde_json::Value>,
1049 expires_after: Option<std::time::Duration>,
1050 ) -> RuntimeResult<Option<Note>> {
1051 self.try_create_note_impl(
1052 token,
1053 kind,
1054 name,
1055 content,
1056 properties,
1057 true,
1058 None,
1059 expires_after,
1060 )
1061 .await
1062 }
1063
1064 #[allow(clippy::too_many_arguments)]
1068 pub async fn try_create_note_as_trusted_ingest_with_attachment(
1069 &self,
1070 _capability: &crate::pack::ChannelIngestCapability,
1071 token: &NamespaceToken,
1072 kind: &str,
1073 name: Option<&str>,
1074 content: &str,
1075 properties: Option<serde_json::Value>,
1076 attachment: NewAttachment,
1077 expires_after: Option<std::time::Duration>,
1078 ) -> RuntimeResult<Option<Note>> {
1079 self.try_create_note_impl(
1080 token,
1081 kind,
1082 name,
1083 content,
1084 properties,
1085 true,
1086 Some(attachment),
1087 expires_after,
1088 )
1089 .await
1090 }
1091
1092 #[allow(clippy::too_many_arguments)]
1093 async fn try_create_note_impl(
1094 &self,
1095 token: &NamespaceToken,
1096 kind: &str,
1097 name: Option<&str>,
1098 content: &str,
1099 properties: Option<serde_json::Value>,
1100 allow_transport_owned_message_properties: bool,
1101 attachment: Option<NewAttachment>,
1102 expires_after: Option<std::time::Duration>,
1103 ) -> RuntimeResult<Option<Note>> {
1104 self.validate_note_kind(kind)?;
1105 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1106 crate::secret_gate::check_at(content, "note", "content")?;
1107 if let Some(n) = name {
1108 crate::secret_gate::check_at(n, "note", "name")?;
1109 }
1110 if let Some(ref p) = properties {
1111 crate::secret_gate::check_json_at(p, "note", "properties")?;
1112 }
1113 if let Some(ref attachment) = attachment {
1114 drop(self.attachments()?);
1117 attachment.validate()?;
1118 let blob_store = self.blob_store().ok_or_else(|| {
1119 RuntimeError::Unconfigured(
1120 "trusted ingest attachment requires an installed BlobStore".to_string(),
1121 )
1122 })?;
1123 if !blob_store.exists(&attachment.content_ref).await? {
1124 return Err(RuntimeError::InvalidInput(format!(
1125 "trusted ingest attachment refers to an unpublished blob: {}",
1126 attachment.content_ref
1127 )));
1128 }
1129 }
1130 if !allow_transport_owned_message_properties && kind == "message" {
1131 if let Some(key) = properties
1132 .as_ref()
1133 .and_then(serde_json::Value::as_object)
1134 .and_then(|supplied| {
1135 crate::curation::kind_owned_properties("message")
1136 .iter()
1137 .copied()
1138 .find(|key| supplied.contains_key(*key))
1139 })
1140 {
1141 return Err(RuntimeError::InvalidInput(format!(
1142 "`{key}` is transport-owned on a `message` note and cannot be supplied \
1143 through `try_create_note`; only the trusted channel-ingest path may \
1144 establish quarantine disposition and channel provenance"
1145 )));
1146 }
1147 }
1148
1149 let ns = token.namespace().as_str();
1150 let mut note = Note::new(ns, kind, content);
1151 if let Some(retention) = expires_after {
1152 let duration_us = i64::try_from(retention.as_micros()).map_err(|_| {
1153 RuntimeError::InvalidInput(
1154 "trusted ingest retention exceeds i64 microseconds".into(),
1155 )
1156 })?;
1157 note.expires_at = Some(note.created_at.checked_add(duration_us).ok_or_else(|| {
1158 RuntimeError::InvalidInput("trusted ingest expiry exceeds i64 microseconds".into())
1159 })?);
1160 }
1161 if let Some(n) = name {
1162 note = note.with_name(n);
1163 }
1164 if let Some(p) = properties {
1165 note = note.with_properties(p);
1166 }
1167
1168 let inserted = if let Some(attachment) = attachment {
1175 self.raw_notes(token)?
1176 .try_insert_note_with_attachments(
1177 note.clone(),
1178 vec![Attachment::from_new(
1179 note.id,
1180 AttachmentSubstrate::Note,
1181 attachment,
1182 note.created_at,
1183 )],
1184 )
1185 .await?
1186 } else {
1187 self.raw_notes(token)?.try_insert_note(note.clone()).await?
1188 };
1189 if !inserted {
1190 return Ok(None);
1191 }
1192
1193 let mut degradations = Vec::new();
1194 match self.text_for_notes(token) {
1195 Ok(fts) => {
1196 if let Err(error) = fts.upsert_document(note_fts_document(¬e)).await {
1197 record_conditional_insert_degradation(
1198 &mut degradations,
1199 note.id,
1200 ConditionalInsertStage::FtsUpsert,
1201 error,
1202 );
1203 }
1204 }
1205 Err(error) => record_conditional_insert_degradation(
1206 &mut degradations,
1207 note.id,
1208 ConditionalInsertStage::FtsAcquisition,
1209 error,
1210 ),
1211 }
1212
1213 let embed_model_names = self.embedding_models_for_note_kind(kind);
1214 for model_name in &embed_model_names {
1215 match self
1216 .embed_document_with_model_outcome_for_token(
1217 token,
1218 model_name,
1219 note_embedding_text_ref(¬e),
1220 )
1221 .await
1222 {
1223 Ok(outcome) => {
1224 if outcome.truncated {
1225 tracing::warn!(
1226 note_id = %note.id,
1227 model = %outcome.model_name,
1228 source_bytes = outcome.source_bytes,
1229 embedded_bytes = outcome.embedded_bytes,
1230 "try_create_note: embedding input truncated; full content stored unchanged"
1231 );
1232 }
1233 match self.vectors_for_model(token, model_name) {
1234 Ok(vs) => {
1235 if let Err(error) = vs
1236 .insert(
1237 note.id,
1238 SubstrateKind::Note,
1239 ns,
1240 "note.content",
1241 vec![outcome.vector],
1242 )
1243 .await
1244 {
1245 record_conditional_insert_degradation(
1246 &mut degradations,
1247 note.id,
1248 ConditionalInsertStage::VectorInsert,
1249 format!("model {model_name}: {error}"),
1250 );
1251 }
1252 }
1253 Err(error) => record_conditional_insert_degradation(
1254 &mut degradations,
1255 note.id,
1256 ConditionalInsertStage::VectorAcquisition,
1257 format!("model {model_name}: {error}"),
1258 ),
1259 }
1260 }
1261 Err(error) => record_conditional_insert_degradation(
1262 &mut degradations,
1263 note.id,
1264 ConditionalInsertStage::Embedding,
1265 format!("model {model_name}: {error}"),
1266 ),
1267 }
1268 }
1269
1270 legacy_post_commit_result("try_create_note", note.id, Some(note), degradations)
1271 }
1272
1273 #[allow(clippy::too_many_arguments)]
1277 async fn create_note_inner(
1278 &self,
1279 token: &NamespaceToken,
1280 kind: &str,
1281 name: Option<&str>,
1282 content: &str,
1283 embedding_content: Option<&str>,
1284 salience: Option<f64>,
1285 decay_factor: Option<f64>,
1286 properties: Option<serde_json::Value>,
1287 annotates: Vec<Uuid>,
1288 embedding_model: Option<&str>,
1289 capture_visibility: bool,
1290 web_receipt: bool,
1291 ) -> RuntimeResult<(
1292 Note,
1293 crate::retrieval::EmbeddingTruncationReport,
1294 Vec<PostCommitDegradation>,
1295 Vec<(String, u64)>,
1296 )> {
1297 self.validate_note_kind(kind)?;
1298 let mut properties = self.derive_note_write_properties(kind, token, properties)?;
1304 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1305 if web_receipt {
1306 let map = properties
1307 .as_mut()
1308 .and_then(serde_json::Value::as_object_mut)
1309 .expect("web receipt properties are constructed as an object");
1310 map.insert(
1311 crate::secret_gate::RESERVED_WEB_RECEIPT_KEY.to_string(),
1312 serde_json::Value::String(
1313 crate::secret_gate::WEB_RECEIPT_PROVENANCE_VALUE.to_string(),
1314 ),
1315 );
1316 }
1317 crate::secret_gate::check_at(content, "note", "content")?;
1319 if let Some(n) = name {
1320 crate::secret_gate::check_at(n, "note", "name")?;
1321 }
1322 if let Some(ref p) = properties {
1323 crate::secret_gate::check_json_at(p, "note", "properties")?;
1324 }
1325 if let Some(ec) = embedding_content {
1331 if ec.is_empty() {
1332 return Err(RuntimeError::InvalidInput(
1333 "embedding_content must not be empty".into(),
1334 ));
1335 }
1336 if ec.len() >= content.len() || !content.starts_with(ec) {
1337 return Err(RuntimeError::InvalidInput(
1338 "embedding_content must be a proper prefix of content".into(),
1339 ));
1340 }
1341 crate::secret_gate::check_at(ec, "note", "embedding_content")?;
1342 }
1343 let ns = token.namespace().as_str();
1344
1345 for &target_id in &annotates {
1348 if !self.substrate_exists_by_id(token, target_id).await? {
1349 return Err(RuntimeError::NotFound(format!(
1350 "create_note annotates target {target_id} not found"
1351 )));
1352 }
1353 }
1354
1355 if let Some(s) = salience {
1358 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
1359 return Err(RuntimeError::InvalidInput(format!(
1360 "salience must be a finite value in [0.0, 1.0]; got {s}"
1361 )));
1362 }
1363 }
1364 if let Some(d) = decay_factor {
1365 if !d.is_finite() || d < 0.0 {
1366 return Err(RuntimeError::InvalidInput(format!(
1367 "decay_factor must be a finite value >= 0.0; got {d}"
1368 )));
1369 }
1370 }
1371
1372 if let Some(model_name) = embedding_model {
1376 self.resolve_embedding_model(Some(model_name))?;
1377 }
1378
1379 let mut note = Note::new(ns, kind, content);
1380 if let Some(s) = salience {
1381 note = note.with_salience(s);
1382 }
1383 if let Some(df) = decay_factor {
1384 note = note.with_decay(df);
1385 }
1386 if let Some(n) = name {
1387 note = note.with_name(n);
1388 }
1389 if let Some(p) = properties {
1390 note = note.with_properties(p);
1391 }
1392 let notes = if web_receipt {
1393 self.raw_notes(token)?
1394 } else {
1395 self.notes(token)?
1396 };
1397 notes.upsert_note(note.clone()).await?;
1398
1399 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
1405 vec![m.to_string()]
1406 } else {
1407 self.embedding_models_for_note_kind(kind)
1408 };
1409
1410 {
1412 #[cfg(any(test, feature = "fault-injection"))]
1418 let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
1419 #[cfg(not(any(test, feature = "fault-injection")))]
1420 let fts_inject = false;
1421 let fts_result: RuntimeResult<()> = if fts_inject {
1422 Err(RuntimeError::Internal("injected FTS failure".to_string()))
1423 } else {
1424 let statements =
1425 khive_db::stores::text::delete_document_statements("fts_notes", ns, note.id)
1426 .into_iter()
1427 .chain(khive_db::stores::text::insert_document_statements(
1428 "fts_notes",
1429 ¬e_fts_document(¬e),
1430 ))
1431 .collect();
1432 self.apply_note_index_revision(¬e, statements)
1433 .await
1434 .map(|_| ())
1435 };
1436
1437 if let Err(e) = fts_result {
1438 self.compensate_note_creation(¬e).await;
1439 return Err(e);
1440 }
1441 }
1442
1443 let canonical_embed_text = note_embedding_text_ref(¬e);
1453 let embed_text = embedding_content.unwrap_or(canonical_embed_text);
1454
1455 let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
1456 let mut vector_fences = Vec::with_capacity(embed_model_names.len());
1457 if embed_model_names.len() == 1 {
1458 let model_name = &embed_model_names[0];
1460 let vec_result = self
1461 .embed_document_with_model_outcome_for_token(token, model_name, embed_text)
1462 .await;
1463
1464 #[cfg(any(test, feature = "fault-injection"))]
1474 let vec_inject = {
1475 let ns_inject = consume_fault(&VECTOR_FAIL_NS, ns);
1476 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
1477 Some(0) => {
1478 cell.set(None);
1479 true
1480 }
1481 Some(n) => {
1482 cell.set(Some(n - 1));
1483 false
1484 }
1485 None => false,
1486 });
1487 ns_inject || count_inject
1488 };
1489 #[cfg(not(any(test, feature = "fault-injection")))]
1490 let vec_inject = false;
1491 let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
1492 if vec_inject {
1493 Err(RuntimeError::Internal(
1494 "injected vector failure".to_string(),
1495 ))
1496 } else {
1497 vec_result
1498 };
1499
1500 let single_model_result: RuntimeResult<()> = match vec_result {
1501 Ok(outcome) => {
1502 embedding_report.observe(&outcome);
1503 if capture_visibility {
1504 match self
1505 .publish_note_vector_revision_with_seq(
1506 token,
1507 ¬e,
1508 model_name,
1509 &outcome.vector,
1510 )
1511 .await
1512 {
1513 Ok(Some(seq)) => {
1514 vector_fences.push((model_name.clone(), seq));
1515 Ok(())
1516 }
1517 Ok(None) => Ok(()),
1518 Err(error) => Err(error),
1519 }
1520 } else {
1521 self.publish_note_vector_revision(token, ¬e, model_name, &outcome.vector)
1522 .await
1523 .map(|_| ())
1524 }
1525 }
1526 Err(e) => Err(e),
1527 };
1528 if let Err(e) = single_model_result {
1529 self.compensate_note_creation(¬e).await;
1530 return Err(e);
1531 }
1532 } else if !embed_model_names.is_empty() {
1533 let rt_clone = self.clone();
1536 let content_owned: std::sync::Arc<str> = std::sync::Arc::from(embed_text);
1539 let usage_ctx = crate::usage::current();
1540 let mut join_set = tokio::task::JoinSet::new();
1541 for (idx, model_name) in embed_model_names.iter().enumerate() {
1542 let rt = rt_clone.clone();
1543 let text = std::sync::Arc::clone(&content_owned);
1544 let name = model_name.clone();
1545 let ctx = usage_ctx.clone();
1546 let token = (*token).clone();
1547 join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
1548 let fut = rt.embed_document_with_model_outcome_for_token(
1549 &token,
1550 &name,
1551 text.as_ref(),
1552 );
1553 let result = match ctx {
1554 Some(ctx) => crate::usage::scope(ctx, fut).await,
1555 None => fut.await,
1556 };
1557 (idx, result)
1558 }));
1559 }
1560 let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
1564 Ok(outcomes) => outcomes,
1565 Err(e) => {
1566 self.compensate_note_creation(¬e).await;
1567 return Err(e);
1568 }
1569 };
1570 for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
1572 embedding_report.observe(&outcome);
1573 let insert_result = if capture_visibility {
1574 self.publish_note_vector_revision_with_seq(
1575 token,
1576 ¬e,
1577 model_name,
1578 &outcome.vector,
1579 )
1580 .await
1581 .map(|seq| {
1582 if let Some(seq) = seq {
1583 vector_fences.push((model_name.clone(), seq));
1584 }
1585 })
1586 } else {
1587 self.publish_note_vector_revision(token, ¬e, model_name, &outcome.vector)
1588 .await
1589 .map(|_| ())
1590 };
1591 if let Err(e) = insert_result {
1592 self.compensate_note_creation(¬e).await;
1593 return Err(e);
1594 }
1595 }
1596 }
1597
1598 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1603
1604 #[cfg(test)]
1607 let annotates_iter: Vec<(usize, Uuid)> = annotates
1608 .iter()
1609 .enumerate()
1610 .map(|(i, &id)| (i, id))
1611 .collect();
1612 #[cfg(test)]
1613 macro_rules! next_target {
1614 ($pair:expr) => {
1615 $pair.1
1616 };
1617 }
1618 #[cfg(not(test))]
1619 let annotates_iter: Vec<Uuid> = annotates.to_vec();
1620 #[cfg(not(test))]
1621 macro_rules! next_target {
1622 ($pair:expr) => {
1623 $pair
1624 };
1625 }
1626
1627 for pair in annotates_iter {
1628 let target_id = next_target!(pair);
1629
1630 #[cfg(test)]
1632 let injected_err: Option<RuntimeError> = {
1633 let call_idx = pair.0;
1634 LINK_FAIL_AFTER.with(|cell| {
1635 let n = cell.get();
1636 if n > 0 && call_idx + 1 == n {
1637 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
1639 } else {
1640 None
1641 }
1642 })
1643 };
1644 #[cfg(not(test))]
1645 let injected_err: Option<RuntimeError> = None;
1646
1647 let link_result = if let Some(e) = injected_err {
1648 Err(e)
1649 } else {
1650 self.link(
1651 token,
1652 note.id,
1653 target_id,
1654 EdgeRelation::Annotates,
1655 1.0,
1656 None,
1657 )
1658 .await
1659 };
1660
1661 match link_result {
1662 Ok(edge) => created_edges.push(edge.id.into()),
1663 Err(e) => {
1664 let edge_ids = created_edges
1669 .iter()
1670 .map(Uuid::to_string)
1671 .collect::<Vec<_>>()
1672 .join(", ");
1673 match self.compensate_note_creation_with_edges(¬e).await {
1674 Ok(true) => return Err(e),
1675 Ok(false) => {
1676 return Err(RuntimeError::Internal(format!(
1677 "create_note: annotates link failed: {e}; note {} changed before \
1678 compensation, retaining its incident edges [{edge_ids}]",
1679 note.id
1680 )));
1681 }
1682 Err(cleanup_error) => {
1683 return Err(RuntimeError::Internal(format!(
1684 "create_note: annotates link failed: {e}; compensation failed \
1685 for note {} and retained edges [{edge_ids}]: {cleanup_error}; \
1686 note and edges remain for reconciliation",
1687 note.id
1688 )));
1689 }
1690 }
1691 }
1692 }
1693 }
1694
1695 let created_event = khive_storage::event::Event::new(
1700 note.namespace.clone(),
1701 "create",
1702 EventKind::NoteCreated,
1703 SubstrateKind::Note,
1704 "",
1705 )
1706 .with_target(note.id)
1707 .with_payload(serde_json::json!({
1708 "id": note.id,
1709 "namespace": note.namespace,
1710 "kind": note.kind,
1711 "salience": note.salience,
1712 }));
1713 let event_result = match self.events(token) {
1714 Ok(store) => store
1715 .append_event(created_event)
1716 .await
1717 .map_err(RuntimeError::from),
1718 Err(error) => Err(error),
1719 };
1720 let mut degradations = Vec::new();
1721 if let Err(error) = event_result {
1722 record_post_commit_degradation(
1723 &mut degradations,
1724 "create_note",
1725 note.id,
1726 "event_append",
1727 error,
1728 );
1729 }
1730
1731 vector_fences.sort_by(|a, b| a.0.cmp(&b.0));
1732 Ok((note, embedding_report, degradations, vector_fences))
1733 }
1734
1735 #[allow(clippy::too_many_arguments)]
1755 pub async fn search_notes(
1756 &self,
1757 token: &NamespaceToken,
1758 query_text: &str,
1759 query_vector: Option<Vec<f32>>,
1760 limit: u32,
1761 note_kind: Option<&str>,
1762 include_superseded: bool,
1763 tags_any: &[String],
1764 properties_filter: Option<&serde_json::Value>,
1765 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1766 self.search_notes_with_text_mode(
1767 token,
1768 query_text,
1769 query_vector,
1770 limit,
1771 note_kind,
1772 include_superseded,
1773 tags_any,
1774 properties_filter,
1775 TextQueryMode::Plain,
1776 )
1777 .await
1778 }
1779
1780 #[allow(clippy::too_many_arguments)]
1782 pub async fn search_notes_with_text_mode(
1783 &self,
1784 token: &NamespaceToken,
1785 query_text: &str,
1786 query_vector: Option<Vec<f32>>,
1787 limit: u32,
1788 note_kind: Option<&str>,
1789 include_superseded: bool,
1790 tags_any: &[String],
1791 properties_filter: Option<&serde_json::Value>,
1792 text_mode: TextQueryMode,
1793 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1794 let (hits, _vector_error) = self
1795 .search_notes_inner(
1796 token,
1797 query_text,
1798 query_vector,
1799 limit,
1800 note_kind,
1801 include_superseded,
1802 tags_any,
1803 properties_filter,
1804 text_mode,
1805 false,
1806 )
1807 .await?;
1808 Ok(hits)
1809 }
1810
1811 #[allow(clippy::too_many_arguments)]
1818 pub async fn search_notes_outcome(
1819 &self,
1820 token: &NamespaceToken,
1821 query_text: &str,
1822 limit: u32,
1823 note_kind: Option<&str>,
1824 include_superseded: bool,
1825 tags_any: &[String],
1826 properties_filter: Option<&serde_json::Value>,
1827 ) -> RuntimeResult<NoteSearchOutcome> {
1828 self.search_notes_outcome_with_text_mode(
1829 token,
1830 query_text,
1831 limit,
1832 note_kind,
1833 include_superseded,
1834 tags_any,
1835 properties_filter,
1836 TextQueryMode::Plain,
1837 )
1838 .await
1839 }
1840
1841 #[allow(clippy::too_many_arguments)]
1843 pub async fn search_notes_outcome_with_text_mode(
1844 &self,
1845 token: &NamespaceToken,
1846 query_text: &str,
1847 limit: u32,
1848 note_kind: Option<&str>,
1849 include_superseded: bool,
1850 tags_any: &[String],
1851 properties_filter: Option<&serde_json::Value>,
1852 text_mode: TextQueryMode,
1853 ) -> RuntimeResult<NoteSearchOutcome> {
1854 let (hits, vector_error) = self
1855 .search_notes_inner(
1856 token,
1857 query_text,
1858 None,
1859 limit,
1860 note_kind,
1861 include_superseded,
1862 tags_any,
1863 properties_filter,
1864 text_mode,
1865 true,
1866 )
1867 .await?;
1868 Ok(NoteSearchOutcome { hits, vector_error })
1869 }
1870
1871 #[allow(clippy::too_many_arguments)]
1872 async fn search_notes_inner(
1873 &self,
1874 token: &NamespaceToken,
1875 query_text: &str,
1876 query_vector: Option<Vec<f32>>,
1877 limit: u32,
1878 note_kind: Option<&str>,
1879 include_superseded: bool,
1880 tags_any: &[String],
1881 properties_filter: Option<&serde_json::Value>,
1882 text_mode: TextQueryMode,
1883 tolerate_vector_error: bool,
1884 ) -> RuntimeResult<(Vec<NoteSearchHit>, Option<String>)> {
1885 const RRF_K: usize = 60;
1886 let candidates = limit.saturating_mul(4).max(limit);
1887 let visible_ns: Vec<String> = token
1888 .visible_namespaces()
1889 .iter()
1890 .map(|ns| ns.as_str().to_owned())
1891 .collect();
1892
1893 #[cfg(any(test, feature = "fault-injection"))]
1906 let fts_search_inject = {
1907 let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
1908 match g.as_deref() {
1909 Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
1910 *g = None;
1911 true
1912 }
1913 _ => false,
1914 }
1915 };
1916 #[cfg(not(any(test, feature = "fault-injection")))]
1917 let fts_search_inject = false;
1918
1919 let text_store = self.text_for_notes(token)?;
1920 let text_fut = async {
1921 if fts_search_inject {
1922 return Err(khive_storage::StorageError::Timeout {
1923 operation: "fts_search".into(),
1924 });
1925 }
1926 text_store
1927 .search(TextSearchRequest {
1928 query: query_text.to_string(),
1929 mode: text_mode,
1930 filter: Some(TextFilter {
1931 namespaces: visible_ns.clone(),
1932 record_kinds: note_kind
1939 .map(|kind| vec![kind.to_string()])
1940 .unwrap_or_default(),
1941 ..TextFilter::default()
1942 }),
1943 top_k: candidates,
1944 snippet_chars: 200,
1945 })
1946 .await
1947 };
1948 let text_fut = crate::stage_seam::text_stage(text_fut);
1949
1950 let vector_fut = async {
1952 if query_vector.is_some() || self.config().embedding_model.is_some() {
1953 self.note_search_vector_search(token, query_vector, query_text, candidates)
1954 .await
1955 } else {
1956 Ok(vec![])
1957 }
1958 };
1959 let (text_search_result, vector_result) = tokio::join!(text_fut, vector_fut);
1960
1961 let text_hits = crate::error::fts_text_leg_or_err(
1967 text_search_result.map_err(RuntimeError::from),
1968 "search_notes",
1969 query_text,
1970 )?;
1971
1972 let mut vector_error: Option<String> = None;
1973 let vector_hits = match vector_result {
1974 Ok(hits) => hits,
1975 Err(e) if tolerate_vector_error => {
1976 vector_error = Some(e.to_string());
1977 Vec::new()
1978 }
1979 Err(e) => return Err(e),
1980 };
1981
1982 let fuse_k = text_hits.len() + vector_hits.len();
1988 let fused = self
1989 .fuse_with_strategy(
1990 text_hits,
1991 vector_hits,
1992 &crate::fusion::FusionStrategy::Rrf { k: RRF_K },
1993 fuse_k,
1994 )
1995 .await?;
1996
1997 let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
1998 if candidate_ids.is_empty() {
1999 return Ok((vec![], vector_error));
2000 }
2001
2002 let note_store = self.notes(token)?;
2009 let search_pool = self.backend().pool_arc();
2010 let mailbox_view = crate::MailboxView {
2011 actor_id: token.actor().id.clone(),
2012 delegated: false,
2013 };
2014 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
2015 for note in note_store.get_notes_batch(&candidate_ids).await? {
2016 search_pool.record_note_candidate_hydration_row();
2017 if note.deleted_at.is_some() {
2018 continue;
2019 }
2020 if !mailbox_view.permits_message_note(token, ¬e) {
2021 continue;
2022 }
2023 if let Some(want_kind) = note_kind {
2024 if note.kind != want_kind {
2025 continue;
2026 }
2027 }
2028 if !tags_any.is_empty() {
2033 let note_tags: Vec<String> = note
2034 .properties
2035 .as_ref()
2036 .and_then(|p| p.get("tags"))
2037 .and_then(serde_json::Value::as_array)
2038 .map(|arr| {
2039 arr.iter()
2040 .filter_map(serde_json::Value::as_str)
2041 .map(str::to_owned)
2042 .collect()
2043 })
2044 .unwrap_or_default();
2045 if !note_tags
2046 .iter()
2047 .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
2048 {
2049 continue;
2050 }
2051 }
2052 if let Some(pf) = properties_filter {
2054 if !crate::retrieval::properties_match(note.properties.as_ref(), pf) {
2055 continue;
2056 }
2057 }
2058 alive_notes.insert(note.id, note);
2059 }
2060
2061 if !include_superseded && !alive_notes.is_empty() {
2064 let graph = self.graph(token)?;
2065 let note_ids: Vec<Uuid> = alive_notes.keys().copied().collect();
2066 let superseded: std::collections::HashSet<Uuid> = graph
2067 .batch_neighbors(
2068 ¬e_ids,
2069 NeighborQuery {
2070 direction: Direction::In,
2071 relations: Some(vec![EdgeRelation::Supersedes]),
2072 limit: Some(1),
2073 min_weight: None,
2074 },
2075 )
2076 .await?
2077 .into_iter()
2078 .map(|(note_id, _)| note_id)
2079 .collect();
2080 alive_notes.retain(|id, _| !superseded.contains(id));
2081 }
2082
2083 let mut hits: Vec<NoteSearchHit> = fused
2085 .into_iter()
2086 .filter_map(|hit| {
2087 let note = alive_notes.get(&hit.entity_id)?;
2088 let weighted = salience_weighted_rank(hit.score, note.salience);
2089 Some(NoteSearchHit {
2090 note_id: hit.entity_id,
2091 score: weighted,
2092 rank_score_kind: hit.rank_score_kind,
2093 signals: hit.signals,
2094 source: hit.source,
2095 title: hit.title.or_else(|| note_title(note)),
2096 snippet: hit.snippet.or_else(|| note_snippet(note)),
2097 })
2098 })
2099 .collect();
2100
2101 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
2102 hits.truncate(limit as usize);
2103 Ok((hits, vector_error))
2104 }
2105
2106 pub async fn restore_note(
2112 &self,
2113 token: &NamespaceToken,
2114 id: Uuid,
2115 ) -> RuntimeResult<Option<(Note, bool)>> {
2116 let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? else {
2117 return Ok(None);
2118 };
2119 if note.namespace != token.namespace().as_str() {
2120 return Ok(None);
2121 }
2122 if note.deleted_at.is_none() {
2123 return Ok(Some((note, false)));
2124 }
2125 if let Some(key) = note.key.as_deref() {
2126 if let Some(holder) = self
2127 .notes(token)?
2128 .get_live_notes_by_key(¬e.namespace, key, Some(¬e.kind))
2129 .await?
2130 .into_iter()
2131 .find(|holder| holder.id != note.id)
2132 {
2133 return Err(restore_key_conflict(key, &holder));
2134 }
2135 }
2136 let updated_at =
2137 Utc::now()
2138 .timestamp_micros()
2139 .max(note.updated_at.checked_add(1).ok_or_else(|| {
2140 RuntimeError::Internal(format!(
2141 "note {id} updated_at is already at i64::MAX and cannot advance"
2142 ))
2143 })?);
2144 let mut params = vec![
2145 SqlValue::Text("active".into()),
2146 SqlValue::Integer(updated_at),
2147 SqlValue::Text(id.to_string()),
2148 SqlValue::Text(note.namespace.clone()),
2149 SqlValue::Text(note.kind.clone()),
2150 ];
2151 let key_clause = if let Some(key) = note.key.as_deref() {
2152 params.push(SqlValue::Text(key.to_owned()));
2153 format!(
2154 " AND (key IS NULL OR NOT EXISTS (SELECT 1 FROM notes live \
2155 WHERE live.namespace=?4 AND live.kind=?5 AND live.key=?{} \
2156 AND live.deleted_at IS NULL AND live.id != notes.id))",
2157 params.len()
2158 )
2159 } else {
2160 String::new()
2161 };
2162 let mut restored = note.clone();
2163 restored.status = "active".into();
2164 restored.deleted_at = None;
2165 restored.updated_at = updated_at;
2166 restored.version = restored
2167 .version
2168 .checked_add(1)
2169 .ok_or_else(|| RuntimeError::Internal(format!("note {id} version is exhausted")))?;
2170 let mut statements = vec![PlanStatement {
2171 statement: SqlStatement {
2172 sql: format!(
2173 "UPDATE notes SET status=?1, deleted_at=NULL, updated_at=?2 \
2174 WHERE id=?3 AND namespace=?4 AND kind=?5 AND deleted_at IS NOT NULL{key_clause}"
2175 ),
2176 params,
2177 label: Some("note-restore".into()),
2178 },
2179 guard: Some(AffectedRowGuard::exactly(1)),
2180 }];
2181 for statement in
2183 khive_db::stores::text::delete_document_statements("fts_notes", &restored.namespace, id)
2184 .into_iter()
2185 .chain(insert_document_statements(
2186 "fts_notes",
2187 ¬e_fts_document(&restored),
2188 ))
2189 {
2190 statements.push(PlanStatement {
2191 statement,
2192 guard: None,
2193 });
2194 }
2195 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
2196 graph_effects: Vec::new(),
2197 target_id: id,
2198 statements,
2199 post_commit: PostCommitEffect::None,
2200 edge_natural_key: None,
2201 idempotent_noop: false,
2202 entity_guard: None,
2203 note_guard: None,
2204 note_vector_purge: None,
2205 note_embedding_inheritance: None,
2206 }));
2207 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
2208 Ok(AtomicRunOutcome::Committed { .. }) => {
2209 #[cfg(any(test, feature = "fault-injection"))]
2210 if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
2211 return Err(restore_reindex_failed(
2212 "note",
2213 id,
2214 RuntimeError::Internal("injected FTS failure".to_string()),
2215 ));
2216 }
2217 let reindexed = self.reindex_note_with_report(token, &restored).await;
2218 let report = reindexed.map_err(|e| restore_reindex_failed("note", id, e))?;
2219 let degradations = report.post_commit_degradations();
2220 legacy_post_commit_result("restore_note", id, Some((restored, true)), degradations)
2221 }
2222 Ok(AtomicRunOutcome::RolledBack {
2223 failure: AtomicOpFailure::GuardFailed { .. },
2224 ..
2225 }) => {
2226 if let Some(key) = note.key.as_deref() {
2227 if let Some(holder) = self
2228 .notes(token)?
2229 .get_live_notes_by_key(¬e.namespace, key, Some(¬e.kind))
2230 .await?
2231 .into_iter()
2232 .find(|holder| holder.id != note.id)
2233 {
2234 return Err(restore_key_conflict(key, &holder));
2235 }
2236 }
2237 Err(RuntimeError::NotFound(format!(
2238 "note {id} is no longer a caller-owned tombstone"
2239 )))
2240 }
2241 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
2242 format!("note restore rolled back: {failure:?}"),
2243 )),
2244 Err(error) => Err(RuntimeError::Storage(error.0)),
2245 }
2246 }
2247}
2248
2249#[derive(Clone, Debug)]
2252pub struct LinkSpec {
2253 pub namespace: Option<String>,
2254 pub source_id: Uuid,
2255 pub target_id: Uuid,
2256 pub relation: EdgeRelation,
2257 pub weight: f64,
2258 pub metadata: Option<serde_json::Value>,
2259 pub resurrect: bool,
2260}
2261
2262#[derive(Clone, Debug)]
2270pub struct EntityCreateSpec {
2271 pub kind: String,
2272 pub entity_type: Option<String>,
2273 pub name: String,
2274 pub description: Option<String>,
2275 pub properties: Option<serde_json::Value>,
2276 pub tags: Vec<String>,
2277}
2278
2279#[cfg(test)]
2285#[path = "operations_tests.rs"]
2286mod tests;