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/// Bit-identical to `khive-db`'s private `f32_slice_as_bytes` (native-endian
117/// reinterpretation of the float buffer) but built from safe `to_ne_bytes`
118/// calls instead of an unsafe pointer cast — this crate has no visibility
119/// into that helper, and correctness here only requires this process's
120/// writes and reads agree on layout, which native-endian byte-for-byte
121/// concatenation guarantees identically to the raw cast.
122fn f32_vec_to_bytes(data: &[f32]) -> Vec<u8> {
123    let mut bytes = Vec::with_capacity(data.len() * 4);
124    for f in data {
125        bytes.extend_from_slice(&f.to_ne_bytes());
126    }
127    bytes
128}
129
130/// Mirrors `khive-db`'s private `non_finite_index` — this crate has no
131/// visibility into that helper, so the check is reproduced here to keep the
132/// atomic path's embedding validation observably identical to the canonical
133/// `create_note_inner` -> `VectorStore::insert` path.
134fn non_finite_index(data: &[f32]) -> Option<usize> {
135    data.iter().position(|v| !v.is_finite())
136}
137
138/// Same error shape (capability, operation label, message) as `khive-db`'s
139/// private `non_finite_vector_error("vec_insert", ..)` — the atomic path
140/// must reject a non-finite embedding exactly as the raw `VectorStore::insert`
141/// DML does, not silently write it.
142pub(crate) fn non_finite_vector_error(idx: usize, value: f32) -> RuntimeError {
143    RuntimeError::Storage(StorageError::InvalidInput {
144        capability: StorageCapability::Vectors,
145        operation: "vec_insert".into(),
146        message: format!(
147            "non-finite value at index {idx}: {value} \
148             (NaN/Inf values corrupt distance computations)"
149        ),
150    })
151}
152
153/// A harmless no-op `UPDATE` with a statically zero-row predicate (`WHERE 1
154/// = 0`, not a match against a specific id), guarded `exactly(1)` so it
155/// always fails its guard inside the atomic unit regardless of which note
156/// ids exist in the store — a fixed nil-UUID predicate would pass its guard
157/// if a caller ever supplied that id. Spliced in by test-only fault
158/// injection (see [`maybe_inject_fts_failure`]/[`maybe_inject_vector_failure`])
159/// to prove the whole unit — not just the failing note's own plan — rolls
160/// back.
161///
162/// `fault-injection` is an ordinary Cargo feature (see this crate's
163/// `Cargo.toml`); it must never be enabled in a packaged/release build —
164/// doing so would compile this fault-injection path into a shipped binary.
165#[cfg(any(test, feature = "fault-injection"))]
166fn injected_failure_statement(label: &str) -> PlanStatement {
167    PlanStatement {
168        statement: SqlStatement {
169            sql: "UPDATE notes SET updated_at = updated_at WHERE 1 = 0".to_string(),
170            params: vec![],
171            label: Some(label.to_string()),
172        },
173        guard: Some(AffectedRowGuard::exactly(1)),
174    }
175}
176
177#[cfg(any(test, feature = "fault-injection"))]
178fn maybe_inject_fts_failure(namespace: &str, label: &str) -> Option<PlanStatement> {
179    crate::operations::consume_fts_fail_fault(namespace).then(|| injected_failure_statement(label))
180}
181#[cfg(not(any(test, feature = "fault-injection")))]
182fn maybe_inject_fts_failure(_namespace: &str, _label: &str) -> Option<PlanStatement> {
183    None
184}
185
186#[cfg(any(test, feature = "fault-injection"))]
187fn maybe_inject_vector_failure(namespace: &str, label: &str) -> Option<PlanStatement> {
188    crate::operations::consume_vector_fail_fault(namespace)
189        .then(|| injected_failure_statement(label))
190}
191#[cfg(not(any(test, feature = "fault-injection")))]
192fn maybe_inject_vector_failure(_namespace: &str, _label: &str) -> Option<PlanStatement> {
193    None
194}
195
196/// DELETE-then-INSERT-then-log statements for one (note, model) vector row.
197/// This static atomic plan cannot branch on an identity-constrained DELETE's
198/// affected-row count, so it retains the general delete-log scan used before
199/// `khive-db::stores::vectors::replace_vector_row_dml` gained its live-
200/// connection fast path. It uses the same plain-[`PlanStatement`] technique
201/// as `atomic_prepare::push_index_purge_statements`. Unguarded: same
202/// convention as the FTS-insert statement in
203/// `atomic_prepare::prepare_add_note` (the row's existence guard is carried by
204/// the plan's primary note-row statement, applied first).
205#[allow(clippy::too_many_arguments)]
206pub(crate) fn vector_insert_statements(
207    table: &str,
208    namespace: &str,
209    subject_id: Uuid,
210    field: &str,
211    embedding_model: &str,
212    embedding: &[f32],
213    label_prefix: &str,
214) -> Vec<PlanStatement> {
215    let subject = subject_id.to_string();
216    let model_key = table
217        .strip_prefix("vec_")
218        .expect("runtime vector tables use the vec_ prefix");
219    let kind_str = SubstrateKind::Note.to_string();
220    let blob = f32_vec_to_bytes(embedding);
221    vec![
222        // Delta-log any pre-existing row this REPLACE is about to evict —
223        // mirrors `log_vector_deletes`'s "replace" predicate exactly.
224        PlanStatement {
225            statement: SqlStatement {
226                sql: format!(
227                    "INSERT INTO ann_write_log \
228                     (namespace, embedding_model, kind, field, subject_id, op) \
229                     SELECT namespace, embedding_model, kind, field, subject_id, 'delete' \
230                     FROM {table} WHERE subject_id = ?1 AND NOT \
231                     (namespace = ?2 AND embedding_model = ?3 AND kind = ?4 AND field = ?5)"
232                ),
233                params: vec![
234                    SqlValue::Text(subject.clone()),
235                    SqlValue::Text(namespace.to_string()),
236                    SqlValue::Text(embedding_model.to_string()),
237                    SqlValue::Text(kind_str.clone()),
238                    SqlValue::Text(field.to_string()),
239                ],
240                label: Some(format!("{label_prefix}-log-delete")),
241            },
242            guard: None,
243        },
244        PlanStatement {
245            statement: SqlStatement {
246                sql: format!("DELETE FROM {table} WHERE subject_id = ?1"),
247                params: vec![SqlValue::Text(subject.clone())],
248                label: Some(format!("{label_prefix}-delete")),
249            },
250            guard: None,
251        },
252        // A replacement through this raw atomic plan does not know the source
253        // text, even if its new vector bytes happen to equal the old bytes.
254        PlanStatement {
255            statement: SqlStatement {
256                sql: "DELETE FROM vector_provenance \
257                      WHERE model_key = ?1 AND subject_id = ?2"
258                    .to_string(),
259                params: vec![
260                    SqlValue::Text(model_key.to_string()),
261                    SqlValue::Text(subject.clone()),
262                ],
263                label: Some(format!("{label_prefix}-clear-provenance")),
264            },
265            guard: None,
266        },
267        PlanStatement {
268            statement: SqlStatement {
269                sql: format!(
270                    "INSERT INTO {table} \
271                     (subject_id, namespace, kind, field, embedding_model, embedding) \
272                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
273                ),
274                params: vec![
275                    SqlValue::Text(subject.clone()),
276                    SqlValue::Text(namespace.to_string()),
277                    SqlValue::Text(kind_str.clone()),
278                    SqlValue::Text(field.to_string()),
279                    SqlValue::Text(embedding_model.to_string()),
280                    SqlValue::Blob(blob),
281                ],
282                label: Some(format!("{label_prefix}-insert")),
283            },
284            guard: None,
285        },
286        PlanStatement {
287            statement: SqlStatement {
288                sql: "INSERT INTO ann_write_log \
289                      (namespace, embedding_model, kind, field, subject_id, op) \
290                      VALUES (?1, ?2, ?3, ?4, ?5, 'upsert')"
291                    .to_string(),
292                params: vec![
293                    SqlValue::Text(namespace.to_string()),
294                    SqlValue::Text(embedding_model.to_string()),
295                    SqlValue::Text(kind_str),
296                    SqlValue::Text(field.to_string()),
297                    SqlValue::Text(subject),
298                ],
299                label: Some(format!("{label_prefix}-log-upsert")),
300            },
301            guard: None,
302        },
303    ]
304}
305
306/// Build and commit `specs` as one atomic unit. Returns the persisted
307/// [`Note`]s in the same order as `specs`. On any failure — pre-write
308/// validation, embedding, or the atomic commit pass itself — NO note, FTS
309/// document, or vector row from any spec is left behind (embed failures
310/// occur before any write is attempted; commit-pass failures roll back the
311/// whole unit per [`crate::atomic_runner::run_atomic_unit`]'s guarantee).
312#[cfg(test)]
313pub(crate) async fn create_notes_atomic(
314    runtime: &KhiveRuntime,
315    specs: Vec<AtomicNoteSpec<'_>>,
316) -> RuntimeResult<Vec<Note>> {
317    Ok(create_notes_atomic_with_report(runtime, specs).await?.0)
318}
319
320/// Build and commit notes with an embedding-truncation report. The report keeps
321/// logical per-note/model accounting even when identical content shares one
322/// provider result, and is returned only when the whole note set commits
323/// successfully.
324pub async fn create_notes_atomic_with_report(
325    runtime: &KhiveRuntime,
326    specs: Vec<AtomicNoteSpec<'_>>,
327) -> RuntimeResult<(Vec<Note>, crate::retrieval::EmbeddingTruncationReport)> {
328    create_notes_atomic_with_attachments(runtime, specs, &[]).await
329}
330
331/// Create notes and their role-keyed attachments in the same writer transaction.
332/// The existing note, FTS, vector and revision accounting is preserved.
333pub async fn create_notes_atomic_with_attachments(
334    runtime: &KhiveRuntime,
335    specs: Vec<AtomicNoteSpec<'_>>,
336    attachments: &[khive_storage::NewAttachment],
337) -> RuntimeResult<(Vec<Note>, crate::retrieval::EmbeddingTruncationReport)> {
338    validate_note_attachments(runtime, attachments)?;
339    let mut prepared = prepare_atomic_notes(runtime, specs, AtomicNoteOptions::default()).await?;
340    append_note_attachments(&mut prepared, attachments)?;
341    match crate::atomic_runner::run_atomic_unit_with_note_versions(
342        runtime.sql().as_ref(),
343        prepared.plans,
344        true,
345    )
346    .await
347    {
348        Ok((AtomicRunOutcome::Committed { .. }, versions)) => {
349            assert_eq!(
350                prepared.notes.len(),
351                versions.len(),
352                "one revision receipt per prepared note"
353            );
354            for (note, version) in prepared.notes.iter_mut().zip(versions) {
355                note.version = version;
356            }
357            Ok((prepared.notes, prepared.embedding_truncation))
358        }
359        Ok((
360            AtomicRunOutcome::RolledBack {
361                failed_op_index,
362                failure,
363            },
364            _,
365        )) => Err(RuntimeError::Internal(format!(
366            "atomic multi-note write rolled back at op {failed_op_index}: {failure:?}"
367        ))),
368        Err(e) => Err(RuntimeError::Storage(e.0)),
369    }
370}
371
372pub(crate) fn validate_note_attachments(
373    runtime: &KhiveRuntime,
374    attachments: &[khive_storage::NewAttachment],
375) -> RuntimeResult<()> {
376    if !attachments.is_empty() {
377        // Note plans execute on runtime.sql(); attachment liveness belongs to
378        // main. Refuse a split placement before preparing any write.
379        runtime.attachments()?;
380    }
381    for attachment in attachments {
382        attachment.validate()?;
383    }
384    Ok(())
385}
386
387pub(crate) fn append_note_attachments(
388    prepared: &mut PreparedAtomicNotes,
389    attachments: &[khive_storage::NewAttachment],
390) -> RuntimeResult<()> {
391    for (plan, note) in prepared.plans.iter_mut().zip(&prepared.notes) {
392        let AtomicOpPlan::AddNote(plan) = plan else {
393            return Err(RuntimeError::Internal(
394                "expected prepared attachment owner note".into(),
395            ));
396        };
397        for attachment in attachments {
398            let row = khive_storage::Attachment::from_new(
399                note.id,
400                khive_storage::AttachmentSubstrate::Note,
401                attachment.clone(),
402                note.created_at,
403            );
404            // Append after the note's complete index plan, preserving adjacent
405            // last_insert_rowid-dependent FTS statements and their counters.
406            plan.statements.push(PlanStatement {
407                statement: khive_db::stores::attachment::attachment_upsert_statement(&row)?,
408                guard: Some(AffectedRowGuard::exactly(1)),
409            });
410        }
411    }
412    Ok(())
413}
414
415pub(crate) async fn prepare_atomic_notes(
416    runtime: &KhiveRuntime,
417    specs: Vec<AtomicNoteSpec<'_>>,
418    options: AtomicNoteOptions<'_>,
419) -> RuntimeResult<PreparedAtomicNotes> {
420    validate_atomic_note_options(&options)?;
421    if options.embed != Some(false) {
422        if let Some(model) = options.embedding_model {
423            runtime.resolve_embedding_model(Some(model))?;
424        }
425    }
426    prepare_atomic_note_requests(
427        runtime,
428        specs
429            .into_iter()
430            .map(|spec| AtomicNoteRequest { spec, options })
431            .collect(),
432    )
433    .await
434}
435
436fn validate_atomic_note_options(options: &AtomicNoteOptions<'_>) -> RuntimeResult<()> {
437    if let Some(value) = options.salience {
438        if !value.is_finite() || !(0.0..=1.0).contains(&value) {
439            return Err(RuntimeError::InvalidInput(
440                "salience must be a finite value in [0.0, 1.0]".into(),
441            ));
442        }
443    }
444    if let Some(value) = options.decay_factor {
445        if !value.is_finite() || value < 0.0 {
446            return Err(RuntimeError::InvalidInput(
447                "decay_factor must be a finite value >= 0.0".into(),
448            ));
449        }
450    }
451    Ok(())
452}
453
454pub(crate) async fn prepare_atomic_note_requests(
455    runtime: &KhiveRuntime,
456    requests: Vec<AtomicNoteRequest<'_>>,
457) -> RuntimeResult<PreparedAtomicNotes> {
458    let mut embed_model_names = Vec::new();
459    let mut note_models = Vec::with_capacity(requests.len());
460    for request in &requests {
461        let options = &request.options;
462        validate_atomic_note_options(options)?;
463        let models = if options.embed == Some(false) {
464            Vec::new()
465        } else if let Some(model) = options.embedding_model {
466            runtime.resolve_embedding_model(Some(model))?;
467            vec![model.to_owned()]
468        } else {
469            runtime.embedding_models_for_note_kind(request.spec.kind)
470        };
471        let mut indices = Vec::with_capacity(models.len());
472        for model in models {
473            let index = match embed_model_names.iter().position(|name| name == &model) {
474                Some(index) => index,
475                None => {
476                    embed_model_names.push(model);
477                    embed_model_names.len() - 1
478                }
479            };
480            indices.push(index);
481        }
482        note_models.push(indices);
483    }
484    // ---- 1. Validate + build Note objects (all pre-write checks, same as
485    // create_note_inner, before any embedding or DML is attempted). ----
486    let mut notes: Vec<Note> = Vec::with_capacity(requests.len());
487    for (index, request) in requests.iter().enumerate() {
488        let spec = &request.spec;
489        let options = &request.options;
490        let record = format!("note[{index}]");
491        runtime.validate_note_kind(spec.kind)?;
492        // Same owned-identity derivation every other note-write site runs
493        // (`operations.rs`'s create funnel, `atomic_prepare::prepare_add_note`):
494        // derive from the token BEFORE the secret scan and note construction,
495        // so a caller-supplied `from_actor`/`thread_id`/etc. in `spec.properties`
496        // never reaches storage verbatim on this writer either. Skipped when
497        // the caller already derived (`properties_already_derived`): the
498        // validator is not guaranteed idempotent, so a second call for the
499        // same request is a different candidate, not a no-op.
500        let properties = if options.properties_already_derived {
501            spec.properties.clone()
502        } else {
503            runtime.derive_note_write_properties(spec.kind, spec.token, spec.properties.clone())?
504        };
505        crate::secret_gate::reject_reserved_secret_gate_property(properties.as_ref())?;
506        crate::secret_gate::check_at(spec.content, &record, "content")?;
507        if let Some(n) = spec.name {
508            crate::secret_gate::check_at(n, &record, "name")?;
509        }
510        if let Some(ref p) = properties {
511            crate::secret_gate::check_json_at(p, &record, "properties")?;
512        }
513
514        let ns = spec.token.namespace().as_str();
515        let mut note = Note::new(ns, spec.kind, spec.content);
516        note.key = options.key.map(str::to_owned);
517        if let Some(salience) = options.salience {
518            note = note.with_salience(salience);
519        }
520        if let Some(decay_factor) = options.decay_factor {
521            note = note.with_decay(decay_factor);
522        }
523        if let Some(id) = spec.id {
524            note.id = id;
525        }
526        if let Some(n) = spec.name {
527            note = note.with_name(n);
528        }
529        if let Some(p) = properties {
530            note = note.with_properties(p);
531        }
532        notes.push(note);
533    }
534
535    // Deferred key publication clears the temporary Note.key; its private receipt
536    // option is the original keyed-memory intent. Generic and stream routes
537    // retain options.key. Kind is always the canonical prepared note kind.
538    let memory_receipts: Vec<bool> = notes
539        .iter()
540        .zip(&requests)
541        .map(|(note, request)| {
542            note.kind == "memory"
543                && (request.options.key.is_some() || request.options.memory_visibility_receipt)
544        })
545        .collect();
546    if memory_receipts.iter().any(|capture| *capture) {
547        runtime.require_visibility_cutover()?;
548    }
549
550    // ---- 2. Batch distinct content per model in parallel, BEFORE
551    // opening any transaction. Any failure aborts here — no write has been
552    // attempted. Identical note siblings reuse the same computed vector. ----
553    let mut content_group_by_text: HashMap<&str, usize> = HashMap::new();
554    let mut content_groups: Vec<Vec<usize>> = Vec::new();
555    let mut note_content_groups: Vec<usize> = Vec::with_capacity(notes.len());
556    for (note_idx, note) in notes.iter().enumerate() {
557        let text = requests[note_idx]
558            .options
559            .embedding_content
560            .unwrap_or_else(|| crate::curation::note_embedding_text_ref(note));
561        let content_group_idx = match content_group_by_text.get(text) {
562            Some(&idx) => {
563                content_groups[idx].push(note_idx);
564                idx
565            }
566            None => {
567                let idx = content_groups.len();
568                content_group_by_text.insert(text, idx);
569                content_groups.push(vec![note_idx]);
570                idx
571            }
572        };
573        note_content_groups.push(content_group_idx);
574    }
575
576    // (content_group_idx, model_idx) -> outcome, filled as tasks complete.
577    let mut embedding_outcomes: Vec<Vec<Option<crate::retrieval::DocumentEmbeddingOutcome>>> =
578        content_groups
579            .iter()
580            .map(|_| vec![None; embed_model_names.len()])
581            .collect();
582    let mut embedding_truncation = crate::retrieval::EmbeddingTruncationReport::default();
583
584    // `requests` is empty when a caller prepares a note set every member of
585    // which was refused before preparation: there is nothing to embed, no
586    // token to read, and the lazy vector-table create belongs to a write
587    // that is not going to happen.
588    if !embed_model_names.is_empty() {
589        // Ensure every model's vector table exists before the commit pass —
590        // the same lazy-create side effect as vector-store construction, with
591        // all model tables sharing one schema-writer acquisition.
592        let models = embed_model_names
593            .iter()
594            .map(|name| {
595                runtime
596                    .vector_model_metadata(name)
597                    .map(|(name, dimensions)| (crate::config::sanitize_key(&name), dimensions))
598            })
599            .collect::<RuntimeResult<Vec<_>>>()?;
600        let model_specs: Vec<_> = models
601            .iter()
602            .map(|(key, dimensions)| (key.as_str(), *dimensions))
603            .collect();
604        runtime.backend().ensure_vector_tables(&model_specs)?;
605
606        let usage_ctx = crate::usage::current();
607        let mut join_set = tokio::task::JoinSet::new();
608        for (model_idx, model_name) in embed_model_names.iter().enumerate() {
609            let mut groups = Vec::new();
610            let mut texts = Vec::new();
611            let mut token = None;
612            for (group_idx, note_indices) in content_groups.iter().enumerate() {
613                let Some(&note_idx) = note_indices
614                    .iter()
615                    .find(|&&index| note_models[index].contains(&model_idx))
616                else {
617                    continue;
618                };
619                token.get_or_insert_with(|| requests[note_idx].spec.token.clone());
620                groups.push(group_idx);
621                texts.push(
622                    requests[note_idx]
623                        .options
624                        .embedding_content
625                        .unwrap_or_else(|| {
626                            crate::curation::note_embedding_text_ref(&notes[note_idx])
627                        })
628                        .to_owned(),
629                );
630            }
631            let rt = runtime.clone();
632            let token = token.expect("a selected model has at least one note");
633            let name = model_name.clone();
634            let ctx = usage_ctx.clone();
635            join_set.spawn(crate::runtime::inherit_request_embedder_scope(async move {
636                let fut = async {
637                    let mut outcomes = Vec::with_capacity(texts.len());
638                    for chunk in texts.chunks(lattice_embed::DEFAULT_MAX_BATCH_SIZE) {
639                        outcomes.extend(
640                            rt.embed_document_batch_with_model_outcomes_for_token(
641                                &token, &name, chunk,
642                            )
643                            .await?,
644                        );
645                    }
646                    Ok::<_, RuntimeError>(outcomes)
647                };
648                let result = match ctx {
649                    Some(ctx) => crate::usage::scope(ctx, fut).await,
650                    None => fut.await,
651                };
652                (groups, model_idx, result)
653            }));
654        }
655
656        while let Some(joined) = join_set.join_next().await {
657            match joined {
658                Ok((groups, model_idx, Ok(outcomes))) => {
659                    for (group_idx, outcome) in groups.into_iter().zip(outcomes) {
660                        embedding_outcomes[group_idx][model_idx] = Some(outcome);
661                    }
662                }
663                Ok((_, _, Err(e))) => {
664                    join_set.abort_all();
665                    return Err(e);
666                }
667                Err(join_err) => {
668                    join_set.abort_all();
669                    return Err(RuntimeError::Internal(format!(
670                        "embed task panicked: {join_err}"
671                    )));
672                }
673            }
674        }
675    }
676
677    // Preserve the report's logical note/model accounting even when identical
678    // siblings share one provider invocation.
679    for (note_idx, &content_group_idx) in note_content_groups.iter().enumerate() {
680        for &model_idx in &note_models[note_idx] {
681            let outcome = embedding_outcomes[content_group_idx][model_idx]
682                .as_ref()
683                .expect("every requested embedding was observed");
684            embedding_truncation.observe(outcome);
685            if outcome.truncated {
686                tracing::warn!(
687                    model = %outcome.model_name,
688                    source_bytes = outcome.source_bytes,
689                    embedded_bytes = outcome.embedded_bytes,
690                    "atomic note embedding input truncated; full content will be stored unchanged"
691                );
692            }
693        }
694    }
695
696    // Reject any non-finite embedding value BEFORE any plan is built — same
697    // validation the canonical `VectorStore::insert` DML performs, applied
698    // here so a bad custom embedding provider never reaches the raw vector
699    // insert on this path (embed-first: no note, FTS document, or vector row
700    // has been written yet).
701    for outcomes_for_content in &embedding_outcomes {
702        for outcome in outcomes_for_content.iter().flatten() {
703            if let Some(idx) = non_finite_index(&outcome.vector) {
704                return Err(non_finite_vector_error(idx, outcome.vector[idx]));
705            }
706        }
707    }
708
709    // ---- 3. Build one AddNotePlan per spec (row + FTS + vector-insert
710    // statements), all pre-computed embeddings already in hand. ----
711    let mut plans: Vec<AtomicOpPlan> = Vec::with_capacity(notes.len());
712    for (note_idx, note) in notes.iter().enumerate() {
713        let outcomes_for_note = &embedding_outcomes[note_content_groups[note_idx]];
714        let mut statements = vec![PlanStatement {
715            statement: if note.key.is_some() {
716                khive_db::stores::note::note_insert_keyed_statement(note)
717            } else {
718                khive_db::stores::note::note_upsert_statement(note)
719            },
720            guard: Some(AffectedRowGuard::exactly(1)),
721        }];
722
723        if memory_receipts[note_idx] {
724            statements.push(PlanStatement {
725                statement: SqlStatement {
726                    sql:
727                        "INSERT INTO memory_visibility_receipts (namespace, note_id, model_count) \
728                          VALUES (?1, ?2, ?3)"
729                            .into(),
730                    params: vec![
731                        SqlValue::Text(note.namespace.clone()),
732                        SqlValue::Text(note.id.to_string()),
733                        SqlValue::Integer(note_models[note_idx].len() as i64),
734                    ],
735                    label: Some("memory-visibility-receipt".into()),
736                },
737                guard: Some(AffectedRowGuard::exactly(1)),
738            });
739        }
740
741        if let Some(fault) = maybe_inject_fts_failure(&note.namespace, "fault-injected-fts") {
742            statements.push(fault);
743        } else {
744            // Delete-then-insert upsert — mirrors `text.rs`'s
745            // `upsert_document_dml` exactly, so a caller-supplied/reused note
746            // id never leaves a stale/duplicate FTS document behind (the
747            // note row itself is already an upsert via
748            // `note_upsert_statement`). The delete alone is enough on the
749            // sidecar rowid-map side too — the insert pair's `INSERT OR
750            // REPLACE` below overwrites the map row rather than requiring a
751            // prior delete of it (see `delete_document_statement`'s doc
752            // comment).
753            statements.push(PlanStatement {
754                statement: khive_db::stores::text::delete_document_statement(
755                    "fts_notes",
756                    &note.namespace,
757                    note.id,
758                ),
759                guard: None,
760            });
761            // Order-sensitive pair: the map upsert's `last_insert_rowid()`
762            // must read back the FTS insert immediately before it, with
763            // nothing else written to this connection in between — pushed
764            // here as the array `insert_document_statements` returns so the
765            // two can never be separated by an edit to this call site.
766            for statement in khive_db::stores::text::insert_document_statements(
767                "fts_notes",
768                &note_fts_document(note),
769            ) {
770                statements.push(PlanStatement {
771                    statement,
772                    guard: None,
773                });
774            }
775        }
776
777        if let Some(fault) = maybe_inject_vector_failure(&note.namespace, "fault-injected-vector") {
778            statements.push(fault);
779        } else {
780            for &model_idx in &note_models[note_idx] {
781                let model_name = &embed_model_names[model_idx];
782                let outcome = outcomes_for_note[model_idx]
783                    .as_ref()
784                    .expect("every model index observed exactly once");
785                let table = format!("vec_{}", crate::config::sanitize_key(model_name));
786                statements.extend(vector_insert_statements(
787                    &table,
788                    &note.namespace,
789                    note.id,
790                    "note.content",
791                    model_name,
792                    &outcome.vector,
793                    &format!("atomic-message-vec-{table}-{}", note.id),
794                ));
795                if memory_receipts[note_idx] {
796                    // The preceding statement is the model's ann_write_log
797                    // upsert. Keep this insert adjacent: last_insert_rowid()
798                    // reads that exact transaction-local sequence, never a
799                    // later MAX(seq) vulnerable to concurrency/compaction.
800                    statements.push(PlanStatement {
801                        statement: SqlStatement {
802                            sql: "INSERT INTO memory_visibility_fences \
803                                  (namespace, note_id, model, ann_write_log_seq) \
804                                  VALUES (?1, ?2, ?3, last_insert_rowid())"
805                                .into(),
806                            params: vec![
807                                SqlValue::Text(note.namespace.clone()),
808                                SqlValue::Text(note.id.to_string()),
809                                SqlValue::Text(model_name.clone()),
810                            ],
811                            label: Some("memory-visibility-fence".into()),
812                        },
813                        guard: Some(AffectedRowGuard::exactly(1)),
814                    });
815                }
816            }
817        }
818
819        // After every log/fence pair: inserting the independent marker must
820        // never disturb either ANN or FTS last_insert_rowid adjacency.
821        if memory_receipts[note_idx] {
822            statements.push(PlanStatement {
823                statement: SqlStatement {
824                    sql: "INSERT INTO memory_visibility_epochs (note_id, namespace, epoch) \
825                          VALUES (?1, ?2, 'modern')"
826                        .into(),
827                    params: vec![
828                        SqlValue::Text(note.id.to_string()),
829                        SqlValue::Text(note.namespace.clone()),
830                    ],
831                    label: Some("memory-visibility-epoch".into()),
832                },
833                guard: Some(AffectedRowGuard::exactly(1)),
834            });
835        }
836
837        plans.push(AtomicOpPlan::AddNote(Box::new(AddNotePlan {
838            note_guard: Some(crate::note_write::NoteWriteGuard {
839                namespace: requests[note_idx].spec.token.namespace().as_str().into(),
840                target_id: note.id,
841                expected_version: None,
842                fence: requests[note_idx].options.fence.cloned(),
843                create_key: note
844                    .key
845                    .as_ref()
846                    .map(|key| crate::note_write::CreateKeyClaim {
847                        kind: note.kind.clone(),
848                        key: key.clone(),
849                        content: note.content.clone(),
850                        properties: note.properties.clone(),
851                        replay_signal: requests[note_idx].options.replay_receipt,
852                    }),
853            }),
854            note_id: note.id,
855            statements,
856            post_commit: PostCommitEffect::None,
857        })));
858    }
859
860    Ok(PreparedAtomicNotes {
861        notes,
862        plans,
863        embedding_truncation,
864    })
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870    use async_trait::async_trait;
871    use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService, MAX_TEXT_BYTES};
872    use std::sync::{Arc, Mutex};
873
874    use khive_types::Namespace;
875
876    use crate::embedder_registry::EmbedderProvider;
877
878    const NAN_MODEL: &str = "atomic-message-nan-model";
879    const NAN_DIMS: usize = 4;
880
881    struct NanService;
882    #[async_trait]
883    impl EmbeddingService for NanService {
884        async fn embed(
885            &self,
886            texts: &[String],
887            _model: EmbeddingModel,
888        ) -> Result<Vec<Vec<f32>>, EmbedError> {
889            Ok(texts.iter().map(|_| vec![f32::NAN; NAN_DIMS]).collect())
890        }
891        fn supports_model(&self, _model: EmbeddingModel) -> bool {
892            true
893        }
894        fn name(&self) -> &'static str {
895            NAN_MODEL
896        }
897    }
898    struct NanProvider;
899    #[async_trait]
900    impl EmbedderProvider for NanProvider {
901        fn name(&self) -> &str {
902            NAN_MODEL
903        }
904        fn dimensions(&self) -> usize {
905            NAN_DIMS
906        }
907        async fn build(&self) -> RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
908            Ok(std::sync::Arc::new(NanService))
909        }
910    }
911
912    struct TruncationService;
913    #[async_trait]
914    impl EmbeddingService for TruncationService {
915        async fn embed(
916            &self,
917            texts: &[String],
918            _model: EmbeddingModel,
919        ) -> Result<Vec<Vec<f32>>, EmbedError> {
920            Ok(texts
921                .iter()
922                .map(|_| vec![0.5; EmbeddingModel::MultilingualE5Base.dimensions()])
923                .collect())
924        }
925        fn supports_model(&self, _model: EmbeddingModel) -> bool {
926            true
927        }
928        fn name(&self) -> &'static str {
929            "multilingual-e5-base"
930        }
931    }
932
933    struct TruncationProvider;
934    #[async_trait]
935    impl EmbedderProvider for TruncationProvider {
936        fn name(&self) -> &str {
937            "multilingual-e5-base"
938        }
939        fn dimensions(&self) -> usize {
940            EmbeddingModel::MultilingualE5Base.dimensions()
941        }
942        async fn build(&self) -> RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
943            Ok(std::sync::Arc::new(TruncationService))
944        }
945    }
946
947    const DEDUP_DIMS: usize = 4;
948
949    struct DedupService {
950        name: &'static str,
951    }
952
953    #[async_trait]
954    impl EmbeddingService for DedupService {
955        async fn embed(
956            &self,
957            texts: &[String],
958            _model: EmbeddingModel,
959        ) -> Result<Vec<Vec<f32>>, EmbedError> {
960            Ok(texts.iter().map(|_| vec![0.25; DEDUP_DIMS]).collect())
961        }
962
963        fn supports_model(&self, _model: EmbeddingModel) -> bool {
964            true
965        }
966
967        fn name(&self) -> &'static str {
968            self.name
969        }
970    }
971
972    struct DedupProvider {
973        name: &'static str,
974    }
975
976    #[async_trait]
977    impl EmbedderProvider for DedupProvider {
978        fn name(&self) -> &str {
979            self.name
980        }
981
982        fn dimensions(&self) -> usize {
983            DEDUP_DIMS
984        }
985
986        async fn build(&self) -> RuntimeResult<std::sync::Arc<dyn EmbeddingService>> {
987            Ok(std::sync::Arc::new(DedupService { name: self.name }))
988        }
989    }
990
991    struct BatchCountingService {
992        name: &'static str,
993        calls: Arc<Mutex<Vec<Vec<String>>>>,
994    }
995
996    #[async_trait]
997    impl EmbeddingService for BatchCountingService {
998        async fn embed(
999            &self,
1000            texts: &[String],
1001            _model: EmbeddingModel,
1002        ) -> Result<Vec<Vec<f32>>, EmbedError> {
1003            self.calls.lock().unwrap().push(texts.to_vec());
1004            Ok(texts.iter().map(|_| vec![0.25; DEDUP_DIMS]).collect())
1005        }
1006
1007        fn supports_model(&self, _model: EmbeddingModel) -> bool {
1008            true
1009        }
1010
1011        fn name(&self) -> &'static str {
1012            self.name
1013        }
1014    }
1015
1016    struct BatchCountingProvider {
1017        name: &'static str,
1018        calls: Arc<Mutex<Vec<Vec<String>>>>,
1019    }
1020
1021    #[async_trait]
1022    impl EmbedderProvider for BatchCountingProvider {
1023        fn name(&self) -> &str {
1024            self.name
1025        }
1026
1027        fn dimensions(&self) -> usize {
1028            DEDUP_DIMS
1029        }
1030
1031        async fn build(&self) -> RuntimeResult<Arc<dyn EmbeddingService>> {
1032            Ok(Arc::new(BatchCountingService {
1033                name: self.name,
1034                calls: Arc::clone(&self.calls),
1035            }))
1036        }
1037    }
1038
1039    #[tokio::test]
1040    async fn heterogeneous_notes_batch_models_with_one_schema_writer() {
1041        let runtime = KhiveRuntime::memory().unwrap();
1042        let token = runtime
1043            .authorize(Namespace::parse("batch-notes").unwrap())
1044            .unwrap();
1045        let names = ["batch-notes-a", "batch-notes-b"];
1046        let mut counters = Vec::new();
1047        for name in names {
1048            let calls = Arc::new(Mutex::new(Vec::new()));
1049            runtime.register_embedder(BatchCountingProvider {
1050                name,
1051                calls: Arc::clone(&calls),
1052            });
1053            runtime.embedder_with_token(&token, name).await.unwrap();
1054            counters.push(calls);
1055        }
1056        let inputs = [
1057            ("first", "shared text", true, 0.2),
1058            ("second", "shared text", true, 0.4),
1059            ("third", "different text", true, 0.6),
1060            ("disabled", "shared text", false, 0.8),
1061        ];
1062        let requests = inputs
1063            .iter()
1064            .map(|&(key, content, embed, salience)| AtomicNoteRequest {
1065                spec: AtomicNoteSpec {
1066                    token: &token,
1067                    id: None,
1068                    kind: "observation",
1069                    name: None,
1070                    content,
1071                    properties: Some(serde_json::json!({"key": key})),
1072                },
1073                options: AtomicNoteOptions {
1074                    key: Some(key),
1075                    salience: Some(salience),
1076                    embed: Some(embed),
1077                    ..Default::default()
1078                },
1079            })
1080            .collect();
1081        let before = runtime.backend().pool().writer_acquisition_snapshot();
1082        let prepared = prepare_atomic_note_requests(&runtime, requests)
1083            .await
1084            .unwrap();
1085        let after = runtime.backend().pool().writer_acquisition_snapshot();
1086        assert_eq!(after.acquisitions - before.acquisitions, 1);
1087        for calls in &counters {
1088            assert_eq!(
1089                *calls.lock().unwrap(),
1090                vec![vec!["shared text".to_owned(), "different text".to_owned()]],
1091            );
1092        }
1093        for (note, &(key, content, _, salience)) in prepared.notes.iter().zip(&inputs) {
1094            assert_eq!(note.key.as_deref(), Some(key));
1095            assert_eq!(note.content, content);
1096            assert_eq!(note.salience, Some(salience));
1097            assert_eq!(note.properties.as_ref().unwrap()["key"], key);
1098        }
1099        assert!(matches!(
1100            crate::atomic_runner::run_atomic_unit(runtime.sql().as_ref(), prepared.plans)
1101                .await
1102                .unwrap(),
1103            AtomicRunOutcome::Committed { .. }
1104        ));
1105        for name in names {
1106            assert_eq!(
1107                runtime
1108                    .vectors_for_model(&token, name)
1109                    .unwrap()
1110                    .count()
1111                    .await
1112                    .unwrap(),
1113                3,
1114                "disabled notes must not inherit a sibling's shared vector",
1115            );
1116        }
1117    }
1118
1119    #[tokio::test]
1120    async fn atomic_note_batch_chunks_only_above_provider_limit() {
1121        let runtime = KhiveRuntime::memory().unwrap();
1122        let token = runtime.authorize(Namespace::local()).unwrap();
1123        let calls = Arc::new(Mutex::new(Vec::new()));
1124        runtime.register_embedder(BatchCountingProvider {
1125            name: "batch-notes-large",
1126            calls: Arc::clone(&calls),
1127        });
1128        let contents: Vec<_> = (0..=lattice_embed::DEFAULT_MAX_BATCH_SIZE)
1129            .map(|index| format!("distinct document {index}"))
1130            .collect();
1131        let specs = contents
1132            .iter()
1133            .map(|content| AtomicNoteSpec {
1134                token: &token,
1135                id: None,
1136                kind: "observation",
1137                name: None,
1138                content,
1139                properties: None,
1140            })
1141            .collect();
1142        let prepared = prepare_atomic_notes(&runtime, specs, AtomicNoteOptions::default())
1143            .await
1144            .unwrap();
1145        assert_eq!(prepared.notes.len(), contents.len());
1146        let calls = calls.lock().unwrap();
1147        assert_eq!(calls.len(), 2);
1148        assert_eq!(calls[0].len(), lattice_embed::DEFAULT_MAX_BATCH_SIZE);
1149        assert_eq!(calls[1].len(), 1);
1150        assert_eq!(calls.concat(), contents);
1151    }
1152
1153    /// A minimal note-write validator standing in for a pack's real one (e.g.
1154    /// `khive-pack-comm`'s `derive_message_identity`): unconditionally stamps
1155    /// `from_actor` with the caller's token-derived actor id, discarding
1156    /// whatever the caller's own `properties` named for that key.
1157    fn stamp_from_actor(
1158        _kind: &str,
1159        actor_id: &str,
1160        properties: Option<Value>,
1161    ) -> RuntimeResult<Option<Value>> {
1162        let mut props = match properties {
1163            Some(Value::Object(map)) => map,
1164            _ => serde_json::Map::new(),
1165        };
1166        props.insert(
1167            "from_actor".to_string(),
1168            Value::String(actor_id.to_string()),
1169        );
1170        Ok(Some(Value::Object(props)))
1171    }
1172
1173    async fn fts_row_count(runtime: &KhiveRuntime, namespace: &str) -> i64 {
1174        let mut reader = runtime.sql().reader().await.expect("sql reader");
1175        match reader
1176            .query_scalar(SqlStatement {
1177                sql: "SELECT COUNT(*) FROM fts_notes WHERE namespace = ?1".to_string(),
1178                params: vec![SqlValue::Text(namespace.to_string())],
1179                label: None,
1180            })
1181            .await
1182            .expect("fts count query")
1183        {
1184            Some(SqlValue::Integer(n)) => n,
1185            other => panic!("unexpected fts count result: {other:?}"),
1186        }
1187    }
1188
1189    async fn ann_write_log_count(runtime: &KhiveRuntime, namespace: &str, model: &str) -> i64 {
1190        let mut reader = runtime.sql().reader().await.expect("sql reader");
1191        match reader
1192            .query_scalar(SqlStatement {
1193                sql: "SELECT COUNT(*) FROM ann_write_log \
1194                      WHERE namespace = ?1 AND embedding_model = ?2"
1195                    .to_string(),
1196                params: vec![
1197                    SqlValue::Text(namespace.to_string()),
1198                    SqlValue::Text(model.to_string()),
1199                ],
1200                label: None,
1201            })
1202            .await
1203            .expect("ann_write_log count query")
1204        {
1205            Some(SqlValue::Integer(n)) => n,
1206            other => panic!("unexpected ann_write_log count result: {other:?}"),
1207        }
1208    }
1209
1210    /// A custom embedding provider returning NaN must be rejected BEFORE any
1211    /// plan is built — same validation `VectorStore::insert` performs on the
1212    /// canonical `create_note_inner` path. No note row, FTS document, vector
1213    /// row, or `ann_write_log` row may be committed.
1214    #[tokio::test]
1215    async fn create_notes_atomic_rejects_non_finite_embedding_before_any_write() {
1216        let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1217        runtime.register_embedder(NanProvider);
1218        let ns = "atomic-message-nan-test";
1219        let token = runtime
1220            .authorize(Namespace::parse(ns).unwrap())
1221            .expect("authorize");
1222
1223        let result = create_notes_atomic(
1224            &runtime,
1225            vec![AtomicNoteSpec {
1226                token: &token,
1227                id: None,
1228                kind: "observation",
1229                name: None,
1230                content: "nan embedding content",
1231                properties: None,
1232            }],
1233        )
1234        .await;
1235
1236        assert!(
1237            result.is_err(),
1238            "a non-finite embedding must be rejected; got {result:?}"
1239        );
1240
1241        let alive = runtime
1242            .list_notes(&token, Some("observation"), 100, 0)
1243            .await
1244            .expect("list_notes")
1245            .into_iter()
1246            .filter(|n| n.deleted_at.is_none())
1247            .count();
1248        assert_eq!(
1249            alive, 0,
1250            "no note row may be committed when an embedding is non-finite"
1251        );
1252
1253        assert_eq!(
1254            fts_row_count(&runtime, ns).await,
1255            0,
1256            "no FTS document may be committed when an embedding is non-finite"
1257        );
1258
1259        let vs = runtime
1260            .vectors_for_model(&token, NAN_MODEL)
1261            .expect("vec store");
1262        assert_eq!(
1263            vs.count().await.expect("count"),
1264            0,
1265            "no vector row may be committed when an embedding is non-finite"
1266        );
1267
1268        assert_eq!(
1269            ann_write_log_count(&runtime, ns, NAN_MODEL).await,
1270            0,
1271            "no ann_write_log row may be committed when an embedding is non-finite"
1272        );
1273    }
1274
1275    #[tokio::test]
1276    async fn create_notes_atomic_rejects_reserved_secret_gate_key() {
1277        let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1278        let ns = "atomic-message-reserved-key-test";
1279        let token = runtime
1280            .authorize(Namespace::parse(ns).unwrap())
1281            .expect("authorize");
1282
1283        let result = create_notes_atomic(
1284            &runtime,
1285            vec![AtomicNoteSpec {
1286                token: &token,
1287                id: None,
1288                kind: "observation",
1289                name: None,
1290                content: "atomic message reservation target",
1291                properties: Some(
1292                    serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"}),
1293                ),
1294            }],
1295        )
1296        .await;
1297
1298        let err = result.expect_err("caller-supplied reserved key must be rejected");
1299        assert!(
1300            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate")),
1301            "unexpected error: {err:?}"
1302        );
1303
1304        let alive = runtime
1305            .list_notes(&token, Some("observation"), 100, 0)
1306            .await
1307            .expect("list_notes")
1308            .into_iter()
1309            .filter(|n| n.deleted_at.is_none())
1310            .count();
1311        assert_eq!(
1312            alive, 0,
1313            "no note row may be committed on reservation rejection"
1314        );
1315    }
1316
1317    #[tokio::test]
1318    async fn create_notes_atomic_with_report_preserves_truncation_outcome() {
1319        let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1320        runtime.register_embedder(TruncationProvider);
1321        let token = runtime
1322            .authorize(Namespace::parse("atomic-message-truncation-test").unwrap())
1323            .expect("authorize");
1324        let content = "x".repeat(MAX_TEXT_BYTES);
1325
1326        let (notes, report) = create_notes_atomic_with_report(
1327            &runtime,
1328            vec![AtomicNoteSpec {
1329                token: &token,
1330                id: None,
1331                kind: "observation",
1332                name: None,
1333                content: &content,
1334                properties: None,
1335            }],
1336        )
1337        .await
1338        .expect("atomic note write");
1339
1340        assert_eq!(notes.len(), 1);
1341        assert_eq!(report.truncated, 1);
1342        assert_eq!(
1343            report.discarded_bytes,
1344            "passage: ".len() as u64,
1345            "E5 document-prefix reservation must be reflected in the returned report"
1346        );
1347    }
1348
1349    #[tokio::test]
1350    async fn create_notes_atomic_reuses_identical_content_once_per_model() {
1351        let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1352        for name in ["atomic-dedup-model-a", "atomic-dedup-model-b"] {
1353            runtime.register_embedder(DedupProvider { name });
1354        }
1355        let outbound_token = runtime
1356            .authorize(Namespace::parse("atomic-dedup-outbound").unwrap())
1357            .expect("authorize outbound");
1358        let inbound_token = runtime
1359            .authorize(Namespace::parse("atomic-dedup-inbound").unwrap())
1360            .expect("authorize inbound");
1361        let content = "byte-identical outbound and inbound message content";
1362
1363        let usage = crate::usage::UsageContext::new();
1364        let notes = crate::usage::scope(usage.clone(), async {
1365            create_notes_atomic(
1366                &runtime,
1367                vec![
1368                    AtomicNoteSpec {
1369                        token: &outbound_token,
1370                        id: None,
1371                        kind: "observation",
1372                        name: Some("shared subject"),
1373                        content,
1374                        properties: Some(serde_json::json!({"direction": "outbound"})),
1375                    },
1376                    AtomicNoteSpec {
1377                        token: &inbound_token,
1378                        id: None,
1379                        kind: "observation",
1380                        name: Some("shared subject"),
1381                        content,
1382                        properties: Some(serde_json::json!({"direction": "inbound"})),
1383                    },
1384                ],
1385            )
1386            .await
1387        })
1388        .await
1389        .expect("atomic note pair");
1390
1391        assert_eq!(notes.len(), 2);
1392        assert_eq!(notes[0].content, content);
1393        assert_eq!(notes[1].content, content);
1394        assert_eq!(
1395            usage.snapshot()["embed_calls"],
1396            2,
1397            "two identical notes across two models must issue one embed per model"
1398        );
1399        for model in ["atomic-dedup-model-a", "atomic-dedup-model-b"] {
1400            for (direction, token) in [("outbound", &outbound_token), ("inbound", &inbound_token)] {
1401                assert_eq!(
1402                    runtime
1403                        .vectors_for_model(token, model)
1404                        .expect("vector store")
1405                        .count()
1406                        .await
1407                        .expect("vector count"),
1408                    1,
1409                    "the {direction} note must retain its vector row for {model}"
1410                );
1411            }
1412        }
1413    }
1414
1415    /// Calling `create_notes_atomic` twice with the SAME caller-supplied id
1416    /// (different content each time) must leave exactly one FTS document for
1417    /// that id, reflecting the latest content — not an append of two
1418    /// documents (the note row is already an upsert; the FTS half must match).
1419    #[tokio::test]
1420    async fn version_create_notes_atomic_upserts_fts_document_for_reused_note_id() {
1421        let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1422        let ns = "atomic-message-fts-upsert-test";
1423        let token = runtime
1424            .authorize(Namespace::parse(ns).unwrap())
1425            .expect("authorize");
1426        let id = Uuid::new_v4();
1427
1428        let first = create_notes_atomic(
1429            &runtime,
1430            vec![AtomicNoteSpec {
1431                token: &token,
1432                id: Some(id),
1433                kind: "observation",
1434                name: None,
1435                content: "first content",
1436                properties: None,
1437            }],
1438        )
1439        .await
1440        .expect("first write with supplied id");
1441        assert_eq!(first[0].version, 1);
1442
1443        let second = create_notes_atomic(
1444            &runtime,
1445            vec![AtomicNoteSpec {
1446                token: &token,
1447                id: Some(id),
1448                kind: "observation",
1449                name: None,
1450                content: "second content replacing the first",
1451                properties: None,
1452            }],
1453        )
1454        .await
1455        .expect("second write reusing the same id");
1456        assert_eq!(second[0].version, 2);
1457        assert_eq!(
1458            runtime
1459                .notes(&token)
1460                .unwrap()
1461                .get_note(id)
1462                .await
1463                .unwrap()
1464                .unwrap()
1465                .version,
1466            2
1467        );
1468        let repeated = create_notes_atomic(
1469            &runtime,
1470            (0..2)
1471                .map(|_| AtomicNoteSpec {
1472                    token: &token,
1473                    id: Some(id),
1474                    kind: "observation",
1475                    name: None,
1476                    content: "second content replacing the first",
1477                    properties: None,
1478                })
1479                .collect(),
1480        )
1481        .await
1482        .expect("equal-value upserts in the same batch");
1483        assert_eq!(
1484            repeated.iter().map(|note| note.version).collect::<Vec<_>>(),
1485            vec![3, 4]
1486        );
1487        assert_eq!(
1488            runtime
1489                .notes(&token)
1490                .unwrap()
1491                .get_note(id)
1492                .await
1493                .unwrap()
1494                .unwrap()
1495                .version,
1496            4
1497        );
1498
1499        assert_eq!(
1500            fts_row_count(&runtime, ns).await,
1501            1,
1502            "exactly one FTS document may exist for the reused id"
1503        );
1504
1505        let text = runtime.text_for_notes(&token).expect("text store");
1506        let doc = text
1507            .get_document(ns, id)
1508            .await
1509            .expect("get_document")
1510            .expect("document exists for the reused id");
1511        assert!(
1512            doc.body.contains("second content"),
1513            "the surviving FTS document must reflect the latest content; got {:?}",
1514            doc.body
1515        );
1516    }
1517
1518    /// ADR-124 regression: `create_notes_atomic_with_report` must run
1519    /// `derive_note_write_properties` per spec, the same as every other
1520    /// note-write site (`operations.rs`'s create funnel,
1521    /// `atomic_prepare::prepare_add_note`). A caller holding a valid token
1522    /// for one actor must not be able to plant an arbitrary `from_actor` (or
1523    /// any other owner-established property) via `AtomicNoteSpec::properties`
1524    /// — the installed validator's derived value must win.
1525    ///
1526    /// This test fails if the `derive_note_write_properties` call in
1527    /// `create_notes_atomic_with_report`'s spec loop is removed: verified by
1528    /// deleting that call in a scratch copy of this file and re-running —
1529    /// the assertion below turns red because the stored `from_actor` reverts
1530    /// to the forged `"forged-actor"` value instead of the token's actor id.
1531    #[tokio::test]
1532    async fn create_notes_atomic_derives_from_actor_overwriting_a_forged_value() {
1533        let runtime = KhiveRuntime::memory().expect("in-memory runtime");
1534        runtime.install_note_write_validator(std::sync::Arc::new(stamp_from_actor));
1535        let ns = "atomic-message-identity-guard-test";
1536        let token = runtime
1537            .authorize(Namespace::parse(ns).unwrap())
1538            .expect("authorize");
1539        let true_actor = token.actor().id.clone();
1540
1541        let notes = create_notes_atomic(
1542            &runtime,
1543            vec![AtomicNoteSpec {
1544                token: &token,
1545                id: None,
1546                kind: "observation",
1547                name: None,
1548                content: "atomic writer identity guard probe",
1549                properties: Some(serde_json::json!({"from_actor": "forged-actor"})),
1550            }],
1551        )
1552        .await
1553        .expect("atomic note write must succeed — the guard derives, it does not refuse");
1554
1555        assert_eq!(notes.len(), 1);
1556        assert_eq!(
1557            notes[0]
1558                .properties
1559                .as_ref()
1560                .and_then(|p| p.get("from_actor"))
1561                .and_then(|v| v.as_str()),
1562            Some(true_actor.as_str()),
1563            "the atomic writer must store the token-derived from_actor, not the \
1564             caller-supplied forged value"
1565        );
1566    }
1567}