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
43mod note_search;
44mod quarantine_note_retention;
45
46#[cfg(test)]
47mod batch_edge_tests;
48
49struct EdgeReadWindow {
50 outcomes: Vec<Option<RuntimeResult<Option<Edge>>>>,
51 groups: Vec<(khive_types::Namespace, Vec<usize>)>,
52}
53
54fn restore_reindex_failed(kind: &str, id: Uuid, error: RuntimeError) -> RuntimeError {
58 RuntimeError::Internal(format!(
59 "{kind} {id} is restored and text-indexed, but its embedding rebuild failed \
60 and will be retried by the next reindex: {error}"
61 ))
62}
63
64fn merge_tombstone_restore_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
65 KhiveError::conflict(format!(
66 "merge_tombstone: {id} was merged into {kept_id}; a merge tombstone is not restorable, query the kept id"
67 ))
68 .with_details(khive_types::Details::new_owned([
69 ("reason", "merge_tombstone".into()),
70 ("merged_into", kept_id.to_string()),
71 ]))
72 .into()
73}
74
75#[derive(Debug, PartialEq, Eq)]
77pub struct EntityStatsCounts {
78 pub entities: u64,
79 pub entities_by_type: Option<EntityTypeCounts>,
80}
81
82#[derive(Debug, Default, PartialEq, Eq)]
84pub struct EdgeStatsCounts {
85 pub edges: u64,
86 pub edges_by_relation: HashMap<String, u64>,
87 pub edges_by_endpoint_base: khive_storage::types::EdgeEndpointBaseCounts,
88}
89
90pub async fn entity_stats_counts(
93 store: &dyn khive_storage::EntityStore,
94 token: &NamespaceToken,
95) -> RuntimeResult<EntityStatsCounts> {
96 let namespaces: Vec<String> = token
97 .visible_namespaces()
98 .iter()
99 .map(|namespace| namespace.as_str().to_owned())
100 .collect();
101 match store.count_entities_by_type(&namespaces).await? {
102 Some(groups) => {
103 let entities = groups.iter().try_fold(0_u64, |total, (_, count)| {
104 total.checked_add(*count).ok_or_else(|| {
105 RuntimeError::Internal(
106 "entity type counts exceed the scalar count range".into(),
107 )
108 })
109 })?;
110 Ok(EntityStatsCounts {
111 entities,
112 entities_by_type: Some(groups),
113 })
114 }
115 None => {
116 let entities = store
117 .count_entities(
118 token.namespace().as_str(),
119 EntityFilter {
120 namespaces,
121 ..EntityFilter::default()
122 },
123 )
124 .await?;
125 Ok(EntityStatsCounts {
126 entities,
127 entities_by_type: None,
128 })
129 }
130 }
131}
132
133pub struct EntityClaimSpec {
136 pub id: Uuid,
137 pub kind: String,
138 pub entity_type: Option<String>,
139 pub name: String,
140 pub description: Option<String>,
141 pub properties: Option<serde_json::Value>,
142 pub tags: Vec<String>,
143 pub identity_tag: String,
144}
145
146fn live_merged_entity_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
147 KhiveError::conflict(format!(
148 "live_merged_entity: {id} is live but still carries merged_into {kept_id}; a row an \
149 earlier restore left live over its merge is not restorable, re-tombstone it or query the \
150 kept id"
151 ))
152 .with_details(khive_types::Details::new_owned([
153 ("reason", "live_merged_entity".into()),
154 ("merged_into", kept_id.to_string()),
155 ]))
156 .into()
157}
158
159fn restore_key_conflict(key: &str, holder: &Note) -> RuntimeError {
160 KhiveError::conflict(format!(
161 "restore_key_conflict: key {key:?} is already held by live note {}",
162 holder.id
163 ))
164 .with_details(khive_types::Details::new_owned([
165 ("reason", "restore_key_conflict".into()),
166 ("key", key.to_owned()),
167 ("existing_id", holder.id.to_string()),
168 ]))
169 .into()
170}
171
172use crate::atomic_plan::{
173 AddEntityPlan, AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan,
174};
175use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
176use crate::curation::{entity_fts_document, note_embedding_text_ref, note_fts_document};
177use crate::error::{GuardedWriteFailure, RuntimeError, RuntimeResult};
178use crate::runtime::{KhiveRuntime, NamespaceToken};
179
180#[derive(Clone, Debug, Serialize)]
183pub struct PostCommitDegradation {
184 pub stage: &'static str,
185 pub error: String,
186}
187
188impl PostCommitDegradation {
189 fn new(stage: &'static str, error: impl ToString) -> Self {
190 Self {
191 stage,
192 error: error.to_string(),
193 }
194 }
195}
196
197macro_rules! conditional_insert_stages {
199 ($($stage:ident => $label:literal),+ $(,)?) => {
200 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
206 pub enum ConditionalInsertStage {
207 $($stage,)+
208 }
209
210 impl ConditionalInsertStage {
211 pub const ALL: &'static [Self] = &[$(Self::$stage,)+];
213
214 pub const fn label(self) -> &'static str {
215 match self {
216 $(Self::$stage => $label,)+
217 }
218 }
219 }
220 };
221}
222
223conditional_insert_stages! {
224 FtsAcquisition => "fts_acquisition",
225 FtsUpsert => "fts_upsert",
226 Embedding => "embedding",
227 VectorAcquisition => "vector_acquisition",
228 VectorInsert => "vector_insert",
229}
230
231impl ConditionalInsertStage {
232 pub fn from_label(label: &str) -> Option<Self> {
233 Self::ALL
234 .iter()
235 .copied()
236 .find(|stage| stage.label() == label)
237 }
238}
239
240fn record_conditional_insert_degradation(
241 degradations: &mut Vec<PostCommitDegradation>,
242 id: Uuid,
243 stage: ConditionalInsertStage,
244 error: impl ToString,
245) {
246 record_post_commit_degradation(degradations, "try_create_note", id, stage.label(), error);
247}
248
249fn record_post_commit_degradation(
250 degradations: &mut Vec<PostCommitDegradation>,
251 operation: &'static str,
252 id: Uuid,
253 stage: &'static str,
254 error: impl ToString,
255) {
256 let degradation = PostCommitDegradation::new(stage, error);
257 tracing::warn!(%operation, %id, stage, error = %degradation.error,
258 "substrate mutation committed with post-commit degradation");
259 degradations.push(degradation);
260}
261
262pub(crate) fn legacy_post_commit_result<T>(
266 operation: &'static str,
267 id: Uuid,
268 value: T,
269 degradations: Vec<PostCommitDegradation>,
270) -> RuntimeResult<T> {
271 if degradations.is_empty() {
272 return Ok(value);
273 }
274 let failures = serde_json::Value::Array(
275 degradations
276 .iter()
277 .map(|failure| {
278 serde_json::json!({"stage": failure.stage, "error": failure.error.as_str()})
279 })
280 .collect(),
281 );
282 Err(KhiveError::internal(format!(
283 "{operation} committed record {id}, but post-commit work failed; do not retry the mutation; reconcile by record_id"
284 ))
285 .with_details(khive_types::Details::new_owned([
286 ("reason", "post_commit_degraded".to_string()),
287 ("operation", operation.to_string()),
288 ("record_id", id.to_string()),
289 ("committed", "true".to_string()),
290 ("retryable", "false".to_string()),
291 ("post_commit_degradations", failures.to_string()),
292 ]))
293 .into())
294}
295
296pub(crate) fn legacy_post_commit_result_with_embedding<T>(
297 operation: &'static str,
298 id: Uuid,
299 value: T,
300 embedding: crate::retrieval::EmbeddingTruncationReport,
301 degradations: Vec<PostCommitDegradation>,
302) -> RuntimeResult<T> {
303 if !embedding.any_truncated() {
304 return legacy_post_commit_result(operation, id, value, degradations);
305 }
306 let failures = serde_json::Value::Array(
307 degradations
308 .iter()
309 .map(|failure| {
310 serde_json::json!({"stage": failure.stage, "error": failure.error.as_str()})
311 })
312 .collect(),
313 );
314 Err(KhiveError::internal(format!(
315 "{operation} committed record {id}, but embedding input was truncated; do not retry the mutation; reconcile by record_id"
316 ))
317 .with_details(khive_types::Details::new_owned([
318 ("reason", "embedding_input_truncated".to_string()),
319 ("operation", operation.to_string()),
320 ("record_id", id.to_string()),
321 ("committed", "true".to_string()),
322 ("retryable", "false".to_string()),
323 (
324 "embedding_truncation_report",
325 serde_json::json!(embedding).to_string(),
326 ),
327 ("post_commit_degradations", failures.to_string()),
328 ]))
329 .into())
330}
331
332mod entity_bulk;
333mod entity_create;
334mod entity_indexes;
335#[cfg(any(test, feature = "fault-injection"))]
336mod fault_injection;
337mod resolve_prefix_in;
338mod resolve_uuid_or_prefix;
339
340mod delete_restore;
341mod edge_query;
342mod graph_rules;
343mod identity_reads;
344mod link_ops;
345mod read_helpers;
346mod record_ops;
347mod traversal;
348
349use read_helpers::{
350 direction_sort_rank, drain_embed_join_set, normalize_symmetric_direction, note_snippet,
351 note_title, resolve_prefix_statement, salience_weighted_rank,
352};
353pub use read_helpers::{
354 hex_prefix_to_uuid_pattern, uuid_prefix_bounds, NoteSearchHit, NoteSearchOutcome, Resolved,
355};
356pub use record_ops::NoteCreateSpec;
357
358pub use edge_query::QueryResult;
359use graph_rules::{accepted_entity_relations_description, static_impossible_edge_pattern_warnings};
360pub use graph_rules::{
361 accepted_entity_relations_for_entities, accepted_pack_relations_for_entities,
362 dependency_kind_inference_hint, merge_entry_metadata,
363};
364pub(crate) use graph_rules::{
365 canonical_edge_endpoint_kinds, canonical_edge_endpoints, merge_dependency_kind,
366 pack_rule_allows, 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)]
889 async fn try_create_note_impl(
890 &self,
891 token: &NamespaceToken,
892 kind: &str,
893 name: Option<&str>,
894 content: &str,
895 properties: Option<serde_json::Value>,
896 allow_transport_owned_message_properties: bool,
897 attachment: Option<NewAttachment>,
898 expires_after: Option<std::time::Duration>,
899 ) -> RuntimeResult<Option<Note>> {
900 self.validate_note_kind(kind)?;
901 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
902 crate::secret_gate::check_note_fields_at(content, name, properties.as_ref(), "note")?;
903 if let Some(ref attachment) = attachment {
904 drop(self.attachments()?);
907 attachment.validate()?;
908 let blob_store = self.blob_store().ok_or_else(|| {
909 RuntimeError::Unconfigured(
910 "trusted ingest attachment requires an installed BlobStore".to_string(),
911 )
912 })?;
913 if !blob_store.exists(&attachment.content_ref).await? {
914 return Err(RuntimeError::InvalidInput(format!(
915 "trusted ingest attachment refers to an unpublished blob: {}",
916 attachment.content_ref
917 )));
918 }
919 }
920 if !allow_transport_owned_message_properties && kind == "message" {
921 if let Some(key) = properties
922 .as_ref()
923 .and_then(serde_json::Value::as_object)
924 .and_then(|supplied| {
925 crate::curation::kind_owned_properties("message")
926 .iter()
927 .copied()
928 .find(|key| supplied.contains_key(*key))
929 })
930 {
931 return Err(RuntimeError::InvalidInput(format!(
932 "`{key}` is transport-owned on a `message` note and cannot be supplied \
933 through `try_create_note`; only the trusted channel-ingest path may \
934 establish quarantine disposition and channel provenance"
935 )));
936 }
937 }
938
939 let ns = token.namespace().as_str();
940 let mut note = Note::new(ns, kind, content);
941 if let Some(retention) = expires_after {
942 let duration_us = i64::try_from(retention.as_micros()).map_err(|_| {
943 RuntimeError::InvalidInput(
944 "trusted ingest retention exceeds i64 microseconds".into(),
945 )
946 })?;
947 note.expires_at = Some(note.created_at.checked_add(duration_us).ok_or_else(|| {
948 RuntimeError::InvalidInput("trusted ingest expiry exceeds i64 microseconds".into())
949 })?);
950 }
951 if let Some(n) = name {
952 note = note.with_name(n);
953 }
954 if let Some(p) = properties {
955 note = note.with_properties(p);
956 }
957
958 let inserted = if let Some(attachment) = attachment {
965 self.raw_notes(token)?
966 .try_insert_note_with_attachments(
967 note.clone(),
968 vec![Attachment::from_new(
969 note.id,
970 AttachmentSubstrate::Note,
971 attachment,
972 note.created_at,
973 )],
974 )
975 .await?
976 } else {
977 self.raw_notes(token)?.try_insert_note(note.clone()).await?
978 };
979 if !inserted {
980 return Ok(None);
981 }
982
983 let mut degradations = Vec::new();
984 match self.text_for_notes(token) {
985 Ok(fts) => {
986 if let Err(error) = fts.upsert_document(note_fts_document(¬e)).await {
987 record_conditional_insert_degradation(
988 &mut degradations,
989 note.id,
990 ConditionalInsertStage::FtsUpsert,
991 error,
992 );
993 }
994 }
995 Err(error) => record_conditional_insert_degradation(
996 &mut degradations,
997 note.id,
998 ConditionalInsertStage::FtsAcquisition,
999 error,
1000 ),
1001 }
1002
1003 let embed_model_names = self.embedding_models_for_note_kind(kind);
1004 for model_name in &embed_model_names {
1005 match self
1006 .embed_document_with_model_outcome_for_token(
1007 token,
1008 model_name,
1009 note_embedding_text_ref(¬e),
1010 )
1011 .await
1012 {
1013 Ok(outcome) => {
1014 if outcome.truncated {
1015 tracing::warn!(
1016 note_id = %note.id,
1017 model = %outcome.model_name,
1018 source_bytes = outcome.source_bytes,
1019 embedded_bytes = outcome.embedded_bytes,
1020 "try_create_note: embedding input truncated; full content stored unchanged"
1021 );
1022 }
1023 match self.vectors_for_model(token, model_name) {
1024 Ok(vs) => {
1025 if let Err(error) = vs
1026 .insert(
1027 note.id,
1028 SubstrateKind::Note,
1029 ns,
1030 "note.content",
1031 vec![outcome.vector],
1032 )
1033 .await
1034 {
1035 record_conditional_insert_degradation(
1036 &mut degradations,
1037 note.id,
1038 ConditionalInsertStage::VectorInsert,
1039 format!("model {model_name}: {error}"),
1040 );
1041 }
1042 }
1043 Err(error) => record_conditional_insert_degradation(
1044 &mut degradations,
1045 note.id,
1046 ConditionalInsertStage::VectorAcquisition,
1047 format!("model {model_name}: {error}"),
1048 ),
1049 }
1050 }
1051 Err(error) => record_conditional_insert_degradation(
1052 &mut degradations,
1053 note.id,
1054 ConditionalInsertStage::Embedding,
1055 format!("model {model_name}: {error}"),
1056 ),
1057 }
1058 }
1059
1060 legacy_post_commit_result("try_create_note", note.id, Some(note), degradations)
1061 }
1062
1063 #[allow(clippy::too_many_arguments)]
1067 async fn create_note_inner(
1068 &self,
1069 token: &NamespaceToken,
1070 kind: &str,
1071 name: Option<&str>,
1072 content: &str,
1073 embedding_content: Option<&str>,
1074 salience: Option<f64>,
1075 decay_factor: Option<f64>,
1076 properties: Option<serde_json::Value>,
1077 annotates: Vec<Uuid>,
1078 embedding_model: Option<&str>,
1079 capture_visibility: bool,
1080 web_receipt: bool,
1081 ) -> RuntimeResult<(
1082 Note,
1083 crate::retrieval::EmbeddingTruncationReport,
1084 Vec<PostCommitDegradation>,
1085 Vec<(String, u64)>,
1086 )> {
1087 self.validate_note_kind(kind)?;
1088 let mut properties = self.derive_note_write_properties(kind, token, properties)?;
1094 crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1095 if web_receipt {
1096 let map = properties
1097 .as_mut()
1098 .and_then(serde_json::Value::as_object_mut)
1099 .expect("web receipt properties are constructed as an object");
1100 map.insert(
1101 crate::secret_gate::RESERVED_WEB_RECEIPT_KEY.to_string(),
1102 serde_json::Value::String(
1103 crate::secret_gate::WEB_RECEIPT_PROVENANCE_VALUE.to_string(),
1104 ),
1105 );
1106 }
1107 crate::secret_gate::check_note_fields_at(content, name, properties.as_ref(), "note")?;
1109 if let Some(ec) = embedding_content {
1115 if ec.is_empty() {
1116 return Err(RuntimeError::InvalidInput(
1117 "embedding_content must not be empty".into(),
1118 ));
1119 }
1120 if ec.len() >= content.len() || !content.starts_with(ec) {
1121 return Err(RuntimeError::InvalidInput(
1122 "embedding_content must be a proper prefix of content".into(),
1123 ));
1124 }
1125 crate::secret_gate::check_at(ec, "note", "embedding_content")?;
1126 }
1127 let ns = token.namespace().as_str();
1128
1129 for &target_id in &annotates {
1132 if !self.substrate_exists_by_id(token, target_id).await? {
1133 return Err(RuntimeError::NotFound(format!(
1134 "create_note annotates target {target_id} not found"
1135 )));
1136 }
1137 }
1138
1139 if let Some(s) = salience {
1142 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
1143 return Err(RuntimeError::InvalidInput(format!(
1144 "salience must be a finite value in [0.0, 1.0]; got {s}"
1145 )));
1146 }
1147 }
1148 if let Some(d) = decay_factor {
1149 if !d.is_finite() || d < 0.0 {
1150 return Err(RuntimeError::InvalidInput(format!(
1151 "decay_factor must be a finite value >= 0.0; got {d}"
1152 )));
1153 }
1154 }
1155
1156 if let Some(model_name) = embedding_model {
1160 self.resolve_embedding_model(Some(model_name))?;
1161 }
1162
1163 let mut note = Note::new(ns, kind, content);
1164 if let Some(s) = salience {
1165 note = note.with_salience(s);
1166 }
1167 if let Some(df) = decay_factor {
1168 note = note.with_decay(df);
1169 }
1170 if let Some(n) = name {
1171 note = note.with_name(n);
1172 }
1173 if let Some(p) = properties {
1174 note = note.with_properties(p);
1175 }
1176 let notes = if web_receipt {
1177 self.raw_notes(token)?
1178 } else {
1179 self.notes(token)?
1180 };
1181 notes.upsert_note(note.clone()).await?;
1182
1183 let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
1189 vec![m.to_string()]
1190 } else {
1191 self.embedding_models_for_note_kind(kind)
1192 };
1193
1194 {
1196 #[cfg(any(test, feature = "fault-injection"))]
1202 let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
1203 #[cfg(not(any(test, feature = "fault-injection")))]
1204 let fts_inject = false;
1205 let fts_result: RuntimeResult<()> = if fts_inject {
1206 Err(RuntimeError::Internal("injected FTS failure".to_string()))
1207 } else {
1208 let statements =
1209 khive_db::stores::text::delete_document_statements("fts_notes", ns, note.id)
1210 .into_iter()
1211 .chain(khive_db::stores::text::insert_document_statements(
1212 "fts_notes",
1213 ¬e_fts_document(¬e),
1214 ))
1215 .collect();
1216 self.apply_note_index_revision(¬e, statements)
1217 .await
1218 .map(|_| ())
1219 };
1220
1221 if let Err(e) = fts_result {
1222 self.compensate_note_creation(¬e).await;
1223 return Err(e);
1224 }
1225 }
1226
1227 let canonical_embed_text = note_embedding_text_ref(¬e);
1237 let embed_text = embedding_content.unwrap_or(canonical_embed_text);
1238
1239 let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
1240 let mut vector_fences = Vec::with_capacity(embed_model_names.len());
1241 if embed_model_names.len() == 1 {
1242 let model_name = &embed_model_names[0];
1244 let vec_result = self
1245 .embed_document_with_model_outcome_for_token(token, model_name, embed_text)
1246 .await;
1247
1248 #[cfg(any(test, feature = "fault-injection"))]
1258 let vec_inject = {
1259 let ns_inject = consume_fault(&VECTOR_FAIL_NS, ns);
1260 let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
1261 Some(0) => {
1262 cell.set(None);
1263 true
1264 }
1265 Some(n) => {
1266 cell.set(Some(n - 1));
1267 false
1268 }
1269 None => false,
1270 });
1271 ns_inject || count_inject
1272 };
1273 #[cfg(not(any(test, feature = "fault-injection")))]
1274 let vec_inject = false;
1275 let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
1276 if vec_inject {
1277 Err(RuntimeError::Internal(
1278 "injected vector failure".to_string(),
1279 ))
1280 } else {
1281 vec_result
1282 };
1283
1284 let single_model_result: RuntimeResult<()> = match vec_result {
1285 Ok(outcome) => {
1286 embedding_report.observe(&outcome);
1287 if capture_visibility {
1288 match self
1289 .publish_note_vector_revision_with_seq(
1290 token,
1291 ¬e,
1292 model_name,
1293 &outcome.vector,
1294 )
1295 .await
1296 {
1297 Ok(Some(seq)) => {
1298 vector_fences.push((model_name.clone(), seq));
1299 Ok(())
1300 }
1301 Ok(None) => Ok(()),
1302 Err(error) => Err(error),
1303 }
1304 } else {
1305 self.publish_note_vector_revision(token, ¬e, model_name, &outcome.vector)
1306 .await
1307 .map(|_| ())
1308 }
1309 }
1310 Err(e) => Err(e),
1311 };
1312 if let Err(e) = single_model_result {
1313 self.compensate_note_creation(¬e).await;
1314 return Err(e);
1315 }
1316 } else if !embed_model_names.is_empty() {
1317 let rt_clone = self.clone();
1320 let content_owned: std::sync::Arc<str> = std::sync::Arc::from(embed_text);
1323 let usage_ctx = crate::usage::current();
1324 let mut join_set = tokio::task::JoinSet::new();
1325 for (idx, model_name) in embed_model_names.iter().enumerate() {
1326 let rt = rt_clone.clone();
1327 let text = std::sync::Arc::clone(&content_owned);
1328 let name = model_name.clone();
1329 let ctx = usage_ctx.clone();
1330 let token = (*token).clone();
1331 join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
1332 let fut = rt.embed_document_with_model_outcome_for_token(
1333 &token,
1334 &name,
1335 text.as_ref(),
1336 );
1337 let result = match ctx {
1338 Some(ctx) => crate::usage::scope(ctx, fut).await,
1339 None => fut.await,
1340 };
1341 (idx, result)
1342 }));
1343 }
1344 let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
1348 Ok(outcomes) => outcomes,
1349 Err(e) => {
1350 self.compensate_note_creation(¬e).await;
1351 return Err(e);
1352 }
1353 };
1354 for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
1356 embedding_report.observe(&outcome);
1357 let insert_result = if capture_visibility {
1358 self.publish_note_vector_revision_with_seq(
1359 token,
1360 ¬e,
1361 model_name,
1362 &outcome.vector,
1363 )
1364 .await
1365 .map(|seq| {
1366 if let Some(seq) = seq {
1367 vector_fences.push((model_name.clone(), seq));
1368 }
1369 })
1370 } else {
1371 self.publish_note_vector_revision(token, ¬e, model_name, &outcome.vector)
1372 .await
1373 .map(|_| ())
1374 };
1375 if let Err(e) = insert_result {
1376 self.compensate_note_creation(¬e).await;
1377 return Err(e);
1378 }
1379 }
1380 }
1381
1382 let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1387
1388 #[cfg(test)]
1391 let annotates_iter: Vec<(usize, Uuid)> = annotates
1392 .iter()
1393 .enumerate()
1394 .map(|(i, &id)| (i, id))
1395 .collect();
1396 #[cfg(test)]
1397 macro_rules! next_target {
1398 ($pair:expr) => {
1399 $pair.1
1400 };
1401 }
1402 #[cfg(not(test))]
1403 let annotates_iter: Vec<Uuid> = annotates.to_vec();
1404 #[cfg(not(test))]
1405 macro_rules! next_target {
1406 ($pair:expr) => {
1407 $pair
1408 };
1409 }
1410
1411 for pair in annotates_iter {
1412 let target_id = next_target!(pair);
1413
1414 #[cfg(test)]
1416 let injected_err: Option<RuntimeError> = {
1417 let call_idx = pair.0;
1418 LINK_FAIL_AFTER.with(|cell| {
1419 let n = cell.get();
1420 if n > 0 && call_idx + 1 == n {
1421 cell.set(0); Some(RuntimeError::Internal("injected link failure".to_string()))
1423 } else {
1424 None
1425 }
1426 })
1427 };
1428 #[cfg(not(test))]
1429 let injected_err: Option<RuntimeError> = None;
1430
1431 let link_result = if let Some(e) = injected_err {
1432 Err(e)
1433 } else {
1434 self.link(
1435 token,
1436 note.id,
1437 target_id,
1438 EdgeRelation::Annotates,
1439 1.0,
1440 None,
1441 )
1442 .await
1443 };
1444
1445 match link_result {
1446 Ok(edge) => created_edges.push(edge.id.into()),
1447 Err(e) => {
1448 let edge_ids = created_edges
1453 .iter()
1454 .map(Uuid::to_string)
1455 .collect::<Vec<_>>()
1456 .join(", ");
1457 match self.compensate_note_creation_with_edges(¬e).await {
1458 Ok(true) => return Err(e),
1459 Ok(false) => {
1460 return Err(RuntimeError::Internal(format!(
1461 "create_note: annotates link failed: {e}; note {} changed before \
1462 compensation, retaining its incident edges [{edge_ids}]",
1463 note.id
1464 )));
1465 }
1466 Err(cleanup_error) => {
1467 return Err(RuntimeError::Internal(format!(
1468 "create_note: annotates link failed: {e}; compensation failed \
1469 for note {} and retained edges [{edge_ids}]: {cleanup_error}; \
1470 note and edges remain for reconciliation",
1471 note.id
1472 )));
1473 }
1474 }
1475 }
1476 }
1477 }
1478
1479 let created_event = khive_storage::event::Event::new(
1484 note.namespace.clone(),
1485 "create",
1486 EventKind::NoteCreated,
1487 SubstrateKind::Note,
1488 "",
1489 )
1490 .with_target(note.id)
1491 .with_payload(serde_json::json!({
1492 "id": note.id,
1493 "namespace": note.namespace,
1494 "kind": note.kind,
1495 "salience": note.salience,
1496 }));
1497 let event_result = match self.events(token) {
1498 Ok(store) => store
1499 .append_event(created_event)
1500 .await
1501 .map_err(RuntimeError::from),
1502 Err(error) => Err(error),
1503 };
1504 let mut degradations = Vec::new();
1505 if let Err(error) = event_result {
1506 record_post_commit_degradation(
1507 &mut degradations,
1508 "create_note",
1509 note.id,
1510 "event_append",
1511 error,
1512 );
1513 }
1514
1515 vector_fences.sort_by(|a, b| a.0.cmp(&b.0));
1516 Ok((note, embedding_report, degradations, vector_fences))
1517 }
1518
1519 #[allow(clippy::too_many_arguments)]
1539 pub async fn search_notes(
1540 &self,
1541 token: &NamespaceToken,
1542 query_text: &str,
1543 query_vector: Option<Vec<f32>>,
1544 limit: u32,
1545 note_kind: Option<&str>,
1546 include_superseded: bool,
1547 tags_any: &[String],
1548 properties_filter: Option<&serde_json::Value>,
1549 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1550 self.search_notes_with_text_mode(
1551 token,
1552 query_text,
1553 query_vector,
1554 limit,
1555 note_kind,
1556 include_superseded,
1557 tags_any,
1558 properties_filter,
1559 TextQueryMode::Plain,
1560 )
1561 .await
1562 }
1563
1564 #[allow(clippy::too_many_arguments)]
1566 pub async fn search_notes_with_text_mode(
1567 &self,
1568 token: &NamespaceToken,
1569 query_text: &str,
1570 query_vector: Option<Vec<f32>>,
1571 limit: u32,
1572 note_kind: Option<&str>,
1573 include_superseded: bool,
1574 tags_any: &[String],
1575 properties_filter: Option<&serde_json::Value>,
1576 text_mode: TextQueryMode,
1577 ) -> RuntimeResult<Vec<NoteSearchHit>> {
1578 let (hits, _vector_error) = self
1579 .search_notes_inner(
1580 token,
1581 query_text,
1582 query_vector,
1583 limit,
1584 note_kind,
1585 include_superseded,
1586 tags_any,
1587 properties_filter,
1588 text_mode,
1589 false,
1590 )
1591 .await?;
1592 Ok(hits)
1593 }
1594
1595 #[allow(clippy::too_many_arguments)]
1602 pub async fn search_notes_outcome(
1603 &self,
1604 token: &NamespaceToken,
1605 query_text: &str,
1606 limit: u32,
1607 note_kind: Option<&str>,
1608 include_superseded: bool,
1609 tags_any: &[String],
1610 properties_filter: Option<&serde_json::Value>,
1611 ) -> RuntimeResult<NoteSearchOutcome> {
1612 self.search_notes_outcome_with_text_mode(
1613 token,
1614 query_text,
1615 limit,
1616 note_kind,
1617 include_superseded,
1618 tags_any,
1619 properties_filter,
1620 TextQueryMode::Plain,
1621 )
1622 .await
1623 }
1624
1625 #[allow(clippy::too_many_arguments)]
1627 pub async fn search_notes_outcome_with_text_mode(
1628 &self,
1629 token: &NamespaceToken,
1630 query_text: &str,
1631 limit: u32,
1632 note_kind: Option<&str>,
1633 include_superseded: bool,
1634 tags_any: &[String],
1635 properties_filter: Option<&serde_json::Value>,
1636 text_mode: TextQueryMode,
1637 ) -> RuntimeResult<NoteSearchOutcome> {
1638 let (hits, vector_error) = self
1639 .search_notes_inner(
1640 token,
1641 query_text,
1642 None,
1643 limit,
1644 note_kind,
1645 include_superseded,
1646 tags_any,
1647 properties_filter,
1648 text_mode,
1649 true,
1650 )
1651 .await?;
1652 Ok(NoteSearchOutcome { hits, vector_error })
1653 }
1654
1655 pub async fn restore_note(
1661 &self,
1662 token: &NamespaceToken,
1663 id: Uuid,
1664 ) -> RuntimeResult<Option<(Note, bool)>> {
1665 let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? else {
1666 return Ok(None);
1667 };
1668 if note.namespace != token.namespace().as_str() {
1669 return Ok(None);
1670 }
1671 if note.deleted_at.is_none() {
1672 return Ok(Some((note, false)));
1673 }
1674 if let Some(key) = note.key.as_deref() {
1675 if let Some(holder) = self
1676 .notes(token)?
1677 .get_live_notes_by_key(¬e.namespace, key, Some(¬e.kind))
1678 .await?
1679 .into_iter()
1680 .find(|holder| holder.id != note.id)
1681 {
1682 return Err(restore_key_conflict(key, &holder));
1683 }
1684 }
1685 let updated_at =
1686 Utc::now()
1687 .timestamp_micros()
1688 .max(note.updated_at.checked_add(1).ok_or_else(|| {
1689 RuntimeError::Internal(format!(
1690 "note {id} updated_at is already at i64::MAX and cannot advance"
1691 ))
1692 })?);
1693 let mut params = vec![
1694 SqlValue::Text("active".into()),
1695 SqlValue::Integer(updated_at),
1696 SqlValue::Text(id.to_string()),
1697 SqlValue::Text(note.namespace.clone()),
1698 SqlValue::Text(note.kind.clone()),
1699 ];
1700 let key_clause = if let Some(key) = note.key.as_deref() {
1701 params.push(SqlValue::Text(key.to_owned()));
1702 format!(
1703 " AND (key IS NULL OR NOT EXISTS (SELECT 1 FROM notes live \
1704 WHERE live.namespace=?4 AND live.kind=?5 AND live.key=?{} \
1705 AND live.deleted_at IS NULL AND live.id != notes.id))",
1706 params.len()
1707 )
1708 } else {
1709 String::new()
1710 };
1711 let mut restored = note.clone();
1712 restored.status = "active".into();
1713 restored.deleted_at = None;
1714 restored.updated_at = updated_at;
1715 restored.version = restored
1716 .version
1717 .checked_add(1)
1718 .ok_or_else(|| RuntimeError::Internal(format!("note {id} version is exhausted")))?;
1719 let mut statements = vec![PlanStatement {
1720 statement: SqlStatement {
1721 sql: format!(
1722 "UPDATE notes SET status=?1, deleted_at=NULL, updated_at=?2 \
1723 WHERE id=?3 AND namespace=?4 AND kind=?5 AND deleted_at IS NOT NULL{key_clause}"
1724 ),
1725 params,
1726 label: Some("note-restore".into()),
1727 },
1728 guard: Some(AffectedRowGuard::exactly(1)),
1729 }];
1730 for statement in
1732 khive_db::stores::text::delete_document_statements("fts_notes", &restored.namespace, id)
1733 .into_iter()
1734 .chain(insert_document_statements(
1735 "fts_notes",
1736 ¬e_fts_document(&restored),
1737 ))
1738 {
1739 statements.push(PlanStatement {
1740 statement,
1741 guard: None,
1742 });
1743 }
1744 let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
1745 graph_effects: Vec::new(),
1746 target_id: id,
1747 statements,
1748 post_commit: PostCommitEffect::None,
1749 edge_natural_key: None,
1750 idempotent_noop: false,
1751 entity_guard: None,
1752 note_guard: None,
1753 note_vector_purge: None,
1754 note_embedding_inheritance: None,
1755 }));
1756 match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
1757 Ok(AtomicRunOutcome::Committed { .. }) => {
1758 #[cfg(any(test, feature = "fault-injection"))]
1759 if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
1760 return Err(restore_reindex_failed(
1761 "note",
1762 id,
1763 RuntimeError::Internal("injected FTS failure".to_string()),
1764 ));
1765 }
1766 let reindexed = self.reindex_note_with_report(token, &restored).await;
1767 let report = reindexed.map_err(|e| restore_reindex_failed("note", id, e))?;
1768 let degradations = report.post_commit_degradations();
1769 legacy_post_commit_result("restore_note", id, Some((restored, true)), degradations)
1770 }
1771 Ok(AtomicRunOutcome::RolledBack {
1772 failure: AtomicOpFailure::GuardFailed { .. },
1773 ..
1774 }) => {
1775 if let Some(key) = note.key.as_deref() {
1776 if let Some(holder) = self
1777 .notes(token)?
1778 .get_live_notes_by_key(¬e.namespace, key, Some(¬e.kind))
1779 .await?
1780 .into_iter()
1781 .find(|holder| holder.id != note.id)
1782 {
1783 return Err(restore_key_conflict(key, &holder));
1784 }
1785 }
1786 Err(RuntimeError::NotFound(format!(
1787 "note {id} is no longer a caller-owned tombstone"
1788 )))
1789 }
1790 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
1791 format!("note restore rolled back: {failure:?}"),
1792 )),
1793 Err(error) => Err(RuntimeError::Storage(error.0)),
1794 }
1795 }
1796}
1797
1798#[derive(Clone, Debug)]
1801pub struct LinkSpec {
1802 pub namespace: Option<String>,
1803 pub source_id: Uuid,
1804 pub target_id: Uuid,
1805 pub relation: EdgeRelation,
1806 pub weight: f64,
1807 pub metadata: Option<serde_json::Value>,
1808 pub resurrect: bool,
1809}
1810
1811#[derive(Clone, Debug)]
1819pub struct EntityCreateSpec {
1820 pub kind: String,
1821 pub entity_type: Option<String>,
1822 pub name: String,
1823 pub description: Option<String>,
1824 pub properties: Option<serde_json::Value>,
1825 pub tags: Vec<String>,
1826}
1827
1828#[cfg(test)]
1834#[path = "operations_tests.rs"]
1835mod tests;