Skip to main content

khive_runtime/
operations.rs

1// See docs/operations.md#module-layout.
2//! High-level operations composing storage capabilities into user-facing verbs.
3//!
4//! Fault-injection arming uses scoped guards only — see docs/operations.md#fault-injection-arm-migration.
5
6use 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
54/// The restore unit committed the row and its text index; only the
55/// post-commit embedding rebuild failed. Name that, so the caller does not
56/// read an ordinary restore failure over a record that is already live.
57fn 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/// The entity total and optional type report consumed together by `stats`.
76#[derive(Debug, PartialEq, Eq)]
77pub struct EntityStatsCounts {
78    pub entities: u64,
79    pub entities_by_type: Option<EntityTypeCounts>,
80}
81
82/// The live edge total and its two breakdowns consumed together by `stats`.
83#[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
90/// Count caller-visible live entities through one store. A supported breakdown
91/// supplies its own scalar total; only an unavailable report uses legacy counting.
92pub 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
133/// Inputs for a store-owned entity identity. Unlike ordinary creation, a
134/// competing insert must leave the existing row untouched.
135pub 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/// A non-retryable failure after a substrate mutation has committed.
181/// Callers can report the committed id/result and this diagnostic together.
182#[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
197// One list declares the stages, so the variants, `ALL` and the labels cannot drift apart.
198macro_rules! conditional_insert_stages {
199    ($($stage:ident => $label:literal),+ $(,)?) => {
200        /// The post-commit stages a conditional note insert reports in its committed
201        /// `post_commit_degraded` outcome. The comm ingest decoder accepts a stage only
202        /// when it is the label of a member of [`ConditionalInsertStage::ALL`], and the
203        /// insert can record a stage only through this type, so every label the
204        /// runtime emits is one the decoder accepts.
205        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
206        pub enum ConditionalInsertStage {
207            $($stage,)+
208        }
209
210        impl ConditionalInsertStage {
211            /// Every stage, generated from the same list as the variants.
212            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
262/// Legacy methods cannot return both their original result and the new report.
263/// Give direct callers a typed, non-retryable error with the committed record
264/// handle and every failed stage instead of silently dropping the report.
265pub(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/// A by-ID edge-endpoint substrate kind, including `Edge` itself.
393///
394/// Unlike [`Resolved`], this carries no record data — it is used where only
395/// the substrate classification is needed (coordinator locate/link parity
396/// with `get`, ADR-002 rule 1: `annotates` target may be entity, note, edge,
397/// or event).
398#[derive(Clone, Copy, Debug, Eq, PartialEq)]
399pub enum EdgeEndpointKind {
400    Entity,
401    Note,
402    Event,
403    Edge,
404}
405
406impl EdgeEndpointKind {
407    /// Wire name carried by a link lifecycle event for this endpoint.
408    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
418/// Map a resolved endpoint to its `(substrate, kind, entity_type)` triple, or
419/// `None` if the substrate is not a valid edge endpoint (events, edges).
420///
421/// `entity_type` carries the pack-owned granular subtype (`Entity::entity_type`,
422/// e.g. `"theorem"`); it is `None` for notes and for entities with no subtype.
423fn 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    // ---- Note operations ----
439
440    /// Create and persist a note, optionally with properties and annotation targets.
441    ///
442    /// After creating the note:
443    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
444    /// - If an embedding model is configured, indexes into the vector store with
445    ///   `SubstrateKind::Note`.
446    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
447    ///   the note to that target.
448    ///
449    /// A bounded embedding returns a non-retryable error carrying the committed
450    /// note ID and truncation report. The report-aware embedding-content variant
451    /// accepts `None` to retain the same default input selection.
452    // REASON: note creation requires kind, name, content, salience, properties, annotates,
453    // and namespace token — mirrors the MCP verb surface; a builder would not reduce
454    // caller complexity for pack handler callers.
455    #[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    /// Publish a network receipt with provenance that generic note writes
482    /// cannot supply. The web pack provides only the request record and the
483    /// annotation targets; this entry point fixes the note kind, tag, and
484    /// provenance before the first storage write.
485    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    /// Like [`Self::create_note`], but lets the caller supply a smaller text
516    /// to send to the vector embedder while the note's stored/FTS-indexed
517    /// `content` remains the full text.
518    ///
519    /// `embedding_content`, when `Some`, must be non-empty and a proper
520    /// prefix of `content` — anything else is rejected with `InvalidInput`
521    /// before any write. `None` behaves exactly like [`Self::create_note`].
522    /// Use this when `content` may exceed an embedder's input cap (e.g. a
523    /// very long commit message) and only a capped head prefix should be
524    /// embedded, while the full text is still stored and searchable via FTS.
525    #[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    /// The committed note and its non-retryable post-commit diagnostics.
599    #[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    /// Like [`Self::create_note`] but also sets a non-zero decay factor on the note.
635    // REASON: extends create_note with an additional decay_factor parameter; same
636    // rationale — mirrors the MCP surface and reduces an extra builder layer.
637    #[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    /// Create a note with decay and retain embedding truncation accounting.
664    #[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    /// Like [`Self::create_note_with_decay`] but targets a specific embedding model.
691    // REASON: adds an embedding_model parameter to the decay variant; the full parameter
692    // set is required for correct MCP verb routing and cannot be collapsed without
693    // introducing a separate config struct that would obscure call sites.
694    #[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    /// Create a note with decay for a named model and retain embedding truncation accounting.
733    #[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    /// Memory-pack receipt form of the decay create. Each returned sequence
771    /// was read inside the transaction that inserted that model's vector and
772    /// ANN upsert log row; a failed or version-rejected vector writes no fence.
773    #[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    /// Memory-pack create returning both its committed vector fences and
811    /// embedding-input truncation accounting. Other post-commit degradations
812    /// retain the same non-retryable error behavior as the receipt-only API.
813    #[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    /// Insert a note using `INSERT OR IGNORE` semantics for atomic deduplication.
855    ///
856    /// Returns `Ok(Some(note))` when the note was newly written.  Returns
857    /// `Ok(None)` when a unique constraint (e.g. the channel-scoped `external_id`
858    /// partial index on comm message notes) was already satisfied by an existing row,
859    /// making this call a no-op. Indexing failures after an insert return a
860    /// typed `post_commit_degraded` error with the committed record ID and
861    /// `retryable=false`. Healthy models still run; the note is not rolled back.
862    ///
863    /// This method is intentionally narrower than `create_note`: it skips
864    /// salience/decay, annotates edges, and embedding-model selection, which
865    /// are not needed for channel-ingest paths.
866    ///
867    /// Rejects the transport-owned properties on a `message` note: the
868    /// `message` entry of the kind-owned property list that the note-store
869    /// accessor guard enforces, so both guards refuse the same keys. These
870    /// properties are transport-owned evidence (`comm.health` trusts the
871    /// quarantine and channel ones at face value), and this fast path (unlike
872    /// the generic `create` verb funnel) is not covered by the
873    /// pack-installed note-write validator. Only the trusted channel-ingest
874    /// path may establish them — see
875    /// [`Self::try_create_note_as_trusted_ingest`].
876    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            // The note and its owner row must share the main database. A
905            // secondary pack backend cannot atomically root the reference.
906            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        // Bypasses the `notes()` accessor's PolicyEnforcingNoteStore wrapper —
959        // the reserved-transport-property check above already enforces the
960        // identical policy, conditionally allowing the trusted-ingest path,
961        // so this reaches storage directly rather than duplicate the check
962        // through a wrapper that cannot see the trust decision this function
963        // just made.
964        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(&note)).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(&note),
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    // REASON: private inner function unifies all create_note variants; it receives every
1064    // optional parameter individually so that public variants can pass None without
1065    // requiring callers to construct an intermediate struct.
1066    #[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        // Owned identity properties are derived from the authorization token
1089        // before anything else touches them, so every caller of this function —
1090        // the generic `create` verb and direct Rust callers alike — stores the
1091        // same derived values. Runs before the secret gate so the gate scans
1092        // exactly what will be written.
1093        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        // Secret gate: scan content, optional name, and structured properties.
1108        crate::secret_gate::check_note_fields_at(content, name, properties.as_ref(), "note")?;
1109        // `embedding_content` is a caller-supplied alternate vector-embedding
1110        // input: it must be a non-empty proper prefix of `content` (never a
1111        // superset, an unrelated string, or the full text) and passes the
1112        // same secret gate as any other stored/embedded text. Rejected
1113        // before any write, same as the checks above.
1114        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        // Validate all annotates targets before any write (atomicity: all-or-nothing).
1130        // Endpoint resolution is by-ID and namespace-agnostic.
1131        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        // Reject non-finite or out-of-range salience/decay at the runtime boundary
1140        // rather than letting storage silently clamp them (coding-standards §508-516).
1141        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        // Resolve embedding_model BEFORE any note/FTS/vector write so unknown-model
1157        // errors are atomic at the runtime layer, not just at one pack handler.
1158        // Direct Rust callers (other packs, integration tests) get the same guarantee.
1159        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        // From here on, any error must compensate by removing the note row, its
1184        // FTS document, and any vector entries already inserted — the same
1185        // cleanup used by the annotates-edge block below.
1186
1187        // Decide which embedding models to use (before touching FTS/vectors).
1188        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        // FTS step — compensate note row on failure.
1195        {
1196            // Injection: check FTS_FAIL_NS (armed by `arm_fts_fail_scoped(ns)`).
1197            // Fires only when `ns` is in the armed set, removing it on the way
1198            // out (one-shot, atomic check-and-remove under the mutex). No lock
1199            // acquisition in release builds — the cfg(not) branch is a const
1200            // false so the compiler eliminates the if-branch entirely.
1201            #[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                            &note_fts_document(&note),
1214                        ))
1215                        .collect();
1216                self.apply_note_index_revision(&note, statements)
1217                    .await
1218                    .map(|_| ())
1219            };
1220
1221            if let Err(e) = fts_result {
1222                self.compensate_note_creation(&note).await;
1223                return Err(e);
1224            }
1225        }
1226
1227        // Vector embedding + insert step — compensate note row + FTS doc on failure.
1228        // Multi-model vector embedding:
1229        //   - explicit embedding_model → single model (existing behaviour)
1230        //   - None → the note kind's declared model policy
1231        //   - None + no models configured → skip (text-only)
1232        // The effective text sent to every embedder: the caller-supplied
1233        // capped override when present, otherwise the full stored content.
1234        // FTS indexing above always used the full `note.content` — this cap
1235        // affects only the vector-embedding input.
1236        let canonical_embed_text = note_embedding_text_ref(&note);
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            // Single-model path: preserves original sequential behaviour.
1243            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            // Injection: check VECTOR_FAIL_NS (armed by `arm_vector_fail_scoped(ns)`) or
1249            // VECTOR_FAIL_AFTER (armed by `arm_vector_fail_after(n)`). The former
1250            // fires only when the armed namespace matches this note's namespace;
1251            // callers that cannot guarantee no concurrently-running test also
1252            // writes a note into that same namespace (e.g. a test suite whose
1253            // fixtures share one default namespace) should prefer the latter,
1254            // thread-local count instead — see its doc comment. Either clears
1255            // (one-shot) once it fires. No lock/cell access in release builds —
1256            // the cfg(not) branch is a const false eliminating the if-branch.
1257            #[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                                &note,
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, &note, 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(&note).await;
1314                return Err(e);
1315            }
1316        } else if !embed_model_names.is_empty() {
1317            // Multi-model path: embed with each model in parallel via spawned tasks,
1318            // then insert one VectorRecord per model.
1319            let rt_clone = self.clone();
1320            // JoinSet tasks require owned text; an Arc keeps this to one
1321            // content-sized allocation rather than one clone per model.
1322            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            // The first failed or panicked handle aborts and detaches its
1345            // siblings. Embed usage is counted at dispatch, so a synchronous
1346            // provider winding down in the background cannot change it.
1347            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(&note).await;
1351                    return Err(e);
1352                }
1353            };
1354            // TODO(P2): parallelize vector inserts
1355            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                        &note,
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, &note, model_name, &outcome.vector)
1372                        .await
1373                        .map(|_| ())
1374                };
1375                if let Err(e) = insert_result {
1376                    self.compensate_note_creation(&note).await;
1377                    return Err(e);
1378                }
1379            }
1380        }
1381
1382        // Create annotates edges, compensating on failure to preserve atomicity.
1383        // Pre-validation (above) ensures all targets exist, so link failures are
1384        // unexpected. If one occurs, purge incident edges and the note in one
1385        // transaction; a failed purge retains the live note as their source.
1386        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1387
1388        // In test builds, iterate with an index so the failure-injection hook can
1389        // target a specific call.  In release builds, skip the enumerate overhead.
1390        #[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            // Test-only: inject a failure on the configured call index (1-based).
1415            #[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); // reset so subsequent calls are unaffected
1422                        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                    // The graph purge, index cleanup, and note removal share
1449                    // one writer transaction. If the edge purge fails, its
1450                    // transaction rolls back, preserving a live source note
1451                    // for the surviving edges instead of orphaning them.
1452                    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(&note).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        // Same contract as the entity arrival event above: after compensation,
1480        // so a rolled-back create leaves no event. This is the single funnel for
1481        // every note create in the product, which is why the memory pack's own
1482        // note_created emitter was removed rather than left beside it.
1483        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    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
1520    ///
1521    /// Pipeline:
1522    /// 1. FTS5 query against `notes_<namespace>`.
1523    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
1524    /// 3. RRF fusion (k=60).
1525    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
1526    /// 5. Filter soft-deleted notes, apply optional kind / tag / properties predicates.
1527    ///    Tags and properties are pushed into the per-note fetch loop BEFORE truncation
1528    ///    so that matching notes ranked beyond `limit` in the raw fusion are not silently
1529    ///    dropped.
1530    /// 6. Truncate to `limit`.
1531    ///
1532    /// `tags_any`: when non-empty, only notes that have at least one of these tags
1533    /// (stored in `properties["tags"]`, case-insensitive match) are retained. The
1534    /// check happens inside the alive-note loop, before `hits.truncate(limit)`.
1535    ///
1536    /// `properties_filter`: when `Some`, only notes whose `properties` JSON object is
1537    /// a superset of the given filter object are retained. Also applied before truncation.
1538    #[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    /// Note search with an explicit lexical mode for the text arm.
1565    #[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    /// Coordinator fan-out variant of [`Self::search_notes`]: the text arm
1596    /// still fails loud, but a vector-arm failure after a successful text leg
1597    /// is captured instead of discarding the text hits — mirrors
1598    /// [`Self::hybrid_search_outcome`]'s contract for the entity substrate.
1599    /// Reserved for `SubstrateCoordinator::fan_out_search_with_visibility`;
1600    /// every other caller keeps the fail-loud [`Self::search_notes`] contract.
1601    #[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    /// Coordinator note-search variant with an explicit lexical mode.
1626    #[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    /// Restore a note tombstone owned by the caller's primary namespace.
1656    ///
1657    /// A live note holding the tombstone's `(namespace, kind, key)` refuses
1658    /// the operation before any row changes. The same condition is repeated
1659    /// in the guarded restore statement for the concurrent race.
1660    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(&note.namespace, key, Some(&note.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        // Text index published in the same unit as the row; see restore_entity.
1731        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                    &note_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(&note.namespace, key, Some(&note.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/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
1799/// and [`KhiveRuntime::link_many`].
1800#[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/// Fully specified entity creation request — input to [`KhiveRuntime::create_many`].
1812///
1813/// `entity_type` is validated at the runtime layer by the pack-installed
1814/// entity-type validator. When a validator
1815/// is installed (e.g. by `KgPack`), unknown types are rejected with the valid
1816/// set listed. When no validator is installed (bare runtime without packs),
1817/// the value passes through — the handler layer is the primary enforcement point.
1818#[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// INLINE TEST JUSTIFICATION: tests here exercise private helpers (canonical_edge_endpoints,
1829// validate_edge_metadata, merge_dependency_kind, link-fail injection) and runtime methods
1830// that require pub(crate) KhiveRuntime construction. Moving them to tests/ would require
1831// pub-exporting those private helpers, which would widen the crate's public API surface
1832// undesirably. Broad behavioral tests live in tests/integration.rs.
1833#[cfg(test)]
1834#[path = "operations_tests.rs"]
1835mod tests;