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
43#[cfg(test)]
44mod batch_edge_tests;
45
46struct EdgeReadWindow {
47    outcomes: Vec<Option<RuntimeResult<Option<Edge>>>>,
48    groups: Vec<(khive_types::Namespace, Vec<usize>)>,
49}
50
51/// The restore unit committed the row and its text index; only the
52/// post-commit embedding rebuild failed. Name that, so the caller does not
53/// read an ordinary restore failure over a record that is already live.
54fn restore_reindex_failed(kind: &str, id: Uuid, error: RuntimeError) -> RuntimeError {
55    RuntimeError::Internal(format!(
56        "{kind} {id} is restored and text-indexed, but its embedding rebuild failed \
57         and will be retried by the next reindex: {error}"
58    ))
59}
60
61fn merge_tombstone_restore_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
62    KhiveError::conflict(format!(
63        "merge_tombstone: {id} was merged into {kept_id}; a merge tombstone is not restorable, query the kept id"
64    ))
65    .with_details(khive_types::Details::new_owned([
66        ("reason", "merge_tombstone".into()),
67        ("merged_into", kept_id.to_string()),
68    ]))
69    .into()
70}
71
72/// The entity total and optional type report consumed together by `stats`.
73#[derive(Debug, PartialEq, Eq)]
74pub struct EntityStatsCounts {
75    pub entities: u64,
76    pub entities_by_type: Option<EntityTypeCounts>,
77}
78
79/// The live edge total and its two breakdowns consumed together by `stats`.
80#[derive(Debug, Default, PartialEq, Eq)]
81pub struct EdgeStatsCounts {
82    pub edges: u64,
83    pub edges_by_relation: HashMap<String, u64>,
84    pub edges_by_endpoint_base: khive_storage::types::EdgeEndpointBaseCounts,
85}
86
87/// Count caller-visible live entities through one store. A supported breakdown
88/// supplies its own scalar total; only an unavailable report uses legacy counting.
89pub async fn entity_stats_counts(
90    store: &dyn khive_storage::EntityStore,
91    token: &NamespaceToken,
92) -> RuntimeResult<EntityStatsCounts> {
93    let namespaces: Vec<String> = token
94        .visible_namespaces()
95        .iter()
96        .map(|namespace| namespace.as_str().to_owned())
97        .collect();
98    match store.count_entities_by_type(&namespaces).await? {
99        Some(groups) => {
100            let entities = groups.iter().try_fold(0_u64, |total, (_, count)| {
101                total.checked_add(*count).ok_or_else(|| {
102                    RuntimeError::Internal(
103                        "entity type counts exceed the scalar count range".into(),
104                    )
105                })
106            })?;
107            Ok(EntityStatsCounts {
108                entities,
109                entities_by_type: Some(groups),
110            })
111        }
112        None => {
113            let entities = store
114                .count_entities(
115                    token.namespace().as_str(),
116                    EntityFilter {
117                        namespaces,
118                        ..EntityFilter::default()
119                    },
120                )
121                .await?;
122            Ok(EntityStatsCounts {
123                entities,
124                entities_by_type: None,
125            })
126        }
127    }
128}
129
130/// Inputs for a store-owned entity identity. Unlike ordinary creation, a
131/// competing insert must leave the existing row untouched.
132pub struct EntityClaimSpec {
133    pub id: Uuid,
134    pub kind: String,
135    pub entity_type: Option<String>,
136    pub name: String,
137    pub description: Option<String>,
138    pub properties: Option<serde_json::Value>,
139    pub tags: Vec<String>,
140    pub identity_tag: String,
141}
142
143fn live_merged_entity_refused(id: Uuid, kept_id: impl std::fmt::Display) -> RuntimeError {
144    KhiveError::conflict(format!(
145        "live_merged_entity: {id} is live but still carries merged_into {kept_id}; a row an \
146         earlier restore left live over its merge is not restorable, re-tombstone it or query the \
147         kept id"
148    ))
149    .with_details(khive_types::Details::new_owned([
150        ("reason", "live_merged_entity".into()),
151        ("merged_into", kept_id.to_string()),
152    ]))
153    .into()
154}
155
156fn restore_key_conflict(key: &str, holder: &Note) -> RuntimeError {
157    KhiveError::conflict(format!(
158        "restore_key_conflict: key {key:?} is already held by live note {}",
159        holder.id
160    ))
161    .with_details(khive_types::Details::new_owned([
162        ("reason", "restore_key_conflict".into()),
163        ("key", key.to_owned()),
164        ("existing_id", holder.id.to_string()),
165    ]))
166    .into()
167}
168
169use crate::atomic_plan::{
170    AddEntityPlan, AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan,
171};
172use crate::atomic_runner::{run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome};
173use crate::curation::{entity_fts_document, note_embedding_text_ref, note_fts_document};
174use crate::error::{GuardedWriteFailure, RuntimeError, RuntimeResult};
175use crate::runtime::{KhiveRuntime, NamespaceToken};
176
177/// A non-retryable failure after a substrate mutation has committed.
178/// Callers can report the committed id/result and this diagnostic together.
179#[derive(Clone, Debug, Serialize)]
180pub struct PostCommitDegradation {
181    pub stage: &'static str,
182    pub error: String,
183}
184
185impl PostCommitDegradation {
186    fn new(stage: &'static str, error: impl ToString) -> Self {
187        Self {
188            stage,
189            error: error.to_string(),
190        }
191    }
192}
193
194// One list declares the stages, so the variants, `ALL` and the labels cannot drift apart.
195macro_rules! conditional_insert_stages {
196    ($($stage:ident => $label:literal),+ $(,)?) => {
197        /// The post-commit stages a conditional note insert reports in its committed
198        /// `post_commit_degraded` outcome. The comm ingest decoder accepts a stage only
199        /// when it is the label of a member of [`ConditionalInsertStage::ALL`], and the
200        /// insert can record a stage only through this type, so every label the
201        /// runtime emits is one the decoder accepts.
202        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
203        pub enum ConditionalInsertStage {
204            $($stage,)+
205        }
206
207        impl ConditionalInsertStage {
208            /// Every stage, generated from the same list as the variants.
209            pub const ALL: &'static [Self] = &[$(Self::$stage,)+];
210
211            pub const fn label(self) -> &'static str {
212                match self {
213                    $(Self::$stage => $label,)+
214                }
215            }
216        }
217    };
218}
219
220conditional_insert_stages! {
221    FtsAcquisition => "fts_acquisition",
222    FtsUpsert => "fts_upsert",
223    Embedding => "embedding",
224    VectorAcquisition => "vector_acquisition",
225    VectorInsert => "vector_insert",
226}
227
228impl ConditionalInsertStage {
229    pub fn from_label(label: &str) -> Option<Self> {
230        Self::ALL
231            .iter()
232            .copied()
233            .find(|stage| stage.label() == label)
234    }
235}
236
237fn record_conditional_insert_degradation(
238    degradations: &mut Vec<PostCommitDegradation>,
239    id: Uuid,
240    stage: ConditionalInsertStage,
241    error: impl ToString,
242) {
243    record_post_commit_degradation(degradations, "try_create_note", id, stage.label(), error);
244}
245
246fn record_post_commit_degradation(
247    degradations: &mut Vec<PostCommitDegradation>,
248    operation: &'static str,
249    id: Uuid,
250    stage: &'static str,
251    error: impl ToString,
252) {
253    let degradation = PostCommitDegradation::new(stage, error);
254    tracing::warn!(%operation, %id, stage, error = %degradation.error,
255        "substrate mutation committed with post-commit degradation");
256    degradations.push(degradation);
257}
258
259/// Legacy methods cannot return both their original result and the new report.
260/// Give direct callers a typed, non-retryable error with the committed record
261/// handle and every failed stage instead of silently dropping the report.
262pub(crate) fn legacy_post_commit_result<T>(
263    operation: &'static str,
264    id: Uuid,
265    value: T,
266    degradations: Vec<PostCommitDegradation>,
267) -> RuntimeResult<T> {
268    if degradations.is_empty() {
269        return Ok(value);
270    }
271    let failures = serde_json::Value::Array(
272        degradations
273            .iter()
274            .map(|failure| {
275                serde_json::json!({"stage": failure.stage, "error": failure.error.as_str()})
276            })
277            .collect(),
278    );
279    Err(KhiveError::internal(format!(
280        "{operation} committed record {id}, but post-commit work failed; do not retry the mutation; reconcile by record_id"
281    ))
282    .with_details(khive_types::Details::new_owned([
283        ("reason", "post_commit_degraded".to_string()),
284        ("operation", operation.to_string()),
285        ("record_id", id.to_string()),
286        ("committed", "true".to_string()),
287        ("retryable", "false".to_string()),
288        ("post_commit_degradations", failures.to_string()),
289    ]))
290    .into())
291}
292
293pub(crate) fn legacy_post_commit_result_with_embedding<T>(
294    operation: &'static str,
295    id: Uuid,
296    value: T,
297    embedding: crate::retrieval::EmbeddingTruncationReport,
298    degradations: Vec<PostCommitDegradation>,
299) -> RuntimeResult<T> {
300    if !embedding.any_truncated() {
301        return legacy_post_commit_result(operation, id, value, degradations);
302    }
303    let failures = serde_json::Value::Array(
304        degradations
305            .iter()
306            .map(|failure| {
307                serde_json::json!({"stage": failure.stage, "error": failure.error.as_str()})
308            })
309            .collect(),
310    );
311    Err(KhiveError::internal(format!(
312        "{operation} committed record {id}, but embedding input was truncated; do not retry the mutation; reconcile by record_id"
313    ))
314    .with_details(khive_types::Details::new_owned([
315        ("reason", "embedding_input_truncated".to_string()),
316        ("operation", operation.to_string()),
317        ("record_id", id.to_string()),
318        ("committed", "true".to_string()),
319        ("retryable", "false".to_string()),
320        (
321            "embedding_truncation_report",
322            serde_json::json!(embedding).to_string(),
323        ),
324        ("post_commit_degradations", failures.to_string()),
325    ]))
326    .into())
327}
328
329mod entity_bulk;
330mod entity_create;
331mod entity_indexes;
332#[cfg(any(test, feature = "fault-injection"))]
333mod fault_injection;
334mod resolve_prefix_in;
335mod resolve_uuid_or_prefix;
336
337mod delete_restore;
338mod edge_query;
339mod graph_rules;
340mod identity_reads;
341mod link_ops;
342mod read_helpers;
343mod record_ops;
344mod traversal;
345
346use read_helpers::{
347    direction_sort_rank, drain_embed_join_set, normalize_symmetric_direction, note_snippet,
348    note_title, resolve_prefix_statement, salience_weighted_rank,
349};
350pub use read_helpers::{
351    hex_prefix_to_uuid_pattern, uuid_prefix_bounds, NoteSearchHit, NoteSearchOutcome, Resolved,
352};
353pub use record_ops::NoteCreateSpec;
354
355pub use edge_query::QueryResult;
356use graph_rules::{
357    accepted_entity_relations_description, pack_rule_allows,
358    static_impossible_edge_pattern_warnings,
359};
360pub use graph_rules::{
361    accepted_entity_relations_for_entities, accepted_pack_relations_for_entities,
362    merge_entry_metadata,
363};
364pub(crate) use graph_rules::{
365    canonical_edge_endpoint_kinds, canonical_edge_endpoints, merge_dependency_kind,
366    validate_edge_metadata, validate_edge_weight,
367};
368#[cfg(test)]
369use link_ops::guarded_link_batch_failure;
370#[cfg(test)]
371use traversal::merge_traversal_paths_by_root;
372
373#[cfg(any(test, feature = "fault-injection"))]
374pub use fault_injection::{
375    arm_entity_compensation_fail_scoped, arm_fts_fail_many_partial_scoped,
376    arm_fts_fail_many_scoped, arm_fts_fail_scoped, arm_fts_search_fail,
377    arm_prefix_resolve_fail_scoped, arm_rollback_cleanup_fail, arm_vector_fail_after,
378    arm_vector_fail_scoped, FaultInjectionArm,
379};
380#[cfg(test)]
381use fault_injection::{arm_fault, FaultArmSet, LINK_FAIL_AFTER};
382#[cfg(any(test, feature = "fault-injection"))]
383use fault_injection::{
384    consume_fault, ENTITY_COMPENSATION_FAIL_NS, FTS_FAIL_MANY_NS, FTS_FAIL_MANY_PARTIAL_NS,
385    FTS_FAIL_NS, FTS_SEARCH_FAIL_NS, PREFIX_RESOLVE_FAIL_NS, ROLLBACK_CLEANUP_FAIL_NS,
386    VECTOR_FAIL_AFTER, VECTOR_FAIL_NS,
387};
388#[cfg(any(test, feature = "fault-injection"))]
389pub(crate) use fault_injection::{consume_fts_fail_fault, consume_vector_fail_fault};
390pub use resolve_prefix_in::PrefixScope;
391
392/// 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    /// Like [`Self::try_create_note`] but permits the caller to establish the
889    /// transport-owned `message` properties that `try_create_note` refuses.
890    ///
891    /// This is a deliberately named, separate entry point rather than a flag
892    /// on `try_create_note` so the trust decision is visible at every call
893    /// site: `comm.ingest` (`khive-pack-comm/src/handlers/ingest.rs`) is the sole
894    /// legitimate caller, because it is the only code that has just derived
895    /// quarantine disposition and channel provenance from the inbound
896    /// transport itself. The caller set is bounded by possession, not
897    /// documentation: the required [`crate::ChannelIngestCapability`] is
898    /// constructible only inside this crate and granted at pack registration
899    /// exclusively to channel-transport packs. Every other write path uses
900    /// `try_create_note`, which rejects those properties
901    /// unconditionally.
902    #[allow(clippy::too_many_arguments)]
903    pub async fn try_create_note_as_trusted_ingest(
904        &self,
905        _capability: &crate::pack::ChannelIngestCapability,
906        token: &NamespaceToken,
907        kind: &str,
908        name: Option<&str>,
909        content: &str,
910        properties: Option<serde_json::Value>,
911        expires_after: Option<std::time::Duration>,
912    ) -> RuntimeResult<Option<Note>> {
913        self.try_create_note_impl(
914            token,
915            kind,
916            name,
917            content,
918            properties,
919            true,
920            None,
921            expires_after,
922        )
923        .await
924    }
925
926    /// Publish a trusted inbound message and its original-byte attachment in
927    /// one database transaction. Channel quarantine must not advertise a
928    /// reference in note metadata before GC can see its attachment owner.
929    #[allow(clippy::too_many_arguments)]
930    pub async fn try_create_note_as_trusted_ingest_with_attachment(
931        &self,
932        _capability: &crate::pack::ChannelIngestCapability,
933        token: &NamespaceToken,
934        kind: &str,
935        name: Option<&str>,
936        content: &str,
937        properties: Option<serde_json::Value>,
938        attachment: NewAttachment,
939        expires_after: Option<std::time::Duration>,
940    ) -> RuntimeResult<Option<Note>> {
941        self.try_create_note_impl(
942            token,
943            kind,
944            name,
945            content,
946            properties,
947            true,
948            Some(attachment),
949            expires_after,
950        )
951        .await
952    }
953
954    #[allow(clippy::too_many_arguments)]
955    async fn try_create_note_impl(
956        &self,
957        token: &NamespaceToken,
958        kind: &str,
959        name: Option<&str>,
960        content: &str,
961        properties: Option<serde_json::Value>,
962        allow_transport_owned_message_properties: bool,
963        attachment: Option<NewAttachment>,
964        expires_after: Option<std::time::Duration>,
965    ) -> RuntimeResult<Option<Note>> {
966        self.validate_note_kind(kind)?;
967        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
968        crate::secret_gate::check_note_fields_at(content, name, properties.as_ref(), "note")?;
969        if let Some(ref attachment) = attachment {
970            // The note and its owner row must share the main database. A
971            // secondary pack backend cannot atomically root the reference.
972            drop(self.attachments()?);
973            attachment.validate()?;
974            let blob_store = self.blob_store().ok_or_else(|| {
975                RuntimeError::Unconfigured(
976                    "trusted ingest attachment requires an installed BlobStore".to_string(),
977                )
978            })?;
979            if !blob_store.exists(&attachment.content_ref).await? {
980                return Err(RuntimeError::InvalidInput(format!(
981                    "trusted ingest attachment refers to an unpublished blob: {}",
982                    attachment.content_ref
983                )));
984            }
985        }
986        if !allow_transport_owned_message_properties && kind == "message" {
987            if let Some(key) = properties
988                .as_ref()
989                .and_then(serde_json::Value::as_object)
990                .and_then(|supplied| {
991                    crate::curation::kind_owned_properties("message")
992                        .iter()
993                        .copied()
994                        .find(|key| supplied.contains_key(*key))
995                })
996            {
997                return Err(RuntimeError::InvalidInput(format!(
998                    "`{key}` is transport-owned on a `message` note and cannot be supplied \
999                     through `try_create_note`; only the trusted channel-ingest path may \
1000                     establish quarantine disposition and channel provenance"
1001                )));
1002            }
1003        }
1004
1005        let ns = token.namespace().as_str();
1006        let mut note = Note::new(ns, kind, content);
1007        if let Some(retention) = expires_after {
1008            let duration_us = i64::try_from(retention.as_micros()).map_err(|_| {
1009                RuntimeError::InvalidInput(
1010                    "trusted ingest retention exceeds i64 microseconds".into(),
1011                )
1012            })?;
1013            note.expires_at = Some(note.created_at.checked_add(duration_us).ok_or_else(|| {
1014                RuntimeError::InvalidInput("trusted ingest expiry exceeds i64 microseconds".into())
1015            })?);
1016        }
1017        if let Some(n) = name {
1018            note = note.with_name(n);
1019        }
1020        if let Some(p) = properties {
1021            note = note.with_properties(p);
1022        }
1023
1024        // Bypasses the `notes()` accessor's PolicyEnforcingNoteStore wrapper —
1025        // the reserved-transport-property check above already enforces the
1026        // identical policy, conditionally allowing the trusted-ingest path,
1027        // so this reaches storage directly rather than duplicate the check
1028        // through a wrapper that cannot see the trust decision this function
1029        // just made.
1030        let inserted = if let Some(attachment) = attachment {
1031            self.raw_notes(token)?
1032                .try_insert_note_with_attachments(
1033                    note.clone(),
1034                    vec![Attachment::from_new(
1035                        note.id,
1036                        AttachmentSubstrate::Note,
1037                        attachment,
1038                        note.created_at,
1039                    )],
1040                )
1041                .await?
1042        } else {
1043            self.raw_notes(token)?.try_insert_note(note.clone()).await?
1044        };
1045        if !inserted {
1046            return Ok(None);
1047        }
1048
1049        let mut degradations = Vec::new();
1050        match self.text_for_notes(token) {
1051            Ok(fts) => {
1052                if let Err(error) = fts.upsert_document(note_fts_document(&note)).await {
1053                    record_conditional_insert_degradation(
1054                        &mut degradations,
1055                        note.id,
1056                        ConditionalInsertStage::FtsUpsert,
1057                        error,
1058                    );
1059                }
1060            }
1061            Err(error) => record_conditional_insert_degradation(
1062                &mut degradations,
1063                note.id,
1064                ConditionalInsertStage::FtsAcquisition,
1065                error,
1066            ),
1067        }
1068
1069        let embed_model_names = self.embedding_models_for_note_kind(kind);
1070        for model_name in &embed_model_names {
1071            match self
1072                .embed_document_with_model_outcome_for_token(
1073                    token,
1074                    model_name,
1075                    note_embedding_text_ref(&note),
1076                )
1077                .await
1078            {
1079                Ok(outcome) => {
1080                    if outcome.truncated {
1081                        tracing::warn!(
1082                            note_id = %note.id,
1083                            model = %outcome.model_name,
1084                            source_bytes = outcome.source_bytes,
1085                            embedded_bytes = outcome.embedded_bytes,
1086                            "try_create_note: embedding input truncated; full content stored unchanged"
1087                        );
1088                    }
1089                    match self.vectors_for_model(token, model_name) {
1090                        Ok(vs) => {
1091                            if let Err(error) = vs
1092                                .insert(
1093                                    note.id,
1094                                    SubstrateKind::Note,
1095                                    ns,
1096                                    "note.content",
1097                                    vec![outcome.vector],
1098                                )
1099                                .await
1100                            {
1101                                record_conditional_insert_degradation(
1102                                    &mut degradations,
1103                                    note.id,
1104                                    ConditionalInsertStage::VectorInsert,
1105                                    format!("model {model_name}: {error}"),
1106                                );
1107                            }
1108                        }
1109                        Err(error) => record_conditional_insert_degradation(
1110                            &mut degradations,
1111                            note.id,
1112                            ConditionalInsertStage::VectorAcquisition,
1113                            format!("model {model_name}: {error}"),
1114                        ),
1115                    }
1116                }
1117                Err(error) => record_conditional_insert_degradation(
1118                    &mut degradations,
1119                    note.id,
1120                    ConditionalInsertStage::Embedding,
1121                    format!("model {model_name}: {error}"),
1122                ),
1123            }
1124        }
1125
1126        legacy_post_commit_result("try_create_note", note.id, Some(note), degradations)
1127    }
1128
1129    // REASON: private inner function unifies all create_note variants; it receives every
1130    // optional parameter individually so that public variants can pass None without
1131    // requiring callers to construct an intermediate struct.
1132    #[allow(clippy::too_many_arguments)]
1133    async fn create_note_inner(
1134        &self,
1135        token: &NamespaceToken,
1136        kind: &str,
1137        name: Option<&str>,
1138        content: &str,
1139        embedding_content: Option<&str>,
1140        salience: Option<f64>,
1141        decay_factor: Option<f64>,
1142        properties: Option<serde_json::Value>,
1143        annotates: Vec<Uuid>,
1144        embedding_model: Option<&str>,
1145        capture_visibility: bool,
1146        web_receipt: bool,
1147    ) -> RuntimeResult<(
1148        Note,
1149        crate::retrieval::EmbeddingTruncationReport,
1150        Vec<PostCommitDegradation>,
1151        Vec<(String, u64)>,
1152    )> {
1153        self.validate_note_kind(kind)?;
1154        // Owned identity properties are derived from the authorization token
1155        // before anything else touches them, so every caller of this function —
1156        // the generic `create` verb and direct Rust callers alike — stores the
1157        // same derived values. Runs before the secret gate so the gate scans
1158        // exactly what will be written.
1159        let mut properties = self.derive_note_write_properties(kind, token, properties)?;
1160        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1161        if web_receipt {
1162            let map = properties
1163                .as_mut()
1164                .and_then(serde_json::Value::as_object_mut)
1165                .expect("web receipt properties are constructed as an object");
1166            map.insert(
1167                crate::secret_gate::RESERVED_WEB_RECEIPT_KEY.to_string(),
1168                serde_json::Value::String(
1169                    crate::secret_gate::WEB_RECEIPT_PROVENANCE_VALUE.to_string(),
1170                ),
1171            );
1172        }
1173        // Secret gate: scan content, optional name, and structured properties.
1174        crate::secret_gate::check_note_fields_at(content, name, properties.as_ref(), "note")?;
1175        // `embedding_content` is a caller-supplied alternate vector-embedding
1176        // input: it must be a non-empty proper prefix of `content` (never a
1177        // superset, an unrelated string, or the full text) and passes the
1178        // same secret gate as any other stored/embedded text. Rejected
1179        // before any write, same as the checks above.
1180        if let Some(ec) = embedding_content {
1181            if ec.is_empty() {
1182                return Err(RuntimeError::InvalidInput(
1183                    "embedding_content must not be empty".into(),
1184                ));
1185            }
1186            if ec.len() >= content.len() || !content.starts_with(ec) {
1187                return Err(RuntimeError::InvalidInput(
1188                    "embedding_content must be a proper prefix of content".into(),
1189                ));
1190            }
1191            crate::secret_gate::check_at(ec, "note", "embedding_content")?;
1192        }
1193        let ns = token.namespace().as_str();
1194
1195        // Validate all annotates targets before any write (atomicity: all-or-nothing).
1196        // Endpoint resolution is by-ID and namespace-agnostic.
1197        for &target_id in &annotates {
1198            if !self.substrate_exists_by_id(token, target_id).await? {
1199                return Err(RuntimeError::NotFound(format!(
1200                    "create_note annotates target {target_id} not found"
1201                )));
1202            }
1203        }
1204
1205        // Reject non-finite or out-of-range salience/decay at the runtime boundary
1206        // rather than letting storage silently clamp them (coding-standards §508-516).
1207        if let Some(s) = salience {
1208            if !s.is_finite() || !(0.0..=1.0).contains(&s) {
1209                return Err(RuntimeError::InvalidInput(format!(
1210                    "salience must be a finite value in [0.0, 1.0]; got {s}"
1211                )));
1212            }
1213        }
1214        if let Some(d) = decay_factor {
1215            if !d.is_finite() || d < 0.0 {
1216                return Err(RuntimeError::InvalidInput(format!(
1217                    "decay_factor must be a finite value >= 0.0; got {d}"
1218                )));
1219            }
1220        }
1221
1222        // Resolve embedding_model BEFORE any note/FTS/vector write so unknown-model
1223        // errors are atomic at the runtime layer, not just at one pack handler.
1224        // Direct Rust callers (other packs, integration tests) get the same guarantee.
1225        if let Some(model_name) = embedding_model {
1226            self.resolve_embedding_model(Some(model_name))?;
1227        }
1228
1229        let mut note = Note::new(ns, kind, content);
1230        if let Some(s) = salience {
1231            note = note.with_salience(s);
1232        }
1233        if let Some(df) = decay_factor {
1234            note = note.with_decay(df);
1235        }
1236        if let Some(n) = name {
1237            note = note.with_name(n);
1238        }
1239        if let Some(p) = properties {
1240            note = note.with_properties(p);
1241        }
1242        let notes = if web_receipt {
1243            self.raw_notes(token)?
1244        } else {
1245            self.notes(token)?
1246        };
1247        notes.upsert_note(note.clone()).await?;
1248
1249        // From here on, any error must compensate by removing the note row, its
1250        // FTS document, and any vector entries already inserted — the same
1251        // cleanup used by the annotates-edge block below.
1252
1253        // Decide which embedding models to use (before touching FTS/vectors).
1254        let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
1255            vec![m.to_string()]
1256        } else {
1257            self.embedding_models_for_note_kind(kind)
1258        };
1259
1260        // FTS step — compensate note row on failure.
1261        {
1262            // Injection: check FTS_FAIL_NS (armed by `arm_fts_fail_scoped(ns)`).
1263            // Fires only when `ns` is in the armed set, removing it on the way
1264            // out (one-shot, atomic check-and-remove under the mutex). No lock
1265            // acquisition in release builds — the cfg(not) branch is a const
1266            // false so the compiler eliminates the if-branch entirely.
1267            #[cfg(any(test, feature = "fault-injection"))]
1268            let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
1269            #[cfg(not(any(test, feature = "fault-injection")))]
1270            let fts_inject = false;
1271            let fts_result: RuntimeResult<()> = if fts_inject {
1272                Err(RuntimeError::Internal("injected FTS failure".to_string()))
1273            } else {
1274                let statements =
1275                    khive_db::stores::text::delete_document_statements("fts_notes", ns, note.id)
1276                        .into_iter()
1277                        .chain(khive_db::stores::text::insert_document_statements(
1278                            "fts_notes",
1279                            &note_fts_document(&note),
1280                        ))
1281                        .collect();
1282                self.apply_note_index_revision(&note, statements)
1283                    .await
1284                    .map(|_| ())
1285            };
1286
1287            if let Err(e) = fts_result {
1288                self.compensate_note_creation(&note).await;
1289                return Err(e);
1290            }
1291        }
1292
1293        // Vector embedding + insert step — compensate note row + FTS doc on failure.
1294        // Multi-model vector embedding:
1295        //   - explicit embedding_model → single model (existing behaviour)
1296        //   - None → the note kind's declared model policy
1297        //   - None + no models configured → skip (text-only)
1298        // The effective text sent to every embedder: the caller-supplied
1299        // capped override when present, otherwise the full stored content.
1300        // FTS indexing above always used the full `note.content` — this cap
1301        // affects only the vector-embedding input.
1302        let canonical_embed_text = note_embedding_text_ref(&note);
1303        let embed_text = embedding_content.unwrap_or(canonical_embed_text);
1304
1305        let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
1306        let mut vector_fences = Vec::with_capacity(embed_model_names.len());
1307        if embed_model_names.len() == 1 {
1308            // Single-model path: preserves original sequential behaviour.
1309            let model_name = &embed_model_names[0];
1310            let vec_result = self
1311                .embed_document_with_model_outcome_for_token(token, model_name, embed_text)
1312                .await;
1313
1314            // Injection: check VECTOR_FAIL_NS (armed by `arm_vector_fail_scoped(ns)`) or
1315            // VECTOR_FAIL_AFTER (armed by `arm_vector_fail_after(n)`). The former
1316            // fires only when the armed namespace matches this note's namespace;
1317            // callers that cannot guarantee no concurrently-running test also
1318            // writes a note into that same namespace (e.g. a test suite whose
1319            // fixtures share one default namespace) should prefer the latter,
1320            // thread-local count instead — see its doc comment. Either clears
1321            // (one-shot) once it fires. No lock/cell access in release builds —
1322            // the cfg(not) branch is a const false eliminating the if-branch.
1323            #[cfg(any(test, feature = "fault-injection"))]
1324            let vec_inject = {
1325                let ns_inject = consume_fault(&VECTOR_FAIL_NS, ns);
1326                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
1327                    Some(0) => {
1328                        cell.set(None);
1329                        true
1330                    }
1331                    Some(n) => {
1332                        cell.set(Some(n - 1));
1333                        false
1334                    }
1335                    None => false,
1336                });
1337                ns_inject || count_inject
1338            };
1339            #[cfg(not(any(test, feature = "fault-injection")))]
1340            let vec_inject = false;
1341            let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
1342                if vec_inject {
1343                    Err(RuntimeError::Internal(
1344                        "injected vector failure".to_string(),
1345                    ))
1346                } else {
1347                    vec_result
1348                };
1349
1350            let single_model_result: RuntimeResult<()> = match vec_result {
1351                Ok(outcome) => {
1352                    embedding_report.observe(&outcome);
1353                    if capture_visibility {
1354                        match self
1355                            .publish_note_vector_revision_with_seq(
1356                                token,
1357                                &note,
1358                                model_name,
1359                                &outcome.vector,
1360                            )
1361                            .await
1362                        {
1363                            Ok(Some(seq)) => {
1364                                vector_fences.push((model_name.clone(), seq));
1365                                Ok(())
1366                            }
1367                            Ok(None) => Ok(()),
1368                            Err(error) => Err(error),
1369                        }
1370                    } else {
1371                        self.publish_note_vector_revision(token, &note, model_name, &outcome.vector)
1372                            .await
1373                            .map(|_| ())
1374                    }
1375                }
1376                Err(e) => Err(e),
1377            };
1378            if let Err(e) = single_model_result {
1379                self.compensate_note_creation(&note).await;
1380                return Err(e);
1381            }
1382        } else if !embed_model_names.is_empty() {
1383            // Multi-model path: embed with each model in parallel via spawned tasks,
1384            // then insert one VectorRecord per model.
1385            let rt_clone = self.clone();
1386            // JoinSet tasks require owned text; an Arc keeps this to one
1387            // content-sized allocation rather than one clone per model.
1388            let content_owned: std::sync::Arc<str> = std::sync::Arc::from(embed_text);
1389            let usage_ctx = crate::usage::current();
1390            let mut join_set = tokio::task::JoinSet::new();
1391            for (idx, model_name) in embed_model_names.iter().enumerate() {
1392                let rt = rt_clone.clone();
1393                let text = std::sync::Arc::clone(&content_owned);
1394                let name = model_name.clone();
1395                let ctx = usage_ctx.clone();
1396                let token = (*token).clone();
1397                join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
1398                    let fut = rt.embed_document_with_model_outcome_for_token(
1399                        &token,
1400                        &name,
1401                        text.as_ref(),
1402                    );
1403                    let result = match ctx {
1404                        Some(ctx) => crate::usage::scope(ctx, fut).await,
1405                        None => fut.await,
1406                    };
1407                    (idx, result)
1408                }));
1409            }
1410            // The first failed or panicked handle aborts and detaches its
1411            // siblings. Embed usage is counted at dispatch, so a synchronous
1412            // provider winding down in the background cannot change it.
1413            let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
1414                Ok(outcomes) => outcomes,
1415                Err(e) => {
1416                    self.compensate_note_creation(&note).await;
1417                    return Err(e);
1418                }
1419            };
1420            // TODO(P2): parallelize vector inserts
1421            for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
1422                embedding_report.observe(&outcome);
1423                let insert_result = if capture_visibility {
1424                    self.publish_note_vector_revision_with_seq(
1425                        token,
1426                        &note,
1427                        model_name,
1428                        &outcome.vector,
1429                    )
1430                    .await
1431                    .map(|seq| {
1432                        if let Some(seq) = seq {
1433                            vector_fences.push((model_name.clone(), seq));
1434                        }
1435                    })
1436                } else {
1437                    self.publish_note_vector_revision(token, &note, model_name, &outcome.vector)
1438                        .await
1439                        .map(|_| ())
1440                };
1441                if let Err(e) = insert_result {
1442                    self.compensate_note_creation(&note).await;
1443                    return Err(e);
1444                }
1445            }
1446        }
1447
1448        // Create annotates edges, compensating on failure to preserve atomicity.
1449        // Pre-validation (above) ensures all targets exist, so link failures are
1450        // unexpected. If one occurs, purge incident edges and the note in one
1451        // transaction; a failed purge retains the live note as their source.
1452        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1453
1454        // In test builds, iterate with an index so the failure-injection hook can
1455        // target a specific call.  In release builds, skip the enumerate overhead.
1456        #[cfg(test)]
1457        let annotates_iter: Vec<(usize, Uuid)> = annotates
1458            .iter()
1459            .enumerate()
1460            .map(|(i, &id)| (i, id))
1461            .collect();
1462        #[cfg(test)]
1463        macro_rules! next_target {
1464            ($pair:expr) => {
1465                $pair.1
1466            };
1467        }
1468        #[cfg(not(test))]
1469        let annotates_iter: Vec<Uuid> = annotates.to_vec();
1470        #[cfg(not(test))]
1471        macro_rules! next_target {
1472            ($pair:expr) => {
1473                $pair
1474            };
1475        }
1476
1477        for pair in annotates_iter {
1478            let target_id = next_target!(pair);
1479
1480            // Test-only: inject a failure on the configured call index (1-based).
1481            #[cfg(test)]
1482            let injected_err: Option<RuntimeError> = {
1483                let call_idx = pair.0;
1484                LINK_FAIL_AFTER.with(|cell| {
1485                    let n = cell.get();
1486                    if n > 0 && call_idx + 1 == n {
1487                        cell.set(0); // reset so subsequent calls are unaffected
1488                        Some(RuntimeError::Internal("injected link failure".to_string()))
1489                    } else {
1490                        None
1491                    }
1492                })
1493            };
1494            #[cfg(not(test))]
1495            let injected_err: Option<RuntimeError> = None;
1496
1497            let link_result = if let Some(e) = injected_err {
1498                Err(e)
1499            } else {
1500                self.link(
1501                    token,
1502                    note.id,
1503                    target_id,
1504                    EdgeRelation::Annotates,
1505                    1.0,
1506                    None,
1507                )
1508                .await
1509            };
1510
1511            match link_result {
1512                Ok(edge) => created_edges.push(edge.id.into()),
1513                Err(e) => {
1514                    // The graph purge, index cleanup, and note removal share
1515                    // one writer transaction. If the edge purge fails, its
1516                    // transaction rolls back, preserving a live source note
1517                    // for the surviving edges instead of orphaning them.
1518                    let edge_ids = created_edges
1519                        .iter()
1520                        .map(Uuid::to_string)
1521                        .collect::<Vec<_>>()
1522                        .join(", ");
1523                    match self.compensate_note_creation_with_edges(&note).await {
1524                        Ok(true) => return Err(e),
1525                        Ok(false) => {
1526                            return Err(RuntimeError::Internal(format!(
1527                                "create_note: annotates link failed: {e}; note {} changed before \
1528                                 compensation, retaining its incident edges [{edge_ids}]",
1529                                note.id
1530                            )));
1531                        }
1532                        Err(cleanup_error) => {
1533                            return Err(RuntimeError::Internal(format!(
1534                                "create_note: annotates link failed: {e}; compensation failed \
1535                                 for note {} and retained edges [{edge_ids}]: {cleanup_error}; \
1536                                 note and edges remain for reconciliation",
1537                                note.id
1538                            )));
1539                        }
1540                    }
1541                }
1542            }
1543        }
1544
1545        // Same contract as the entity arrival event above: after compensation,
1546        // so a rolled-back create leaves no event. This is the single funnel for
1547        // every note create in the product, which is why the memory pack's own
1548        // note_created emitter was removed rather than left beside it.
1549        let created_event = khive_storage::event::Event::new(
1550            note.namespace.clone(),
1551            "create",
1552            EventKind::NoteCreated,
1553            SubstrateKind::Note,
1554            "",
1555        )
1556        .with_target(note.id)
1557        .with_payload(serde_json::json!({
1558            "id": note.id,
1559            "namespace": note.namespace,
1560            "kind": note.kind,
1561            "salience": note.salience,
1562        }));
1563        let event_result = match self.events(token) {
1564            Ok(store) => store
1565                .append_event(created_event)
1566                .await
1567                .map_err(RuntimeError::from),
1568            Err(error) => Err(error),
1569        };
1570        let mut degradations = Vec::new();
1571        if let Err(error) = event_result {
1572            record_post_commit_degradation(
1573                &mut degradations,
1574                "create_note",
1575                note.id,
1576                "event_append",
1577                error,
1578            );
1579        }
1580
1581        vector_fences.sort_by(|a, b| a.0.cmp(&b.0));
1582        Ok((note, embedding_report, degradations, vector_fences))
1583    }
1584
1585    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
1586    ///
1587    /// Pipeline:
1588    /// 1. FTS5 query against `notes_<namespace>`.
1589    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
1590    /// 3. RRF fusion (k=60).
1591    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
1592    /// 5. Filter soft-deleted notes, apply optional kind / tag / properties predicates.
1593    ///    Tags and properties are pushed into the per-note fetch loop BEFORE truncation
1594    ///    so that matching notes ranked beyond `limit` in the raw fusion are not silently
1595    ///    dropped.
1596    /// 6. Truncate to `limit`.
1597    ///
1598    /// `tags_any`: when non-empty, only notes that have at least one of these tags
1599    /// (stored in `properties["tags"]`, case-insensitive match) are retained. The
1600    /// check happens inside the alive-note loop, before `hits.truncate(limit)`.
1601    ///
1602    /// `properties_filter`: when `Some`, only notes whose `properties` JSON object is
1603    /// a superset of the given filter object are retained. Also applied before truncation.
1604    #[allow(clippy::too_many_arguments)]
1605    pub async fn search_notes(
1606        &self,
1607        token: &NamespaceToken,
1608        query_text: &str,
1609        query_vector: Option<Vec<f32>>,
1610        limit: u32,
1611        note_kind: Option<&str>,
1612        include_superseded: bool,
1613        tags_any: &[String],
1614        properties_filter: Option<&serde_json::Value>,
1615    ) -> RuntimeResult<Vec<NoteSearchHit>> {
1616        self.search_notes_with_text_mode(
1617            token,
1618            query_text,
1619            query_vector,
1620            limit,
1621            note_kind,
1622            include_superseded,
1623            tags_any,
1624            properties_filter,
1625            TextQueryMode::Plain,
1626        )
1627        .await
1628    }
1629
1630    /// Note search with an explicit lexical mode for the text arm.
1631    #[allow(clippy::too_many_arguments)]
1632    pub async fn search_notes_with_text_mode(
1633        &self,
1634        token: &NamespaceToken,
1635        query_text: &str,
1636        query_vector: Option<Vec<f32>>,
1637        limit: u32,
1638        note_kind: Option<&str>,
1639        include_superseded: bool,
1640        tags_any: &[String],
1641        properties_filter: Option<&serde_json::Value>,
1642        text_mode: TextQueryMode,
1643    ) -> RuntimeResult<Vec<NoteSearchHit>> {
1644        let (hits, _vector_error) = self
1645            .search_notes_inner(
1646                token,
1647                query_text,
1648                query_vector,
1649                limit,
1650                note_kind,
1651                include_superseded,
1652                tags_any,
1653                properties_filter,
1654                text_mode,
1655                false,
1656            )
1657            .await?;
1658        Ok(hits)
1659    }
1660
1661    /// Coordinator fan-out variant of [`Self::search_notes`]: the text arm
1662    /// still fails loud, but a vector-arm failure after a successful text leg
1663    /// is captured instead of discarding the text hits — mirrors
1664    /// [`Self::hybrid_search_outcome`]'s contract for the entity substrate.
1665    /// Reserved for `SubstrateCoordinator::fan_out_search_with_visibility`;
1666    /// every other caller keeps the fail-loud [`Self::search_notes`] contract.
1667    #[allow(clippy::too_many_arguments)]
1668    pub async fn search_notes_outcome(
1669        &self,
1670        token: &NamespaceToken,
1671        query_text: &str,
1672        limit: u32,
1673        note_kind: Option<&str>,
1674        include_superseded: bool,
1675        tags_any: &[String],
1676        properties_filter: Option<&serde_json::Value>,
1677    ) -> RuntimeResult<NoteSearchOutcome> {
1678        self.search_notes_outcome_with_text_mode(
1679            token,
1680            query_text,
1681            limit,
1682            note_kind,
1683            include_superseded,
1684            tags_any,
1685            properties_filter,
1686            TextQueryMode::Plain,
1687        )
1688        .await
1689    }
1690
1691    /// Coordinator note-search variant with an explicit lexical mode.
1692    #[allow(clippy::too_many_arguments)]
1693    pub async fn search_notes_outcome_with_text_mode(
1694        &self,
1695        token: &NamespaceToken,
1696        query_text: &str,
1697        limit: u32,
1698        note_kind: Option<&str>,
1699        include_superseded: bool,
1700        tags_any: &[String],
1701        properties_filter: Option<&serde_json::Value>,
1702        text_mode: TextQueryMode,
1703    ) -> RuntimeResult<NoteSearchOutcome> {
1704        let (hits, vector_error) = self
1705            .search_notes_inner(
1706                token,
1707                query_text,
1708                None,
1709                limit,
1710                note_kind,
1711                include_superseded,
1712                tags_any,
1713                properties_filter,
1714                text_mode,
1715                true,
1716            )
1717            .await?;
1718        Ok(NoteSearchOutcome { hits, vector_error })
1719    }
1720
1721    #[allow(clippy::too_many_arguments)]
1722    async fn search_notes_inner(
1723        &self,
1724        token: &NamespaceToken,
1725        query_text: &str,
1726        query_vector: Option<Vec<f32>>,
1727        limit: u32,
1728        note_kind: Option<&str>,
1729        include_superseded: bool,
1730        tags_any: &[String],
1731        properties_filter: Option<&serde_json::Value>,
1732        text_mode: TextQueryMode,
1733        tolerate_vector_error: bool,
1734    ) -> RuntimeResult<(Vec<NoteSearchHit>, Option<String>)> {
1735        const RRF_K: usize = 60;
1736        let candidates = limit.saturating_mul(4).max(limit);
1737        let visible_ns: Vec<String> = token
1738            .visible_namespaces()
1739            .iter()
1740            .map(|ns| ns.as_str().to_owned())
1741            .collect();
1742
1743        // FTS5 over the notes index — search all visible namespaces.
1744        //
1745        // `sanitize_fts5_query` strips known-unsafe FTS5 metacharacters, but
1746        // residual punctuation the sanitizer does not strip can still reach
1747        // the FTS5 parser and error. This fails loud instead of degrading to
1748        // vector-only fusion, so callers see the bad query instead of
1749        // silently losing the lexical leg. Errors from any other leg (vector
1750        // search, note hydration) still propagate normally.
1751        //
1752        // Injection: check FTS_SEARCH_FAIL_NS (armed by `arm_fts_search_fail(ns)`),
1753        // exercising the propagate branch above. Fires only when the armed
1754        // namespace is among this call's visible namespaces, then clears (one-shot).
1755        #[cfg(any(test, feature = "fault-injection"))]
1756        let fts_search_inject = {
1757            let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
1758            match g.as_deref() {
1759                Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
1760                    *g = None;
1761                    true
1762                }
1763                _ => false,
1764            }
1765        };
1766        #[cfg(not(any(test, feature = "fault-injection")))]
1767        let fts_search_inject = false;
1768
1769        let text_store = self.text_for_notes(token)?;
1770        let text_fut = async {
1771            if fts_search_inject {
1772                return Err(khive_storage::StorageError::Timeout {
1773                    operation: "fts_search".into(),
1774                });
1775            }
1776            text_store
1777                .search(TextSearchRequest {
1778                    query: query_text.to_string(),
1779                    mode: text_mode,
1780                    filter: Some(TextFilter {
1781                        namespaces: visible_ns.clone(),
1782                        // Push the note-kind filter into the FTS query. Without it the
1783                        // text arm returns the top `candidates` rows across EVERY note
1784                        // kind in the namespace and the kind is applied post-fetch, so a
1785                        // store where short message/session rows outrank task
1786                        // descriptions under BM25 hands the caller one or two task hits
1787                        // while the store holds many more carrying the literal.
1788                        record_kinds: note_kind
1789                            .map(|kind| vec![kind.to_string()])
1790                            .unwrap_or_default(),
1791                        ..TextFilter::default()
1792                    }),
1793                    top_k: candidates,
1794                    snippet_chars: 200,
1795                })
1796                .await
1797        };
1798        let text_fut = crate::stage_seam::text_stage(text_fut);
1799
1800        // Vector search filtered to notes; it runs with the text stage, and a text error wins.
1801        let vector_fut = async {
1802            if query_vector.is_some() || self.config().embedding_model.is_some() {
1803                self.note_search_vector_search(token, query_vector, query_text, candidates)
1804                    .await
1805            } else {
1806                Ok(vec![])
1807            }
1808        };
1809        let (text_search_result, vector_result) = tokio::join!(text_fut, vector_fut);
1810
1811        // FtsPasses is counted inside the store's `search()` (khive-db
1812        // stores/text.rs), only once a real FTS5 statement is prepared —
1813        // an empty/fully-sanitized query short-circuits there before any
1814        // statement exists and must not count (nor does the injected-failure
1815        // branch above, which never reaches the store at all).
1816        let text_hits = crate::error::fts_text_leg_or_err(
1817            text_search_result.map_err(RuntimeError::from),
1818            "search_notes",
1819            query_text,
1820        )?;
1821
1822        let mut vector_error: Option<String> = None;
1823        let vector_hits = match vector_result {
1824            Ok(hits) => hits,
1825            Err(e) if tolerate_vector_error => {
1826                vector_error = Some(e.to_string());
1827                Vec::new()
1828            }
1829            Err(e) => return Err(e),
1830        };
1831
1832        // Keep the full text∪vector union through RRF — salience weighting and
1833        // soft-delete/kind filtering happen *after* this, and the final
1834        // `hits.truncate(limit)` is the only result-limiting cut. Truncating to
1835        // `candidates` here would drop a high-salience note ranked just outside
1836        // the raw RRF cutoff before salience ever applied.
1837        let fuse_k = text_hits.len() + vector_hits.len();
1838        let fused = self
1839            .fuse_with_strategy(
1840                text_hits,
1841                vector_hits,
1842                &crate::fusion::FusionStrategy::Rrf { k: RRF_K },
1843                fuse_k,
1844            )
1845            .await?;
1846
1847        let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
1848        if candidate_ids.is_empty() {
1849            return Ok((vec![], vector_error));
1850        }
1851
1852        // Hydrate every candidate note with one batched read to get salience and
1853        // apply soft-delete + (optional) kind filtering. The store chunks the read
1854        // below its bound-parameter ceiling, so the read costs `ceil(candidates / 900)`
1855        // statements instead of one per candidate. Notes whose `kind` doesn't
1856        // match `note_kind` are dropped post-fetch — they're a small set
1857        // bounded by the text∪vector union (≤ 2×candidates), so the read is cheap.
1858        let note_store = self.notes(token)?;
1859        let search_pool = self.backend().pool_arc();
1860        let mailbox_view = crate::MailboxView {
1861            actor_id: token.actor().id.clone(),
1862            delegated: false,
1863        };
1864        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
1865        for note in note_store.get_notes_batch(&candidate_ids).await? {
1866            search_pool.record_note_candidate_hydration_row();
1867            if note.deleted_at.is_some() {
1868                continue;
1869            }
1870            if !mailbox_view.permits_message_note(token, &note) {
1871                continue;
1872            }
1873            if let Some(want_kind) = note_kind {
1874                if note.kind != want_kind {
1875                    continue;
1876                }
1877            }
1878            // Apply tag predicate before adding to alive set: tags on notes live
1879            // inside `properties["tags"]` (a JSON array). This pushes the filter
1880            // before truncation so matching notes ranked beyond `limit` in the raw
1881            // fusion are not silently dropped.
1882            if !tags_any.is_empty() {
1883                let note_tags: Vec<String> = note
1884                    .properties
1885                    .as_ref()
1886                    .and_then(|p| p.get("tags"))
1887                    .and_then(serde_json::Value::as_array)
1888                    .map(|arr| {
1889                        arr.iter()
1890                            .filter_map(serde_json::Value::as_str)
1891                            .map(str::to_owned)
1892                            .collect()
1893                    })
1894                    .unwrap_or_default();
1895                if !note_tags
1896                    .iter()
1897                    .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
1898                {
1899                    continue;
1900                }
1901            }
1902            // Apply properties predicate before truncation, same reasoning as tags above.
1903            if let Some(pf) = properties_filter {
1904                if !crate::retrieval::properties_match(note.properties.as_ref(), pf) {
1905                    continue;
1906                }
1907            }
1908            alive_notes.insert(note.id, note);
1909        }
1910
1911        // Drop superseded notes unless include_superseded is true: any note targeted
1912        // by a `supersedes` edge is obsolete and excluded from default search.
1913        if !include_superseded && !alive_notes.is_empty() {
1914            let graph = self.graph(token)?;
1915            let note_ids: Vec<Uuid> = alive_notes.keys().copied().collect();
1916            let superseded: std::collections::HashSet<Uuid> = graph
1917                .batch_neighbors(
1918                    &note_ids,
1919                    NeighborQuery {
1920                        direction: Direction::In,
1921                        relations: Some(vec![EdgeRelation::Supersedes]),
1922                        limit: Some(1),
1923                        min_weight: None,
1924                    },
1925                )
1926                .await?
1927                .into_iter()
1928                .map(|(note_id, _)| note_id)
1929                .collect();
1930            alive_notes.retain(|id, _| !superseded.contains(id));
1931        }
1932
1933        // Apply salience weighting and collect final hits.
1934        let mut hits: Vec<NoteSearchHit> = fused
1935            .into_iter()
1936            .filter_map(|hit| {
1937                let note = alive_notes.get(&hit.entity_id)?;
1938                let weighted = salience_weighted_rank(hit.score, note.salience);
1939                Some(NoteSearchHit {
1940                    note_id: hit.entity_id,
1941                    score: weighted,
1942                    rank_score_kind: hit.rank_score_kind,
1943                    signals: hit.signals,
1944                    source: hit.source,
1945                    title: hit.title.or_else(|| note_title(note)),
1946                    snippet: hit.snippet.or_else(|| note_snippet(note)),
1947                })
1948            })
1949            .collect();
1950
1951        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
1952        hits.truncate(limit as usize);
1953        Ok((hits, vector_error))
1954    }
1955
1956    /// Restore a note tombstone owned by the caller's primary namespace.
1957    ///
1958    /// A live note holding the tombstone's `(namespace, kind, key)` refuses
1959    /// the operation before any row changes. The same condition is repeated
1960    /// in the guarded restore statement for the concurrent race.
1961    pub async fn restore_note(
1962        &self,
1963        token: &NamespaceToken,
1964        id: Uuid,
1965    ) -> RuntimeResult<Option<(Note, bool)>> {
1966        let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? else {
1967            return Ok(None);
1968        };
1969        if note.namespace != token.namespace().as_str() {
1970            return Ok(None);
1971        }
1972        if note.deleted_at.is_none() {
1973            return Ok(Some((note, false)));
1974        }
1975        if let Some(key) = note.key.as_deref() {
1976            if let Some(holder) = self
1977                .notes(token)?
1978                .get_live_notes_by_key(&note.namespace, key, Some(&note.kind))
1979                .await?
1980                .into_iter()
1981                .find(|holder| holder.id != note.id)
1982            {
1983                return Err(restore_key_conflict(key, &holder));
1984            }
1985        }
1986        let updated_at =
1987            Utc::now()
1988                .timestamp_micros()
1989                .max(note.updated_at.checked_add(1).ok_or_else(|| {
1990                    RuntimeError::Internal(format!(
1991                        "note {id} updated_at is already at i64::MAX and cannot advance"
1992                    ))
1993                })?);
1994        let mut params = vec![
1995            SqlValue::Text("active".into()),
1996            SqlValue::Integer(updated_at),
1997            SqlValue::Text(id.to_string()),
1998            SqlValue::Text(note.namespace.clone()),
1999            SqlValue::Text(note.kind.clone()),
2000        ];
2001        let key_clause = if let Some(key) = note.key.as_deref() {
2002            params.push(SqlValue::Text(key.to_owned()));
2003            format!(
2004                " AND (key IS NULL OR NOT EXISTS (SELECT 1 FROM notes live \
2005                          WHERE live.namespace=?4 AND live.kind=?5 AND live.key=?{} \
2006                            AND live.deleted_at IS NULL AND live.id != notes.id))",
2007                params.len()
2008            )
2009        } else {
2010            String::new()
2011        };
2012        let mut restored = note.clone();
2013        restored.status = "active".into();
2014        restored.deleted_at = None;
2015        restored.updated_at = updated_at;
2016        restored.version = restored
2017            .version
2018            .checked_add(1)
2019            .ok_or_else(|| RuntimeError::Internal(format!("note {id} version is exhausted")))?;
2020        let mut statements = vec![PlanStatement {
2021            statement: SqlStatement {
2022                sql: format!(
2023                    "UPDATE notes SET status=?1, deleted_at=NULL, updated_at=?2 \
2024                     WHERE id=?3 AND namespace=?4 AND kind=?5 AND deleted_at IS NOT NULL{key_clause}"
2025                ),
2026                params,
2027                label: Some("note-restore".into()),
2028            },
2029            guard: Some(AffectedRowGuard::exactly(1)),
2030        }];
2031        // Text index published in the same unit as the row; see restore_entity.
2032        for statement in
2033            khive_db::stores::text::delete_document_statements("fts_notes", &restored.namespace, id)
2034                .into_iter()
2035                .chain(insert_document_statements(
2036                    "fts_notes",
2037                    &note_fts_document(&restored),
2038                ))
2039        {
2040            statements.push(PlanStatement {
2041                statement,
2042                guard: None,
2043            });
2044        }
2045        let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
2046            graph_effects: Vec::new(),
2047            target_id: id,
2048            statements,
2049            post_commit: PostCommitEffect::None,
2050            edge_natural_key: None,
2051            idempotent_noop: false,
2052            entity_guard: None,
2053            note_guard: None,
2054            note_vector_purge: None,
2055            note_embedding_inheritance: None,
2056        }));
2057        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
2058            Ok(AtomicRunOutcome::Committed { .. }) => {
2059                #[cfg(any(test, feature = "fault-injection"))]
2060                if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
2061                    return Err(restore_reindex_failed(
2062                        "note",
2063                        id,
2064                        RuntimeError::Internal("injected FTS failure".to_string()),
2065                    ));
2066                }
2067                let reindexed = self.reindex_note_with_report(token, &restored).await;
2068                let report = reindexed.map_err(|e| restore_reindex_failed("note", id, e))?;
2069                let degradations = report.post_commit_degradations();
2070                legacy_post_commit_result("restore_note", id, Some((restored, true)), degradations)
2071            }
2072            Ok(AtomicRunOutcome::RolledBack {
2073                failure: AtomicOpFailure::GuardFailed { .. },
2074                ..
2075            }) => {
2076                if let Some(key) = note.key.as_deref() {
2077                    if let Some(holder) = self
2078                        .notes(token)?
2079                        .get_live_notes_by_key(&note.namespace, key, Some(&note.kind))
2080                        .await?
2081                        .into_iter()
2082                        .find(|holder| holder.id != note.id)
2083                    {
2084                        return Err(restore_key_conflict(key, &holder));
2085                    }
2086                }
2087                Err(RuntimeError::NotFound(format!(
2088                    "note {id} is no longer a caller-owned tombstone"
2089                )))
2090            }
2091            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
2092                format!("note restore rolled back: {failure:?}"),
2093            )),
2094            Err(error) => Err(RuntimeError::Storage(error.0)),
2095        }
2096    }
2097}
2098
2099/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
2100/// and [`KhiveRuntime::link_many`].
2101#[derive(Clone, Debug)]
2102pub struct LinkSpec {
2103    pub namespace: Option<String>,
2104    pub source_id: Uuid,
2105    pub target_id: Uuid,
2106    pub relation: EdgeRelation,
2107    pub weight: f64,
2108    pub metadata: Option<serde_json::Value>,
2109    pub resurrect: bool,
2110}
2111
2112/// Fully specified entity creation request — input to [`KhiveRuntime::create_many`].
2113///
2114/// `entity_type` is validated at the runtime layer by the pack-installed
2115/// entity-type validator. When a validator
2116/// is installed (e.g. by `KgPack`), unknown types are rejected with the valid
2117/// set listed. When no validator is installed (bare runtime without packs),
2118/// the value passes through — the handler layer is the primary enforcement point.
2119#[derive(Clone, Debug)]
2120pub struct EntityCreateSpec {
2121    pub kind: String,
2122    pub entity_type: Option<String>,
2123    pub name: String,
2124    pub description: Option<String>,
2125    pub properties: Option<serde_json::Value>,
2126    pub tags: Vec<String>,
2127}
2128
2129// INLINE TEST JUSTIFICATION: tests here exercise private helpers (canonical_edge_endpoints,
2130// validate_edge_metadata, merge_dependency_kind, link-fail injection) and runtime methods
2131// that require pub(crate) KhiveRuntime construction. Moving them to tests/ would require
2132// pub-exporting those private helpers, which would widen the crate's public API surface
2133// undesirably. Broad behavioral tests live in tests/integration.rs.
2134#[cfg(test)]
2135#[path = "operations_tests.rs"]
2136mod tests;