Skip to main content

khive_runtime/
atomic_message.rs

1//! Atomic multi-note write primitive: commits a set of notes — each with its
2//! FTS document and its kind-selected embedding model rows — in ONE
3//! writer transaction, instead of one `create_note` call per note.
4//!
5//! Built for `khive-pack-comm`'s `dual_write_message` (outbound + inbound
6//! copy of a `comm.send`/`comm.reply`), which previously cost roughly a
7//! dozen separate writer acquisitions per send: two `create_note_inner`
8//! calls (row + FTS + one vector insert per registered model each) plus a
9//! root-send `thread_id` patch. Shaped as `create_notes_atomic_with_report(Vec<AtomicNoteSpec>)`
10//! rather than a comm-specific pair primitive so other multi-write verbs
11//! can share the same preparation. Keyed `memory.remember` appends a required
12//! annotation and final key claim before committing its prepared plan.
13//!
14//! # Embed-first
15//!
16//! Embedding is slow compute (network/model calls). Every distinct content's
17//! embeddings, across the selected models, are computed **before** any
18//! transaction opens — the writer is held only for synchronous DML, exactly
19//! like the rest of the ADR-099 atomic-unit machinery
20//! (`atomic_plan`/`atomic_runner`).
21//! This is the same reason `atomic_prepare::prepare_add_note` defers vector
22//! indexing to a post-commit `PostCommitEffect::ReindexNote`; the difference
23//! here is embeddings are computed *before* commit instead of *after*, so
24//! the vector rows land in the SAME atomic unit as the note row and FTS
25//! document, and no post-commit reindex is needed at all — every plan built
26//! by [`create_notes_atomic_with_report`] carries `post_commit: PostCommitEffect::None`.
27//!
28//! # One writer acquisition
29//!
30//! Each note becomes its own [`AddNotePlan`]; every spec's plan is applied by
31//! ONE [`crate::atomic_runner::run_atomic_unit`] commit pass — one [`khive_storage::SqlAccess::atomic_unit`],
32//! one writer checkout, one WAL commit for the whole set. A failure on any
33//! note's plan rolls back the ENTIRE unit (`atomic_runner`'s documented
34//! guarantee: a later op's failure unwinds even an earlier op's own
35//! already-`RELEASE`d `SAVEPOINT`), so a crash or guard failure partway
36//! through never leaves an orphan copy — the process-crash gap the two-call
37//! version of `dual_write_message` used to document is closed by
38//! construction.
39
40use std::collections::HashMap;
41
42use serde_json::Value;
43use uuid::Uuid;
44
45use khive_storage::note::Note;
46use khive_storage::types::SqlValue;
47use khive_storage::{SqlStatement, StorageCapability, StorageError};
48use khive_types::SubstrateKind;
49
50use crate::atomic_plan::{AddNotePlan, AffectedRowGuard, PlanStatement, PostCommitEffect};
51use crate::atomic_runner::{AtomicOpPlan, AtomicRunOutcome};
52use crate::config::NamespaceToken;
53use crate::curation::note_fts_document;
54use crate::error::{RuntimeError, RuntimeResult};
55use crate::runtime::KhiveRuntime;
56
57/// One note to write as part of an atomic set. Mirrors the subset of
58/// `create_note_inner`'s parameters `comm.send`/`comm.reply` actually use —
59/// no `annotates`, `salience`, `decay_factor`, `embedding_content` override,
60/// or explicit `embedding_model` pin. Keyed-memory preparation carries those
61/// through internal options without changing the public multi-note spec.
62pub struct AtomicNoteSpec<'a> {
63    /// Namespace + actor identity this note is written under.
64    pub token: &'a NamespaceToken,
65    /// Caller-supplied id, when the id must be known before the write (e.g.
66    /// a root send's own id becomes the canonical `thread_id` stored in
67    /// BOTH copies' properties). `None` generates a fresh id, same as
68    /// `Note::new`.
69    pub id: Option<Uuid>,
70    pub kind: &'a str,
71    pub name: Option<&'a str>,
72    pub content: &'a str,
73    pub properties: Option<Value>,
74}
75
76#[derive(Clone, Copy, Default)]
77pub(crate) struct AtomicNoteOptions<'a> {
78    pub salience: Option<f64>,
79    pub decay_factor: Option<f64>,
80    pub embedding_model: Option<&'a str>,
81    pub embedding_content: Option<&'a str>,
82    pub embed: Option<bool>,
83    pub key: Option<&'a str>,
84    /// Keyed memory only: persist a zero-model receipt header and each model's
85    /// exact log-upsert rowid in this same atomic unit.
86    pub memory_visibility_receipt: bool,
87    /// Ask the writer transaction to compare a live key holder against this
88    /// candidate and report the result (ADR-172 Amendment 6). Every other
89    /// keyed route defaults this to `false` and gets today's Details shape
90    /// on a key conflict, with no comparison outcome in it at all.
91    pub replay_receipt: bool,
92    pub fence: Option<&'a crate::note_write::NoteFences>,
93    /// `spec.properties` is already the fully validator-derived value, so
94    /// skip calling the note-write validator again below (ADR-172 Amendment
95    /// 6's keyed singleton-create staging derives properties once, before
96    /// its initial holder check, and reuses that same value through
97    /// preparation and any final-check fallback; the installed validator is
98    /// not guaranteed idempotent, and a kg regression test counts its calls,
99    /// so deriving twice for one request is an observably different
100    /// candidate, not a redundant no-op). Every other caller leaves this
101    /// `false` and keeps deriving from raw caller-supplied properties here.
102    pub properties_already_derived: bool,
103}
104
105pub(crate) struct AtomicNoteRequest<'a> {
106    pub spec: AtomicNoteSpec<'a>,
107    pub options: AtomicNoteOptions<'a>,
108}
109
110pub(crate) struct PreparedAtomicNotes {
111    pub notes: Vec<Note>,
112    pub plans: Vec<AtomicOpPlan>,
113    pub embedding_truncation: crate::retrieval::EmbeddingTruncationReport,
114}
115
116/// Mirrors `khive-db`'s private `non_finite_index` — this crate has no
117/// visibility into that helper, so the check is reproduced here to keep the
118/// atomic path's embedding validation observably identical to the canonical
119/// `create_note_inner` -> `VectorStore::insert` path.
120pub(crate) fn non_finite_index(data: &[f32]) -> Option<usize> {
121    data.iter().position(|v| !v.is_finite())
122}
123
124/// Same error shape (capability, operation label, message) as `khive-db`'s
125/// private `non_finite_vector_error("vec_insert", ..)` — the atomic path
126/// must reject a non-finite embedding exactly as the raw `VectorStore::insert`
127/// DML does, not silently write it.
128pub(crate) fn non_finite_vector_error(idx: usize, value: f32) -> RuntimeError {
129    RuntimeError::Storage(StorageError::InvalidInput {
130        capability: StorageCapability::Vectors,
131        operation: "vec_insert".into(),
132        message: format!(
133            "non-finite value at index {idx}: {value} \
134             (NaN/Inf values corrupt distance computations)"
135        ),
136    })
137}
138
139/// A harmless no-op `UPDATE` with a statically zero-row predicate (`WHERE 1
140/// = 0`, not a match against a specific id), guarded `exactly(1)` so it
141/// always fails its guard inside the atomic unit regardless of which note
142/// ids exist in the store — a fixed nil-UUID predicate would pass its guard
143/// if a caller ever supplied that id. Spliced in by test-only fault
144/// injection (see [`maybe_inject_fts_failure`]/[`maybe_inject_vector_failure`])
145/// to prove the whole unit — not just the failing note's own plan — rolls
146/// back.
147///
148/// `fault-injection` is an ordinary Cargo feature (see this crate's
149/// `Cargo.toml`); it must never be enabled in a packaged/release build —
150/// doing so would compile this fault-injection path into a shipped binary.
151#[cfg(any(test, feature = "fault-injection"))]
152fn injected_failure_statement(label: &str) -> PlanStatement {
153    PlanStatement {
154        statement: SqlStatement {
155            sql: "UPDATE notes SET updated_at = updated_at WHERE 1 = 0".to_string(),
156            params: vec![],
157            label: Some(label.to_string()),
158        },
159        guard: Some(AffectedRowGuard::exactly(1)),
160    }
161}
162
163#[cfg(any(test, feature = "fault-injection"))]
164fn maybe_inject_fts_failure(namespace: &str, label: &str) -> Option<PlanStatement> {
165    crate::operations::consume_fts_fail_fault(namespace).then(|| injected_failure_statement(label))
166}
167#[cfg(not(any(test, feature = "fault-injection")))]
168fn maybe_inject_fts_failure(_namespace: &str, _label: &str) -> Option<PlanStatement> {
169    None
170}
171
172#[cfg(any(test, feature = "fault-injection"))]
173fn maybe_inject_vector_failure(namespace: &str, label: &str) -> Option<PlanStatement> {
174    crate::operations::consume_vector_fail_fault(namespace)
175        .then(|| injected_failure_statement(label))
176}
177#[cfg(not(any(test, feature = "fault-injection")))]
178fn maybe_inject_vector_failure(_namespace: &str, _label: &str) -> Option<PlanStatement> {
179    None
180}
181
182/// DELETE-then-INSERT-then-log statements for one (note, model) vector row.
183/// This static atomic plan cannot branch on an identity-constrained DELETE's
184/// affected-row count, so it retains the general delete-log scan used before
185/// `khive-db::stores::vectors::replace_vector_row_dml` gained its live-
186/// connection fast path. It uses the same plain-[`PlanStatement`] technique
187/// as `atomic_prepare::push_index_purge_statements`. Unguarded: same
188/// convention as the FTS-insert statement in
189/// `atomic_prepare::prepare_add_note` (the row's existence guard is carried by
190/// the plan's primary note-row statement, applied first).
191#[allow(clippy::too_many_arguments)]
192pub(crate) fn vector_insert_statements(
193    table: &str,
194    namespace: &str,
195    subject_id: Uuid,
196    field: &str,
197    embedding_model: &str,
198    embedding: &[f32],
199    label_prefix: &str,
200) -> Vec<PlanStatement> {
201    vector_insert_statements_for_substrate(
202        table,
203        namespace,
204        subject_id,
205        SubstrateKind::Note,
206        field,
207        embedding_model,
208        embedding,
209        label_prefix,
210        None,
211    )
212}
213
214/// Build atomic vector and ANN-log writes for a typed substrate. The caller
215/// chooses whether the two single-row inserts require affected-row evidence.
216#[allow(clippy::too_many_arguments)]
217pub(crate) fn vector_insert_statements_for_substrate(
218    table: &str,
219    namespace: &str,
220    subject_id: Uuid,
221    substrate: SubstrateKind,
222    field: &str,
223    embedding_model: &str,
224    embedding: &[f32],
225    label_prefix: &str,
226    insert_guard: Option<AffectedRowGuard>,
227) -> Vec<PlanStatement> {
228    let subject = subject_id.to_string();
229    let model_key = table
230        .strip_prefix("vec_")
231        .expect("runtime vector tables use the vec_ prefix");
232    let kind_str = substrate.to_string();
233    let blob = khive_storage::encode_f32_native(embedding);
234    vec![
235        // Delta-log any pre-existing row this REPLACE is about to evict —
236        // mirrors `log_vector_deletes`'s "replace" predicate exactly.
237        PlanStatement {
238            statement: SqlStatement {
239                sql: format!(
240                    "INSERT INTO ann_write_log \
241                     (namespace, embedding_model, kind, field, subject_id, op) \
242                     SELECT namespace, embedding_model, kind, field, subject_id, 'delete' \
243                     FROM {table} WHERE subject_id = ?1 AND NOT \
244                     (namespace = ?2 AND embedding_model = ?3 AND kind = ?4 AND field = ?5)"
245                ),
246                params: vec![
247                    SqlValue::Text(subject.clone()),
248                    SqlValue::Text(namespace.to_string()),
249                    SqlValue::Text(embedding_model.to_string()),
250                    SqlValue::Text(kind_str.clone()),
251                    SqlValue::Text(field.to_string()),
252                ],
253                label: Some(format!("{label_prefix}-log-delete")),
254            },
255            guard: None,
256        },
257        PlanStatement {
258            statement: SqlStatement {
259                sql: format!("DELETE FROM {table} WHERE subject_id = ?1"),
260                params: vec![SqlValue::Text(subject.clone())],
261                label: Some(format!("{label_prefix}-delete")),
262            },
263            guard: None,
264        },
265        // A replacement through this raw atomic plan does not know the source
266        // text, even if its new vector bytes happen to equal the old bytes.
267        PlanStatement {
268            statement: SqlStatement {
269                sql: "DELETE FROM vector_provenance \
270                      WHERE model_key = ?1 AND subject_id = ?2"
271                    .to_string(),
272                params: vec![
273                    SqlValue::Text(model_key.to_string()),
274                    SqlValue::Text(subject.clone()),
275                ],
276                label: Some(format!("{label_prefix}-clear-provenance")),
277            },
278            guard: None,
279        },
280        PlanStatement {
281            statement: SqlStatement {
282                sql: format!(
283                    "INSERT INTO {table} \
284                     (subject_id, namespace, kind, field, embedding_model, embedding) \
285                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
286                ),
287                params: vec![
288                    SqlValue::Text(subject.clone()),
289                    SqlValue::Text(namespace.to_string()),
290                    SqlValue::Text(kind_str.clone()),
291                    SqlValue::Text(field.to_string()),
292                    SqlValue::Text(embedding_model.to_string()),
293                    SqlValue::Blob(blob),
294                ],
295                label: Some(format!("{label_prefix}-insert")),
296            },
297            guard: insert_guard,
298        },
299        PlanStatement {
300            statement: SqlStatement {
301                sql: "INSERT INTO ann_write_log \
302                      (namespace, embedding_model, kind, field, subject_id, op) \
303                      VALUES (?1, ?2, ?3, ?4, ?5, 'upsert')"
304                    .to_string(),
305                params: vec![
306                    SqlValue::Text(namespace.to_string()),
307                    SqlValue::Text(embedding_model.to_string()),
308                    SqlValue::Text(kind_str),
309                    SqlValue::Text(field.to_string()),
310                    SqlValue::Text(subject),
311                ],
312                label: Some(format!("{label_prefix}-log-upsert")),
313            },
314            guard: insert_guard,
315        },
316    ]
317}
318
319/// Build and commit `specs` as one atomic unit. Returns the persisted
320/// [`Note`]s in the same order as `specs`. On any failure — pre-write
321/// validation, embedding, or the atomic commit pass itself — NO note, FTS
322/// document, or vector row from any spec is left behind (embed failures
323/// occur before any write is attempted; commit-pass failures roll back the
324/// whole unit per [`crate::atomic_runner::run_atomic_unit`]'s guarantee).
325#[cfg(test)]
326pub(crate) async fn create_notes_atomic(
327    runtime: &KhiveRuntime,
328    specs: Vec<AtomicNoteSpec<'_>>,
329) -> RuntimeResult<Vec<Note>> {
330    Ok(create_notes_atomic_with_report(runtime, specs).await?.0)
331}
332
333/// Build and commit notes with an embedding-truncation report. The report keeps
334/// logical per-note/model accounting even when identical content shares one
335/// provider result, and is returned only when the whole note set commits
336/// successfully.
337pub async fn create_notes_atomic_with_report(
338    runtime: &KhiveRuntime,
339    specs: Vec<AtomicNoteSpec<'_>>,
340) -> RuntimeResult<(Vec<Note>, crate::retrieval::EmbeddingTruncationReport)> {
341    create_notes_atomic_with_attachments(runtime, specs, &[]).await
342}
343
344/// Create notes and their role-keyed attachments in the same writer transaction.
345/// The existing note, FTS, vector and revision accounting is preserved.
346pub async fn create_notes_atomic_with_attachments(
347    runtime: &KhiveRuntime,
348    specs: Vec<AtomicNoteSpec<'_>>,
349    attachments: &[khive_storage::NewAttachment],
350) -> RuntimeResult<(Vec<Note>, crate::retrieval::EmbeddingTruncationReport)> {
351    validate_note_attachments(runtime, attachments)?;
352    let mut prepared = prepare_atomic_notes(runtime, specs, AtomicNoteOptions::default()).await?;
353    append_note_attachments(&mut prepared, attachments)?;
354    match crate::atomic_runner::run_atomic_unit_with_note_versions(
355        runtime.sql().as_ref(),
356        prepared.plans,
357        true,
358    )
359    .await
360    {
361        Ok((AtomicRunOutcome::Committed { .. }, versions)) => {
362            assert_eq!(
363                prepared.notes.len(),
364                versions.len(),
365                "one revision receipt per prepared note"
366            );
367            for (note, version) in prepared.notes.iter_mut().zip(versions) {
368                note.version = version;
369            }
370            Ok((prepared.notes, prepared.embedding_truncation))
371        }
372        Ok((
373            AtomicRunOutcome::RolledBack {
374                failed_op_index,
375                failure,
376            },
377            _,
378        )) => Err(RuntimeError::Internal(format!(
379            "atomic multi-note write rolled back at op {failed_op_index}: {failure:?}"
380        ))),
381        Err(e) => Err(RuntimeError::Storage(e.0)),
382    }
383}
384
385pub(crate) fn validate_note_attachments(
386    runtime: &KhiveRuntime,
387    attachments: &[khive_storage::NewAttachment],
388) -> RuntimeResult<()> {
389    if !attachments.is_empty() {
390        // Note plans execute on runtime.sql(); attachment liveness belongs to
391        // main. Refuse a split placement before preparing any write.
392        runtime.attachments()?;
393    }
394    for attachment in attachments {
395        attachment.validate()?;
396    }
397    Ok(())
398}
399
400pub(crate) fn append_note_attachments(
401    prepared: &mut PreparedAtomicNotes,
402    attachments: &[khive_storage::NewAttachment],
403) -> RuntimeResult<()> {
404    for (plan, note) in prepared.plans.iter_mut().zip(&prepared.notes) {
405        let AtomicOpPlan::AddNote(plan) = plan else {
406            return Err(RuntimeError::Internal(
407                "expected prepared attachment owner note".into(),
408            ));
409        };
410        for attachment in attachments {
411            let row = khive_storage::Attachment::from_new(
412                note.id,
413                khive_storage::AttachmentSubstrate::Note,
414                attachment.clone(),
415                note.created_at,
416            );
417            // Append after the note's complete index plan, preserving adjacent
418            // last_insert_rowid-dependent FTS statements and their counters.
419            plan.statements.push(PlanStatement {
420                statement: khive_db::stores::attachment::attachment_upsert_statement(&row)?,
421                guard: Some(AffectedRowGuard::exactly(1)),
422            });
423        }
424    }
425    Ok(())
426}
427
428pub(crate) async fn prepare_atomic_notes(
429    runtime: &KhiveRuntime,
430    specs: Vec<AtomicNoteSpec<'_>>,
431    options: AtomicNoteOptions<'_>,
432) -> RuntimeResult<PreparedAtomicNotes> {
433    validate_atomic_note_options(&options)?;
434    if options.embed != Some(false) {
435        if let Some(model) = options.embedding_model {
436            runtime.resolve_embedding_model(Some(model))?;
437        }
438    }
439    prepare_atomic_note_requests(
440        runtime,
441        specs
442            .into_iter()
443            .map(|spec| AtomicNoteRequest { spec, options })
444            .collect(),
445    )
446    .await
447}
448
449fn validate_atomic_note_options(options: &AtomicNoteOptions<'_>) -> RuntimeResult<()> {
450    if let Some(value) = options.salience {
451        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
452            return Err(RuntimeError::InvalidInput(
453                "salience must be a finite value in [0.0, 1.0]".into(),
454            ));
455        }
456    }
457    if let Some(value) = options.decay_factor {
458        if !value.is_finite() || value < 0.0 {
459            return Err(RuntimeError::InvalidInput(
460                "decay_factor must be a finite value >= 0.0".into(),
461            ));
462        }
463    }
464    Ok(())
465}
466
467pub(crate) async fn prepare_atomic_note_requests(
468    runtime: &KhiveRuntime,
469    requests: Vec<AtomicNoteRequest<'_>>,
470) -> RuntimeResult<PreparedAtomicNotes> {
471    let mut embed_model_names = Vec::new();
472    let mut note_models = Vec::with_capacity(requests.len());
473    for request in &requests {
474        let options = &request.options;
475        validate_atomic_note_options(options)?;
476        let models = if options.embed == Some(false) {
477            Vec::new()
478        } else if let Some(model) = options.embedding_model {
479            runtime.resolve_embedding_model(Some(model))?;
480            vec![model.to_owned()]
481        } else {
482            runtime.embedding_models_for_note_kind(request.spec.kind)
483        };
484        let mut indices = Vec::with_capacity(models.len());
485        for model in models {
486            let index = match embed_model_names.iter().position(|name| name == &model) {
487                Some(index) => index,
488                None => {
489                    embed_model_names.push(model);
490                    embed_model_names.len() - 1
491                }
492            };
493            indices.push(index);
494        }
495        note_models.push(indices);
496    }
497    // ---- 1. Validate + build Note objects (all pre-write checks, same as
498    // create_note_inner, before any embedding or DML is attempted). ----
499    let mut notes: Vec<Note> = Vec::with_capacity(requests.len());
500    for (index, request) in requests.iter().enumerate() {
501        let spec = &request.spec;
502        let options = &request.options;
503        let record = format!("note[{index}]");
504        runtime.validate_note_kind(spec.kind)?;
505        // Same owned-identity derivation every other note-write site runs
506        // (`operations.rs`'s create funnel, `atomic_prepare::prepare_add_note`):
507        // derive from the token BEFORE the secret scan and note construction,
508        // so a caller-supplied `from_actor`/`thread_id`/etc. in `spec.properties`
509        // never reaches storage verbatim on this writer either. Skipped when
510        // the caller already derived (`properties_already_derived`): the
511        // validator is not guaranteed idempotent, so a second call for the
512        // same request is a different candidate, not a no-op.
513        let properties = if options.properties_already_derived {
514            spec.properties.clone()
515        } else {
516            runtime.derive_note_write_properties(spec.kind, spec.token, spec.properties.clone())?
517        };
518        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
519        crate::secret_gate::check_note_fields_at(
520            spec.content,
521            spec.name,
522            properties.as_ref(),
523            &record,
524        )?;
525
526        let ns = spec.token.namespace().as_str();
527        let mut note = Note::new(ns, spec.kind, spec.content);
528        note.key = options.key.map(str::to_owned);
529        if let Some(salience) = options.salience {
530            note = note.with_salience(salience);
531        }
532        if let Some(decay_factor) = options.decay_factor {
533            note = note.with_decay(decay_factor);
534        }
535        if let Some(id) = spec.id {
536            note.id = id;
537        }
538        if let Some(n) = spec.name {
539            note = note.with_name(n);
540        }
541        if let Some(p) = properties {
542            note = note.with_properties(p);
543        }
544        notes.push(note);
545    }
546
547    // Deferred key publication clears the temporary Note.key; its private receipt
548    // option is the original keyed-memory intent. Generic and stream routes
549    // retain options.key. Kind is always the canonical prepared note kind.
550    let memory_receipts: Vec<bool> = notes
551        .iter()
552        .zip(&requests)
553        .map(|(note, request)| {
554            note.kind == "memory"
555                && (request.options.key.is_some() || request.options.memory_visibility_receipt)
556        })
557        .collect();
558    if memory_receipts.iter().any(|capture| *capture) {
559        runtime.require_visibility_cutover()?;
560    }
561
562    // ---- 2. Batch distinct content per model in parallel, BEFORE
563    // opening any transaction. Any failure aborts here — no write has been
564    // attempted. Identical note siblings reuse the same computed vector. ----
565    let mut content_group_by_text: HashMap<&str, usize> = HashMap::new();
566    let mut content_groups: Vec<Vec<usize>> = Vec::new();
567    let mut note_content_groups: Vec<usize> = Vec::with_capacity(notes.len());
568    for (note_idx, note) in notes.iter().enumerate() {
569        let text = requests[note_idx]
570            .options
571            .embedding_content
572            .unwrap_or_else(|| crate::curation::note_embedding_text_ref(note));
573        let content_group_idx = match content_group_by_text.get(text) {
574            Some(&idx) => {
575                content_groups[idx].push(note_idx);
576                idx
577            }
578            None => {
579                let idx = content_groups.len();
580                content_group_by_text.insert(text, idx);
581                content_groups.push(vec![note_idx]);
582                idx
583            }
584        };
585        note_content_groups.push(content_group_idx);
586    }
587
588    // (content_group_idx, model_idx) -> outcome, filled as tasks complete.
589    let mut embedding_outcomes: Vec<Vec<Option<crate::retrieval::DocumentEmbeddingOutcome>>> =
590        content_groups
591            .iter()
592            .map(|_| vec![None; embed_model_names.len()])
593            .collect();
594    let mut embedding_truncation = crate::retrieval::EmbeddingTruncationReport::default();
595
596    // `requests` is empty when a caller prepares a note set every member of
597    // which was refused before preparation: there is nothing to embed, no
598    // token to read, and the lazy vector-table create belongs to a write
599    // that is not going to happen.
600    if !embed_model_names.is_empty() {
601        // Ensure every model's vector table exists before the commit pass —
602        // the same lazy-create side effect as vector-store construction, with
603        // all model tables sharing one schema-writer acquisition.
604        let models = embed_model_names
605            .iter()
606            .map(|name| {
607                runtime
608                    .vector_model_metadata(name)
609                    .map(|(name, dimensions)| (crate::config::sanitize_key(&name), dimensions))
610            })
611            .collect::<RuntimeResult<Vec<_>>>()?;
612        let model_specs: Vec<_> = models
613            .iter()
614            .map(|(key, dimensions)| (key.as_str(), *dimensions))
615            .collect();
616        runtime.backend().ensure_vector_tables(&model_specs)?;
617
618        let usage_ctx = crate::usage::current();
619        let mut join_set = tokio::task::JoinSet::new();
620        for (model_idx, model_name) in embed_model_names.iter().enumerate() {
621            let mut groups = Vec::new();
622            let mut texts = Vec::new();
623            let mut token = None;
624            for (group_idx, note_indices) in content_groups.iter().enumerate() {
625                let Some(&note_idx) = note_indices
626                    .iter()
627                    .find(|&&index| note_models[index].contains(&model_idx))
628                else {
629                    continue;
630                };
631                token.get_or_insert_with(|| requests[note_idx].spec.token.clone());
632                groups.push(group_idx);
633                texts.push(
634                    requests[note_idx]
635                        .options
636                        .embedding_content
637                        .unwrap_or_else(|| {
638                            crate::curation::note_embedding_text_ref(&notes[note_idx])
639                        })
640                        .to_owned(),
641                );
642            }
643            let rt = runtime.clone();
644            let token = token.expect("a selected model has at least one note");
645            let name = model_name.clone();
646            let ctx = usage_ctx.clone();
647            join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
648                let fut = async {
649                    let mut outcomes = Vec::with_capacity(texts.len());
650                    for chunk in texts.chunks(lattice_embed::DEFAULT_MAX_BATCH_SIZE) {
651                        outcomes.extend(
652                            rt.embed_document_batch_with_model_outcomes_for_token(
653                                &token, &name, chunk,
654                            )
655                            .await?,
656                        );
657                    }
658                    Ok::<_, RuntimeError>(outcomes)
659                };
660                let result = match ctx {
661                    Some(ctx) => crate::usage::scope(ctx, fut).await,
662                    None => fut.await,
663                };
664                (groups, model_idx, result)
665            }));
666        }
667
668        while let Some(joined) = join_set.join_next().await {
669            match joined {
670                Ok((groups, model_idx, Ok(outcomes))) => {
671                    for (group_idx, outcome) in groups.into_iter().zip(outcomes) {
672                        embedding_outcomes[group_idx][model_idx] = Some(outcome);
673                    }
674                }
675                Ok((_, _, Err(e))) => {
676                    join_set.abort_all();
677                    return Err(e);
678                }
679                Err(join_err) => {
680                    join_set.abort_all();
681                    return Err(RuntimeError::Internal(format!(
682                        "embed task panicked: {join_err}"
683                    )));
684                }
685            }
686        }
687    }
688
689    // Preserve the report's logical note/model accounting even when identical
690    // siblings share one provider invocation.
691    for (note_idx, &content_group_idx) in note_content_groups.iter().enumerate() {
692        for &model_idx in &note_models[note_idx] {
693            let outcome = embedding_outcomes[content_group_idx][model_idx]
694                .as_ref()
695                .expect("every requested embedding was observed");
696            embedding_truncation.observe(outcome);
697            if outcome.truncated {
698                tracing::warn!(
699                    model = %outcome.model_name,
700                    source_bytes = outcome.source_bytes,
701                    embedded_bytes = outcome.embedded_bytes,
702                    "atomic note embedding input truncated; full content will be stored unchanged"
703                );
704            }
705        }
706    }
707
708    // Reject any non-finite embedding value BEFORE any plan is built — same
709    // validation the canonical `VectorStore::insert` DML performs, applied
710    // here so a bad custom embedding provider never reaches the raw vector
711    // insert on this path (embed-first: no note, FTS document, or vector row
712    // has been written yet).
713    for outcomes_for_content in &embedding_outcomes {
714        for outcome in outcomes_for_content.iter().flatten() {
715            if let Some(idx) = non_finite_index(&outcome.vector) {
716                return Err(non_finite_vector_error(idx, outcome.vector[idx]));
717            }
718        }
719    }
720
721    // ---- 3. Build one AddNotePlan per spec (row + FTS + vector-insert
722    // statements), all pre-computed embeddings already in hand. ----
723    let mut plans: Vec<AtomicOpPlan> = Vec::with_capacity(notes.len());
724    for (note_idx, note) in notes.iter().enumerate() {
725        let outcomes_for_note = &embedding_outcomes[note_content_groups[note_idx]];
726        let mut statements = vec![PlanStatement {
727            statement: if note.key.is_some() {
728                khive_db::stores::note::note_insert_keyed_statement(note)
729            } else {
730                khive_db::stores::note::note_upsert_statement(note)
731            },
732            guard: Some(AffectedRowGuard::exactly(1)),
733        }];
734
735        if memory_receipts[note_idx] {
736            statements.push(PlanStatement {
737                statement: SqlStatement {
738                    sql:
739                        "INSERT INTO memory_visibility_receipts (namespace, note_id, model_count) \
740                          VALUES (?1, ?2, ?3)"
741                            .into(),
742                    params: vec![
743                        SqlValue::Text(note.namespace.clone()),
744                        SqlValue::Text(note.id.to_string()),
745                        SqlValue::Integer(note_models[note_idx].len() as i64),
746                    ],
747                    label: Some("memory-visibility-receipt".into()),
748                },
749                guard: Some(AffectedRowGuard::exactly(1)),
750            });
751        }
752
753        if let Some(fault) = maybe_inject_fts_failure(&note.namespace, "fault-injected-fts") {
754            statements.push(fault);
755        } else {
756            // Delete-then-insert upsert — mirrors `text.rs`'s
757            // `upsert_document_dml` exactly, so a caller-supplied/reused note
758            // id never leaves a stale/duplicate FTS document behind (the
759            // note row itself is already an upsert via
760            // `note_upsert_statement`). The delete alone is enough on the
761            // sidecar rowid-map side too — the insert pair's `INSERT OR
762            // REPLACE` below overwrites the map row rather than requiring a
763            // prior delete of it (see `delete_document_statement`'s doc
764            // comment).
765            statements.push(PlanStatement {
766                statement: khive_db::stores::text::delete_document_statement(
767                    "fts_notes",
768                    &note.namespace,
769                    note.id,
770                ),
771                guard: None,
772            });
773            // Order-sensitive pair: the map upsert's `last_insert_rowid()`
774            // must read back the FTS insert immediately before it, with
775            // nothing else written to this connection in between — pushed
776            // here as the array `insert_document_statements` returns so the
777            // two can never be separated by an edit to this call site.
778            for statement in khive_db::stores::text::insert_document_statements(
779                "fts_notes",
780                &note_fts_document(note),
781            ) {
782                statements.push(PlanStatement {
783                    statement,
784                    guard: None,
785                });
786            }
787        }
788
789        if let Some(fault) = maybe_inject_vector_failure(&note.namespace, "fault-injected-vector") {
790            statements.push(fault);
791        } else {
792            for &model_idx in &note_models[note_idx] {
793                let model_name = &embed_model_names[model_idx];
794                let outcome = outcomes_for_note[model_idx]
795                    .as_ref()
796                    .expect("every model index observed exactly once");
797                let table = format!("vec_{}", crate::config::sanitize_key(model_name));
798                statements.extend(vector_insert_statements(
799                    &table,
800                    &note.namespace,
801                    note.id,
802                    "note.content",
803                    model_name,
804                    &outcome.vector,
805                    &format!("atomic-message-vec-{table}-{}", note.id),
806                ));
807                if memory_receipts[note_idx] {
808                    // The preceding statement is the model's ann_write_log
809                    // upsert. Keep this insert adjacent: last_insert_rowid()
810                    // reads that exact transaction-local sequence, never a
811                    // later MAX(seq) vulnerable to concurrency/compaction.
812                    statements.push(PlanStatement {
813                        statement: SqlStatement {
814                            sql: "INSERT INTO memory_visibility_fences \
815                                  (namespace, note_id, model, ann_write_log_seq) \
816                                  VALUES (?1, ?2, ?3, last_insert_rowid())"
817                                .into(),
818                            params: vec![
819                                SqlValue::Text(note.namespace.clone()),
820                                SqlValue::Text(note.id.to_string()),
821                                SqlValue::Text(model_name.clone()),
822                            ],
823                            label: Some("memory-visibility-fence".into()),
824                        },
825                        guard: Some(AffectedRowGuard::exactly(1)),
826                    });
827                }
828            }
829        }
830
831        // After every log/fence pair: inserting the independent marker must
832        // never disturb either ANN or FTS last_insert_rowid adjacency.
833        if memory_receipts[note_idx] {
834            statements.push(PlanStatement {
835                statement: SqlStatement {
836                    sql: "INSERT INTO memory_visibility_epochs (note_id, namespace, epoch) \
837                          VALUES (?1, ?2, 'modern')"
838                        .into(),
839                    params: vec![
840                        SqlValue::Text(note.id.to_string()),
841                        SqlValue::Text(note.namespace.clone()),
842                    ],
843                    label: Some("memory-visibility-epoch".into()),
844                },
845                guard: Some(AffectedRowGuard::exactly(1)),
846            });
847        }
848
849        plans.push(AtomicOpPlan::AddNote(Box::new(AddNotePlan {
850            note_guard: Some(crate::note_write::NoteWriteGuard {
851                namespace: requests[note_idx].spec.token.namespace().as_str().into(),
852                target_id: note.id,
853                expected_version: None,
854                fence: requests[note_idx].options.fence.cloned(),
855                create_key: note
856                    .key
857                    .as_ref()
858                    .map(|key| crate::note_write::CreateKeyClaim {
859                        kind: note.kind.clone(),
860                        key: key.clone(),
861                        content: note.content.clone(),
862                        properties: note.properties.clone(),
863                        replay_signal: requests[note_idx].options.replay_receipt,
864                    }),
865            }),
866            note_id: note.id,
867            statements,
868            post_commit: PostCommitEffect::None,
869        })));
870    }
871
872    Ok(PreparedAtomicNotes {
873        notes,
874        plans,
875        embedding_truncation,
876    })
877}
878
879#[cfg(test)]
880#[path = "atomic_message_tests.rs"]
881mod tests;