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.
262fn 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
432/// `true` if `spec` matches the given substrate + kind + entity_type triple.
433///
434/// Pure and DB-free — exposed so offline consumers (e.g. `kkernel kg
435/// validate`, which parses `(substrate, kind, entity_type)` straight out of
436/// NDJSON with no live record to resolve) can apply the exact same
437/// `EdgeEndpointRule` matching semantics `pack_rule_allows` uses internally,
438/// instead of re-deriving a parallel matcher that could drift out of sync.
439pub fn endpoint_matches(
440    spec: &EndpointKind,
441    substrate: &str,
442    kind: &str,
443    entity_type: Option<&str>,
444) -> bool {
445    match spec {
446        EndpointKind::EntityOfKind(k) => substrate == "entity" && *k == kind,
447        EndpointKind::NoteOfKind(k) => substrate == "note" && *k == kind,
448        EndpointKind::EntityOfType {
449            kind: k,
450            entity_type: t,
451        } => substrate == "entity" && *k == kind && entity_type == Some(*t),
452    }
453}
454
455/// Base entity endpoint allowlist — the closed set of permitted entity→entity
456/// relation triples.
457///
458/// Each entry `(src_kind, relation, tgt_kind)` explicitly allows that combination.
459/// `"*"` as `src_kind` means "any entity kind" (used by `instance_of` whose source
460/// is unrestricted).
461///
462/// Pack rules (via `EDGE_RULES`) are additive — they cannot remove rows here.
463/// Exposed via `base_entity_endpoint_rules()` for the ADR-076 certificate tests.
464pub const BASE_ENTITY_ENDPOINT_RULES: &[(&str, EdgeRelation, &str)] = &[
465    // Structure
466    ("concept", EdgeRelation::Contains, "concept"),
467    ("project", EdgeRelation::Contains, "project"),
468    ("project", EdgeRelation::Contains, "artifact"),
469    ("org", EdgeRelation::Contains, "project"),
470    ("org", EdgeRelation::Contains, "service"),
471    ("concept", EdgeRelation::PartOf, "concept"),
472    ("project", EdgeRelation::PartOf, "project"),
473    ("project", EdgeRelation::PartOf, "org"),
474    ("*", EdgeRelation::InstanceOf, "concept"),
475    ("service", EdgeRelation::InstanceOf, "project"),
476    // ADR-002 amendment (ADR-191): web hyperlink — a document points at
477    // another document it links to. No qualifier inference (unlike
478    // depends_on); the endpoint pair is intentionally narrow (document only,
479    // no service/concept targets — see ADR-191 D2/F10).
480    ("document", EdgeRelation::LinksTo, "document"),
481    // ADR-196 location, amended 2026-10-05: the source occupies or is manifested in the target
482    // without being a constituent of it; base rows for concept and org, packs narrow the rest.
483    ("concept", EdgeRelation::LocatedIn, "concept"),
484    ("org", EdgeRelation::LocatedIn, "concept"),
485    // Ownership
486    ("person", EdgeRelation::Owns, "org"),
487    ("org", EdgeRelation::Owns, "org"),
488    // Derivation
489    ("concept", EdgeRelation::Extends, "concept"),
490    ("concept", EdgeRelation::VariantOf, "concept"),
491    ("artifact", EdgeRelation::VariantOf, "artifact"),
492    ("concept", EdgeRelation::IntroducedBy, "document"),
493    ("concept", EdgeRelation::IntroducedBy, "person"),
494    ("artifact", EdgeRelation::IntroducedBy, "document"),
495    ("project", EdgeRelation::IntroducedBy, "document"),
496    // ADR-002 amendment (ADR-167): service provenance — the document that
497    // introduced a service (its ADR or design record).
498    ("service", EdgeRelation::IntroducedBy, "document"),
499    ("document", EdgeRelation::IntroducedBy, "person"),
500    ("document", EdgeRelation::IntroducedBy, "org"),
501    ("concept", EdgeRelation::IntroducedBy, "org"),
502    // Provenance
503    ("artifact", EdgeRelation::DerivedFrom, "dataset"),
504    ("artifact", EdgeRelation::DerivedFrom, "document"),
505    ("artifact", EdgeRelation::DerivedFrom, "project"),
506    ("artifact", EdgeRelation::DerivedFrom, "artifact"),
507    // ADR-002 amendment 2026-07-27: publication provenance — a curated or
508    // filtered publication copy points at the canonical source document.
509    ("document", EdgeRelation::DerivedFrom, "document"),
510    // Temporal
511    ("document", EdgeRelation::Precedes, "document"),
512    ("dataset", EdgeRelation::Precedes, "dataset"),
513    ("artifact", EdgeRelation::Precedes, "artifact"),
514    ("service", EdgeRelation::Precedes, "service"),
515    ("project", EdgeRelation::Precedes, "project"),
516    // Dependency
517    ("project", EdgeRelation::DependsOn, "project"),
518    ("service", EdgeRelation::DependsOn, "project"),
519    ("service", EdgeRelation::DependsOn, "service"),
520    ("service", EdgeRelation::DependsOn, "artifact"),
521    ("service", EdgeRelation::DependsOn, "dataset"),
522    ("artifact", EdgeRelation::DependsOn, "project"),
523    ("artifact", EdgeRelation::DependsOn, "service"),
524    ("document", EdgeRelation::DependsOn, "document"),
525    ("concept", EdgeRelation::Enables, "concept"),
526    ("service", EdgeRelation::Enables, "concept"),
527    ("dataset", EdgeRelation::Enables, "concept"),
528    // Implementation
529    ("project", EdgeRelation::Implements, "concept"),
530    ("service", EdgeRelation::Implements, "concept"),
531    // Lateral
532    ("concept", EdgeRelation::CompetesWith, "concept"),
533    ("project", EdgeRelation::CompetesWith, "project"),
534    ("service", EdgeRelation::CompetesWith, "service"),
535    ("org", EdgeRelation::CompetesWith, "org"),
536    ("concept", EdgeRelation::ComposedWith, "concept"),
537    ("project", EdgeRelation::ComposedWith, "project"),
538    // Versioning (Supersedes — Concept/Document/Artifact/Service/Dataset only)
539    ("concept", EdgeRelation::Supersedes, "concept"),
540    ("document", EdgeRelation::Supersedes, "document"),
541    ("artifact", EdgeRelation::Supersedes, "artifact"),
542    ("service", EdgeRelation::Supersedes, "service"),
543    ("dataset", EdgeRelation::Supersedes, "dataset"),
544    // Epistemic (Supports/Refutes — evidence sources → Concept claim only)
545    ("concept", EdgeRelation::Supports, "concept"),
546    ("document", EdgeRelation::Supports, "concept"),
547    ("dataset", EdgeRelation::Supports, "concept"),
548    ("artifact", EdgeRelation::Supports, "concept"),
549    ("concept", EdgeRelation::Refutes, "concept"),
550    ("document", EdgeRelation::Refutes, "concept"),
551    ("dataset", EdgeRelation::Refutes, "concept"),
552    ("artifact", EdgeRelation::Refutes, "concept"),
553];
554
555/// Returns the base entity endpoint allowlist.
556///
557/// The returned slice is the same data that `base_entity_rule_allows` consults at
558/// runtime. Exposed for the ADR-076 certificate tests in `khive-pack-kg`, which
559/// must audit live rules rather than hand-copied snapshots.
560pub fn base_entity_endpoint_rules() -> &'static [(&'static str, EdgeRelation, &'static str)] {
561    BASE_ENTITY_ENDPOINT_RULES
562}
563
564/// `true` if `(src_kind, relation, tgt_kind)` is in the base entity endpoint
565/// allowlist. Pure and DB-free — exposed alongside [`base_entity_endpoint_rules`]
566/// so offline consumers (e.g. `kkernel kg validate`) can apply the exact same
567/// base-table membership test the live validator uses, instead of re-deriving
568/// a parallel `.any()` predicate over a hand-copied allowlist.
569pub fn base_entity_rule_allows(src_kind: &str, relation: EdgeRelation, tgt_kind: &str) -> bool {
570    BASE_ENTITY_ENDPOINT_RULES.iter().any(|(src, rel, tgt)| {
571        *rel == relation && (*src == "*" || *src == src_kind) && *tgt == tgt_kind
572    })
573}
574
575impl KhiveRuntime {
576    // ---- Note operations ----
577
578    /// Create and persist a note, optionally with properties and annotation targets.
579    ///
580    /// After creating the note:
581    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
582    /// - If an embedding model is configured, indexes into the vector store with
583    ///   `SubstrateKind::Note`.
584    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
585    ///   the note to that target.
586    ///
587    /// A bounded embedding returns a non-retryable error carrying the committed
588    /// note ID and truncation report. The report-aware embedding-content variant
589    /// accepts `None` to retain the same default input selection.
590    // REASON: note creation requires kind, name, content, salience, properties, annotates,
591    // and namespace token — mirrors the MCP verb surface; a builder would not reduce
592    // caller complexity for pack handler callers.
593    #[allow(clippy::too_many_arguments)]
594    pub async fn create_note(
595        &self,
596        token: &NamespaceToken,
597        kind: &str,
598        name: Option<&str>,
599        content: &str,
600        salience: Option<f64>,
601        properties: Option<serde_json::Value>,
602        annotates: Vec<Uuid>,
603    ) -> RuntimeResult<Note> {
604        let (note, embedding, degradations, _) = self
605            .create_note_inner(
606                token, kind, name, content, None, salience, None, properties, annotates, None,
607                false, false,
608            )
609            .await?;
610        legacy_post_commit_result_with_embedding(
611            "create_note",
612            note.id,
613            note,
614            embedding,
615            degradations,
616        )
617    }
618
619    /// Publish a network receipt with provenance that generic note writes
620    /// cannot supply. The web pack provides only the request record and the
621    /// annotation targets; this entry point fixes the note kind, tag, and
622    /// provenance before the first storage write.
623    pub async fn create_web_receipt_note(
624        &self,
625        token: &NamespaceToken,
626        summary: &str,
627        request: serde_json::Value,
628        annotates: Vec<Uuid>,
629    ) -> RuntimeResult<Note> {
630        let properties = serde_json::json!({
631            "tags": ["web.receipt"],
632            "request": request,
633        });
634        let (note, _, degradations, _) = self
635            .create_note_inner(
636                token,
637                "observation",
638                None,
639                summary,
640                None,
641                None,
642                None,
643                Some(properties),
644                annotates,
645                None,
646                false,
647                true,
648            )
649            .await?;
650        legacy_post_commit_result("create_web_receipt_note", note.id, note, degradations)
651    }
652
653    /// Like [`Self::create_note`], but lets the caller supply a smaller text
654    /// to send to the vector embedder while the note's stored/FTS-indexed
655    /// `content` remains the full text.
656    ///
657    /// `embedding_content`, when `Some`, must be non-empty and a proper
658    /// prefix of `content` — anything else is rejected with `InvalidInput`
659    /// before any write. `None` behaves exactly like [`Self::create_note`].
660    /// Use this when `content` may exceed an embedder's input cap (e.g. a
661    /// very long commit message) and only a capped head prefix should be
662    /// embedded, while the full text is still stored and searchable via FTS.
663    #[allow(clippy::too_many_arguments)]
664    pub async fn create_note_with_embedding_content(
665        &self,
666        token: &NamespaceToken,
667        kind: &str,
668        name: Option<&str>,
669        content: &str,
670        embedding_content: Option<&str>,
671        salience: Option<f64>,
672        properties: Option<serde_json::Value>,
673        annotates: Vec<Uuid>,
674    ) -> RuntimeResult<Note> {
675        let (note, embedding, degradations, _) = self
676            .create_note_inner(
677                token,
678                kind,
679                name,
680                content,
681                embedding_content,
682                salience,
683                None,
684                properties,
685                annotates,
686                None,
687                false,
688                false,
689            )
690            .await?;
691        legacy_post_commit_result_with_embedding(
692            "create_note_with_embedding_content",
693            note.id,
694            note,
695            embedding,
696            degradations,
697        )
698    }
699
700    #[allow(clippy::too_many_arguments)]
701    pub async fn create_note_with_embedding_content_and_report(
702        &self,
703        token: &NamespaceToken,
704        kind: &str,
705        name: Option<&str>,
706        content: &str,
707        embedding_content: Option<&str>,
708        salience: Option<f64>,
709        properties: Option<serde_json::Value>,
710        annotates: Vec<Uuid>,
711    ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
712        let (note, embedding, degradations, _) = self
713            .create_note_inner(
714                token,
715                kind,
716                name,
717                content,
718                embedding_content,
719                salience,
720                None,
721                properties,
722                annotates,
723                None,
724                false,
725                false,
726            )
727            .await?;
728        legacy_post_commit_result(
729            "create_note_with_embedding_content_and_report",
730            note.id,
731            (note, embedding),
732            degradations,
733        )
734    }
735
736    /// The committed note and its non-retryable post-commit diagnostics.
737    #[allow(clippy::too_many_arguments)]
738    pub async fn create_note_with_embedding_content_and_post_commit_report(
739        &self,
740        token: &NamespaceToken,
741        kind: &str,
742        name: Option<&str>,
743        content: &str,
744        embedding_content: Option<&str>,
745        salience: Option<f64>,
746        properties: Option<serde_json::Value>,
747        annotates: Vec<Uuid>,
748    ) -> RuntimeResult<(
749        Note,
750        crate::retrieval::EmbeddingTruncationReport,
751        Vec<PostCommitDegradation>,
752    )> {
753        let (note, embedding, degradations, _) = self
754            .create_note_inner(
755                token,
756                kind,
757                name,
758                content,
759                embedding_content,
760                salience,
761                None,
762                properties,
763                annotates,
764                None,
765                false,
766                false,
767            )
768            .await?;
769        Ok((note, embedding, degradations))
770    }
771
772    /// Like [`Self::create_note`] but also sets a non-zero decay factor on the note.
773    // REASON: extends create_note with an additional decay_factor parameter; same
774    // rationale — mirrors the MCP surface and reduces an extra builder layer.
775    #[allow(clippy::too_many_arguments)]
776    pub async fn create_note_with_decay(
777        &self,
778        token: &NamespaceToken,
779        kind: &str,
780        name: Option<&str>,
781        content: &str,
782        salience: Option<f64>,
783        decay_factor: f64,
784        properties: Option<serde_json::Value>,
785        annotates: Vec<Uuid>,
786    ) -> RuntimeResult<Note> {
787        self.create_note_with_decay_for_embedding_model(
788            token,
789            kind,
790            name,
791            content,
792            salience,
793            decay_factor,
794            properties,
795            annotates,
796            None,
797        )
798        .await
799    }
800
801    /// Create a note with decay and retain embedding truncation accounting.
802    #[allow(clippy::too_many_arguments)]
803    pub async fn create_note_with_decay_and_report(
804        &self,
805        token: &NamespaceToken,
806        kind: &str,
807        name: Option<&str>,
808        content: &str,
809        salience: Option<f64>,
810        decay_factor: f64,
811        properties: Option<serde_json::Value>,
812        annotates: Vec<Uuid>,
813    ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
814        self.create_note_with_decay_for_embedding_model_and_report(
815            token,
816            kind,
817            name,
818            content,
819            salience,
820            decay_factor,
821            properties,
822            annotates,
823            None,
824        )
825        .await
826    }
827
828    /// Like [`Self::create_note_with_decay`] but targets a specific embedding model.
829    // REASON: adds an embedding_model parameter to the decay variant; the full parameter
830    // set is required for correct MCP verb routing and cannot be collapsed without
831    // introducing a separate config struct that would obscure call sites.
832    #[allow(clippy::too_many_arguments)]
833    pub async fn create_note_with_decay_for_embedding_model(
834        &self,
835        token: &NamespaceToken,
836        kind: &str,
837        name: Option<&str>,
838        content: &str,
839        salience: Option<f64>,
840        decay_factor: f64,
841        properties: Option<serde_json::Value>,
842        annotates: Vec<Uuid>,
843        embedding_model: Option<&str>,
844    ) -> RuntimeResult<Note> {
845        let (note, embedding, degradations, _) = self
846            .create_note_inner(
847                token,
848                kind,
849                name,
850                content,
851                None,
852                salience,
853                Some(decay_factor),
854                properties,
855                annotates,
856                embedding_model,
857                false,
858                false,
859            )
860            .await?;
861        legacy_post_commit_result_with_embedding(
862            "create_note_with_decay_for_embedding_model",
863            note.id,
864            note,
865            embedding,
866            degradations,
867        )
868    }
869
870    /// Create a note with decay for a named model and retain embedding truncation accounting.
871    #[allow(clippy::too_many_arguments)]
872    pub async fn create_note_with_decay_for_embedding_model_and_report(
873        &self,
874        token: &NamespaceToken,
875        kind: &str,
876        name: Option<&str>,
877        content: &str,
878        salience: Option<f64>,
879        decay_factor: f64,
880        properties: Option<serde_json::Value>,
881        annotates: Vec<Uuid>,
882        embedding_model: Option<&str>,
883    ) -> RuntimeResult<(Note, crate::retrieval::EmbeddingTruncationReport)> {
884        let (note, embedding, degradations, _) = self
885            .create_note_inner(
886                token,
887                kind,
888                name,
889                content,
890                None,
891                salience,
892                Some(decay_factor),
893                properties,
894                annotates,
895                embedding_model,
896                false,
897                false,
898            )
899            .await?;
900        legacy_post_commit_result(
901            "create_note_with_decay_for_embedding_model_and_report",
902            note.id,
903            (note, embedding),
904            degradations,
905        )
906    }
907
908    /// Memory-pack receipt form of the decay create. Each returned sequence
909    /// was read inside the transaction that inserted that model's vector and
910    /// ANN upsert log row; a failed or version-rejected vector writes no fence.
911    #[allow(clippy::too_many_arguments)]
912    pub async fn create_note_with_decay_for_embedding_model_with_visibility(
913        &self,
914        token: &NamespaceToken,
915        kind: &str,
916        name: Option<&str>,
917        content: &str,
918        salience: Option<f64>,
919        decay_factor: f64,
920        properties: Option<serde_json::Value>,
921        annotates: Vec<Uuid>,
922        embedding_model: Option<&str>,
923    ) -> RuntimeResult<(Note, Vec<(String, u64)>)> {
924        let (note, _, degradations, fences) = self
925            .create_note_inner(
926                token,
927                kind,
928                name,
929                content,
930                None,
931                salience,
932                Some(decay_factor),
933                properties,
934                annotates,
935                embedding_model,
936                true,
937                false,
938            )
939            .await?;
940        legacy_post_commit_result(
941            "create_note_with_decay_for_embedding_model_with_visibility",
942            note.id,
943            (note, fences),
944            degradations,
945        )
946    }
947
948    /// Memory-pack create returning both its committed vector fences and
949    /// embedding-input truncation accounting. Other post-commit degradations
950    /// retain the same non-retryable error behavior as the receipt-only API.
951    #[allow(clippy::too_many_arguments)]
952    pub async fn create_note_with_decay_for_embedding_model_with_visibility_and_report(
953        &self,
954        token: &NamespaceToken,
955        kind: &str,
956        name: Option<&str>,
957        content: &str,
958        salience: Option<f64>,
959        decay_factor: f64,
960        properties: Option<serde_json::Value>,
961        annotates: Vec<Uuid>,
962        embedding_model: Option<&str>,
963    ) -> RuntimeResult<(
964        Note,
965        Vec<(String, u64)>,
966        crate::retrieval::EmbeddingTruncationReport,
967    )> {
968        let (note, embedding, degradations, fences) = self
969            .create_note_inner(
970                token,
971                kind,
972                name,
973                content,
974                None,
975                salience,
976                Some(decay_factor),
977                properties,
978                annotates,
979                embedding_model,
980                true,
981                false,
982            )
983            .await?;
984        legacy_post_commit_result(
985            "create_note_with_decay_for_embedding_model_with_visibility_and_report",
986            note.id,
987            (note, fences, embedding),
988            degradations,
989        )
990    }
991
992    /// Insert a note using `INSERT OR IGNORE` semantics for atomic deduplication.
993    ///
994    /// Returns `Ok(Some(note))` when the note was newly written.  Returns
995    /// `Ok(None)` when a unique constraint (e.g. the channel-scoped `external_id`
996    /// partial index on comm message notes) was already satisfied by an existing row,
997    /// making this call a no-op. Indexing failures after an insert return a
998    /// typed `post_commit_degraded` error with the committed record ID and
999    /// `retryable=false`. Healthy models still run; the note is not rolled back.
1000    ///
1001    /// This method is intentionally narrower than `create_note`: it skips
1002    /// salience/decay, annotates edges, and embedding-model selection, which
1003    /// are not needed for channel-ingest paths.
1004    ///
1005    /// Rejects the transport-owned properties on a `message` note: the
1006    /// `message` entry of the kind-owned property list that the note-store
1007    /// accessor guard enforces, so both guards refuse the same keys. These
1008    /// properties are transport-owned evidence (`comm.health` trusts the
1009    /// quarantine and channel ones at face value), and this fast path (unlike
1010    /// the generic `create` verb funnel) is not covered by the
1011    /// pack-installed note-write validator. Only the trusted channel-ingest
1012    /// path may establish them — see
1013    /// [`Self::try_create_note_as_trusted_ingest`].
1014    pub async fn try_create_note(
1015        &self,
1016        token: &NamespaceToken,
1017        kind: &str,
1018        name: Option<&str>,
1019        content: &str,
1020        properties: Option<serde_json::Value>,
1021    ) -> RuntimeResult<Option<Note>> {
1022        self.try_create_note_impl(token, kind, name, content, properties, false, None, None)
1023            .await
1024    }
1025
1026    /// Like [`Self::try_create_note`] but permits the caller to establish the
1027    /// transport-owned `message` properties that `try_create_note` refuses.
1028    ///
1029    /// This is a deliberately named, separate entry point rather than a flag
1030    /// on `try_create_note` so the trust decision is visible at every call
1031    /// site: `comm.ingest` (`khive-pack-comm/src/handlers/ingest.rs`) is the sole
1032    /// legitimate caller, because it is the only code that has just derived
1033    /// quarantine disposition and channel provenance from the inbound
1034    /// transport itself. The caller set is bounded by possession, not
1035    /// documentation: the required [`crate::ChannelIngestCapability`] is
1036    /// constructible only inside this crate and granted at pack registration
1037    /// exclusively to channel-transport packs. Every other write path uses
1038    /// `try_create_note`, which rejects those properties
1039    /// unconditionally.
1040    #[allow(clippy::too_many_arguments)]
1041    pub async fn try_create_note_as_trusted_ingest(
1042        &self,
1043        _capability: &crate::pack::ChannelIngestCapability,
1044        token: &NamespaceToken,
1045        kind: &str,
1046        name: Option<&str>,
1047        content: &str,
1048        properties: Option<serde_json::Value>,
1049        expires_after: Option<std::time::Duration>,
1050    ) -> RuntimeResult<Option<Note>> {
1051        self.try_create_note_impl(
1052            token,
1053            kind,
1054            name,
1055            content,
1056            properties,
1057            true,
1058            None,
1059            expires_after,
1060        )
1061        .await
1062    }
1063
1064    /// Publish a trusted inbound message and its original-byte attachment in
1065    /// one database transaction. Channel quarantine must not advertise a
1066    /// reference in note metadata before GC can see its attachment owner.
1067    #[allow(clippy::too_many_arguments)]
1068    pub async fn try_create_note_as_trusted_ingest_with_attachment(
1069        &self,
1070        _capability: &crate::pack::ChannelIngestCapability,
1071        token: &NamespaceToken,
1072        kind: &str,
1073        name: Option<&str>,
1074        content: &str,
1075        properties: Option<serde_json::Value>,
1076        attachment: NewAttachment,
1077        expires_after: Option<std::time::Duration>,
1078    ) -> RuntimeResult<Option<Note>> {
1079        self.try_create_note_impl(
1080            token,
1081            kind,
1082            name,
1083            content,
1084            properties,
1085            true,
1086            Some(attachment),
1087            expires_after,
1088        )
1089        .await
1090    }
1091
1092    #[allow(clippy::too_many_arguments)]
1093    async fn try_create_note_impl(
1094        &self,
1095        token: &NamespaceToken,
1096        kind: &str,
1097        name: Option<&str>,
1098        content: &str,
1099        properties: Option<serde_json::Value>,
1100        allow_transport_owned_message_properties: bool,
1101        attachment: Option<NewAttachment>,
1102        expires_after: Option<std::time::Duration>,
1103    ) -> RuntimeResult<Option<Note>> {
1104        self.validate_note_kind(kind)?;
1105        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1106        crate::secret_gate::check_at(content, "note", "content")?;
1107        if let Some(n) = name {
1108            crate::secret_gate::check_at(n, "note", "name")?;
1109        }
1110        if let Some(ref p) = properties {
1111            crate::secret_gate::check_json_at(p, "note", "properties")?;
1112        }
1113        if let Some(ref attachment) = attachment {
1114            // The note and its owner row must share the main database. A
1115            // secondary pack backend cannot atomically root the reference.
1116            drop(self.attachments()?);
1117            attachment.validate()?;
1118            let blob_store = self.blob_store().ok_or_else(|| {
1119                RuntimeError::Unconfigured(
1120                    "trusted ingest attachment requires an installed BlobStore".to_string(),
1121                )
1122            })?;
1123            if !blob_store.exists(&attachment.content_ref).await? {
1124                return Err(RuntimeError::InvalidInput(format!(
1125                    "trusted ingest attachment refers to an unpublished blob: {}",
1126                    attachment.content_ref
1127                )));
1128            }
1129        }
1130        if !allow_transport_owned_message_properties && kind == "message" {
1131            if let Some(key) = properties
1132                .as_ref()
1133                .and_then(serde_json::Value::as_object)
1134                .and_then(|supplied| {
1135                    crate::curation::kind_owned_properties("message")
1136                        .iter()
1137                        .copied()
1138                        .find(|key| supplied.contains_key(*key))
1139                })
1140            {
1141                return Err(RuntimeError::InvalidInput(format!(
1142                    "`{key}` is transport-owned on a `message` note and cannot be supplied \
1143                     through `try_create_note`; only the trusted channel-ingest path may \
1144                     establish quarantine disposition and channel provenance"
1145                )));
1146            }
1147        }
1148
1149        let ns = token.namespace().as_str();
1150        let mut note = Note::new(ns, kind, content);
1151        if let Some(retention) = expires_after {
1152            let duration_us = i64::try_from(retention.as_micros()).map_err(|_| {
1153                RuntimeError::InvalidInput(
1154                    "trusted ingest retention exceeds i64 microseconds".into(),
1155                )
1156            })?;
1157            note.expires_at = Some(note.created_at.checked_add(duration_us).ok_or_else(|| {
1158                RuntimeError::InvalidInput("trusted ingest expiry exceeds i64 microseconds".into())
1159            })?);
1160        }
1161        if let Some(n) = name {
1162            note = note.with_name(n);
1163        }
1164        if let Some(p) = properties {
1165            note = note.with_properties(p);
1166        }
1167
1168        // Bypasses the `notes()` accessor's PolicyEnforcingNoteStore wrapper —
1169        // the reserved-transport-property check above already enforces the
1170        // identical policy, conditionally allowing the trusted-ingest path,
1171        // so this reaches storage directly rather than duplicate the check
1172        // through a wrapper that cannot see the trust decision this function
1173        // just made.
1174        let inserted = if let Some(attachment) = attachment {
1175            self.raw_notes(token)?
1176                .try_insert_note_with_attachments(
1177                    note.clone(),
1178                    vec![Attachment::from_new(
1179                        note.id,
1180                        AttachmentSubstrate::Note,
1181                        attachment,
1182                        note.created_at,
1183                    )],
1184                )
1185                .await?
1186        } else {
1187            self.raw_notes(token)?.try_insert_note(note.clone()).await?
1188        };
1189        if !inserted {
1190            return Ok(None);
1191        }
1192
1193        let mut degradations = Vec::new();
1194        match self.text_for_notes(token) {
1195            Ok(fts) => {
1196                if let Err(error) = fts.upsert_document(note_fts_document(&note)).await {
1197                    record_conditional_insert_degradation(
1198                        &mut degradations,
1199                        note.id,
1200                        ConditionalInsertStage::FtsUpsert,
1201                        error,
1202                    );
1203                }
1204            }
1205            Err(error) => record_conditional_insert_degradation(
1206                &mut degradations,
1207                note.id,
1208                ConditionalInsertStage::FtsAcquisition,
1209                error,
1210            ),
1211        }
1212
1213        let embed_model_names = self.embedding_models_for_note_kind(kind);
1214        for model_name in &embed_model_names {
1215            match self
1216                .embed_document_with_model_outcome_for_token(
1217                    token,
1218                    model_name,
1219                    note_embedding_text_ref(&note),
1220                )
1221                .await
1222            {
1223                Ok(outcome) => {
1224                    if outcome.truncated {
1225                        tracing::warn!(
1226                            note_id = %note.id,
1227                            model = %outcome.model_name,
1228                            source_bytes = outcome.source_bytes,
1229                            embedded_bytes = outcome.embedded_bytes,
1230                            "try_create_note: embedding input truncated; full content stored unchanged"
1231                        );
1232                    }
1233                    match self.vectors_for_model(token, model_name) {
1234                        Ok(vs) => {
1235                            if let Err(error) = vs
1236                                .insert(
1237                                    note.id,
1238                                    SubstrateKind::Note,
1239                                    ns,
1240                                    "note.content",
1241                                    vec![outcome.vector],
1242                                )
1243                                .await
1244                            {
1245                                record_conditional_insert_degradation(
1246                                    &mut degradations,
1247                                    note.id,
1248                                    ConditionalInsertStage::VectorInsert,
1249                                    format!("model {model_name}: {error}"),
1250                                );
1251                            }
1252                        }
1253                        Err(error) => record_conditional_insert_degradation(
1254                            &mut degradations,
1255                            note.id,
1256                            ConditionalInsertStage::VectorAcquisition,
1257                            format!("model {model_name}: {error}"),
1258                        ),
1259                    }
1260                }
1261                Err(error) => record_conditional_insert_degradation(
1262                    &mut degradations,
1263                    note.id,
1264                    ConditionalInsertStage::Embedding,
1265                    format!("model {model_name}: {error}"),
1266                ),
1267            }
1268        }
1269
1270        legacy_post_commit_result("try_create_note", note.id, Some(note), degradations)
1271    }
1272
1273    // REASON: private inner function unifies all create_note variants; it receives every
1274    // optional parameter individually so that public variants can pass None without
1275    // requiring callers to construct an intermediate struct.
1276    #[allow(clippy::too_many_arguments)]
1277    async fn create_note_inner(
1278        &self,
1279        token: &NamespaceToken,
1280        kind: &str,
1281        name: Option<&str>,
1282        content: &str,
1283        embedding_content: Option<&str>,
1284        salience: Option<f64>,
1285        decay_factor: Option<f64>,
1286        properties: Option<serde_json::Value>,
1287        annotates: Vec<Uuid>,
1288        embedding_model: Option<&str>,
1289        capture_visibility: bool,
1290        web_receipt: bool,
1291    ) -> RuntimeResult<(
1292        Note,
1293        crate::retrieval::EmbeddingTruncationReport,
1294        Vec<PostCommitDegradation>,
1295        Vec<(String, u64)>,
1296    )> {
1297        self.validate_note_kind(kind)?;
1298        // Owned identity properties are derived from the authorization token
1299        // before anything else touches them, so every caller of this function —
1300        // the generic `create` verb and direct Rust callers alike — stores the
1301        // same derived values. Runs before the secret gate so the gate scans
1302        // exactly what will be written.
1303        let mut properties = self.derive_note_write_properties(kind, token, properties)?;
1304        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
1305        if web_receipt {
1306            let map = properties
1307                .as_mut()
1308                .and_then(serde_json::Value::as_object_mut)
1309                .expect("web receipt properties are constructed as an object");
1310            map.insert(
1311                crate::secret_gate::RESERVED_WEB_RECEIPT_KEY.to_string(),
1312                serde_json::Value::String(
1313                    crate::secret_gate::WEB_RECEIPT_PROVENANCE_VALUE.to_string(),
1314                ),
1315            );
1316        }
1317        // Secret gate: scan content, optional name, and structured properties.
1318        crate::secret_gate::check_at(content, "note", "content")?;
1319        if let Some(n) = name {
1320            crate::secret_gate::check_at(n, "note", "name")?;
1321        }
1322        if let Some(ref p) = properties {
1323            crate::secret_gate::check_json_at(p, "note", "properties")?;
1324        }
1325        // `embedding_content` is a caller-supplied alternate vector-embedding
1326        // input: it must be a non-empty proper prefix of `content` (never a
1327        // superset, an unrelated string, or the full text) and passes the
1328        // same secret gate as any other stored/embedded text. Rejected
1329        // before any write, same as the checks above.
1330        if let Some(ec) = embedding_content {
1331            if ec.is_empty() {
1332                return Err(RuntimeError::InvalidInput(
1333                    "embedding_content must not be empty".into(),
1334                ));
1335            }
1336            if ec.len() >= content.len() || !content.starts_with(ec) {
1337                return Err(RuntimeError::InvalidInput(
1338                    "embedding_content must be a proper prefix of content".into(),
1339                ));
1340            }
1341            crate::secret_gate::check_at(ec, "note", "embedding_content")?;
1342        }
1343        let ns = token.namespace().as_str();
1344
1345        // Validate all annotates targets before any write (atomicity: all-or-nothing).
1346        // Endpoint resolution is by-ID and namespace-agnostic.
1347        for &target_id in &annotates {
1348            if !self.substrate_exists_by_id(token, target_id).await? {
1349                return Err(RuntimeError::NotFound(format!(
1350                    "create_note annotates target {target_id} not found"
1351                )));
1352            }
1353        }
1354
1355        // Reject non-finite or out-of-range salience/decay at the runtime boundary
1356        // rather than letting storage silently clamp them (coding-standards §508-516).
1357        if let Some(s) = salience {
1358            if !s.is_finite() || !(0.0..=1.0).contains(&s) {
1359                return Err(RuntimeError::InvalidInput(format!(
1360                    "salience must be a finite value in [0.0, 1.0]; got {s}"
1361                )));
1362            }
1363        }
1364        if let Some(d) = decay_factor {
1365            if !d.is_finite() || d < 0.0 {
1366                return Err(RuntimeError::InvalidInput(format!(
1367                    "decay_factor must be a finite value >= 0.0; got {d}"
1368                )));
1369            }
1370        }
1371
1372        // Resolve embedding_model BEFORE any note/FTS/vector write so unknown-model
1373        // errors are atomic at the runtime layer, not just at one pack handler.
1374        // Direct Rust callers (other packs, integration tests) get the same guarantee.
1375        if let Some(model_name) = embedding_model {
1376            self.resolve_embedding_model(Some(model_name))?;
1377        }
1378
1379        let mut note = Note::new(ns, kind, content);
1380        if let Some(s) = salience {
1381            note = note.with_salience(s);
1382        }
1383        if let Some(df) = decay_factor {
1384            note = note.with_decay(df);
1385        }
1386        if let Some(n) = name {
1387            note = note.with_name(n);
1388        }
1389        if let Some(p) = properties {
1390            note = note.with_properties(p);
1391        }
1392        let notes = if web_receipt {
1393            self.raw_notes(token)?
1394        } else {
1395            self.notes(token)?
1396        };
1397        notes.upsert_note(note.clone()).await?;
1398
1399        // From here on, any error must compensate by removing the note row, its
1400        // FTS document, and any vector entries already inserted — the same
1401        // cleanup used by the annotates-edge block below.
1402
1403        // Decide which embedding models to use (before touching FTS/vectors).
1404        let embed_model_names: Vec<String> = if let Some(m) = embedding_model {
1405            vec![m.to_string()]
1406        } else {
1407            self.embedding_models_for_note_kind(kind)
1408        };
1409
1410        // FTS step — compensate note row on failure.
1411        {
1412            // Injection: check FTS_FAIL_NS (armed by `arm_fts_fail_scoped(ns)`).
1413            // Fires only when `ns` is in the armed set, removing it on the way
1414            // out (one-shot, atomic check-and-remove under the mutex). No lock
1415            // acquisition in release builds — the cfg(not) branch is a const
1416            // false so the compiler eliminates the if-branch entirely.
1417            #[cfg(any(test, feature = "fault-injection"))]
1418            let fts_inject = consume_fault(&FTS_FAIL_NS, ns);
1419            #[cfg(not(any(test, feature = "fault-injection")))]
1420            let fts_inject = false;
1421            let fts_result: RuntimeResult<()> = if fts_inject {
1422                Err(RuntimeError::Internal("injected FTS failure".to_string()))
1423            } else {
1424                let statements =
1425                    khive_db::stores::text::delete_document_statements("fts_notes", ns, note.id)
1426                        .into_iter()
1427                        .chain(khive_db::stores::text::insert_document_statements(
1428                            "fts_notes",
1429                            &note_fts_document(&note),
1430                        ))
1431                        .collect();
1432                self.apply_note_index_revision(&note, statements)
1433                    .await
1434                    .map(|_| ())
1435            };
1436
1437            if let Err(e) = fts_result {
1438                self.compensate_note_creation(&note).await;
1439                return Err(e);
1440            }
1441        }
1442
1443        // Vector embedding + insert step — compensate note row + FTS doc on failure.
1444        // Multi-model vector embedding:
1445        //   - explicit embedding_model → single model (existing behaviour)
1446        //   - None → the note kind's declared model policy
1447        //   - None + no models configured → skip (text-only)
1448        // The effective text sent to every embedder: the caller-supplied
1449        // capped override when present, otherwise the full stored content.
1450        // FTS indexing above always used the full `note.content` — this cap
1451        // affects only the vector-embedding input.
1452        let canonical_embed_text = note_embedding_text_ref(&note);
1453        let embed_text = embedding_content.unwrap_or(canonical_embed_text);
1454
1455        let mut embedding_report = crate::retrieval::EmbeddingTruncationReport::default();
1456        let mut vector_fences = Vec::with_capacity(embed_model_names.len());
1457        if embed_model_names.len() == 1 {
1458            // Single-model path: preserves original sequential behaviour.
1459            let model_name = &embed_model_names[0];
1460            let vec_result = self
1461                .embed_document_with_model_outcome_for_token(token, model_name, embed_text)
1462                .await;
1463
1464            // Injection: check VECTOR_FAIL_NS (armed by `arm_vector_fail_scoped(ns)`) or
1465            // VECTOR_FAIL_AFTER (armed by `arm_vector_fail_after(n)`). The former
1466            // fires only when the armed namespace matches this note's namespace;
1467            // callers that cannot guarantee no concurrently-running test also
1468            // writes a note into that same namespace (e.g. a test suite whose
1469            // fixtures share one default namespace) should prefer the latter,
1470            // thread-local count instead — see its doc comment. Either clears
1471            // (one-shot) once it fires. No lock/cell access in release builds —
1472            // the cfg(not) branch is a const false eliminating the if-branch.
1473            #[cfg(any(test, feature = "fault-injection"))]
1474            let vec_inject = {
1475                let ns_inject = consume_fault(&VECTOR_FAIL_NS, ns);
1476                let count_inject = VECTOR_FAIL_AFTER.with(|cell| match cell.get() {
1477                    Some(0) => {
1478                        cell.set(None);
1479                        true
1480                    }
1481                    Some(n) => {
1482                        cell.set(Some(n - 1));
1483                        false
1484                    }
1485                    None => false,
1486                });
1487                ns_inject || count_inject
1488            };
1489            #[cfg(not(any(test, feature = "fault-injection")))]
1490            let vec_inject = false;
1491            let vec_result: RuntimeResult<crate::retrieval::DocumentEmbeddingOutcome> =
1492                if vec_inject {
1493                    Err(RuntimeError::Internal(
1494                        "injected vector failure".to_string(),
1495                    ))
1496                } else {
1497                    vec_result
1498                };
1499
1500            let single_model_result: RuntimeResult<()> = match vec_result {
1501                Ok(outcome) => {
1502                    embedding_report.observe(&outcome);
1503                    if capture_visibility {
1504                        match self
1505                            .publish_note_vector_revision_with_seq(
1506                                token,
1507                                &note,
1508                                model_name,
1509                                &outcome.vector,
1510                            )
1511                            .await
1512                        {
1513                            Ok(Some(seq)) => {
1514                                vector_fences.push((model_name.clone(), seq));
1515                                Ok(())
1516                            }
1517                            Ok(None) => Ok(()),
1518                            Err(error) => Err(error),
1519                        }
1520                    } else {
1521                        self.publish_note_vector_revision(token, &note, model_name, &outcome.vector)
1522                            .await
1523                            .map(|_| ())
1524                    }
1525                }
1526                Err(e) => Err(e),
1527            };
1528            if let Err(e) = single_model_result {
1529                self.compensate_note_creation(&note).await;
1530                return Err(e);
1531            }
1532        } else if !embed_model_names.is_empty() {
1533            // Multi-model path: embed with each model in parallel via spawned tasks,
1534            // then insert one VectorRecord per model.
1535            let rt_clone = self.clone();
1536            // JoinSet tasks require owned text; an Arc keeps this to one
1537            // content-sized allocation rather than one clone per model.
1538            let content_owned: std::sync::Arc<str> = std::sync::Arc::from(embed_text);
1539            let usage_ctx = crate::usage::current();
1540            let mut join_set = tokio::task::JoinSet::new();
1541            for (idx, model_name) in embed_model_names.iter().enumerate() {
1542                let rt = rt_clone.clone();
1543                let text = std::sync::Arc::clone(&content_owned);
1544                let name = model_name.clone();
1545                let ctx = usage_ctx.clone();
1546                let token = (*token).clone();
1547                join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
1548                    let fut = rt.embed_document_with_model_outcome_for_token(
1549                        &token,
1550                        &name,
1551                        text.as_ref(),
1552                    );
1553                    let result = match ctx {
1554                        Some(ctx) => crate::usage::scope(ctx, fut).await,
1555                        None => fut.await,
1556                    };
1557                    (idx, result)
1558                }));
1559            }
1560            // The first failed or panicked handle aborts and detaches its
1561            // siblings. Embed usage is counted at dispatch, so a synchronous
1562            // provider winding down in the background cannot change it.
1563            let outcomes = match drain_embed_join_set(join_set, embed_model_names.len()).await {
1564                Ok(outcomes) => outcomes,
1565                Err(e) => {
1566                    self.compensate_note_creation(&note).await;
1567                    return Err(e);
1568                }
1569            };
1570            // TODO(P2): parallelize vector inserts
1571            for (model_name, outcome) in embed_model_names.iter().zip(outcomes) {
1572                embedding_report.observe(&outcome);
1573                let insert_result = if capture_visibility {
1574                    self.publish_note_vector_revision_with_seq(
1575                        token,
1576                        &note,
1577                        model_name,
1578                        &outcome.vector,
1579                    )
1580                    .await
1581                    .map(|seq| {
1582                        if let Some(seq) = seq {
1583                            vector_fences.push((model_name.clone(), seq));
1584                        }
1585                    })
1586                } else {
1587                    self.publish_note_vector_revision(token, &note, model_name, &outcome.vector)
1588                        .await
1589                        .map(|_| ())
1590                };
1591                if let Err(e) = insert_result {
1592                    self.compensate_note_creation(&note).await;
1593                    return Err(e);
1594                }
1595            }
1596        }
1597
1598        // Create annotates edges, compensating on failure to preserve atomicity.
1599        // Pre-validation (above) ensures all targets exist, so link failures are
1600        // unexpected. If one occurs, purge incident edges and the note in one
1601        // transaction; a failed purge retains the live note as their source.
1602        let mut created_edges: Vec<Uuid> = Vec::with_capacity(annotates.len());
1603
1604        // In test builds, iterate with an index so the failure-injection hook can
1605        // target a specific call.  In release builds, skip the enumerate overhead.
1606        #[cfg(test)]
1607        let annotates_iter: Vec<(usize, Uuid)> = annotates
1608            .iter()
1609            .enumerate()
1610            .map(|(i, &id)| (i, id))
1611            .collect();
1612        #[cfg(test)]
1613        macro_rules! next_target {
1614            ($pair:expr) => {
1615                $pair.1
1616            };
1617        }
1618        #[cfg(not(test))]
1619        let annotates_iter: Vec<Uuid> = annotates.to_vec();
1620        #[cfg(not(test))]
1621        macro_rules! next_target {
1622            ($pair:expr) => {
1623                $pair
1624            };
1625        }
1626
1627        for pair in annotates_iter {
1628            let target_id = next_target!(pair);
1629
1630            // Test-only: inject a failure on the configured call index (1-based).
1631            #[cfg(test)]
1632            let injected_err: Option<RuntimeError> = {
1633                let call_idx = pair.0;
1634                LINK_FAIL_AFTER.with(|cell| {
1635                    let n = cell.get();
1636                    if n > 0 && call_idx + 1 == n {
1637                        cell.set(0); // reset so subsequent calls are unaffected
1638                        Some(RuntimeError::Internal("injected link failure".to_string()))
1639                    } else {
1640                        None
1641                    }
1642                })
1643            };
1644            #[cfg(not(test))]
1645            let injected_err: Option<RuntimeError> = None;
1646
1647            let link_result = if let Some(e) = injected_err {
1648                Err(e)
1649            } else {
1650                self.link(
1651                    token,
1652                    note.id,
1653                    target_id,
1654                    EdgeRelation::Annotates,
1655                    1.0,
1656                    None,
1657                )
1658                .await
1659            };
1660
1661            match link_result {
1662                Ok(edge) => created_edges.push(edge.id.into()),
1663                Err(e) => {
1664                    // The graph purge, index cleanup, and note removal share
1665                    // one writer transaction. If the edge purge fails, its
1666                    // transaction rolls back, preserving a live source note
1667                    // for the surviving edges instead of orphaning them.
1668                    let edge_ids = created_edges
1669                        .iter()
1670                        .map(Uuid::to_string)
1671                        .collect::<Vec<_>>()
1672                        .join(", ");
1673                    match self.compensate_note_creation_with_edges(&note).await {
1674                        Ok(true) => return Err(e),
1675                        Ok(false) => {
1676                            return Err(RuntimeError::Internal(format!(
1677                                "create_note: annotates link failed: {e}; note {} changed before \
1678                                 compensation, retaining its incident edges [{edge_ids}]",
1679                                note.id
1680                            )));
1681                        }
1682                        Err(cleanup_error) => {
1683                            return Err(RuntimeError::Internal(format!(
1684                                "create_note: annotates link failed: {e}; compensation failed \
1685                                 for note {} and retained edges [{edge_ids}]: {cleanup_error}; \
1686                                 note and edges remain for reconciliation",
1687                                note.id
1688                            )));
1689                        }
1690                    }
1691                }
1692            }
1693        }
1694
1695        // Same contract as the entity arrival event above: after compensation,
1696        // so a rolled-back create leaves no event. This is the single funnel for
1697        // every note create in the product, which is why the memory pack's own
1698        // note_created emitter was removed rather than left beside it.
1699        let created_event = khive_storage::event::Event::new(
1700            note.namespace.clone(),
1701            "create",
1702            EventKind::NoteCreated,
1703            SubstrateKind::Note,
1704            "",
1705        )
1706        .with_target(note.id)
1707        .with_payload(serde_json::json!({
1708            "id": note.id,
1709            "namespace": note.namespace,
1710            "kind": note.kind,
1711            "salience": note.salience,
1712        }));
1713        let event_result = match self.events(token) {
1714            Ok(store) => store
1715                .append_event(created_event)
1716                .await
1717                .map_err(RuntimeError::from),
1718            Err(error) => Err(error),
1719        };
1720        let mut degradations = Vec::new();
1721        if let Err(error) = event_result {
1722            record_post_commit_degradation(
1723                &mut degradations,
1724                "create_note",
1725                note.id,
1726                "event_append",
1727                error,
1728            );
1729        }
1730
1731        vector_fences.sort_by(|a, b| a.0.cmp(&b.0));
1732        Ok((note, embedding_report, degradations, vector_fences))
1733    }
1734
1735    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
1736    ///
1737    /// Pipeline:
1738    /// 1. FTS5 query against `notes_<namespace>`.
1739    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
1740    /// 3. RRF fusion (k=60).
1741    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
1742    /// 5. Filter soft-deleted notes, apply optional kind / tag / properties predicates.
1743    ///    Tags and properties are pushed into the per-note fetch loop BEFORE truncation
1744    ///    so that matching notes ranked beyond `limit` in the raw fusion are not silently
1745    ///    dropped.
1746    /// 6. Truncate to `limit`.
1747    ///
1748    /// `tags_any`: when non-empty, only notes that have at least one of these tags
1749    /// (stored in `properties["tags"]`, case-insensitive match) are retained. The
1750    /// check happens inside the alive-note loop, before `hits.truncate(limit)`.
1751    ///
1752    /// `properties_filter`: when `Some`, only notes whose `properties` JSON object is
1753    /// a superset of the given filter object are retained. Also applied before truncation.
1754    #[allow(clippy::too_many_arguments)]
1755    pub async fn search_notes(
1756        &self,
1757        token: &NamespaceToken,
1758        query_text: &str,
1759        query_vector: Option<Vec<f32>>,
1760        limit: u32,
1761        note_kind: Option<&str>,
1762        include_superseded: bool,
1763        tags_any: &[String],
1764        properties_filter: Option<&serde_json::Value>,
1765    ) -> RuntimeResult<Vec<NoteSearchHit>> {
1766        self.search_notes_with_text_mode(
1767            token,
1768            query_text,
1769            query_vector,
1770            limit,
1771            note_kind,
1772            include_superseded,
1773            tags_any,
1774            properties_filter,
1775            TextQueryMode::Plain,
1776        )
1777        .await
1778    }
1779
1780    /// Note search with an explicit lexical mode for the text arm.
1781    #[allow(clippy::too_many_arguments)]
1782    pub async fn search_notes_with_text_mode(
1783        &self,
1784        token: &NamespaceToken,
1785        query_text: &str,
1786        query_vector: Option<Vec<f32>>,
1787        limit: u32,
1788        note_kind: Option<&str>,
1789        include_superseded: bool,
1790        tags_any: &[String],
1791        properties_filter: Option<&serde_json::Value>,
1792        text_mode: TextQueryMode,
1793    ) -> RuntimeResult<Vec<NoteSearchHit>> {
1794        let (hits, _vector_error) = self
1795            .search_notes_inner(
1796                token,
1797                query_text,
1798                query_vector,
1799                limit,
1800                note_kind,
1801                include_superseded,
1802                tags_any,
1803                properties_filter,
1804                text_mode,
1805                false,
1806            )
1807            .await?;
1808        Ok(hits)
1809    }
1810
1811    /// Coordinator fan-out variant of [`Self::search_notes`]: the text arm
1812    /// still fails loud, but a vector-arm failure after a successful text leg
1813    /// is captured instead of discarding the text hits — mirrors
1814    /// [`Self::hybrid_search_outcome`]'s contract for the entity substrate.
1815    /// Reserved for `SubstrateCoordinator::fan_out_search_with_visibility`;
1816    /// every other caller keeps the fail-loud [`Self::search_notes`] contract.
1817    #[allow(clippy::too_many_arguments)]
1818    pub async fn search_notes_outcome(
1819        &self,
1820        token: &NamespaceToken,
1821        query_text: &str,
1822        limit: u32,
1823        note_kind: Option<&str>,
1824        include_superseded: bool,
1825        tags_any: &[String],
1826        properties_filter: Option<&serde_json::Value>,
1827    ) -> RuntimeResult<NoteSearchOutcome> {
1828        self.search_notes_outcome_with_text_mode(
1829            token,
1830            query_text,
1831            limit,
1832            note_kind,
1833            include_superseded,
1834            tags_any,
1835            properties_filter,
1836            TextQueryMode::Plain,
1837        )
1838        .await
1839    }
1840
1841    /// Coordinator note-search variant with an explicit lexical mode.
1842    #[allow(clippy::too_many_arguments)]
1843    pub async fn search_notes_outcome_with_text_mode(
1844        &self,
1845        token: &NamespaceToken,
1846        query_text: &str,
1847        limit: u32,
1848        note_kind: Option<&str>,
1849        include_superseded: bool,
1850        tags_any: &[String],
1851        properties_filter: Option<&serde_json::Value>,
1852        text_mode: TextQueryMode,
1853    ) -> RuntimeResult<NoteSearchOutcome> {
1854        let (hits, vector_error) = self
1855            .search_notes_inner(
1856                token,
1857                query_text,
1858                None,
1859                limit,
1860                note_kind,
1861                include_superseded,
1862                tags_any,
1863                properties_filter,
1864                text_mode,
1865                true,
1866            )
1867            .await?;
1868        Ok(NoteSearchOutcome { hits, vector_error })
1869    }
1870
1871    #[allow(clippy::too_many_arguments)]
1872    async fn search_notes_inner(
1873        &self,
1874        token: &NamespaceToken,
1875        query_text: &str,
1876        query_vector: Option<Vec<f32>>,
1877        limit: u32,
1878        note_kind: Option<&str>,
1879        include_superseded: bool,
1880        tags_any: &[String],
1881        properties_filter: Option<&serde_json::Value>,
1882        text_mode: TextQueryMode,
1883        tolerate_vector_error: bool,
1884    ) -> RuntimeResult<(Vec<NoteSearchHit>, Option<String>)> {
1885        const RRF_K: usize = 60;
1886        let candidates = limit.saturating_mul(4).max(limit);
1887        let visible_ns: Vec<String> = token
1888            .visible_namespaces()
1889            .iter()
1890            .map(|ns| ns.as_str().to_owned())
1891            .collect();
1892
1893        // FTS5 over the notes index — search all visible namespaces.
1894        //
1895        // `sanitize_fts5_query` strips known-unsafe FTS5 metacharacters, but
1896        // residual punctuation the sanitizer does not strip can still reach
1897        // the FTS5 parser and error. This fails loud instead of degrading to
1898        // vector-only fusion, so callers see the bad query instead of
1899        // silently losing the lexical leg. Errors from any other leg (vector
1900        // search, note hydration) still propagate normally.
1901        //
1902        // Injection: check FTS_SEARCH_FAIL_NS (armed by `arm_fts_search_fail(ns)`),
1903        // exercising the propagate branch above. Fires only when the armed
1904        // namespace is among this call's visible namespaces, then clears (one-shot).
1905        #[cfg(any(test, feature = "fault-injection"))]
1906        let fts_search_inject = {
1907            let mut g = FTS_SEARCH_FAIL_NS.lock().unwrap();
1908            match g.as_deref() {
1909                Some(armed) if visible_ns.iter().any(|ns| ns == armed) => {
1910                    *g = None;
1911                    true
1912                }
1913                _ => false,
1914            }
1915        };
1916        #[cfg(not(any(test, feature = "fault-injection")))]
1917        let fts_search_inject = false;
1918
1919        let text_store = self.text_for_notes(token)?;
1920        let text_fut = async {
1921            if fts_search_inject {
1922                return Err(khive_storage::StorageError::Timeout {
1923                    operation: "fts_search".into(),
1924                });
1925            }
1926            text_store
1927                .search(TextSearchRequest {
1928                    query: query_text.to_string(),
1929                    mode: text_mode,
1930                    filter: Some(TextFilter {
1931                        namespaces: visible_ns.clone(),
1932                        // Push the note-kind filter into the FTS query. Without it the
1933                        // text arm returns the top `candidates` rows across EVERY note
1934                        // kind in the namespace and the kind is applied post-fetch, so a
1935                        // store where short message/session rows outrank task
1936                        // descriptions under BM25 hands the caller one or two task hits
1937                        // while the store holds many more carrying the literal.
1938                        record_kinds: note_kind
1939                            .map(|kind| vec![kind.to_string()])
1940                            .unwrap_or_default(),
1941                        ..TextFilter::default()
1942                    }),
1943                    top_k: candidates,
1944                    snippet_chars: 200,
1945                })
1946                .await
1947        };
1948        let text_fut = crate::stage_seam::text_stage(text_fut);
1949
1950        // Vector search filtered to notes; it runs with the text stage, and a text error wins.
1951        let vector_fut = async {
1952            if query_vector.is_some() || self.config().embedding_model.is_some() {
1953                self.note_search_vector_search(token, query_vector, query_text, candidates)
1954                    .await
1955            } else {
1956                Ok(vec![])
1957            }
1958        };
1959        let (text_search_result, vector_result) = tokio::join!(text_fut, vector_fut);
1960
1961        // FtsPasses is counted inside the store's `search()` (khive-db
1962        // stores/text.rs), only once a real FTS5 statement is prepared —
1963        // an empty/fully-sanitized query short-circuits there before any
1964        // statement exists and must not count (nor does the injected-failure
1965        // branch above, which never reaches the store at all).
1966        let text_hits = crate::error::fts_text_leg_or_err(
1967            text_search_result.map_err(RuntimeError::from),
1968            "search_notes",
1969            query_text,
1970        )?;
1971
1972        let mut vector_error: Option<String> = None;
1973        let vector_hits = match vector_result {
1974            Ok(hits) => hits,
1975            Err(e) if tolerate_vector_error => {
1976                vector_error = Some(e.to_string());
1977                Vec::new()
1978            }
1979            Err(e) => return Err(e),
1980        };
1981
1982        // Keep the full text∪vector union through RRF — salience weighting and
1983        // soft-delete/kind filtering happen *after* this, and the final
1984        // `hits.truncate(limit)` is the only result-limiting cut. Truncating to
1985        // `candidates` here would drop a high-salience note ranked just outside
1986        // the raw RRF cutoff before salience ever applied.
1987        let fuse_k = text_hits.len() + vector_hits.len();
1988        let fused = self
1989            .fuse_with_strategy(
1990                text_hits,
1991                vector_hits,
1992                &crate::fusion::FusionStrategy::Rrf { k: RRF_K },
1993                fuse_k,
1994            )
1995            .await?;
1996
1997        let candidate_ids: Vec<Uuid> = fused.iter().map(|hit| hit.entity_id).collect();
1998        if candidate_ids.is_empty() {
1999            return Ok((vec![], vector_error));
2000        }
2001
2002        // Hydrate every candidate note with one batched read to get salience and
2003        // apply soft-delete + (optional) kind filtering. The store chunks the read
2004        // below its bound-parameter ceiling, so the read costs `ceil(candidates / 900)`
2005        // statements instead of one per candidate. Notes whose `kind` doesn't
2006        // match `note_kind` are dropped post-fetch — they're a small set
2007        // bounded by the text∪vector union (≤ 2×candidates), so the read is cheap.
2008        let note_store = self.notes(token)?;
2009        let search_pool = self.backend().pool_arc();
2010        let mailbox_view = crate::MailboxView {
2011            actor_id: token.actor().id.clone(),
2012            delegated: false,
2013        };
2014        let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
2015        for note in note_store.get_notes_batch(&candidate_ids).await? {
2016            search_pool.record_note_candidate_hydration_row();
2017            if note.deleted_at.is_some() {
2018                continue;
2019            }
2020            if !mailbox_view.permits_message_note(token, &note) {
2021                continue;
2022            }
2023            if let Some(want_kind) = note_kind {
2024                if note.kind != want_kind {
2025                    continue;
2026                }
2027            }
2028            // Apply tag predicate before adding to alive set: tags on notes live
2029            // inside `properties["tags"]` (a JSON array). This pushes the filter
2030            // before truncation so matching notes ranked beyond `limit` in the raw
2031            // fusion are not silently dropped.
2032            if !tags_any.is_empty() {
2033                let note_tags: Vec<String> = note
2034                    .properties
2035                    .as_ref()
2036                    .and_then(|p| p.get("tags"))
2037                    .and_then(serde_json::Value::as_array)
2038                    .map(|arr| {
2039                        arr.iter()
2040                            .filter_map(serde_json::Value::as_str)
2041                            .map(str::to_owned)
2042                            .collect()
2043                    })
2044                    .unwrap_or_default();
2045                if !note_tags
2046                    .iter()
2047                    .any(|t| tags_any.iter().any(|w| t.eq_ignore_ascii_case(w)))
2048                {
2049                    continue;
2050                }
2051            }
2052            // Apply properties predicate before truncation, same reasoning as tags above.
2053            if let Some(pf) = properties_filter {
2054                if !crate::retrieval::properties_match(note.properties.as_ref(), pf) {
2055                    continue;
2056                }
2057            }
2058            alive_notes.insert(note.id, note);
2059        }
2060
2061        // Drop superseded notes unless include_superseded is true: any note targeted
2062        // by a `supersedes` edge is obsolete and excluded from default search.
2063        if !include_superseded && !alive_notes.is_empty() {
2064            let graph = self.graph(token)?;
2065            let note_ids: Vec<Uuid> = alive_notes.keys().copied().collect();
2066            let superseded: std::collections::HashSet<Uuid> = graph
2067                .batch_neighbors(
2068                    &note_ids,
2069                    NeighborQuery {
2070                        direction: Direction::In,
2071                        relations: Some(vec![EdgeRelation::Supersedes]),
2072                        limit: Some(1),
2073                        min_weight: None,
2074                    },
2075                )
2076                .await?
2077                .into_iter()
2078                .map(|(note_id, _)| note_id)
2079                .collect();
2080            alive_notes.retain(|id, _| !superseded.contains(id));
2081        }
2082
2083        // Apply salience weighting and collect final hits.
2084        let mut hits: Vec<NoteSearchHit> = fused
2085            .into_iter()
2086            .filter_map(|hit| {
2087                let note = alive_notes.get(&hit.entity_id)?;
2088                let weighted = salience_weighted_rank(hit.score, note.salience);
2089                Some(NoteSearchHit {
2090                    note_id: hit.entity_id,
2091                    score: weighted,
2092                    rank_score_kind: hit.rank_score_kind,
2093                    signals: hit.signals,
2094                    source: hit.source,
2095                    title: hit.title.or_else(|| note_title(note)),
2096                    snippet: hit.snippet.or_else(|| note_snippet(note)),
2097                })
2098            })
2099            .collect();
2100
2101        hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
2102        hits.truncate(limit as usize);
2103        Ok((hits, vector_error))
2104    }
2105
2106    /// Restore a note tombstone owned by the caller's primary namespace.
2107    ///
2108    /// A live note holding the tombstone's `(namespace, kind, key)` refuses
2109    /// the operation before any row changes. The same condition is repeated
2110    /// in the guarded restore statement for the concurrent race.
2111    pub async fn restore_note(
2112        &self,
2113        token: &NamespaceToken,
2114        id: Uuid,
2115    ) -> RuntimeResult<Option<(Note, bool)>> {
2116        let Some(note) = self.notes(token)?.get_note_including_deleted(id).await? else {
2117            return Ok(None);
2118        };
2119        if note.namespace != token.namespace().as_str() {
2120            return Ok(None);
2121        }
2122        if note.deleted_at.is_none() {
2123            return Ok(Some((note, false)));
2124        }
2125        if let Some(key) = note.key.as_deref() {
2126            if let Some(holder) = self
2127                .notes(token)?
2128                .get_live_notes_by_key(&note.namespace, key, Some(&note.kind))
2129                .await?
2130                .into_iter()
2131                .find(|holder| holder.id != note.id)
2132            {
2133                return Err(restore_key_conflict(key, &holder));
2134            }
2135        }
2136        let updated_at =
2137            Utc::now()
2138                .timestamp_micros()
2139                .max(note.updated_at.checked_add(1).ok_or_else(|| {
2140                    RuntimeError::Internal(format!(
2141                        "note {id} updated_at is already at i64::MAX and cannot advance"
2142                    ))
2143                })?);
2144        let mut params = vec![
2145            SqlValue::Text("active".into()),
2146            SqlValue::Integer(updated_at),
2147            SqlValue::Text(id.to_string()),
2148            SqlValue::Text(note.namespace.clone()),
2149            SqlValue::Text(note.kind.clone()),
2150        ];
2151        let key_clause = if let Some(key) = note.key.as_deref() {
2152            params.push(SqlValue::Text(key.to_owned()));
2153            format!(
2154                " AND (key IS NULL OR NOT EXISTS (SELECT 1 FROM notes live \
2155                          WHERE live.namespace=?4 AND live.kind=?5 AND live.key=?{} \
2156                            AND live.deleted_at IS NULL AND live.id != notes.id))",
2157                params.len()
2158            )
2159        } else {
2160            String::new()
2161        };
2162        let mut restored = note.clone();
2163        restored.status = "active".into();
2164        restored.deleted_at = None;
2165        restored.updated_at = updated_at;
2166        restored.version = restored
2167            .version
2168            .checked_add(1)
2169            .ok_or_else(|| RuntimeError::Internal(format!("note {id} version is exhausted")))?;
2170        let mut statements = vec![PlanStatement {
2171            statement: SqlStatement {
2172                sql: format!(
2173                    "UPDATE notes SET status=?1, deleted_at=NULL, updated_at=?2 \
2174                     WHERE id=?3 AND namespace=?4 AND kind=?5 AND deleted_at IS NOT NULL{key_clause}"
2175                ),
2176                params,
2177                label: Some("note-restore".into()),
2178            },
2179            guard: Some(AffectedRowGuard::exactly(1)),
2180        }];
2181        // Text index published in the same unit as the row; see restore_entity.
2182        for statement in
2183            khive_db::stores::text::delete_document_statements("fts_notes", &restored.namespace, id)
2184                .into_iter()
2185                .chain(insert_document_statements(
2186                    "fts_notes",
2187                    &note_fts_document(&restored),
2188                ))
2189        {
2190            statements.push(PlanStatement {
2191                statement,
2192                guard: None,
2193            });
2194        }
2195        let plan = AtomicOpPlan::Update(Box::new(UpdatePlan {
2196            graph_effects: Vec::new(),
2197            target_id: id,
2198            statements,
2199            post_commit: PostCommitEffect::None,
2200            edge_natural_key: None,
2201            idempotent_noop: false,
2202            entity_guard: None,
2203            note_guard: None,
2204            note_vector_purge: None,
2205            note_embedding_inheritance: None,
2206        }));
2207        match run_atomic_unit(self.sql().as_ref(), vec![plan]).await {
2208            Ok(AtomicRunOutcome::Committed { .. }) => {
2209                #[cfg(any(test, feature = "fault-injection"))]
2210                if consume_fault(&FTS_FAIL_NS, &restored.namespace) {
2211                    return Err(restore_reindex_failed(
2212                        "note",
2213                        id,
2214                        RuntimeError::Internal("injected FTS failure".to_string()),
2215                    ));
2216                }
2217                let reindexed = self.reindex_note_with_report(token, &restored).await;
2218                let report = reindexed.map_err(|e| restore_reindex_failed("note", id, e))?;
2219                let degradations = report.post_commit_degradations();
2220                legacy_post_commit_result("restore_note", id, Some((restored, true)), degradations)
2221            }
2222            Ok(AtomicRunOutcome::RolledBack {
2223                failure: AtomicOpFailure::GuardFailed { .. },
2224                ..
2225            }) => {
2226                if let Some(key) = note.key.as_deref() {
2227                    if let Some(holder) = self
2228                        .notes(token)?
2229                        .get_live_notes_by_key(&note.namespace, key, Some(&note.kind))
2230                        .await?
2231                        .into_iter()
2232                        .find(|holder| holder.id != note.id)
2233                    {
2234                        return Err(restore_key_conflict(key, &holder));
2235                    }
2236                }
2237                Err(RuntimeError::NotFound(format!(
2238                    "note {id} is no longer a caller-owned tombstone"
2239                )))
2240            }
2241            Ok(AtomicRunOutcome::RolledBack { failure, .. }) => Err(RuntimeError::Internal(
2242                format!("note restore rolled back: {failure:?}"),
2243            )),
2244            Err(error) => Err(RuntimeError::Storage(error.0)),
2245        }
2246    }
2247}
2248
2249/// Fully specified edge creation request — input to [`KhiveRuntime::build_edge`]
2250/// and [`KhiveRuntime::link_many`].
2251#[derive(Clone, Debug)]
2252pub struct LinkSpec {
2253    pub namespace: Option<String>,
2254    pub source_id: Uuid,
2255    pub target_id: Uuid,
2256    pub relation: EdgeRelation,
2257    pub weight: f64,
2258    pub metadata: Option<serde_json::Value>,
2259    pub resurrect: bool,
2260}
2261
2262/// Fully specified entity creation request — input to [`KhiveRuntime::create_many`].
2263///
2264/// `entity_type` is validated at the runtime layer by the pack-installed
2265/// entity-type validator. When a validator
2266/// is installed (e.g. by `KgPack`), unknown types are rejected with the valid
2267/// set listed. When no validator is installed (bare runtime without packs),
2268/// the value passes through — the handler layer is the primary enforcement point.
2269#[derive(Clone, Debug)]
2270pub struct EntityCreateSpec {
2271    pub kind: String,
2272    pub entity_type: Option<String>,
2273    pub name: String,
2274    pub description: Option<String>,
2275    pub properties: Option<serde_json::Value>,
2276    pub tags: Vec<String>,
2277}
2278
2279// INLINE TEST JUSTIFICATION: tests here exercise private helpers (canonical_edge_endpoints,
2280// validate_edge_metadata, merge_dependency_kind, link-fail injection) and runtime methods
2281// that require pub(crate) KhiveRuntime construction. Moving them to tests/ would require
2282// pub-exporting those private helpers, which would widen the crate's public API surface
2283// undesirably. Broad behavioral tests live in tests/integration.rs.
2284#[cfg(test)]
2285#[path = "operations_tests.rs"]
2286mod tests;